Adding MCP tools
Every MCP tool in Engram follows one pattern: a strict Zod input schema, a
typed handler, and a tool definition object that ties them together.
Shared, dependency-free tools live in packages/core/src/mcp/tools/;
app-level tools that need NestJS services (Prisma, embeddings) are built
in apps/mcp-server and registered at boot.
1. Define the schema, handler, and definition
Section titled “1. Define the schema, handler, and definition”Create packages/core/src/mcp/tools/<name>.tool.ts. The ping tool is the
minimal reference implementation:
import { z } from 'zod';
/** Input schema — always .strict() so unknown keys are rejected. */export const pingInputSchema = z.object({}).strict();
export interface PingOutput { status: string; timestamp: string;}
export async function pingHandler(): Promise<PingOutput> { return { status: 'pong', timestamp: new Date().toISOString(), };}
export const pingTool = { name: 'ping', description: 'Test connectivity to ENGRAM server', inputSchema: pingInputSchema, handler: pingHandler, // Connectivity check — callable without authentication. auth: 'public' as const,};Rules that apply to every tool:
.strict()on all object schemas — unknown keys must be rejected.- Validate everything at the boundary; the handler receives parsed input only.
- No
anywithout justification (TypeScript strict mode).
2. Pick the auth mode
Section titled “2. Pick the auth mode”The auth field declares how an authenticated identity maps onto the tool
(ToolAuthMode in packages/core/src/mcp/tools/index.ts):
| Mode | Meaning |
|---|---|
identity |
Default. The verified userId is injected into the input, overriding any client-supplied value — a forged tenant cannot read another’s data. |
admin |
userId is an operator-chosen parameter; the tool carries its own adminToken gate (checked against MCP_ADMIN_TOKEN). |
public |
Callable without authentication (e.g. ping). |
Two optional refinements for identity tools:
requiredScope— a scope the principal must hold (e.g.memories:write); theadminscope satisfies any requirement.delegable: true— allows anadmin-scoped key to act on another tenant by passing an explicituserId(audited). Leave destructive credential tools pinned.
Handlers that need to attribute the call receive an optional second argument,
ToolCallContext, carrying the verified actorUserId, apiKeyId, scopes,
and delegated — built from the transport’s auth info, never from tool input.
3. Register the tool
Section titled “3. Register the tool”- Core tool: add it to the
builtInToolsarray inpackages/core/src/mcp/tools/index.ts. - App-level tool (needs injected services): build the
Toolobject inapps/mcp-server(seememory.controller.tsfor the pattern) and register it viaMcpHandler.registerAdditionalTools([...])before the server initializes.
4. Test at both levels
Section titled “4. Test at both levels”Every tool needs tests at the service level and the wiring level — see testing conventions:
<name>.tool.spec.tsnext to the tool: schema rejects unknown/invalid input, handler behavior, error paths.- A wiring-level test asserting the tool is actually registered and reachable
through dispatch (including its auth mode —
dispatch-auth.spec.tsandtools-list-schema.spec.tsshow the pattern).
5. Regenerate the tool reference
Section titled “5. Regenerate the tool reference”The MCP tools reference is generated from the Zod schemas and committed. After adding or changing a tool:
pnpm docs:generateCI fails on stale generated docs (git diff --exit-code drift gate), so commit
the regenerated pages with the tool.
Checklist
Section titled “Checklist”- Strict Zod schema, exported
- Typed handler, exported
- Tool definition with
name,description,inputSchema,handler, and an explicitauthmode (plusrequiredScope/delegablewhere relevant) - Registered (core
builtInToolsorregisterAdditionalTools) - Service-level and wiring-level tests
-
pnpm docs:generaterun and output committed