/*
* 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);
});
});