diff --git a/CLAUDE.md b/CLAUDE.md index ec959c1..a496292 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,8 +25,9 @@ protocol plugs in through one in-tree adapter object. - `src/main.ts`: argv dispatch to the subcommands. - `src/commands/{add,install,list,login,logout,remove,serve}.ts`: one file per subcommand. `serve.ts` is the MCP server exposing `search`, `execute`, - `authenticate`, `configure_oauth`, `add_api`; `install.ts` registers - anyapi-mcp with Claude Code / Desktop; `login`/`logout` manage OAuth sessions. + `authenticate`, `configure_oauth`, `add_api`, `list_apis`, `remove_api`; + `install.ts` registers anyapi-mcp with Claude Code / Desktop; `login`/`logout` + manage OAuth sessions. - `src/adapter.ts`: the `ProtocolAdapter` seam. `prepare()` turns a source into base URL + hosts + operation index + generated client types (+ optional discovered OAuth config); `buildHarness()` builds the `execute` preamble that @@ -34,7 +35,10 @@ protocol plugs in through one in-tree adapter object. - `src/adapters.ts`: discriminated-union registry keyed by `kind`. **Adding a protocol means a new entry here plus an adapter file, not a plugin system.** - `src/openapi.ts` / `src/graphql.ts` / `src/soap.ts`: the three adapters. - OpenAPI also does OAuth2 discovery (`discoverOAuth`). + OpenAPI also does OAuth2 discovery (`discoverOAuth`) and derives the base URL + from document-, path-, or operation-level `servers` (`resolveBaseUrl`), + failing loudly if the result lands on a raw spec-hosting host (e.g. + raw.githubusercontent.com) rather than silently registering a broken base. - `src/oauth.ts`: protocol-agnostic OAuth 2.0 authorization-code support — the browser login flow (local one-shot callback server), token storage in the keystore, automatic refresh (`ensureAccessToken`), and a small known-provider @@ -42,15 +46,23 @@ protocol plugs in through one in-tree adapter object. - `src/registry.ts`: the `apis.jsonl` registry (one `RegistryEntry` per line). `Auth` is a union: `none` | `bearer` | `oauth2`. - `src/register.ts`: registration logic shared by `add` and `add_api`; resolves - the OAuth config (precedence: explicit flag > quirk > discovered). -- `src/operation.ts`: operation index + keyword `search` (no embeddings). + the OAuth config (precedence: explicit flag > quirk > discovered). `force` + upserts in place (fresh `addedAt` invalidates serve's ops cache; same-id + keystore accounts are preserved, so an OAuth API stays logged in across a + re-register). `unregisterApi` (shared by `remove` and `remove_api`) deletes + the entry, its secrets, and its cached artifacts. +- `src/operation.ts`: operation index + keyword `search` (no embeddings). Params + carry optional `description`/`enum`, surfaced in search (`clampDescription`/ + `applyEnum` bound their size; `enum` holds only real values — an over-cap + count is noted in `description`, never as a fake enum entry). - `src/keystore.ts`: OS keychain access (`security` on macOS, `secret-tool` on Linux). Service name is `anyapi-mcp`; accounts look like `anyapi-mcp:` (bearer), `anyapi-mcp::client` / `anyapi-mcp::oauth` (OAuth client creds + token bundle, each a JSON blob). - `src/paths.ts`: XDG dirs. Registry under `~/.config/anyapi-mcp`, generated types under `~/.cache/anyapi-mcp`. -- `src/execute/run.ts`: the sandboxed subprocess runner. +- `src/execute/run.ts`: the sandboxed subprocess runner (per-call `check` and + `timeoutMs` options; the net/env allowlist is unconditional). ## Invariants (do not break) @@ -59,8 +71,13 @@ protocol plugs in through one in-tree adapter object. path. - **The execute sandbox** runs model code in a `deno` subprocess with `--allow-net=`, `--allow-env=ANYAPI_MCP_TOKEN`, no - read/write/run, and a 30s timeout. Type-checking stays **on** (`--check`) so - the model sees type errors. Don't widen these grants. + read/write/run, and a timeout (default 30s; `execute` may raise it, capped at + 120s). These grants are the security boundary — **don't widen them**. + Type-checking is on by default (`--check`) so the model sees type errors; + `execute`'s `check:false` skips it for one run (e.g. when a stale spec enum + rejects a value the live API still accepts). That toggle removes only + `--check` — it never touches the net/env allowlist above, so it is not a grant + widening. - **Secrets** live only in the OS keystore. The registry stores keystore account names (`tokenKey`; for OAuth also `clientKey`), never the secret itself. No MCP tool may accept secrets: bearer tokens go through diff --git a/README.md b/README.md index 63b1d31..569bc2e 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,13 @@ integration per API: one server covers everything you register, and it stays token-efficient however many calls a task takes. Under the hood it generates a typed client from the API's own description and -exposes just three tools: `search` to find operations, `execute` to run a short -TypeScript program against that client, and `add_api` to register more. Instead -of one MCP tool per endpoint clogging the context window, the model writes code -and runs it in a locked-down sandbox; intermediate results stay in that -subprocess rather than round-tripping through the model, so a ten-step workflow -costs one tool call and a handful of tokens, not ten. +exposes a small set of tools: `search` to find operations, `execute` to run a +short TypeScript program against that client, and `add_api`/`list_apis`/ +`remove_api` to manage what's registered. Instead of one MCP tool per endpoint +clogging the context window, the model writes code and runs it in a locked-down +sandbox; intermediate results stay in that subprocess rather than round-tripping +through the model, so a ten-step workflow costs one tool call and a handful of +tokens, not ten. Because that client is fully typed, the model drives the API without guesswork: a wrong argument is a type error, caught before any request leaves the machine. @@ -79,19 +80,24 @@ Inspects the source (an OpenAPI spec, a GraphQL endpoint, or a WSDL), derives the base URL and host, builds a searchable operation index, generates a typed client, and writes a registry entry. -| Option | Description | -| --------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `--kind ` | Protocol (default: `openapi`). `graphql` introspects an endpoint; `soap` reads a WSDL URL. | -| `--id ` | Id used on the CLI and in `execute` (default: the base URL in reverse-DNS form, e.g. `com.github.api`). | -| `--name ` | Human-friendly name (default: spec `info.title`). | -| `--base-url ` | Override the base URL derived from the spec's `servers[]`. | -| `--docs ` | Documentation URL to store and surface (not parsed). | -| `--token` | Store a bearer token. Read without echo from a TTY, or piped via stdin. | -| `--oauth` | Treat the API as OAuth 2.0 even if the spec doesn't declare it. | -| `--auth-url` / `--token-url` | OAuth authorize / token endpoints (override the spec's values). | -| `--scope ` | Scope to request at login (repeatable; default: the spec's scopes). | -| `--scope-separator ` | Scope separator in the authorize URL (default `" "`; Strava uses `","`). | -| `--no-auth` | Register without authentication. | +| Option | Description | +| --------------------------------- | --------------------------------------------------------------------------------------------------------- | +| `--kind ` | Protocol (default: `openapi`). `graphql` introspects an endpoint; `soap` reads a WSDL URL. | +| `--id ` | Id used on the CLI and in `execute` (default: the base URL in reverse-DNS form, e.g. `com.github.api`). | +| `--name ` | Human-friendly name (default: spec `info.title`). | +| `--base-url ` | Override the base URL (otherwise derived from the spec's document-, path-, or operation-level `servers`). | +| `--docs ` | Documentation URL to store and surface (not parsed). | +| `--token` | Store a bearer token. Read without echo from a TTY, or piped via stdin. | +| `--oauth` | Treat the API as OAuth 2.0 even if the spec doesn't declare it. | +| `--auth-url` / `--token-url` | OAuth authorize / token endpoints (override the spec's values). | +| `--scope ` | Scope to request at login (repeatable; default: the spec's scopes). | +| `--scope-separator ` | Scope separator in the authorize URL (default `" "`; Strava uses `","`). | +| `--no-auth` | Register without authentication. | +| `--force` | Overwrite an existing API with the same id instead of failing (e.g. to fix a wrong base URL). | + +If the base URL can't be derived and would fall back to a raw spec-hosting host +(e.g. `raw.githubusercontent.com`), `add` fails loudly instead of registering a +broken base — pass `--base-url` with the real API origin. OpenAPI specs that declare an **OAuth 2.0 authorization-code flow are detected automatically**: the API is registered as `oauth2`, and you run @@ -159,12 +165,16 @@ Runs the stdio MCP server. It re-reads the registry on each call (so newly registered APIs are picked up without a restart) and exposes: - **`search`** - `{ query, api? }` → compact operation matches (`api`, `method`, - `path`, `operationId`, `summary`, `params`, `requestBodyHint`). -- **`execute`** - `{ api, code }` → runs `code` against a typed `client` and - returns `{ stdout, stderr, exitCode }` verbatim. For OAuth APIs the access - token is refreshed automatically first; if the API isn't authenticated, the - result explains how to fix it (call `authenticate`, or run - `anyapi-mcp login`). + `path`, `operationId`, `summary`, `params`, `requestBodyHint`). Each param + also carries a `description` and its allowed `enum` values when the spec + provides them, so the model can pick valid arguments without a failed call. +- **`execute`** - `{ api, code, check?, timeoutMs? }` → runs `code` against a + typed `client` and returns `{ stdout, stderr, exitCode }` verbatim. For OAuth + APIs the access token is refreshed automatically first; if the API isn't + authenticated, the result explains how to fix it (call `authenticate`, or run + `anyapi-mcp login`). `check:false` skips type-checking for one run (use when a + stale spec `enum` rejects a value the live API still accepts); `timeoutMs` + raises the 30s default (capped at 120s) for long or paginated runs. - **`authenticate`** - `{ api }` → opens the user's browser to (re-)authenticate an OAuth API and stores the tokens. Lets the model recover from an expired or revoked session without leaving the chat. It never accepts secrets — the user @@ -176,11 +186,19 @@ registered APIs are picked up without a restart) and exposes: authorize/token endpoints (those carry the client secret and stay CLI-only), and reserved params like `redirect_uri`/`client_id` are rejected. Call with just `{ api }` to read the current config. -- **`add_api`** - `{ specUrl, kind?, id?, name?, baseUrl?, docsUrl? }` → +- **`add_api`** - `{ specUrl, kind?, id?, name?, baseUrl?, docsUrl?, force? }` → registers a new API (`kind` `openapi`, `graphql`, or `soap`) so the model can self-serve public APIs. Secrets are **not** accepted here; for authenticated APIs use `anyapi-mcp add … --token` (bearer) or `anyapi-mcp login` (OAuth) so - the secret goes to the OS keychain, not the conversation. + the secret goes to the OS keychain, not the conversation. `force:true` + overwrites an existing id in place (e.g. to correct a wrong `baseUrl`) instead + of failing; an OAuth API stays logged in across the overwrite. +- **`list_apis`** - `{}` → the registered APIs as JSON (id, name, kind, baseUrl, + operation count, auth/login status, docsUrl). Lets the model see what's + available and confirm an `add_api`/`remove_api` took effect mid-session. +- **`remove_api`** - `{ api }` → unregisters an API: removes its entry, deletes + any stored secrets from the keychain, and cleans up its cached types and ops + index. The model's own way to clean up a mistaken registration. The server also sends MCP `instructions` at connect time describing the workflow, and - when the registry is empty - exactly how to register an API. @@ -293,15 +311,20 @@ and runs it in a `deno` subprocess scoped with: - `--allow-env=ANYAPI_MCP_TOKEN` - the only environment variable the code can read. - no `--allow-read` / `--allow-write` / `--allow-run`. -- a 30-second timeout. +- a 30-second timeout by default (raise it per call with `execute`'s + `timeoutMs`, capped at 120s). The token is injected into the subprocess environment only; the model never sees it, and the net allowlist means it can't be exfiltrated. For OAuth APIs the parent process refreshes the access token (in the keychain) **before** building the harness, so the sandbox only ever receives a currently-valid token and never -touches the refresh token or token endpoint. **Type-checking stays on** +touches the refresh token or token endpoint. **Type-checking is on by default** (`--check`) so the model sees type errors and can self-correct - calling an -operation with the wrong arguments returns the exact expected shape. +operation with the wrong arguments returns the exact expected shape. Passing +`check:false` to `execute` skips type-checking for that one run - useful when a +spec's `enum` is stale and rejects a value the live API still accepts (the model +trades type feedback for the call). It removes only `--check`; the net/env +sandbox above is unchanged. Writing code with `openapi-fetch`: `response` is always present, so check `response.status`; on success `data` is set, on an HTTP error `error` is set (no diff --git a/src/commands/add.ts b/src/commands/add.ts index cf6bfa2..7166c6f 100644 --- a/src/commands/add.ts +++ b/src/commands/add.ts @@ -28,6 +28,7 @@ Options: --scope Scope to request at login (repeatable; default: the spec's scopes) --scope-separator Scope separator in the authorize URL (default " "; Strava uses ",") --no-auth Register without authentication + --force Overwrite an existing API with the same id instead of failing -h, --help Show this help OpenAPI specs that declare an OAuth2 authorization-code flow are detected @@ -55,7 +56,7 @@ export async function runAdd(args: string[]): Promise { "scope-separator", ], collect: ["scope"], - boolean: ["help", "no-auth", "token", "oauth"], + boolean: ["help", "no-auth", "token", "oauth", "force"], alias: { h: "help" }, }); @@ -97,7 +98,7 @@ export async function runAdd(args: string[]): Promise { const scopes = (flags.scope as string[] | undefined) ?? []; try { - const { entry, operationCount } = await registerApi({ + const { entry, operationCount, overwritten } = await registerApi({ specSource, kind: flags.kind as ApiKind | undefined, id: flags.id, @@ -111,6 +112,7 @@ export async function runAdd(args: string[]): Promise { scopes: scopes.length ? scopes : undefined, scopeSeparator: flags["scope-separator"], noAuth: flags["no-auth"], + force: flags.force, onProgress: (m) => console.error(m), }); const authLine = entry.auth.kind === "bearer" @@ -119,7 +121,9 @@ export async function runAdd(args: string[]): Promise { ? `oauth2 (not logged in)` : entry.auth.kind; console.error( - `Registered "${entry.id}" (${entry.name})\n` + + `${ + overwritten ? "Re-registered" : "Registered" + } "${entry.id}" (${entry.name})\n` + ` kind: ${entry.kind}\n` + ` base URL: ${entry.baseUrl}\n` + ` hosts: ${entry.hosts.join(", ")}\n` + diff --git a/src/commands/remove.ts b/src/commands/remove.ts index 7f598bf..075bcc3 100644 --- a/src/commands/remove.ts +++ b/src/commands/remove.ts @@ -1,11 +1,9 @@ // `anyapi-mcp remove ` - remove an API: its registry entry, stored token, and -// cached artifacts (.d.ts, ops index). +// cached artifacts (.d.ts, ops index). The removal itself lives in +// ../register.ts (shared with the `remove_api` MCP tool). import { parseArgs } from "@std/cli/parse-args"; -import { findEntry, removeEntry } from "../registry.ts"; -import { deleteSecret } from "../keystore.ts"; -import { clearOAuthSecrets } from "../oauth.ts"; -import { opsPathFor } from "../paths.ts"; +import { unregisterApi } from "../register.ts"; export async function runRemove(args: string[]): Promise { const flags = parseArgs(args, { boolean: ["help"], alias: { h: "help" } }); @@ -22,39 +20,11 @@ export async function runRemove(args: string[]): Promise { Deno.exit(1); } - const entry = await findEntry(id); - if (!entry) { + const { removed, secretsNote } = await unregisterApi(id); + if (!removed) { console.error(`anyapi-mcp remove: no API with id "${id}".`); Deno.exit(1); } - - if (entry.auth.kind === "bearer") { - const removed = await deleteSecret(entry.auth.tokenKey); - console.error( - removed - ? `Deleted token ${entry.auth.tokenKey}.` - : `No stored token found for ${entry.auth.tokenKey}.`, - ); - } else if (entry.auth.kind === "oauth2") { - const { token, client } = await clearOAuthSecrets(entry.auth, { - forgetClient: true, - }); - console.error( - `Deleted OAuth secrets (token: ${token ? "yes" : "none"}, client: ${ - client ? "yes" : "none" - }).`, - ); - } - - await removeEntry(id); - - for (const path of [entry.typesPath, opsPathFor(id)]) { - try { - await Deno.remove(path); - } catch (err) { - if (!(err instanceof Deno.errors.NotFound)) throw err; - } - } - + if (secretsNote) console.error(secretsNote.replace(/^d/, "D")); console.error(`Removed "${id}".`); } diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 9aa917e..0d92c1f 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -1,5 +1,5 @@ // `anyapi-mcp serve` - stdio MCP server exposing `search`, `execute`, -// `authenticate`, `configure_oauth`, and `add_api`. +// `authenticate`, `configure_oauth`, `add_api`, `list_apis`, and `remove_api`. // // The registry is re-read on each call (hot-reload), so an API registered mid- // session - via the add_api tool or the `anyapi-mcp add` CLI - is usable without a @@ -20,7 +20,7 @@ import { import { isUrl } from "../openapi.ts"; import { type OperationInfo, readOpsIndex } from "../operation.ts"; import { getSecret } from "../keystore.ts"; -import { registerApi } from "../register.ts"; +import { registerApi, unregisterApi } from "../register.ts"; import { getAdapter } from "../adapters.ts"; import { runSandboxed } from "../execute/run.ts"; import { @@ -39,21 +39,31 @@ const MAX_RESULTS = 25; const SEARCH_DESCRIPTION = `Search registered API operations by keyword. Returns compact matches - ` + `{ api, method, path, operationId, summary, params, requestBodyHint } - so you ` + - `can learn which path + method to call and roughly what arguments it takes. ` + + `can learn which path + method to call and roughly what arguments it takes. Each ` + + `param lists its name and type, plus a description and its allowed "enum" values ` + + `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 openapi-fetch client named ` + - `"client" is already in scope, with auth injected automatically. Call it like ` + - '`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 }, " + - `and each returns { data, error, response }. ` + - "`response.status` is always available; on success `data` is set, on an HTTP error " + - "`error` is set (no throw) - narrow on `data` for success. console.log anything you want back. " + - `Chain multiple calls in one execution: intermediate results stay in the sandbox ` + - `instead of round-tripping through you. Input: { api: , code: }.`; + `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 }.`; const AUTHENTICATE_DESCRIPTION = "Start the OAuth browser login for a registered OAuth API: opens the user's browser to the " + @@ -79,7 +89,23 @@ const ADD_API_DESCRIPTION = 'URL (kind "soap"). Generates a typed client and makes the API available immediately (no ' + "restart). Use this for public APIs. For an API that requires a secret token, do NOT pass the " + "token here - tell the user to run `anyapi-mcp add --token [--kind …]` in a " + - "shell, which stores it in the OS keychain. Input: { specUrl, kind?, id?, name?, baseUrl?, docsUrl? }."; + "shell, which stores it in the OS keychain. If the spec's server can't be derived (it lands on a " + + "raw-file host like raw.githubusercontent.com), registration fails loudly - pass baseUrl with the " + + "real API base. If an id already exists, pass force:true to overwrite it in place (e.g. to fix a " + + "wrong baseUrl); an OAuth API stays logged in across the overwrite. " + + "Input: { specUrl, kind?, id?, name?, baseUrl?, docsUrl?, force? }."; + +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."; + +const REMOVE_API_DESCRIPTION = + "Unregister an API by id: removes its registry entry, deletes any stored secrets (bearer token, " + + "or OAuth client credentials + tokens) from the OS keychain, and cleans up its cached client types " + + "and operation index. Use it to clear a mistaken or stale registration (or pass force:true to " + + "add_api to overwrite one in place instead). Input: { api: }."; /** How-to-register guidance, surfaced when the registry is empty. */ function howToAdd(): string { @@ -95,9 +121,11 @@ function howToAdd(): string { function buildInstructions(entries: RegistryEntry[]): string { const intro = - "anyapi-mcp turns OpenAPI HTTP APIs into code you run. `search` finds operations; `execute` runs " + - "TypeScript against a typed openapi-fetch `client` (chain several calls in one execute - " + - "intermediate data stays in the sandbox, saving round-trips). `add_api` registers new APIs."; + "anyapi-mcp turns HTTP APIs (OpenAPI, GraphQL, SOAP) into code you run. `search` finds " + + "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."; 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 " + @@ -285,6 +313,7 @@ async function executeRequest( entries: RegistryEntry[], api: string, code: string, + opts: { check?: boolean; timeoutMs?: number } = {}, ): Promise<{ text: string; isError: boolean }> { if (entries.length === 0) { return { @@ -330,9 +359,13 @@ async function executeRequest( source, entry.hosts, token, + { check: opts.check, timeoutMs: opts.timeoutMs }, ); + const note = opts.check === false + ? "note: type-checking was disabled for this run (check:false).\n" + : ""; return { - text: formatResult(stdout, stderr, exitCode), + text: note + formatResult(stdout, stderr, exitCode), isError: exitCode !== 0, }; } @@ -543,6 +576,53 @@ async function configureOAuthRequest( }; } +// ---- list / remove ---- + +/** JSON summary of every registered API (id, name, kind, base, op count, auth). */ +async function listApisResult(state: ServeState): Promise { + const apis: Record[] = []; + for (const e of state.entries) { + const ops = state.opsById.get(e.id); + let auth: string = e.auth.kind; + if (e.auth.kind === "oauth2") { + const token = await loadToken(e.auth); + auth = token + ? `oauth2 (logged in, ${describeExpiry(token)})` + : "oauth2 (not logged in)"; + } + const api: Record = { + id: e.id, + name: e.name, + kind: e.kind, + baseUrl: e.baseUrl, + operations: ops ? ops.length : null, + auth, + }; + if (e.docsUrl) api.docsUrl = e.docsUrl; + apis.push(api); + } + return JSON.stringify(apis, null, 2); +} + +async function removeApiRequest( + entries: RegistryEntry[], + api: string, +): Promise<{ text: string; isError: boolean }> { + const { removed, secretsNote } = await unregisterApi(api); + if (!removed) { + const known = entries.map((e) => e.id).join(", ") || "(none)"; + return { + text: `Unknown api "${api}"; nothing to remove. Registered ids: ${known}`, + isError: true, + }; + } + return { + text: `Removed "${api}"${secretsNote ? ` (${secretsNote})` : ""}. ` + + `Its types and operation index are deleted; it no longer appears in search/execute.`, + isError: false, + }; +} + // ---- server ---- export async function runServe(_args: string[]): Promise { @@ -589,7 +669,19 @@ export async function runServe(_args: string[]): Promise { }, ); - const executeShape = { api: z.string(), code: z.string() }; + const executeShape = { + api: z.string().describe("Registered API id (see list_apis)"), + code: z.string().describe( + "TypeScript to run; a typed `client` is in scope", + ), + check: z.boolean().optional().describe( + "Type-check before running (default true). Set false to bypass a stale spec " + + "enum that rejects a value the live API accepts; you lose type feedback that run.", + ), + timeoutMs: z.number().optional().describe( + "Max run time in ms (default 30000, clamped to 1000-120000); raise it for long or paginated runs.", + ), + }; type ExecuteArgs = z.infer>; server.registerTool( "execute", @@ -598,9 +690,12 @@ export async function runServe(_args: string[]): Promise { description: EXECUTE_DESCRIPTION, inputSchema: executeShape, }, - async ({ api, code }: ExecuteArgs) => { + async ({ api, code, check, timeoutMs }: ExecuteArgs) => { const { entries } = await loadState(); - const { text, isError } = await executeRequest(entries, api, code); + const { text, isError } = await executeRequest(entries, api, code, { + check, + timeoutMs, + }); return { content: [{ type: "text" as const, text }], isError }; }, ); @@ -670,6 +765,9 @@ export async function runServe(_args: string[]): Promise { docsUrl: z.string().optional().describe( "Documentation URL to store and surface", ), + force: z.boolean().optional().describe( + "Overwrite an existing API with the same id instead of failing (e.g. to fix a wrong baseUrl)", + ), }; type AddApiArgs = z.infer>; server.registerTool( @@ -679,23 +777,29 @@ export async function runServe(_args: string[]): Promise { description: ADD_API_DESCRIPTION, inputSchema: addApiShape, }, - async ({ specUrl, kind, id, name, baseUrl, docsUrl }: AddApiArgs) => { + async ( + { specUrl, kind, id, name, baseUrl, docsUrl, force }: AddApiArgs, + ) => { try { const specSource = isUrl(specUrl) ? specUrl : resolve(specUrl); - const { entry, operationCount } = await registerApi({ + const { entry, operationCount, overwritten } = await registerApi({ specSource, kind, id, name, baseUrl, docsUrl, + force, }); const kindFlag = entry.kind === "openapi" ? "" : ` --kind ${entry.kind}`; const head = - `Registered "${entry.id}" (${entry.name}): ${operationCount} operations, ` + - `base ${entry.baseUrl}. Available now - call search with api "${entry.id}", then execute.`; + `${ + overwritten ? "Re-registered" : "Registered" + } "${entry.id}" (${entry.name}): ` + + `${operationCount} operations, base ${entry.baseUrl}. ` + + `Available now - call search with api "${entry.id}", then execute.`; const authHelp = entry.auth.kind === "oauth2" ? ` This API uses OAuth 2.0. The user must create an OAuth app (redirect ` + `URL ${entry.auth.redirectUri}) and run \`anyapi-mcp login ${entry.id} ` + @@ -720,6 +824,39 @@ export async function runServe(_args: string[]): Promise { }, ); + server.registerTool( + "list_apis", + { + title: "List registered APIs", + description: LIST_APIS_DESCRIPTION, + inputSchema: {}, + }, + async () => { + const state = await loadState(); + return { + content: [{ type: "text" as const, text: await listApisResult(state) }], + }; + }, + ); + + const removeApiShape = { + api: z.string().describe("Registered API id to unregister"), + }; + type RemoveApiArgs = z.infer>; + server.registerTool( + "remove_api", + { + title: "Unregister an API", + description: REMOVE_API_DESCRIPTION, + inputSchema: removeApiShape, + }, + async ({ api }: RemoveApiArgs) => { + const { entries } = await loadState(); + const { text, isError } = await removeApiRequest(entries, api); + return { content: [{ type: "text" as const, text }], isError }; + }, + ); + const transport = new StdioServerTransport(); await server.connect(transport); console.error("anyapi-mcp serve: ready on stdio."); diff --git a/src/execute/run.ts b/src/execute/run.ts index ce41dcf..2c57660 100644 --- a/src/execute/run.ts +++ b/src/execute/run.ts @@ -9,9 +9,12 @@ // The token is injected into the subprocess env only; the model never sees it, // and the net allowlist means even hostile code can't ship it elsewhere. // -// Type-checking stays on (`--check`) so the model sees type errors and can -// self-correct. The module cache is warmed in the parent first (full network), -// so the sandboxed run needs no registry access to load its imports. +// Type-checking is on by default (`--check`) so the model sees type errors and +// can self-correct; a caller may turn it off per call (e.g. when a stale spec +// enum rejects a value the live API still accepts). Opting out changes nothing +// about the security boundary above - same net/env allowlist, no read/write/run. +// The module cache is warmed in the parent first (full network), so the +// sandboxed run needs no registry access to load its imports. export interface ExecuteResult { stdout: string; @@ -19,7 +22,22 @@ export interface ExecuteResult { exitCode: number; } -const TIMEOUT_MS = 30_000; +export interface SandboxOptions { + /** Type-check before running (default true). Off bypasses stale spec enums. */ + check?: boolean; + /** Max wall-clock ms before SIGKILL (default 30000, clamped to [1000, 120000]). */ + timeoutMs?: number; +} + +const DEFAULT_TIMEOUT_MS = 30_000; +const MIN_TIMEOUT_MS = 1_000; +const MAX_TIMEOUT_MS = 120_000; + +function clampTimeout(ms: number | undefined): number { + if (ms === undefined || !Number.isFinite(ms)) return DEFAULT_TIMEOUT_MS; + return Math.min(MAX_TIMEOUT_MS, Math.max(MIN_TIMEOUT_MS, Math.floor(ms))); +} + /** Env vars the child needs to function (find its module cache, temp dir). */ const PASSTHROUGH_ENV = ["HOME", "PATH", "DENO_DIR", "TMPDIR"]; @@ -47,8 +65,10 @@ export async function runSandboxed( source: string, hosts: string[], token: string | undefined, + opts: SandboxOptions = {}, ): Promise { const deno = denoExecutable(); + const timeoutMs = clampTimeout(opts.timeoutMs); const dec = new TextDecoder(); const tmp = await Deno.makeTempFile({ prefix: "anyapi-mcp-exec-", @@ -74,8 +94,11 @@ export async function runSandboxed( }; } - // 2) Type-check + run, sandboxed. - const args = ["run", "--check", "--no-config", "--no-lock"]; + // 2) (Type-check +) run, sandboxed. --check is opt-out; the allowlists below + // are the security boundary and are unconditional. + const args = ["run"]; + if (opts.check !== false) args.push("--check"); + args.push("--no-config", "--no-lock"); if (hosts.length > 0) args.push(`--allow-net=${hosts.join(",")}`); args.push("--allow-env=ANYAPI_MCP_TOKEN", tmp); @@ -95,7 +118,7 @@ export async function runSandboxed( } catch { // already exited } - }, TIMEOUT_MS); + }, timeoutMs); const { code, stdout, stderr } = await child.output(); clearTimeout(timer); @@ -103,7 +126,7 @@ export async function runSandboxed( let err = dec.decode(stderr); if (timedOut) { err += `\nanyapi-mcp: execution timed out after ${ - TIMEOUT_MS / 1000 + timeoutMs / 1000 }s and was killed.`; } return { diff --git a/src/graphql.ts b/src/graphql.ts index 6db9ee0..d9f05f9 100644 --- a/src/graphql.ts +++ b/src/graphql.ts @@ -5,6 +5,7 @@ import { toFileUrl } from "@std/path"; import { ensureCacheDir } from "./paths.ts"; import type { ProtocolAdapter } from "./adapter.ts"; +import { applyEnum, clampDescription } from "./operation.ts"; import type { OperationInfo, OperationParam } from "./operation.ts"; import type { RegistryEntry } from "./registry.ts"; @@ -108,19 +109,40 @@ function renderTypeRef(t: TypeRef): string { // ---- operation index ---- -function buildParams(args: InputValue[]): OperationParam[] { - return args.map((a) => ({ - name: a.name, - in: "argument" as const, - required: a.type.kind === "NON_NULL", - type: renderTypeRef(a.type), - })); +/** Unwrap NON_NULL/LIST wrappers to the underlying named type (for enum lookup). */ +function namedTypeName(t: TypeRef): string | undefined { + let cur: TypeRef | null | undefined = t; + while (cur && (cur.kind === "NON_NULL" || cur.kind === "LIST")) { + cur = cur.ofType; + } + return cur?.name ?? undefined; +} + +function buildParams( + args: InputValue[], + enumsByType: Map, +): OperationParam[] { + return args.map((a) => { + const param: OperationParam = { + name: a.name, + in: "argument" as const, + required: a.type.kind === "NON_NULL", + type: renderTypeRef(a.type), + }; + const description = clampDescription(a.description); + if (description) param.description = description; + const named = namedTypeName(a.type); + const values = named ? enumsByType.get(named) : undefined; + if (values) applyEnum(param, values); + return param; + }); } function fieldsToOps( type: FullType | undefined, method: string, tag: string, + enumsByType: Map, ): OperationInfo[] { if (!type?.fields) return []; return type.fields.map((f) => { @@ -129,7 +151,7 @@ function fieldsToOps( path: f.name, operationId: f.name, tags: [tag], - params: buildParams(f.args ?? []), + params: buildParams(f.args ?? [], enumsByType), returns: renderTypeRef(f.type), }; if (f.description) op.summary = f.description; @@ -139,7 +161,14 @@ function fieldsToOps( function buildOperationIndex(schema: GqlSchema): OperationInfo[] { const byName = new Map(); - for (const t of schema.types) if (t.name) byName.set(t.name, t); + const enumsByType = new Map(); + for (const t of schema.types) { + if (!t.name) continue; + byName.set(t.name, t); + if (t.kind === "ENUM" && t.enumValues?.length) { + enumsByType.set(t.name, t.enumValues.map((v) => v.name)); + } + } const queryType = schema.queryType ? byName.get(schema.queryType.name) : undefined; @@ -147,8 +176,8 @@ function buildOperationIndex(schema: GqlSchema): OperationInfo[] { ? byName.get(schema.mutationType.name) : undefined; return [ - ...fieldsToOps(queryType, "query", "Query"), - ...fieldsToOps(mutationType, "mutation", "Mutation"), + ...fieldsToOps(queryType, "query", "Query", enumsByType), + ...fieldsToOps(mutationType, "mutation", "Mutation", enumsByType), ]; } diff --git a/src/openapi.ts b/src/openapi.ts index 9984b13..d3c15fe 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -15,6 +15,7 @@ import { parse as parseYaml } from "@std/yaml"; import { toFileUrl } from "@std/path"; import { ensureCacheDir } from "./paths.ts"; import type { ProtocolAdapter } from "./adapter.ts"; +import { applyEnum, clampDescription } from "./operation.ts"; import type { OperationInfo, OperationParam } from "./operation.ts"; import type { RegistryEntry } from "./registry.ts"; import type { DiscoveredOAuth } from "./oauth.ts"; @@ -198,6 +199,31 @@ function schemaHint(root: Json, schemaNode: unknown): string { return "any"; } +/** Stringify a JSON enum list (skipping nulls); undefined if there's nothing usable. */ +function enumValues(v: unknown): string[] | undefined { + if (!Array.isArray(v) || v.length === 0) return undefined; + const out = v + .filter((x) => x !== null && x !== undefined) + .map((x) => typeof x === "string" ? x : JSON.stringify(x)); + return out.length ? out : undefined; +} + +/** + * Allowed values for a parameter schema: a direct `enum`, or - for an array + * parameter (e.g. Open-Meteo's `daily`/`hourly`) - the `enum` of its items. + */ +function schemaEnum(root: Json, schemaNode: unknown): string[] | undefined { + const s = obj(resolveRef(root, schemaNode)); + if (!s) return undefined; + const direct = enumValues(s.enum); + if (direct) return direct; + if (str(s.type) === "array") { + const items = obj(resolveRef(root, s.items)); + if (items) return enumValues(items.enum); + } + return undefined; +} + function buildParams(root: Json, rawParams: unknown[]): OperationParam[] { const params: OperationParam[] = []; for (const raw of rawParams) { @@ -210,12 +236,17 @@ function buildParams(root: Json, rawParams: unknown[]): OperationParam[] { location !== "path" && location !== "query" && location !== "header" && location !== "cookie" ) continue; - params.push({ + const param: OperationParam = { name, in: location, required: p.required === true || location === "path", type: schemaHint(root, p.schema), - }); + }; + const description = clampDescription(str(p.description)); + if (description) param.description = description; + const values = schemaEnum(root, p.schema); + if (values) applyEnum(param, values); + params.push(param); } return params; } @@ -301,18 +332,69 @@ export function discoverOAuth(spec: Json): DiscoveredOAuth | undefined { // ---- base URL / hosts ---- -/** Derive an absolute base URL from servers[0], resolving relative URLs and {vars}. */ -export function resolveBaseUrl(spec: Json, specSource: string): string { - const first = obj(arr(spec.servers)[0]); - let url = first ? str(first.url) : undefined; - if (url) { - const vars = first ? obj(first.variables) : undefined; - if (vars) { - url = url.replace(/\{([^}]+)\}/g, (m, name: string) => { - const v = obj(vars[name]); - return (v && str(v.default)) ?? m; - }); +/** + * Hosts that serve raw spec/text files but never a live API. A base URL derived + * onto one of these means the spec declared no usable server (or only a relative + * one, resolved against wherever the spec itself was fetched), so we fail loudly + * instead of silently pointing every request at a file CDN. + */ +const SPEC_HOSTING_HOSTS: ReadonlySet = new Set([ + "raw.githubusercontent.com", + "gist.githubusercontent.com", + "raw.githack.com", + "rawcdn.githack.com", + "cdn.jsdelivr.net", + "fastly.jsdelivr.net", + "cdn.statically.io", +]); + +function looksLikeSpecHostingHost(host: string): boolean { + return SPEC_HOSTING_HOSTS.has(host.toLowerCase().split(":")[0]); +} + +/** + * The first server object found at the document, path-item, or operation level, + * in that precedence order. OpenAPI 3.x allows `servers` at all three levels; + * many real specs (Open-Meteo among them) declare it only per-path, where a + * top-level-only lookup finds nothing and wrongly falls back to the spec's host. + */ +function firstServer(spec: Json): Json | undefined { + const top = obj(arr(spec.servers)[0]); + if (top) return top; + const paths = obj(spec.paths); + if (!paths) return undefined; + const pathItems = Object.values(paths) + .map((p) => obj(resolveRef(spec, p))) + .filter((p): p is Json => p !== undefined); + for (const pathItem of pathItems) { + const s = obj(arr(pathItem.servers)[0]); + if (s) return s; + } + for (const pathItem of pathItems) { + for (const method of HTTP_METHODS) { + const op = obj(pathItem[method]); + const s = op && obj(arr(op.servers)[0]); + if (s) return s; } + } + return undefined; +} + +/** Substitute `{var}` placeholders in a server URL with the variables' defaults. */ +function applyServerVars(url: string, server: Json): string { + const vars = obj(server.variables); + if (!vars) return url; + return url.replace(/\{([^}]+)\}/g, (m, name: string) => { + const v = obj(vars[name]); + return (v && str(v.default)) ?? m; + }); +} + +function deriveBaseUrl(spec: Json, specSource: string): string { + const server = firstServer(spec); + const rawUrl = server ? str(server.url) : undefined; + if (server && rawUrl) { + const url = applyServerVars(rawUrl, server); if (isUrl(url)) return stripTrailingSlash(url); if (isUrl(specSource)) { return stripTrailingSlash(new URL(url, specSource).toString()); @@ -328,6 +410,34 @@ export function resolveBaseUrl(spec: Json, specSource: string): string { ); } +/** + * Derive an absolute base URL from the spec's servers (document, path, or + * operation level), resolving relative URLs against the spec source and + * substituting `{vars}`. Falls back to the spec's own host only when no server + * is declared anywhere. Throws if the result lands on a known spec-hosting host + * (e.g. raw.githubusercontent.com) - a strong signal the real server was not + * found - so the caller fixes it with --base-url instead of registering a broken + * API that would send every request to a file CDN. + */ +export function resolveBaseUrl(spec: Json, specSource: string): string { + const baseUrl = deriveBaseUrl(spec, specSource); + let host: string; + try { + host = new URL(baseUrl).host; + } catch { + return baseUrl; // relative base from a local spec; hostsFromBaseUrl rejects it + } + if (looksLikeSpecHostingHost(host)) { + throw new Error( + `Derived base URL "${baseUrl}" points at ${host}, which hosts raw spec ` + + `files rather than a live API - the spec likely declares its server only ` + + `per-path/operation or relatively, or not at all. Pass --base-url (CLI) / ` + + `baseUrl (add_api) with the real API base, e.g. https://api.example.com.`, + ); + } + return baseUrl; +} + export function hostsFromBaseUrl(baseUrl: string): string[] { try { return [new URL(baseUrl).host]; diff --git a/src/operation.ts b/src/operation.ts index 564a419..7f207f4 100644 --- a/src/operation.ts +++ b/src/operation.ts @@ -10,6 +10,61 @@ export interface OperationParam { in: "path" | "query" | "header" | "cookie" | "argument"; required: boolean; type: string; + /** One-line human description, surfaced in search so the model needn't guess. */ + description?: string; + /** + * Allowed values, when the schema constrains them (an OpenAPI `enum`, the + * `enum` of an array's items, or a GraphQL enum type). Surfaced in search so + * the model can pick a valid value without a failing call to leak the set. + */ + enum?: string[]; +} + +// Caps so search payloads stay compact: a pathological spec can carry huge enums +// or paragraph-long parameter docs, and search returns up to MAX_RESULTS of them. +const MAX_ENUM_VALUES = 64; +const MAX_DESCRIPTION_LEN = 200; + +/** Normalize and length-cap a parameter description; undefined if empty. */ +export function clampDescription( + s: string | undefined | null, +): string | undefined { + if (!s) return undefined; + const t = s.trim().replace(/\s+/g, " "); + if (!t) return undefined; + return t.length > MAX_DESCRIPTION_LEN + ? t.slice(0, MAX_DESCRIPTION_LEN - 1) + "…" + : t; +} + +/** Cap an enum list to a sane size, reporting how many real values were dropped. */ +function clampEnum( + values: string[], +): { values: string[]; truncated: number } | undefined { + if (values.length === 0) return undefined; + if (values.length <= MAX_ENUM_VALUES) return { values, truncated: 0 }; + return { + values: values.slice(0, MAX_ENUM_VALUES), + truncated: values.length - MAX_ENUM_VALUES, + }; +} + +/** + * Attach a param's allowed values, keeping `enum` to real values only. When the + * list is capped, the dropped count is noted in `description` (prose) rather than + * polluting `enum` with a non-value sentinel a caller might select and send. + */ +export function applyEnum(param: OperationParam, values: string[]): void { + const clamped = clampEnum(values); + if (!clamped) return; + param.enum = clamped.values; + if (clamped.truncated > 0) { + const note = + `(+${clamped.truncated} more allowed values; see generated types)`; + param.description = param.description + ? `${param.description} ${note}` + : note; + } } export interface OperationInfo { diff --git a/src/register.ts b/src/register.ts index f814f5d..cebd40e 100644 --- a/src/register.ts +++ b/src/register.ts @@ -11,11 +11,14 @@ import { findEntry, type OAuth2Auth, type RegistryEntry, + removeEntry, + updateEntry, } from "./registry.ts"; import { writeOpsIndex } from "./operation.ts"; -import { setSecret } from "./keystore.ts"; -import { typesPathFor } from "./paths.ts"; +import { deleteSecret, setSecret } from "./keystore.ts"; +import { opsPathFor, typesPathFor } from "./paths.ts"; import { + clearOAuthSecrets, defaultRedirectUri, type DiscoveredOAuth, quirkForAuthUrl, @@ -66,6 +69,8 @@ export interface RegisterOptions { scopeSeparator?: string; /** Register with no auth even if OAuth was discovered. */ noAuth?: boolean; + /** Overwrite an existing entry with the same id instead of failing. */ + force?: boolean; /** Optional progress sink (the CLI passes console.error; the MCP tool omits it). */ onProgress?: (message: string) => void; } @@ -106,6 +111,75 @@ function resolveOAuth( export interface RegisterResult { entry: RegistryEntry; operationCount: number; + /** True when this call overwrote an existing entry with the same id. */ + overwritten: boolean; +} + +/** Keystore account names a given auth config references (none for kind "none"). */ +function secretAccounts(auth: Auth): string[] { + if (auth.kind === "bearer") return [auth.tokenKey]; + if (auth.kind === "oauth2") return [auth.clientKey, auth.tokenKey]; + return []; +} + +/** + * On overwrite, delete keystore secrets the old auth referenced that the new one + * no longer does (e.g. a bearer token left behind when re-registering as no-auth, + * or OAuth credentials dropped when switching to bearer). A same-kind re-register + * keeps its accounts, so an OAuth API stays logged in across a re-register that + * only fixes its base URL. + */ +async function cleanupOrphanedSecrets( + oldAuth: Auth, + newAuth: Auth, +): Promise { + const keep = new Set(secretAccounts(newAuth)); + for (const account of secretAccounts(oldAuth)) { + if (!keep.has(account)) await deleteSecret(account); + } +} + +export interface UnregisterResult { + /** False when no entry matched the id. */ + removed: boolean; + /** What secrets were cleared (empty string when none, or when not found). */ + secretsNote: string; +} + +/** + * Remove an API: its registry entry, stored secrets (bearer token, or OAuth + * client credentials + tokens), and cached artifacts (generated types + ops + * index). Shared by the `remove` CLI command and the `remove_api` MCP tool. + * Returns removed:false when no entry matches `id`. + */ +export async function unregisterApi(id: string): Promise { + const entry = await findEntry(id); + if (!entry) return { removed: false, secretsNote: "" }; + + let secretsNote = ""; + if (entry.auth.kind === "bearer") { + const removed = await deleteSecret(entry.auth.tokenKey); + secretsNote = removed ? "deleted bearer token" : "no bearer token stored"; + } else if (entry.auth.kind === "oauth2") { + const { token, client } = await clearOAuthSecrets(entry.auth, { + forgetClient: true, + }); + secretsNote = `deleted OAuth secrets (token: ${ + token ? "yes" : "none" + }, client: ${client ? "yes" : "none"})`; + } + + await removeEntry(id); + + for (const path of [entry.typesPath, opsPathFor(id)]) { + try { + await Deno.remove(path); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + + return { removed: true, secretsNote }; } /** @@ -137,9 +211,11 @@ export async function registerApi( "Could not derive an id from the base URL; pass an explicit id.", ); } - if (await findEntry(id)) { + const existing = await findEntry(id); + if (existing && !opts.force) { throw new Error( - `An API with id "${id}" already exists. Choose another id, or remove it first.`, + `An API with id "${id}" already exists. Re-run with force (CLI: --force, ` + + `add_api: force:true) to overwrite it, choose another id, or remove it first.`, ); } @@ -175,7 +251,29 @@ export async function registerApi( addedAt: new Date().toISOString(), }; if (opts.docsUrl) entry.docsUrl = opts.docsUrl; - await appendEntry(entry); - return { entry, operationCount: prepared.operations.length }; + if (existing) { + // In-place overwrite. The fresh addedAt invalidates serve's ops cache; the + // regenerated types/ops files already replaced the old ones at the same + // paths; same-id keystore accounts are preserved (an OAuth API stays logged + // in). Only drop secrets, and a types file at a now-stale path (kind change), + // that the new entry no longer references. + await cleanupOrphanedSecrets(existing.auth, entry.auth); + if (existing.typesPath !== entry.typesPath) { + try { + await Deno.remove(existing.typesPath); + } catch (err) { + if (!(err instanceof Deno.errors.NotFound)) throw err; + } + } + if (!(await updateEntry(entry))) await appendEntry(entry); + } else { + await appendEntry(entry); + } + + return { + entry, + operationCount: prepared.operations.length, + overwritten: existing !== undefined, + }; }