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
33 changes: 25 additions & 8 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,32 +25,44 @@ 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
puts a typed `client` in scope.
- `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
quirks table (e.g. Strava's real endpoints + comma scope separator).
- `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:<id>`
(bearer), `anyapi-mcp:<id>:client` / `anyapi-mcp:<id>: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)

Expand All @@ -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=<registered API hosts only>`, `--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
Expand Down
83 changes: 53 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <openapi\|graphql\|soap>` | Protocol (default: `openapi`). `graphql` introspects an endpoint; `soap` reads a WSDL URL. |
| `--id <slug>` | Id used on the CLI and in `execute` (default: the base URL in reverse-DNS form, e.g. `com.github.api`). |
| `--name <name>` | Human-friendly name (default: spec `info.title`). |
| `--base-url <url>` | Override the base URL derived from the spec's `servers[]`. |
| `--docs <url>` | 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 <name>` | Scope to request at login (repeatable; default: the spec's scopes). |
| `--scope-separator <sep>` | Scope separator in the authorize URL (default `" "`; Strava uses `","`). |
| `--no-auth` | Register without authentication. |
| Option | Description |
| --------------------------------- | --------------------------------------------------------------------------------------------------------- |
| `--kind <openapi\|graphql\|soap>` | Protocol (default: `openapi`). `graphql` introspects an endpoint; `soap` reads a WSDL URL. |
| `--id <slug>` | Id used on the CLI and in `execute` (default: the base URL in reverse-DNS form, e.g. `com.github.api`). |
| `--name <name>` | Human-friendly name (default: spec `info.title`). |
| `--base-url <url>` | Override the base URL (otherwise derived from the spec's document-, path-, or operation-level `servers`). |
| `--docs <url>` | 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 <name>` | Scope to request at login (repeatable; default: the spec's scopes). |
| `--scope-separator <sep>` | 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
Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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
Expand Down
10 changes: 7 additions & 3 deletions src/commands/add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ Options:
--scope <name> Scope to request at login (repeatable; default: the spec's scopes)
--scope-separator <sep> 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
Expand Down Expand Up @@ -55,7 +56,7 @@ export async function runAdd(args: string[]): Promise<void> {
"scope-separator",
],
collect: ["scope"],
boolean: ["help", "no-auth", "token", "oauth"],
boolean: ["help", "no-auth", "token", "oauth", "force"],
alias: { h: "help" },
});

Expand Down Expand Up @@ -97,7 +98,7 @@ export async function runAdd(args: string[]): Promise<void> {

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,
Expand All @@ -111,6 +112,7 @@ export async function runAdd(args: string[]): Promise<void> {
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"
Expand All @@ -119,7 +121,9 @@ export async function runAdd(args: string[]): Promise<void> {
? `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` +
Expand Down
42 changes: 6 additions & 36 deletions src/commands/remove.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
// `anyapi-mcp remove <id>` - 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<void> {
const flags = parseArgs(args, { boolean: ["help"], alias: { h: "help" } });
Expand All @@ -22,39 +20,11 @@ export async function runRemove(args: string[]): Promise<void> {
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}".`);
}
Loading
Loading