From 754400bebddcd6c11de12a9495cc93f60c9fa8f0 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 25 Aug 2026 14:47:11 -0400 Subject: [PATCH 1/2] feat: log which MCP client connected, fix capability-read race server.server.getClientVersion()/getClientCapabilities() are only populated once the client's initialize request has been processed -- reading them synchronously right after server.connect() races that request, since connect() only waits for the transport to start, not for the handshake to finish. Confirmed live: reading capabilities immediately after connect() reliably returned undefined even for a client that declared them. Moved both reads into a server.server.oninitialized callback, which fires only once the handshake is fully done. This also fixes the same-shaped bug in the existing capability-gated elicitation-tool registration (currently dormant since ELICITATION_TOOLS is empty, but was silently broken for whenever a tool is added there), and adds client identification as a byproduct: the client's name/version from its initialize request is now logged on connect. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 1 + src/index.ts | 29 ++++++++++++++++++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f31cfd..32a3fa1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ ### New Features +- **The server now identifies which MCP client connected to it.** `server.server.getClientVersion()` (populated from the `clientInfo` sent in the client's `initialize` request) is now logged on connect, e.g. `Client identified as: claude-ai v1.0.0` — useful for support/debugging when behavior differs across Claude Desktop, Cursor, VS Code, etc. Reading it required moving the read to a `server.server.oninitialized` callback rather than right after `server.connect()`, since `getClientVersion()`/`getClientCapabilities()` are only populated once the client's `initialize` request has actually been processed, which is not guaranteed by the time `connect()`'s promise resolves (it only waits for the transport to start). Confirmed live that reading capabilities immediately after `connect()` reliably returned `undefined` even for a client that declared them; moving both reads into `oninitialized` fixed the same-shaped bug in the existing capability-gated tool registration (currently dormant, since no tool is registered through that path yet, but was silently broken for whenever one is added). - **`render_map_tool`: restyle the base map itself via `baseMapConfig`, and place custom layers with `slot`.** Previously `MapAppPayload`/`RenderMapInputSchema` only supported adding new GeoJSON layers on top of a fixed base map. `baseMapConfig` exposes Mapbox Standard's config-property system (`map.setConfigProperty('basemap', ...)`) — e.g. `{ "colorWater": "#ff0000" }` turns the water red, or `{ "lightPreset": "night" }` switches to night lighting — covering colors, `theme`, `lightPreset`, and label/3D-object visibility toggles. Applied at map-construction time (via the `config` option, when present in the initial payload) to avoid a flash of default colors, and via `setConfigProperty` on later re-renders. `layers[].slot` (`"bottom" | "middle" | "top"`) places a custom layer relative to Standard's own layers instead of always rendering above everything, including labels (still the default when `slot` is omitted). Both verified live in a real browser against the Mapbox API. See `docs/render-map-tool.md` for the full property list and an example. Filed as #249 after a user asked whether `render_map_tool` could turn the water red on the fly. ### Breaking Changes diff --git a/src/index.ts b/src/index.ts index c64ca974..98bf35b2 100644 --- a/src/index.ts +++ b/src/index.ts @@ -261,6 +261,22 @@ async function main() { } } + // Registered before connect() so it's already in place the moment the + // client's initialize handshake completes. getClientCapabilities() and + // getClientVersion() are only populated once the server has processed the + // client's `initialize` request -- reading them synchronously right after + // `server.connect()` races that request and reliably sees them as unset, + // since connect() only waits for the transport to start, not for the + // handshake to finish. + server.server.oninitialized = () => { + onClientInitialized().catch((error) => { + server.server.sendLoggingMessage({ + level: 'warning', + data: `Error handling client initialization: ${error instanceof Error ? error.message : String(error)}` + }); + }); + }; + // Start receiving messages on stdin and sending messages on stdout const transport = new StdioServerTransport(); await server.connect(transport); @@ -317,8 +333,19 @@ async function main() { level: 'debug', data: JSON.stringify(relevantEnvVars, null, 2) }); +} + +// Runs once per session, after the client's initialize handshake completes +// (see the `oninitialized` registration in main() for why that timing +// matters). Reports which client connected and registers any tools gated on +// capabilities the client declared during that handshake. +async function onClientInitialized() { + const clientInfo = server.server.getClientVersion(); + server.server.sendLoggingMessage({ + level: 'info', + data: `Client identified as: ${clientInfo?.name ?? 'unknown'} v${clientInfo?.version ?? 'unknown'}` + }); - // After connection, dynamically register capability-dependent tools const clientCapabilities = server.server.getClientCapabilities(); // Debug: Log what capabilities we detected From 816491e0090b2c54b7b4f745e9531f51d893c5bc Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Tue, 25 Aug 2026 14:51:19 -0400 Subject: [PATCH 2/2] feat: also record client name/version on tool-execution trace spans setClientInfo (tracing.ts) stores the connected client's clientInfo as module-level state -- there's exactly one client per stdio server process -- and createToolSpan now reads it into mcp.client.name/mcp.client.version attributes on every tool span. index.ts calls setClientInfo from the same oninitialized callback that already logs the client identity, so OTel traces can be filtered/grouped by which client made each call. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 2 +- src/index.ts | 6 +++++- src/utils/tracing.ts | 23 +++++++++++++++++++++++ test/utils/tracing.test.ts | 31 ++++++++++++++++++++++++++++++- 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 32a3fa1e..ca8c8125 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### New Features -- **The server now identifies which MCP client connected to it.** `server.server.getClientVersion()` (populated from the `clientInfo` sent in the client's `initialize` request) is now logged on connect, e.g. `Client identified as: claude-ai v1.0.0` — useful for support/debugging when behavior differs across Claude Desktop, Cursor, VS Code, etc. Reading it required moving the read to a `server.server.oninitialized` callback rather than right after `server.connect()`, since `getClientVersion()`/`getClientCapabilities()` are only populated once the client's `initialize` request has actually been processed, which is not guaranteed by the time `connect()`'s promise resolves (it only waits for the transport to start). Confirmed live that reading capabilities immediately after `connect()` reliably returned `undefined` even for a client that declared them; moving both reads into `oninitialized` fixed the same-shaped bug in the existing capability-gated tool registration (currently dormant, since no tool is registered through that path yet, but was silently broken for whenever one is added). +- **The server now identifies which MCP client connected to it.** `server.server.getClientVersion()` (populated from the `clientInfo` sent in the client's `initialize` request) is now logged on connect, e.g. `Client identified as: claude-ai v1.0.0` — useful for support/debugging when behavior differs across Claude Desktop, Cursor, VS Code, etc. Reading it required moving the read to a `server.server.oninitialized` callback rather than right after `server.connect()`, since `getClientVersion()`/`getClientCapabilities()` are only populated once the client's `initialize` request has actually been processed, which is not guaranteed by the time `connect()`'s promise resolves (it only waits for the transport to start). Confirmed live that reading capabilities immediately after `connect()` reliably returned `undefined` even for a client that declared them; moving both reads into `oninitialized` fixed the same-shaped bug in the existing capability-gated tool registration (currently dormant, since no tool is registered through that path yet, but was silently broken for whenever one is added). The client name/version is also recorded as `mcp.client.name`/`mcp.client.version` on every subsequent tool-execution trace span, so OTel-backed traces can be filtered or grouped by client. - **`render_map_tool`: restyle the base map itself via `baseMapConfig`, and place custom layers with `slot`.** Previously `MapAppPayload`/`RenderMapInputSchema` only supported adding new GeoJSON layers on top of a fixed base map. `baseMapConfig` exposes Mapbox Standard's config-property system (`map.setConfigProperty('basemap', ...)`) — e.g. `{ "colorWater": "#ff0000" }` turns the water red, or `{ "lightPreset": "night" }` switches to night lighting — covering colors, `theme`, `lightPreset`, and label/3D-object visibility toggles. Applied at map-construction time (via the `config` option, when present in the initial payload) to avoid a flash of default colors, and via `setConfigProperty` on later re-renders. `layers[].slot` (`"bottom" | "middle" | "top"`) places a custom layer relative to Standard's own layers instead of always rendering above everything, including labels (still the default when `slot` is omitted). Both verified live in a real browser against the Mapbox API. See `docs/render-map-tool.md` for the full property list and an example. Filed as #249 after a user asked whether `render_map_tool` could turn the water red on the fly. ### Breaking Changes diff --git a/src/index.ts b/src/index.ts index 98bf35b2..b8cedf58 100644 --- a/src/index.ts +++ b/src/index.ts @@ -35,7 +35,8 @@ import { initializeTracing, shutdownTracing, isTracingInitialized, - getTracer + getTracer, + setClientInfo } from './utils/tracing.js'; // Load .env from current working directory (where npm run is executed) @@ -345,6 +346,9 @@ async function onClientInitialized() { level: 'info', data: `Client identified as: ${clientInfo?.name ?? 'unknown'} v${clientInfo?.version ?? 'unknown'}` }); + // Recorded so every subsequent tool-execution span carries it too -- + // see setClientInfo's doc comment in tracing.ts. + setClientInfo(clientInfo); const clientCapabilities = server.server.getClientCapabilities(); diff --git a/src/utils/tracing.ts b/src/utils/tracing.ts index bad6dcda..8afa85c5 100644 --- a/src/utils/tracing.ts +++ b/src/utils/tracing.ts @@ -266,6 +266,23 @@ export function getTracer() { return trace.getTracer('mapbox-mcp-server'); } +/** + * The connected client's `clientInfo` (name/version), as reported in its + * `initialize` request. There is exactly one client per stdio server + * process, so this is safe as module-level state; set once via + * `setClientInfo` after the initialize handshake completes (see + * `server.server.oninitialized` in index.ts) and read by every tool span + * created afterward, so traces can be filtered/grouped by which MCP client + * (Claude Desktop, Cursor, VS Code, etc.) made the call. + */ +let currentClientInfo: { name?: string; version?: string } | undefined; + +export function setClientInfo( + info: { name?: string; version?: string } | undefined +): void { + currentClientInfo = info; +} + /** * Create a span for tool execution with comprehensive attributes */ @@ -288,6 +305,12 @@ export function createToolSpan( 'tool.name': toolName, 'tool.input.size': inputSize, 'operation.type': 'tool_execution', + ...(currentClientInfo?.name && { + 'mcp.client.name': currentClientInfo.name + }), + ...(currentClientInfo?.version && { + 'mcp.client.version': currentClientInfo.version + }), ...(extra?.sessionId && { 'session.id': extra.sessionId }), ...(extra?.userId && { 'user.id': extra.userId }), ...(extra?.accountId && { 'account.id': extra.accountId }), diff --git a/test/utils/tracing.test.ts b/test/utils/tracing.test.ts index 0da5943a..769b8a73 100644 --- a/test/utils/tracing.test.ts +++ b/test/utils/tracing.test.ts @@ -12,7 +12,8 @@ import { markSpanSuccess, markSpanError, validateJwtForTracing, - getObjectSize + getObjectSize, + setClientInfo } from '../../src/utils/tracing.js'; // Mock the OpenTelemetry modules to avoid actual tracing in tests @@ -58,6 +59,7 @@ vi.mock('@opentelemetry/api', () => ({ describe('tracing utilities', () => { beforeEach(() => { vi.clearAllMocks(); + setClientInfo(undefined); }); afterEach(async () => { @@ -159,6 +161,33 @@ describe('tracing utilities', () => { }); }); + it('should include the connected client name/version once set via setClientInfo', () => { + const tracer = getTracer(); + const mockSpan = { + setAttributes: vi.fn(), + setStatus: vi.fn(), + recordException: vi.fn(), + end: vi.fn() + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(tracer.startSpan).mockReturnValue(mockSpan as any); + + setClientInfo({ name: 'claude-ai', version: '1.0.0' }); + createToolSpan('test_tool', 1024); + + expect(tracer.startSpan).toHaveBeenCalledWith('tool.test_tool', { + kind: expect.any(Number), + attributes: { + 'tool.name': 'test_tool', + 'tool.input.size': 1024, + 'operation.type': 'tool_execution', + 'mcp.client.name': 'claude-ai', + 'mcp.client.version': '1.0.0' + } + }); + }); + it('should create HTTP span with basic attributes', () => { const tracer = getTracer(); const mockSpan = {