All posts

MCP App Observability: Logs, Traces, and Production Debugging

Abe Wheeler
MCP AppsMCP App FrameworkMCP App TestingChatGPT AppsChatGPT App TestingChatGPT App FrameworkClaude ConnectorsClaude Connector TestingClaude Connector FrameworkObservabilityProduction Debugging
Production MCP App debugging needs logs from the server, tool handlers, resource iframe, and host bridge.

Production MCP App debugging needs logs from the server, tool handlers, resource iframe, and host bridge.

When an MCP App breaks in production, the failure rarely lives in one place. A host discovers the tool, the model chooses arguments, your MCP server validates them, your tool handler talks to upstream services, the host renders a sandboxed iframe, and the resource connects back through the host bridge.

That split is useful for security, but it makes production debugging harder than a normal web app. A user might report “the app is blank” when the actual problem is a stale resource URI, a CSP block, an OAuth token failure, a schema mismatch, or a React exception inside a host iframe.

TL;DR: Treat MCP App observability as a four-layer problem: MCP server logs, tool handler traces, resource iframe telemetry, and host-aware tests. Use one short correlation ID across the layers. Keep logs structured. Keep secrets, prompts, raw transcripts, tokens, trace payloads, and private records out of model-visible content and structuredContent. Put safe support IDs in _meta only when the UI needs them.

Why MCP App Observability Is Different

A normal web app usually has a request, a server response, and browser telemetry. MCP Apps add a host and a model in the middle.

Here is the production path you need to observe:

LayerWhat happensWhat can fail
MCP serverHost initializes, lists tools, reads resources, and calls toolsAuth, routing, transport, stale metadata
Tool handlerServer validates arguments and calls databases or APIsBad input, timeout, upstream error, permission error
Tool resultServer returns content, structuredContent, and _metaWrong data lane, missing fields, leaked diagnostics
Resource iframeHost renders HTML and sends bridge eventsCSP, render error, missing asset, host capability mismatch
Host runtimeHost controls display mode, theme, viewport, and permissionsUnsupported feature, mobile-only layout bug, stale connector state

The user sees one app. You need logs that keep those pieces separate.

Start With Structured Server Logs

Your MCP server should log protocol events in a format that is easy to filter. JSON logs are enough for most teams. You want one record when a request starts, one when it finishes, and enough fields to group failures without storing sensitive data.

Log these fields for each MCP request:

FieldExampleWhy it helps
requestIdreq_7b42Groups server, tool, and resource reports
methodtools/callSeparates protocol failures
toolNameshow_invoiceFinds failing tools
tenantIdtenant_123Scopes reports without exposing user records
hostchatgpt, claude, unknownFinds host-specific bugs
statusok, error, cancelledMeasures reliability
durationMs842Finds slow handlers
errorCodeupstream_timeoutGroups root causes

Do not log full prompts, raw conversation history, access tokens, files, private records, or complete tool output. If support needs to look up a record, log a stable internal ID or a one-way hash.

type LogFields = Record<string, string | number | boolean | undefined>;

function log(event: string, fields: LogFields) {
  console.log(
    JSON.stringify({
      event,
      time: new Date().toISOString(),
      ...fields,
    }),
  );
}

export async function handleToolCall({
  requestId,
  toolName,
  tenantId,
  input,
}: {
  requestId: string;
  toolName: string;
  tenantId: string;
  input: unknown;
}) {
  const startedAt = Date.now();

  log('mcp.tool.start', {
    requestId,
    toolName,
    tenantId,
    inputType: typeof input,
  });

  try {
    const result = await runTool(input);

    log('mcp.tool.finish', {
      requestId,
      toolName,
      tenantId,
      status: 'ok',
      durationMs: Date.now() - startedAt,
    });

    return result;
  } catch (error) {
    log('mcp.tool.finish', {
      requestId,
      toolName,
      tenantId,
      status: 'error',
      errorCode: classifyError(error),
      durationMs: Date.now() - startedAt,
    });

    throw error;
  }
}

The exact logger does not matter. The stable fields matter because they let you answer, “Did this fail before the tool ran, inside the tool, after the result, or in the iframe?”

Use Correlation IDs Without Leaking Internals

You need a way to connect a user report, a server log line, and an iframe error report. A short correlation ID solves that.

Use two IDs:

IDWhere it livesWho sees it
traceIdServer logs and observability backendEngineers only
supportIdOptional _meta or UI error stateUser and support team

Keep raw traces server-side. If the resource iframe needs to report an error, pass only the safe supportId through _meta.

import { randomUUID } from 'node:crypto';

export async function showInvoice(args: { invoiceId: string }) {
  const traceId = randomUUID();
  const supportId = traceId.slice(0, 8);

  log('invoice.lookup.start', {
    traceId,
    supportId,
    invoiceIdHash: hashId(args.invoiceId),
  });

  try {
    const invoice = await getInvoice(args.invoiceId);

    return {
      content: [
        {
          type: 'text',
          text: `Found invoice ${invoice.number}.`,
        },
      ],
      structuredContent: {
        invoice: {
          number: invoice.number,
          status: invoice.status,
          total: invoice.total,
        },
      },
      _meta: {
        supportId,
      },
    };
  } catch (error) {
    log('invoice.lookup.error', {
      traceId,
      supportId,
      errorCode: classifyError(error),
    });

    return {
      isError: true,
      content: [
        {
          type: 'text',
          text: 'The invoice lookup failed. Try again in a moment.',
        },
      ],
      _meta: {
        supportId,
      },
    };
  }
}

Notice what is not in structuredContent: trace IDs, stack traces, upstream response bodies, tokens, or request headers. structuredContent is model-visible. If the model does not need it to answer the user, keep it out.

For more on data lanes, read the guide to tool results, content, structuredContent, and _meta.

Capture Resource Iframe Errors

Server logs tell you whether the tool worked. They do not tell you whether the iframe rendered.

Add a small client-side reporter to each production resource. It should capture:

  • JavaScript render errors
  • unhandled promise rejections
  • client-side fetch() failures
  • bridge connection failures
  • CSP-like “failed to fetch” symptoms
  • host context fields such as theme, display mode, locale, and viewport

Send reports to your own telemetry endpoint. That origin must be declared in _meta.ui.csp.connectDomains, otherwise the browser will block the report inside the sandbox.

import { useEffect } from 'react';
import { useHostContext, useToolData } from 'sunpeak';

export function useResourceErrorReporting() {
  const host = useHostContext();
  const { meta } = useToolData<unknown, unknown>();
  const supportId = meta?.supportId;

  useEffect(() => {
    function report(kind: string, error: unknown) {
      void fetch('https://telemetry.example.com/mcp-resource-error', {
        method: 'POST',
        headers: { 'content-type': 'application/json' },
        body: JSON.stringify({
          kind,
          supportId,
          displayMode: host.displayMode,
          theme: host.theme,
          message: getSafeMessage(error),
        }),
      });
    }

    const onError = (event: ErrorEvent) => {
      report('window_error', event.error ?? event.message);
    };

    const onRejection = (event: PromiseRejectionEvent) => {
      report('unhandled_rejection', event.reason);
    };

    window.addEventListener('error', onError);
    window.addEventListener('unhandledrejection', onRejection);

    return () => {
      window.removeEventListener('error', onError);
      window.removeEventListener('unhandledrejection', onRejection);
    };
  }, [host.displayMode, host.theme, supportId]);
}

Keep this reporter boring. Do not send full output, full input, DOM snapshots, cookies, local storage, raw stack traces with user data, or complete API responses. Send enough to group the error and reproduce it with a saved simulation.

The matching resource metadata needs the telemetry origin:

import type { ResourceConfig } from 'sunpeak';

export const resource: ResourceConfig = {
  description: 'Show an invoice review panel',
  _meta: {
    ui: {
      csp: {
        connectDomains: ['https://telemetry.example.com'],
      },
    },
  },
};

If your error reporter fails with TypeError: Failed to fetch, check CSP first, then CORS. The iframe sandbox guide covers that debugging order in more detail.

Track the Metrics That Map to User Pain

Do not start with a huge dashboard. Start with metrics that map to support tickets.

For the MCP server:

  • mcp_initialize_error_rate
  • mcp_tools_list_error_rate
  • mcp_tool_call_count
  • mcp_tool_error_rate
  • mcp_tool_duration_ms
  • mcp_resource_read_error_rate
  • mcp_auth_failure_rate

For the resource iframe:

  • mcp_resource_render_error_count
  • mcp_resource_fetch_error_count
  • mcp_resource_csp_violation_count
  • mcp_bridge_connect_failure_count
  • mcp_display_mode_distribution
  • mcp_host_distribution
  • mcp_cancellation_rate

For upstream services:

  • API latency by service
  • timeout count
  • rate-limit count
  • auth refresh failure count
  • retry count

These metrics tell you where to look. If tool latency is fine but iframe errors spike, do not rewrite the handler. If auth failures spike after a deploy, check OAuth discovery and token refresh. If only fullscreen mode fails, debug layout and safe area handling, not the MCP protocol.

Separate User Errors From System Errors

MCP Apps have several kinds of failure, and they should not all page the team.

FailureExampleAlert?
User input errorMissing required field, invalid date rangeNo, track count
Auth errorExpired token, revoked accessAlert only on spike
Upstream errorPayment API timeout, database unavailableYes
Host integration errorresources/read fails, bridge never connectsYes
Resource render errorReact exception, missing bundle assetYes
User cancellationUser stops the modelNo, track trend

Return user-facing errors for expected failures. Alert on system failures and spikes.

function classifyError(error: unknown) {
  if (isValidationError(error)) return 'invalid_input';
  if (isAuthError(error)) return 'auth_failed';
  if (isTimeoutError(error)) return 'upstream_timeout';
  return 'internal_error';
}

The UI should also tell the truth. A cancellation is not an error. An empty result is not a crash. A failed upstream call should show a retry path and a support code when you have one. The MCP App error handling guide covers those UI states.

Redact Before Data Leaves the App

Observability can create its own security bug if you collect too much.

Redact at three boundaries:

BoundaryWhat to redact
Server logstokens, prompts, transcripts, files, request bodies, secrets
Tool resulttrace payloads, stack traces, internal IDs, debug fields
Iframe telemetrytool output, raw input, DOM text, API responses, account data

Use allowlists where you can. It is safer to decide “these fields are allowed in logs” than to keep adding fields to a blocklist.

function safeToolLog(input: { query?: string; limit?: number }) {
  return {
    hasQuery: Boolean(input.query),
    queryLength: input.query?.length ?? 0,
    limit: input.limit,
  };
}

That gives you debugging value without storing the user’s actual query.

The same rule applies to browser reports. A front-end error reporter that uploads the whole app state can leak more data than the MCP server ever returned to the model.

Test Observability Before You Need It

Observability code is production code. Test it.

At the integration layer, verify that each tool emits start and finish logs with the same request ID:

import { expect, test } from 'sunpeak/test';

test('show-invoice emits correlated logs', async ({ mcp }) => {
  const logs: Array<Record<string, unknown>> = [];
  const stopCapture = captureLogs((entry) => logs.push(entry));

  await mcp.callTool('show-invoice', { invoiceId: 'inv_test_123' });
  stopCapture();

  const start = logs.find((entry) => entry.event === 'mcp.tool.start');
  const finish = logs.find((entry) => entry.event === 'mcp.tool.finish');

  expect(start?.requestId).toBeTruthy();
  expect(finish?.requestId).toBe(start?.requestId);
  expect(finish?.durationMs).toEqual(expect.any(Number));
});

At the iframe layer, intercept telemetry requests and assert redaction:

import { expect, test } from 'sunpeak/test';

test('resource error reports are redacted', async ({ inspector, page }) => {
  const reports: unknown[] = [];

  await page.route('https://telemetry.example.com/**', async (route) => {
    reports.push(route.request().postDataJSON());
    await route.fulfill({ status: 204 });
  });

  const result = await inspector.renderTool('show-invoice-error');
  await result.app().locator('button', { hasText: 'Retry' }).click();

  expect(reports.length).toBeGreaterThan(0);
  expect(JSON.stringify(reports)).not.toContain('access_token');
  expect(JSON.stringify(reports)).not.toContain('rawInvoice');
});

Also test CSP. If telemetry is part of production debugging, the telemetry endpoint needs to work inside the same sandbox your users get.

Use Live Tests for Host-Only Failures

Local tests catch most observability bugs. Live host tests catch the ones that depend on the real host:

  • connector refresh behavior
  • production resource caching
  • real iframe origins
  • mobile host viewport differences
  • OAuth account linking
  • host permission prompts
  • CSP enforcement in the deployed resource

Keep live tests small. A good smoke test invokes one read-only tool, renders one resource, captures the support ID, and proves the resource can send a redacted telemetry event. The rest belongs in local E2E tests and integration tests, because they are faster and do not spend host credits.

In sunpeak projects, use the local Inspector while building, then add a small live test before release when the bug depends on ChatGPT or Claude itself.

A Production Debugging Checklist

Before you ship an MCP App to real users, make sure you can answer these questions:

  • Can I find one user’s failing tool call by support ID?
  • Can I tell whether the failure happened in tools/list, resources/read, tools/call, or the iframe?
  • Do tool logs include status, duration, tool name, and safe tenant context?
  • Do logs avoid prompts, transcripts, tokens, files, and private records?
  • Does the resource iframe report render errors and promise rejections?
  • Is the telemetry endpoint included in connectDomains?
  • Does the UI show a support code only when it helps the user?
  • Do error reports include host, theme, display mode, and viewport?
  • Do tests prove log correlation and telemetry redaction?
  • Do alerts fire on system failures, not normal user cancellations?

If the answer is yes, production bugs become much easier to handle. You can reproduce the failing state in the sunpeak Inspector, turn it into a simulation, add a regression test, and ship the fix with the same flow you use for normal app work.

Where sunpeak Fits

sunpeak will not replace your logs or observability backend. It helps with the part that usually wastes the most time: reproducing host state.

Use the Inspector to replay the exact tool result, theme, display mode, and host context that appeared in a production report. Use protocol tests to verify the server contract. Use Playwright tests to verify the iframe renders and sends safe telemetry. Use live tests only when the real host is part of the failure.

That workflow keeps production debugging concrete. The support ID points to the trace. The trace points to the failing layer. The failing layer becomes a simulation and a test. The next deploy proves the same bug does not come back.

Start with npx sunpeak new for a new MCP App, or run npx sunpeak inspect --server <url> against an existing server when you need a local host replica for production debugging.

Get Started

Documentation →
npx sunpeak new

Further Reading

Frequently Asked Questions

How do I debug an MCP App in production?

Start by separating the layers. Check MCP server logs for initialization, tools/list, resources/read, and tool calls. Check tool handler logs for validation, auth, upstream API calls, and latency. Check resource iframe reports for render errors, CSP failures, bridge events, and client-side fetch errors. Use a short correlation ID across those layers so one user report maps to one server trace without exposing raw logs to the model.

What should I log for MCP App tool calls?

Log the tool name, request ID, authenticated account or tenant ID, input schema version, validation result, upstream service names, status, duration, and whether the result was an error. Do not log access tokens, full prompts, raw conversation transcripts, large files, payment data, or private user records. Prefer field counts, hashes, and IDs that your support team can use safely.

Should MCP App trace IDs go in structuredContent?

No. structuredContent is model-visible data, so trace IDs, internal request IDs, stack traces, and diagnostics should not go there. If the UI needs a support code, put a short non-sensitive support ID in the tool result _meta field or render it from iframe telemetry. Keep raw traces in your server logs and observability backend.

How do I capture errors from a ChatGPT App iframe?

Add a small browser error reporter inside the resource. Capture window error events, unhandled promise rejections, bridge lifecycle failures, display mode, theme, and a safe support ID. Send reports only to an origin declared in your resource CSP connectDomains. Redact tool output before sending reports because the iframe may contain private user data.

How do I connect ChatGPT App frontend errors to MCP server logs?

Generate a request or trace ID when the tool call starts, keep it in server logs, and pass a short support ID to the resource through _meta if the UI needs to show or report it. The resource can include that support ID in client-side error reports. Do not pass raw trace payloads or secrets through content or structuredContent.

What metrics matter for MCP Apps?

Track tool call count, tool error rate, p50 and p95 tool latency, upstream API latency, auth failure rate, resource load failure rate, iframe render errors, CSP violations, cancellation rate, and host or display-mode distribution. These metrics tell you whether users are hitting server failures, host integration failures, or frontend runtime bugs.

How do I test MCP App observability?

Add integration tests that assert every tool logs start and finish events with the same request ID. Add E2E tests that render error simulations and verify the UI shows a support code. Add browser tests that intercept the telemetry endpoint and assert redaction, CSP allowlists, and host context fields. Run those tests in CI before deploying.