Skip to content

Commit e767e62

Browse files
Merge pull request #139 from mapbox/fix/log-messages-before-connect
fix: startup logging messages never reached clients
2 parents fe01305 + 1b0ed65 commit e767e62

5 files changed

Lines changed: 287 additions & 42 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
## Unreleased
22

3+
### Fixed
4+
5+
- **Startup logging messages (`.env` status, tracing status, detected client capabilities) now actually reach clients.** The `McpServer` never declared the `logging` capability, so every `sendLoggingMessage` call was a silent no-op regardless of when it was sent. Several of those calls also ran before `server.connect(transport)`, which would have dropped them anyway even with the capability declared, since there's no connected client yet to receive a notification sent before the transport is connected. Fixed both: `logging: {}` is now declared in the server's capabilities, and startup logging is deferred until after `connect()`. Covered by a new integration test that spawns the real built server and asserts a real client receives at least one startup log message.
6+
- **The server now identifies which MCP client connected to it, and fixes a related capability-read race.** `server.server.getClientVersion()`/`getClientCapabilities()` are only populated once the client's `initialize` request has been processed, which is not guaranteed by the time `server.connect()`'s promise resolves (it only waits for the transport to start). Reading them right after `connect()`, as the existing capability-gated tool registration did, reliably saw them as unset — confirmed live, even for a client that explicitly declared `elicitation` support. Both reads now happen inside a `server.server.oninitialized` callback, which only fires once the handshake is fully done. As part of this, the client's identity (name/version from its `initialize` request) is now logged on connect (e.g. `Client identified as: claude-ai v1.0.0`) and 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.
7+
38
### New Features
49

510
- **`preview_style_tool` and `style_comparison_tool` no longer require an existing `accessToken`.** Both previously made `accessToken` a required `pk.*` input — meaning a caller had to already have (or separately go create) a public token before either tool would do anything, for even a quick one-off preview. `accessToken` is now optional on both: when omitted, the tool auto-generates a preview token from the server's own access token (the same pattern `geojson_preview_tool`'s resource already used) via a new shared `mintScopedPreviewToken` utility, so a first call needs no setup at all.

‎src/index.ts‎

Lines changed: 81 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@ import {
2525
initializeTracing,
2626
shutdownTracing,
2727
isTracingInitialized,
28-
getTracer
28+
getTracer,
29+
setClientInfo
2930
} from './utils/tracing.js';
3031

3132
// Load .env from current working directory (where npm run is executed)
@@ -77,7 +78,8 @@ const server = new McpServer(
7778
listChanged: true // Advertise support for dynamic tool registration
7879
},
7980
resources: {},
80-
prompts: {}
81+
prompts: {},
82+
logging: {}
8183
}
8284
}
8385
);
@@ -148,44 +150,19 @@ prompts.forEach((prompt) => {
148150
});
149151

150152
async function main() {
151-
// Send MCP logging messages about .env loading
152-
if (envLoadError) {
153-
server.server.sendLoggingMessage({
154-
level: 'warning',
155-
data: `Failed to load .env file: ${envLoadError.message}`
156-
});
157-
} else if (envLoadedCount > 0) {
158-
server.server.sendLoggingMessage({
159-
level: 'info',
160-
data: `Loaded ${envLoadedCount} environment variables from ${envPath}`
161-
});
162-
} else {
163-
server.server.sendLoggingMessage({
164-
level: 'debug',
165-
data: 'No .env file found or file was empty'
166-
});
167-
}
168-
169-
// Initialize OpenTelemetry tracing if not in test mode
153+
// Initialize OpenTelemetry tracing if not in test mode. This happens before
154+
// the transport connects (below), so any resulting MCP logging messages are
155+
// only sent once we're connected -- sending them earlier would be silently
156+
// dropped, since there's no client to receive them yet.
157+
let tracingInitialized = false;
158+
let tracingInitError: Error | null = null;
170159
if (process.env.NODE_ENV !== 'test' && !process.env.VITEST) {
171160
try {
172161
await initializeTracing();
173-
174-
// Send MCP logging message about tracing status
175-
if (isTracingInitialized()) {
176-
server.server.sendLoggingMessage({
177-
level: 'info',
178-
data: 'OpenTelemetry tracing enabled'
179-
});
180-
} else {
181-
server.server.sendLoggingMessage({
182-
level: 'debug',
183-
data: 'OpenTelemetry tracing disabled (no OTLP endpoint configured)'
184-
});
185-
}
162+
tracingInitialized = isTracingInitialized();
186163

187164
// Record .env loading as a span (retrospectively since it happened before tracing init)
188-
if (isTracingInitialized()) {
165+
if (tracingInitialized) {
189166
const tracer = getTracer();
190167
const span = tracer.startSpan('config.load_env', {
191168
attributes: {
@@ -217,12 +194,64 @@ async function main() {
217194
span.end();
218195
}
219196
} catch (error) {
220-
// Log tracing initialization failure
197+
tracingInitError =
198+
error instanceof Error ? error : new Error(String(error));
199+
}
200+
}
201+
202+
// Registered before connect() so it's already in place the moment the
203+
// client's initialize handshake completes. getClientCapabilities() and
204+
// getClientVersion() are only populated once the server has processed the
205+
// client's `initialize` request -- reading them synchronously right after
206+
// `server.connect()` races that request and reliably sees them as unset,
207+
// since connect() only waits for the transport to start, not for the
208+
// handshake to finish.
209+
server.server.oninitialized = () => {
210+
onClientInitialized().catch((error) => {
221211
server.server.sendLoggingMessage({
222212
level: 'warning',
223-
data: `Failed to initialize tracing: ${error instanceof Error ? error.message : String(error)}`
213+
data: `Error handling client initialization: ${error instanceof Error ? error.message : String(error)}`
224214
});
225-
}
215+
});
216+
};
217+
218+
// Start receiving messages on stdin and sending messages on stdout
219+
const transport = new StdioServerTransport();
220+
await server.connect(transport);
221+
222+
// Now that we're connected, send all the logging messages accumulated above.
223+
if (envLoadError) {
224+
server.server.sendLoggingMessage({
225+
level: 'warning',
226+
data: `Failed to load .env file: ${envLoadError.message}`
227+
});
228+
} else if (envLoadedCount > 0) {
229+
server.server.sendLoggingMessage({
230+
level: 'info',
231+
data: `Loaded ${envLoadedCount} environment variables from ${envPath}`
232+
});
233+
} else {
234+
server.server.sendLoggingMessage({
235+
level: 'debug',
236+
data: 'No .env file found or file was empty'
237+
});
238+
}
239+
240+
if (tracingInitError) {
241+
server.server.sendLoggingMessage({
242+
level: 'warning',
243+
data: `Failed to initialize tracing: ${tracingInitError.message}`
244+
});
245+
} else if (tracingInitialized) {
246+
server.server.sendLoggingMessage({
247+
level: 'info',
248+
data: 'OpenTelemetry tracing enabled'
249+
});
250+
} else {
251+
server.server.sendLoggingMessage({
252+
level: 'debug',
253+
data: 'OpenTelemetry tracing disabled (no OTLP endpoint configured)'
254+
});
226255
}
227256

228257
const relevantEnvVars = Object.freeze({
@@ -239,12 +268,22 @@ async function main() {
239268
level: 'debug',
240269
data: JSON.stringify(relevantEnvVars, null, 2)
241270
});
271+
}
242272

243-
// Start receiving messages on stdin and sending messages on stdout
244-
const transport = new StdioServerTransport();
245-
await server.connect(transport);
273+
// Runs once per session, after the client's initialize handshake completes
274+
// (see the `oninitialized` registration in main() for why that timing
275+
// matters). Reports which client connected and registers any tools gated on
276+
// capabilities the client declared during that handshake.
277+
async function onClientInitialized() {
278+
const clientInfo = server.server.getClientVersion();
279+
server.server.sendLoggingMessage({
280+
level: 'info',
281+
data: `Client identified as: ${clientInfo?.name ?? 'unknown'} v${clientInfo?.version ?? 'unknown'}`
282+
});
283+
// Recorded so every subsequent tool-execution span carries it too --
284+
// see setClientInfo's doc comment in tracing.ts.
285+
setClientInfo(clientInfo);
246286

247-
// After connection, dynamically register capability-dependent tools
248287
const clientCapabilities = server.server.getClientCapabilities();
249288

250289
// Debug: Log what capabilities we detected

‎src/utils/tracing.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,23 @@ export function getTracer() {
253253
return trace.getTracer('mapbox-mcp-devkit-server');
254254
}
255255

256+
/**
257+
* The connected client's `clientInfo` (name/version), as reported in its
258+
* `initialize` request. There is exactly one client per stdio server
259+
* process, so this is safe as module-level state; set once via
260+
* `setClientInfo` after the initialize handshake completes (see
261+
* `server.server.oninitialized` in index.ts) and read by every tool span
262+
* created afterward, so traces can be filtered/grouped by which MCP client
263+
* (Claude Desktop, Cursor, VS Code, etc.) made the call.
264+
*/
265+
let currentClientInfo: { name?: string; version?: string } | undefined;
266+
267+
export function setClientInfo(
268+
info: { name?: string; version?: string } | undefined
269+
): void {
270+
currentClientInfo = info;
271+
}
272+
256273
/**
257274
* Create a span for tool execution with comprehensive attributes
258275
*/
@@ -275,6 +292,12 @@ export function createToolSpan(
275292
'tool.name': toolName,
276293
'tool.input.size': inputSize,
277294
'operation.type': 'tool_execution',
295+
...(currentClientInfo?.name && {
296+
'mcp.client.name': currentClientInfo.name
297+
}),
298+
...(currentClientInfo?.version && {
299+
'mcp.client.version': currentClientInfo.version
300+
}),
278301
...(extra?.sessionId && { 'session.id': extra.sessionId }),
279302
...(extra?.userId && { 'user.id': extra.userId }),
280303
...(extra?.accountId && { 'account.id': extra.accountId }),
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
// Copyright (c) Mapbox, Inc.
2+
// Licensed under the MIT License.
3+
4+
import { describe, it, expect } from 'vitest';
5+
import { existsSync } from 'node:fs';
6+
import { join, dirname } from 'node:path';
7+
import { fileURLToPath } from 'node:url';
8+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
9+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
10+
import { LoggingMessageNotificationSchema } from '@modelcontextprotocol/sdk/types.js';
11+
12+
/**
13+
* Spawns the *actual built server* (dist/esm/index.js) as a real child
14+
* process and drives it over real stdio with a real MCP client. Startup used
15+
* to send several `notifications/message` logging calls (.env status,
16+
* tracing status, a debug env dump) before `server.connect(transport)` ran
17+
* -- a real client can never receive a notification sent before the
18+
* transport it's listening on is connected, so those messages were silently
19+
* dropped every time. Only a test that crosses the real process/transport
20+
* boundary can catch a regression back to that ordering; asserting against
21+
* the return value of some internal function can't, since nothing here is
22+
* about return values.
23+
*/
24+
25+
const __dirname = dirname(fileURLToPath(import.meta.url));
26+
const SERVER_ENTRY = join(__dirname, '..', '..', 'dist', 'esm', 'index.js');
27+
28+
const DUMMY_TOKEN = 'sk.eyJ1IjoidGVzdC11c2VyIn0.signature';
29+
30+
describe.skipIf(!existsSync(SERVER_ENTRY))(
31+
'startup logging (real server process, real MCP protocol)',
32+
() => {
33+
it('delivers startup logging messages to a client connected before they are sent', async () => {
34+
const transport = new StdioClientTransport({
35+
command: process.execPath,
36+
args: [SERVER_ENTRY],
37+
env: {
38+
MAPBOX_ACCESS_TOKEN: DUMMY_TOKEN,
39+
PATH: process.env.PATH ?? ''
40+
}
41+
});
42+
const client = new Client({
43+
name: 'logging-order-integration-test',
44+
version: '1.0.0'
45+
});
46+
47+
const received: unknown[] = [];
48+
client.setNotificationHandler(
49+
LoggingMessageNotificationSchema,
50+
async (notification) => {
51+
received.push(notification.params);
52+
}
53+
);
54+
55+
try {
56+
await client.connect(transport);
57+
// Startup logging happens asynchronously right after connect;
58+
// give it a moment to arrive rather than racing it.
59+
await new Promise((resolve) => setTimeout(resolve, 500));
60+
61+
expect(received.length).toBeGreaterThan(0);
62+
} finally {
63+
await client.close().catch(() => {
64+
// Already closed or the process exited on its own.
65+
});
66+
}
67+
});
68+
}
69+
);

‎test/utils/tracing.test.ts‎

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
// Copyright (c) Mapbox, Inc.
2+
// Licensed under the MIT License.
3+
4+
import { describe, it, expect, vi, beforeEach } from 'vitest';
5+
import {
6+
createToolSpan,
7+
getTracer,
8+
setClientInfo
9+
} from '../../src/utils/tracing.js';
10+
11+
// Mock the OpenTelemetry modules to avoid actual tracing in tests
12+
vi.mock('@opentelemetry/sdk-node', () => ({
13+
NodeSDK: vi.fn().mockImplementation(() => ({
14+
start: vi.fn(),
15+
shutdown: vi.fn().mockResolvedValue(undefined)
16+
}))
17+
}));
18+
19+
vi.mock('@opentelemetry/api', () => ({
20+
trace: {
21+
getTracer: vi.fn().mockReturnValue({
22+
startSpan: vi.fn().mockReturnValue({
23+
setAttributes: vi.fn(),
24+
setStatus: vi.fn(),
25+
recordException: vi.fn(),
26+
end: vi.fn()
27+
})
28+
})
29+
},
30+
SpanStatusCode: {
31+
OK: 1,
32+
ERROR: 2
33+
},
34+
SpanKind: {
35+
INTERNAL: 0,
36+
CLIENT: 3
37+
},
38+
diag: {
39+
setLogger: vi.fn()
40+
},
41+
DiagLogLevel: {
42+
NONE: 0,
43+
ERROR: 30,
44+
WARN: 50,
45+
INFO: 60,
46+
DEBUG: 70,
47+
VERBOSE: 80
48+
}
49+
}));
50+
51+
describe('tracing utilities', () => {
52+
beforeEach(() => {
53+
vi.clearAllMocks();
54+
setClientInfo(undefined);
55+
});
56+
57+
describe('createToolSpan', () => {
58+
it('creates a tool span with basic attributes and no client info by default', () => {
59+
const tracer = getTracer();
60+
const mockSpan = {
61+
setAttributes: vi.fn(),
62+
setStatus: vi.fn(),
63+
recordException: vi.fn(),
64+
end: vi.fn()
65+
};
66+
67+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
68+
vi.mocked(tracer.startSpan).mockReturnValue(mockSpan as any);
69+
70+
createToolSpan('test_tool', 1024);
71+
72+
expect(tracer.startSpan).toHaveBeenCalledWith('tool.test_tool', {
73+
kind: expect.any(Number),
74+
attributes: {
75+
'tool.name': 'test_tool',
76+
'tool.input.size': 1024,
77+
'operation.type': 'tool_execution'
78+
}
79+
});
80+
});
81+
82+
it('includes the connected client name/version once set via setClientInfo', () => {
83+
const tracer = getTracer();
84+
const mockSpan = {
85+
setAttributes: vi.fn(),
86+
setStatus: vi.fn(),
87+
recordException: vi.fn(),
88+
end: vi.fn()
89+
};
90+
91+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
92+
vi.mocked(tracer.startSpan).mockReturnValue(mockSpan as any);
93+
94+
setClientInfo({ name: 'claude-ai', version: '1.0.0' });
95+
createToolSpan('test_tool', 1024);
96+
97+
expect(tracer.startSpan).toHaveBeenCalledWith('tool.test_tool', {
98+
kind: expect.any(Number),
99+
attributes: {
100+
'tool.name': 'test_tool',
101+
'tool.input.size': 1024,
102+
'operation.type': 'tool_execution',
103+
'mcp.client.name': 'claude-ai',
104+
'mcp.client.version': '1.0.0'
105+
}
106+
});
107+
});
108+
});
109+
});

0 commit comments

Comments
 (0)