A gatekeeper decides whether an MCP tool runs immediately as an observation (if read-only and compliant) or enters an approval queue (if a write); the trust boundary at tools.ts enforces this via readOnlyHint, destructiveHint, and idempotentHint annotations, with only vetted endpoints auto-approving writes that meet all conditions. The session layer (McpSessionBase) stages and queues actions through an approval service while classifying tools from annotations, and generated methods delegate to callTool — but tool names are validated, normalized for JavaScript identifiers, and collision-checked to reach the RPC stub securely. In slack-api.ts and slack.ts, account identity is qualified by the Slack workspace, preventing collisions when a user is connected to multiple workspaces or when multiple workspaces share one deployment. The workspace-qualified account identity shape in slack-api.ts and slack.ts is a breaking change: stored identifiers in KV, Durable Objects, or permission checks may require verification or migration.
packages/mcp-shared is a shared library (not a Worker) used by both gatekeeper-mcp and gatekeeper-mcp-portal. It holds the MCP client, OAuth chain, account DO base, resource-URL scope grammar, and queued-action store.[1] packages/mcp-shared/src/tools.ts is the exclusive trust boundary between MCP server self-description and Gadget permissions — nothing outside it reads a tool's annotations.[2] In gatekeeper-mcp-portal, connectors flagged native: true are filtered out before the connector list is rendered, making native connectors invisible to portal users. The filtering logic lives in src/config.ts and src/portal.ts, with related changes in src/configurator/server-configurator-ui.tsx. scripts/build-gatekeeper-configurator.ts builds the gatekeeper configurator artefact, including scroll-state concerns for picker components. Its test suite lives in build-gatekeeper-configurator.test.ts.
McpToolMode classifies each MCP tool as either "read" (returns data immediately; every call recorded as an observation) or "action" (queued for approval before running).[3] The ServerTrust type has two values: "vetted" (an administrator asserted the endpoint's annotations are reliable, enabling auto-approval) and "byo" (a user typed the URL in, so no annotation can auto-apply a write).[2] readOnlyHint is honoured on both vetted and byo tiers — a known tradeoff: a mislabelled read-only tool runs with no approval, whereas an unlabelled tool is queued. Auto-applying a write additionally requires a vetted endpoint.[2] isDeclaredReadOnly requires tool.annotations?.readOnlyHint === true (strict equality), matching the MCP spec's default of false — an absent annotation is never treated as read-only.[2] Tool classification fails closed: a tool without readOnlyHint is classified as an action (write), not a read.[4] A tool is autoApprovable only when all four conditions hold: it is not read-only, the server trust is "vetted", destructiveHint === false, and idempotentHint === true.[2] MAX_TOOLS_PER_SERVER is set to 200 as an upper bound on tools taken from one endpoint, to keep generated types and catalogs bounded.[2] catalogRevision produces a 16-hex-character SHA-256 fingerprint of a tool catalog covering each tool's name plus its policy-relevant annotations (readOnlyHint, destructiveHint, idempotentHint). Descriptions are excluded so copy edits do not trigger the signal.[2] actionKindFor produces an ActionKind by percent-encoding both the scope tag and tool name and joining them with :, ensuring two connectors using the same binding ID cannot share pre-approvals.[2] The portal layer implements pagination primitives to page through an MCP server's tool list incrementally, avoiding loading the entire catalog into memory at once. Callers must not assume all tools are available immediately after connection.
describeCall renders an approval prompt in Markdown, sanitizing server-supplied text via quoteUntrusted (strips headings, defuses fences, block-quotes the result, caps at 600 chars for descriptions and 4000 chars for arguments) to prevent a tool description from forging content in the prompt's own voice.[2] defuseFences replaces any run of 3+ backticks with ''' to prevent a server-supplied value from closing a Markdown code fence inside an approval prompt.[2] quoteUntrusted strips heading and blockquote markers repeatedly (because one pass turns ## into #, still a heading), then caps and block-quotes the result. MAX_DESCRIPTION is 600 characters and MAX_ARGUMENTS is 4000 characters.[2] codeSpan removes all backticks from server-chosen text before placing it in a Markdown code span, preventing a tool name containing a backtick from closing the span and injecting arbitrary prompt prose.[2]
McpSessionBase in packages/mcp-shared/src/session.ts is the Gadget-facing RPC class for one MCP session over one binding. It exposes listTools(), callTool(), and getActionResult(); per-tool named methods are installed by a per-grant subclass in session-methods.ts.[5] McpSessionHost is an intentionally narrow interface: it is handed to a Gadget, so anything reachable from it is one followPath away from untrusted code.[5] McpSessionBase is designed to be subclassed by connectors, which apply @validateRpc() in the subclass so the decorator is visible in the file that hands the session to a Gadget.[5] McpSessionBase.listTools() fetches the classified tool list from the host and calls queue.authorizeObservation() before returning — listing tools is itself an observation gated by the gatekeeper approval flow.[5] For "read" mode tools, McpSessionBase.callTool() executes the call immediately and then calls queue.authorizeObservation() before returning the result — authorization is recorded after the call, not before.[5] For "action" mode tools, McpSessionBase.callTool() stages the action, submits it to the approval queue via queue.submitAction(), and immediately returns { status: "pending", actionId, message }. The caller must poll getActionResult() for the outcome.[5] If queue.submitAction() throws in McpSessionBase.callTool(), the staged action is immediately discarded via host.discardStagedAction() to prevent orphaned pending records.[5] McpSessionBase.callTool() throws if the requested tool name is not found in the current tool list. The error message is context-sensitive: on a scoped binding it lists only the granted tools; on a whole-endpoint binding it reports the tool as missing from the server.[5] For an "applied" action, getActionResult() calls queue.authorizeObservation() at the moment the result is handed to the Gadget — not when the action was applied — because the result was produced while the Gadget was not watching.[5] When getActionResult() is called for an "applied" action whose result is missing, McpSessionBase returns a synthetic { status: "ok", content: [], text: "", isError: false } fallback rather than throwing.[5] McpSessionBase holds a private RpcStub<ApprovalQueue> and disposes it via [Symbol.dispose](), ensuring the approval queue stub is cleaned up when the session is disposed.[5]
StoredAction.state transitions through "pending" → "applying" → "applied" | "rejected" | "failed". The "applying" state exists specifically to prevent a second applyAction from finding the record still "pending" and calling the tool a second time.[5] StoredAction.retryable marks whether a "failed" action can be retried. Absent means retryable (backward-compatible default); false means the request may already have taken effect and a retry could duplicate a non-undoable MCP write.[5]
McpContent in packages/mcp-shared/src/base-types.ts is a discriminated union covering five content block types: text, image, audio, resource_link, and resource.[3] toCallResult flattens McpToolCallResult content: it concatenates all "text" blocks (joined by newlines) into a text field and also passes through structuredContent and isError unchanged.[2]
In packages/mcp-shared/src/session-methods.ts, session tool methods are placed on a prototype rather than as own properties because Cap'n Web and Workers RPC both refuse own properties on an RpcTarget.[6] Each generated session method is a one-line delegate to callTool, keeping the scope check, approval queue, and observation record in one place while delegates inherit the @validateRpc() checking applied there.[6] toMethodName converts MCP wire tool names to camelCase JavaScript method names (e.g., list_issues → listIssues). It returns null when the result cannot start an identifier (a leading digit), keeping those tools reachable only through callTool.[6] When two tools produce the same generated method name (e.g., list_issues and listIssues both map to listIssues), BOTH are dropped from the generated methods; they remain reachable through callTool, which keeps the names distinct.[6] RESERVED_METHOD_NAMES is the set of names that must never be generated as tool methods. The first group (then, catch, finally, dup, onRpcBroken, constructor, toString, valueOf, hasOwnProperty, __proto__, map) is intercepted or hijacked by the RPC stub; the second group (callTool, getActionResult, listTools) is the session's own surface.[6] installToolMethods returns an anonymous subclass of the provided Base class so the mutated prototype cannot be one that anything else shares. If the tool list cannot be fetched, the base class is returned directly and still works.[6] Write tools (those without readOnlyHint) are installed as callable session methods and reach callTool normally — the approval queue, not the method type system, is the gate that holds writes for user approval.[4] The e2e test in packages/mcp-shared/__tests__/session-methods-e2e.test.ts verifies that the generated .d.ts type and the installToolMethods-produced object agree: every method the type promises is installed and routes to the correct tool name via callTool, and nothing callable was omitted from the type.[4] The e2e test asserts that list_issues and listIssues both collide and are excluded from generated methods, that then/map are excluded because they are hijacked by the RPC stub, and that 2fa is excluded because it is not a valid JavaScript identifier.[4]
A ToolScope in packages/mcp-shared/src/scope.ts encodes a binding's grant over an MCP endpoint using two optional fields: serverId (restricts to one portal upstream server) and tools (restricts to exact tool wire names). Absence of both fields means the whole endpoint is granted.[7] Tool names inside a ToolScope are stored as exact wire names (not relative to a server), so no code has to guess at the portal's separator and grants issued before portals existed still resolve correctly.[7] The URL fragment of a resource URL encodes the scope: no fragment = whole endpoint; #server=<id> = all tools of one portal server; #tool=a&tool=b = exact named tools; both keys together are enforced independently.[7] parseToolScope() is intentionally fail-closed: an obsolete tools key (plural) produces an empty restriction rather than granting the whole endpoint, and a tool key that yields nothing usable is kept as an empty restriction. Only the complete absence of both tool and tools keys grants the whole endpoint.[7] formatToolScope() explicitly emits an empty tool key when scope.tools is an empty array, preserving the fail-closed semantics after a round-trip through parseToolScope().[7] sameEndpoint() compares the full URL (path and query included, not just origin) to determine if two resource URLs name the same MCP endpoint. It returns false for anything unparseable.[7] endpointTag() produces a URL-encoded, fragment-stripped endpoint identity for use in persistent approval policy namespacing. Two endpoints share a tag if and only if sameEndpoint() considers them equal; the value is encodeURIComponent-encoded so path or query characters cannot act as separators.[7] scopeAllows() always blocks portal-native tools (those matching isPortalNativeTool) regardless of scope, but requires the isPortal boolean argument to be set — a plain server with a tool coincidentally named portal_something is still permitted.[7] requireCompleteCatalogForToolSelection() throws if the catalog is truncated, preventing individual-tool selection when the server's catalog is too large to enumerate completely. Callers must grant all tools instead.[7] validateToolScopeAgainstCatalog() throws if the catalog is truncated and the scope names specific tools, because a truncated catalog cannot reliably validate individual tool names.[7] For portal server scopes, validateToolScopeAgainstCatalog() throws if no tool in the catalog belongs to the requested server and the server is not in reportedServers; a server present in reportedServers even with no current tools is accepted and returned so callers can persist its display name.[7]
validateCustomEndpoint in packages/mcp-shared/src/endpoint.ts requires HTTPS and rejects private/link-local/metadata hosts for user-supplied MCP endpoint URLs. The MCP_ALLOW_INSECURE env flag (checked via fetchOptions(env).allowInsecure) disables both checks for local development.[8] validateCustomEndpoint strips URL credentials and the fragment (url.hash = "") before returning the canonical URL, because credentials in the URL would end up in logs and approval prompts.[8] The SSRF blocklist (BLOCKED_HOST_PATTERNS) is deliberately NOT the security boundary; enforcement is the global_fetch_strictly_public wrangler compat flag (set in each connector's wrangler.jsonc), which makes workerd reject reserved IP ranges after DNS resolution on every request and redirect hop. The blocklist adds only a legible refusal at connect time.[8] isBlockedHost is exported because a user-supplied endpoint URL is not the only URL its server can cause the system to fetch: OAuth discovery follows a WWW-Authenticate header and then an issuer chosen by the far side, so redirect targets must also be checked.[8] normalizeHost converts IPv4-mapped IPv6 addresses ([::ffff:7f00:1]), hex/octal/decimal bare-integer IPv4 addresses (e.g., 0x7f000001, 2130706433), and normal dotted-quad to a single canonical dotted-quad form so blocklist patterns cannot be bypassed by alternate address spellings.[8]
OAuthTokens in packages/mcp-shared/src/oauth.ts is defined as StoredOAuthTokens & { expiresAt?: number }, extending the SDK type with an absolute expiry timestamp used on the account hot path.[9] MCP OAuth must always use sdkFetch(...) to ensure every request and redirect retains endpoint and SSRF checks.[1] isCredentialRejection() returns true only for authorization-server verdicts (invalid_grant, invalid_client, unauthorized_client, invalid_scope); transport failures are intentionally left retryable.[9] safeOAuthError() scrubs submitted credentials (including both plain and URL-encoded Basic auth header values) from OAuth error messages before logging or displaying them.[9] revokeToken() sends the token_type_hint, client_id, and token as application/x-www-form-urlencoded POST body; it does not include client_secret, reflecting that the account registers as a public client with token_endpoint_auth_method of "none".[9]
In mcp-shared, the trust boundary is tools.ts: a tool declared readOnlyHint: true by the server runs as an observation; everything else is queued for approval. Auto-applying a write additionally requires a vetted endpoint, which only the portal gatekeeper can produce via MCP_PORTAL_TRUST_ANNOTATIONS.[1] The Gatekeeper skeleton's HTTP handler routes auth initiation via a two-segment path /<doId>/<nonce>; the first segment is 64 hex characters (the DO ID string) and the second is NONCE_BYTES * 2 hex characters (64 hex characters for a 32-byte nonce).[10]
The "nothing escaped to the internet" assertion must be placed in afterAll, not afterEach. With it.concurrent, an afterEach fires while sibling tests are still running and could discard an escape event a sibling was about to be blamed for.[11] When a consumer repo installs both its own workspace and the public/ submodule separately, capnweb resolves to two different copies, causing TypeError: Cannot serialize value: [object RpcStub] when stub instances cross the boundary. This manifests in CI but not locally when a single pnpm install deduplicates both.[11] To avoid the dual-capnweb serialization trap, always mint callback stubs with stubFor() from rpc-client, never with an imported RpcStub value. Importing RpcStub as a TypeScript type is fine. This is enforced by .oxlintrc.json restricting capnweb value imports within the integration-tests package to rpc-client.ts.[11] Worker entry modules may only export classes and the default handler. Exporting a plain string constant from the fixture entry module causes workerd to throw Incorrect type for map entry '...': the provided value is not of type 'function or ExportedHandler'. Type-only exports are safe because they erase.[11]
The gatekeeper-cloudflare package contains an observability subsystem spanning observability-api.ts, observability-discovery.ts, observability-parse.ts, observability-session.ts, and observability.ts; together they query Cloudflare Workers logs and metrics and expose that telemetry through the gatekeeper resource catalog. observability-discovery.ts in gatekeeper-cloudflare is the designated extension point for surfacing per-worker telemetry through the gatekeeper interface.
packages/gatekeeper-google is a gatekeeper package for Google Drive integration. Its core modules are: drive-api.ts (Drive REST API client), observers.ts (change-watching), resources.ts (resource model), cursor.ts (pagination/cursor handling), gmail-validate.ts (Gmail address validation), and google-configurators.ts (configurator registry). packages/gatekeeper-google includes a Gmail configurator UI at configurator/gmail-configurator-ui.tsx and a test suite in __tests__/ covering the Drive API client, markdown converter, cursor logic, observer behaviour, and Gmail address validation. packages/gatekeeper-google is wired into the workspace via pnpm-lock.yaml; gatekeeper registration into the main workshop is not present in the package. The gatekeeper-google OAuth flow accepts configurable redirect origins, enabling preview and staging deployments to complete the OAuth round-trip with Google; previously only production origins were supported. Test coverage for this path lives in packages/gatekeeper-google/__tests__/oauth.test.ts. packages/gatekeeper-google supports Google Drive metadata search, allowing callers to filter on Drive-native metadata fields in addition to full-text search. The feature spans drive-api.ts, drive-session.ts, drive-observers.ts, and drive-types.d.ts. The configurator type definitions drive-account-configurator-types.d.ts, drive-file-configurator-types.d.ts, and shared-drive-configurator-types.d.ts in packages/gatekeeper-google, along with their corresponding UI components, expose metadata search query parameters to configurator consumers. packages/gatekeeper-google includes src/auth-retry.ts for resilience during token-refresh failures; test coverage lives in drive-session.test.ts, native-sessions.test.ts, and types-parity.test.ts. packages/gatekeeper-google includes a markdown converter at src/markdown-converter.ts and type declaration files docs-types.d.ts, docs-read-types.d.ts, and drive-types.d.ts formalising Drive and Docs API response shapes. packages/gatekeeper-google provides a native Google Drive document session layer in src/drive-session.ts and a Docs API client in src/docs-api.ts, enabling agents to open, read, and interact with Google Docs and Drive files through a session abstraction. packages/gatekeeper-google includes workerd integration tests at workerd/gmail-actions.test.ts and workerd/gmail-state.test.ts confirming Gmail runtime behaviour inside the Workers runtime. packages/gatekeeper-google adds OAuth scope negotiation via src/gmail-scope.ts and per-session state tracking via src/gmail-state.ts to reduce privilege-scope mismatch bugs. packages/gatekeeper-google supports cursor-based pagination for Gmail via src/cursor.ts, enabling incremental navigation of large mailboxes. A silent OAuth token-refresh failure in packages/gatekeeper-google/src/google-configurators.ts could leave the gatekeeper holding expired credentials without surfacing a clear error. The silent token-refresh bug in google-configurators.ts is covered by a workerd-environment test in packages/gatekeeper-google/__tests__/workerd/configurators.test.ts; vitest.worker.config.ts was updated to include that test. In packages/gatekeeper-google/src/resources.ts, agent-facing resource descriptions instruct agents to batch Google Calendar free/busy queries rather than issuing one per calendar, reducing API call volume and rate-limit collisions. In src/configurator/calendar-configurator-ui.tsx and src/google-configurators.ts, the Google Calendar "primary" alias is resolved to the concrete calendar ID at configuration time, preventing mismatches when the real calendar ID is returned by other API responses.
packages/gatekeeper-github implements a read-write Git workflow: agents pull file trees from GitHub, edit them in-session, and push commits back. The implementation spans src/git-transport.ts, src/git-commits.ts, src/git-diff.ts, and src/github-api.ts. packages/gatekeeper-github includes storage-schema.md documenting the on-disk layout for Git state and file trees, and workerd-scoped integration tests under __tests__/workerd/ covering push, pull simulation, and session-level Git state.
Sources
AGENTS.mdpackages/mcp-shared/src/tools.tspackages/mcp-shared/src/base-types.tspackages/mcp-shared/__tests__/session-methods-e2e.test.tspackages/mcp-shared/src/session.tspackages/mcp-shared/src/session-methods.tspackages/mcp-shared/src/scope.tspackages/mcp-shared/src/endpoint.tspackages/mcp-shared/src/oauth.ts.agents/skills/write-gatekeeper/SKELETON.mddocs/integration-testing.md