Skip to content

Debugging MCP Servers

Practical, verified guide to debugging Model Context Protocol servers, applied to the @satware/onlyoffice-mcp project. All commands and claims below were executed on 2026-07-01 against the running Docker deployment.

# Tool When Captures
1 MCP Inspector First stop. Transport-agnostic interactive UI. initialize exchange, capability negotiation, tool calls, resource reads, prompt renders, notification stream
2 Server logging Always. stderr (stdio) or notifications/message (HTTP) Init steps, resource access, tool exec, errors, timings
3 Client dev tools After Inspector passes, test real client. Connection events, message payloads, network timing

The Inspector runs directly through npx; no install required. Verified version 0.22.0:

Terminal window
npx @modelcontextprotocol/inspector --help
# Options:
# -e <env> environment variables in KEY=VALUE format
# --config <path> config file path
# --server <n> server name from config file
# --cli enable CLI mode
# --transport <type> transport type (stdio, sse, http)
# --server-url <url> server URL for SSE/HTTP transport
# --header <headers...> HTTP headers as "HeaderName: Value" pairs

Default mode opens a browser UI on localhost:6277. Use --cli for non-interactive runs.

2.2 Inspecting local servers (verified patterns)

Section titled “2.2 Inspecting local servers (verified patterns)”
Terminal window
# TypeScript server built to dist/index.js (stdio transport)
npx @modelcontextprotocol/inspector node /home/mw/internal/onlyoffice/dist/index.js
# Streamable HTTP server - launch Inspector UI, pick "Streamable HTTP", enter URL
npx @modelcontextprotocol/inspector
# URL field: http://localhost:3847/mcp OR https://onlyoffice.localhost/mcp
# CLI mode against HTTP transport (non-interactive)
npx @modelcontextprotocol/inspector --cli --transport http --server-url https://onlyoffice.localhost/mcp
Pane Purpose
Connection Choose transport (stdio / Streamable HTTP / SSE), set args + env
Resources List, MIME types, content preview, subscription test
Prompts List templates, args, preview generated messages
Tools Schemas, invoke with custom inputs, see results
Notifications Live log + notification stream from server
Transport stderr captured by client? Logging mechanism
stdio YES (host captures stderr automatically) process.stderr.write()
Streamable HTTP NO (per docs: “stderr is not captured by the client”) server.sendLoggingMessage({level, data})

WARNING (from docs): stdio servers MUST NOT log to stdout - it corrupts the JSON-RPC protocol.

3.2 sendLoggingMessage API (verified in SDK)

Section titled “3.2 sendLoggingMessage API (verified in SDK)”

Confirmed exists in @modelcontextprotocol/sdk v1.28.0:

// node_modules/@modelcontextprotocol/sdk/dist/esm/server/mcp.d.ts:198
sendLoggingMessage(
params: LoggingMessageNotification['params'],
sessionId?: string
): Promise<void>;

Usage in a tool handler:

await server.sendLoggingMessage({
level: 'info',
data: 'Server started successfully',
});

Eight RFC 5424 severity levels (debugemergency). Clients adjust the minimum level at runtime via logging/setLevel.

Symptom Likely cause Fix
Server won’t start Wrong command path, missing files, perms Use absolute paths in config
Missing env vars stdio inherits limited env subset Pass env in client config
-32602 Invalid params Server sent sampling/elicitation to client that didn’t declare that capability Inspect initialize exchange; both sides must declare capabilities
Connection drops Session mismatch on HTTP Verify Mcp-Session-Id header round-trips
Config errors Invalid JSON, missing fields Validate config file syntax
  1. Dev loop: Inspector for basic connectivity → implement → add logging points
  2. Integration: switch to target client, monitor logs, check error handling
  3. Iteration: config changes → restart client; server code changes → fully quit + reopen client (closing window is not enough for Claude Desktop); quick iteration → stay in Inspector
  • Structured logging: consistent format, timestamps, request IDs
  • Log stack traces + error context, not just messages
  • Track operation timing + message sizes
  • Sanitize secrets/PII from logs (OnlyOffice has jwtSecret, credentials - mask them)

Aspect Value Verification
Transport (primary) Streamable HTTP src/server.ts:3
Transport (secondary) stdio src/index.ts:3, package.json bin onlyoffice-mcp
HTTP endpoint http://localhost:3847/mcp (dev) / https://onlyoffice.localhost/mcp (Caddy proxy) docker ps shows onlyoffice_mcp_server on 0.0.0.0:3847
Tools registered 20 total tools/list via stdio returned 20 tool names
Resources registered stdio entry only grep registerResource matches only in src/index.ts, not src/server.ts
Session mgmt Mcp-Session-Id header, Map<sid, StreamableHTTPServerTransport> src/server.ts:114
Health GET /health returns {"status":"healthy","version":"1.1.0",...} curl https://onlyoffice.localhost/health → 200
SDK version @modelcontextprotocol/sdk@^1.28.0 package.json:72

8. Verified tool inventory (via stdio tools/list)

Section titled “8. Verified tool inventory (via stdio tools/list)”
onlyoffice_generate_document
onlyoffice_batch_generate
onlyoffice_merge_documents
onlyoffice_split_document
onlyoffice_list_templates
onlyoffice_modify_document
onlyoffice_validate_data
onlyoffice_get_metadata
onlyoffice_set_metadata
onlyoffice_extract_content
onlyoffice_extract_text
onlyoffice_extract_tables
onlyoffice_extract_images
onlyoffice_add_watermark
onlyoffice_storage_read
onlyoffice_storage_write
onlyoffice_storage_list
onlyoffice_storage_delete
onlyoffice_storage_exists
onlyoffice_list_instances

(20 tools. Credential tools are conditional on CREDENTIAL_MASTER_PASSWORD; the dev container has it set, so they would push the count higher if registered - but the stdio test above did NOT pass the env var, so credential tools were absent.)

9. Verified resource inventory (via stdio resources/list)

Section titled “9. Verified resource inventory (via stdio resources/list)”
onlyoffice://templates
onlyoffice://templates/stellenbeschreibung/schema
onlyoffice://templates/dienstliches_schreiben/schema
... (one per registered template)
onlyoffice://documents/{id} (ResourceTemplate, listed dynamically)

Resources are registered only in src/index.ts:95-123 (stdio entry). The HTTP entry (src/server.ts) does not call registerResource. To inspect Resources via the Inspector, you MUST use the stdio transport.

10. BUG FOUND & FIXED: Streamable HTTP endpoint (#84, FIXED 2026-07-01)

Section titled “10. BUG FOUND & FIXED: Streamable HTTP endpoint (#84, FIXED 2026-07-01)”

Status: FIXED. Fix merged in commit c6e912e (fix(mcp): fresh McpServer per session — /mcp no longer 500 after first (#84)). Verified working 2026-07-01 13:36 UTC+2. See §10.6 for verification results.

Every initialize request to https://onlyoffice.localhost/mcp (and http://localhost:3847/mcp) returns HTTP 500 {"error":"Internal error"}.

Terminal window
$ curl -i http://localhost:3847/mcp -X POST \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl-test","version":"1.0"}}}'
HTTP/1.1 500 Internal Server Error
{"error":"Internal error"}

src/server.ts creates ONE mcpServer instance and calls mcpServer.connect(t) for each new session:

// src/server.ts:87
const mcpServer = new McpServer({ name: 'onlyoffice-mcp', version });
// src/server.ts:122-123 (inside handleStreamableTransport, per new session)
t = new StreamableHTTPServerTransport({ sessionIdGenerator: ... });
await mcpServer.connect(t); // <-- throws on 2nd call

The SDK’s Protocol.connect() throws if _transport is already set:

// node_modules/@modelcontextprotocol/sdk/dist/esm/shared/protocol.js:215-218
async connect(transport) {
if (this._transport) {
throw new Error('Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.');
}
...
}
$ docker logs --tail 10 onlyoffice_mcp_server
Error: Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.
Error: Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.
Error: Already connected to a transport. Call close() before connecting to a new transport, or use a separate Protocol instance per connection.
  • The first session after container start succeeds; every subsequent session fails with 500.
  • Inspector, TypingMind, OpenClaw, and any other HTTP client cannot connect once the first session is held.
  • This explains why the production endpoint at https://onlyoffice.localhost/mcp is unusable for debugging via Inspector.

Per the SDK error message itself: “use a separate Protocol instance per connection”. The devs extracted the McpServer construction + all tool registrations into a factory (src/mcp-server-factory.ts). src/server.ts calls buildServer() per new session; the transports Map tracks { server, transport } pairs so DELETE /mcp closes both (server.ts:120-122).

Actual implementation (src/mcp-server-factory.ts):

export function createMcpServerFactory(deps, serverInfo): () => McpServer {
return (): McpServer => {
const server = new McpServer({ name: serverInfo.name, version: serverInfo.version });
registerBuilderTools(server, { ... });
registerContentExtractionTools(server);
registerWatermarkTools(server);
registerStorageTools(server);
registerInstanceTools(server, deps.registry);
if (deps.credentialManager) registerCredentialTools(server, deps.credentialManager);
return server;
};
}

src/server.ts usage:

// server.ts:83 - transports Map now tracks pairs
const transports = new Map<string, { server: McpServer; transport: StreamableHTTPServerTransport }>();
// server.ts:92 - fresh server per new session
const server = buildServer();
// server.ts:120-122 - DELETE closes both
await entry.transport.close();
await entry.server.close();
transports.delete(sId);
Section titled “11. Recommended debug setup for OnlyOffice”

11.1 Verification (2026-07-01 13:36 UTC+2, post-fix)

Section titled “11.1 Verification (2026-07-01 13:36 UTC+2, post-fix)”

Container rebuilt and restarted with the fix. All issue #84 acceptance criteria pass:

Test Method Result
Container runs new code docker exec ... ls /app/dist/mcp-server-factory.js OK - exists; grep -c buildServer /app/dist/server.js = 4
/health 200 curl https://onlyoffice.localhost/health OK - v1.1.0
5 sequential initialize curl loop to /mcp OK - all 200, 5 unique sids, no errors in logs
DELETE /mcp valid sid curl -X DELETE -H "mcp-session-id: <sid>" OK - 200 {"message":"Terminated"}
DELETE /mcp deleted sid second DELETE same sid OK - 404 {"error":"Not found"}
DELETE /mcp unknown sid DELETE random UUID OK - 404 {"error":"Not found"}
New session after DELETE POST initialize after DELETE OK - 200, new sid
tools/list via HTTP curl POST with session OK - 25 tools
Inspector CLI session 1 npx @modelcontextprotocol/inspector --cli http://localhost:3847/mcp --method tools/list OK - 25 tools
Inspector CLI session 2 same command, second run OK - 25 tools
Inspector CLI session 3 same command, third run OK - 25 tools
Tool invocation via Inspector --method tools/call --tool-name onlyoffice_list_templates OK - template list JSON
Inspector via HTTPS proxy --cli https://onlyoffice.localhost/mcp OK with NODE_TLS_REJECT_UNAUTHORIZED=0 (self-signed cert - Inspector has no --insecure flag; not a server bug)
resources/list via HTTP --method resources/list -32601 Method not found - by design, Resources are stdio-only (§9)
Docker logs after all tests docker logs --tail 20 Only New:/Closed: lines, zero Error: lines

Conclusion: Fix is correct and complete. The /mcp endpoint now supports unlimited concurrent/sequential MCP client sessions.

11.2 Inspector against stdio (works today, covers Resources tab)

Section titled “11.2 Inspector against stdio (works today, covers Resources tab)”
Terminal window
cd ~/internal/onlyoffice && npm run build
npx @modelcontextprotocol/inspector node /home/mw/internal/onlyoffice/dist/index.js

Use this to test all 20 tools and all 3 resource templates. The Resources tab will populate.

11.3 Inspector against Streamable HTTP (now working, post-fix #84)

Section titled “11.3 Inspector against Streamable HTTP (now working, post-fix #84)”

Once the §10 bug is fixed (DONE - commit c6e912e):

3847/mcp
npx @modelcontextprotocol/inspector

11.4 HTTP-level debugging without Inspector

Section titled “11.4 HTTP-level debugging without Inspector”
Terminal window
# Initialize (capture Mcp-Session-Id from response headers)
curl -i -X POST http://localhost:3847/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1"}}}'
# Browser DevTools Network panel against https://onlyoffice.localhost/mcp:
# - inspect SSE stream chunks
# - inspect Mcp-Session-Id header round-trip
# - inspect request timing

src/server.ts uses process.stderr.write() exclusively. Per the docs and SDK, stderr is NOT captured by the client on Streamable HTTP - so Inspector’s Notifications pane will NOT show those messages even after the §10 fix. To make server logs visible in Inspector, add sendLoggingMessage calls in tool handlers:

// In builder-tools.ts etc:
await server.sendLoggingMessage({ level: 'info', data: `generateDocument: template=${id}` });
await server.sendLoggingMessage({ level: 'error', data: `DocBuilder failed: ${err.message}` });

This is the highest-value change for debugging the OnlyOffice MCP server through Inspector over HTTP.

~/external/mcp-inspector (v0.22.0) - useful for:

  • server/ + client/ - how Inspector sends initialize and negotiates capabilities (mirror in integration tests)
  • How it renders tool schemas (catch schema-extraction regressions for the admin UI tree editor)
  • sample-config.json - stdio server arg format reference
Claim Method Result
Inspector v0.22.0 runs via npx npx @modelcontextprotocol/inspector --help OK - prints help with --transport, --server-url, --cli
OnlyOffice Docker container running docker ps OK - onlyoffice_mcp_server on 0.0.0.0:3847
/health returns 200 curl https://onlyoffice.localhost/health OK - {"status":"healthy","version":"1.1.0"}
/mcp returns 500 curl http://localhost:3847/mcp initialize BUG - 500 Internal error
stdio entry works pipe initialize JSON to node dist/index.js OK - returns protocolVersion + capabilities
Resources are stdio-only grep registerResource in src/server.ts CONFIRMED - 0 matches in server.ts, all in index.ts
sendLoggingMessage exists grep in SDK .d.ts OK - mcp.d.ts:198
20 tools registered stdio tools/list OK - 20 names returned
“Already connected” error source grep SDK source OK - protocol.js:217
Docker logs show the error docker logs onlyoffice_mcp_server OK - 3+ occurrences
  1. Fix §10 bug - DONE (commit c6e912e, issue #84 verified closed)
  2. Add sendLoggingMessage calls to tool handlers so Inspector Notifications pane shows server logs over HTTP (still pending)
  3. Write an Inspector-based smoke test in ~/internal/onlyoffice/tests/integration/ exercising initialize -> tools/list -> generateDocument
  4. Document the Inspector debug recipe in ~/internal/onlyoffice/docs/

Source: https://modelcontextprotocol.io/docs/concepts/resources and ~/external/modelcontextprotocol/docs/specification/2025-11-25/server/resources.mdx. Verified against the OnlyOffice stdio entry on 2026-07-01.

Resources are server-exposed, read-only context data identified by URIs that the host application (not the model) curates and injects into the conversation. The host decides how to surface them - a tree/list UI, a search filter, or auto-inclusion heuristics. The protocol mandates no specific UX.

Resources Tools
Who initiates Application / user picks what to include Model decides to call
Direction Read-only context the host injects Action the model invokes
Returns Text or base64 blob at a URI Arbitrary result (text, artifacts, side effects)
Methods resources/list, resources/read, resources/subscribe tools/list, tools/call
Use case “Show me what’s available, I’ll pick” “Do this for me”
  1. Static resources - fixed URI, e.g. onlyoffice://templates
  2. Resource Templates - parameterized via RFC 6570 URI templates, e.g. onlyoffice://templates/{id}/schema. Client lists them via resources/templates/list, expands the URI, then resources/reads it.
  3. Subscriptions (optional) - client sends resources/subscribe for a URI; server pushes notifications/resources/updated when it changes. Useful for live data.
{ "capabilities": { "resources": { "subscribe": true, "listChanged": true } } }
  • subscribe - per-resource change notifications
  • listChanged - server emits notifications/resources/list_changed when the resource set itself changes (e.g. a new template gets added)

Both are independent; servers may support neither, either, or both.

// Text
{ "uri": "file:///README.md", "mimeType": "text/markdown", "text": "..." }
// Binary (base64)
{ "uri": "file:///logo.png", "mimeType": "image/png", "blob": "iVBORw0KG..." }

Annotations (audience "user"/"assistant", priority 0.0-1.0, lastModified ISO 8601) let clients filter and rank.

  • https:// - client fetches directly from the web (server doesn’t proxy)
  • file:// - filesystem-like; may use XDG MIME types (e.g. inode/directory)
  • git:// - version control
  • Custom schemes (must be RFC 3986 compliant) - e.g. onlyoffice://
Scenario Why a resource beats a tool
File/context picker UI Host shows a tree of file:// URIs; user picks; host reads and injects. No model round-trip needed.
Schema discovery Client lists onlyoffice://templates/{id}/schema, expands, reads schema before calling generateDocument - lets the host render a form.
Live data feeds resources/subscribe to db://users/active; server pushes updates when the underlying data changes. No polling.
Config / reference data Templates, credentials metadata, DB schemas - read-once context that doesn’t change per request.
Generated artifacts After a tool produces output, expose it at onlyoffice://documents/{id} so the host can preview/download without re-invoking the tool.
Audit/history log://session/12345 resources let the host show what happened without exposing a “get history” tool the model could abuse.

A.7 Verified OnlyOffice resource inventory

Section titled “A.7 Verified OnlyOffice resource inventory”

Tested via stdio resources/list against node dist/index.js:

onlyoffice://templates [application/json] static
onlyoffice://templates/stellenbeschreibung/schema [application/json] template
onlyoffice://templates/dienstliches_schreiben/schema [application/json] template
onlyoffice://templates/beschlussvorlage/schema [application/json] template
onlyoffice://templates/praesentation/schema [application/json] template
onlyoffice://templates/bericht/schema [application/json] template
onlyoffice://templates/worms_beschlussvorlage/schema [application/json] template
onlyoffice://templates/worms_kopfbogen_intern/schema [application/json] template
onlyoffice://templates/worms_stellenausschreibung/schema [application/json] template
onlyoffice://templates/worms_arbeitsplatz_bewertung/schema [application/json] template
onlyoffice://templates/worms_amtliches_anschreiben/schema [application/json] template
onlyoffice://templates/worms_presentation/schema [application/json] template

Plus a ResourceTemplate onlyoffice://documents/{id} for generated artifacts (listed dynamically from the artifact store).

Reading one (onlyoffice://templates/stellenbeschreibung/schema) returns the JSON schema the admin UI uses to render the fill-form - exactly the “schema discovery before tool call” pattern from the table above.

{ "resources": { "listChanged": true } }
  • listChanged: true - server will notify when templates are added/removed (admin CRUD)
  • subscribe is not advertised - clients cannot subscribe to individual template changes. Gap if you want live schema updates when an admin edits a template.
  • Resources are stdio-only - the HTTP entry point does not register them (per src/index.ts:95, src/server.ts has no registerResource calls). HTTP clients see -32601 Method not found on resources/list.

A.9 Why OnlyOffice uses resources (design rationale)

Section titled “A.9 Why OnlyOffice uses resources (design rationale)”

The three registrations map to three real workflow needs:

  1. onlyoffice://templates - host app can render a template browser UI without calling onlyoffice_list_templates tool. The model doesn’t need to be in the loop for browsing.
  2. onlyoffice://templates/{id}/schema - host app reads the schema, renders a fill-form (TemplateFillDialog.vue in the admin UI does exactly this), submits via generateDocument tool. The resource decouples “what fields exist” from “generate the doc”.
  3. onlyoffice://documents/{id} - after generation, the artifact is exposed as a URI so the host can preview/download without re-invoking the tool.

This is the textbook MCP resource pattern: application-curated context that flows through the host, not through model tool calls.