From 9f679c9f1199cdb41a562ca9e4dd9e23cdde9606 Mon Sep 17 00:00:00 2001 From: Gabriel Bauman <967743+gabrielbauman@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:13:37 -0700 Subject: [PATCH 1/2] Improve operation discovery and token efficiency Keep the MCP tool surface constant while making registered APIs more discoverable and search/execute cheaper in always-on context: - search: emit compact JSON instead of pretty-printed and accept an optional limit (default 25, max 50) - ~32% fewer tokens per search. - execute: assemble its description from only the API kinds actually registered, so an OpenAPI-only setup drops the GraphQL/SOAP blurbs. - Capture each API's description (OpenAPI info.description; GraphQL Query-root doc, best-effort) through the adapter seam into the registry, surfaced by list_apis. - list_apis: an optional api id returns that API plus a capability map (operation tags -> counts, top 40) so an agent sees an API's areas before searching - a navigable overview of thousands of operations. - Add a one-line discovery-strategy hint to the server instructions. --- src/adapter.ts | 2 + src/commands/serve.ts | 173 +++++++++++++++++++++++++++++++++--------- src/graphql.ts | 9 +++ src/openapi.ts | 8 ++ src/register.ts | 1 + src/registry.ts | 2 + 6 files changed, 158 insertions(+), 37 deletions(-) diff --git a/src/adapter.ts b/src/adapter.ts index ad5426b..35ab95d 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -21,6 +21,8 @@ export interface PrepareOptions { export interface PreparedApi { name: string; + /** Short human description of the API (from the source), surfaced by list_apis. */ + description?: string; baseUrl: string; hosts: string[]; operations: OperationInfo[]; diff --git a/src/commands/serve.ts b/src/commands/serve.ts index ee0029f..c18637e 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -12,6 +12,7 @@ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" import { resolve } from "@std/path"; import { z } from "zod"; import { + type ApiKind, type OAuth2Auth, readRegistry, type RegistryEntry, @@ -39,7 +40,11 @@ import { saveToken, } from "../oauth.ts"; -const MAX_RESULTS = 25; +const DEFAULT_LIMIT = 25; +const MAX_LIMIT = 50; +// The capability map (list_apis drill-in) shows only the busiest areas so it stays +// an overview, not a second index; a fine-grained spec can carry hundreds of tags. +const MAX_CAPABILITY_TAGS = 40; const SEARCH_DESCRIPTION = `Search registered API operations by keyword. Returns compact matches - ` + @@ -49,26 +54,47 @@ const SEARCH_DESCRIPTION = `when the spec provides them, so you can pick valid arguments without a failed call. ` + `Feed what you learn into the "execute" tool. When an endpoint exists in several ` + `versions, the newest (e.g. v2 over v1) is ranked first unless your query names a ` + - `version. Optionally pass "api" to restrict the search to one registered API id.`; - -const EXECUTE_DESCRIPTION = - `Run TypeScript against a registered API. A typed "client" is already in scope, with auth ` + - `injected automatically. The client's shape depends on the API's kind (shown by list_apis; ` + - `search's method also signals it):\n` + - '- OpenAPI: an openapi-fetch client - `const { data, error } = await client.GET("/pet/{petId}", ' + - "{ params: { path: { petId: 1 } } });`. Methods are GET/POST/PUT/PATCH/DELETE; options are " + - "{ params: { path, query }, body }; each returns { data, error, response }. response.status is " + - "always set; on success data is set, on an HTTP error error is set (no throw) - narrow on data.\n" + - "- GraphQL (search method QUERY/MUTATION): `client.query(query, variables?)` and " + - "`client.mutate(query, variables?)`, each returning { data, errors }; introspected types are " + - "available as Schema.* (e.g. `client.query<{ user: Schema.User }>(...)`).\n" + - "- SOAP (kind soap): one method per operation - " + - "`const { status, data, raw } = await client.OperationName({ ...args });` (data is the parsed Body).\n" + - `console.log anything you want back. Chain multiple calls in one execution: intermediate results ` + - `stay in the sandbox instead of round-tripping through you. Set check:false to skip type-checking ` + - `for one run (use when a stale spec enum rejects a value the live API accepts; you lose type ` + - `feedback that run). Set timeoutMs to raise the 30s default (max 120000) for long or paginated ` + - `runs. Input: { api: , code: , check?: boolean, timeoutMs?: number }.`; + `version. Optionally pass "api" to restrict the search to one registered API id, and "limit" to ` + + `cap how many matches come back (default 25, max 50).`; + +/** Per-kind client-shape blurbs, assembled into the execute description for the kinds present. */ +const EXEC_SHAPE_BULLETS: Record = { + openapi: + '- OpenAPI: an openapi-fetch client - `const { data, error } = await client.GET("/pet/{petId}", ' + + "{ params: { path: { petId: 1 } } });`. Methods are GET/POST/PUT/PATCH/DELETE; options are " + + "{ params: { path, query }, body }; each returns { data, error, response }. response.status is " + + "always set; on success data is set, on an HTTP error error is set (no throw) - narrow on data.\n", + graphql: + "- GraphQL (search method QUERY/MUTATION): `client.query(query, variables?)` and " + + "`client.mutate(query, variables?)`, each returning { data, errors }; introspected types are " + + "available as Schema.* (e.g. `client.query<{ user: Schema.User }>(...)`).\n", + soap: "- SOAP (kind soap): one method per operation - " + + "`const { status, data, raw } = await client.OperationName({ ...args });` (data is the parsed Body).\n", +}; + +/** + * The execute tool description, carrying only the client-shape blurbs for the API + * kinds actually registered - an OpenAPI-only setup needn't keep the GraphQL/SOAP + * shapes in always-on context. Falls back to all kinds when the registry is empty. + */ +function buildExecuteDescription(kinds: Set): string { + const head = + `Run TypeScript against a registered API. A typed "client" is already in scope, with auth ` + + `injected automatically. The client's shape depends on the API's kind (shown by list_apis; ` + + `search's method also signals it):\n`; + const order: ApiKind[] = ["openapi", "graphql", "soap"]; + const bullets = order + .filter((k) => kinds.size === 0 || kinds.has(k)) + .map((k) => EXEC_SHAPE_BULLETS[k]) + .join(""); + const tail = + `console.log anything you want back. Chain multiple calls in one execution: intermediate results ` + + `stay in the sandbox instead of round-tripping through you. Set check:false to skip type-checking ` + + `for one run (use when a stale spec enum rejects a value the live API accepts; you lose type ` + + `feedback that run). Set timeoutMs to raise the 30s default (max 120000) for long or paginated ` + + `runs. Input: { api: , code: , check?: boolean, timeoutMs?: number }.`; + return head + bullets + tail; +} const AUTHENTICATE_DESCRIPTION = "Start the OAuth browser login for a registered OAuth API: opens the user's browser to the " + @@ -102,9 +128,11 @@ const ADD_API_DESCRIPTION = const LIST_APIS_DESCRIPTION = "List the registered APIs as JSON - each with id, name, kind (openapi/graphql/soap), baseUrl, " + - "operation count, auth status (including OAuth login/expiry), and docsUrl. Use it to see what's " + - "available, to confirm an add_api/remove_api took effect, or to check an API's base URL and kind " + - "(the kind determines the execute client shape). No input."; + "operation count, a short description (when the spec provides one), auth status (including OAuth " + + "login/expiry), and docsUrl. Use it to see what's available and what each API is for, or to confirm " + + "an add_api/remove_api took effect. Pass an `api` id to get just that one API plus its capability " + + "map - the operation tags with per-tag counts - so you can see an API's areas before searching " + + "within one. The kind determines the execute client shape."; const REMOVE_API_DESCRIPTION = "Unregister an API by id: removes its registry entry, deletes any stored secrets (bearer token, " + @@ -130,7 +158,8 @@ function buildInstructions(entries: RegistryEntry[]): string { "operations; `execute` runs TypeScript against a typed `client` (chain several calls in one " + "execute - intermediate data stays in the sandbox, saving round-trips). `list_apis` shows what's " + "registered; `add_api` registers more (force:true overwrites a wrong one) and `remove_api` " + - "deletes one."; + "deletes one. To see what an API can do, call `list_apis` with its `api` id for a description " + + "and its capability areas (operation tags), then `search` within one."; const oauthNote = "\n\nSome APIs use OAuth 2.0. If execute reports that one needs authentication, call the " + "`authenticate` tool with its id to open a browser login for the user (or tell the user to run " + @@ -265,6 +294,7 @@ function searchOperations( state: ServeState, query: string, apiFilter: string | undefined, + limit: number | undefined, ): SearchMatch[] { const tokens = tokenize(query); const scored: { score: number; version: number; match: SearchMatch }[] = []; @@ -297,7 +327,8 @@ function searchOperations( // Relevance first; for equal relevance (e.g. /v1/x vs /v2/x), prefer the newer // version. A query that names a version scores it higher, so it still wins. scored.sort((a, b) => (b.score - a.score) || (b.version - a.version)); - return scored.slice(0, MAX_RESULTS).map((s) => s.match); + const cap = Math.min(Math.max(1, limit ?? DEFAULT_LIMIT), MAX_LIMIT); + return scored.slice(0, cap).map((s) => s.match); } // ---- execute ---- @@ -583,10 +614,49 @@ async function configureOAuthRequest( // ---- list / remove ---- -/** JSON summary of every registered API (id, name, kind, base, op count, auth). */ -async function listApisResult(state: ServeState): Promise { +/** + * Tag -> operation-count map for an API's ops, busiest first (untagged ops + * bucketed), capped to the `limit` biggest areas. Like the enum/description caps, + * this keeps the capability view an overview rather than a second full index; the + * dropped count is returned so truncation is never silent. + */ +function tagCounts( + ops: OperationInfo[], + limit: number, +): { tags: Record; truncated: number } { + const counts = new Map(); + for (const op of ops) { + const tags = op.tags.length ? op.tags : ["(untagged)"]; + for (const t of tags) counts.set(t, (counts.get(t) ?? 0) + 1); + } + const sorted = [...counts].sort( + (a, b) => b[1] - a[1] || a[0].localeCompare(b[0]), + ); + return { + tags: Object.fromEntries(sorted.slice(0, limit)), + truncated: Math.max(0, sorted.length - limit), + }; +} + +/** + * JSON summary of registered APIs (id, name, description, kind, base, op count, + * auth). With `apiFilter` set, returns just that API and adds its `capabilities` + * map (operation tags -> counts) - the heavier territory view, paid only on a + * drill-in so the unfiltered inventory stays lean. + */ +async function listApisResult( + state: ServeState, + apiFilter?: string, +): Promise { + const entries = apiFilter + ? state.entries.filter((e) => e.id === apiFilter) + : state.entries; + if (apiFilter && entries.length === 0) { + const known = state.entries.map((e) => e.id).join(", ") || "(none)"; + return `Unknown api "${apiFilter}". Registered ids: ${known}`; + } const apis: Record[] = []; - for (const e of state.entries) { + for (const e of entries) { const ops = state.opsById.get(e.id); let auth: string = e.auth.kind; if (e.auth.kind === "oauth2") { @@ -603,7 +673,17 @@ async function listApisResult(state: ServeState): Promise { operations: ops ? ops.length : null, auth, }; + if (e.description) api.description = e.description; if (e.docsUrl) api.docsUrl = e.docsUrl; + if (apiFilter && ops) { + const { tags, truncated } = tagCounts(ops, MAX_CAPABILITY_TAGS); + api.capabilities = tags; + if (truncated > 0) { + api.capabilitiesNote = `showing the ${MAX_CAPABILITY_TAGS} busiest of ${ + MAX_CAPABILITY_TAGS + truncated + } capability areas; search to reach the rest`; + } + } apis.push(api); } return JSON.stringify(apis, null, 2); @@ -671,12 +751,22 @@ export async function runServe(_args: string[]): Promise { ); } + // Regeneration above never changes an entry's kind or the registered set, so + // the kinds drive the execute description correctly here. + const executeDescription = buildExecuteDescription( + new Set(startup.entries.map((e) => e.kind)), + ); + const server = new McpServer( { name: "anyapi-mcp", version: "0.1.0" }, { instructions: buildInstructions(startup.entries) }, ); - const searchShape = { query: z.string(), api: z.string().optional() }; + const searchShape = { + query: z.string(), + api: z.string().optional(), + limit: z.number().int().positive().optional(), + }; type SearchArgs = z.infer>; server.registerTool( "search", @@ -685,7 +775,7 @@ export async function runServe(_args: string[]): Promise { description: SEARCH_DESCRIPTION, inputSchema: searchShape, }, - async ({ query, api }: SearchArgs) => { + async ({ query, api, limit }: SearchArgs) => { const state = await loadState(); if (state.entries.length === 0) { return { @@ -696,11 +786,11 @@ export async function runServe(_args: string[]): Promise { }], }; } - const matches = searchOperations(state, query, api); + const matches = searchOperations(state, query, api, limit); return { content: [{ type: "text" as const, - text: JSON.stringify(matches, null, 2), + text: JSON.stringify(matches), }], }; }, @@ -724,7 +814,7 @@ export async function runServe(_args: string[]): Promise { "execute", { title: "Execute TypeScript against an API", - description: EXECUTE_DESCRIPTION, + description: executeDescription, inputSchema: executeShape, }, async ({ api, code, check, timeoutMs }: ExecuteArgs) => { @@ -861,17 +951,26 @@ export async function runServe(_args: string[]): Promise { }, ); + const listApisShape = { + api: z.string().optional().describe( + "Registered API id; when set, returns just that API plus its capability map (operation tags)", + ), + }; + type ListApisArgs = z.infer>; server.registerTool( "list_apis", { title: "List registered APIs", description: LIST_APIS_DESCRIPTION, - inputSchema: {}, + inputSchema: listApisShape, }, - async () => { + async ({ api }: ListApisArgs) => { const state = await loadState(); return { - content: [{ type: "text" as const, text: await listApisResult(state) }], + content: [{ + type: "text" as const, + text: await listApisResult(state, api), + }], }; }, ); diff --git a/src/graphql.ts b/src/graphql.ts index d9f05f9..8990a62 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -34,6 +34,7 @@ interface GqlField { interface FullType { kind: string; name: string | null; + description?: string | null; fields?: GqlField[] | null; inputFields?: InputValue[] | null; enumValues?: { name: string }[] | null; @@ -263,8 +264,16 @@ export const graphqlAdapter: ProtocolAdapter = { const schema = await introspect(source, opts.token); const baseUrl = opts.baseUrlOverride ?? source; const operations = buildOperationIndex(schema); + // Best-effort: the Query root type's own doc string, when the schema sets one. + const rootName = schema.queryType?.name; + const description = clampDescription( + rootName + ? schema.types.find((t) => t.name === rootName)?.description + : undefined, + ); return { name: new URL(baseUrl).host, + ...(description ? { description } : {}), baseUrl, hosts: [new URL(baseUrl).host], operations, diff --git a/src/openapi.ts b/src/openapi.ts index a2f2ffa..8faae84 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -454,6 +454,12 @@ export function specName(spec: Json): string { return (info && str(info.title)) || "API"; } +/** The spec's info.description, normalized + length-capped; undefined if absent. */ +export function specDescription(spec: Json): string | undefined { + const info = obj(spec.info); + return info ? clampDescription(str(info.description)) : undefined; +} + // ---- type generation ---- /** Generate the typed .d.ts via the openapi-typescript CLI into `outPath`. */ @@ -547,8 +553,10 @@ export const openapiAdapter: ProtocolAdapter = { const hosts = hostsFromBaseUrl(baseUrl); const operations = buildOperationIndex(spec); const oauth = discoverOAuth(spec); + const description = specDescription(spec); return { name: specName(spec), + ...(description ? { description } : {}), baseUrl, hosts, operations, diff --git a/src/register.ts b/src/register.ts index 47dca88..4739484 100644 --- a/src/register.ts +++ b/src/register.ts @@ -267,6 +267,7 @@ export async function registerApi( addedAt: new Date().toISOString(), }; if (opts.docsUrl) entry.docsUrl = opts.docsUrl; + if (prepared.description) entry.description = prepared.description; if (existing) { // In-place overwrite. The fresh addedAt invalidates serve's ops cache; the diff --git a/src/registry.ts b/src/registry.ts index fdd3533..b107bb2 100644 --- a/src/registry.ts +++ b/src/registry.ts @@ -46,6 +46,8 @@ export interface RegistryEntry { /** Slug, unique, used on the CLI and in execute. */ id: string; name: string; + /** Short human description from the source spec, surfaced by list_apis (optional). */ + description?: string; /** Protocol adapter for this API (defaults to "openapi" for older entries). */ kind: ApiKind; /** URL or absolute local path the source was loaded from. */ From 99da401f404fe5b9f943c94d41c062f30bd7c227 Mon Sep 17 00:00:00 2001 From: Gabriel Bauman <967743+gabrielbauman@users.noreply.github.com> Date: Wed, 3 Jun 2026 00:44:58 -0700 Subject: [PATCH 2/2] Address review: list_apis JSON contract, compact output, live execute description - list_apis returns { text, isError }: an unknown api id is now reported with isError set, rather than a bare string that broke the JSON contract. - list_apis emits compact JSON like search (was pretty-printed), trimming per-call tokens, especially with the capability map. - The execute description hot-reloads: every handler reads the registry via loadStateSynced, which re-derives the description (and pushes tools/list_changed) whenever the registered kind-set changes, so a mid-session add_api/remove_api or external add keeps it accurate. Verified end-to-end over stdio. --- src/commands/serve.ts | 66 ++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 16 deletions(-) diff --git a/src/commands/serve.ts b/src/commands/serve.ts index c18637e..ef42958 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -647,13 +647,16 @@ function tagCounts( async function listApisResult( state: ServeState, apiFilter?: string, -): Promise { +): Promise<{ text: string; isError: boolean }> { const entries = apiFilter ? state.entries.filter((e) => e.id === apiFilter) : state.entries; if (apiFilter && entries.length === 0) { const known = state.entries.map((e) => e.id).join(", ") || "(none)"; - return `Unknown api "${apiFilter}". Registered ids: ${known}`; + return { + text: `Unknown api "${apiFilter}". Registered ids: ${known}`, + isError: true, + }; } const apis: Record[] = []; for (const e of entries) { @@ -686,7 +689,7 @@ async function listApisResult( } apis.push(api); } - return JSON.stringify(apis, null, 2); + return { text: JSON.stringify(apis), isError: false }; } async function removeApiRequest( @@ -776,7 +779,7 @@ export async function runServe(_args: string[]): Promise { inputSchema: searchShape, }, async ({ query, api, limit }: SearchArgs) => { - const state = await loadState(); + const state = await loadStateSynced(); if (state.entries.length === 0) { return { content: [{ @@ -810,7 +813,7 @@ export async function runServe(_args: string[]): Promise { ), }; type ExecuteArgs = z.infer>; - server.registerTool( + const executeTool = server.registerTool( "execute", { title: "Execute TypeScript against an API", @@ -818,7 +821,7 @@ export async function runServe(_args: string[]): Promise { inputSchema: executeShape, }, async ({ api, code, check, timeoutMs }: ExecuteArgs) => { - const { entries } = await loadState(); + const { entries } = await loadStateSynced(); const { text, isError } = await executeRequest(entries, api, code, { check, timeoutMs, @@ -827,6 +830,35 @@ export async function runServe(_args: string[]): Promise { }, ); + // The execute description above lists only the client shapes for the kinds + // present at startup, but the registry hot-reloads on every tool call: a + // mid-session add_api/remove_api (or an external `anyapi-mcp add`) can + // introduce or drop a kind. Re-derive the description whenever the loaded + // kind-set changes and push a tools/list_changed via update(); loadStateSynced + // wraps the per-call registry read so every handler keeps execute current. + let executeKinds = [...new Set(startup.entries.map((e) => e.kind))] + .sort() + .join(","); + + function syncExecuteDescription(entries: RegistryEntry[]): void { + const kinds = new Set(entries.map((e) => e.kind)); + const key = [...kinds].sort().join(","); + if (key === executeKinds) return; + executeKinds = key; + executeTool.update({ description: buildExecuteDescription(kinds) }); + console.error( + `anyapi-mcp serve: execute description updated for kinds [${ + key || "none" + }].`, + ); + } + + async function loadStateSynced(): Promise { + const state = await loadState(); + syncExecuteDescription(state.entries); + return state; + } + const authenticateShape = { api: z.string() }; type AuthenticateArgs = z.infer>; server.registerTool( @@ -837,7 +869,7 @@ export async function runServe(_args: string[]): Promise { inputSchema: authenticateShape, }, async ({ api }: AuthenticateArgs) => { - const { entries } = await loadState(); + const { entries } = await loadStateSynced(); const { text, isError } = await authenticateRequest(entries, api); return { content: [{ type: "text" as const, text }], isError }; }, @@ -867,7 +899,7 @@ export async function runServe(_args: string[]): Promise { inputSchema: configureOAuthShape, }, async (args: ConfigureOAuthToolArgs) => { - const { entries } = await loadState(); + const { entries } = await loadStateSynced(); const { text, isError } = await configureOAuthRequest(entries, args); return { content: [{ type: "text" as const, text }], isError }; }, @@ -936,6 +968,10 @@ export async function runServe(_args: string[]): Promise { : ` If requests come back 401/403 this API needs a token: run ` + `\`anyapi-mcp add ${specSource} --id ${entry.id} --token${kindFlag}\` in a shell so the token ` + `is stored in your OS keychain (never through this chat).`; + // A new registration may introduce a kind (e.g. the first GraphQL/SOAP + // API in an OpenAPI-only session); refresh the execute description so its + // client shape is documented immediately. + syncExecuteDescription(await readRegistry()); return { content: [{ type: "text" as const, text: head + authHelp }] }; } catch (err) { return { @@ -965,13 +1001,9 @@ export async function runServe(_args: string[]): Promise { inputSchema: listApisShape, }, async ({ api }: ListApisArgs) => { - const state = await loadState(); - return { - content: [{ - type: "text" as const, - text: await listApisResult(state, api), - }], - }; + const state = await loadStateSynced(); + const { text, isError } = await listApisResult(state, api); + return { content: [{ type: "text" as const, text }], isError }; }, ); @@ -987,8 +1019,10 @@ export async function runServe(_args: string[]): Promise { inputSchema: removeApiShape, }, async ({ api }: RemoveApiArgs) => { - const { entries } = await loadState(); + const { entries } = await loadStateSynced(); const { text, isError } = await removeApiRequest(entries, api); + // Removing the last API of a kind drops its client shape from execute. + if (!isError) syncExecuteDescription(entries.filter((e) => e.id !== api)); return { content: [{ type: "text" as const, text }], isError }; }, );