From 3ff448a3e345eef5b5d305f44f0d6dcd6789f8f8 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Wed, 11 Nov 2026 22:01:41 +0000 Subject: [PATCH] feat(mcp): human titles + tool annotations on all 51 tools Claude's Connectors Directory requires a title and readOnlyHint / destructiveHint / openWorldHint on every tool. lib/mcp/annotations.ts holds one entry per tool (plus idempotentHint) and withToolHints() merges it into every registerTool config before the modules register, so the SDK's own tools/list mapper emits title + annotations. A tool with no entry throws at startup. Names, schemas and behaviour are unchanged; the tools/list cache-field wrapper still spreads the SDK result. Posting, sending outreach, publishing articles, spending credits or money, deletes and overwrites are destructive; reads are readOnly; anything reaching a third-party site, network, mailbox, AI engine or webhook receiver is openWorld. Test: tests/mcp-annotations.test.ts lists tools through the real SDK client and asserts all 51 carry a title + all four hints. Co-Authored-By: Claude Opus 5.5 --- app/api/mcp/route.ts | 3 ++ lib/mcp/annotations.ts | 89 +++++++++++++++++++++++++++++++++++ tests/mcp-annotations.test.ts | 64 +++++++++++++++++++++++++ 3 files changed, 156 insertions(+) create mode 100644 lib/mcp/annotations.ts create mode 100644 tests/mcp-annotations.test.ts diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 016d152..848e6a5 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -24,12 +24,15 @@ import { registerActorTools } from "@/lib/mcp/actors"; import { registerCreditTools } from "@/lib/mcp/credits"; import { registerWebhookTools } from "@/lib/mcp/webhooks"; import { registerTrendTools } from "@/lib/mcp/trends"; +import { withToolHints } from "@/lib/mcp/annotations"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const handler = createMcpHandler( (server) => { + // Title + readOnly/destructive/idempotent/openWorld hints on every tool. + withToolHints(server); registerPromoteTools(server); registerStatsTools(server); registerAuditTools(server); diff --git a/lib/mcp/annotations.ts b/lib/mcp/annotations.ts new file mode 100644 index 0000000..978ea56 --- /dev/null +++ b/lib/mcp/annotations.ts @@ -0,0 +1,89 @@ +// A human title and behaviour hints (MCP tool annotations) for every CrawlProof +// MCP tool. Connector directories, Claude's among them, require both on every +// tool, and a client uses the hints to decide what to confirm with the person. +// +// Rules: reads are readOnly and never destructive. Anything that deletes, +// overwrites, publishes or sends where it cannot be taken back (a social post, +// an email, an article) or spends money or credits is destructive. openWorld +// means the tool reaches outside CrawlProof's own data (a third-party site, +// a social network, a mailbox, an AI engine, a webhook receiver). +// +// withToolHints() is applied before any module registers, so a tool added +// without an entry here fails at startup instead of shipping unannotated. + +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ToolAnnotations } from "@modelcontextprotocol/sdk/types.js"; + +export interface ToolHints { + title: string; + readOnlyHint: boolean; + destructiveHint: boolean; + idempotentHint: boolean; + openWorldHint: boolean; +} + +export const TOOL_HINTS: Record = { + list_actors: { title: "List declared actors", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + add_actor: { title: "Add declared actor", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + mint_actor_token: { title: "Mint actor token", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + affiliate_link: { title: "Affiliate link and balances", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + affiliate_ledger: { title: "Affiliate ledger", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + affiliate_set_payout_address: { title: "Set affiliate payout address", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + affiliate_set_webhook: { title: "Set affiliate webhook", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + affiliate_token: { title: "Issue affiliate token", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + affiliate_payout: { title: "Pay out affiliate balance", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + affiliate_programs: { title: "Affiliate program directory", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + affiliate_join: { title: "Join affiliate program", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + affiliate_joined: { title: "Joined affiliate programs", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + start_audit: { title: "Start AEO audit", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + get_audit: { title: "Get audit result", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + autoblog_sites: { title: "List autoblog sites", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + autoblog_post: { title: "Queue autoblog article", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + autoblog_guest_post: { title: "Request guest post", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + autoblog_link_exchange: { title: "Find link-exchange matches", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + autoblog_articles: { title: "List autoblog articles", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + traffic: { title: "Site traffic", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + redeem_credit_code: { title: "Redeem credit code", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + list_invites: { title: "List invites", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + create_invite: { title: "Create invite", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + find_leads: { title: "Find leads", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true }, + research_lead: { title: "Research lead", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + draft_message: { title: "Draft outreach message", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + send_message: { title: "Send outreach message", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + leads: { title: "Lead pipeline", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + suppress: { title: "Add to do-not-contact list", readOnlyHint: false, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + list_accounts: { title: "List social accounts", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + generate_promo_post: { title: "Generate promo post", readOnlyHint: true, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + post_to_socials: { title: "Post to socials", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + promote_url: { title: "Promote URL on socials", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true }, + promote_list_campaigns: { title: "List Promote campaigns", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + promote_add_keyword_source: { title: "Add keyword sources", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + promote_add_feed_source: { title: "Add feed source", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + promote_list_sources: { title: "List campaign sources", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + list_projects: { title: "List projects", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + recent_audits: { title: "Recent audits", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + ad_earnings: { title: "Ad earnings summary", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + promote_status: { title: "Promote post status", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + video_ad_funnel: { title: "Video ad funnel", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + list_trending_topics: { title: "Trending topics", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + list_webhooks: { title: "List webhooks", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + create_webhook: { title: "Create webhook", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false }, + update_webhook: { title: "Update webhook", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + delete_webhook: { title: "Delete webhook", readOnlyHint: false, destructiveHint: true, idempotentHint: true, openWorldHint: false }, + test_webhook: { title: "Send test webhook", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, + rotate_webhook_secret: { title: "Rotate webhook secret", readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: false }, + webhook_deliveries: { title: "Webhook deliveries", readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }, + retry_webhook_delivery: { title: "Retry webhook delivery", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true }, +}; + +/** Make every later registerTool call carry its title and annotations from TOOL_HINTS. */ +export function withToolHints(server: McpServer): void { + const register = server.registerTool.bind(server) as (name: string, config: Record, cb: unknown) => unknown; + (server as unknown as { registerTool: typeof register }).registerTool = (name, config, cb) => { + const hints = TOOL_HINTS[name]; + if (!hints) throw new Error(`CrawlProof MCP tool ${name} has no title/annotations in lib/mcp/annotations.ts`); + const { title, ...rest } = hints; + const annotations: ToolAnnotations = { title, ...rest }; + return register(name, { ...config, title, annotations }, cb); + }; +} diff --git a/tests/mcp-annotations.test.ts b/tests/mcp-annotations.test.ts new file mode 100644 index 0000000..a0ad850 --- /dev/null +++ b/tests/mcp-annotations.test.ts @@ -0,0 +1,64 @@ +// Every CrawlProof MCP tool reaches tools/list with a human title and all of +// the behaviour hints. Connector directories reject a server where any tool +// lacks them. Goes through the real SDK server and client, because the SDK's +// own tools/list mapper is what decides whether title/annotations survive. +import { describe, expect, it } from "vitest"; +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { TOOL_HINTS, withToolHints } from "@/lib/mcp/annotations"; +import { registerPromoteTools } from "@/lib/mcp/promote"; +import { registerStatsTools } from "@/lib/mcp/stats"; +import { registerAuditTools } from "@/lib/mcp/audits"; +import { registerLeadTools } from "@/lib/mcp/leads"; +import { registerAutoblogTools } from "@/lib/mcp/autoblog"; +import { registerAffiliateTools } from "@/lib/mcp/affiliate"; +import { registerActorTools } from "@/lib/mcp/actors"; +import { registerCreditTools } from "@/lib/mcp/credits"; +import { registerWebhookTools } from "@/lib/mcp/webhooks"; +import { registerTrendTools } from "@/lib/mcp/trends"; + +async function listTools() { + const server = new McpServer({ name: "crawlproof-test", version: "0" }); + withToolHints(server); + for (const register of [ + registerPromoteTools, registerStatsTools, registerAuditTools, registerLeadTools, registerAutoblogTools, + registerAffiliateTools, registerActorTools, registerCreditTools, registerWebhookTools, registerTrendTools, + ]) register(server); + const [a, b] = InMemoryTransport.createLinkedPair(); + await server.connect(a); + const client = new Client({ name: "t", version: "0" }); + await client.connect(b); + const { tools } = await client.listTools(); + await client.close(); + return tools; +} + +describe("MCP tool annotations", () => { + it("every listed tool has a title and readOnly/destructive/idempotent/openWorld hints", async () => { + const tools = await listTools(); + expect(tools.length).toBe(51); + expect(tools.map((t) => t.name).sort()).toEqual(Object.keys(TOOL_HINTS).sort()); + for (const t of tools) { + expect(t.title, t.name).toBeTruthy(); + expect(t.annotations?.title, t.name).toBe(t.title); + for (const k of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] as const) { + expect(typeof t.annotations?.[k], `${t.name}.${k}`).toBe("boolean"); + } + if (t.annotations?.readOnlyHint) expect(t.annotations.destructiveHint, t.name).toBe(false); + } + }); + + it("publishing, sending, spending and deleting tools are destructive", () => { + for (const n of ["post_to_socials", "promote_url", "send_message", "autoblog_post", "affiliate_payout", "delete_webhook"]) { + expect(TOOL_HINTS[n].destructiveHint, n).toBe(true); + expect(TOOL_HINTS[n].readOnlyHint, n).toBe(false); + } + }); + + it("an unannotated tool fails at registration", () => { + const server = new McpServer({ name: "t", version: "0" }); + withToolHints(server); + expect(() => server.registerTool("no_such_tool", { description: "x" }, async () => ({ content: [] }))).toThrow(/no title/); + }); +});