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
surfacesarray - a missing
requiresarray, or a key this deployment does not define - an
inputSchemathat is not an object schema
On every call, in this order:
- Unknown on this surface →
404 Unknown tool "<name>". - Missing a
requireskey →403 "<name>" requires content:read, which this token does not hold. - Arguments fail
inputSchema→422with 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.
| Surface | Caller |
|---|---|
copilot | A signed-in user, in the product, with a permission prompt before writes |
mcp | A credential an operator minted, with no interactive user |
The first yes decides it:
- Does it write, or return a change for someone to apply? →
['copilot'] - Does it expose unpublished content on
content:readalone? →['copilot'] - Does it need a permission no token scope carries? →
['copilot'] - Does it only mean something for a signed-in human? →
['copilot'] - 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
effect | Means | Copilot |
|---|---|---|
read | Returns data | Never prompts |
propose | Computes a change and returns it without writing | Prompts, records a copilot_proposals row, applies through the owning plugin’s applier |
apply | Performs the change itself | Prompts, records a receipt afterwards |
effectis separate from the MCP hintsreadOnly/destructive. Declare both.- Every shipped copilot write is
propose; its applier (registered withcopilotAppliersRegistrar) must call the same use case an HTTP request would. The oneapplytool isprotection_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.previewrefuses any tool that is not a previewableproposetool, 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_updateandcontent_propose_bulk_savecarry 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.