EveryAppEveryApp

MCP: tools, the connector and OAuth

How an app exposes tools to AI clients with defineTools(), how the Gateway serves them on its one /mcp connector under app-prefixed names, and how claude.ai, Claude Code, Cursor and scripts connect with OAuth or a personal access token.

The model

An app never speaks MCP. It describes its tools with defineTools() from @every-app/sdk/server and passes the result to everyApp(handler, { tools }) (packages/sdk/src/server/tools.ts, starter/src/entry.worker.ts). The Gateway snapshots that catalog into D1 and serves MCP clients from the snapshot through its ToolBus (@every-app/toolbus, packages/toolbus/src/, built on @modelcontextprotocol/server 2.4.0). There is one connector per Gateway, https://<gateway>/mcp, serving every app the caller can reach with tools named <app>_<tool> (todo_create_todo) plus the built-ins under everyapp_: everyapp_list_apps, everyapp_whoami, everyapp_search_tools, and the two call wrappers everyapp_call_read and everyapp_call_write (listed only when the connection gets the compact listing, callable always). An app host serves https://<app-host>/mcp (that app alone, under its tools' own names) only when its manifest opts in with mcp: { by: "gateway", why }; by default it serves no MCP, and the four launch apps don't ("The app host's own /mcp" below). tools/list never calls an app. tools/call makes exactly one request to the app's internal tools endpoint over its service binding, carrying a 120-second identity JWT (IDENTITY_TTL_SECONDS, packages/sdk/src/internal/protocol.ts) minted for that one call: the same verified user the browser gets, with chan: "mcp", explicit scopes (mcp:read, or mcp:read and mcp:write; never *) and a tc claim { id, name } naming the call and the tool. The handler receives that user; it never sees a cookie, a personal token or an OAuth token.

The person's side (the one address, a custom connector in Claude and ChatGPT, claude mcp add, a token for tools that can't sign in, disconnecting) is the user page /docs/connect-ai; the how-to every new app ships with is packages/cli/skills/everyapp/mcp.md (in an app .agents/skills/everyapp/mcp.md), which walks the starter's tools from defineTools() to a connected client, and docs/runbooks/mcp-oauth.md (/docs/agent/repo/runbooks/mcp-oauth) is the operator's runbook for the OAuth server.

defineTools() reference

packages/sdk/src/server/tools.ts, exported from @every-app/sdk/server (packages/sdk/src/server/index.ts): the values defineTools, toolResult and ToolError, and the types ToolDefinition, ToolContext, ToolSchema, PlainToolSchema, StandardSchemaWithJSON, ToolResult, ToolSet, DefineToolsOptions and ToolRisk. defineTools takes (tools, options?) and returns a frozen ToolSet (names: the tool names, sorted); pass it as everyApp(handler, { tools }). It is generic over the tools' inputs (defineTools<TInputs>, ToolDefinition<TInput>, ToolSchema<TInput>): each handler's first argument is typed as its input schema's output type. It validates every definition when called and throws a TypeError for a bad one (the table under "Fields of a tool definition"); the catalog itself (descriptors and hash) is built lazily, once per isolate.

import { ToolError, defineTools, toolResult } from "@every-app/sdk/server";
import { z } from "zod";

export const tools = defineTools(
  {
    list_todos: {
      title: "List todos",
      description: "List the user's todos.",
      risk: "read",
      input: z.object({}).strict(),
      handler: async (_input, { user }) => ({ todos: await list(user.id) }),
    },
  },
  { instructions: "Never guess todo ids; call list_todos first." },
);

Tool names

Lowercase snake_case, matching TOOL_NAME_PATTERN (/^[a-z][a-z0-9_]{0,47}$/, packages/sdk/src/internal/tools.ts): a letter, then letters, digits or underscores, at most 48 characters. On the Gateway's connector the name becomes <app-id>_<tool> (namespacedToolName, packages/toolbus/src/names.ts); app ids are kebab-case and never contain an underscore, so the first underscore ends the prefix. The namespaced name must fit MAX_TOOL_NAME_LENGTH 64, or the Gateway skips the tool with the warning "<app>_<tool>" is longer than 64 characters. everyapp is a reserved app id: the ToolBus names its built-ins under it (BUILTIN_APP_ID, packages/toolbus/src/builtins.ts); pnpm everyapp create refuses it ("everyapp" is reserved for the platform, packages/cli/src/commands/create/steps/appId.ts), the strict manifest validator refuses it (Invalid everyapp.config: id "everyapp" is reserved for the platform., isReservedAppId, packages/perimeter/src/manifest/manifest.ts) and app registration refuses it (400 RESERVED_APP_ID, The app id "everyapp" is reserved for the platform., packages/perimeter/src/registry/registration.ts).

Fields of a tool definition

Field Type Required Meaning
description string, non-empty yes What the tool does and when to use it; the model reads it. The Gateway skips a tool whose description is longer than 2048 characters.
risk "read" | "write" | "destructive" yes What a call can change; see "Risk levels". TypeError needs a risk: "read", "write" or "destructive" otherwise.
input ToolSchema yes The arguments schema; must describe a JSON object. See "Schemas".
handler (input, context) => unknown yes Runs the tool with the validated input and a ToolContext. See "Results" and "Errors".
title string no Short human-readable name clients can show. The Gateway skips a tool whose title is longer than 100 characters.
output ToolSchema no The result schema; must describe an object. The handler's return value is validated against it and sent as MCP structuredContent as well as text.
idempotent boolean no Repeating a call with the same arguments has no further effect. Sent to clients as annotations.idempotentHint.
openWorld boolean no The tool reaches systems outside the app. Sent as annotations.openWorldHint; when unset, clients receive openWorldHint: false (the spec reads a missing hint as true, so the ToolBus always spells it out; packages/toolbus/src/server.ts).

options.instructions (DefineToolsOptions) is guidance for the model spanning the app's tools, a string. The Gateway trims it to 2048 characters (warning instructions trimmed to 2048 characters); on its multi-app connector each app's instructions are clipped to 1024 characters and all of them together to 8 KiB (packages/toolbus/src/view.ts).

What clients receive per tool (toMcpTool, packages/toolbus/src/server.ts): name, title, description, inputSchema, outputSchema, and annotations with readOnlyHint: risk === "read", destructiveHint: risk === "destructive" on non-read tools, openWorldHint (default false) and idempotentHint when set. An app cannot claim readOnlyHint for a write tool: hints derive from the declared risk.

assertToolDefinition checks the definitions in sorted name order and throws the first problem as TypeError("defineTools: tool \"<name>\" <problem>"); options.instructions is checked after them. They fire when defineTools() runs, which in the starter is at module load (starter/src/tools.ts), so a bad definition fails the Worker at module load rather than serving a catalog without the tool.

TypeError message Cause
defineTools: tool "<name>" must be named in lowercase snake_case: a letter, then letters, digits or _ (at most 48 characters) The key fails TOOL_NAME_PATTERN.
defineTools: tool "<name>" must be an object The definition is not an object.
defineTools: tool "<name>" needs a description description is missing, not a string, or blank.
defineTools: tool "<name>" needs a risk: "read", "write" or "destructive" risk is none of the three.
defineTools: tool "<name>" input must be a Standard Schema with JSON Schema support (zod >= 4.2) or { jsonSchema, validate } input is neither form.
defineTools: tool "<name>" output must be a Standard Schema with JSON Schema support (zod >= 4.2) or { jsonSchema, validate } output is set and neither form.
defineTools: tool "<name>" needs a handler function handler is not a function.
defineTools: tool "<name>" title must be a string title is set and not a string.
defineTools: tool "<name>" idempotent must be a boolean, … openWorld must be a boolean The flag is set and not a boolean.
defineTools: instructions must be a string options.instructions is set and not a string.

Schemas

input and output accept either of (ToolSchema):

  • A validator implementing Standard Schema v1 and Standard JSON Schema v1 (~standard.validate plus ~standard.jsonSchema.input/output), which zod 4.2 and later does. The SDK takes no dependency on zod.
  • A plain pair { jsonSchema: Record<string, unknown>, validate(value) } (PlainToolSchema) for validators without Standard JSON Schema.

Anything else fails with TypeError input must be a Standard Schema with JSON Schema support (zod >= 4.2) or { jsonSchema, validate }.

How a schema becomes the JSON Schema clients see (objectJsonSchema): the converter is called with target: "draft-2020-12"; the result goes through a JSON round trip (dropping what JSON can't carry); every x-mcp-header keyword is removed at every depth (the 2026-07-28 MCP revision's header-mirroring hint; the Gateway fronts many apps and never routes on an app's headers); a $schema equal to https://json-schema.org/draft/2020-12/schema is dropped. Both sides must describe objects: a typeless input root (zod union/discriminatedUnion emit a root anyOf/oneOf without type) gets type: "object" added, as MCP arguments are always an object; a typeless output root gets it only when it is provably object-shaped (it has properties, patternProperties, additionalProperties or required, or every branch of oneOf/anyOf/allOf is an object; $ref is not followed). Otherwise the tool is left out of the catalog with a warning <name>: skipped, <side> must describe an object (JSON Schema type "object") or <side> has no JSON Schema form: <error>, logged as [everyapp] defineTools: tool "<name>" <reason> and reported to the Gateway in the catalog's warnings, instead of taking the app's other tools down.

The Gateway's own limits on a received schema (CATALOG_LIMITS, packages/toolbus/src/catalog.ts): serialized input or output schema at most 20 KiB (is larger than 20 KB), type must be "object", and every $ref must resolve inside the schema (is not a usable JSON Schema ($ref "<ref>" does not resolve in the schema)); a schema the Workers validator refuses is skipped with its message.

The handler's context

ToolContext, the second handler argument:

Member Type Meaning
user EveryAppUser The verified caller: id (the JWT sub), email, orgId, orgRole, channel: "mcp", actor: { sub } (pat:<token id>, oauth:<connection id>, access:<email>, or the user id for a browser or dev session), scopes (["mcp:read"] or ["mcp:read", "mcp:write"]), jti, and toolCall: { id, name } naming this call (packages/sdk/src/internal/protocol.ts, packages/toolbus/src/execute.ts).
env Cloudflare.Env The Worker's bindings.
ctx ExecutionContextLike waitUntil and friends.
request Request The internal POST /__everyapp/tools/call request, not the client's MCP request.
callId string The call's id (a UUID the ToolBus generated); also the ref in masked error messages.

Handlers must scope every read and write by user.id (and user.orgId): an MCP client never supplies a user id.

The identity's scopes also bound what the handler can ask the Gateway for. sendEmail() (/docs/agent/email) under a chan: "mcp" identity without mcp:write is refused 403 integration_read_only (This agent connection is read-only, and sending email writes. Reconnect the agent with write access.; apps/every-app-gateway/src/server/capabilities/email.ts), which the SDK throws as GatewayRefusalError, so a read tool's handler can send mail only when the caller connected with read & write.

Results

toToolResult (packages/sdk/src/server/tools.ts) turns the handler's return value into an MCP CallToolResult:

Handler returns Client receives
a string one text content block
undefined content: []
any other JSON-serializable value, no output schema one text block holding JSON.stringify(value)
a value, with an output schema the value validated against output (it must be a JSON object), sent as structuredContent and as a text block of its JSON
toolResult({ text, structured }) text as the text block (a summary, a table the model reads) and structured as structuredContent, validated against output when there is one; structured must be a JSON object

toolResult marks its return value with Symbol.for("everyapp.toolResult"); a handler returns it as is. The ToolBus relays at most MAX_RESULT_BYTES 1 MiB from the app; past that the client gets the error The result from <app> is larger than 1 MB. (packages/toolbus/src/execute.ts).

Errors

Throw ToolError (new ToolError("No todo with that id")) for an error the model can read and act on: it reaches the client as { content: [{ type: "text", text: message }], isError: true }. Any other thrown error, and a result that fails its output schema (result does not match the output schema: …) or isn't an object (structured results must be JSON objects), is logged in the app as [everyapp] tool "<name>" failed (ref <callId>): <error> and reaches the client only as Internal error in <app-id> (ref <callId>) with isError: true (packages/sdk/src/server/toolsEndpoint.ts). A failed ToolError never un-commits a mutation: the handler already ran.

Risk levels

TOOL_RISKS and requiredScopeForRisk (packages/sdk/src/internal/tools.ts); policy in packages/toolbus/src/policy.ts.

risk Meaning Scope the call's identity must carry What clients get today
"read" Changes nothing. mcp:read Listed and callable for every caller; readOnlyHint: true.
"write" Creates or changes data. mcp:write Listed and callable for read & write callers; hidden from read-only ones (policy reason read_only_connection); destructiveHint: false.
"destructive" Deletes or irreversibly changes data. mcp:destructive Hidden from every caller until approvals exist (policy reason destructive_requires_approval): absent from tools/list, and a call answers JSON-RPC -32602 Unknown tool: <name>. mcp:destructive is never minted (MCP_DESTRUCTIVE_SCOPE). The handler stays in the app.

A tool the Gateway receives without a risk is treated as write; an unknown risk is skipped (unknown risk "<value>"). Hidden means nothing about the tool leaks: the client sees the same Unknown tool as for a name that never existed, through everyapp_call_read and everyapp_call_write as much as by a direct call, and everyapp_search_tools never returns it. Unlisted is not hidden: in the compact listing mode ("Built-in tools" below) an allowed tool is absent from tools/list yet callable by its own name and through the wrappers, and the search finds it. The SDK enforces the scope again on every call (Calling <name> needs the <scope> scope.); * does not count there, because the ToolBus only mints explicit scopes.

The internal tools contract

packages/sdk/src/internal/tools.ts (shapes, paths, hash) and packages/sdk/src/server/toolsEndpoint.ts (the app side). everyApp() answers two reserved paths before any app code runs, whether or not the app defines tools, and the perimeter refuses both from outside with 404 {"error":"not_found"} however the path is encoded (isReservedToolsPath, packages/perimeter/src/gateway.ts), so only the Gateway's ToolBus reaches them over the service binding. Both are POST with cache-control: no-store answers; TOOLS_PROTOCOL_VERSION is 1. These boundaries (the perimeter refusing the endpoints from outside, the ToolBus minting a one-call identity, the app acting only for the user it names) are the subject of docs/threat-model.md (assets, actors, trust boundaries, one testable invariant per boundary), mirrored at /docs/agent/repo/threat-model.

Endpoint Credential Request body Success body
POST /__everyapp/tools/list x-everyapp-system: a system JWT, purpose tools.list, 60 s (SYSTEM_TOKEN_TTL_SECONDS); it can't call tools {"v":1} { v: 1, sdk: "0.3.0", hash: "sha256:<hex>", instructions?, tools: ToolDescriptorV1[], warnings?: string[] }
POST /__everyapp/tools/call x-everyapp-identity: an identity JWT with chan: "mcp" and tc: { id, name } {"v":1,"name":"<tool>","arguments":{…},"callId":"<tc.id>"} { v: 1, catalogHash: "sha256:<hex>", result: { content, structuredContent?, isError? } }

hash is sha256: over the canonical JSON (keys sorted at every depth, undefined dropped) of { instructions: instructions ?? null, tools } (catalogHash). An app without tools still answers /list with an empty catalog, so the Gateway can tell "no tools" from "no contract". Error answers are { v: 1, error: { code, message, issues? } }:

HTTP code When
405 method_not_allowed Not POST (Use POST., allow: POST).
500 misconfigured The identity verification options can't be resolved (ConfigurationError), or the whole catalog can't be built.
401 unauthenticated The system or identity JWT doesn't verify (IdentityError message).
400 invalid_request Body isn't {"v":1} / {"v":1,"name","arguments","callId"}.
403 forbidden Tool calls need an identity the gateway minted for this call. (no chan: "mcp" or no tc); The identity was minted for a different tool call. (name/callId differ from tc); Calling <name> needs the <scope> scope.
404 unknown_tool Unknown tool: <name>; the ToolBus then marks the snapshot drifted and re-syncs.
400 invalid_arguments Invalid arguments for <name>: <path>: <message>; …, with issues: [{ message, path? }].

Snapshots: deploy, activation and first use

The Gateway stores each app's catalog in D1 (app_tool_catalogs, app_tools; apps/every-app-gateway/src/server/toolbus/catalog-sync.ts, AppToolRepository) with the app version (apps.version_id) it was taken from and a status ok, empty, unsupported (the app answered 401, 403, 404 or 405: SDK older than 0.3, does not serve agent tools (EveryApp SDK 0.3 or later)) or error. One sync is one POST /__everyapp/tools/list over the service binding with a 15 s deadline covering the body (CATALOG_TIMEOUT_MS), the response capped at 5 MiB (CATALOG_LIMITS.responseBytes), duplicate names skipped. There is no count cap per app: a catalog is bounded by its per-tool limits (the 64-character namespaced name, the 2048-character description, the 100-character title, 20 KiB per schema), by the catalog response size (5 MiB, 2048 characters of instructions) and by the one atomic D1 write that stores the snapshot (AppToolRepository, apps/every-app-gateway/src/server/repositories/AppToolRepository.ts: TOOL_ROWS_PER_INSERT rows per statement, every statement in one db.batch, so a snapshot is stored whole or not at all), which holds the largest catalog that passes against Cloudflare's documented D1 limits (100 bound parameters per statement, 2 MB per row, no cap on a batch's statements), so the response size is the bound; the warnings kept on a snapshot are capped at 100 (warningsMax). It happens:

Trigger (mcp.catalog.sync log field trigger) When
first_use The app is activated (activateApp, apps/every-app-gateway/src/server/app-activation.ts), after the response; a POST to the Gateway's /mcp finds an app with no snapshot or one from another version than the app runs (findVisibleUnsyncedApps; gateway-mcp.ts passes first_use for both): synced within the request, up to 20 apps (INLINE_SYNCS_MAX) for 3 s (INLINE_SYNC_BUDGET_MS); what finishes in time is in the answer, the rest finishes after it (waitUntil); or, on an app host whose manifest has mcp: { by: "gateway" }, a /mcp POST finds no snapshot.
new_version A deploy through the Gateway's own pipeline finishes (its deploy API or a browser install, apps/every-app-gateway/src/server/deploy/run.ts); the first request on an isolate that sees an active app run a version it hasn't checked (followAppVersion); or an app host's /mcp POST finds a snapshot from another version than the app runs (app-host-mcp.ts is the only /mcp surface that tells new_version from first_use). Until the new snapshot is stored, the older version's keeps serving.
consent The OAuth consent page lists an app with no snapshot or one from another version (the same selection and budget as the Gateway's /mcp).
drift A tool call's answer reports a catalogHash other than the snapshot's, or unknown_tool: re-synced in the background after the response.

On the Gateway's connector and the consent page, a sync whose last attempt failed is retried only in the background and at most every 5 minutes (RETRY_FAILED_SYNC_MS; syncUnsyncedCatalogs schedules it after the response), so a broken app never slows a request there. On an app host's /mcp (where the manifest has mcp: { by: "gateway" }), a POST that finds the current version's snapshot in a status other than ok (error, and likewise empty or unsupported) is answered 503 { "error": "mcp_unavailable", "app" } while that snapshot is younger than 5 minutes, and re-syncs within the request once it is older (servableCatalog, app-host-mcp.ts). One isolate claims at most one sync per app per 30 s (SYNC_WINDOW_MS). everyapp deploy from the CLI does not sync tools: packages/cli/src/commands/deploy/deployApp.ts says the Gateway takes the app's agent tools itself, on first use and whenever the app's version changes, and a self-host redeploy is noticed from the app host's next request in each isolate. The SDK's and the Gateway's skip warnings are stored on the snapshot (warnings) and counted in the mcp.catalog.sync line; pnpm everyapp dev prints them as TOOLS_CATALOG warnings. A GET never makes the Gateway call an app.

How clients learn about changes

/mcp keeps nothing between requests (packages/toolbus/src/http.ts), yet a connected client learns when its tool list changes (an app added, removed or redeployed, access gained or lost):

  • 2025-06-18 / 2025-11-25 clients (the initialize handshake): initialize issues an Mcp-Session-Id of <uuid>.<client>.<16 hex chars>, the suffix a SHA-256 prefix of the caller's tool list (toolListHash), the middle part the client's own clientInfo from initialize as <name> <version> in base64url without padding (at most 128 characters; omitted, <uuid>.<16 hex chars>, when the client named none), which is how a stateless server audits later calls on the session with the client's name (describeClient, clientOfSession, packages/toolbus/src/http.ts). A later request whose session carries another hash is answered as an SSE stream, notifications/tools/list_changed before the result, with a new mcp-session-id header; a tools/list on such a session gets HTTP 404 -32001 Session not found, so the client initializes again. Claude Code refetches tools/list within milliseconds of either.
  • 2026-07-28 clients (per-request _meta envelope, mcp-method and mcp-protocol-version headers): no sessions. A subscriptions/listen request with notifications: { toolsListChanged: true } is held open as an SSE stream: notifications/subscriptions/acknowledged first, then every 30 s (listenPollMs, default LISTEN_POLL_MS) the view is re-read and either notifications/tools/list_changed is pushed or a : keepalive comment is written; after the surface's hold the stream ends gracefully with a resultType: "complete" result and the client opens a new one: 30 minutes on the Gateway's connector (GATEWAY_LISTEN_MAX_MS), 10 on an app host (APP_LISTEN_MAX_MS; listenMaxMs on ToolBusDeps overrides both), and no later than the OAuth access token's or Access assertion's expiry (listenUntil on the view, from the token the request carried). The hold is sized for Claude Code (2.1.309), which counts its reopens in a trailing hour and parks re-listening for about six hours once five are in it; two scheduled closes an hour leave room for a deploy, an evicted isolate and a token expiry. A poll whose re-read fails (D1 unavailable) keeps the stream open and pushes nothing; three failed polls in a row end it. A poll that finds the caller refused (ViewRefusedError from refresh: a revoked connection or token, a removed or deactivated member, a suspended organization) ends it at once. It needs Accept: text/event-stream (else 406), only toolsListChanged is served (a filter without it is acknowledged and closed), and one credential can hold 8 streams per isolate (LISTEN_STREAMS_PER_CALLER; more get -32603 Subscription limit reached). On the Gateway's connector every poll first rechecks the caller (the OAuth connection, the personal token or the Access identity, without touching last-used), so a revoked connection's stream ends at the next poll (recheckFor, apps/every-app-gateway/src/server/toolbus/gateway-mcp.ts). The modern era declares listChanged: true only when the view can be re-read (view.refresh): the Gateway's connector and app hosts can; everyapp dev can't (below).
  • tools/list carries the 2026-07-28 cache hint ttlMs: 60000, cacheScope: "private" (TOOLS_LIST_TTL_MS).

The connector: https://<gateway>/mcp

Routes in apps/every-app-gateway/src/server/mcp-auth/routes.ts; serving in apps/every-app-gateway/src/server/toolbus/gateway-mcp.ts.

Who can call it

Credential Resolved by Access Apps served
OAuth access token this Gateway issued (Authorization: Bearer …) resolveOauthCaller (mcp-auth/validate.ts) The connection's choice (read & write or read only), capped by the token's scopes The connection's chosen apps, or every active app of its organization the user can use (plus the shared apps on the hosted Gateway)
Personal access token Authorization: Bearer epat_… resolvePatCaller, recognized by its prefix before the OAuth server sees it * or mcp:write: read & write; mcp:read: read only; anything else: 403 token_scope The one app the token is bound to, or every app the user can use
Cf-Access-Jwt-Assertion from Cloudflare Access (outer ring configured) resolveAccessCaller (mcp-auth/access-caller.ts) Read & write, like a browser session Every app the member can use

Never a cookie. Nothing is cached: every request re-reads the connection or token, the member's role and ban state, the organization's status and app access, so revocation, removal, deactivation, suspension or losing an app takes effect on the next request. A refused caller gets 403 { "error": "forbidden", "reason": … } with reasons token_scope, organization_suspended, not_connected, account_deactivated, organization_unresolved, and for the Access reasons a message saying what to do. An OAuth access token that verifies but whose scope carries neither mcp:read nor mcp:write is refused by mcpBearerValidator with the OAuth error insufficient_scope (This token carries no MCP access., required scope mcp:read); the resource server is built with requiredScopes: ["mcp:read", "mcp:write"], which the library advertises (the challenge, the protected-resource metadata) and does not enforce. Both concern only a token narrowed below what consent grants, since every connection is granted mcp:read; a grant is requested ∩ supported ∩ approved: mcp:write only when the client asked for it (or named no scope) and the person chose read & write, and the consent page offers read & write only to a client that asked (grantedScopes, writeRequested, mcp-auth/consent.ts). A token whose connection no longer stands (revoked, superseded, lapsed, or the member removed or deactivated) gets invalid_token (This connection was revoked or lost its access. Connect again.), so the client signs in again (mcpBearerValidator, mcp-auth/validate.ts).

Without a credential, POST /mcp answers 401 with WWW-Authenticate: Bearer realm="OAuth", resource_metadata="<GATEWAY_URL>/.well-known/oauth-protected-resource/mcp", scope="mcp:read mcp:write" while OAuth is on (what the resource supports and the consent page can grant; a client that then asks for mcp:read alone gets a read-only connection whatever the person chooses), and Bearer realm="every-app" (or Bearer realm="every-app", error="invalid_token" for a bad bearer) with body {"error":"unauthenticated"} while it is off, so a client fails fast instead of half-registering.

What a caller sees

gatewaySurfaceView (packages/toolbus/src/view.ts): serverInfo name every-app, title <organization> · EveryApp, version 0.1.0 (TOOLBUS_VERSION), and instructions beginning Tools for <organization> on EveryApp. Each tool is named `<app>_<tool>`; call everyapp_list_apps to see what each app is for. everyapp_search_tools finds a tool by words. in the full listing mode, or Tools for <organization> on EveryApp. This connection lists its <N> tools compactly: call everyapp_search_tools (words, an app id, or both) to find tools and their input schemas, then everyapp_call_read or everyapp_call_write with a tool's name and arguments. Call everyapp_list_apps to see what each app is for. in the compact one (<N> the number of app tools this caller could reach; for a read-only caller the middle clause is then everyapp_call_read with a tool's name and arguments (this connection is read only), since the write wrapper is hidden from it and the head never names a tool the caller's tools/list lacks), then Apps: <id> (<name>), …, one <id> (<name>) is not available: <reason>. line per app that needs setup, then, for an owner or admin of the install organization only, one <id> (<name>) is off and its key is set: an admin runs pnpm everyapp mcp on <id>. line per provider app whose key is set on the Gateway while the install organization has no row for it and no deployed app of its lists the provider's capability (offered on the view, an OfferedApp { appId, name, command }; offeredProviderApps in gateway-mcp.ts checks the caller's role (isOwnerOrAdmin), then the Gateway's env, then the catalogs already read (an app the caller sees has its row), and only in that state reads the install organization's id (OrganizationRepository.findInstallOrganizationId) and its live rows (AppRepository.findSlugsAndManifests): the rows decide, never the caller's view, so an admin without a grant, a connection that chose other apps or a token bound to one app is never told to turn on what is on; gatewaySurfaceView drops offered for any other role, so a member never sees operator state, and another organization's owner never does either since mcp on writes the install organization's row), and then each app's own instructions. That head (header, app list, not-available lines, offered lines) comes first. Nothing in the code clips it; a test (view.test.ts, with CLAUDE_CODE_INSTRUCTIONS_READ) pins it inside Claude Code's 4,096-character read of a server's instructions for the launch apps, and a gateway with many more apps, or many that need setup, can grow it past that read (Codex's documentation asks that the first 512 characters stand on their own). Each app's own instructions are clipped to 1024 characters, and the per-app blocks that follow the head stop once the whole reaches 8 KiB, a later app's block dropped whole, so an app's own guidance may fall past a client's cut and the words that matter belong first. tools/list returns the built-ins first, then, in the full listing mode, apps by id and tools by name, after policy: destructive tools hidden from everyone, write tools hidden from read-only callers; in the compact mode it returns the built-ins alone (the listing modes are under "Built-in tools").

Built-in tools

packages/toolbus/src/builtins.ts. Named under the reserved app id everyapp, Gateway connector only, result as JSON text and structuredContent, every call audited with an args_digest like an app tool's. Three are read-only (readOnlyHint: true, idempotentHint: true, openWorldHint: false) and answered from the caller's view without calling any app; two are wrappers that each delegate exactly one inner tool call. Built-ins take arguments: executeToolCall runs the same shape checks on them as on an app tool's (a JSON object, nested at most 64 levels) and then validates them against the built-in's own inputSchema (additionalProperties: false on each) whatever their value count: the 1,000-value gate under "Executing a call" is an app schema's alone, since the app validates again and nothing validates after a built-in, and everyapp_search_tools reads its fields to the schema's bounds itself (a padded call with a 201-character query or a limit of 26 is refused like an unpadded one); so a bad call answers Invalid arguments for <built-in>: <problem> as a tool error (isError: true), audited with outcome invalid_arguments; everyapp_list_apps and everyapp_whoami keep the empty schema {}. Each listed built-in carries _meta["anthropic/alwaysLoad"]: true (the anthropic/alwaysLoad key) in both listing modes, which Claude Code reads since 2.1.285 to keep them loaded ahead of its tool search while app tools stay deferred; a server-level alwaysLoad: false in a person's Claude Code configuration (2.1.287 and later) keeps them deferred anyway, and other clients ignore the key.

Tool Input Result
everyapp_list_apps {} { apps: [{ id, name, description?, url, tools }, …, { id, name, available: false, reason }] }: each app this connection can use, its URL (https://<app-host>) and how many tools this caller can see; then each app that needs setup (a required setting unset), with reason needs setup: an admin runs pnpm everyapp secret set <names> [--app <id>] (plain needs setup for a caller who can't run it). Such an app has no tools in tools/list and is named the same way in the instructions. Then, for an owner or admin, each provider app an admin could turn on, as { id, name, available: false, offered: true, reason } with reason off and its key is set: an admin runs pnpm everyapp mcp on <id> (the view's offered, above).
everyapp_whoami {} { user: { email, role }, organization: { id, name? }, connection: { kind, access, client? }, apps: [ids] }; kind is pat, session, dev, oauth or access, access is read & write or read only.
everyapp_search_tools { query?, app?, limit? }: query a string of 1 to 200 characters, app an app id (1 to 64 characters), limit an integer from 1 to 25 (default 10). At least one of query and app, else the tool error Invalid arguments for everyapp_search_tools: give a query, an app id, or both. (outcome invalid_arguments). { tools: [{ name, app, title?, description, risk, inputSchema }], total, truncated? }, as JSON text and structuredContent: the tools this caller may call, searched over the policy-filtered view (never a destructive tool, no write tool for a read-only caller, no built-in), each with its listed name <app>_<tool>, its risk (read or write) and its full input schema, so the client can call it next. Ranking is deterministic: the query is lowercased and split on non-alphanumerics into tokens; a tool scores 10 when the query equals its listed or local name, 5 per token contained in its name, 3 per token in its title, 1 per token in its description; with a query, tools scoring 0 are left out; ties by app id, then local name. app alone returns that app's tools by name. total counts the matches; the list stops, with truncated: true, once the serialized result would pass SEARCH_RESULT_MAX_BYTES (256 KiB). One audit row: tool everyapp_search_tools, app everyapp, risk read, with the arguments' digest and result_bytes.
everyapp_call_read { name, arguments? }: name a string of 1 to 128 characters, the listed name of an app tool (<app>_<tool>); arguments a plain object, the inner tool's arguments. Wrapper arguments that fail the shape checks (not an object, name missing or not a string, arguments present but not a plain object, too deep) answer Invalid arguments for everyapp_call_read: <problem> with one row (tool everyapp_call_read, outcome invalid_arguments). The inner tool's own result, exactly as a direct tools/call of it answers. The wrapper delegates one call through the app-tool path of executeToolCall (resolve the name, policy, shape checks, the snapshotted schema, one identity, one app invocation, drift, audit): no fan-out, no recursion. Risk read, with a read ceiling: it resolves read tools only, and the identity it mints carries mcp:read alone whatever the caller's access; a write tool through it answers the tool error `<name>` changes data; call it through everyapp_call_write. with one row (tool the inner name, via everyapp_call_read, decision blocked, reason write_via_read_wrapper, outcome not_run). A built-in name, an unknown name or a policy-hidden tool answers the tool error Unknown tool: <name> (the name cut past 128 characters, displayedToolName) with one row (tool the inner name, via the wrapper, decision blocked, reason unknown_tool or the policy reason, outcome not_run).
everyapp_call_write The same { name, arguments? }, the same shape errors under its own name. The same delegation at risk write: for a read & write caller it resolves read and write tools and mints the caller's own scopes (scopesForAccess); for a read-only caller it is hidden like any write tool (absent from tools/list and from the search; a call answers the protocol-level -32602 Unknown tool: everyapp_call_write, audited blocked with reason read_only_connection and tool everyapp_call_write).

A call through either wrapper writes no audit row of its own: the inner call's single row names the inner tool, sets via to the wrapper, and digests the inner arguments, so a call made through the wrapper and the same call made directly have the same args_digest; one tools/call is one row through every path. Admin → Activity shows via on such a row. The wrappers are callable on the Gateway connector in both listing modes and listed only in the compact one.

Listing modes (listing on the view, packages/toolbus/src/view.ts; Gateway connector only, an app host's /mcp always lists in full). full: tools/list is everyapp_list_apps, everyapp_whoami, everyapp_search_tools, then the workspace tools the caller's role reaches (below), then the apps' tools exactly as above. compact: everyapp_list_apps, everyapp_whoami, everyapp_search_tools, everyapp_call_read and, for a read & write caller, everyapp_call_write; no app tools and no workspace tools. The mode is the caller's explicit choice when it made one; else compact when the full listing (the built-ins listed in full mode, the workspace tools this caller would be listed on the Gateway connector, and every policy-allowed app tool, as tools/list would serialize them; gateway-mcp.ts passes workspace: true to gatewaySurfaceView, so an owner's 30 workspace tools count where a member's four do) would pass FULL_LISTING_MAX_TOOLS (256 entries, claude.ai's reported connector cap) or FULL_LISTING_MAX_BYTES (1 MiB of serialized JSON); else full. Both constants are exported from @every-app/toolbus. The explicit choice is the header X-EveryApp-Tools: full or X-EveryApp-Tools: compact (case-insensitive; any other value is ignored) or the query ?tools=full or ?tools=compact on /mcp, read for every caller kind the Gateway serves, the header first when both are given (gateway-mcp.ts passes it to gatewaySurfaceView as listing, and a held listen stream re-reads with the same choice); the header is in the connector's CORS allow-headers (x-everyapp-tools). The query string changes nothing about the OAuth resource, which stays /mcp (mcp.one-protected-resource: the 401 challenge and the protected-resource metadata name https://<gateway>/mcp whatever the query), and that decides who can use the query form: a personal token, an Access caller, and an OAuth client whose resource check is by origin and path prefix (Claude Code; the TypeScript SDK's checkResourceAllowed ignores the query) can connect to /mcp?tools=compact; a client that requires the metadata's resource to equal the URL it was given, query included (claude.ai, Claude Desktop, ChatGPT; RFC 9728 section 3.3), fails discovery on the query form and must connect to the bare /mcp, and since such a client sets no header either, it gets the automatic rule. Registering /mcp?tools=full and /mcp?tools=compact as resources of their own would open the query form to them and amend mcp.one-protected-resource; that is a decision in TODO.md, not built. A mode change rotates a 2025-era session like any list change (the session's hash is computed from the listed tools; a stale session's tools/list answers 404 and the client initializes again), and a 2026-07-28 listen stream reports it as notifications/tools/list_changed. Compact never hides anything: a tool left unlisted stays callable by its own name and through the wrappers and is found by the search, while a tool hidden by policy stays Unknown tool through every path. For a caller whose list stays under both thresholds nothing changes but the three new built-ins.

Workspace tools

Every admin action of the Gateway's UI (Admin → Users, Groups, Apps, Usage, Activity, Events, and the organization's name) is a built-in on the Gateway connector too, under the reserved app id everyapp as workspace_<action> (everyapp_workspace_users and so on, the table below), with four more a member has on their own account. Definitions (name, schema, risk, the lowest role that sees the tool) are in packages/toolbus/src/workspace.ts; the handlers, which call the same services as the UI's server functions on an OrgContext built from the caller's live role, are in apps/every-app-gateway/src/server/toolbus/workspace-tools.ts (ToolBusDeps.workspace; without it, as on an app host's /mcp and under everyapp dev, none is listed). The design is the agent parity page under docs/design (2026-10, "Agent parity: everything the UI can do, the CLI and MCP can do").

The rules, the same for every one of them:

  • Role: a tool is listed and runs only for a caller whose live role reaches its minRole (member < admin < owner); below it the tool is hidden, Unknown tool on call, audited blocked with reason role_required. "Admin" below means owner or admin. A read-only connection never sees a write tool (reason read_only_connection), like any app tool. A credential narrowed to apps (a personal token bound to one app, or an OAuth connection consented to "Only these apps") sees none of them, whatever the person's role: it reaches those apps' tools and administers nothing (reason app_scoped_credential; the Gateway sets appScope: "selected" on the principal from the token's app or the connection's chosen apps, gateway-mcp.ts). The services' own rules apply on top (only an owner changes an admin, never yourself, never an owner, a shared app is nobody's to grant) and come back as tool errors CODE: message (isError: true, outcome tool_error); any other failure answers <tool> failed. with nothing more (outcome app_error, reason host_error).
  • Listing: full mode only, after the core built-ins and before the apps' tools. In the compact listing an admin finds them with everyapp_search_tools (which returns them in either mode, under app everyapp, to the caller's role) and runs them through everyapp_call_read (read tools only) or everyapp_call_write, like app tools. They carry no anthropic/alwaysLoad: Claude Code defers them behind its tool search like an app's tools.
  • Arguments: the tool's own schema (closed objects, every field described, no regular expressions, references or unions), checked whatever the value count. Identifiers are what a person would type: a user by email or id, an app by manifest id (crm) or row id, a group by name or grp_ id, an invitation by id or email, a token by id or name.
  • Audit: one mcp_tool_calls row per call under app everyapp, with the arguments' digest, result_bytes, and via when wrapped; Admin → Activity shows an agent's admin actions like any tool call.
  • Never a credential that opens the Gateway: no invitation link, no password-reset link, no token value; and no tool creates a personal access token (a revocable connection must not mint itself a year-long credential). An admin gets a link from Admin → Users. The one secret a tool returns is the webhook signing secret everyapp_workspace_events_add generates when the caller gives none: once, in that answer, never again. It signs deliveries to the URL the caller chose and opens nothing on the Gateway; the person stores it in the receiver at once (toolbus.workspace-tools-no-credentials).
Tool Role Risk What it does
everyapp_workspace_show admin read The organization (id, name, slug), your role, whether email is configured, counts of members, pending invitations, groups and apps.
everyapp_workspace_users admin read Members (id, name, email, role, deactivated, createdAt the account's creation, joinedAt when they joined this organization) and pending invitations (id, email, role, expiry; an expired one stays listed until canceled).
everyapp_workspace_invite admin write { email, role? } (member or admin): creates the invitation and mails it when the Gateway can; answers invitationId, email, expiresAt, emailed, and next when it was not mailed. Never the link. INVALID_INPUT for an address that isn't one (the UI's rule), ALREADY_MEMBER, ALREADY_INVITED, INVITATION_LIMIT_REACHED once the organization holds 100 pending, unexpired invitations (Better Auth's invitationLimit, which the UI path shares).
everyapp_workspace_uninvite admin write { invitation } (id or email): cancels a pending invitation, an expired one too (Admin → Users lists and cancels those alike). INVITATION_NOT_FOUND.
everyapp_workspace_role admin write { user, role } (member, admin, owner): MembershipService.changeRole. An admin changes members only (OWNER_REQUIRED for an admin, an owner, or asking owner); an owner makes and demotes owners; never yourself (CANNOT_MANAGE_SELF), never the organization's last owner (LAST_OWNER).
everyapp_workspace_deactivate admin write { user }: lock the member out everywhere (sessions, tokens, connections revoked).
everyapp_workspace_reactivate admin write { user }: let them sign in again.
everyapp_workspace_remove admin write { user, confirm: true }: remove the membership (and the account when it belongs to no other organization); deactivates instead where Access would let them back in, and says so (deactivatedInstead). destructiveHint: true.
everyapp_workspace_reset_email admin write { user }: mail the member a password-reset link (the same one-hour, single-use token as Admin → Users → Copy password-reset link, sent and awaited). The link goes to them, never to the caller. EMAIL_NOT_CONFIGURED where the Gateway sends no email (refused before any token is minted; the UI hides the action then), EMAIL_DELIVERY_FAILED when the mail could not go; sent: true only once it went.
everyapp_workspace_groups admin read The groups with member and app counts.
everyapp_workspace_group_create admin write { name, description? }.
everyapp_workspace_group_set admin write { group, name?, description? }: a field left out keeps its value.
everyapp_workspace_group_delete admin write { group }: the access it granted ends with it.
everyapp_workspace_group_members admin read { group }: every member with inGroup.
everyapp_workspace_group_members_set admin write { group, users } (the full list) or { group, add?, remove? } (reads the list, changes it, sets it); both forms at once are refused.
everyapp_workspace_group_apps admin read { group }: the organization's own apps with granted.
everyapp_workspace_group_apps_set admin write { group, apps } or { group, add?, remove? }, apps by manifest id or row id.
everyapp_workspace_apps admin read Every app (row id, manifest id, name, description, status, everyone, accessCount, readOnly for a shared app, setup); never the manifest or secret names.
everyapp_workspace_app_set admin write { app, name?, description?, everyone? }: everyone: true grants every current and future member.
everyapp_workspace_app_access admin read { app }: every member with hasAccess (a direct grant) and viaGroups.
everyapp_workspace_app_access_set admin write { app, users } or { app, add?, remove? }: direct grants only; the caller's own access is never revoked.
everyapp_workspace_app_cap admin write { app, cap?, costUsd?, unlimited? }: calls per month (1 to 1,000,000,000) and dollars of provider-reported cost per month (0.01 to 1,000,000, whole cents; counted only where the provider reports a price: OpenRouter, DataForSEO), each a number, null for none of its own (the workspace default applies), or left out to keep it; or unlimited: true alone for no cap at all; a shared app too. Answers the app's own caps and the ones that bind it (effectiveMonthlyCallCap, capSource, effectiveMonthlyCostCapMicros, costCapSource).
everyapp_workspace_cap_default admin write { cap?, costUsd? }: calls per month (1 to 1,000,000,000) and dollars per month (0.01 to 1,000,000) for every app without a cap of that kind or unlimited of its own, each null for no default or left out to keep it (Manage workspace → Usage → Default monthly cap). Answers defaultMonthlyCallCap and defaultMonthlyCostCapMicros.
everyapp_ai_show member read {}: what the Gateway's default model provider resolves to now: provider, label, model, source (setting, order, none), via (ai-gateway or direct), choice, aiGateway: { on }, candidates[] in order; configured and settings for owners and admins only (/docs/agent/ai).
everyapp_ai_default admin write { provider, model? } or { clear: true }: choose the default model provider (and the model filled in when an app names none), or clear the choice so the shipped order decides (Admin → Usage → AI default; everyapp ai default). Refused for an API provider, Cohere or AI Gateway; queues ai.default_changed when the choice changed. Answers as everyapp_ai_show does.
everyapp_workspace_app_delete admin write { app, confirm: true }: the catalog row; the Worker stays until everyapp delete. destructiveHint: true.
everyapp_workspace_usage admin read { month? } (YYYY-MM, UTC; default the current one): per app, member and provider, with the workspace default cap and, per app, its own cap, capUnlimited, the cap that binds it (effectiveMonthlyCallCap, capSource) and paused.
everyapp_workspace_activity admin read { app?, user?, decision?, risk?, since?, limit?, before? }: one page of the audit trail, newest first, limit 1 to 200 (default 50), before the previous answer's nextBefore (its createdAt at most 8.64e15, as the UI's filter bounds it; INVALID_INPUT past the schema). app is a manifest id or row id, a removed app's manifest id too (its rows outlive it), or everyapp for the Gateway's own tools. Never arguments or results.
everyapp_workspace_audit admin read { actor?, type?, since?, limit?, before? }: one page of the audit log (the organization's events rows, kept 90 days), newest first, limit 1 to 200 (default 50), before the previous answer's nextBefore ({ time, id }). actor is a member's email or user id, or an actor id as the rows hold it; type one type, a prefix (member.*) or * (pings and tool calls too; out otherwise). Each row: time, type, label, summary, actor, subject, appEvent; never an event's data.
everyapp_workspace_rename admin write { name }: 1 to 64 characters; the slug stays.
everyapp_workspace_events admin read The event subscriptions (Admin → Events): id (sub_…), name, sink, target (a webhook's host and path, never its query; or whom it mails), events, enabled, builtin (cap_alerts), lastDeliveryAt, lastDeliveryStatus, consecutiveFailures. Never a signing secret.
everyapp_workspace_events_add admin write { url?, email?, events?, name?, secret? }: a webhook url or email (owners or admins), not both; events types or patterns (none is every event); secret 16 to 128 characters, or the Gateway generates one and answers it once (below).
everyapp_workspace_events_set admin write { subscription, enabled?, events?, name? }: enabled: false pauses it (a test still reaches it), events replaces the list (empty is every event), name renames. A call that changes nothing is INVALID_INPUT.
everyapp_workspace_events_remove admin write { subscription, confirm: true }: events stop at once. A built-in subscription (Cap alerts) is turned off instead (removed: false, disabled: true, a note), so the Gateway never recreates it. destructiveHint: true.
everyapp_workspace_events_test admin write { subscription }: a ping to that one subscription, a paused one too, and its first attempt awaited: delivered, and the delivery with state, statusCode (for email, mailed), error and nextAttemptAt.
everyapp_workspace_event_deliveries admin read { subscription?, dead?, limit?, before? }: the last 30 days' deliveries, newest first, limit 1 to 100 (default 20), before the previous answer's nextBefore; each with the event's id, type, summary and time (never its data), state, attempts, statusCode or mailed, error.
everyapp_workspace_event_redeliver admin write { delivery } (dlv_…): sends a failed or dead delivery again now, its schedule starting over, and answers where it stands (redelivered: true); a delivered one is left alone (redelivered: false, a note). EVENT_DELIVERY_NOT_FOUND.
everyapp_my_tokens member read The caller's own personal access tokens in this organization (id, name, prefix, scopes, app, dates, revokedAt). Never a value.
everyapp_revoke_token member write { token } (id or name; a shared name needs the id): the caller's own only. TOKEN_NOT_FOUND.
everyapp_my_connections member read The caller's AI connections in every organization (id, organization, client, apps, access, dates).
everyapp_revoke_connection member write { connection }: the caller's own only; endsThisConnection when it is the one the call came through, which ends on its next request. CONNECTION_NOT_FOUND.
everyapp_mcp_list admin read Every MCP app of the organization (each built-in that was added, each remote server added by URL): id, rowId, name, kind (builtin, remote), status (on, off, needs setup), tools (the snapshot's count; null when never listed or the last listing failed), a remote one's url, auth and risk, credential (the setting's name, set, bound, state), and setupCommand while it needs setting. Never a value.
everyapp_mcp_show admin read { app } (id or row id): the list's fields with description and authLabel, then every tool: name (what AI clients call), serverName, title, risk and its source (override, app, hint, default), on, listed, problem. A built-in's tools are its snapshot's. Never the credential, nor the origin it is bound to.
everyapp_mcp_add admin write { url, id?, name?, description?, auth: { kind, secret?, header?, prefix? }, risk? }: the registry rules (REMOTE_MCP_URL_REFUSED, REMOTE_MCP_INVALID, REMOTE_MCP_EXISTS, REMOTE_MCP_SECRET_TAKEN, APP_TIER_MISMATCH). Answers id, url, auth, credential (name, set), tools (listed at once for none), setupCommand (pnpm everyapp secret set <NAME>) and next. secret is a setting's name; no field takes a value.
everyapp_mcp_remove admin write { app, confirm: true }: the tombstone; access, tokens, the snapshot and its place in AI connections go, and the credential stays set (the answer names secret unset). destructiveHint: true.
everyapp_mcp_on admin write { app }: serve it again; answers the list's fields and changed. A built-in never added answers APP_NOT_FOUND naming pnpm everyapp mcp on <id>.
everyapp_mcp_off admin write { app }: stop serving it; access, tokens and the snapshot are kept, so everyapp_mcp_on is instant.
everyapp_mcp_refresh admin write { app }: drop the snapshot and list the tools now (up to 20 s); answers status, tools, the listed names added and removed, and problem when the listing failed.
everyapp_mcp_tool admin write { app, tool, risk?, enabled? }: one tool's override, the tool by listed or server name; risk is read, write, destructive, or annotations to clear it. Answers the override and the new rev (re-listed on first use); a name the last listing lacks is kept (inListing: false). APP_NOT_FOUND for a built-in.

The events tools (Events out, docs/design/2026-10-events-out.md section 9) run through the same service as Admin → Events. A subscription is named by its id (sub_…) or its name (a name two share needs the id); a refusal carries the service's code (EVENT_SINK_CONFLICT, EVENT_TARGET_REFUSED, EVENT_TYPE_UNKNOWN, EVENT_SECRET_INVALID, EVENT_SUBSCRIPTIONS_LIMIT at 25, EVENT_SUBSCRIPTION_NOT_FOUND, EVENT_DELIVERY_NOT_FOUND). everyapp_workspace_events_add's description lists every event type, so a client needs no catalogue call. When the call gives no secret, the answer carries the generated one (whsec_…) as secret with next telling the person to store it in the receiver now: it signs every delivery (X-EveryApp-Signature) and opens nothing on the Gateway, and no answer, list or audit row carries it again; a secret given is never echoed. A webhook is shown by its host and path, never its query, where a receiver's own token often rides. The row records agent:<credential id> as its maker.

Provider tools

The API provider registry (providers) and the OAuth providers people connect their accounts to are on the Gateway connector too, as five more host-run built-ins under everyapp (everyapp_provider_add also takes { preset: "github-oauth" }, and everyapp_provider_list carries the presets; the connection tools are every member's over their own account): definitions in packages/toolbus/src/providers.ts, handlers in apps/every-app-gateway/src/server/toolbus/provider-tools.ts, run through the same ToolBusDeps.workspace path as the workspace tools, so every rule above (role, listing, arguments, audit, never a credential) holds for them. The two that write rows exist only where the Gateway registers providers: a self-hosted Gateway (WorkspaceHost.providers, set by registersProviders() in runtime.ts when GATEWAY_DEPLOYMENT_MODE is self_hosted). On a hosted Gateway they are absent from tools/list and from everyapp_search_tools, and a call answers Unknown tool (audited blocked, reason unknown_tool) before any handler runs, since a row there would be every tenant's. No value passes through any of them: a spec names its settings, and the key is set from the terminal (everyapp secret set NAME). The decisions behind the roles are in TODO.md ("Provider registry (2026-10-25)").

Tool Role Risk What it does
everyapp_provider_list member read {}: every provider this Gateway brokers, the catalog's then the rows', each with id, name, kind (api_key, ai, oauth_client; the Gateway's own services are left out), source (catalog or row), auth (where the key goes: kind, and the header, param, scheme or as with it), hosts or origin, hostSetting, docs, hint, data, settings (each name with its hint), and apps: the caller's own organization's apps whose deployed manifest lists it, never another organization's. For an owner or admin each setting also carries set (as the proxies read it: false for a row whose key isn't bound, whatever the Worker holds) and a row carries bound; a row carries addedBy and addedAt for everyone. registered counts the rows.
everyapp_provider_add admin write { spec }: the spec object as everyapp provider add takes it (the parser is the same); never a URL (a string fails the schema) and never a replace (there is no flag; an id with a row is PROVIDER_EXISTS, naming everyapp provider add --replace for the terminal). PROVIDER_SPEC_INVALID with the parser's problems; the row rules as PROVIDER_BUILTIN, PROVIDER_SECRET_TAKEN (a setting the Gateway, the catalog, another row or a deployed app's own secrets holds) and PROVIDER_HOST_REFUSED; GATEWAY_OUTDATED where the database predates the providers table; INVALID_INPUT over 64 KiB. The row is written unbound with the caller's email as added_by, served by this isolate at once and by the others within 30 s; the answer has settings and next (everyapp secret set NAME).
everyapp_provider_connections member read { all?, provider? }: the caller's own connections to OAuth providers (all: true: the whole organization's, owners and admins), each with provider, providerName, account (user or organization), userId/userEmail, label (the account at the provider), scopeSets, status (active or expired), createdAt, lastUsedAt, expiresAt. Never a token. ADMIN_REQUIRED for a member's all. People connect in the browser at /connect/<provider>; no tool starts the flow (OAuth providers)
everyapp_provider_disconnect member write { provider, account?, user? }: removes the caller's own connection, or for owners and admins another person's (user: email or id) or the organization's (account: "organization"), through the same service as Settings and Admin: the tokens dropped, the grant revoked at the provider where the preset names a revoke URL (revoked says). ADMIN_REQUIRED, PROVIDER_CONNECTION_NOT_FOUND (no such member), CONNECTION_NOT_FOUND (nothing to remove)
everyapp_provider_remove admin write { id }: removes a registered row. PROVIDER_BUILTIN for a catalog id, PROVIDER_NOT_FOUND, PROVIDER_BOUND while the row's key is set (its bound_to; removing such a row is the operator's: everyapp provider remove <id>, then everyapp secret unset), PROVIDER_IN_USE while any deployed manifest on this Gateway lists the id under capabilities (naming the apps; there is no force through MCP). The Gateway settings the row named stay until everyapp secret unset; the answer says so under next.

Sign-in provider tools

Manage workspace → Sign-in (gateway) is on the Gateway connector too, as two host-run built-ins under everyapp for owners and admins, in either deployment mode: definitions in packages/toolbus/src/signin.ts, handlers in apps/every-app-gateway/src/server/toolbus/signin-tools.ts, answering from the same listing as the tab (apps/every-app-gateway/src/server/sign-in-providers.ts) through the same ToolBusDeps.workspace path, so every rule above holds for them. Both read; a read-only connection runs them. Neither returns a value: a setting is its name and whether it is set. There is deliberately no tool that adds or removes a sign-in provider: the settings are Worker secrets the Gateway can't write to itself, and such a tool would let a stolen agent token add a way in (pnpm everyapp signin add <id> and pnpm everyapp signin remove <id> from a terminal; threat model B10).

Tool Role Risk What it does
everyapp_signin_list admin read {}: every sign-in provider in catalog order (google, microsoft, github, apple, cloudflare, oidc), each with id, label, status (on, off, or incomplete: some settings set, the provider off until the rest are), missing, set, settings (name, role, required, plain, what, set), callbackUrl (the redirect URL to register), console, appNoun, scopes, emailClaim, notes, setup (the skill's guide), command and, for one that needs setup, problem; then password (on, and offPending when EMAIL_PASSWORD_SIGN_IN=false waits for an owner to connect another way in), access (on, label), operator (self_hosted or hosted: who runs the command) and next.
everyapp_signin_show admin read { id } (one of the catalog's ids; any other is refused by the schema): that provider's fields as the list has them, and next while it isn't on.

Transport

Streamable HTTP with JSON responses, both protocol eras (packages/toolbus/src/http.ts):

  • POST only. GET and DELETE get 405 -32000 Method not allowed. (allow: POST, OPTIONS); OPTIONS gets a 204 preflight answer. Body at most 256 KiB (MAX_REQUEST_BODY_BYTES; 413 -32600 Request body too large.); unreadable body 400 -32700; JSON-RPC batches 400 -32600; an Mcp-Method or Mcp-Name header that contradicts the body 400 -32020.
  • A browser Origin must be the endpoint's own origin or one listed in MCP_ALLOWED_ORIGINS; anything else gets 403 -32000 Forbidden: origin not allowed. AI apps call from servers and send none. CORS answers never allow credentials.
  • An unknown or hidden tool: JSON-RPC -32602 Unknown tool: <name> (names longer than 128 characters are cut with …). A tool the compact listing leaves out of tools/list is neither: it answers as if listed.

Executing a call

executeToolCall (packages/toolbus/src/execute.ts), the one path for both surfaces: resolve the name, apply policy, check the arguments against the snapshotted schema, mint the identity, POST /__everyapp/tools/call once, map the answer, emit one audit event. A built-in passes the shape checks first, is then validated against its own schema and answered from the view; everyapp_call_read and everyapp_call_write run this same path once for the inner name, with the wrapper's ceiling on what it may resolve and on the scopes it mints.

  • Arguments must be a JSON object, nested at most 64 levels (MAX_ARGUMENT_DEPTH; arguments are nested more than 64 levels deep.). The Gateway's check (packages/toolbus/src/validation.ts) runs on shared infrastructure, so it never runs an app's regular expressions (pattern, patternProperties are dropped from its copy; a schema that needs them under not, if, oneOf or contains, or with unevaluatedProperties/unevaluatedItems, is left to the app), skips schemas with $ref/$dynamicRef/$recursiveRef, and skips arguments with more than 1,000 values (MAX_CHECKED_VALUES) against an app tool's schema. The app's own schema validates every call anyway. A built-in's own schema (the Gateway's, small and regex-free) is checked whatever the count. Refused arguments answer Invalid arguments for <name>: <problem> as a tool error (isError: true), audited with outcome invalid_arguments.
  • The identity: sub/email/orgId/orgRole of the caller, aud the app's Worker name, iss the Gateway URL, chan: "mcp", act.sub as in the context table, scopes scopesForAccess, tc: { id: callId, name }.
  • The app has 30 s for headers and body (callTimeoutMs, DEFAULT_CALL_TIMEOUT_MS): <app> did not answer within 30 seconds. (outcome timeout), <app> could not be reached. (unreachable), <app> failed to run <tool>. (app_error), <tool> is no longer available in <app>. Its tool list is being refreshed; list tools again. (the app answered unknown_tool; the snapshot is re-synced).

Audit

Every call, allowed or not, logs one mcp.tool_call line and writes one mcp_tool_calls row after the response (apps/every-app-gateway/src/server/toolbus/runtime.ts): surface, app, tool, via (the wrapper the call came through, everyapp_call_read or everyapp_call_write, when it did; tool is then the inner tool and args_digest the inner arguments', and the wrapper writes no row of its own, so one call is one row through every path), risk, decision (allowed/blocked), reason, outcome (ok, tool_error, invalid_arguments, app_error, timeout, unreachable, not_run), ms, args_digest (the first 32 hex characters of HMAC-SHA256 keyed with BETTER_AUTH_SECRET, context every-app-gateway:mcp-args:v1, over the canonical arguments), result_bytes, proto, request_id (cf-ray), call_id, cred_kind, cred_id (omitted when the credential is an Access email), client (the row's client_info: what the client called itself in the MCP handshake, <name> <version>, from the 2026-07-28 per-request _meta["io.modelcontextprotocol/clientInfo"] or from a 2025 initialize carried in the session id; the name cut to 100 characters and the version to 50, control characters removed, at most 200 in all; self-asserted, never verified; absent when the client sent none), org, user_id. The row's client_name is the OAuth connection's registered name, so a personal-token or Access caller has only client_info to say which AI app called; Admin → Activity shows it beside the credential badge, and via on a row that came through a wrapper. Never the arguments or the result. The hourly cron deletes rows older than MCP_AUDIT_RETENTION_DAYS. Owners and admins read these rows in Admin → Activity (/admin/activity, "Agent activity"): per organization, filterable by app, person and outcome.

MCP apps

An MCP app is a set of tools the Gateway serves on its /mcp for no deployed app: no page, no Worker, nothing to deploy. In the code it is a provider app: an apps row on tier provider (PROVIDER_APP_TIER) with no hostname and no worker_name, status active from the start, and a manifest the Gateway generates. There are two kinds, told apart by the stored manifest (remoteMcpOf(manifest) in packages/sdk/src/internal/remoteMcp.ts):

Kind What it is Its credential How it is added
built-in A provider the Gateway holds a key for, with a curated set of tools compiled into the Gateway: DataForSEO, the one entry of PROVIDER_APPS. The row's id is the registry's. The provider's key (DATAFORSEO_API_KEY). pnpm everyapp mcp on dataforseo
remote Any remote MCP server, registered by its URL. The Gateway lists the server's tools and forwards each call to it with a credential it holds. The row's manifest has a remote block. A Gateway setting named <ID>_MCP_TOKEN, or none. pnpm everyapp mcp add <url>, or Admin → Apps → Add app → Add an MCP server

Both kinds work the same way once added. The tools are named <id>_<tool> on the Gateway's /mcp. Every call is attributed to the member, audited in mcp_tool_calls, counted in usage_daily and usage_monthly, and held to the organization's monthly cap on the row (Admin → Usage). The ToolBus's policy applies as to any app: read tools for every connection, write tools for read & write connections, destructive tools for nobody. Who gets the app follows a deployed app's rule: the person adding it, the owners and the admins; an admin grants others in Admin → Apps → Manage access.

The launcher lists both kinds under "MCP apps" below the grid, with an "MCP" chip. The card, the chip on Admin → Apps and a pinned entry in the sidebar open the "Use it from your agent" dialog instead of a page (apps/every-app-gateway/src/client/components/McpAppDialog.tsx): the Gateway's /mcp address with a copy button, the tool prefix, an example sentence, and links to /docs/connect-ai and here. ?mcp=<id> on the launcher opens it. A built-in's example is PROVIDER_APPS[id].example; a remote app's is List what <name> offers, then use one of its tools. The designs: docs/design/2026-10-mcp-provider-apps.md (the tier), docs/design/2026-10-mcp-apps-ui.md (the name and the launcher) and docs/design/2026-10-mcp-by-url.md (the remote kind).

On, off and remove

everyapp mcp on|off|remove, and Turn on, Turn off and Remove in Admin → Apps, mean the same for both kinds (mcpAppStatusUpdate and mcpAppRemoveUpdate in packages/perimeter/src/registry/remoteMcpApps.ts):

Verb The row What stays
on status active: served. A built-in's first on creates its row. An app that is off is served again at once. Everything.
off status disabled: the ToolBus and the launcher leave it out, and Admin → Apps shows "Off". Everyone's access, the personal tokens bound to it, its tools snapshot, its overrides.
remove status deleted, the tombstone a deleted app leaves: everyone's access, the tokens bound to it, its tools snapshot and its place in AI connections go. Only the Gateway setting holding its key or credential, and a credential's pin. pnpm everyapp secret unset <NAME> removes both.

This changed on 2026-10-25 (#631). From #599 until then, mcp off was the tombstone and nothing stopped serving an MCP app while keeping its access; the old off is remove now. After remove, a built-in comes back with mcp on and a remote app with mcp add, each from scratch. on and off refuse a tombstone (APP_NOT_FOUND: "<id>" was removed; add it again.) and a deployed app's row (APP_TIER_MISMATCH). In Admin → Apps an owner or admin removes an MCP app, as an owner or admin deletes an app (#676, Ben's ruling of 2026-10-25: owners and admins control the Gateway); the CLI acts as the operator.

The built-in kind: DataForSEO

The registry is PROVIDER_APPS in packages/sdk/src/internal/providerApps.ts (id, name, description, the api_key capability whose setting holds the key, why, example); providerAppKeySetting(id) names the setting (DATAFORSEO_API_KEY), providerAppManifest(id) the manifest the row stores (the capability listed with access: "read", so Admin → Apps and the settings model read it like any app's; everyapp secret list lists its key among the Gateway's names). The Gateway serves a curated set of DataForSEO's tools (below), with the key never leaving the Gateway.

pnpm everyapp mcp list                      # every MCP app: kind, on or off, tools, key set or not
pnpm everyapp mcp on dataforseo             # writes the registry row; the Gateway serves its tools within 30 s
pnpm everyapp secret set DATAFORSEO_API_KEY # the key, if the Gateway doesn't hold it yet (either order)
pnpm everyapp mcp off dataforseo            # stops serving; access, tokens and the snapshot are kept
pnpm everyapp mcp remove dataforseo         # the tombstone, like delete; the key stays

everyapp provider on <id> and everyapp provider off <id> still run mcp on|off through launch week, as hidden routes that say so first; everyapp provider list is not mcp list: everyapp provider is the API provider registry's group (/docs/agent/providers), and its list ends with MCP apps: pnpm everyapp mcp list through launch week. mcp ls is an alias of mcp list.

What mcp on writes (turnProviderAppOn in packages/cli/src/lib/gateway/providerApps.ts, the rules in packages/perimeter/src/registry/providerApps.ts): an apps row in the install organization with app_slug the provider's id, tier provider, no hostname and no worker_name, the generated manifest, status active at once (nothing to upload or bind), deployed_by_actor the Cloudflare login, is_default false; and user_app_access rows for the person turning it on, the owners and the admins. Nobody else until an admin grants them in Admin → Apps → Manage access. Repeating on keeps the row and its access and refreshes the name and description from the registry; on after off sets status back to active with the same access; on after remove revives the tombstone as new. The id of a deployed app is refused (APP_TIER_MISMATCH), and a deploy under an MCP app's id likewise. mcp on warns with PROVIDER_KEY_UNSET while the key isn't set. everyapp delete <id> refuses an MCP app (APP_NOT_FOUND, rerun pnpm everyapp mcp remove <id>: it has no Worker), and so does everyapp email; everyapp status leaves MCP apps out of its per-app report (it has no Worker to read).

The perimeter never routes a provider row (no hostname; DrizzleAppRegistry leaves tier provider out as it leaves tombstones out), and getAppFetcher reaches no Worker for it: the Gateway serves it in-process (apps/every-app-gateway/src/server/provider-apps/), as an SDK app (everyApp() with defineTools()) running inside the Gateway Worker, so the catalog snapshot, tools/list, the per-call identity, policy, the audit row and the usage counters (kind capability, target the capability id, the provider row as the app, the member as the user) are the ones every app gets. The one seam that differs is appFetcherFor in catalog-sync.ts: tier provider resolves to the in-process app (providerAppFetcher; a row with a remote block to remoteMcpAppFetcher, below). The identity's aud is provider:<row id> (providerAudience), never a Worker name, verified with the Gateway's JWT_PUBLIC_KEY (or the public half of JWT_PRIVATE_KEY where an install never wrote it); everyapp_list_apps shows the Gateway's own URL as the app's. The snapshot records the Gateway's provider catalog version (providerCatalogVersion, a digest of every provider catalog's hash) as its version_id, so a Gateway whose provider tools changed re-syncs on first use (findVisibleUnsyncedApps takes it), and a Gateway that can't compute it (no signing key) logs mcp.catalog.provider_version_unavailable and syncs the other apps all the same.

Each tool call reaches the provider through the capability proxy's one path (handleIntegrationProxyRequest, src/server/provider-apps/call.ts): the provider's hosts only, account-wide paths refused, the key put on the call and never seen by the tool, redirects refused, DataForSEO's cost read off the answer's head; admit attributes the call to the member whose client called, and onCall/onCost count it in usage_daily and usage_monthly against the provider row (integration.proxy.call is logged with orgSource provider). While the key isn't set the app needs setup like any app whose required setting is missing (the row's manifest lists the capability): its tools are left out of /mcp, and the instructions and everyapp_list_apps name it with available: false and the command (needs setup: an admin runs pnpm everyapp secret set DATAFORSEO_API_KEY); the app lists show "Needs setup". The other way round, the key set while the install organization has no row for the app (and no deployed app of its lists dataforseo, which makes the key that app's), the instructions and everyapp_list_apps tell an owner or admin of the install organization that it is off (offered: true, off and its key is set: an admin runs pnpm everyapp mcp on dataforseo; members see nothing), and everyapp status suggests the same as PROVIDER_OFFER in data.next (PROVIDER_KEY for the app on without its key). A refusal of the Gateway's own reached in a call (the key unset between the list and the call) comes back as a tool error: integration_not_configured with the command (DataForSEO isn't set up on this gateway. Its operator sets DATAFORSEO_API_KEY with: everyapp secret set DATAFORSEO_API_KEY), any other as The gateway refused the call (<code>): <message>.. The organization's monthly cap on the row (Manage workspace → Usage; its own, or the workspace default) is checked before every call (usage.cap_exceeded is logged; the tool error names the cap, where it came from, and Manage workspace → Usage, never a person), and a call the ToolBus's 30 s deadline aborts is cancelled upstream too; an answer over 768 KB is refused as a tool error asking for a smaller limit or depth; the provider's own errors (status_code other than 20000 on the envelope or the task) come back as DataForSEO answered <code>: <message>. or DataForSEO: <message> (<code>).. Every provider tool is risk: "read", idempotent, openWorld, and titled.

The DataForSEO set (src/server/provider-apps/dataforseo.ts; Live endpoints, one POST each, the price in each description; every task carries tag: everyapp:<organization id> for DataForSEO's own usage reports; location_code defaults to 2840, language_code to en; a tool answers { cost, result }, the call's cost in USD and tasks[0].result as DataForSEO shapes it): dataforseo_serp_google, dataforseo_serp_google_maps, dataforseo_serp_google_autocomplete (/v3/serp/google/{organic,maps,autocomplete}/live/advanced), dataforseo_keyword_search_volume (/v3/keywords_data/google_ads/search_volume/live), dataforseo_keyword_overview, dataforseo_keyword_suggestions, dataforseo_keyword_ideas, dataforseo_related_keywords, dataforseo_keyword_difficulty, dataforseo_search_intent, dataforseo_ranked_keywords, dataforseo_domain_rank_overview, dataforseo_competitors_domain, dataforseo_domain_intersection (/v3/dataforseo_labs/google/<endpoint>/live), dataforseo_backlinks_summary, dataforseo_backlinks, dataforseo_referring_domains (/v3/backlinks/<endpoint>/live). Lists are capped (keywords at 100 or 200, and 20 seeds for dataforseo_keyword_ideas; limit at 100; depth at 100 on the SERP tools and 3 on dataforseo_related_keywords; Google Ads keywords at 80 characters) so one answer stays well under the 1 MiB a tool may relay. dataforseo_search_intent takes keywords alone (the endpoint has no location or language). In local dev DATAFORSEO_SANDBOX=true in the Gateway's .dev.vars sends every call to sandbox.dataforseo.com (/docs/agent/secrets).

The remote kind: any MCP server by URL

A remote MCP app is a provider-tier row whose manifest carries a remote block. The registration rules are pure functions in packages/perimeter/src/registry/remoteMcpApps.ts: planRemoteMcpAppAdd (the row, the grants, the refusals), remoteMcpAppUpdate (the block changed, rev bumped), mcpAppStatusUpdate and mcpAppRemoveUpdate. The CLI runs them against the Gateway's D1 under the Cloudflare login (ADR-0003); the Gateway's server functions run them in-process for owners and admins. The shared rules (the URL, the credential's name, the tool names, the risk) are in packages/sdk/src/internal/remoteMcp.ts. Serving it is apps/every-app-gateway/src/server/remote-mcp/{index.ts,bind.ts,relist.ts} and the ToolBus's client, packages/toolbus/src/remote/{client.ts,normalize.ts}.

The registration

What mcp add https://treg.to/mcp/ --auth bearer stores, with two overrides added later by mcp tool:

{
  "id": "treg",
  "name": "treg",
  "description": "treg's MCP server (https://treg.to/mcp/), served to your AI clients through this Gateway.",
  "remote": {
    "url": "https://treg.to/mcp/",
    "auth": { "kind": "bearer", "secret": "TREG_MCP_TOKEN" },
    "risk": "annotations",
    "tools": {
      "delete_everything": { "risk": "destructive" },
      "noisy_tool": { "enabled": false },
    },
    "rev": 3,
  },
}
Field What it is
remote.url The one address the Gateway fetches and the only place it sends the credential. https: only; http: on localhost or a loopback address only while the Gateway's own GATEWAY_URL is http:// (local dev). No user or password and no fragment in it, at most 2048 characters. Refused with REMOTE_MCP_URL_REFUSED (remoteMcpUrlProblem(url, { gatewayUrl, appBaseDomain, thirdPartyDomain })): an IP address, the Gateway's own origin, any host under APP_BASE_DOMAIN or THIRD_PARTY_DOMAIN, and the hosts whose keys the Gateway holds (reservedUpstreamHosts(): api.openai.com, openrouter.ai, gateway.ai.cloudflare.com, api.cloudflare.com, every api_key capability's hosts and every OAuth client's token host). The Gateway checks the stored URL again before every fetch, so a row edited by hand in D1 is refused the same way.
remote.auth One object, discriminated by kind (REMOTE_MCP_AUTH_KINDS, parseRemoteMcpAuth). { kind: "none" } sends nothing. { kind: "bearer", secret } sends Authorization: Bearer <value>. { kind: "header", secret, header, prefix? } sends <header>: <prefix><value>; header is a header name other than Authorization, Cookie, Host, Content-Type, Content-Length, Accept, Connection, Transfer-Encoding and the Mcp-* routing headers, and prefix is at most 32 characters. { kind: "basic", secret } takes the value as user:password and sends Authorization: Basic <base64>. { kind: "oauth" } is recorded and not served ("Upstream OAuth", below). secret is always the name of a Gateway setting, never a value.
remote.risk How a tool's risk is decided where no override says: annotations (the default) from the server's hints; read or write for every tool, whatever it says (REMOTE_MCP_RISK_DEFAULTS). read is for a server the operator knows only reads, such as a docs or search server that never annotates.
remote.tools The operator's overrides, keyed by the server's own tool name: risk (read, write or destructive) and enabled: false (left out of the catalog; a call answers Unknown tool). At most 500. An override for a name the server no longer lists is kept and does nothing.
remote.rev A counter every change of the block bumps. The snapshot records remote:<rev> as its version, so a changed override or risk default makes the Gateway list the tools again on first use.

The id comes from the URL's host unless --name gives one: the label before the public suffix (https://mcp.deepwiki.com/mcp → deepwiki, https://treg.to/mcp/ → treg; remoteMcpIdFromUrl). It is kebab-case, at most 32 characters, never everyapp, a built-in's id or a deployed app's id, tombstones included (remoteMcpIdProblem; APP_TIER_MISMATCH). The name defaults to the id and the description to <name>'s MCP server (<url>), served to your AI clients through this Gateway. (remoteMcpDefaultDescription). The URL and the auth are never changed in place: a server at another address is another app (mcp remove, then mcp add). Nobody writes this block in everyapp.config.ts: the Gateway's registry writes it, and the strict manifest schema refuses the key (/docs/agent/manifest).

Refusals (/docs/agent/errors): REMOTE_MCP_URL_REFUSED and REMOTE_MCP_INVALID (400); REMOTE_MCP_EXISTS (409, the id is a remote MCP app already, on or off); REMOTE_MCP_SECRET_TAKEN (409, a live MCP app at another origin sends that credential name); APP_TIER_MISMATCH. The Admin form also refuses a credential name another organization's MCP app uses, without saying where.

The credential and its pin

The credential is a Gateway Worker secret named in the family <ID>_MCP_TOKEN (REMOTE_MCP_SECRET_PATTERN, /^[A-Z][A-Z0-9_]*_MCP_TOKEN$/; isRemoteMcpSecretName). The default is the app id upper-cased with - as _ (remoteMcpSecretName("treg") is TREG_MCP_TOKEN); --secret picks any other name in the family. Only the operator sets the value: pnpm everyapp secret set <NAME>, or mcp add at a terminal (it asks Set <NAME> now?), or mcp add --from-env VAR. Never from the UI, never over MCP. A value never reaches an AI client, an audit row, a log line, an error or a result. Because the family is a fixed rule, secret set sends such a name to the Gateway without reading the registry, an app's own secret can never take one, and an admin adding a server can never point a key the Gateway already holds (OPENAI_API_KEY) at their own URL. The settings model reads remote.auth.secret as one required Gateway setting (kind remote-mcp, setup guide setup/remote-mcp.md; appSettings() in packages/sdk/src/internal/appSettings.ts), so until it is set the app needs setup like any app (/docs/agent/secrets). auth.kind: "none" needs nothing and serves at once.

A credential is pinned to one origin. secret set <NAME> writes (name, origin, bound_by, bound_at) to remote_mcp_credentials in the Gateway's D1 (migration 0011, apps/every-app-gateway/src/db/remote-mcp.schema.ts), the origin being that of the live MCP app that declares the name. A credential set another way (the Cloudflare dashboard) is pinned by the Gateway to the origin of the first row that uses it, and logged mcp.remote.credential_bound. The Gateway sends a credential only to the origin it is pinned to (credentialFor in remote-mcp/bind.ts). A row whose URL names another origin reads as needing setup (its credential <NAME> was set for <origin>, not for this server; its operator runs pnpm everyapp secret set <NAME> again), is logged mcp.remote.credential_origin_mismatch, and gets nothing sent. So an owner or admin who re-points a row, or removes one and adds another under the same id, can't send a credential anywhere the operator didn't set it for. mcp remove leaves the setting and its pin; secret unset <NAME> removes both. A pin is cached a minute per isolate.

How the Gateway serves it

appFetcherFor in apps/every-app-gateway/src/server/toolbus/catalog-sync.ts hands a provider row with a remote block to remoteMcpAppFetcher (remote-mcp/index.ts): an in-process app that answers the internal tools contract itself. It verifies the system token on /__everyapp/tools/list, and on /__everyapp/tools/call the identity minted for this one call (chan: "mcp", the tc claim naming the call, aud provider:<row id>, the Gateway's public key), as a built-in does. Above that seam nothing changes: the snapshot, the policy, the identity, the audit row with via, the compact listing and everyapp_search_tools treat an MCP app like any app.

The client, RemoteMcpClient (remote/client.ts in the ToolBus), has no dependencies, so the Gateway, the tests and the claude -p harness share it:

  • POST only, to the registered URL only: a JSON body per request, Accept: application/json, text/event-stream, the credential's header on every request and none of the caller's headers, and redirect: "manual". A 3xx is a failure, so the credential never follows a redirect.
  • The 2026-07-28 era first, as the ToolBus itself serves it: per-request Mcp-Method and Mcp-Name headers and _meta (io.modelcontextprotocol/protocolVersion, io.modelcontextprotocol/clientInfo naming every-app-gateway, io.modelcontextprotocol/clientCapabilities empty), no session. When the server refuses that (an HTTP 4xx other than 401 and 403, or a JSON-RPC error saying the method or version is unknown), the 2025 era: initialize asking 2025-11-25 and taking 2025-06-18, 2025-03-26 or 2024-11-05 back, notifications/initialized, the Mcp-Session-Id sent on every later request, and one new initialize on a 404. The era and session are kept per row for the isolate's life; a new rev, URL or credential name starts a new client.
  • Answers in JSON or SSE. An SSE stream is read until the message with the request's id; notifications before it (progress, logging) are dropped. The client declares no capabilities: a request from the server in the stream (sampling/createMessage, elicitation/create, roots/list) gets a JSON-RPC "method not found" back, and the call fails.
  • Limits: a tools/list page gets 15 s and 5 MiB, and nextCursor is followed at most 20 times (MAX_LIST_PAGES; past that the list is cut and mcp.remote.listed says truncated). A call gets 25 s (CALL_TIMEOUT_MS, inside the ToolBus's 30) and 768 KiB of answer (CALL_MAX_BYTES), and is cancelled upstream when the ToolBus gives up.
  • Errors never carry the credential, the request's headers or the server's body verbatim. The model reads one sentence: <name> redirected the request; this Gateway follows no redirects., <name> rejected the credential (401); its operator sets it again with everyapp secret set., <name> refused the request (403)., <name> did not answer in time., <name> could not be reached., <name>'s answer is larger than this Gateway relays; ask for less., <name> answered <status>., or the server's JSON-RPC error in at most 200 characters. The mcp.remote.upstream_failed log line carries the kind, the status, the JSON-RPC code and a 200-character head with anything that looks like the credential replaced.

The catalog (normalizeRemoteCatalog in the ToolBus's remote/normalize.ts):

  • Names. Each server tool name becomes a ToolBus name (normalizeRemoteToolName): camelCase split, -, ., /, : and spaces as _, other characters dropped, lowercase, t_ before a leading digit, cut so the name fits 48 characters and <id>_<tool> fits 64. Two server names that normalize to one name are both left out (<name>: skipped, two tools normalize to "<tool>" (<a>, <b>)), and so is a name the server lists twice. A tool turned off by an override is left out (turned off by the operator).
  • Risk. remoteToolRisk(annotations, block, name): an override wins, then the app's read or write default, then the server's hints read conservatively. readOnlyHint: true is read; destructiveHint: true is destructive, hidden from everyone until approvals exist; anything else, no hint included, is write. The spec reads a missing destructiveHint as true, which would hide every unhinted tool; the Gateway reads a missing hint as unknown and unknown as write, so an unhinted tool reaches read & write connections and never read-only ones.
  • What passes through: title (at most 100 characters), description (at most 2048; the title, or The <name> tool of <app>., when the server gives none), inputSchema (an empty open object when it isn't an object schema), an object outputSchema, and idempotentHint and openWorldHint when true. The ToolBus's own limits still apply (schema sizes, x-mcp-header stripped, an unusable schema skipped). The server's instructions from the handshake become the app's, at most 2048 characters.
  • The contract's hash is catalogHash() over the normalized list, so a changed server list changes the hash, and the next call's answer reports drift.

When the Gateway lists the tools: on first use and on drift, like any app; every hour, when the maintenance cron (runMcpMaintenance, relistStaleRemoteMcpApps in remote-mcp/relist.ts) re-lists each active remote app whose snapshot is missing or older than an hour, at most 50 a run (mcp.catalog.sync with trigger scheduled); after pnpm everyapp mcp refresh <id>, which drops the snapshot so the next /mcp request lists again; and at once from Admin → Apps → Refresh tools (trigger refresh). Within an isolate, the server's list and the name map are cached for the server's 2026-07-28 ttlMs (at most an hour) or 10 minutes; a call for a name the cache doesn't know lists once more before answering unknown_tool. Each listing is logged mcp.remote.listed and written to remote_mcp_tools (migration 0011): per server tool, the server's name, the ToolBus name or null, the title, what its annotations alone say (read, write, destructive or unknown), whether it is listed, and why not. mcp show, the MCP app dialog and mcp tool read that table to show where a risk comes from and to key an override by the server's name. A listing whose names differ from the snapshot's logs mcp.remote.tools_changed with added and removed.

A call: verify the identity; map the ToolBus name to the server's; refuse a tool whose effective risk needs a scope the identity lacks (forbidden); check the organization's monthly cap on the row (checkMonthlyCap; the tool error names the cap and Admin → Usage); forward tools/call with the server's name, the arguments and the credential; relay the result's content blocks as they are, structuredContent and isError, and nothing else (no _meta, no tasks). The call is counted in usage_daily and usage_monthly with kind capability, target mcp:<id>, the row as the app and the member as the user; a server error or an isError result counts as a failure. The member's own OAuth or personal token never goes upstream.

What a caller sees when the app isn't served: while the credential is unset or pinned elsewhere, the auth is oauth, or the stored URL is refused (mcp.remote.refused), the app has no tools on /mcp, and the instructions and everyapp_list_apps name it with available: false and the reason (needs setup: an admin runs pnpm everyapp secret set TREG_MCP_TOKEN). A remote app whose last listing failed is named the same way (its tools couldn't be listed: <error>) until a listing works. The launcher and Admin → Apps show "Needs setup", "Off" or "Not supported yet".

Upstream OAuth: not supported yet

--auth oauth (in the Admin form, "OAuth (not supported yet)") adds the row so it records what the server wants, and the Gateway serves no tools for it: the reason is this server wants OAuth, which this Gateway doesn't do yet; if it also takes an API key, add it again with --auth bearer. mcp list shows OAuth: not supported yet as its key, and Admin → Apps shows "Not supported yet". mcp add's probe warns REMOTE_MCP_WANTS_OAUTH when the server answers 401 with a WWW-Authenticate challenge that names resource_metadata. Signing in to a server with OAuth would make the Gateway an OAuth client: discovery, dynamic client registration, an authorize round trip through the operator's browser to a callback on the Gateway, PKCE, and tokens kept encrypted and refreshed. That is a new security boundary, and a follow-up in TODO.md ("MCP apps by URL (2026-10-25)").

From the CLI

pnpm everyapp mcp add https://mcp.deepwiki.com/mcp               # a public server: served at once
pnpm everyapp mcp add https://treg.to/mcp/ --auth bearer          # at a terminal: Set TREG_MCP_TOKEN now?
pnpm everyapp secret set TREG_MCP_TOKEN                           # later; pins it to https://treg.to
pnpm everyapp mcp show treg                                       # URL, credential and pin, every tool with its risk
pnpm everyapp mcp tool treg delete_everything --risk destructive  # one tool's risk
pnpm everyapp mcp tool treg noisy_tool --off                      # leave one tool out
pnpm everyapp mcp refresh treg                                    # list the tools again
pnpm everyapp mcp off treg                                        # stop serving; access kept
pnpm everyapp mcp remove treg                                     # the tombstone; the credential and its pin stay

The commands are packages/cli/src/commands/provider/ (group everyapp mcp); their SQL is in packages/cli/src/lib/gateway/remoteMcpApps.ts and packages/cli/src/lib/gateway/providerApps.ts, the probe in packages/cli/src/lib/gateway/remoteMcpProbe.ts. Every flag is in the CLI reference.

  • mcp add <url> writes the row and its grants in one batch (addRemoteMcpApp), then looks at the URL once from the operator's machine with no credential (probeRemoteMcp: one 2026-07-28 tools/list, else one 2025 initialize, no redirect followed, 10 s in all). The probe only warns, never refuses, since the Gateway, not the laptop, fetches the server: REMOTE_MCP_WANTS_OAUTH, or REMOTE_MCP_PROBE_FAILED for a 401 from a server added with no credential, a redirect, or no answer. --auth defaults to none, or bearer with --secret, or header with --header; flags that say two things are refused before anything is written (REMOTE_MCP_INVALID). For a server with a credential, add asks Set <NAME> now? at a terminal and reads the value with secret set's hidden prompt, or reads --from-env VAR before anything is written; either way the value goes through secret set's own path (the lease, one Gateway version, the pin). Otherwise it warns PROVIDER_KEY_UNSET with the command, or REMOTE_MCP_SECRET_BOUND_ELSEWHERE when the name is set but pinned to another origin. On a Gateway whose D1 predates migration 0011 the row is written and REMOTE_MCP_GATEWAY_OUTDATED says to run pnpm everyapp update.
  • mcp on <id> and mcp off <id> set the status for both kinds, as above. Safe to repeat.
  • mcp remove <id> leaves the tombstone. It asks nothing.
  • mcp list (ls) lists every MCP app of the install organization, the built-ins whether added or not: APP, KIND (built-in or remote), STATUS (on, off, needs setup or not added), TOOLS (the snapshot's count, not listed yet or listing failed) and KEY (the setting and whether it is set, set for <origin> when it is pinned elsewhere, none, or OAuth: not supported yet).
  • mcp show <id>: for a remote app, the URL, the auth, the risk default, the revision, who added it and when, the credential (set, and the origin it is pinned to) and the snapshot (count, when, the last error); then every tool the server listed, with its name on the Gateway, the server's name, its risk and where that comes from (hint, app, override or default), and whether it is on or why not. For a built-in, its catalog's tools.
  • mcp refresh <id> deletes the app's app_tool_catalogs and app_tools rows and nothing else.
  • mcp tool <id> <tool> changes one override in the block, keyed by the server's name (either name finds the tool; REMOTE_MCP_TOOL_NOT_LISTED warns when the last listing doesn't have it), and bumps rev. --risk annotations drops the risk override; --on drops enabled: false. A built-in's tools are refused (MCP_APP_BUILTIN), and so is a change that says nothing or says both --on and --off (MCP_TOOL_CHANGE_INVALID).
  • An id that is no MCP app is PROVIDER_APP_UNKNOWN, naming the built-ins and the added servers (with rerun pnpm everyapp mcp add <id> when the id looks like a URL; for a deployed app's id, pnpm everyapp delete <id>). secret list names each remote credential and lists the MCP apps that need setup.

--json: the done event's data (/docs/agent/json):

Command data
mcp add { id, name, url, auth: { kind, secret?, header? }, risk, on: true, was, organization: { id, name }, probe, key, mcp: { url, toolPrefix }, granted, next }. was is new or revived. probe is { outcome: "reachable", era, tools, server }, { outcome: "needs-credential", status, oauth }, { outcome: "redirected", status, location } or { outcome: "unreachable", reason }. key is null for none and oauth, else { name, set, bound, boundOrigin }. The steps are mcp.add and mcp.probe.
mcp on { id, name, kind, on: true, was, organization, key, mcp, granted, next? }. was is new, revived, off or existing; granted is the user ids granted by a built-in's first on.
mcp off { id, name, kind, on: false, wasOn, key }.
mcp remove { id, name, kind, removed, key }.
mcp list { apps: [{ id, name, kind, status, on, url?, auth?, supported?, tools, key, lastListedAt?, lastError? }], mcp, next? }. Until 2026-10-25 the list was providers.
mcp show { id, name, kind, status, on, url?, auth?, supported?, risk?, rev?, overrides?, addedBy, addedAt, key, snapshot, tools, mcp, next? }; snapshot is { status, tools, listedAt, lastError } or null; each of tools is { name, serverName, title, risk, source, on, problem } (a built-in's { name, title, risk, source: "registry", on: true }).
mcp refresh { id, name, kind, refreshed, dropped }; dropped is the snapshot it deleted, or null.
mcp tool { id, tool: { serverName, name, inListing }, before, override, rev }.

From Admin → Apps

Add app → Add an MCP server sits between the four apps and Build your own. Its form takes the URL, the name (from the URL when left empty), the credential (None, Bearer token, Header, User and password, or OAuth (not supported yet)), the header name, the credential's setting name (filled in as <ID>_MCP_TOKEN, editable within the family) and the risk default (Annotations (recommended), Everything write, Everything read). The value is never typed there: for a server with a credential the result names pnpm everyapp secret set <NAME> for the operator; for one with none, the Gateway lists its tools in the same request and says how many. An MCP app's row menu has Use from your agent, Manage access, Edit (the MCP app dialog: the URL and auth, the credential's state, the risk default, Refresh tools, and the tools with a risk select and an on/off switch each), Turn off or Turn on, and Remove. The server functions (apps/every-app-gateway/src/serverFunctions/mcpApps.ts, over McpAppService in apps/every-app-gateway/src/server/services/McpAppService.ts) are addRemoteMcpApp, updateRemoteMcpApp, setMcpAppStatus, refreshMcpAppTools and getMcpApp for owners and admins, and removeMcpApp for owners alone. Members never register or change an MCP app.

From an agent

Eight workspace tools on the seam #614 built (the "Workspace tools" table above) are planned for a later PR: mcp_add, mcp_remove, mcp_on, mcp_off, mcp_list, mcp_show, mcp_refresh and mcp_tool, under the reserved everyapp_ prefix. They are for owners and admins (mcp_remove included since 2026-10-25), hidden from lower roles like every workspace tool, and never take or return a credential: secret is a setting's name, and the answer names the secret set command for the operator. Until they land, an agent runs the CLI with --json.

Connecting clients

The person's steps are on /docs/connect-ai; the facts behind them:

Client Identifies itself by Connect
claude.ai (web, Desktop, mobile), ChatGPT, VS Code, Codex, Copilot CLI Client ID Metadata Document (CIMD) Give the client https://<gateway>/mcp as the server URL; it discovers the OAuth server from the 401 challenge, and the consent page opens in the browser.
Claude Code (2.1.81 and later uses CIMD) CIMD claude mcp add --transport http everyapp https://<gateway>/mcp, then /mcp → everyapp → Authenticate. everyapp_whoami then answers with your account.
Cursor, Gemini CLI, MCP Inspector, older clients Dynamic client registration (/oauth/register) Same URL; they register a client first, which MCP_DCR_ENABLED controls. MCP Inspector's direct (browser) mode also needs its origin in MCP_ALLOWED_ORIGINS (http://localhost:6274).
Scripts, and any client that takes a header Personal access token Authorization: Bearer epat_… on https://<gateway>/mcp (every app the token reaches; a token bound to one app sees that app alone): claude mcp add --transport http everyapp https://<gateway>/mcp --header "Authorization: Bearer $EVERYAPP_TOKEN" (packages/cli/skills/everyapp/mcp.md). https://<app-host>/mcp takes the same header only where the app's manifest sets mcp.

Personal access tokens

apps/every-app-gateway/src/server/services/UserPatService.ts, apps/every-app-gateway/src/schemas/user-token.ts, apps/every-app-gateway/src/routes/_app/admin/tokens.tsx. A token is epat_ followed by 64 hex characters (32 random bytes); only an HMAC-SHA256 of it (hashUserPat, context every-app-gateway:user-pat:v1, keyed with BETTER_AUTH_SECRET; apps/every-app-gateway/src/server/user-pat-hash.ts) and the prefix epat_ plus 4 characters are stored. A person creates one in the Gateway under "Manage workspace" (the sidebar footer's link to /admin, apps/every-app-gateway/src/client/components/Sidebar.tsx; the user page /docs/connect-ai spells it Admin → Tokens, the skill packages/cli/skills/everyapp/mcp.md Manage workspace → Tokens), tab "Tokens" (/admin/tokens, apps/every-app-gateway/src/routes/_app/admin.tsx), heading "Personal access tokens", button "New token" (the dialog's title; its submit is "Create token"): a name (at most 64 characters), an access level ("Read only", the default, mcp:read; "Read & write", mcp:write; "Full access", *, shown with a warning that it acts as the person on every app route and tool), "All my apps" or one app (the one app is preselected when the person has exactly one; a token bound to an app opens that app only: boundToAnotherApp, apps/every-app-gateway/src/perimeter/betterAuthAuthenticator.ts), and an expiry (default 30 days; between 1 day and 1 year). The admin layout is owner/admin-gated (Only owners and admins of this organization can manage users, apps and tokens.), so only they reach the page (a TODO in tokens.tsx: move it to a member-accessible settings page). The dialog always sends scopes (["mcp:read"], ["mcp:write"] or ["*"]); the server function's schema accepts scopes (at most 20, each matching ^[a-z0-9][a-z0-9:_-]{0,63}$, none starting with provider:) and a call that omits them still gets * (normalizeScopes, kept for callers that pass none; the dialog never relies on it), and the service's own default expiry stays 90 days for such a caller. A token whose scopes are all mcp: scopes is MCP-only: every app route other than /mcp answers it 403 { "error": "forbidden", "reason": "token_scope" } (isMcpOnlyCredential, packages/perimeter/src/session.ts). A deploy key (deploy scope alone, 30-day default) opens no app and no /mcp. Revoke on the same page; a revoked or expired token fails verification (401) on its next use.

GET /oauth/authorize (apps/every-app-gateway/src/server/mcp-auth/authorize.ts) validates the request with the library, requires PKCE S256 of every client (This app didn't use PKCE (S256), which EveryApp requires.), refuses a client id or redirect URI on the platform's domains (the Gateway's host, APP_BASE_DOMAIN, or any host under either: This app uses an address on this gateway's own domain, which only EveryApp itself may use.), shows errors on its own page rather than redirecting (registration is open, so a redirect could go anywhere), sends a signed-out user to /sign-in and back, then starts a consent transaction (a one-time handle, bound to the browser by a __Host-everyapp-oauth- cookie, for ten minutes) and redirects to /oauth/consent.

The page (apps/every-app-gateway/src/server/mcp-auth/consent.ts, apps/every-app-gateway/src/client/components/oauth/ConsentForm.tsx) shows: the client's name (control and formatting characters removed, at most 80 characters, Unnamed app when empty); for a CIMD client Published by <domain>, shown as verified only when access goes to that domain, a subdomain of it, or an app on this computer (a loopback redirect) (domainCoversRedirect, clientSite.ts), and a warning otherwise; for a dynamically registered client This app named itself; its name is not verified.; where access will be sent (the redirect host); the signed-in email; the user's active organizations with their role; each organization's apps with whether they have tools (ready, none or unknown: an app with none is shown (no AI tools yet) and can't be chosen; one still unknown stays selectable, and its tools load on first use). The user chooses the organization, All apps I can use, including future ones or Only these apps, and read & write (default) or Read only, then Allow or Deny.

POST /oauth/consent/decision must come from the page itself (Sec-Fetch-Site: same-origin, or an Origin equal to the issuer in a browser without that header), from a signed-in user, with a form of at most 64 KiB. Approving records an mcp_connections row whose id mcpc_<32 hex> derives from the handle (so a double-clicked Allow records one; the second gets 409 Already answered), completes the authorization with ids-only props { v: 1, kind: "oauth", connectionId, userId, organizationId }, grants mcp:read, mcp:write when read & write was chosen, and offline_access when the client asked for it (grantedScopes), then supersedes the user's earlier connections through the same client in the same organization (for a CIMD client, the same installation's: same redirect URI, a loopback redirect's port not counting; McpConnectionService.supersedeEarlier, installation.ts). Denying redirects with access_denied. Logged as mcp.connection.created.

Connections and revocation

apps/every-app-gateway/src/server/services/McpConnectionService.ts. A person lists and disconnects their connections at /settings/connections: the "AI connections" item in the account menu at the bottom of the sidebar (apps/every-app-gateway/src/client/components/Sidebar.tsx), a page titled "AI connections" (apps/every-app-gateway/src/routes/_app/settings/connections.tsx; docs/runbooks/cloudflare-access.md calls it "Connected AI apps"): client name and domain, organization, apps (all or named), access, created and last used. A connection ends when the user revokes it (mcp.connection.revoked, reason user; its grant in OAUTH_KV is revoked too), when the member is removed from the organization (member_removed; all of their connections there) or deactivated (user_deactivated; all of theirs), when the same client connects again (superseded), when the client revokes its own token or the grant lapses after 30 days without a refresh (listed as ended), or after 31 days unused (the cron marks it expired). A refresh for a connection that no longer stands fails with invalid_grant (This connection was revoked. Connect again.), which also revokes the grant; a request with such a token gets invalid_token (This connection was revoked or lost its access. Connect again.). Removing a user's access to an app only drops that app from their connections' tools.

The OAuth server

apps/every-app-gateway/src/server/mcp-auth/config.ts: @cloudflare/workers-oauth-provider 1.2, both the authorization server and the resource server, in the Gateway Worker. The issuer is GATEWAY_URL's origin, which must be https, or http on exactly localhost (not *.localhost) or a 127.0.0.0/8 address (/^127(?:\.\d{1,3}){3}$/, canonicalIssuer); otherwise OAuth stays off (mcp.oauth.unavailable) and /mcp takes personal tokens only. Changing GATEWAY_URL invalidates every connection. The one protected resource is <GATEWAY_URL>/mcp: Claude requires the protected resource metadata to name the exact URL the user typed, path included and no trailing slash, so there is one connector per Gateway, not one per app.

Endpoints

Path Served by
/mcp The resource server (OAuth tokens and PATs), or the Access path when a verified assertion is present.
/.well-known/oauth-protected-resource/mcp RFC 9728 metadata: resource exactly <GATEWAY_URL>/mcp, authorization_servers: ["<GATEWAY_URL>"], scopes_supported: ["mcp:read", "mcp:write"], resource_name: "EveryApp".
/.well-known/oauth-authorization-server issuer, client_id_metadata_document_supported: true, code_challenge_methods_supported: ["S256"], "none" among token_endpoint_auth_methods_supported, scopes_supported mcp:read, mcp:write, offline_access; registration_endpoint only while DCR is on.
/oauth/authorize GET only (405 otherwise): validation, sign-in, consent transaction.
/oauth/consent, POST /oauth/consent/decision The consent page and its form.
/oauth/token Token exchange, refresh and revocation; body at most 64 KiB; a client id on the platform's domains answers 401 invalid_client.
/oauth/register Dynamic client registration, within limits; 404 { "error": "registration_disabled" } when DCR is off.

Any other /mcp/*, /oauth/* or discovery path answers 404 { "error": "not_found" }; with OAuth off, every OAuth path answers 404 { "error": "oauth_disabled" }. A failed route (D1 or KV down) answers 503 in the shape its caller reads (unavailable, routes.ts): an HTML page (Something went wrong, no retry-after) on /oauth/authorize and /oauth/consent/decision; JSON-RPC -32603 EveryApp is unavailable just now. with retry-after: 30 on /mcp; { "error": "temporarily_unavailable", "error_description": "Try again in a moment." } with retry-after: 30 on the other OAuth paths.

Switches

Set with pnpm everyapp secret set <NAME> (a prompt reads the value); locally in apps/every-app-gateway/.dev.vars. Parsing in apps/every-app-gateway/src/server/oauth-flag.ts: on-values are true, 1, on, yes; off-values false, 0, off, no (case-insensitive, trimmed). Unset or an on-value means on. Any other value turns the switch off and logs mcp.oauth.flag_unrecognized once per value, with the reason <name> is neither on nor off, so it is off. Use "false", or delete it.

Name Default Effect
MCP_OAUTH_ENABLED on Off: /oauth/* and the discovery documents return 404, /mcp accepts epat_ tokens only, and its 401 names no authorization server. Connections stay recorded and work again when it is turned back on, unless revoked or lapsed meanwhile.
MCP_DCR_ENABLED on Off: the authorization server metadata names no registration_endpoint, /oauth/register returns 404, only CIMD clients can connect, and clients registered before keep working until their registration lapses.
MCP_ALLOWED_ORIGINS none Browser origins, besides the Gateway's own, that can call /mcp; comma or space separated, each parsed as a URL and reduced to a lowercase http/https origin (allowedOrigins, gateway-mcp.ts). Behind Cloudflare Access's Managed OAuth a page on such an origin can also call as the person whose Access cookie the browser holds.
MCP_AUDIT_RETENTION_DAYS 90 Days of mcp_tool_calls the hourly cron keeps: an integer of at least 1, capped at 3650 (MAX_AUDIT_RETENTION_DAYS); a non-integer or a value below 1 means 90 (retentionDays, apps/every-app-gateway/src/server/mcp-auth/maintenance.ts).
MCP_ACCESS_TOKEN_TTL 3600 Local testing only (secret set refuses it): shorter access tokens, at least 60 s, honored only when the issuer is http:// (loopback).

Client registration

A client identifies itself one of two ways:

  • CIMD (Client ID Metadata Document): the client id is an https URL serving its metadata; nothing is stored until a user connects. The Gateway fetches it under the global_fetch_strictly_public compatibility flag (apps/every-app-gateway/cloudflare.config.ts): the fetch goes out through Cloudflare's front door as a request from the internet would, never straight to an origin behind a zone, and the flag is what makes the server advertise client_id_metadata_document_supported. Because the front door of the Gateway's own domain is the Gateway itself, client ids and redirect URIs on the platform's domains are refused before anything is fetched (apps/every-app-gateway/src/server/mcp-auth/own-domain.ts). A document that can't be fetched shows This app couldn't be verified: its client metadata document could not be fetched. Try again later. and logs mcp.oauth.cimd_failed.
  • DCR (/oauth/register, apps/every-app-gateway/src/server/mcp-auth/registration.ts): anyone can register, so it is bounded. The body is read with a 16 KiB cap (413 invalid_request); only client_name (cleaned, at most 200 characters), up to 10 redirect_uris of at most 2048 characters (400 invalid_redirect_uri past either), grant_types, response_types, token_endpoint_auth_methods_supported (passed through as sent, whatever their length; the library refuses malformed ones) and token_endpoint_auth_method (a string is cut to 100 characters) reach the library; contacts, logo, policy and terms URIs are dropped. A registration that omits token_endpoint_auth_method is registered as a public client (none, PKCE required) instead of RFC 7591's client_secret_basic, because most MCP clients that omit it never keep a secret, unless it names Perplexity's callback (client_secret_post) or lists supported methods without none. Limits (registration-limit.ts): 5 a minute per client address per isolate (429, retry-after: 60), and per UTC day in D1 rate_limit_counters, 20 per address (an IPv6 address by its /64) and 200 per Gateway when self-hosted, 200 and 20,000 on the hosted Gateway, none when the deployment mode is invalid (apps/every-app-gateway/src/server/platform-policy.ts); only a registration that gets 201 spends the budget. Past a budget: 429 { "error": "rate_limited", "error_description": "Too many client registrations. Try again later, or connect with a client that publishes a client ID metadata document." }, logged once per isolate and day as mcp.oauth.registration_limited; a budget that can't be read answers 503 temporarily_unavailable. A registration lasts 60 days without use (CLIENT_REGISTRATION_TTL_SECONDS, twice the grant TTL) and renews while its client keeps refreshing. Before any of this, POST /oauth/register and POST /oauth/token are counted by the Gateway's credential rate limiter while OAuth is on (rateLimitCredentialRequest, apps/every-app-gateway/src/server/auth-rate-limit.ts, run by serveGuarded in apps/every-app-gateway/src/server.ts before the route): the Workers Rate Limiting binding AUTH_RATE_LIMIT_IP, 60 requests per 60 s per client address (an IPv6 address by its /64), counted per Cloudflare location (apps/every-app-gateway/cloudflare.config.ts). Past it: 429 { "error": "rate_limited", "message": "Too many attempts. Wait a minute and try again." } with retry-after: 60, before the DCR budgets are read; the body is never read, so only the address counts.

Token lifetimes

Token Lifetime
Access token 3600 s (ACCESS_TOKEN_TTL_SECONDS).
Refresh token / grant 30 days from consent (REFRESH_TOKEN_TTL_SECONDS, also the idle TTL); each refresh moves it 30 days later, so a connection in use keeps working and an idle one lapses.
Client registration (DCR) 60 days unused.
Consent transaction 10 minutes, one use.

Housekeeping

The Gateway's cron 17 * * * * runs runMcpMaintenance (maintenance.ts): deletes mcp_tool_calls older than MCP_AUDIT_RETENTION_DAYS (up to 20,000 a run; audit_behind: true as a warning when more remain), marks connections unused for 31 days (CONNECTION_IDLE_DAYS) expired, deletes rate-limit counters whose day ended, and sweeps expired and orphaned grants and tokens from OAUTH_KV 50 at a time, resuming from the purge-cursor key (after three failed runs in a row it starts a new sweep). It also lists again the tools of every active remote MCP app whose snapshot is missing or older than an hour, up to 50 a run (relistStaleRemoteMcpApps; remote_mcp_relisted in the summary line; "MCP apps" above). Each run logs one mcp.maintenance line; a failed step logs mcp.maintenance.failed and the others still run. What the Gateway needs for all of this (the OAUTH_KV namespace every-app-gateway-OAUTH_KV, the compatibility flag, the cron, the migrations creating mcp_connections, mcp_connection_apps, mcp_tool_calls and rate_limit_counters) ships with it and pnpm everyapp setup provisions it.

Behind Cloudflare Access

PR #361 (merged 2026-10-20) made the Gateway's /mcp take a verified Cloudflare Access assertion as the caller, so an operator can turn on Access's Managed OAuth for the Gateway's host and bypass nothing (routes.ts serveMcpThroughAccess, access-caller.ts; the perimeter side is on /docs/agent/access and in docs/runbooks/cloudflare-access.md, "MCP through Access too"). With the outer ring configured, a /mcp request carrying a non-blank Cf-Access-Jwt-Assertion header (the header only, never the CF_Authorization cookie) is first checked for a browser Origin as every request is (403 Forbidden: origin not allowed. before anything is read for the person, since behind Managed OAuth Access vouches for a cookie-carrying browser too), then the assertion is verified against the team's keys and the Gateway's own application AUD. One that doesn't verify (forged, a service token's, or the apps' application's) is ignored and logged mcp.access_assertion_ignored (reason invalid or audience), and the request is served as before, with whatever Authorization carries. A verified identity acts as the account the outer ring linked for it (the accounts row with providerId: "cloudflare-access" and the email; a users row that merely shares the email is never a fallback), else 403 not_connected with No EveryApp account is connected to this Cloudflare Access identity. Open <gateway> in your browser, sign in once, then connect again.; it must not be deactivated; its organization is the only one it belongs to, else the active organization of its most recent live browser session, else 403 organization_unresolved (You belong to several organizations here. Open the gateway in your browser and pick one first.). The caller then has write access to every app the member can use, credential kind access with the email as id, actor access:<email>; no connection row is written, so /settings/connections shows nothing and revocation happens at Access or by deactivating. With Managed OAuth on, Access is the OAuth server AI apps talk to (it serves the discovery documents on the hostname), the Gateway's own OAuth server, consent page and DCR go idle, and the Authorization header holds Access's opaque token, which is never looked at when the assertion verified.

pnpm everyapp access on (packages/cli/src/commands/access/index.ts; the plan in packages/cli/src/lib/access/plan.ts) keeps the machine endpoints outside Access unless Managed OAuth is on: on the Gateway's host, /mcp, /mcp/*, /oauth/token, /oauth/register, /.well-known/* and /health are public path overrides (GATEWAY_BYPASS_PATHS; /oauth/authorize and the consent page are pages for people and stay behind Access); the apps' wildcard *.<base> gets no override, and an app host gets a destination of its own when its manifest has public routes or mcp, where /mcp and /mcp/* (MCP_PATHS) come before the app's routes only when the manifest sets mcp. Its tick lines name what is let through (MCP, the OAuth endpoints and /health on the Gateway's host; each app's public routes, and /mcp where a manifest asks for it) and its done message says MCP clients and personal access tokens keep working without a login. When the Gateway's application has Managed OAuth on (oauth_configuration.enabled === true, managedOAuthOn), access on and access show plan no MCP or OAuth override on either application, and each app's public routes stay let through (gatewayDestination(host, true) keeps /health alone, appsDestinations(…, { mcp: false }) drops the MCP paths); access show prints · Managed OAuth after the Gateway's application and reports data.applications.gateway.managedOAuth: true under --json; access off warns The Gateway's application has Managed OAuth on: AI apps connected through it stop. before deleting it. Managed OAuth itself is a switch on the Gateway's application at Access (the runbook); Offering Managed OAuth from access on is not built ("Open decisions"). Step by step for the outer ring: packages/cli/skills/everyapp/setup/access.md, in an app .claude/skills/everyapp/setup/access.md.

The app host's own /mcp and mcp: { by, why }

packages/perimeter/src/gateway.ts, apps/every-app-gateway/src/server/toolbus/app-host-mcp.ts. By default an app host serves no MCP: https://<app-host>/mcp is a private route like any other, proxied to the app with the identity (the app answers, usually 404), and a credential whose scopes are all mcp: scopes gets 403 token_scope on every route of such a host, /mcp included. The manifest field mcp ({ by: "gateway" | "app", why }, Manifest) opts a host in, and Cloudflare Access lets /mcp through on that host alone (Cloudflare Access). Where it is served, https://<app-host>/mcp is private (/mcp can't be in the manifest's public routes, nor a pattern that covers it: the strict schema refuses both, and an MCP-only token is 403 token_scope at a /mcp a stored row's public route still covers on a by: "gateway" host) and offers no OAuth: its 401 challenge is Bearer realm="every-app" or, for a bearer that isn't a personal token, Bearer realm="every-app", error="invalid_token", error_description="Use a personal access token here, or connect <GATEWAY_URL>/mcp"; the RFC 9728 paths /.well-known/oauth-protected-resource* answer 404 there. The perimeter authenticates the caller first: a personal access token (Authorization: Bearer epat_…; an OAuth access token is refused), or the user's browser session from the app's own pages (intended, so an app can call its own tools; the CSRF check requires a cookie-carrying POST to be same-origin: Sec-Fetch-Site: same-origin, or without that header an Origin on the app's host; a sibling app's page or another site gets 403 csrf_denied). It checks the caller's access to the app, refuses an MCP-only token on every other path (403 token_scope), then, for the exact path /mcp, when the manifest says by: "gateway" (and a stored manifest doesn't also declare /mcp public), hands the request to the ToolBus (interceptMcp). Token scopes set access as on the connector (* or mcp:write read & write, mcp:read read only, otherwise 403 { "error": "forbidden", "reason": "token_scope" }); a browser session is read & write. Tools are served under their own names with serverInfo name every-app-<app-id> and title the app's name. With no serving snapshot (an app without defineTools(), or on an SDK older than 0.3, or the catalog tables unreadable) the answer is 503 { "error": "mcp_unavailable", "app" }, never the app: the manifest said the Gateway answers here; only a POST makes the Gateway take a missing or outdated snapshot. everyapp dev serves http://<app-id>.localhost:8787/mcp from its local ToolBus whatever the manifest says (createDevGatewayHandler fills in by: "gateway" for an app without the field).

The manifest field (packages/perimeter/src/manifest/manifest.ts): mcp: { by: "gateway", why } serves <app-host>/mcp from the app's defineTools(); mcp: { by: "app", why } leaves the path to the app's own MCP server, which the perimeter then proxies with the usual identity header; the old string form is refused. That identity is an ordinary one, not an MCP-channel one (packages/perimeter/src/gateway.ts: channel: session.credential?.channel ?? "web", actor and scopes from the credential): for a personal token chan: "api", act.sub pat:<token id>, scopes the token's (["*"] when it was created without scopes; apps/every-app-gateway/src/perimeter/betterAuthAuthenticator.ts); for the app's own browser session chan: "web", no act, no scopes. Never chan: "mcp", never a tc claim; the Gateway applies no tool policy, checks no arguments and writes no mcp_tool_calls row for such a request. Such an app runs its own MCP server (the SDK no longer ships one: its createMcpHandler was deprecated in 0.3.0 and is gone) and consumes the verified EveryAppUser itself; the Gateway proxies /mcp to it with that identity only because the manifest says by: "app", and the why is where the operator says they accept that. defineTools() is the supported path. The manifest's scopes field (a record of scope id, matching ^[a-z0-9][a-z0-9:_-]{0,63}$, to a description of at most 200 characters; * and provider:* reserved) declares opaque app-domain scopes personal tokens can request; the Gateway itself acts on *, mcp:read, mcp:write and deploy, and passes a token's scopes to the app in the identity's scopes for the app to interpret.

Local dev

pnpm everyapp dev (packages/cli/src/commands/dev/index.ts, devAppGateway.ts) serves http://<app-id>.localhost:8787/mcp from a local ToolBus (packages/toolbus/src/dev/index.ts) with the same policy, argument checks and identity contract as the Gateway; the ready block prints it under "MCP". In stub mode (the default) any bearer token acts as the seeded dev user; in mirror mode (--mode mirror) a personal access token from the separately running local Gateway is verified for real. The catalog is refetched from the live app every 2 s (DEFAULT_CATALOG_TTL_MS), so edits to defineTools() show on the next request; identities are signed with the SDK's dev keypair (kid everyapp-identity-dev) for the audience <app-id>, which an app trusts only under EVERYAPP_DEV. Only localhost-class Host values are served (403 Forbidden: invalid Host header.) and any localhost origin can call, with CORS preflights answered, so MCP Inspector works. Each call prints a dev.mcp progress line, its parts joined by two spaces and the outcome or reason written as words (underscores become spaces): MCP <tool> <outcome> <ms>ms (MCP create_todo tool error 8ms) or MCP <tool> blocked: <reason> (blocked: read only connection); a catalog problem prints once as a TOOLS_CATALOG warning (Agent tools: <problem>). An app without tools, or without the contract, keeps /mcp for itself.

Point Claude Code at it as packages/cli/skills/everyapp/mcp.md does for a deployed app, with any bearer value in stub mode: claude mcp add --transport http todo-local http://<app-id>.localhost:8787/mcp --header "Authorization: Bearer dev". Caveat: the dev ToolBus gives its view no refresh, so the 2026-07-28 era declares listChanged: false there and Claude Code does not learn of edited tools without reconnecting; only 2025-era clients get the session-hash notification (TODO.md, "everyapp dev: let Claude Code see edited defineTools() without reconnecting").

To test the OAuth server itself locally, run the Gateway on http://localhost:19300 with GATEWAY_URL=http://localhost:19300 in .dev.vars and follow the runbook's "Test locally" (apps/every-app-gateway/scripts/mcp-oauth-local.mjs, run from apps/every-app-gateway as node scripts/mcp-oauth-local.mjs --gateway http://localhost:19300 with EVERYAPP_EMAIL and EVERYAPP_PASSWORD set; flags --org <slug>, --read-only, --conformance, --cimd <client id URL> --redirect-uri <uri>; or claude mcp add --transport http everyapp-local http://localhost:19300/mcp); a Gateway under vite dev has no service bindings to apps, so it lists only the built-in tools.

Logs

Event Meaning
mcp.tool_call One per call (see "Audit"); mcp.tool_call.audit_failed when the D1 row couldn't be written.
mcp.catalog.sync One per snapshot: app, org, trigger, status, tools, warnings, error; mcp.catalog.sync_failed when the app couldn't be asked; mcp.catalog.unavailable when D1 couldn't be read.
mcp.oauth.error The library's error: status, error, category, reason; mcp.oauth.unavailable, mcp.oauth.flag_unrecognized, mcp.oauth.cimd_failed, mcp.oauth.registration_limited, mcp.oauth.registration_budget_unreadable, mcp.oauth.connection_unreadable.
mcp.connection.created, mcp.connection.revoked Consent and revocation; mcp.connection.grant_revoke_failed, mcp.connection.supersede_failed, mcp.connection.revoke_failed for best-effort steps.
mcp.access_assertion_ignored, mcp.access_caller_refused The Access path: an assertion that didn't verify, or a refused person (reason only, never the email).
mcp.maintenance, mcp.maintenance.failed The hourly run and its failed steps.
mcp.route.failed An MCP or OAuth route threw; the caller got a 503.
mcp.pat.touch_failed, mcp.connection.touch_failed The token's or connection's last-used time couldn't be written after a /mcp request (best effort, in the background; mcp-auth/validate.ts).
mcp.connection.grants_unreadable The user's grants in OAUTH_KV couldn't be listed while listing their connections, so none is marked ended that run (McpConnectionService.ts listForUser).
mcp.oauth.registration_budget_unwritable A registration got 201 but its daily counter in rate_limit_counters couldn't be incremented (registration-limit.ts spend).
mcp.remote.listed, mcp.remote.tools_changed A remote MCP app's tools were listed: app, era, tools, skipped, truncated; and when the listed names differ from the snapshot's, added and removed (at most 50 each; the events thread's app.tools_changed rides this one). mcp.remote.tools_record_failed when remote_mcp_tools couldn't be written.
mcp.remote.upstream_failed A remote MCP server failed a listing or a call: app, stage (list or call), tool, kind, status, code, and a 200-character head of the answer with anything that looks like the credential replaced. Never the credential or the request's headers.
mcp.remote.credential_unpinned, mcp.remote.credential_origin_mismatch A credential is set but no operator pinned it (everyapp secret set writes the pin; the Gateway never pins on first use), so nothing was sent; a row's URL names another origin than its credential's pin, so nothing was sent (origin, boundOrigin, never the value).
mcp.remote.refused A remote MCP app's stored URL is one the rules refuse (a row edited by hand): nothing was fetched.

Open decisions

From TODO.md:

  • MCP defaults. OAuth is on, and dynamic client registration is on with limits; MCP_OAUTH_ENABLED=false and MCP_DCR_ENABLED=false turn them off.
  • Destructive tools stay hidden from AI clients until approvals exist; approvals by browser link, then show them again (Admin → Activity already shows the blocked rows).
  • The compact listing (2026-10-24): automatic past FULL_LISTING_MAX_TOOLS (256) or FULL_LISTING_MAX_BYTES (1 MiB), or by X-EveryApp-Tools/?tools=; to tune the thresholds once client_info shows which clients call, and to measure live what claude.ai loads in each mode; a choice on the consent page is not built, nor query-bearing resources (/mcp?tools=full|compact) that would let claude.ai-class clients choose.
  • Tool list changes (2026-10-19, revised 2026-10-24): on the Gateway's connector a poll rechecks the connection, token or Access identity, and the stream ends at the access token's or assertion's expiry; on the app-host surface a poll rechecks nothing (the perimeter authenticates per request; a held stream there only re-reads the snapshot), which is why its hold stays at 10 minutes while the Gateway's is 30. To decide: the 30 s poll; the per-isolate cap of 8 streams (a global cap needs a counter outside the isolate); whether the app-host surface should recheck the caller too and then hold 30 minutes as well; whether a stale session's tools/list should answer 404 rather than keep notifying.
  • Tool snapshots (2026-10-19): the consent page and /mcp sync missing snapshots within the request (3 s, 20 apps); not built: a deploy-time trigger from the CLI (everyapp deploy telling the Gateway about the version it recorded), an hourly reconcile in the cron.
  • MCP through Access (2026-10-20): the Access identity must already be linked by a browser sign-in (/mcp never creates an account or membership); write access to every app; the organization rule; no connection row. Alternatives rejected for now: auto-join from /mcp; read-only by default. An access on option that turns Managed OAuth on (not built; access on already reads oauth_configuration.enabled on the Gateway's application and then bypasses nothing for /mcp on either application); the apps' application taking Managed OAuth too, once the perimeter accepts the assertion on <app-host>/mcp.
  • A member of two organizations keeps one MCP connection per organization for the same AI client.
  • everyapp dev: give the dev ToolBus a refresh so Claude Code sees edited defineTools() without reconnecting.
  • A longer tool-call limit than 30 seconds for apps that need it.
  • A deploy key opens no app and no /mcp, even its owner's.
  • MCP apps by URL (2026-10-25): on and off serve and stop serving for both kinds, remove is the tombstone; credentials in the _MCP_TOKEN family, pinned to an origin; a tool with no hint is write; owners and admins register remote servers, owners remove them; upstream OAuth is not supported yet; the probe at mcp add warns and never refuses; usage target mcp:<id>. The defaults and follow-ups are TODO.md's "MCP apps by URL (2026-10-25)".
  • Access levels for personal access tokens. Since 2026-10-25 the dialog offers Read only (the default), Read & write and Full access; the server still maps omitted scopes to *.
  • OAuth: refuse app hosts outside the Gateway's base domain. Theoretical today: the deploy already forces them to match.
  • To verify live: secret set MCP_OAUTH_ENABLED takes effect without a deploy and survives setup; the cron logs mcp.maintenance at :17; the Gateway takes an app's tools on first /mcp use and again after a redeploy; a connected Claude Code refetches tools/list within about 30 s of a deploy.

Sources

  • docs/runbooks/mcp-oauth.md
  • packages/cli/skills/everyapp/mcp.md
  • docs/security-model.md
  • docs/runbooks/cloudflare-access.md
  • TODO.md
  • packages/sdk/src/server/tools.ts
  • packages/sdk/src/server/toolsEndpoint.ts
  • packages/sdk/src/server/everyApp.ts
  • packages/sdk/src/internal/tools.ts
  • packages/sdk/src/internal/protocol.ts
  • packages/toolbus/package.json
  • packages/toolbus/src/index.ts
  • packages/toolbus/src/types.ts
  • packages/toolbus/src/names.ts
  • packages/toolbus/src/builtins.ts
  • packages/toolbus/src/server.ts
  • packages/toolbus/src/http.ts
  • packages/toolbus/src/catalog.ts
  • packages/toolbus/src/execute.ts
  • packages/toolbus/src/policy.ts
  • packages/toolbus/src/principal.ts
  • packages/toolbus/src/view.ts
  • packages/toolbus/src/validation.ts
  • packages/toolbus/src/digest.ts
  • packages/toolbus/src/io.ts
  • packages/toolbus/src/dev/index.ts
  • packages/toolbus/src/remote/{client.ts,normalize.ts}
  • apps/every-app-gateway/cloudflare.config.ts
  • apps/every-app-gateway/src/server/oauth-flag.ts
  • apps/every-app-gateway/src/server/platform-policy.ts
  • apps/every-app-gateway/src/server/app-activation.ts
  • apps/every-app-gateway/src/server/deploy/run.ts
  • apps/every-app-gateway/src/server/toolbus/gateway-mcp.ts
  • apps/every-app-gateway/src/server/toolbus/app-host-mcp.ts
  • apps/every-app-gateway/src/server/toolbus/catalog-sync.ts
  • apps/every-app-gateway/src/server/toolbus/runtime.ts
  • apps/every-app-gateway/src/server/remote-mcp/{index.ts,bind.ts,relist.ts}
  • apps/every-app-gateway/src/db/remote-mcp.schema.ts
  • docs/design/2026-10-mcp-by-url.md
  • packages/sdk/src/internal/remoteMcp.ts
  • packages/sdk/src/internal/providerApps.ts
  • packages/perimeter/src/registry/remoteMcpApps.ts
  • packages/perimeter/src/registry/providerApps.ts
  • packages/cli/src/commands/provider/index.ts
  • packages/cli/src/lib/gateway/remoteMcpApps.ts
  • packages/cli/src/lib/gateway/remoteMcpProbe.ts
  • packages/cli/src/lib/gateway/providerApps.ts
  • apps/every-app-gateway/src/server/mcp-auth/config.ts
  • apps/every-app-gateway/src/server/mcp-auth/routes.ts
  • apps/every-app-gateway/src/server/mcp-auth/validate.ts
  • apps/every-app-gateway/src/server/mcp-auth/access-caller.ts
  • apps/every-app-gateway/src/server/mcp-auth/authorize.ts
  • apps/every-app-gateway/src/server/mcp-auth/consent.ts
  • apps/every-app-gateway/src/server/mcp-auth/registration.ts
  • apps/every-app-gateway/src/server/mcp-auth/registration-limit.ts
  • apps/every-app-gateway/src/server/mcp-auth/installation.ts
  • apps/every-app-gateway/src/server/mcp-auth/own-domain.ts
  • apps/every-app-gateway/src/server/mcp-auth/maintenance.ts
  • apps/every-app-gateway/src/server/mcp-auth/body.ts
  • apps/every-app-gateway/src/server/mcp-auth/gateway-mcp.e2e.sqlite.test.ts
  • apps/every-app-gateway/src/server/services/McpConnectionService.ts
  • apps/every-app-gateway/src/server/services/UserPatService.ts
  • apps/every-app-gateway/src/server/user-pat-scopes.ts
  • apps/every-app-gateway/src/server/user-pat-hash.ts
  • apps/every-app-gateway/src/schemas/user-token.ts
  • apps/every-app-gateway/src/schemas/consent-decision.ts
  • apps/every-app-gateway/src/routes/_app/admin/tokens.tsx
  • apps/every-app-gateway/src/routes/_app/settings/connections.tsx
  • apps/every-app-gateway/src/client/components/admin/CreateUserTokenModal.tsx
  • apps/every-app-gateway/src/client/components/oauth/ConsentForm.tsx
  • apps/every-app-gateway/src/client/components/oauth/clientSite.ts
  • apps/every-app-gateway/src/perimeter/betterAuthAuthenticator.ts
  • packages/perimeter/src/manifest/manifest.ts
  • packages/perimeter/src/gateway.ts
  • packages/perimeter/src/session.ts
  • packages/perimeter/src/hostSession.ts
  • packages/cli/src/commands/dev/index.ts
  • packages/cli/src/commands/dev/devAppGateway.ts
  • packages/cli/src/commands/dev/ready.ts
  • packages/cli/src/commands/dev/mode.ts
  • packages/cli/src/commands/deploy/deployApp.ts
  • packages/cli/src/commands/access/index.ts
  • packages/cli/src/lib/access/plan.ts
  • packages/cli/src/lib/access/sync.ts
  • apps/every-app-gateway/src/routes/_app/admin.tsx
  • landing-page/content/docs/connect-ai.mdx
  • starter/src/tools.ts
  • starter/src/entry.worker.ts
  • starter/README.md
  • packages/sdk/src/server/index.ts
  • packages/perimeter/src/registry/registration.ts
  • packages/cli/src/commands/create/steps/appId.ts
  • apps/every-app-gateway/src/server.ts
  • apps/every-app-gateway/src/server/auth-rate-limit.ts
  • apps/every-app-gateway/src/server/capabilities/email.ts
  • apps/every-app-gateway/src/server/repositories/AppToolRepository.ts
  • apps/every-app-gateway/src/client/components/Sidebar.tsx
  • apps/every-app-gateway/scripts/mcp-oauth-local.mjs

On this page