Agent tools: write, connect and debug them
How an app's AI tools work end to end: defineTools() in src/tools.ts field by field (names, risk, context, results, errors, limits), how the Gateway snapshots and serves them, MCP apps, the dev connector, connecting a person, and troubleshooting missing tools, 401s and 403s.
An app never speaks MCP. It describes its tools with defineTools() in
src/tools.ts and passes them to everyApp(handler, { tools }); the Gateway
snapshots that catalog and serves AI clients at https://<gateway>/mcp (every
app the caller can use, tools named <app-id>_<tool>). An app's own host
serves no MCP: point clients at the Gateway. Never add an MCP server, an /mcp route, a login
or a token check to an app. The connector, its credentials and the built-in tools in brief:
mcp; the person's steps: /docs/connect-ai.
Define tools: src/tools.ts
The starter's shape (starter/src/tools.ts, shortened; the queries and their zod schemas live in starter/src/server/todos.ts, shared with the server functions):
import { ToolError, defineTools } from "@every-app/sdk/server";
import { z } from "zod";
import * as todos from "@/server/todos";
export const tools = defineTools(
{
list_todos: {
description: "List the user's todos, completed ones included.",
risk: "read",
input: z.object({}).strict(),
handler: (_input, { user }) => todos.listTodos(user.id),
},
create_todo: {
description: "Add a todo. The server assigns its id.",
risk: "write",
idempotent: false,
input: todos.createTodoInput,
handler: (input, { user }) => todos.createTodo(user.id, input),
},
delete_todo: {
description: "Delete one todo for good.",
risk: "write",
destructive: true,
input: todos.deleteTodoInput,
handler: async (input, { user }) =>
(await todos.deleteTodo(user.id, input)) ?? raise("No todo with that id"),
},
},
{
instructions:
"Never guess todo ids: call list_todos first when an id isn't already known.",
},
);
/** A message the model may read; any other error reaches it as "Internal error". */
function raise(message: string): never {
throw new ToolError(message);
}Register them once, in src/entry.worker.ts:
export default everyApp<Env>(async (request) => tanstackEntry.fetch(request), { tools });
Names: lowercase snake_case, a letter first, at most 48 characters; on the
Gateway connector <app-id>_<tool> must fit 64 or the tool is skipped.
Fields: description (required; the model reads it; over 2048 characters the
Gateway skips the tool), risk (required), input (required: zod 4.2
or later, any Standard Schema with JSON Schema support, or a plain
{ jsonSchema, validate }; it must describe an object),
handler(input, context) (required), destructive (boolean, a write
tool that deletes or can't be undone, sent as
annotations.destructiveHint), idempotent (boolean, sent as
annotations.idempotentHint), title, output (an object schema,
validated and sent as structuredContent), openWorld. Second
argument { instructions }: guidance across the tools, at most 2048;
on the Gateway connector it is clipped to 1024 and placed after the
app list, and Claude Code shows the model only the first 4,096
characters of the whole (Codex's docs ask that the first 512 stand
alone), so put what matters first.
Risk: "read": listed and callable for every caller (readOnlyHint: true).
"write": hidden from read-only connections (read_only_connection);
with destructive: true clients see destructiveHint: true and ask
the person before calling it.
Context: the handler's second argument: user (the verified caller: id,
email, orgId, orgRole, channel: "mcp", actor.sub of
pat:<token id>, oauth:<connection id> or access:<email>,
scopes of ["mcp:read"] or ["mcp:read", "mcp:write"], and
toolCall with this call's id and name), env, ctx, request
(the internal POST /__everyapp/tools/call), callId. Scope every
read and write by user.id: a client never supplies a user id.
Results: a string (one text block), any JSON value (a JSON text block), a value
checked by output (text plus structuredContent), or
toolResult({ text, structured }). At most 1 MiB reaches the client.
Errors: throw new ToolError("No todo with that id") for a message the model
may read (isError: true). Any other error is logged in the app as
[everyapp] tool "<name>" failed (ref <callId>): … and reaches the
client only as Internal error in <app-id> (ref <callId>).
A bad definition throws at module load (defineTools: tool "<name>" needs a risk: "read" or "write"),
so the Worker fails rather than serving a catalog without the tool; a schema with
no object JSON Schema form only skips that tool with a warning (TOOLS_CATALOG in dev).
MCP apps: the Gateway also serves tools that aren't any app's, with no page
and nothing to deploy, on the same /mcp under the same access rules: a data
provider's built in (DataForSEO: dataforseo_<tool>, once the operator runs
pnpm everyapp mcp on dataforseo and sets DATAFORSEO_API_KEY), and any remote
MCP server added by URL (pnpm everyapp mcp add <url>, its tools
<id>_<tool>). For a remote server the Gateway holds the credential (a
provider: a key such as <ID>_MCP_TOKEN bound to the URL's host, or each
member's own OAuth connection), forwards each
call, and decides each tool's risk from the server's annotations (no hint
counts as write; mcp tool <id> <tool> --risk … overrides one). mcp off
stops serving and keeps access; mcp remove deletes it. Never wrap a
remote MCP server in an app of your own to put it behind the Gateway: add it
(setup/remote-mcp, mcp).
What the Gateway does with them
Snapshot: the Gateway stores each app's catalog in D1 with the app version it
came from, taken when the app is activated (the first deploy: the CLI waits
for the app to go live, so the catalog exists before anyone connects), when a
deploy through the Gateway (a Gateway app install) finishes a new version, on the first app-host request each
isolate sees after a self-hosted redeploy, when /mcp or the consent page
finds a missing or outdated snapshot (synced within the request, 3 s budget),
and when a call reports a drifted catalogHash or unknown_tool. The CLI's
deploy syncs nothing itself. One sync: POST /__everyapp/tools/list (15 s, a
5 MiB response; no count cap per app: a catalog is bounded by its per-tool
limits, the response size and one atomic D1 write); status ok, empty,
unsupported (SDK older than 0.3) or error (retried at most every 5
minutes); logged as mcp.catalog.sync.
Calls: tools/list never calls the app. tools/call checks the arguments
against the snapshot, mints a 120 s identity JWT for that one call
(chan: "mcp", explicit scopes, tc: { id, name }), makes one
POST /__everyapp/tools/call (30 s) and audits one mcp_tool_calls row.
Built-ins (Gateway connector only; always loaded in Claude Code):
everyapp_list_apps (the apps this connection can use; one that
needs setup is available: false with the command an admin runs;
for an owner or admin of the install organization, an MCP app
that is off while its key is set, and no deployed app lists its
capability, is offered: true with pnpm everyapp mcp on <id>,
and the instructions' head says the same in one line),
everyapp_whoami (user, org, access), everyapp_search_tools
({ query?, app?, limit? }: finds callable tools by words, by app
id or both, with their input schemas; read-only), and the two call
wrappers everyapp_call_read and everyapp_call_write
({ name, arguments? }: run one app tool by its <app-id>_<tool>
name; the read wrapper runs read tools only with mcp:read alone,
the write wrapper is hidden from read-only connections). A wrapped
call is the same one call: same policy, checks and identity, and
one audit row naming the inner tool with via set to the wrapper.
Workspace tools (Gateway connector only; every admin action of the UI, listed
in full mode to a caller whose role reaches each; a read-only
connection never sees a write one; a token bound to one app or a
connection consented to some apps sees none of them; never a
credential that opens the Gateway; one audit row per call under app
everyapp): owners and admins get
everyapp_workspace_show, everyapp_workspace_users,
everyapp_workspace_invite (never the link), everyapp_workspace_uninvite,
everyapp_workspace_role, everyapp_workspace_deactivate,
everyapp_workspace_reactivate, everyapp_workspace_reset_email,
everyapp_workspace_groups, everyapp_workspace_group_create,
everyapp_workspace_group_set, everyapp_workspace_group_delete,
everyapp_workspace_group_members, everyapp_workspace_group_members_set,
everyapp_workspace_group_apps, everyapp_workspace_group_apps_set,
everyapp_workspace_apps, everyapp_workspace_app_set,
everyapp_workspace_app_access, everyapp_workspace_app_access_set,
everyapp_workspace_app_cap, everyapp_workspace_cap_default,
everyapp_ai_show (any member), everyapp_ai_default,
everyapp_workspace_usage, everyapp_workspace_activity,
everyapp_workspace_audit (the audit log: who did what, 90 days),
everyapp_workspace_rename, everyapp_workspace_linked_dev, and the
events tools everyapp_workspace_events, everyapp_workspace_events_add
(a generated signing secret comes back once, in that answer: it opens
nothing on the Gateway, so the person stores it in the receiver now),
everyapp_workspace_events_set, everyapp_workspace_events_remove
(asks confirm: true; Cap alerts is turned off instead),
everyapp_workspace_events_test, everyapp_workspace_event_deliveries,
everyapp_workspace_event_redeliver, everyapp_workspace_remove
and everyapp_workspace_app_delete (the last two ask confirm: true;
an admin never removes an owner); every member gets their own
everyapp_my_tokens, everyapp_revoke_token, everyapp_my_connections
and everyapp_revoke_connection. A user is an email or id, an app a
manifest id or row id, a group a name or grp_ id, an event
subscription a name or sub_ id. MCP apps, the
mcp commands as tools: everyapp_mcp_list, everyapp_mcp_show,
everyapp_mcp_add (a credential's setting name, never its value; the
answer names secret set), everyapp_mcp_on, everyapp_mcp_off,
everyapp_mcp_refresh, everyapp_mcp_tool and
everyapp_mcp_remove (confirm: true). Full reference:
/docs/agent/mcp#workspace-tools.
Provider tools (Gateway connector only; one audit row per call like the
workspace tools): every member gets everyapp_provider_list (the
API providers this Gateway brokers, catalog and rows, with their
settings and hints and which of this organization's apps list
each; set-ness and the key binding for owners and admins only);
owners and admins on a self-hosted Gateway also get
everyapp_provider_add ({ spec }: the spec object, never a URL,
never a replace of a row that is there) and
everyapp_provider_remove ({ id }: refuses a row whose key is
set, and a row an app lists). A hosted Gateway hides the two
writers. everyapp_provider_add also takes { preset }, an
OAuth preset (google-workspace, github-oauth, …; the list tool's
presets; one registered already is answered as it is), and
answers the redirect URI to register at the provider's console. Every
member gets everyapp_provider_connections (their own
connections, one per app they connected in, with the scopes each
holds; all: true the organization's, owners and admins) and
everyapp_provider_disconnect (their own; owners and admins
anyone's; by connection id, or provider with app).
Connecting is a browser flow inside each app (connectUrl(),
<gateway>/connect/<provider>?app=<app>), never a tool. No key, client or
token ever passes through a tool: everyapp secret set NAME from
the terminal, or Admin → Providers. Full reference:
/docs/agent/mcp#provider-tools.
Sign-in provider tools (Gateway connector only, owners and admins, reads):
everyapp_signin_list (Manage workspace → Sign-in: each sign-in
provider on, off or needing setup, its settings by name with
whether each is set, the redirect URL to register and the
command) and everyapp_signin_show ({ id }). Never a value, and
no tool adds or removes a provider: pnpm everyapp signin add <id> from the terminal. Full reference:
/docs/agent/mcp#sign-in-provider-tools.
Listing: full (the built-ins, the workspace tools, then every tool) until a connection's full list
would pass 256 tools or 1 MiB, then compact (the built-ins only:
find tools with everyapp_search_tools, run them through the
wrappers or by name); X-EveryApp-Tools: full|compact chooses
for any caller that can set a header, ?tools= on /mcp for a
personal token, an Access caller or Claude Code (the header first
when both are given); claude.ai, Claude Desktop and ChatGPT need
the OAuth resource to equal the URL typed, so for them the query
form fails to connect and the bare /mcp gets the automatic rule.
Compact hides nothing: an unlisted tool is still callable; a
hidden one stays Unknown tool. A read-only connection is told
to use everyapp_call_read alone.
Changes: 2026-07-28 clients (Claude Code) hold a subscriptions/listen stream
re-read every 30 s, so a change reaches them within about half a
minute; the Gateway ends it after 30 minutes (an app host after 10,
and never past the token's expiry) and the client opens a new one,
few enough an hour that Claude Code keeps listening indefinitely;
2025-era clients get notifications/tools/list_changed and a
new Mcp-Session-Id on their next request (a stale session's
tools/list answers 404, so the client initializes again).
tools/list carries ttlMs: 60000 (a minute's cache).
Local: pnpm everyapp dev
pnpm everyapp dev prints http://<app-id>.localhost:8787/mcp under "MCP" in
its ready block: a local ToolBus with the Gateway's policy, argument checks and
identity contract, serving the app's live defineTools() catalog, re-read
every 2 s, so an edit shows on the next request. In the default stub mode any
bearer value acts as the seeded dev user; in --mode mirror it takes a
personal access token from the local Gateway. Each call prints
MCP <tool> <outcome> <ms>ms or MCP <tool> blocked: <reason> (two
spaces between parts; outcome and reason codes are printed as words: tool error, read only connection). Claude Code:
claude mcp add --transport http todo-local http://<app-id>.localhost:8787/mcp --header "Authorization: Bearer dev"Caveat: the dev ToolBus declares listChanged: false, so Claude Code sees
tools added to defineTools() only after reconnecting (/mcp, the server).
Connecting a person
Claude Code (2.1.81 and later), then /mcp, choose everyapp, choose
Authenticate, allow in the browser; everyapp_whoami confirms the account:
claude mcp add --transport http everyapp https://<gateway>/mcpclaude.ai, Claude Desktop, ChatGPT: a custom connector with the URL
https://<gateway>/mcp; the client identifies itself with a Client ID
Metadata Document (CIMD), finds the OAuth server from the 401 challenge, and
the consent page opens. Cursor, Gemini CLI, MCP Inspector: the same URL, after
dynamic client registration (/oauth/register; MCP_DCR_ENABLED controls it).
Consent page: the organization, All apps I can use, including future ones
or Only these apps, read & write (default) or Read only, then
Allow. It grants mcp:read and, for read & write, mcp:write; an app
shown as (no AI tools yet) has a snapshot with no tools; one with no
snapshot yet shows no hint (its tools load on first use).
Scripts and anything that takes a header: a personal access token. In the
Gateway open Admin (the sidebar footer's Manage workspace; owners and
admins only), tab Tokens, heading Personal access tokens, button
New token: a name, the access (Read only, the default: mcp:read,
MCP tools that read; Full access: *, acts as you on every app route and
tool, with a warning), All my apps or one app (the one app is the default
when there is one), and 7, 30 (the default) or 90 days or 1 year. The token is epat_…, shown once.
Send it as
Authorization: Bearer epat_… to https://<gateway>/mcp (a token made for one
app sees that app alone):
claude mcp add --transport http everyapp https://<gateway>/mcp --header "Authorization: Bearer $EVERYAPP_TOKEN"On an app host /mcp is the app's own private route, and an MCP-only token
gets 403 token_scope everywhere there.
Disconnecting: AI connections in the account menu at the bottom of the
sidebar (/settings/connections) lists each OAuth connection; revoking one
makes the client's next request fail invalid_token. Removing or
deactivating a person ends all of theirs; tokens are revoked on Admin, Tokens.
Other servers worth having beside the Gateway's (Cloudflare's API server for the owner, read before write; nothing else): setup/gateway, "Optional: MCP servers worth having beside EveryApp". Offer one in a line; never add it unasked.
Troubleshooting
Tools missing after a deploy, in order: (1) the client's cache: tools/list is
good for 60 s and a held stream polls every 30 s, so list tools again after a
minute; (2) the snapshot: read mcp.catalog.sync in the Gateway's logs
(unsupported: an SDK older than 0.3; error: the app's tools endpoint
failed, retried in 5 minutes; a self-hosted redeploy is noticed from the app
host's next request in each isolate); (3) hidden on purpose: write tools
are hidden from a read-only connection or token
(everyapp_whoami shows access), and a tool whose namespaced name passes 64
characters, whose description passes 2048 or whose schema isn't an object is
dropped with a warning (dev prints it as TOOLS_CATALOG); unlisted is not
hidden: a compact listing (the instructions' first line says the connection
"lists its tools compactly") leaves every app tool out of tools/list on
purpose, and each is found by everyapp_search_tools and callable by name or
through everyapp_call_read/everyapp_call_write, while a hidden tool stays
Unknown tool there too; (4) the person
cannot open the app: it is absent from everyapp_list_apps; grant access in
Admin (a connection made with Only these apps needs a reconnect to add one).
401 vs 403 on /mcp: 401 means no credential or one that doesn't verify (an
expired or revoked token, a bearer that isn't epat_ on an app host, the token
of a revoked connection); its body depends on the surface. On
https://<gateway>/mcp with OAuth on: an empty body and
WWW-Authenticate: Bearer realm="OAuth", resource_metadata="…/.well-known/oauth-protected-resource/mcp", scope="mcp:read mcp:write"
(error="invalid_token" added when a token was sent); a revoked connection's
token gets {"error":"invalid_token","error_description":"This connection was revoked or lost its access. Connect again."}.
On the Gateway with OAuth off:
{"error":"unauthenticated"} with WWW-Authenticate: Bearer realm="every-app"
(error="invalid_token" when a bearer was sent).
An app host answers /mcp as the app does (usually 404).
403 {"error":"forbidden","reason":…} means the credential verified and was
refused: token_scope (a token that is neither read only nor full, an
MCP-only token on any route of an app host), no_app_access (app host: the person cannot
open this app), organization_suspended.
OAuth off: MCP_OAUTH_ENABLED is false, or GATEWAY_URL is neither https
nor http on exactly localhost or 127.x: /oauth/* and the discovery documents
answer 404 oauth_disabled, /mcp takes epat_ tokens only and its 401 says
WWW-Authenticate: Bearer realm="every-app" with no authorization server. Turn
it on with pnpm everyapp secret set MCP_OAUTH_ENABLED, entering true (live at once).
DCR off or limited: with MCP_DCR_ENABLED false, /oauth/register answers
404 registration_disabled; CIMD clients still connect, Cursor cannot. While
on: 5 registrations a minute and 20 a day per client address, 200 a day per
self-hosted Gateway, then 429 rate_limited (wait, or use a CIMD client); past
60 requests a minute per address on /oauth/register or /oauth/token, 429 Too many attempts.
Behind Cloudflare Access: pnpm everyapp access on lets /mcp, /mcp/*,
/oauth/token, /oauth/register, /.well-known/* and /health through on the Gateway
host, so OAuth and tokens keep working; the consent
page stays behind Access. An Access assertion is never a credential on /mcp:
clients bring an OAuth or personal token.
Sources
- packages/sdk/src/server/tools.ts, toolsEndpoint.ts, everyApp.ts; packages/sdk/src/internal/tools.ts, protocol.ts
- packages/toolbus/src/server.ts, http.ts, builtins.ts, policy.ts, dev/index.ts
- apps/every-app-gateway/src/server/toolbus/catalog-sync.ts, gateway-mcp.ts; src/server/app-activation.ts, deploy/run.ts; src/server/mcp-auth/routes.ts, config.ts, validate.ts, consent.ts, registration-limit.ts
- apps/every-app-gateway/src/routes/_app/admin/tokens.tsx, settings/connections.tsx; src/client/components/Sidebar.tsx, oauth/ConsentForm.tsx, admin/CreateUserTokenModal.tsx
- packages/perimeter/src/gateway.ts; packages/cli/src/commands/dev/devAppGateway.ts, ready.ts; packages/cli/src/commands/deploy/deployApp.ts
- starter/src/tools.ts, entry.worker.ts, server/todos.ts, README.md; docs/runbooks/mcp-oauth.md; landing-page/content/agent/mcp.md; landing-page/content/docs/connect-ai.mdx
MCP: tools, the connector and OAuth
How an app defines agent tools with defineTools(), what the Gateway's one MCP address serves (app tools, built-in tools, MCP apps), how AI clients connect (OAuth, consent, personal tokens), and the switches.
Native apps: Sign in with Every App
How a Mac or phone app signs a person in to one EveryApp app through the Gateway's OAuth server: bearer routes (`public` entries with `bearer: true`), the client contract (discovery, registration, loopback redirect, PKCE, resource, refresh, sign-out), and Cloudflare Access.