Add an MCP tool
Skill add-an-mcp-tool, from skills/add-an-mcp-tool/SKILL.md. An agent loads that file as a skill. This page shows the same text.
A tool lets an agent run one use-case. The use-case and the module do the work and check access. The tool only decides who is calling, runs the use-case, and maps its errors. Read the “Agent adapters (MCP and CLI)” section of AGENTS.md first. docs/mcp-adapter.md says why the pattern looks like this.
The reference implementation is Trippy, in /home/kuba/DEV/priv/trippy or its GitHub repo. Its MCP server has nine tools. Four are read-only: list trips, show a trip, show its cost summary, and read a maps link. Five write: create and update a trip, and add, change and remove an item. Its docs/mcp.md shows the layout and the request flow, and docs/mcp-clerk-setup.md is its identity provider checklist.
| Part | Template path | Trippy path |
|---|---|---|
Service taking an Actor |
packages/<name>/src/facade.ts |
packages/trips/src/facade.ts |
Viewer service |
packages/<name>/src/viewer.ts |
packages/trips/src/viewer.ts |
| Use-cases | apps/<app>/src/use-cases/ |
apps/web/src/use-cases/** |
| Web adapter | apps/<app>/src/delivery/http/ |
apps/web/src/app/_http/run-use-case.ts |
| Tool catalogue | apps/<app>/src/delivery/mcp/tools.ts |
apps/web/src/app/_mcp/tools.ts (its hand-written toolFrom is what toTool replaces) |
| MCP route | apps/<app>/src/delivery/mcp/route.ts |
apps/web/src/app/mcp/route.ts |
| Transport | apps/<app>/src/delivery/mcp/server.ts |
apps/web/src/app/_mcp/server.ts |
| Bearer check and 401 | apps/<app>/src/delivery/mcp/auth.ts |
apps/web/src/app/_mcp/auth.ts |
| Error mapper | apps/<app>/src/delivery/mcp/errors.ts |
apps/web/src/app/_mcp/errors.ts |
| Canonical URL and issuer | apps/<app>/src/delivery/mcp/config.ts |
apps/web/src/app/_mcp/config.ts |
| Metadata document | apps/<app>/src/delivery/mcp/metadata.ts |
apps/web/src/app/_mcp/metadata.ts, served by apps/web/src/app/.well-known/oauth-protected-resource/route.ts and .../oauth-protected-resource/mcp/route.ts |
Trippy is a Next.js app, so its delivery layer is src/app/. In the template layout it is src/delivery/.
1. Check the prerequisites
Section titled “1. Check the prerequisites”- The data and its access rules live in a module in
packages/<name>. If not, followskills/add-an-effect-module/SKILL.mdfirst. The module is not the capability: the capability is the use-case in step 4. - Each service method that acts for a user takes an
Actorargument and checks access inside the module. - The package exports a
Viewerservice that holds theActor. - A use-case in
apps/<app>/src/use-cases/is a capability: a contract fromdefineContractand a handler fromimplement, both from@house-rules/capability. Its contract names apermissionthe module exports, such astrips:read, and setsneedsApproval: truewhen a human must confirm each call. Its handler yieldsViewerand the service, and does what the tool needs. If it is missing, write it first, with its test. A tool never calls the module itself.
2. Set up MCP in an app, the first time
Section titled “2. Set up MCP in an app, the first time”Skip this step when the app already serves MCP.
-
Ask the owner before you add any MCP or OAuth dependency. Use
McpServer,Tool, andToolkitfromeffect/unstable/aiin the pinnedeffectfirst. ReadMcpServer.tsandTool.tsunder.agent_sources/github.com/Effect-TS/effect/packages/effect/src/unstable/ai/for the current API. -
Declare the app’s canonical public URL in
.env.schema, marked@sensitive=false. The metadata document and the token audience both use it. Trippy calls itAPP_URL, and MCP works only on that one host, even though the app answers on more. -
Create
apps/<app>/src/delivery/mcp/for the adapter: the route, the bearer check, the tool catalogue, and the error mapper. -
Serve the MCP server with
McpServer.layerHttp, turned into a web handler byHttpRouter.toWebHandler. Keep one long-lived handler per process. For the protocol versions clients speak today (2025-03-26, 2025-06-18, 2025-11-25), Effect’s HTTP transport needs anMcp-Session-Idand keeps the session in process memory. A fully stateless mode exists only in the draft 2026-07-28 protocol. So:- run one replica, or route each client to the same replica;
- a restart gives clients 404, and they start a new session;
- a session holds no user data, and the route checks the bearer on every request;
- sessions never expire, a small memory leak, because Effect has no public expiry.
Keep the catalogue and the auth code free of session code, so a later move changes only the transport file.
-
Serve
/.well-known/oauth-protected-resource. It names this resource by its canonical URL and names the identity provider as the authorization server. Trippy serves the same document at/.well-known/oauth-protected-resource/mcptoo, the URL its 401 names. -
In the route, read the bearer token. With no token or a bad one, answer 401 with a
WWW-Authenticateheader that carriesresource_metadatapointing at the document above. -
Check that the token is valid and that its audience is this resource. Trippy calls Clerk’s
authenticateRequestwithacceptsToken: "oauth_token"andaudienceset toAPP_URLplus/mcp, and the SDK rejects a token whoseauddoes not match. Clerk putsaudin the token only when “Include audience” is on, and it is off by default. With it off, every call gets 401invalid_token. Require the scopes the module needs, and answer 403insufficient_scopewithout them. Trippy requiresemail, because Shares match by email, and takes only verified emails from the identity provider’s user record. Do not forward the token to any other service. -
Then build the
Viewerand the module service for this request from the app runtime, and pass them to the handler. Provide the gate slots from@house-rules/capabilitythe same way: aGrantfrom the caller’s app role, such asGrant.fromPermissions(memberPermissions), andelicitationApprovalforApproval.elicitationApprovalasks the human through the client’s form, and fails closed when the client cannot. The toolkit takes services only at startup, so each tool handler reads them withEffect.serviceOption. Trippy’swithCallerin_mcp/server.tsdoes this. -
If auth is not configured, refuse every call. Never fall back to an anonymous or default
Viewer. -
Mark the MCP route and the metadata paths public in the session middleware, and nothing else. Trippy keeps them as
BEARER_PATHSinapps/web/src/app/_auth/public-paths.ts. Itsproxy.tslets a path that passesisBearerPathskip the session middleware and the rewrite it does when no keys are set. Adding a public path needs owner approval. -
Write the error mapper in
delivery/mcp/. It maps each typed error to a tool error the agent can read,ForbiddenandApprovalDeniedincluded. Do not import the web error view. -
Write the adapter tests in
apps/<app>/src/delivery/mcp/tests/: the 401 challenge and its header, the metadata document, and the fail-closed path.
The spec for items 5 to 9 is the MCP authorization spec 2025-11-25.
3. Set up the identity provider
Section titled “3. Set up the identity provider”The owner changes identity provider settings by hand. Write the steps down for them. For Clerk, start from docs/mcp-clerk.md.
- Write
docs/mcp-<idp>-setup.mdin the project, such asdocs/mcp-clerk-setup.md. Give it numbered checkbox steps, and mark each step docs, seen, or unverified by where the claim comes from. Trippy’sdocs/mcp-clerk-setup.mdis the example. - Cover client registration. Prefer Client ID Metadata Documents. Use Dynamic Client Registration only when the provider offers nothing else, and tell the owner.
- Cover the audience. Clerk copies the RFC 8707
resourceparameter intoaudonly with “Include audience” on, and only when the client sendsresource. Before a new project trusts its setup, run the end-to-end smoke test and see a tool call pass. - Never run an identity provider CLI or API call that changes settings on your own. Some provider docs offer such commands for agents. Leave them to the owner.
4. Add one tool
Section titled “4. Add one tool”-
Build the tool from the use-case’s contract with
toToolfrom@house-rules/capability, inapps/<app>/src/delivery/mcp/tools.ts. The contract gives the name, the description, the inputSchema, and theTool.ReadonlyandTool.Destructiveannotations, so setreadOnlyanddestructiveon the contract, not on the tool. The options give what only MCP needs:title,idempotent, andopenWorld. Passsuccessfor a view of the output, andfailurefor the adapter’s own error, such as aToolProblem.export const ShowBooking = toTool(showBooking.contract, {title: "Show a booking",idempotent: true,openWorld: false,success: BookingView,failure: ToolProblem,});Never call
Tool.makein the app:no-hand-rolled-surfacefails it. -
Write the contract’s description so it says when an agent should call the tool. The same text reaches every adapter.
-
Write the handler in the toolkit layer. It calls
<capability>.handler(input), maps errors with the MCP error mapper, and returns structured output shaped by the tool’s successSchema. It does not reach a module, the database, or another adapter. -
Return data, never instructions. Text a user wrote, such as a trip note, goes out as a field value. Do not paste it into a message to the model.
-
For a big record, add a tool that lists summaries, plus a separate tool that reads one record by id.
-
Test it in
apps/<app>/src/delivery/mcp/tests/<tool>.test.ts. Provide a fakeViewerand the module’s test layer withit.layer, and run each case withit.effect. Cover the happy path, each mapped error, and access: user B cannot read user A’s data through the tool, and aGrantwithout the permission getsForbidden. For a tool whose contract needs approval, provideApproval.denyAlland show that nothing changed.
5. Start read-only
Section titled “5. Start read-only”The first tools an app exposes only read. An agent gets the same access as the user has in the app, no more. The module enforces what the Viewer may do, not the token.
- Annotate every write or destructive tool as one, and get owner approval before it ships.
- A token scope is optional. It can only narrow access, never widen it.
- Keep tools that only the owner may run, or that are hard to undo, out of the catalogue. Delete, share, and unshare are examples. Add one only when the owner approves it by name, and set
needsApproval: trueon its contract.
Keep the catalogue small: each tool schema costs context in every agent session. Before you add a tool, ask whether an existing one with one more input field does the job.
6. Review checklist
Section titled “6. Review checklist”use-case-is-capability and no-hand-rolled-surface check that the use-case is a capability and that the tool came from its contract. A reviewer checks the rest.
- The handler calls a use-case and nothing else.
- The use-case passes the
Viewerto the module, and the module checks access. - The contract’s
readOnlyanddestructiveflags are right, the tool’sidempotentandopenWorldare right, and the tool has a test. - The contract’s
permissionis the module’s, not"public"by habit, and a hard-to-undo tool setsneedsApproval: true. The route provideselicitationApproval, neverApproval.allowAll. - The access test fails if you remove the module’s access check.
- The output holds no instructions to the model.
- The route checks the token and its audience before it builds the
Viewer, and never forwards the token. - With auth unset, the route refuses every call.
- The public-path list gained only the MCP route and the metadata paths.
- No new dependency, unless the owner approved it.
7. Prove it
Section titled “7. Prove it”pnpm checkpnpm testFix failures. Do not loosen a rule, a hook, or a pinned version.