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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
## Unreleased

### Changed

- **Tool calls are now bound to the MCP server that received them.** `BaseTool` tracked its target server in a single mutable instance field set by `installTo()`, and the callback it registered read that field at call time rather than capturing the server it was registered on. Because the pre-configured instances exported from `@mapbox/mcp-server/tools` are module-level singletons, an application that installed one instance into more than one `McpServer` would have the later `installTo()` silently redirect the earlier server's callbacks: logging, sampling (`ground_location_tool`), and elicitations (`search_and_geocode_tool`, `directions_tool`) would all be sent to whichever server was installed last. Each invocation now resolves the server that registered its callback, via `AsyncLocalStorage`, so concurrent calls arriving through different servers stay on their own. Single-server applications — including the server shipped by this package — behave exactly as before. Calling `run()` directly, outside a registered callback, still falls back to the most recently installed server.

`BaseResource` carried the same pattern and got the same treatment. Its only reader was `log()`, so the practical effect there was misdirected log messages rather than misdirected client interaction, but the resource instances exported from `@mapbox/mcp-server/resources` are module-level singletons for the same reason and the shared field was the same hazard.

## 0.14.0 - 2026-07-30

### New Features
Expand Down
38 changes: 34 additions & 4 deletions src/resources/BaseResource.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { AsyncLocalStorage } from 'node:async_hooks';
import {
type McpServer,
ResourceTemplate
Expand All @@ -21,8 +22,30 @@ export abstract class BaseResource {
abstract readonly description?: string;
abstract readonly mimeType?: string;

/**
* The most recently installed server. Used as a fallback when `read()` is
* called directly rather than through a handler registered by `installTo()`.
* A single instance installed into several servers only retains the last one,
* so code handling a read should use `activeServer` instead.
*/
protected server: McpServer | null = null;

/**
* The server whose registered handler is serving the current read. Scoped per
* invocation, so concurrent reads arriving through different servers each
* observe their own.
*/
private readonly invocationServer = new AsyncLocalStorage<McpServer>();

/**
* The server a read should communicate with — the one that registered the
* handler serving it, falling back to the last installed server when `read()`
* is invoked outside a registered handler.
*/
protected get activeServer(): McpServer | null {
return this.invocationServer.getStore() ?? this.server;
}

/**
* Installs the resource to the given MCP server.
*/
Expand All @@ -47,7 +70,10 @@ export abstract class BaseResource {
uri: URL,
_variables: Record<string, string | string[]>,
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
) => this.read(uri.toString(), extra)
) =>
this.invocationServer.run(server, () =>
this.read(uri.toString(), extra)
)
);
} else {
server.registerResource(
Expand All @@ -57,7 +83,10 @@ export abstract class BaseResource {
(
uri: URL,
extra: RequestHandlerExtra<ServerRequest, ServerNotification>
) => this.read(uri.toString(), extra)
) =>
this.invocationServer.run(server, () =>
this.read(uri.toString(), extra)
)
);
}
}
Expand All @@ -79,8 +108,9 @@ export abstract class BaseResource {
level: 'debug' | 'info' | 'warning' | 'error',
data: unknown
): void {
if (this.server?.server) {
void this.server.server.sendLoggingMessage({ level, data });
const server = this.activeServer;
if (server?.server) {
void server.server.sendLoggingMessage({ level, data });
}
}
}
31 changes: 28 additions & 3 deletions src/tools/BaseTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import type {
CallToolResult
} from '@modelcontextprotocol/sdk/types.js';
import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js';
import { AsyncLocalStorage } from 'node:async_hooks';
import type { ZodTypeAny } from 'zod';
import type { z } from 'zod';

Expand All @@ -33,8 +34,30 @@ export abstract class BaseTool<
};
};
};
/**
* The most recently installed server. Used as a fallback when `run()` is
* called directly rather than through a callback registered by `installTo()`.
* A single instance installed into several servers only retains the last one,
* so code handling a tool call should read `activeServer` instead.
*/
protected server: McpServer | null = null;

/**
* The server whose registered callback is handling the current tool call.
* Scoped per invocation, so concurrent calls arriving through different
* servers each observe their own.
*/
private readonly invocationServer = new AsyncLocalStorage<McpServer>();

/**
* The server a tool call should communicate with — the one that registered
* the callback handling it, falling back to the last installed server when
* `run()` is invoked outside a registered callback.
*/
protected get activeServer(): McpServer | null {
return this.invocationServer.getStore() ?? this.server;
}

constructor(params: {
inputSchema: InputSchema;
outputSchema?: OutputSchema;
Expand Down Expand Up @@ -91,7 +114,8 @@ export abstract class BaseTool<
this.name,
config,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(args: any, extra: any) => this.run(args, extra)
(args: any, extra: any) =>
this.invocationServer.run(server, () => this.run(args, extra))
);
}

Expand All @@ -111,8 +135,9 @@ export abstract class BaseTool<
level: 'debug' | 'info' | 'warning' | 'error',
data: unknown
): void {
if (this.server?.server) {
void this.server.server.sendLoggingMessage({ level, data });
const server = this.activeServer;
if (server?.server) {
void server.server.sendLoggingMessage({ level, data });
}
}

Expand Down
5 changes: 3 additions & 2 deletions src/tools/directions-tool/DirectionsTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,15 +104,16 @@ export class DirectionsTool extends MapboxApiBasedTool<
private async elicitRouteSelection(
routes: Route[]
): Promise<number | undefined> {
if (!this.server || routes.length < 2) return undefined;
const server = this.activeServer;
if (!server || routes.length < 2) return undefined;

try {
const options = routes.map((route, index) => ({
value: String(index),
label: this.describeRoute(route)
}));

const result = await this.server.server.elicitInput({
const result = await server.server.elicitInput({
mode: 'form',
message: `Found ${routes.length} routes. Choose your preferred route:`,
requestedSchema: {
Expand Down
8 changes: 4 additions & 4 deletions src/tools/ground-location-tool/GroundLocationTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,9 @@ export class GroundLocationTool extends MapboxApiBasedTool<
longitude: number,
latitude: number
): Promise<GroundingStrategy> {
const samplingCapability =
this.server?.server.getClientCapabilities()?.sampling;
if (!samplingCapability || !this.server) {
const server = this.activeServer;
const samplingCapability = server?.server.getClientCapabilities()?.sampling;
if (!samplingCapability || !server) {
return 'neighborhood';
}

Expand All @@ -116,7 +116,7 @@ export class GroundLocationTool extends MapboxApiBasedTool<
`- "region" — user wants area/boundary context like travel-time zones or coverage areas`;

try {
const result = await this.server.server.createMessage({
const result = await server.server.createMessage({
messages: [{ role: 'user', content: { type: 'text', text: prompt } }],
maxTokens: 10
});
Expand Down
5 changes: 3 additions & 2 deletions src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,8 +154,9 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool<
}

// Check if we have multiple results that might be ambiguous
const server = this.activeServer;
if (
this.server &&
server &&
data.features &&
data.features.length >= 2 &&
data.features.length <= 10
Expand All @@ -174,7 +175,7 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool<
});

// Create a JSON Schema with enum for the selection
const result = await this.server.server.elicitInput({
const result = await server.server.elicitInput({
mode: 'form',
message: `Found ${data.features.length} results for "${input.q}". Please select the correct location:`,
requestedSchema: {
Expand Down
99 changes: 99 additions & 0 deletions test/resources/BaseResource.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { describe, it, expect } from 'vitest';
import type { ReadResourceResult } from '@modelcontextprotocol/sdk/types.js';
import { BaseResource } from '../../src/resources/BaseResource.js';

describe('BaseResource', () => {
describe('server binding', () => {
// Reports the server it observed at entry and again after an await, so a
// binding that only holds until the first suspension point is caught.
class ObservingResource extends BaseResource {
readonly uri = 'mapbox://observing';
readonly name = 'observing_resource';
readonly description = 'Reports which server its read is bound to';
readonly mimeType = 'application/json';

async read(uri: string): Promise<ReadResourceResult> {
const before = this.activeServer;
await new Promise((resolve) => setTimeout(resolve, 0));
const after = this.activeServer;
return {
contents: [
{
uri,
mimeType: this.mimeType,
text: JSON.stringify({
before: (before as any)?.id ?? null,
after: (after as any)?.id ?? null
})
}
]
};
}
}

// Minimal McpServer stand-in that captures the handler installTo
// registers, and carries an id so a read can be traced to its server.
function createFakeServer(id: string) {
let registered: (uri: URL, extra: any) => Promise<ReadResourceResult>;
const server = {
id,
server: {},
registerResource: (
_name: string,
_uri: string,
_metadata: any,
handler: any
) => {
registered = handler;
return {};
}
};
return {
server: server as any,
invoke: () => registered(new URL('mapbox://observing'), {})
};
}

function observed(result: ReadResourceResult) {
return JSON.parse((result.contents[0] as { text: string }).text);
}

function installedInTwoServers() {
const resource = new ObservingResource();
const a = createFakeServer('a');
const b = createFakeServer('b');
resource.installTo(a.server);
resource.installTo(b.server);
return { resource, a, b };
}

it('routes each read to the server that registered its handler', async () => {
const { a, b } = installedInTwoServers();

// Installing into b must not redirect the handler registered on a.
expect(observed(await a.invoke()).before).toBe('a');
expect(observed(await b.invoke()).before).toBe('b');
});

it('keeps concurrent reads on separate servers from cross-binding', async () => {
const { a, b } = installedInTwoServers();

const [fromA, fromB] = await Promise.all([a.invoke(), b.invoke()]);

expect(observed(fromA)).toEqual({ before: 'a', after: 'a' });
expect(observed(fromB)).toEqual({ before: 'b', after: 'b' });
});

it('falls back to the installed server when read() is called directly', async () => {
const resource = new ObservingResource();
const a = createFakeServer('a');
resource.installTo(a.server);

// A direct read() has no registered handler to take a binding from.
expect(observed(await resource.read(resource.uri)).before).toBe('a');
});
});
});
Loading
Loading