Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

Contributing agent tools

Adding a capability an agent can call, and deciding which surfaces it is offered to.

Documents 0.13.1 Updated Edit this page Report a problem

On this page

A tool is a named, permission-gated capability. All tools live in one registry — ToolRegistry from @orthacms/tools-server — read by two consumers: the copilot’s run loop and the MCP endpoint. The contract has no HTTP and no JSON-RPC in it.

A complete provider

// @acme/reviews-server — review-tool.provider.ts
import { BadRequestException, Injectable, Optional, type OnModuleInit } from '@nestjs/common';
import { PERMISSIONS } from '@orthacms/identity-server';
import {
    ToolRegistry,
    type ToolContext,
    type ToolDefinition,
    type ToolProvider
} from '@orthacms/tools-server';
import { ReviewsService } from './reviews.service';

const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

@Injectable()
export class ReviewToolProvider implements ToolProvider, OnModuleInit {
    constructor(
        private readonly reviews: ReviewsService,
        // Optional: a deployment running neither the copilot nor MCP still boots
        @Optional() private readonly toolRegistry?: ToolRegistry
    ) {}

    onModuleInit(): void {
        this.toolRegistry?.register(this);
    }

    tools(): readonly ToolDefinition[] {
        return [
            {
                name: 'review_list', // snake_case, unique across plugins
                title: 'List reviews',
                description:
                    'List the reviews left on one content entry, newest first. ' +
                    'Find the entry id with admin_content_search first.',
                inputSchema: {
                    type: 'object',
                    properties: {
                        entryId: { type: 'string', description: 'The entry id.' }
                    },
                    required: ['entryId'],
                    additionalProperties: false
                },
                requires: [PERMISSIONS.CONTENT_READ], // enforced by the registry
                readOnly: true,   // MCP hint
                effect: 'read',   // copilot: never prompts
                // Decided, not defaulted: omitting this would also put it on MCP
                surfaces: ['copilot'],
                handler: (input, context) => this.list(input, context)
            }
        ];
    }

    private async list(input: Record<string, unknown>, context: ToolContext) {
        const entryId = input['entryId'] as string;
        if (!UUID.test(entryId)) {
            // HttpException subclasses reach the model as readable, fixable errors
            throw new BadRequestException('entryId must be a uuid.');
        }
        const rows = await this.reviews.list(context.workspaceId, entryId);
        return {
            items: rows.map((r) => ({ id: r.id, body: r.body, resolved: r.resolvedAt !== null }))
        };
    }
}

Add ReviewToolProvider to your module’s providers. To render its output as a card in the chat, fill COPILOT_TOOL_RESULT_SLOT — see slots.

The contract

// @orthacms/tools-server
interface ToolDefinition {
    name: string;
    title: string;
    description: string;              // written for the model
    inputSchema: JsonSchema;          // always an object schema
    requires: readonly PermissionKey[];
    readOnly: boolean;                // MCP readOnlyHint
    destructive?: boolean;            // MCP destructiveHint
    effect?: 'read' | 'propose' | 'apply'; // default 'read'
    previewable?: boolean;            // propose only: the handler has no side effects
    surfaces?: readonly ('copilot' | 'mcp')[]; // omitted = both
    handler(input: Record<string, unknown>, context: ToolContext): Promise<unknown>;
}

interface ToolContext {
    actor: { kind: 'token' | 'user'; id: string; displayName: string;
             grantedPermissions: ReadonlySet<string>; userId: string | null };
    workspaceId: string;   // the workspace the call acts in
    signal?: AbortSignal;  // aborted on MCP timeout or client disconnect
    surface?: 'copilot' | 'mcp';
    can(permission: PermissionKey): boolean; // for decisions finer than `requires`
}

A provider may also expose resources() / readResource() — content uses them for its orthacms://content-type/<name> schemas.

What the registry checks

At boot — every problem is reported at once and the server refuses to start:

  • a duplicate name, or one that is not snake_case
  • an empty surfaces array
  • a missing requires array, or a key this deployment does not define
  • an inputSchema that is not an object schema

On every call, in this order:

  1. Unknown on this surface → 404 Unknown tool "<name>".
  2. Missing a requires key → 403 "<name>" requires content:read, which this token does not hold.
  3. Arguments fail inputSchema → 422 with per-field issues:
{
    "status": 422,
    "code": "validation_failed",
    "message": "Invalid arguments for \"review_list\".",
    "issues": [{ "field": "entryId", "message": "required" }]
}

The schema check ignores anyOf, oneOf, $ref, format and pattern — a convenience, not a boundary. Validate in the handler too.

Errors: throw HttpException subclasses. They become { status, code, message, issues? } with codes bad_request, unauthorized, forbidden, not_found, conflict, validation_failed. Anything else is logged and reaches the caller only as internal_error.

Decide surfaces deliberately

Omitting surfaces means both

A tool with no surfaces is offered to the copilot and to MCP — so an internal admin tool becomes callable by any external agent holding a bearer token. Write the field.

SurfaceCaller
copilotA signed-in user, in the product, with a permission prompt before writes
mcpA credential an operator minted, with no interactive user

The first yes decides it:

  1. Does it write, or return a change for someone to apply? → ['copilot']
  2. Does it expose unpublished content on content:read alone? → ['copilot']
  3. Does it need a permission no token scope carries? → ['copilot']
  4. Does it only mean something for a signed-in human? → ['copilot']
  5. Is it built on the public API, and so published-only? → ['mcp']

Otherwise omit it (or write ['copilot', 'mcp']) and leave a comment saying you decided. review_list is copilot-only by rule 2: reviews sit on drafts too.

context.surface may change how a fact is presented (media hands the copilot a session path and MCP a bearer-fetchable /v1 path). It must never change what a tool returns or checks — that is two tools.

Effects

effectMeansCopilot
readReturns dataNever prompts
proposeComputes a change and returns it without writingPrompts, records a copilot_proposals row, applies through the owning plugin’s applier
applyPerforms the change itselfPrompts, records a receipt afterwards
  • effect is separate from the MCP hints readOnly / destructive. Declare both.
  • Every shipped copilot write is propose; its applier (registered with copilotAppliersRegistrar) must call the same use case an HTTP request would. The one apply tool is protection_request_review.
  • A propose tool cannot write, whatever a prompt-injected model tells it.

previewable (0.13.0)

A propose tool may declare previewable: true: a claim, by its binder, that the handler reads, computes a draft and writes nothing. The copilot then runs it once before its permission prompt, through ToolRegistry.preview — the same surface scoping, requires check and validation as a call — and the prompt shows the draft as a Not saved “Was → Now” card instead of the raw arguments (permissionPreview in @orthacms/copilot-domain, at most MAX_PREVIEW_CHANGES, 100, field changes).

  • The preview is a picture. Allowing runs the tool again against the records as they are by then; that second draft is what is recorded and applied.
  • A failed preview still asks, with the arguments shown instead.
  • ToolRegistry.preview refuses any tool that is not a previewable propose tool, and nothing is recorded or audited for a preview.
  • Leave it off a propose tool whose draft fetches anything or touches state. content_propose_create, content_propose_update and content_propose_bulk_save carry it.

Writing a good write tool

  • Read the live record at propose time, so the change carries a real before/after.
  • Drop fields that would not change. Rewriting an entry’s current values still appends a version and takes a live entry back to draft.
  • Reject unknown field names. The person approving must not believe a non-existent field is being written.
  • Say what the change reaches in its summary — it is the card title, the audit row and the model’s receipt.
  • Batch, don’t loop. One tool taking a list, applied item by item, stopping at the first failure and reporting how many landed.

Authorization lives in requires

ToolRegistry.call checks requires against live grants before dispatch — the analogue of @RequirePermissions. Listing is a courtesy: an MCP client may call a name it was never shown. Declare every key; don’t rely on a check in the handler.