/* * Copyright (C) 2026 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 type { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { connectInMemory } from "../inMemoryClient"; import { cannedApiResponse } from "../services/shared/cannedApiResponses"; import { type RecordedRequest, recordFetch } from "../services/shared/recordFetch"; import { AMSTERDAM, API_TOOLS, NOT_API_TOOLS, type ToolArguments } from "./toolBaselines"; // Every input a tool advertises must change the request sent to the TomTom // API. A schema key the service never maps compiles fine and is dropped // silently, so this test calls each tool through the real server, once with a // baseline and once with one input added, and compares the recorded requests. /** A value for each input, by name; TOOL_SAMPLES overrides it for one tool. */ const SAMPLES: ToolArguments = { query: "Tesla", origin: [4.95, 52.35], destination: [5.0, 52.2], locations: [ [4.95, 52.35], [5.0, 52.2], ], bbox: [4.85, 52.32, 4.95, 52.38], limit: 3, language: "nl-NL", countries: ["BE"], view: "IN", extendedPostalCodesFor: "PAD", mapcodes: ["Local"], timeZone: "iana", position: [4.95, 52.35], radius: 2500, boundingBox: [4.8, 52.3, 5.0, 52.4], brandSet: "Shell", connectorSet: "IEC62196Type2CCS", connectorTypes: ["IEC62196Type2CCS"], fuelSet: "Diesel", minPowerKW: 50, maxPowerKW: 150, openingHours: "nextSevenDays", typeahead: true, maxFuzzyLevel: 3, minFuzzyLevel: 2, entityTypeSet: "Municipality", entityType: "Municipality", // The nextCursor the SDK mints for the results after the first 10 cursor: btoa(JSON.stringify({ ofs: 10 })), idxSet: "POI", relatedPois: "all", poiCategories: ["RESTAURANT"], heading: 90, filters: ["CAFE"], center: [4.95, 52.35], polygon: [ [4.88, 52.36], [4.92, 52.36], [4.92, 52.38], [4.88, 52.38], ], corridorWidth: 500, routeType: "short", travelMode: "car", traffic: "historical", avoid: ["tollRoads"], departAt: "2030-01-01T08:00:00Z", arriveAt: "2030-01-01T08:00:00Z", maxAlternatives: 2, sectionType: ["toll"], vehicleMaxSpeed: 90, vehicleWeight: 2000, vehicleHeading: 90, categoryFilter: ["accident", "road-closed"], timeValidityFilter: ["future"], maxResults: 5, }; /** * Inputs that only make sense with others. `with` goes into both calls, `drop` * and `extra` change only the call with the input (another geometry), and * `value` replaces the sample. */ interface Companion { with?: ToolArguments; drop?: string[]; extra?: ToolArguments; value?: unknown; } const COMPANIONS: Record = { "tomtom-area-search.polygon": { drop: ["center", "radius"] }, "tomtom-area-search.boundingBox": { drop: ["center", "radius"], value: [ [4.8, 52.4], [5.0, 52.3], ], }, "tomtom-area-search.radius": { value: 2500 }, "tomtom-geocode.radius": { with: { position: AMSTERDAM } }, "tomtom-fuzzy-search.radius": { with: { position: AMSTERDAM } }, "tomtom-poi-search.radius": { with: { position: AMSTERDAM } }, }; /** Inputs the handler consumes itself: they shape the tool result, not the API request. */ const HANDLER_INPUTS = new Set(["show_ui", "response_detail"]); /** * Inputs that do not change the request, each with the reason. Keep this list * short: an input that reaches no API belongs in no tool schema. */ const NOT_SENT: Record = { "tomtom-poi-categories.filters": "filters the downloaded category list", "tomtom-traffic.maxResults": "the handler caps the incidents it returns", }; let client: Client; let tools: { name: string; inputSchema: { properties?: Record } }[]; beforeAll(async () => { client = await connectInMemory("inputs-test"); tools = (await client.listTools()).tools; }); afterAll(async () => { await client.close(); }); afterEach(() => { vi.unstubAllGlobals(); }); async function requestsFor( tool: string, args: ToolArguments ): Promise<{ requests: string[]; error?: string }> { const recorded: RecordedRequest[] = recordFetch(cannedApiResponse); const result = await client.callTool({ name: tool, arguments: args }); vi.unstubAllGlobals(); const text = (result.content as { type: string; text?: string }[]) .map((c) => c.text ?? "") .join(""); // Routing names the response sections it wants in the Attributes header. const requests = recorded.map(({ url, headers, body }) => { url.searchParams.delete("key"); headers.delete("tomtom-api-key"); const query = [...url.searchParams].sort().join("&"); return `${url.pathname}?${query} ${[...headers].sort().join("&")} ${body}`; }); return { requests, ...(requests.length === 0 && { error: text.slice(0, 300) }) }; } function argsFor(tool: string, input: string): { base: ToolArguments; variant: ToolArguments } { const companion = COMPANIONS[`${tool}.${input}`] ?? {}; const base: ToolArguments = { ...API_TOOLS[tool], ...companion.with }; const variant: ToolArguments = { ...base, ...companion.extra }; for (const key of companion.drop ?? []) delete variant[key]; variant[input] = "value" in companion ? companion.value : SAMPLES[input]; const defined = (args: ToolArguments) => Object.fromEntries(Object.entries(args).filter(([, v]) => v !== undefined)); return { base: defined(base), variant: defined(variant) }; } /** Why the input fails to reach the API, or undefined when it does (or needs no check). */ async function checkInput(tool: string, input: string): Promise { const id = `${tool}.${input}`; if (HANDLER_INPUTS.has(input)) return undefined; if (!(input in SAMPLES) && !(id in COMPANIONS)) return `${id}: no sample value`; const { base, variant } = argsFor(tool, input); const before = await requestsFor(tool, base); if (before.error) return `${id}: baseline sent no request: ${before.error}`; const after = await requestsFor(tool, variant); const changed = JSON.stringify(before.requests) !== JSON.stringify(after.requests); // A cached download sends nothing the second time, which also means not sent. if (id in NOT_SENT) { return changed && !after.error ? `${id}: listed in NOT_SENT, but sent` : undefined; } if (after.error) return `${id}: rejected: ${after.error}`; return changed ? undefined : `${id}: not sent`; } describe("tool inputs reach the API", () => { it("covers every tool", () => { const names = tools.map((t) => t.name).filter((name) => !NOT_API_TOOLS.has(name)); expect(names.sort()).toEqual(Object.keys(API_TOOLS).sort()); }); it("lists only inputs a tool advertises", () => { const inputs = tools.flatMap((t) => Object.keys(t.inputSchema.properties ?? {}).map((input) => `${t.name}.${input}`) ); const names = new Set(inputs.map((id) => id.split(".")[1])); const stale = [ ...Object.keys(SAMPLES).filter((input) => !names.has(input)), ...[...Object.keys(COMPANIONS), ...Object.keys(NOT_SENT)].filter( (id) => !inputs.includes(id) ), ]; expect(stale).toEqual([]); }); it("changes the request for every input", async () => { const problems: string[] = []; for (const tool of tools.filter((t) => !NOT_API_TOOLS.has(t.name))) { for (const input of Object.keys(tool.inputSchema.properties ?? {})) { const problem = await checkInput(tool.name, input); if (problem) problems.push(problem); } } expect(problems.join("\n")).toBe(""); }, 120_000); });