/** * Tool annotations — human-readable titles + read/destructive hints. * * WHY THIS IS ONE TABLE AND NOT 121 INLINE FIELDS * ----------------------------------------------- * ONE reason, and it is worth being honest that it is only one: the * read/destructive split is a *policy* decision — it decides which tools run * without a per-call confirmation prompt in Claude. Policy belongs in one * reviewable table, not scattered across 30 files where a reviewer (ours or * Anthropic's) cannot see the whole picture at once. A directory reviewer * genuinely does need one page. * * An earlier version of this comment also claimed annotations *could not* be * colocated because `McpToolDefinition` comes from the published `@frase/core`. * That was wrong and has been struck: a local * `interface AnnotatedTool extends McpToolDefinition` needs no core bump, exactly * as `WireTool` and `ToWireOptions` already do in `./index.ts`. The cost of the * table is real — the classification sits far from the executor that justifies * it — and is paid down by the verb-derivation check below rather than denied. * * `annotations.test.ts` asserts this table and `ALL_TOOLS` are exactly the same * set (both directions), so adding a tool without classifying it fails CI. It * also re-derives each tool's HTTP verbs from its executor source — following * calls into same-file helpers, because scanning only the executor's own body * was defeatable by moving the mutation one hop away — and fails if anything * mutating is marked read-only. That check fails loudly when an executor * resolves to NO HTTP call at all; it cannot see a mutation reached through a * module the parser does not load. * * HOW TOOLS WERE CLASSIFIED * ------------------------- * Not by name. Every tool's executor was traced to the HTTP verb it actually * issues against the Frase API, which is the same safe-vs-unsafe-method * distinction Anthropic's review criteria use: * * - `read` — GET only (or a dispatcher that only ever reaches GETs). * Nothing in the account changes, AND nothing sensitive is * disclosed (see the delivery-token exception below). * - `write` — POST/PUT/PATCH that ADDS something or computes a result: * creates, async job starts, analyses, reversible state * flips. Nothing existing is overwritten or removed. * - `destructive` — any of: overwrites or removes existing data; has an * irreversible or externally-visible effect (pushing to a * live CMS, invalidating issued tokens, shipping content to * a third-party host); or AUTHORISES future unattended * writes (auto-optimization, resuming a playbook that may * publish). * * WHAT IS AND IS NOT MACHINE-CHECKED: the test derives each executor's HTTP * verbs and fails if anything mutating is marked read-only. That is the edge * with teeth — read-only is what suppresses the confirmation prompt. The * `write` vs `destructive` split is NOT checked by anything: `destructiveHint: * false` is a positive claim that a tool destroys nothing, and only review * catches it being wrong. Treat classifying a new write as a hand-review * obligation, and leave a one-line justification when it is not obvious. * * The primary line between `write` and `destructive` is REVERSIBILITY. * `resolve_alert` flips a flag you can flip back, so it is a `write`. * `regenerate_content` overwrites an article body you cannot get back, so it is * `destructive` even though it is "just" a POST. * * TWO DELIBERATE DEPARTURES FROM "the HTTP verb decides": * * 1. `get_delivery_token` is classified `write`, NOT `read`, even though it is * a plain GET — because it renders a LIVE CMS delivery credential into the * transcript. `readOnlyHint: true` is the maximal auto-run affordance, so * the verb-derived answer would hand a credential out with no confirmation * prompt. The app's own scope model already treats this as privileged (the * route requires `write:sites`, not `read:sites`), and we follow that. * * 2. Tools that merely ENABLE unattended future edits are destructive even * though the flag itself flips back, because flipping it back does not * undo what was auto-applied while it was on. * * A note on what these hints actually do: per the MCP spec they are HINTS. A * client decides its own confirmation policy and is explicitly told not to * trust annotations from untrusted servers. We classify for the behaviour we * want a well-behaved client to be able to choose — we do not control it. */ /** MCP tool annotations, explicit for ChatGPT Apps submission review. */ export interface ToolAnnotations { title: string; readOnlyHint: boolean; destructiveHint: boolean; idempotentHint: boolean; openWorldHint: boolean; } /** * WHAT `openWorldHint` ACTUALLY ASKS — and what this table used to answer. * * The MCP schema's own words (@modelcontextprotocol/sdk, ToolAnnotationsSchema): * * "If true, this tool may INTERACT with an 'open world' of external * entities. If false, the tool's domain of interaction is closed. For * example, the world of a web search tool is open, whereas that of a * memory tool is not." Default: true * * The test is INTERACTION, not MODIFICATION. An earlier version of this file * had only `openWorldWrite` / `openWorldDestructive` constructors, both defined * by their EFFECT ON the outside world ("creates state publicly reachable", * "publishes outside the workspace"). There was no way to express "reads FROM * an open world", so no read-only tool could ever be open-world — and none was: * 0 of 67. `analyze_serp`, whose entire job is querying Google, shipped as * `openWorldHint: false` with a justification arguing that it "does not change * public internet or third-party systems". That claim is true and irrelevant, * which is precisely why it read as an annotation that does not match the * tool's behaviour. * * So open-world is now derived from ONE question, asked per tool in * `EXTERNAL_REACH` below: does calling this tool cause Frase to contact a * system outside the caller's own Frase workspace — synchronously, or by * queueing a job that will? Reading a result Frase already stored is closed, * even when the work that produced it was not: `get_audit` reads a finished * crawl out of our database and contacts nobody. * * TIE-BREAK: unknown resolves to TRUE, matching the schema default. A wrong * `false` is an under-claim that tells a client it is safer than it is; a wrong * `true` only makes a client more cautious. We were rejected for the former. */ export interface ExternalReach { /** The outside system contacted, named for the justification text. */ system: string; /** Whether this call reads from it, writes to it, or queues work that will. */ how: "reads" | "writes" | "queues"; } /** * The tools that reach outside the caller's Frase workspace, and what they * touch. Absence means closed-world: the call is served entirely from data * Frase already holds. * * Every entry was traced from the tool to the system it ends up contacting, * rather than guessed from the tool's name. The three families are search- * engine results, fetches of a live page, and a CMS the customer connected. * * `system` is customer-facing: it is rendered into the review descriptor, so * it names the entity contacted (Google, the page being audited, the connected * CMS) and NOT the vendor we buy that access through. Which supplier sits * behind a capability is a business detail, it is not what the hint asks, and * it changes without the tool's behaviour changing. * * Two families look external and are deliberately absent, because the tool * call itself contacts nobody: the Search Console tools read metrics a * background sync has already stored, and the AI-visibility reads return * results a scheduled job recorded earlier. The traced evidence for both — and * for every entry below — lives in `test/annotations.test.ts`, which is not * published to npm. */ export declare const EXTERNAL_REACH: Readonly>; /** * Every advertised tool, with all four MCP hints resolved to an explicit * boolean. `openWorldHint` comes from `EXTERNAL_REACH` alone so the hint and * the justification that explains it can never disagree. */ export declare const TOOL_ANNOTATIONS: Record; /** The four justification strings an OpenAI reviewer reads, per tool. */ export interface ToolJustifications { read_only_justification: string; destructive_justification: string; idempotent_justification: string; open_world_justification: string; } /** * Derive a tool's justifications from the SAME facts that set its hints. * * Generated rather than hand-written, and that is the point: the previous * submission carried one sentence per category, reused verbatim across every * tool in that category — "creates, updates, deletes, publishes, or queues * work" named five verbs at once and therefore committed to none. Deriving * from `ToolAnnotations` + `EXTERNAL_REACH` means each sentence states the one * thing that is true of THIS tool, names the external system when there is * one, and cannot drift from the hint it explains. */ export declare function buildJustifications(name: string): ToolJustifications; //# sourceMappingURL=annotations.d.ts.map