Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/lib/annotations.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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<string, unknown> {
return {
securitySchemes: [{ type: "oauth2", scopes: oauth.scopes }],
};
}
11 changes: 11 additions & 0 deletions src/lib/tool-result.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>,
): CallToolResult {
return { ...toolResult(value), structuredContent: value };
}
73 changes: 73 additions & 0 deletions src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -40,7 +47,9 @@ describe("cloro MCP server", () => {
name: string;
title?: string;
inputSchema: ToolSchema;
outputSchema?: ToolSchema;
annotations?: ToolAnnotations;
_meta?: { securitySchemes?: unknown };
}>;

beforeAll(async () => {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -234,4 +306,5 @@ describe("cloro MCP server", () => {
"INSUFFICIENT_CREDITS",
);
});

});
3 changes: 2 additions & 1 deletion src/tools/monitor-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -209,6 +209,7 @@ export function registerMonitorTools(
description: tool.description,
inputSchema: tool.inputSchema,
annotations: SCRAPE_ANNOTATIONS,
_meta: toolMeta(),
},
(args: Record<string, unknown>) =>
observeToolCall(tool.name, async () => {
Expand Down
27 changes: 23 additions & 4 deletions src/tools/reference-tools.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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,
Expand All @@ -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[] });
}),
);

Expand All @@ -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 }>,
});
}),
);
}
Loading