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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@

### Fixed

- **`ui://` resources now receive the request context when read.** `src/index.ts` registered MCP Apps UI resources with a wrapper that called `readCallback` with `{}` instead of the request's `extra`, so `MapPreviewUIResource` never saw the caller's `authInfo.token` and always minted its preview token from `MAPBOX_ACCESS_TOKEN`. All resources are now registered through a new `installResources()` in `resourceRegistry.ts`, which uses `BaseResource.installTo()` and forwards `extra` unchanged. UI resources already declare `RESOURCE_MIME_TYPE`, so the `registerAppResource` wrapper is no longer needed. Covered by regression tests that read every resource through a client that sends `authInfo`. (#141)

- **`create-and-preview-style` prompt now defaults to the Standard base style, not Classic `streets-v12`.** `StyleBuilderTool` (used by every other style-related prompt in this repo) has defaulted `base_style` to `"standard"` for a while — "ALWAYS use 'standard' as the default for all new styles" — but this prompt's own `base_style` fallback and its "Create the map style" step were never updated to match: it hardcoded `streets-v12` and hand-authored a bare Classic-style JSON skeleton (`sources`/`layers` background fill) directly in the prompt text, disconnected from the `base_style` argument entirely. Now defaults to `"standard"` and, like `build-custom-map` and other prompts in this repo, delegates style construction to `style_builder_tool` (which knows how to build either Standard or Classic structures correctly) instead of hand-rolling a Classic-only skeleton that would have been wrong for any base style other than the one it hardcoded.
- **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.
- **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.
Expand Down
34 changes: 2 additions & 32 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,14 +11,10 @@ import { SpanStatusCode } from '@opentelemetry/api';

import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
import {
registerAppResource,
RESOURCE_MIME_TYPE
} from '@modelcontextprotocol/ext-apps/server';
import { z } from 'zod';
import { parseToolConfigFromArgs, filterTools } from './config/toolConfig.js';
import { getCoreTools, getElicitationTools } from './tools/toolRegistry.js';
import { getAllResources } from './resources/resourceRegistry.js';
import { installResources } from './resources/resourceRegistry.js';
import { getAllPrompts } from './prompts/promptRegistry.js';
import { getVersionInfo } from './utils/versionUtils.js';
import {
Expand Down Expand Up @@ -90,33 +86,7 @@ enabledCoreTools.forEach((tool) => {
tool.installTo(server);
});

// Register resources to the server
const resources = getAllResources();

// Separate MCP Apps UI resources from regular resources
const uiResources = resources.filter((r) => r.uri.startsWith('ui://'));
const regularResources = resources.filter((r) => !r.uri.startsWith('ui://'));

// Register MCP Apps UI resources using registerAppResource
// IMPORTANT: Use RESOURCE_MIME_TYPE which is "text/html;profile=mcp-app"
// This tells clients (like Claude Desktop) that this is an MCP App
uiResources.forEach((resource) => {
registerAppResource(
server as any,
resource.name,
resource.uri,
{ mimeType: RESOURCE_MIME_TYPE, description: resource.description },
async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return await resource.readCallback(new URL(resource.uri), {} as any);
}
);
});

// Register regular resources using standard registration
regularResources.forEach((resource) => {
resource.installTo(server);
});
installResources(server);

// Register prompts to the server
const prompts = getAllPrompts();
Expand Down
15 changes: 15 additions & 0 deletions src/resources/resourceRegistry.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import type { BaseResource } from './BaseResource.js';
import { MapboxStyleLayersResource } from './mapbox-style-layers-resource/MapboxStyleLayersResource.js';
import { MapboxStreetsV8FieldsResource } from './mapbox-streets-v8-fields-resource/MapboxStreetsV8FieldsResource.js';
import { MapboxTokenScopesResource } from './mapbox-token-scopes-resource/MapboxTokenScopesResource.js';
Expand All @@ -25,6 +27,19 @@ export function getAllResources(): readonly ResourceInstance[] {
return ALL_RESOURCES;
}

/**
* Registers every resource on `server` via `BaseResource.installTo`, so each
* read callback receives the request's `extra` (including the caller's
* `authInfo`). UI resources declare `RESOURCE_MIME_TYPE` themselves, which is
* all `registerAppResource` would add.
*/
export function installResources(
server: McpServer,
resources: readonly BaseResource[] = ALL_RESOURCES
): void {
resources.forEach((resource) => resource.installTo(server));
}

export function getResourceByUri(uri: string): ResourceInstance | undefined {
return ALL_RESOURCES.find((resource) => resource.uri === uri);
}
119 changes: 119 additions & 0 deletions test/resources/resourceRegistry.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Copyright (c) Mapbox, Inc.
// Licensed under the MIT License.

import { describe, it, expect, vi, afterEach } from 'vitest';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
import type { JSONRPCMessage } from '@modelcontextprotocol/sdk/types.js';
import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server';
import {
getAllResources,
installResources
} from '../../src/resources/resourceRegistry.js';
import { MapPreviewUIResource } from '../../src/resources/ui-apps/MapPreviewUIResource.js';

function makeToken(prefix: 'sk' | 'pk' | 'tk', username: string): string {
const payload = Buffer.from(JSON.stringify({ u: username })).toString(
'base64'
);
return `${prefix}.${payload}.sig`;
}

function usernameOf(token: string): string {
return JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString()).u;
}

// Connects a client whose every request carries `authInfo`, the way an
// authenticating HTTP transport delivers the caller's bearer token.
async function connectWithAuth(
server: McpServer,
token: string
): Promise<Client> {
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const send = clientTransport.send.bind(clientTransport);
clientTransport.send = (message: JSONRPCMessage) =>
send(message, {
authInfo: { token, clientId: 'test-client', scopes: [] }
});
await server.connect(serverTransport);
const client = new Client({ name: 'test-client', version: '0.0.0' });
await client.connect(clientTransport);
return client;
}

describe('installResources', () => {
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
vi.unstubAllEnvs();
});

it('passes the caller authInfo to every resource read callback', async () => {
const resources = getAllResources();
const spies = resources.map((resource) =>
vi
.spyOn(resource, 'readCallback')
.mockResolvedValue({ contents: [{ uri: resource.uri, text: '' }] })
);
const server = new McpServer({ name: 'test', version: '0.0.0' });
installResources(server);
const client = await connectWithAuth(server, 'caller-token');

for (const resource of resources) {
await client.readResource({ uri: resource.uri });
}

spies.forEach((spy) => {
expect(spy).toHaveBeenCalledTimes(1);
expect(spy.mock.calls[0][1].authInfo?.token).toBe('caller-token');
});
await client.close();
});

it('registers ui:// resources with the MCP Apps mime type', async () => {
const server = new McpServer({ name: 'test', version: '0.0.0' });
installResources(server);
const client = await connectWithAuth(server, 'caller-token');

const { resources } = await client.listResources();
const uiResources = resources.filter((r) => r.uri.startsWith('ui://'));

expect(uiResources.length).toBeGreaterThan(0);
uiResources.forEach((r) => expect(r.mimeType).toBe(RESOURCE_MIME_TYPE));
await client.close();
});

it('mints the map preview token on the caller account, not the server env token account', async () => {
vi.stubEnv('MAPBOX_ACCESS_TOKEN', makeToken('sk', 'server-operator'));
const fetchMock = vi.fn(async (input: string | URL | Request) => {
const username = String(input).match(/tokens\/v2\/([^?]+)/)?.[1] ?? '';
return new Response(
JSON.stringify({ token: makeToken('tk', username) }),
{ status: 200 }
);
});
vi.stubGlobal('fetch', fetchMock);

const resource = new MapPreviewUIResource();
const server = new McpServer({ name: 'test', version: '0.0.0' });
installResources(server, [resource]);
const client = await connectWithAuth(
server,
makeToken('sk', 'caller-user')
);

const result = await client.readResource({ uri: resource.uri });
const html = result.contents[0].text as string;
const embedded = html.match(/var TOKEN = '([^']*)'/)?.[1] ?? '';

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(String(fetchMock.mock.calls[0][0])).toContain(
'tokens/v2/caller-user?'
);
expect(usernameOf(embedded)).toBe('caller-user');
expect(html).not.toContain('sk.');
await client.close();
});
});
Loading