npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 zod

zod 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: requiredPermission is checked through the policy. Failure is CapabilityError(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 is CapabilityError(-32602) carrying the Zod issues. Missing arguments parse as {}.
  • Output: the run result is parsed with outputSchema; a mismatch is CapabilityError(-32603, "... returned an invalid result"). That is a server bug surface, never a client error.
  • Output schemas stay server-side: capabilityToListEntry advertises the input schema and the annotation hints, never outputSchema. Every connected client pays for the catalogue on every session, and output schemas describe shapes a caller already receives in structuredContent. Keep writing real output schemas: they are what catches a capability returning something it did not promise.
  • Input schemas survive conversion: toJsonSchema uses io: "input" and unrepresentable: "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: runCapability returns the structured value. Adapters that need text call renderCapabilityText(value) (strings pass through, everything else pretty-printed JSON). Never return a pre-stringified blob from run.
  • 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: true means the capability is safe to run without assistant approval. Derive approvalRequired = !(readOnly === true) so anything not explicitly read-only fails closed into requiring approval.
  • destructive: true means 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.