diff --git a/CHANGELOG.md b/CHANGELOG.md index b4f31cf..ca8c812 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). 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 c64ca97..b8cedf5 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) @@ -261,6 +262,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 +334,22 @@ 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'}` + }); + // Recorded so every subsequent tool-execution span carries it too -- + // see setClientInfo's doc comment in tracing.ts. + setClientInfo(clientInfo); - // After connection, dynamically register capability-dependent tools const clientCapabilities = server.server.getClientCapabilities(); // Debug: Log what capabilities we detected diff --git a/src/utils/tracing.ts b/src/utils/tracing.ts index bad6dcd..8afa85c 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 0da5943..769b8a7 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 = {