/* * Copyright (C) 2025 TomTom Navigation B.V. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { EXTENSION_ID, RESOURCE_MIME_TYPE } from "@modelcontextprotocol/ext-apps/server"; import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js"; import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import type { Tool } from "@modelcontextprotocol/sdk/types.js"; import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { ENDPOINT_HEALTH, ENDPOINT_MCP } from "./constants"; import { createHttpServer, type HttpServerResult, needsApiKey } from "./indexHttp"; import { cannedApiResponse } from "./services/shared/cannedApiResponses"; import { type RecordedRequest, recordFetch } from "./services/shared/recordFetch"; import { AI_CLIENTS, CLIENTS_AT_NAME_LIMIT, CLIENTS_WITHOUT_VALID_NAME } from "./testClients"; import { API_TOOLS, CACHED_AFTER_FIRST_CALL, TOOL_CALLS_REACHING_TOMTOM, } from "./tools/toolBaselines"; import { logger } from "./utils/logger"; import { VERSION } from "./version"; /** An app template the size of a real one: the built apps are not there under test. */ const APP_HTML = `${"
map
".repeat(50_000)}`; vi.mock("./tools/helpers/appHtmlCache", () => ({ readAppHtml: async () => APP_HTML })); /** Small delay to ensure SSE responses complete before shutdown */ const delay = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); const TEST_API_KEY = "test-api-key"; interface ToolsListResponse { jsonrpc: string; id: number; result?: { tools: Array<{ name: string; _meta?: { visibility?: string[]; ui?: { visibility?: string[] } }; }>; }; } interface HealthResponse { status: string; version: string; } function parseSSEResponse(text: string): T { const dataLine = text.split("\n").find((line) => line.startsWith("data: ")); if (!dataLine) { throw new Error(`No data line in SSE response: ${text}`); } return JSON.parse(dataLine.slice(6)); } async function postMcpListTools({ port, extraHeaders = {}, }: { port: number; extraHeaders?: Record; }) { const headers: Record = { "Content-Type": "application/json", Accept: "application/json,text/event-stream", Connection: "close", "tomtom-api-key": TEST_API_KEY, ...extraHeaders, }; return await fetch(`http://localhost:${port}/${ENDPOINT_MCP}`, { method: "POST", headers, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/list", params: {} }), }); } async function postMcp( port: number, method: string, params: Record, acceptEncoding: string ) { return await fetch(`http://localhost:${port}/${ENDPOINT_MCP}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json,text/event-stream", "Accept-Encoding": acceptEncoding, Connection: "close", "tomtom-api-key": TEST_API_KEY, }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), }); } async function listTools(port: number): Promise { const response = await postMcpListTools({ port }); return parseSSEResponse(await response.text()); } async function getHealth(port: number): Promise { const response = await fetch(`http://localhost:${port}/${ENDPOINT_HEALTH}`); return response.json(); } /** Sorted tool names, excluding app-internal tools (those with visibility: ["app"]) */ function publicToolNames(result: ToolsListResponse): string[] { expect(result.result?.tools).toBeDefined(); return result .result!.tools.filter( (tool) => !tool._meta?.visibility?.includes("app") && !tool._meta?.ui?.visibility?.includes("app") ) .map((tool) => tool.name) .sort(); } /** Connects the SDK's own client, which keeps the Mcp-Session-Id as every Streamable HTTP client must. */ async function connectClient(port: number, name: string, options: ClientOptions = {}, query = "") { const client = new Client({ name, version: "1.0.0" }, options); const transport = new StreamableHTTPClientTransport( new URL(`http://localhost:${port}/${ENDPOINT_MCP}${query}`), { requestInit: { headers: { "tomtom-api-key": TEST_API_KEY } } } ); await client.connect(transport); const { tools } = await client.listTools(); const sessionId = transport.sessionId; await client.close(); return { tools, sessionId }; } const MAP_TOOLS = ["tomtom-data-viz", "tomtom-dynamic-map"]; const toolNames = (tools: Tool[]) => tools.map((tool) => tool.name); const withShowUi = (tools: Tool[]) => toolNames(tools.filter((tool) => tool.inputSchema.properties?.show_ui)); // As ChatGPT sends it on tool calls that carry no Mcp-Session-Id, seen in prod gateway logs const CHATGPT_USER_AGENT = "openai-mcp/1.0.0"; const sessionPrefix = (apps: string, client: string) => new RegExp(`^${apps}\\.${Buffer.from(client).toString("base64url")}\\.`); describe("HTTP Server Integration", () => { let serverResult: HttpServerResult; const TEST_PORT = 3998; beforeAll(async () => { serverResult = await createHttpServer({ port: TEST_PORT }); }); afterAll(async () => { // Small delay to ensure SSE responses complete before shutdown await delay(50); await serverResult.shutdown(); }); // Small delay between tests to prevent SSE stream overlap issues beforeEach(async () => { await delay(100); }); it("health endpoint reports status and version", async () => { const health = await getHealth(TEST_PORT); expect(health.status).toBe("ok"); expect(health.version).toBeTruthy(); }); it("serves a non-empty tool list", async () => { expect(publicToolNames(await listTools(TEST_PORT)).length).toBeGreaterThan(0); }); it("gives a client that advertises MCP Apps every tool and a session naming it", async () => { const { tools, sessionId } = await connectClient(TEST_PORT, "claude-ai", { capabilities: { extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } } }, }); expect(sessionId).toMatch(sessionPrefix("apps", "claude-ai")); expect(toolNames(tools)).toEqual(expect.arrayContaining(MAP_TOOLS)); expect(withShowUi(tools)).toContain("tomtom-routing"); }); it("gives an unknown client without the extension every tool and a session naming it", async () => { const { tools, sessionId } = await connectClient(TEST_PORT, "mcp"); expect(sessionId).toMatch(sessionPrefix("unknown", "mcp")); expect(toolNames(tools)).toEqual(expect.arrayContaining(MAP_TOOLS)); }); it("keeps a known text-only client's tool list text-only through its session", async () => { const { tools, sessionId } = await connectClient(TEST_PORT, "claude-code"); expect(sessionId).toMatch(sessionPrefix("text-only", "claude-code")); expect(toolNames(tools)).toContain("tomtom-routing"); expect(toolNames(tools).filter((name) => MAP_TOOLS.includes(name))).toEqual([]); expect(withShowUi(tools)).toEqual([]); }); it("hides the app parts from any client that asks with ?apps=false, and its session keeps that choice", async () => { const { tools, sessionId } = await connectClient(TEST_PORT, "mcp", {}, "?apps=false"); expect(sessionId).toMatch(sessionPrefix("text-only", "mcp")); expect(toolNames(tools)).toContain("tomtom-routing"); expect(toolNames(tools).filter((name) => MAP_TOOLS.includes(name))).toEqual([]); expect(withShowUi(tools)).toEqual([]); }); it("gives a known text-only client every tool when it asks with ?apps=true", async () => { const { tools, sessionId } = await connectClient(TEST_PORT, "claude-code", {}, "?apps=true"); expect(sessionId).toMatch(sessionPrefix("apps", "claude-code")); expect(toolNames(tools)).toEqual(expect.arrayContaining(MAP_TOOLS)); expect(withShowUi(tools)).toContain("tomtom-routing"); }); it("answers DELETE on the MCP endpoint with 405, as it keeps no session to end", async () => { const response = await fetch(`http://localhost:${TEST_PORT}/${ENDPOINT_MCP}`, { method: "DELETE", }); expect(response.status).toBe(405); }); it("returns TomTom-Upstream-Metadata response header with base64-encoded auth type for api key", async () => { const response = await postMcpListTools({ port: TEST_PORT, extraHeaders: { "User-Agent": CHATGPT_USER_AGENT }, }); const header = response.headers.get("tomtom-upstream-metadata"); expect(header).toBeDefined(); const decoded = JSON.parse(Buffer.from(header!, "base64").toString()); expect(decoded).toEqual({ auth_method: "tomtom-api-key", client: CHATGPT_USER_AGENT }); }); it("names the client in TomTom-Upstream-Metadata on the initialize response itself", async () => { const response = await postMcp( TEST_PORT, "initialize", { protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "openai-mcp (Codex)", version: "1.0.0" }, }, "identity" ); await response.text(); const header = response.headers.get("tomtom-upstream-metadata"); const decoded = JSON.parse(Buffer.from(header!, "base64").toString()); expect(decoded).toEqual({ auth_method: "tomtom-api-key", client: "openai-mcp (Codex)" }); expect(response.headers.get("mcp-session-id")).toMatch( sessionPrefix("unknown", "openai-mcp (Codex)") ); }); it("names the client in TomTom-Upstream-Metadata on a request that carries its session", async () => { const { sessionId } = await connectClient(TEST_PORT, "claude-code"); const response = await postMcpListTools({ port: TEST_PORT, extraHeaders: { "mcp-session-id": sessionId! }, }); const header = response.headers.get("tomtom-upstream-metadata"); const decoded = JSON.parse(Buffer.from(header!, "base64").toString()); expect(decoded).toEqual({ auth_method: "tomtom-api-key", client: "claude-code" }); }); it("logs the client's name when it initializes", async () => { const info = vi.spyOn(logger, "info"); await connectClient(TEST_PORT, "claude-code"); expect(info).toHaveBeenCalledWith( expect.objectContaining({ client: { name: "claude-code", version: "1.0.0" }, apps: "text-only", appsChosen: false, }), "Client initialized" ); info.mockRestore(); }); it("logs what it read back from the session on every request", async () => { const debug = vi.spyOn(logger, "debug"); const { sessionId } = await connectClient(TEST_PORT, "claude-code"); const response = await postMcpListTools({ port: TEST_PORT, extraHeaders: { "mcp-session-id": sessionId! }, }); await response.text(); expect(debug).toHaveBeenCalledWith( expect.objectContaining({ clientName: "claude-code", apps: "text-only" }), "Processing MCP request" ); debug.mockRestore(); }); it("warns that the tomtom-maps-backend header is ignored", async () => { const warn = vi.spyOn(logger, "warn"); const response = await postMcpListTools({ port: TEST_PORT, extraHeaders: { "tomtom-maps-backend": "tomtom-maps" }, }); await response.text(); expect(warn).toHaveBeenCalledWith( expect.objectContaining({ "tomtom-maps-backend": "tomtom-maps" }), "The tomtom-maps-backend header is no longer read; all tools use the TomTom Orbis Maps APIs" ); warn.mockRestore(); }); describe("compression", () => { const APP_URI = "ui://tomtom-map/places-and-routes/app.html"; it("gzips app templates for a client that accepts gzip", async () => { const response = await postMcp(TEST_PORT, "resources/read", { uri: APP_URI }, "gzip"); expect(response.headers.get("content-encoding")).toBe("gzip"); expect(response.headers.get("content-type")).toContain("application/json"); const body = (await response.json()) as { result: { contents: Array<{ text: string }> } }; expect(body.result.contents[0].text).toBe(APP_HTML); }); it("sends app templates uncompressed to a client that does not accept gzip", async () => { const response = await postMcp(TEST_PORT, "resources/read", { uri: APP_URI }, "identity"); await response.text(); expect(response.headers.get("content-encoding")).toBeNull(); }); it("never compresses other methods, whose results can hold secrets", async () => { const response = await postMcp(TEST_PORT, "tools/list", {}, "gzip"); await response.text(); expect(response.headers.get("content-encoding")).toBeNull(); }); }); }); describe("every TomTom call over HTTP names the client in its user agent", () => { const PORT = 3997; let server: HttpServerResult; beforeAll(async () => { server = await createHttpServer({ port: PORT }); }); afterAll(async () => { await server.shutdown(); }); afterEach(() => { vi.unstubAllGlobals(); }); async function callToolOverHttp(clientName: string, tool: string, args: Record) { // Without MCP Apps a known text-only client like claude-code gets no map tools const client = new Client( { name: clientName, version: "1.0.0" }, { capabilities: { extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } } } } ); await client.connect( new StreamableHTTPClientTransport(new URL(`http://localhost:${PORT}/${ENDPOINT_MCP}`), { requestInit: { headers: { "tomtom-api-key": TEST_API_KEY } }, }) ); const result = await client.callTool({ name: tool, arguments: args }); await client.close(); return result; } async function callToolWithSessionId( sessionId: string | undefined, tool: string, args: Record ) { const response = await fetch(`http://localhost:${PORT}/${ENDPOINT_MCP}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json,text/event-stream", "User-Agent": CHATGPT_USER_AGENT, "tomtom-api-key": TEST_API_KEY, ...(sessionId !== undefined && { "mcp-session-id": sessionId }), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "tools/call", params: { name: tool, arguments: args }, }), }); await response.text(); } function expectUserAgents(requests: RecordedRequest[], tool: string, userAgentToken: string) { if (!CACHED_AFTER_FIRST_CALL.has(tool)) expect(requests.length).toBeGreaterThan(0); for (const request of requests) { expect(request.headers.get("tomtom-user-agent")).toBe( `TomTomMCPSDKHttp/${VERSION} ${userAgentToken}` ); } } describe.each([...AI_CLIENTS, ...CLIENTS_WITHOUT_VALID_NAME, ...CLIENTS_AT_NAME_LIMIT])( "client $name", ({ name, userAgentToken }) => { it.each(TOOL_CALLS_REACHING_TOMTOM)("%s", async (tool, args) => { const requests = recordFetch(cannedApiResponse); await callToolOverHttp(name, tool, args); expectUserAgents(requests, tool, userAgentToken); }); } ); it.each([...AI_CLIENTS, ...CLIENTS_WITHOUT_VALID_NAME, ...CLIENTS_AT_NAME_LIMIT])( "names client $name in the MCP App user agent of tomtom-get-app-config", async ({ name, userAgentToken }) => { const result = await callToolOverHttp(name, "tomtom-get-app-config", {}); const [content] = result.content as { text: string }[]; expect(JSON.parse(content.text)).toEqual({ userAgent: `TomTomMCPAPPHttp/${VERSION} ${userAgentToken}`, }); } ); describe("a request without a session id", () => { it.each(TOOL_CALLS_REACHING_TOMTOM)( "%s names the client by its User-Agent", async (tool, args) => { const requests = recordFetch(cannedApiResponse); await callToolWithSessionId(undefined, tool, args); expectUserAgents(requests, tool, encodeURIComponent(CHATGPT_USER_AGENT)); } ); }); describe("a request with a session id this server never minted", () => { it.each(TOOL_CALLS_REACHING_TOMTOM)("%s names no client", async (tool, args) => { const requests = recordFetch(cannedApiResponse); await callToolWithSessionId("not-a-session", tool, args); expectUserAgents(requests, tool, "unknown"); }); }); it("keeps the clients of sessions calling at once apart", async () => { const requests = recordFetch(cannedApiResponse); await Promise.all( AI_CLIENTS.map(({ name }) => callToolOverHttp(name, "tomtom-routing", API_TOOLS["tomtom-routing"]) ) ); const userAgentTokens = requests.map( (request) => request.headers.get("tomtom-user-agent")?.split(" ")[1] ); expect(userAgentTokens.sort()).toEqual( AI_CLIENTS.map(({ userAgentToken }) => userAgentToken).sort() ); }); }); describe("every hop of an HTTP session names the same client", () => { const PORT = 3996; let server: HttpServerResult; beforeAll(async () => { server = await createHttpServer({ port: PORT }); }); afterAll(async () => { await server.shutdown(); }); afterEach(() => { vi.unstubAllGlobals(); }); async function postMcpInSession( sessionId: string | undefined, method: string, params: Record ) { const response = await fetch(`http://localhost:${PORT}/${ENDPOINT_MCP}`, { method: "POST", headers: { "Content-Type": "application/json", Accept: "application/json,text/event-stream", "User-Agent": CHATGPT_USER_AGENT, "tomtom-api-key": TEST_API_KEY, ...(sessionId !== undefined && { "mcp-session-id": sessionId }), }, body: JSON.stringify({ jsonrpc: "2.0", id: 1, method, params }), }); return { response, body: await response.text() }; } const clientInUpstreamMetadata = (response: Response): string => JSON.parse( Buffer.from(response.headers.get("tomtom-upstream-metadata") ?? "", "base64").toString() ).client; const clientInUserAgent = (userAgent: string | null | undefined): string => decodeURIComponent(userAgent?.split(" ")[1] ?? ""); it.each([...AI_CLIENTS, ...CLIENTS_WITHOUT_VALID_NAME, ...CLIENTS_AT_NAME_LIMIT])( "client $name", async ({ name, userAgentToken }) => { const requests = recordFetch(cannedApiResponse); const initialize = await postMcpInSession(undefined, "initialize", { protocolVersion: "2025-06-18", capabilities: { extensions: { [EXTENSION_ID]: { mimeTypes: [RESOURCE_MIME_TYPE] } } }, clientInfo: { name, version: "1.0.0" }, }); const sessionId = initialize.response.headers.get("mcp-session-id") ?? undefined; const routing = await postMcpInSession(sessionId, "tools/call", { name: "tomtom-routing", arguments: API_TOOLS["tomtom-routing"], }); const appConfig = await postMcpInSession(sessionId, "tools/call", { name: "tomtom-get-app-config", arguments: {}, }); const { result } = parseSSEResponse<{ result: { content: { text: string }[] } }>( appConfig.body ); const clientPerHop = { initialize: clientInUpstreamMetadata(initialize.response), toolCall: clientInUpstreamMetadata(routing.response), serverToTomTom: clientInUserAgent(requests[0]?.headers.get("tomtom-user-agent")), appToTomTom: clientInUserAgent(JSON.parse(result.content[0].text).userAgent), }; const cleanedName = decodeURIComponent(userAgentToken); expect(clientPerHop).toEqual({ initialize: cleanedName, toolCall: cleanedName, serverToTomTom: cleanedName, appToTomTom: cleanedName, }); } ); it("names a client that never sends its session id by its User-Agent on every hop", async () => { const requests = recordFetch(cannedApiResponse); const routing = await postMcpInSession(undefined, "tools/call", { name: "tomtom-routing", arguments: API_TOOLS["tomtom-routing"], }); const appConfig = await postMcpInSession(undefined, "tools/call", { name: "tomtom-get-app-config", arguments: {}, }); const { result } = parseSSEResponse<{ result: { content: { text: string }[] } }>( appConfig.body ); expect({ toolCall: clientInUpstreamMetadata(routing.response), serverToTomTom: clientInUserAgent(requests[0]?.headers.get("tomtom-user-agent")), appToTomTom: clientInUserAgent(JSON.parse(result.content[0].text).userAgent), }).toEqual({ toolCall: CHATGPT_USER_AGENT, serverToTomTom: CHATGPT_USER_AGENT, appToTomTom: CHATGPT_USER_AGENT, }); }); }); describe("needsApiKey", () => { it("is false for methods that never call the TomTom API, and notifications", () => { for (const method of ["initialize", "tools/list", "resources/list", "resources/read", "ping"]) { expect(needsApiKey({ jsonrpc: "2.0", id: 1, method })).toBe(false); } expect(needsApiKey({ jsonrpc: "2.0", method: "notifications/initialized" })).toBe(false); }); it("is true for a tool call and any method it does not know", () => { expect(needsApiKey({ jsonrpc: "2.0", id: 1, method: "tools/call" })).toBe(true); expect(needsApiKey({ jsonrpc: "2.0", id: 1, method: "completion/complete" })).toBe(true); }); it("is true for a batch with one message that needs it, a response or an empty body", () => { expect( needsApiKey([ { jsonrpc: "2.0", id: 1, method: "tools/list" }, { jsonrpc: "2.0", id: 2, method: "tools/call" }, ]) ).toBe(true); expect(needsApiKey({ jsonrpc: "2.0", id: 1, result: {} })).toBe(true); expect(needsApiKey([])).toBe(true); expect(needsApiKey(undefined)).toBe(true); }); });