@automators/capabilities
v0.3.0
Published
The transport-neutral capability registry Automators apps expose to agents: one definition per tool, permission and schema checks applied centrally, and a stateless MCP JSON-RPC adapter over it.
Readme
@automators/capabilities
One way to define agent tools across Automators apps.
An app writes each tool once as a capability: a name, a description, a Zod input schema, a Zod output schema, explicit safety annotations, and the permission it requires. The app keeps its capabilities in one registry and serves that registry to every surface it has, so a tool added in one place shows up on all of them with the same permission checks, the same schemas, and the same safety hints:
- an MCP endpoint, through the stateless JSON-RPC adapter in this package,
- an in-app assistant, running capabilities in-process,
- scheduled or unattended runs.
The package holds the runtime and the wire adapter. It holds no tools and no authorization rules of its own: the app injects both.
Install
pnpm add @automators/capabilities zodzod is a peer dependency. The runtime uses the Zod 4 API through the
zod/v4 entry point, which zod@^4 and zod@^3.25 both provide. Write
schemas with the same Zod the runtime sees: import { z } from "zod" on
Zod 4, or import { z } from "zod/v4" on a 3.25 install.
Bind it to your app once
// src/lib/capabilities/runtime.ts
import { createCapabilityRuntime } from "@automators/capabilities";
import { userHasPermission, type Permission } from "@/lib/permissions";
import type { AppUser } from "@/lib/auth";
export const { defineCapability, runCapability, handleMcpRpc } =
createCapabilityRuntime<AppUser, Permission>({
policy: { userHasPermission },
serverInfo: { name: "my-app", version: "1.0.0" },
});AppUser must carry appRoles: string[] and permissions: string[];
anything else it carries (a session, an email, token scopes) reaches
run untouched.
The policy is the app's
userHasPermission(user, permission) is the whole permission rule. If an
owner role implicitly holds every permission in your app, say so there.
The package never decides that a role key means everything.
authorize(capability, user) is an optional second gate that runs after
the permission check with the whole capability and user in hand. Return
a message to refuse (surfaced as JSON-RPC error -32001), or null to
allow. It exists for rules that are not permissions, such as a token
whose scopes attenuate what its holder may do:
policy: {
userHasPermission,
authorize: (cap, user) =>
user.tokenScopes === undefined
? null
: missingScopeFor(cap.name, user.tokenScopes),
}Define a capability
import { z } from "zod";
import { defineCapability } from "./runtime";
export const readDocument = defineCapability({
name: "read_document",
description: "Read one document by slug. Returns found: false when absent.",
inputSchema: z.object({ slug: z.string() }),
outputSchema: z.union([
z.object({ found: z.literal(true), title: z.string(), body: z.string() }),
z.object({ found: z.literal(false), hint: z.string() }),
]),
annotations: { readOnly: true, destructive: false },
requiredPermission: "documents:read",
run: async ({ slug }, { user }) => {
// input is fully typed; the return value must match outputSchema
},
});Rules the runtime enforces (and the package tests assert):
- Permission:
requiredPermissionis checked through the policy. Failure isCapabilityError(RPC_FORBIDDEN). - Authorize hook: runs only when the permission check passed. A
returned message is
CapabilityError(RPC_FORBIDDEN, message). - Input: parsed with
inputSchema; invalid input isCapabilityError(-32602)carrying the Zod issues. Missing arguments parse as{}. - Output: the
runresult is parsed withoutputSchema; a mismatch isCapabilityError(-32603, "... returned an invalid result"). That is a server bug surface, never a client error. - Output schemas stay server-side:
capabilityToListEntryadvertises the input schema and the annotation hints, neveroutputSchema. Every connected client pays for the catalogue on every session, and output schemas describe shapes a caller already receives instructuredContent. Keep writing real output schemas: they are what catches a capability returning something it did not promise. - Input schemas survive conversion:
toJsonSchemausesio: "input"andunrepresentable: "any", so a.transform()or one exotic field cannot collapse a whole schema into{ type: "object", additionalProperties: true }. That fallback is silent and leaves a model guessing every argument; assert against it in your registry test. - Structured results:
runCapabilityreturns the structured value. Adapters that need text callrenderCapabilityText(value)(strings pass through, everything else pretty-printed JSON). Never return a pre-stringified blob fromrun. - Empty and missing cases are structured too: return a schema-covered
branch like
{ found: false, hint }or{ items: [], hint }, not a prose sentence.
Annotation semantics
annotations is required and both fields are explicit booleans. There is
no name-based classification anywhere.
readOnly: truemeans the capability is safe to run without assistant approval. DeriveapprovalRequired = !(readOnly === true)so anything not explicitly read-only fails closed into requiring approval.destructive: truemeans it deletes or irreversibly replaces data.
capabilityToListEntry maps them onto MCP ToolAnnotations as
readOnlyHint / destructiveHint.
Serve the registry over MCP
handleMcpRpc(message, user, capabilities) is a stateless JSON-RPC 2.0
dispatcher: one message or a batch in, the response out, nothing kept
between calls. It answers initialize, ping, tools/list,
tools/call, resources/list, prompts/list, and the
notifications/* no-ops; anything else is -32601. tools/call always
returns a text content block, and object results additionally ride in
structuredContent. Capability errors pass through with their JSON-RPC
codes. A notification-only message returns null so the route can
answer 204.
The route around it is the app's: authenticate the caller into a user,
parse the body, and hand both to the handler. Operator's
src/app/api/mcp/route.ts is the reference shape (JSON in, JSON out, a
401 that carries RFC 9728 discovery, GET refused as stateless, DELETE
answered 204).
The error envelope
Every failure a caller can act on arrives as one shape,
{ error: { code, message, hint?, field?, allowed?, ... } }, whether a
capability returned it for a domain reason (toolError("CONFLICT", ...))
or the runtime threw. isToolError recognises it, which is how
tools/call decides to set isError.
capabilityFailure(error, options?) is the mapping from a thrown failure
to that envelope: RPC_INVALID_PARAMS becomes VALIDATION with the
offending field and its allowed values when Zod reported them,
RPC_FORBIDDEN becomes FORBIDDEN, and anything else, including a throw
that is not a CapabilityError at all, becomes UPSTREAM. The MCP
handler calls it around runCapability.
Export it because MCP is not the only surface over a registry. An app
running an in-process assistant calls runCapability directly and needs
the same mapping to turn a throw into a tool result its model can
recover from; before this was exported, each app wrote the switch again
and they drifted on the wording. Pass supportContact to name your own
support channel in the FORBIDDEN and UPSTREAM hints (the defaults are
"an administrator" and "the owning app team"). code, field and
allowed are the package's, not the app's, so every surface a caller
meets branches on the same contract.
import { capabilityFailure, runCapability } from "@automators/capabilities";
try {
return await runCapability(capability, args, user, policy);
} catch (error) {
return capabilityFailure(error, { supportContact: "#operator-support" });
}Exports
| Export | Purpose |
| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- |
| defineCapability, runCapability, handleMcpRpc | Unbound forms; take the policy or options explicitly |
| createCapabilityRuntime({ policy, serverInfo }) | Bound forms for one app |
| capabilityToListEntry, toJsonSchema, renderCapabilityText | Adapter helpers |
| CapabilityError, RPC_* | Error shape and JSON-RPC codes |
| capabilityFailure, toolError, validationToolError, isToolError, TOOL_ERROR_CODES | The tool-error envelope and the mapping onto it |
| MCP_PROTOCOL_VERSION | The protocol version initialize advertises |
| Capability, CapabilityDefinition, CapabilityPolicy, CapabilityUser, CapabilityContext, CapabilityAnnotations, CapabilityListEntry, McpServerInfo, McpHandlerOptions, JsonRpc*, ToolError, ToolErrorCode, ToolErrorDetail, CapabilityFailureOptions | Types |
Origin
The runtime and handler were extracted unchanged in behaviour from
Operator's src/lib/capabilities/runtime.ts and src/lib/mcp/handler.ts,
with the user type made generic and the permission rule made injectable.
