From f0f9fbebfb462fd0f51e411a2a563cc5aedf4a2b Mon Sep 17 00:00:00 2001 From: Ricardo Batista Date: Thu, 1 Oct 2026 18:18:16 +0200 Subject: [PATCH] feat: add securitySchemes and outputSchema to the tools This syncs cloro-dev/backend#392 to the public source. Every tool now declares _meta.securitySchemes with type oauth2 and the scopes of the 401 challenge. ChatGPT reads this value to show its account-linking UI. list_countries and list_states now declare an outputSchema and return structuredContent as { countries } and { states }. The scrape tools do not declare an outputSchema, because nothing validates the scraper output before the server gets it. The domain verification route from cloro-dev/backend#395 is not in this change. It holds the token of the hosted server only. --- src/lib/annotations.ts | 13 +++++++ src/lib/tool-result.ts | 11 ++++++ src/server.test.ts | 73 ++++++++++++++++++++++++++++++++++++ src/tools/monitor-tools.ts | 3 +- src/tools/reference-tools.ts | 27 +++++++++++-- 5 files changed, 122 insertions(+), 5 deletions(-) diff --git a/src/lib/annotations.ts b/src/lib/annotations.ts index ba0c992..d818523 100644 --- a/src/lib/annotations.ts +++ b/src/lib/annotations.ts @@ -1,3 +1,5 @@ +import { oauth } from "../config"; + /** * Shared tool annotation base. Every cloro tool is read-only, so the two * variants in `tools/` extend this rather than restating it. @@ -17,3 +19,14 @@ export const BASE_READ_ONLY_ANNOTATIONS = { readOnlyHint: true, destructiveHint: false, } as const; + +/** + * `securitySchemes` for ChatGPT's account-linking UI. It goes in `_meta` + * because the MCP SDK drops unknown top-level tool fields. No `noauth`: an + * anonymous call gets a 401. The scopes are those of the 401 challenge. + */ +export function toolMeta(): Record { + return { + securitySchemes: [{ type: "oauth2", scopes: oauth.scopes }], + }; +} diff --git a/src/lib/tool-result.ts b/src/lib/tool-result.ts index 2cff714..452255b 100644 --- a/src/lib/tool-result.ts +++ b/src/lib/tool-result.ts @@ -5,3 +5,14 @@ export function toolResult(value: unknown): CallToolResult { content: [{ type: "text", text: JSON.stringify(value) }], }; } + +/** + * For a tool that declares an `outputSchema`. The SDK checks + * `structuredContent` against that schema. The text block carries the same + * JSON for clients that do not read `structuredContent`. + */ +export function structuredToolResult( + value: Record, +): CallToolResult { + return { ...toolResult(value), structuredContent: value }; +} diff --git a/src/server.test.ts b/src/server.test.ts index b5adac0..6e3d1cb 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -2,6 +2,13 @@ import { readFileSync } from "node:fs"; import { Client } from "@modelcontextprotocol/sdk/client/index.js"; import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { + ALL_COUNTRY_CODES, + availableStates, + COUNTRY_MODELS, + MODEL_COUNTRIES, + STATE_COUNTRIES, +} from "./schemas"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; import { buildServer } from "./server"; @@ -40,7 +47,9 @@ describe("cloro MCP server", () => { name: string; title?: string; inputSchema: ToolSchema; + outputSchema?: ToolSchema; annotations?: ToolAnnotations; + _meta?: { securitySchemes?: unknown }; }>; beforeAll(async () => { @@ -82,6 +91,23 @@ describe("cloro MCP server", () => { } }); + it("declares OAuth securitySchemes on every tool", () => { + for (const tool of tools) { + expect(tool._meta?.securitySchemes, tool.name).toEqual([ + { type: "oauth2", scopes: ["profile", "email", "user:org:read"] }, + ]); + } + }); + + // A declared outputSchema makes the SDK fail any call whose result does not + // conform, so only the tools with a fixed response shape declare one. + it("declares an outputSchema only on the reference tools", () => { + for (const tool of tools) { + const isScrape = tool.name.startsWith("scrape_"); + expect(Boolean(tool.outputSchema), tool.name).toBe(!isScrape); + } + }); + // The third copy of the version. server-json.test.ts pins server.json // against package.json; this pins the running server against server.json, // so all three move together or the suite fails. @@ -205,6 +231,52 @@ describe("cloro MCP server", () => { } }); + // Feeds each tool the exact response the API sends for every input it + // accepts. The SDK checks structuredContent against the outputSchema on the + // server and again on the client, so a mismatch rejects the call. + it("returns reference results that conform to their outputSchema", async () => { + const cases = [ + { + name: "list_countries", + args: {}, + body: ALL_COUNTRY_CODES, + expected: { countries: ALL_COUNTRY_CODES }, + }, + ...COUNTRY_MODELS.map((model) => ({ + name: "list_countries", + args: { model }, + body: MODEL_COUNTRIES[model], + expected: { countries: MODEL_COUNTRIES[model] }, + })), + ...STATE_COUNTRIES.map((country) => ({ + name: "list_states", + args: { country }, + body: availableStates(country), + expected: { states: availableStates(country) }, + })), + ]; + + for (const { name, args, body, expected } of cases) { + vi.stubGlobal( + "fetch", + vi.fn(() => + Promise.resolve(new Response(JSON.stringify(body), { status: 200 })), + ), + ); + + const result = (await client.callTool({ + name, + arguments: args, + })) as CallToolResult; + + expect(result.isError, `${name} ${JSON.stringify(args)}`).toBeFalsy(); + expect(result.structuredContent).toEqual(expected); + expect(JSON.parse((result.content[0] as { text: string }).text)).toEqual( + expected, + ); + } + }); + it("returns API errors as tool errors with the envelope details", async () => { vi.stubGlobal( "fetch", @@ -234,4 +306,5 @@ describe("cloro MCP server", () => { "INSUFFICIENT_CREDITS", ); }); + }); diff --git a/src/tools/monitor-tools.ts b/src/tools/monitor-tools.ts index c098a9f..60c7a8f 100644 --- a/src/tools/monitor-tools.ts +++ b/src/tools/monitor-tools.ts @@ -10,7 +10,7 @@ import { } from "../schemas"; import { z } from "zod"; -import { BASE_READ_ONLY_ANNOTATIONS } from "../lib/annotations"; +import { BASE_READ_ONLY_ANNOTATIONS, toolMeta } from "../lib/annotations"; import { describeFields } from "../lib/describe-fields"; import { observeToolCall } from "../lib/metrics"; import { toolResult } from "../lib/tool-result"; @@ -209,6 +209,7 @@ export function registerMonitorTools( description: tool.description, inputSchema: tool.inputSchema, annotations: SCRAPE_ANNOTATIONS, + _meta: toolMeta(), }, (args: Record) => observeToolCall(tool.name, async () => { diff --git a/src/tools/reference-tools.ts b/src/tools/reference-tools.ts index a2a5ccc..c931fac 100644 --- a/src/tools/reference-tools.ts +++ b/src/tools/reference-tools.ts @@ -1,9 +1,9 @@ import { COUNTRY_MODELS, STATE_COUNTRIES } from "../schemas"; import { z } from "zod"; -import { BASE_READ_ONLY_ANNOTATIONS } from "../lib/annotations"; +import { BASE_READ_ONLY_ANNOTATIONS, toolMeta } from "../lib/annotations"; import { observeToolCall } from "../lib/metrics"; -import { toolResult } from "../lib/tool-result"; +import { structuredToolResult } from "../lib/tool-result"; import type { CloroClient } from "../lib/cloro-client"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -16,6 +16,19 @@ const REFERENCE_ANNOTATIONS = { openWorldHint: false, } as const; +// structuredContent must be an object, and the API returns a bare array, so +// the result wraps the array in one key. The item schemas stay loose +// (string, not an enum of codes): a stricter schema than the API response +// makes the SDK fail the call. +const COUNTRIES_OUTPUT = { + countries: z.array(z.string()).describe("ISO 3166-1 alpha-2 country codes."), +}; +const STATES_OUTPUT = { + states: z + .array(z.object({ code: z.string(), name: z.string() })) + .describe("States available for state-level targeting."), +}; + export function registerReferenceTools( server: McpServer, client: CloroClient, @@ -34,14 +47,16 @@ export function registerReferenceTools( "Optionally filter to countries supported by a specific engine.", ), }, + outputSchema: COUNTRIES_OUTPUT, annotations: REFERENCE_ANNOTATIONS, + _meta: toolMeta(), }, ({ model }) => observeToolCall("list_countries", async () => { const { body } = await client.get( model ? `/v1/countries?model=${model}` : "/v1/countries", ); - return toolResult(body); + return structuredToolResult({ countries: body as string[] }); }), ); @@ -58,12 +73,16 @@ export function registerReferenceTools( "ISO 3166-1 alpha-2 country code to list states for. Only countries with state-level targeting are supported.", ), }, + outputSchema: STATES_OUTPUT, annotations: REFERENCE_ANNOTATIONS, + _meta: toolMeta(), }, ({ country }) => observeToolCall("list_states", async () => { const { body } = await client.get(`/v1/states?country=${country}`); - return toolResult(body); + return structuredToolResult({ + states: body as Array<{ code: string; name: string }>, + }); }), ); }