Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
35 changes: 33 additions & 2 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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
Expand Down
23 changes: 23 additions & 0 deletions src/utils/tracing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -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 }),
Expand Down
31 changes: 30 additions & 1 deletion test/utils/tracing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -58,6 +59,7 @@ vi.mock('@opentelemetry/api', () => ({
describe('tracing utilities', () => {
beforeEach(() => {
vi.clearAllMocks();
setClientInfo(undefined);
});

afterEach(async () => {
Expand Down Expand Up @@ -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 = {
Expand Down
Loading