Unified Tools
Declare a product capability once and serve it through REST, MCP, the CLI and the assistant.
A capability is declared once so REST, MCP, the CLI and the foundation's assistant cannot drift. The registry and its handlers belong to the Forms service — ADR-064.
Layout
apps/forms/src/ai/tools.ts the declarations, and nothing else
apps/forms/tools.snapshot.json their reviewed projection, under CODEOWNERS
packages/service/src/productTools.ts the registry, its §23.2 controls, the /v1/tools plugin
apps/forms/web/src/lib/mcp/server.ts the MCP adapter — it DISCOVERS, it declares nothing
scripts/product-tools-cli.mjs the CLI adapter, shared by every productDefinition
defineProductTool({
name: "list_forms",
description: "List the organisation's forms with their status and current version.",
category: "builder",
riskLevel: "READ",
requiredPermissions: [FORMS_PERMISSIONS.FORM_READ],
declaredTestCases: [{ limit: 10 }],
inputSchema: z.object({ limit: z.coerce.number().int().min(1).max(100).default(25) }),
handler: async (input, { session }) =>
withOrganization(prisma, session.workspaceId, (tx) => listForms(tx, input.limit)),
});session.workspaceId comes from the verified product session (ADR-036). It is never accepted
from tool input.
What the registry refuses at construction
The service does not boot if a tool declares no test case, declares no permission, is declared
READ while reaching an external effect, or sits below the floor its externalEffects impose
(§23.2 control 5). access — the readOnlyHint an MCP client shows a human — is derived
from riskLevel and cannot be declared.
The three routes
| Method | Path | Contract |
|---|---|---|
| GET | /v1/tools | Only the tools this session may run; the others are absent |
| GET | /v1/tools/:name | The tool's JSON Schema |
| POST | /v1/tools/:name | The one place a tool executes |
MCP and the CLI translate a transport and call POST /v1/tools/:name. Neither runs a handler,
so authorisation, validation and the business rule exist in exactly one place.
Adding one
- declare it in
src/ai/tools.ts; bun run guard:tools -- --write;- have the
tools.snapshot.jsondiff reviewed — that diff is the security review.
bun run guard fails while the declaration and the snapshot disagree, in either direction.
Using the CLI
AMEN_API_KEY=amen_sk_test_… bun run --filter @amen/forms tools list
AMEN_API_KEY=amen_sk_test_… bun run --filter @amen/forms tools run list_forms '{"limit":5}'It exchanges the API key for a product session through Core, exactly as the MCP adapter does.
Amen Forms