Unified Tools
developermcp

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 product

Definition

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

MethodPathContract
GET/v1/toolsOnly the tools this session may run; the others are absent
GET/v1/tools/:nameThe tool's JSON Schema
POST/v1/tools/:nameThe 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

  1. declare it in src/ai/tools.ts;
  2. bun run guard:tools -- --write;
  3. have the tools.snapshot.json diff 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.