From a85516cc741aa3aab5b8d31f276236cda2976e68 Mon Sep 17 00:00:00 2001 From: jussi-sa Date: Tue, 13 Oct 2026 20:17:59 +0300 Subject: [PATCH 1/3] fix(resources): forward request context to ui:// resource reads The ui:// registration wrapper in src/index.ts called readCallback with {} instead of the request's extra, so MapPreviewUIResource never saw the caller's authInfo and always minted from MAPBOX_ACCESS_TOKEN. Register all resources through BaseResource.installTo() instead. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 + src/index.ts | 34 +------ src/resources/resourceRegistry.ts | 15 +++ test/resources/resourceRegistry.test.ts | 118 ++++++++++++++++++++++++ 4 files changed, 137 insertions(+), 32 deletions(-) create mode 100644 test/resources/resourceRegistry.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1da222..d2486d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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`. + - **`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. diff --git a/src/index.ts b/src/index.ts index 96cbe82..b6a6ee0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 { @@ -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(); diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index a3c871a..637a2bd 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -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'; @@ -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); } diff --git a/test/resources/resourceRegistry.test.ts b/test/resources/resourceRegistry.test.ts new file mode 100644 index 0000000..74617aa --- /dev/null +++ b/test/resources/resourceRegistry.test.ts @@ -0,0 +1,118 @@ +// 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 { + 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'); + await client.close(); + }); +}); From 84fd258b97d28d19d264525f25209018e4bc59c2 Mon Sep 17 00:00:00 2001 From: jussi-sa Date: Tue, 13 Oct 2026 20:18:19 +0300 Subject: [PATCH 2/3] docs(changelog): add PR number for ui:// resource fix Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d2486d0..17d822e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ### 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`. +- **`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. From 42ac6f523343fba723802cdbe8e0db4eedc2b755 Mon Sep 17 00:00:00 2001 From: jussi-sa Date: Tue, 13 Oct 2026 21:20:12 +0300 Subject: [PATCH 3/3] test(resources): assert map preview HTML never contains an sk. token Co-Authored-By: Claude Opus 5.5 --- test/resources/resourceRegistry.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/resources/resourceRegistry.test.ts b/test/resources/resourceRegistry.test.ts index 74617aa..067836d 100644 --- a/test/resources/resourceRegistry.test.ts +++ b/test/resources/resourceRegistry.test.ts @@ -113,6 +113,7 @@ describe('installResources', () => { 'tokens/v2/caller-user?' ); expect(usernameOf(embedded)).toBe('caller-user'); + expect(html).not.toContain('sk.'); await client.close(); }); });