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
3 changes: 3 additions & 0 deletions app/api/mcp/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
89 changes: 89 additions & 0 deletions lib/mcp/annotations.ts
Original file line number Diff line number Diff line change
@@ -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<string, ToolHints> = {
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<string, unknown>, 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);
};
}
64 changes: 64 additions & 0 deletions tests/mcp-annotations.test.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});