MCP App Observability: Logs, Traces, and Production Debugging

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:
| Layer | What happens | What can fail |
|---|---|---|
| MCP server | Host initializes, lists tools, reads resources, and calls tools | Auth, routing, transport, stale metadata |
| Tool handler | Server validates arguments and calls databases or APIs | Bad input, timeout, upstream error, permission error |
| Tool result | Server returns content, structuredContent, and _meta | Wrong data lane, missing fields, leaked diagnostics |
| Resource iframe | Host renders HTML and sends bridge events | CSP, render error, missing asset, host capability mismatch |
| Host runtime | Host controls display mode, theme, viewport, and permissions | Unsupported 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:
| Field | Example | Why it helps |
|---|---|---|
requestId | req_7b42 | Groups server, tool, and resource reports |
method | tools/call | Separates protocol failures |
toolName | show_invoice | Finds failing tools |
tenantId | tenant_123 | Scopes reports without exposing user records |
host | chatgpt, claude, unknown | Finds host-specific bugs |
status | ok, error, cancelled | Measures reliability |
durationMs | 842 | Finds slow handlers |
errorCode | upstream_timeout | Groups 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:
| ID | Where it lives | Who sees it |
|---|---|---|
traceId | Server logs and observability backend | Engineers only |
supportId | Optional _meta or UI error state | User 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_ratemcp_tools_list_error_ratemcp_tool_call_countmcp_tool_error_ratemcp_tool_duration_msmcp_resource_read_error_ratemcp_auth_failure_rate
For the resource iframe:
mcp_resource_render_error_countmcp_resource_fetch_error_countmcp_resource_csp_violation_countmcp_bridge_connect_failure_countmcp_display_mode_distributionmcp_host_distributionmcp_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.
| Failure | Example | Alert? |
|---|---|---|
| User input error | Missing required field, invalid date range | No, track count |
| Auth error | Expired token, revoked access | Alert only on spike |
| Upstream error | Payment API timeout, database unavailable | Yes |
| Host integration error | resources/read fails, bridge never connects | Yes |
| Resource render error | React exception, missing bundle asset | Yes |
| User cancellation | User stops the model | No, 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:
| Boundary | What to redact |
|---|---|
| Server logs | tokens, prompts, transcripts, files, request bodies, secrets |
| Tool result | trace payloads, stack traces, internal IDs, debug fields |
| Iframe telemetry | tool 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
npx sunpeak newFurther Reading
- How to debug ChatGPT Apps - local and live troubleshooting workflow
- MCP App error handling - loading, error, and cancelled states
- MCP App lifecycle - bridge events, tool results, and teardown
- MCP App iframe sandbox - origins, CSP, postMessage, and CORS
- Security testing MCP Apps - auth, CSP, annotations, and input validation
- How to deploy an MCP App to production
- Live testing Claude Connectors and ChatGPT Apps
- MCP App framework
- ChatGPT App framework
- Claude Connector framework
- sunpeak testing docs
- sunpeak deployment guide
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.