@orthacms/tools-server 0.0.0-reserve.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ortha CMS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @orthacms/tools-server
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/tools-server
7
+ ```
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Public API of `@orthacms/tools-server` — the shared, transport-neutral
3
+ * catalogue of everything an agent can do to this CMS, and the single place a
4
+ * tool call is authorized.
5
+ *
6
+ * It lives in its own package rather than inside `mcp/server` (where
7
+ * [ADR-0006](../../../docs/adr/0006-cms-as-an-mcp-server.md) §2 first put it)
8
+ * because MCP turned out to be one consumer of two, not the owner: the
9
+ * copilot's in-process tool loop injects the same registry. Importing it from
10
+ * the MCP package would have made a deployment that wants only the copilot pull
11
+ * `@modelcontextprotocol/sdk` through that package's barrel — a dependency on a
12
+ * protocol it does not speak.
13
+ */
14
+ export { ToolRegistry } from './lib/tool-registry';
15
+ export { ToolsModule } from './lib/tools.module';
16
+ export type { ToolProvider } from './lib/tool-provider';
17
+ export type { JsonSchema, ResourceContents, ResourceDefinition, ToolActor, ToolActorKind, ToolContext, ToolDefinition, ToolEffect, ToolOutput, ToolSurface } from './lib/tool';
18
+ export { createToolContext } from './lib/tool-context';
19
+ export { toToolError } from './lib/tool-error';
20
+ export type { ToolError } from './lib/tool-error';
21
+ export { validateToolInput } from './lib/validate-tool-input';
22
+ export type { ToolInputValidation } from './lib/validate-tool-input';
23
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAEH,OAAO,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAGnD,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,YAAY,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AACxD,YAAY,EACR,UAAU,EACV,gBAAgB,EAChB,kBAAkB,EAClB,SAAS,EACT,aAAa,EACb,WAAW,EACX,cAAc,EACd,UAAU,EACV,UAAU,EACV,WAAW,EACd,MAAM,YAAY,CAAC;AAIpB,OAAO,EAAE,iBAAiB,EAAE,MAAM,oBAAoB,CAAC;AAEvD,OAAO,EAAE,WAAW,EAAE,MAAM,kBAAkB,CAAC;AAC/C,YAAY,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAMlD,OAAO,EAAE,iBAAiB,EAAE,MAAM,2BAA2B,CAAC;AAC9D,YAAY,EAAE,mBAAmB,EAAE,MAAM,2BAA2B,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ /**
3
+ * Public API of `@orthacms/tools-server` — the shared, transport-neutral
4
+ * catalogue of everything an agent can do to this CMS, and the single place a
5
+ * tool call is authorized.
6
+ *
7
+ * It lives in its own package rather than inside `mcp/server` (where
8
+ * [ADR-0006](../../../docs/adr/0006-cms-as-an-mcp-server.md) §2 first put it)
9
+ * because MCP turned out to be one consumer of two, not the owner: the
10
+ * copilot's in-process tool loop injects the same registry. Importing it from
11
+ * the MCP package would have made a deployment that wants only the copilot pull
12
+ * `@modelcontextprotocol/sdk` through that package's barrel — a dependency on a
13
+ * protocol it does not speak.
14
+ */
15
+ Object.defineProperty(exports, "__esModule", { value: true });
16
+ exports.validateToolInput = exports.toToolError = exports.createToolContext = exports.ToolsModule = exports.ToolRegistry = void 0;
17
+ var tool_registry_1 = require("./lib/tool-registry");
18
+ Object.defineProperty(exports, "ToolRegistry", { enumerable: true, get: function () { return tool_registry_1.ToolRegistry; } });
19
+ // The module that provides it. Imported by each consumer (MCP, copilot) rather
20
+ // than provided by either — see its JSDoc.
21
+ var tools_module_1 = require("./lib/tools.module");
22
+ Object.defineProperty(exports, "ToolsModule", { enumerable: true, get: function () { return tools_module_1.ToolsModule; } });
23
+ // Building a context is exported because neither consumer owns it: the MCP
24
+ // endpoint builds one for a bearer token, the copilot for the signed-in user.
25
+ var tool_context_1 = require("./lib/tool-context");
26
+ Object.defineProperty(exports, "createToolContext", { enumerable: true, get: function () { return tool_context_1.createToolContext; } });
27
+ var tool_error_1 = require("./lib/tool-error");
28
+ Object.defineProperty(exports, "toToolError", { enumerable: true, get: function () { return tool_error_1.toToolError; } });
29
+ // The argument check `ToolRegistry.call` applies before dispatch. Exported
30
+ // because a consumer may want to reject a malformed call earlier than the
31
+ // registry does — the copilot's run engine validates at the point it decides
32
+ // what to tell the model, so the run continues instead of throwing.
33
+ var validate_tool_input_1 = require("./lib/validate-tool-input");
34
+ Object.defineProperty(exports, "validateToolInput", { enumerable: true, get: function () { return validate_tool_input_1.validateToolInput; } });
@@ -0,0 +1,16 @@
1
+ import type { ToolActor, ToolContext } from './tool';
2
+ /**
3
+ * Build a {@link ToolContext} from an already-resolved actor and workspace.
4
+ *
5
+ * Exported because the MCP endpoint is not the only thing that builds one: the
6
+ * copilot's tool loop runs as the **signed-in user**, resolves that user's role
7
+ * grants through identity's RBAC service, and calls the same registry. Sharing
8
+ * the constructor keeps `can()` meaning one thing.
9
+ *
10
+ * Note what this function does **not** do: it never derives permissions. The
11
+ * caller resolves them (a token's scope via `scopePermissions`, a user's via
12
+ * their role) and hands them over. Authorization inputs are decided once, at
13
+ * the edge, by the code that authenticated the caller.
14
+ */
15
+ export declare function createToolContext(actor: ToolActor, workspaceId: string): ToolContext;
16
+ //# sourceMappingURL=tool-context.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-context.d.ts","sourceRoot":"","sources":["../../src/lib/tool-context.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,SAAS,EAAE,WAAW,EAAE,MAAM,QAAQ,CAAC;AAErD;;;;;;;;;;;;GAYG;AACH,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,SAAS,EAChB,WAAW,EAAE,MAAM,GACpB,WAAW,CAOb"}
@@ -0,0 +1,23 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.createToolContext = createToolContext;
4
+ /**
5
+ * Build a {@link ToolContext} from an already-resolved actor and workspace.
6
+ *
7
+ * Exported because the MCP endpoint is not the only thing that builds one: the
8
+ * copilot's tool loop runs as the **signed-in user**, resolves that user's role
9
+ * grants through identity's RBAC service, and calls the same registry. Sharing
10
+ * the constructor keeps `can()` meaning one thing.
11
+ *
12
+ * Note what this function does **not** do: it never derives permissions. The
13
+ * caller resolves them (a token's scope via `scopePermissions`, a user's via
14
+ * their role) and hands them over. Authorization inputs are decided once, at
15
+ * the edge, by the code that authenticated the caller.
16
+ */
17
+ function createToolContext(actor, workspaceId) {
18
+ return {
19
+ actor,
20
+ workspaceId,
21
+ can: (permission) => actor.grantedPermissions.has(permission)
22
+ };
23
+ }
@@ -0,0 +1,28 @@
1
+ /** A tool failure, flattened into something a model can read and act on. */
2
+ export interface ToolError {
3
+ /** HTTP-ish status the underlying operation failed with. */
4
+ status: number;
5
+ /** A short machine label, e.g. `not_found`, `validation_failed`. */
6
+ code: string;
7
+ /** Human/model-readable explanation. */
8
+ message: string;
9
+ /** Per-field issues, when the failure was a validation one. */
10
+ issues?: unknown;
11
+ }
12
+ /**
13
+ * Turn whatever a tool handler threw into a {@link ToolError}.
14
+ *
15
+ * Tool handlers delegate to the very same services the HTTP controllers call,
16
+ * so they throw Nest `HttpException`s — a 404 for an unknown entry, a 422
17
+ * carrying per-field validation issues. Those are **the most useful thing a
18
+ * model can be told**: "title must be at most 200 characters" is a failure it
19
+ * can fix on the next call, so the issues are carried through verbatim rather
20
+ * than flattened to "bad request".
21
+ *
22
+ * Anything that is *not* an `HttpException` is a bug, not a caller error. It is
23
+ * logged with its stack for the operator and reported as a bare 500 — the
24
+ * message could name a table, a column, or a connection string, and a tool
25
+ * result is read by a third-party model.
26
+ */
27
+ export declare function toToolError(error: unknown): ToolError;
28
+ //# sourceMappingURL=tool-error.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-error.d.ts","sourceRoot":"","sources":["../../src/lib/tool-error.ts"],"names":[],"mappings":"AAEA,4EAA4E;AAC5E,MAAM,WAAW,SAAS;IACtB,4DAA4D;IAC5D,MAAM,EAAE,MAAM,CAAC;IACf,oEAAoE;IACpE,IAAI,EAAE,MAAM,CAAC;IACb,wCAAwC;IACxC,OAAO,EAAE,MAAM,CAAC;IAChB,+DAA+D;IAC/D,MAAM,CAAC,EAAE,OAAO,CAAC;CACpB;AAcD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,SAAS,CAoCrD"}
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.toToolError = toToolError;
4
+ const common_1 = require("@nestjs/common");
5
+ /** Status → the short code a model sees. */
6
+ const CODES = {
7
+ [common_1.HttpStatus.BAD_REQUEST]: 'bad_request',
8
+ [common_1.HttpStatus.UNAUTHORIZED]: 'unauthorized',
9
+ [common_1.HttpStatus.FORBIDDEN]: 'forbidden',
10
+ [common_1.HttpStatus.NOT_FOUND]: 'not_found',
11
+ [common_1.HttpStatus.CONFLICT]: 'conflict',
12
+ [common_1.HttpStatus.UNPROCESSABLE_ENTITY]: 'validation_failed'
13
+ };
14
+ const logger = new common_1.Logger('McpTools');
15
+ /**
16
+ * Turn whatever a tool handler threw into a {@link ToolError}.
17
+ *
18
+ * Tool handlers delegate to the very same services the HTTP controllers call,
19
+ * so they throw Nest `HttpException`s — a 404 for an unknown entry, a 422
20
+ * carrying per-field validation issues. Those are **the most useful thing a
21
+ * model can be told**: "title must be at most 200 characters" is a failure it
22
+ * can fix on the next call, so the issues are carried through verbatim rather
23
+ * than flattened to "bad request".
24
+ *
25
+ * Anything that is *not* an `HttpException` is a bug, not a caller error. It is
26
+ * logged with its stack for the operator and reported as a bare 500 — the
27
+ * message could name a table, a column, or a connection string, and a tool
28
+ * result is read by a third-party model.
29
+ */
30
+ function toToolError(error) {
31
+ if (error instanceof common_1.HttpException) {
32
+ const status = error.getStatus();
33
+ const response = error.getResponse();
34
+ const body = typeof response === 'object' && response !== null
35
+ ? response
36
+ : {};
37
+ const message = typeof body['message'] === 'string'
38
+ ? body['message']
39
+ : Array.isArray(body['message'])
40
+ ? body['message'].join('; ')
41
+ : error.message;
42
+ // `issues` is what the content plugin's 422 carries per field; keeping
43
+ // the key intact is what makes a validation failure self-correcting.
44
+ const issues = body['issues'];
45
+ return {
46
+ status,
47
+ code: CODES[status] ?? 'error',
48
+ message,
49
+ ...(issues === undefined ? {} : { issues })
50
+ };
51
+ }
52
+ logger.error(`Unhandled error in a tool handler: ${error instanceof Error ? error.message : String(error)}`, error instanceof Error ? error.stack : undefined);
53
+ return {
54
+ status: common_1.HttpStatus.INTERNAL_SERVER_ERROR,
55
+ code: 'internal_error',
56
+ message: 'The tool failed unexpectedly. See the server logs.'
57
+ };
58
+ }
@@ -0,0 +1,40 @@
1
+ import type { ResourceContents, ResourceDefinition, ToolContext, ToolDefinition } from './tool';
2
+ /**
3
+ * A plugin's contribution of tools (and optionally resources) to the shared
4
+ * {@link ToolRegistry}.
5
+ *
6
+ * The **inversion** that keeps the package graph acyclic and this package free
7
+ * of domain knowledge: `mcp/server` owns the protocol and knows nothing about
8
+ * content, media, or users; each capability plugin implements this and
9
+ * registers itself. `content/server` binds the first one. A plugin's tools can
10
+ * therefore reach straight into its own internals — `PublicEntriesQuery`,
11
+ * `PublicEntryWritesService` — instead of those having to be re-exported for an
12
+ * outside package to drive, which is precisely how a second, drifting copy of
13
+ * the visibility rules would get written.
14
+ *
15
+ * Registration is a **call**, not a DI multi-binding, because Nest has no
16
+ * multi-provider token: a contributor injects the registry `@Optional()` and
17
+ * registers itself in `onModuleInit`. Optional because MCP is a plugin — a
18
+ * deployment that leaves it out must still boot, with its capability plugins
19
+ * simply contributing nothing.
20
+ */
21
+ export interface ToolProvider {
22
+ /**
23
+ * The tools this plugin contributes. Called once per `tools/list`, so it
24
+ * may vary with runtime state (the content registry's types), but must not
25
+ * be expensive or do I/O — permission filtering happens after it returns.
26
+ */
27
+ tools(): readonly ToolDefinition[];
28
+ /**
29
+ * Resources this plugin exposes to the calling actor, if any. Async and
30
+ * context-taking because visibility is per-workspace: a content type the
31
+ * workspace was not granted must not be listed.
32
+ */
33
+ resources?(context: ToolContext): Promise<readonly ResourceDefinition[]>;
34
+ /**
35
+ * Read one of this plugin's resources. Return `undefined` for a URI this
36
+ * provider does not own, so the registry can try the next one.
37
+ */
38
+ readResource?(uri: string, context: ToolContext): Promise<ResourceContents | undefined>;
39
+ }
40
+ //# sourceMappingURL=tool-provider.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-provider.d.ts","sourceRoot":"","sources":["../../src/lib/tool-provider.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACR,gBAAgB,EAChB,kBAAkB,EAClB,WAAW,EACX,cAAc,EACjB,MAAM,QAAQ,CAAC;AAEhB;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,WAAW,YAAY;IACzB;;;;OAIG;IACH,KAAK,IAAI,SAAS,cAAc,EAAE,CAAC;IAEnC;;;;OAIG;IACH,SAAS,CAAC,CAAC,OAAO,EAAE,WAAW,GAAG,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC,CAAC;IAEzE;;;OAGG;IACH,YAAY,CAAC,CACT,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,GACrB,OAAO,CAAC,gBAAgB,GAAG,SAAS,CAAC,CAAC;CAC5C"}
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,137 @@
1
+ import { type OnApplicationBootstrap } from '@nestjs/common';
2
+ import type { ResourceContents, ResourceDefinition, ToolContext, ToolDefinition, ToolOutput, ToolSurface } from './tool';
3
+ import type { ToolProvider } from './tool-provider';
4
+ /**
5
+ * The shared, transport-neutral catalogue of everything an agent can do to this
6
+ * CMS — and the **single place** a tool call is authorized.
7
+ *
8
+ * Two consumers, one registry: the MCP endpoint exposes it to external clients
9
+ * (Claude Desktop, Cursor, an agent framework), and the copilot's in-process
10
+ * tool loop will inject the very same instance. That is the whole reason
11
+ * nothing on {@link ToolDefinition} or {@link ToolContext} mentions HTTP or
12
+ * JSON-RPC — a tool added for one consumer is automatically available to the
13
+ * other, and neither can end up with a private set of rules.
14
+ *
15
+ * **The authorization invariant.** `call()` checks `requires` before dispatch,
16
+ * and `visibleTo()` merely *hides* what the actor could not call anyway.
17
+ * Filtering a list is a usability nicety; the check in `call()` is the security
18
+ * boundary, and it must stay that way — an MCP client is free to invoke a tool
19
+ * name it was never shown, and the copilot may hallucinate one outright.
20
+ */
21
+ export declare class ToolRegistry implements OnApplicationBootstrap {
22
+ private readonly providers;
23
+ /**
24
+ * Add a provider's contributions. Called by each capability plugin from
25
+ * `onModuleInit`; see {@link ToolProvider} for why this is a call rather
26
+ * than a DI multi-binding.
27
+ *
28
+ * **Idempotent per provider instance.** Registration is a call, so nothing
29
+ * stops a plugin making it twice — an `onModuleInit` that also runs on a
30
+ * re-created testing module, a provider bound in two modules. Pushing the
31
+ * same instance twice would make every one of its tools collide with
32
+ * itself and take the *whole* catalogue down, on both surfaces, for a
33
+ * mistake that changes nothing. Registering the same provider again is
34
+ * therefore a no-op; two *different* providers claiming one name is still
35
+ * the wiring bug {@link onApplicationBootstrap} refuses to boot with.
36
+ */
37
+ register(provider: ToolProvider): void;
38
+ /**
39
+ * Validate the assembled catalogue **once, at boot**.
40
+ *
41
+ * Every problem below used to surface at the first `tools/list` or
42
+ * `tools/call` instead — a duplicate name took both surfaces down at
43
+ * runtime while boot reported success, and a tool whose `requires` names a
44
+ * permission no role grants (a raw string literal gone stale, a stray
45
+ * space) simply became invisible and uncallable with nothing said. Both are
46
+ * wiring bugs, and a wiring bug belongs to the deploy, not to the first
47
+ * caller unlucky enough to hit it.
48
+ *
49
+ * `OnApplicationBootstrap` rather than `register()` because every
50
+ * capability plugin registers from `onModuleInit`, and a provider's
51
+ * `tools()` may depend on state (the content-type registry) that is only
52
+ * complete once every module has initialized. Throwing here aborts
53
+ * `app.init()`, so the process never starts serving.
54
+ */
55
+ onApplicationBootstrap(): void;
56
+ /**
57
+ * Every registered tool, in registration order.
58
+ *
59
+ * A duplicate name is a **wiring bug** — two plugins claiming one tool name
60
+ * would make which implementation runs depend on registration order — so it
61
+ * fails loudly here rather than resolving silently. This is the backstop:
62
+ * {@link onApplicationBootstrap} refuses to boot with one, so the only way
63
+ * to reach this throw is a provider whose `tools()` starts colliding after
64
+ * boot.
65
+ */
66
+ all(): readonly ToolDefinition[];
67
+ /**
68
+ * Every tool offered to one **surface** — the MCP endpoint or the copilot.
69
+ *
70
+ * A tool that names no surfaces is offered to both, so this narrows only
71
+ * the ones that declared a reason to be narrowed (see {@link ToolSurface}).
72
+ */
73
+ forSurface(surface: ToolSurface): readonly ToolDefinition[];
74
+ /**
75
+ * The tools `context`'s actor holds the permissions for, on one surface.
76
+ * What a client is shown — a read-only token never learns that
77
+ * `content_create` exists, which is a far better experience than
78
+ * discovering it via a refusal, and keeps a model from burning turns on
79
+ * calls that cannot succeed.
80
+ */
81
+ visibleTo(context: ToolContext, surface: ToolSurface): readonly ToolDefinition[];
82
+ /**
83
+ * Authorize, validate and run one tool.
84
+ *
85
+ * An unknown name and a name the actor may not call are deliberately
86
+ * **different** answers: the tool set is not secret (it is the same for
87
+ * every actor of a given scope, and documented), so conflating them would
88
+ * only make a legitimate permission problem undiagnosable. What is never
89
+ * revealed is *data* — that distinction lives in the handlers, where an
90
+ * ungranted content type 404s exactly like an unknown one.
91
+ *
92
+ * Arguments are checked against the tool's own `inputSchema` before
93
+ * dispatch (see {@link validateToolInput} for the subset that covers, and
94
+ * the keywords it deliberately ignores). It is not a security boundary —
95
+ * `requires` is — but it is what keeps a caller's malformed arguments from
96
+ * reaching a handler and coming back as an opaque 500 instead of a
97
+ * `validation_failed` naming the field.
98
+ */
99
+ call(name: string, input: Record<string, unknown> | undefined, context: ToolContext, surface: ToolSurface): Promise<ToolOutput>;
100
+ /**
101
+ * Every resource visible to the actor, across providers — permission
102
+ * filtered, exactly as {@link visibleTo} filters tools.
103
+ */
104
+ resources(context: ToolContext): Promise<readonly ResourceDefinition[]>;
105
+ /**
106
+ * Read one resource. Providers are asked in registration order and the
107
+ * first that claims the URI wins; nobody claiming it is a 404.
108
+ *
109
+ * A declared resource's `requires` is checked **here, before any provider
110
+ * is asked for the bytes** — the same rule as {@link call}, for the same
111
+ * reason: `resources()` hiding a resource is a listing nicety, and a client
112
+ * is free to read a URI it was never shown. The extra enumeration is the
113
+ * price of the gate; `resources/read` is not a hot path, and a provider
114
+ * that declares no resources costs nothing.
115
+ */
116
+ readResource(uri: string, context: ToolContext): Promise<ResourceContents>;
117
+ /** Every resource every provider declares, before permission filtering. */
118
+ private declaredResources;
119
+ /** Whether the actor holds every permission the tool requires. */
120
+ private permits;
121
+ /**
122
+ * Whether the actor holds every permission the resource requires. A
123
+ * resource that declares none is readable by anyone who reached the
124
+ * endpoint, which is what every shipped resource means today.
125
+ */
126
+ private permitsResource;
127
+ /**
128
+ * Everything wrong with the registered catalogue, as human sentences.
129
+ *
130
+ * Reported all at once rather than throwing on the first, so a deploy that
131
+ * broke two things learns about both.
132
+ */
133
+ private catalogueProblems;
134
+ /** Everything wrong with one tool definition. */
135
+ private toolProblems;
136
+ }
137
+ //# sourceMappingURL=tool-registry.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool-registry.d.ts","sourceRoot":"","sources":["../../src/lib/tool-registry.ts"],"names":[],"mappings":"AAAA,OAAO,EAKH,KAAK,sBAAsB,EAC9B,MAAM,gBAAgB,CAAC;AAExB,OAAO,KAAK,EACR,gBAAgB,EAChB,kBAAkB,EAClB,WAAW,EACX,cAAc,EACd,UAAU,EACV,WAAW,EACd,MAAM,QAAQ,CAAC;AAChB,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,iBAAiB,CAAC;AAgCpD;;;;;;;;;;;;;;;;GAgBG;AACH,qBACa,YAAa,YAAW,sBAAsB;IACvD,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAsB;IAEhD;;;;;;;;;;;;;OAaG;IACH,QAAQ,CAAC,QAAQ,EAAE,YAAY,GAAG,IAAI;IAOtC;;;;;;;;;;;;;;;;OAgBG;IACH,sBAAsB,IAAI,IAAI;IAW9B;;;;;;;;;OASG;IACH,GAAG,IAAI,SAAS,cAAc,EAAE;IAiBhC;;;;;OAKG;IACH,UAAU,CAAC,OAAO,EAAE,WAAW,GAAG,SAAS,cAAc,EAAE;IAM3D;;;;;;OAMG;IACH,SAAS,CACL,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,WAAW,GACrB,SAAS,cAAc,EAAE;IAM5B;;;;;;;;;;;;;;;;OAgBG;IACG,IAAI,CACN,IAAI,EAAE,MAAM,EACZ,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,SAAS,EAC1C,OAAO,EAAE,WAAW,EACpB,OAAO,EAAE,WAAW,GACrB,OAAO,CAAC,UAAU,CAAC;IA0CtB;;;OAGG;IACG,SAAS,CACX,OAAO,EAAE,WAAW,GACrB,OAAO,CAAC,SAAS,kBAAkB,EAAE,CAAC;IAMzC;;;;;;;;;;OAUG;IACG,YAAY,CACd,GAAG,EAAE,MAAM,EACX,OAAO,EAAE,WAAW,GACrB,OAAO,CAAC,gBAAgB,CAAC;IAsB5B,2EAA2E;YAC7D,iBAAiB;IAW/B,kEAAkE;IAClE,OAAO,CAAC,OAAO;IAIf;;;;OAIG;IACH,OAAO,CAAC,eAAe;IASvB;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;IAiBzB,iDAAiD;IACjD,OAAO,CAAC,YAAY;CAyCvB"}
@@ -0,0 +1,290 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ToolRegistry = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@nestjs/common");
6
+ const identity_server_1 = require("@orthacms/identity-server");
7
+ const validate_tool_input_1 = require("./validate-tool-input");
8
+ /**
9
+ * The shape a tool name must have — `snake_case`, lowercase, no leading,
10
+ * trailing or doubled underscores. Checked at boot rather than trusted, because
11
+ * every deviation is silent: a padded name registers and is callable only by
12
+ * sending the padding back, and `Content_List` next to `content_list` is two
13
+ * live tools one letter apart.
14
+ */
15
+ const TOOL_NAME_PATTERN = /^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$/;
16
+ /** The permission keys this deployment actually defines. */
17
+ const KNOWN_PERMISSIONS = new Set(identity_server_1.PERMISSION_KEYS);
18
+ /** A non-null, non-array object. */
19
+ function isPlainObject(value) {
20
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
21
+ }
22
+ /**
23
+ * Split one `path: message` validation error into the per-field shape the rest
24
+ * of the API's 422s carry, so a model reads a registry refusal exactly as it
25
+ * reads a content plugin's.
26
+ */
27
+ function toIssue(error) {
28
+ const at = error.indexOf(': ');
29
+ return at === -1
30
+ ? { field: 'input', message: error }
31
+ : { field: error.slice(0, at), message: error.slice(at + 2) };
32
+ }
33
+ /**
34
+ * The shared, transport-neutral catalogue of everything an agent can do to this
35
+ * CMS — and the **single place** a tool call is authorized.
36
+ *
37
+ * Two consumers, one registry: the MCP endpoint exposes it to external clients
38
+ * (Claude Desktop, Cursor, an agent framework), and the copilot's in-process
39
+ * tool loop will inject the very same instance. That is the whole reason
40
+ * nothing on {@link ToolDefinition} or {@link ToolContext} mentions HTTP or
41
+ * JSON-RPC — a tool added for one consumer is automatically available to the
42
+ * other, and neither can end up with a private set of rules.
43
+ *
44
+ * **The authorization invariant.** `call()` checks `requires` before dispatch,
45
+ * and `visibleTo()` merely *hides* what the actor could not call anyway.
46
+ * Filtering a list is a usability nicety; the check in `call()` is the security
47
+ * boundary, and it must stay that way — an MCP client is free to invoke a tool
48
+ * name it was never shown, and the copilot may hallucinate one outright.
49
+ */
50
+ let ToolRegistry = class ToolRegistry {
51
+ providers = [];
52
+ /**
53
+ * Add a provider's contributions. Called by each capability plugin from
54
+ * `onModuleInit`; see {@link ToolProvider} for why this is a call rather
55
+ * than a DI multi-binding.
56
+ *
57
+ * **Idempotent per provider instance.** Registration is a call, so nothing
58
+ * stops a plugin making it twice — an `onModuleInit` that also runs on a
59
+ * re-created testing module, a provider bound in two modules. Pushing the
60
+ * same instance twice would make every one of its tools collide with
61
+ * itself and take the *whole* catalogue down, on both surfaces, for a
62
+ * mistake that changes nothing. Registering the same provider again is
63
+ * therefore a no-op; two *different* providers claiming one name is still
64
+ * the wiring bug {@link onApplicationBootstrap} refuses to boot with.
65
+ */
66
+ register(provider) {
67
+ if (this.providers.includes(provider)) {
68
+ return;
69
+ }
70
+ this.providers.push(provider);
71
+ }
72
+ /**
73
+ * Validate the assembled catalogue **once, at boot**.
74
+ *
75
+ * Every problem below used to surface at the first `tools/list` or
76
+ * `tools/call` instead — a duplicate name took both surfaces down at
77
+ * runtime while boot reported success, and a tool whose `requires` names a
78
+ * permission no role grants (a raw string literal gone stale, a stray
79
+ * space) simply became invisible and uncallable with nothing said. Both are
80
+ * wiring bugs, and a wiring bug belongs to the deploy, not to the first
81
+ * caller unlucky enough to hit it.
82
+ *
83
+ * `OnApplicationBootstrap` rather than `register()` because every
84
+ * capability plugin registers from `onModuleInit`, and a provider's
85
+ * `tools()` may depend on state (the content-type registry) that is only
86
+ * complete once every module has initialized. Throwing here aborts
87
+ * `app.init()`, so the process never starts serving.
88
+ */
89
+ onApplicationBootstrap() {
90
+ const problems = this.catalogueProblems();
91
+ if (problems.length > 0) {
92
+ throw new Error(`The agent tool catalogue is invalid:\n- ${problems.join('\n- ')}`);
93
+ }
94
+ }
95
+ /**
96
+ * Every registered tool, in registration order.
97
+ *
98
+ * A duplicate name is a **wiring bug** — two plugins claiming one tool name
99
+ * would make which implementation runs depend on registration order — so it
100
+ * fails loudly here rather than resolving silently. This is the backstop:
101
+ * {@link onApplicationBootstrap} refuses to boot with one, so the only way
102
+ * to reach this throw is a provider whose `tools()` starts colliding after
103
+ * boot.
104
+ */
105
+ all() {
106
+ const tools = [];
107
+ const seen = new Set();
108
+ for (const provider of this.providers) {
109
+ for (const tool of provider.tools()) {
110
+ if (seen.has(tool.name)) {
111
+ throw new Error(`Duplicate tool name "${tool.name}" — two providers registered it. Tool names must be unique across plugins.`);
112
+ }
113
+ seen.add(tool.name);
114
+ tools.push(tool);
115
+ }
116
+ }
117
+ return tools;
118
+ }
119
+ /**
120
+ * Every tool offered to one **surface** — the MCP endpoint or the copilot.
121
+ *
122
+ * A tool that names no surfaces is offered to both, so this narrows only
123
+ * the ones that declared a reason to be narrowed (see {@link ToolSurface}).
124
+ */
125
+ forSurface(surface) {
126
+ return this.all().filter((tool) => !tool.surfaces || tool.surfaces.includes(surface));
127
+ }
128
+ /**
129
+ * The tools `context`'s actor holds the permissions for, on one surface.
130
+ * What a client is shown — a read-only token never learns that
131
+ * `content_create` exists, which is a far better experience than
132
+ * discovering it via a refusal, and keeps a model from burning turns on
133
+ * calls that cannot succeed.
134
+ */
135
+ visibleTo(context, surface) {
136
+ return this.forSurface(surface).filter((tool) => this.permits(tool, context));
137
+ }
138
+ /**
139
+ * Authorize, validate and run one tool.
140
+ *
141
+ * An unknown name and a name the actor may not call are deliberately
142
+ * **different** answers: the tool set is not secret (it is the same for
143
+ * every actor of a given scope, and documented), so conflating them would
144
+ * only make a legitimate permission problem undiagnosable. What is never
145
+ * revealed is *data* — that distinction lives in the handlers, where an
146
+ * ungranted content type 404s exactly like an unknown one.
147
+ *
148
+ * Arguments are checked against the tool's own `inputSchema` before
149
+ * dispatch (see {@link validateToolInput} for the subset that covers, and
150
+ * the keywords it deliberately ignores). It is not a security boundary —
151
+ * `requires` is — but it is what keeps a caller's malformed arguments from
152
+ * reaching a handler and coming back as an opaque 500 instead of a
153
+ * `validation_failed` naming the field.
154
+ */
155
+ async call(name, input, context, surface) {
156
+ // Scoped to the surface, so a caller cannot invoke a tool the *other*
157
+ // consumer's rules were written for — an MCP client naming
158
+ // `content_propose_edit` gets "unknown tool", not a proposal it has no
159
+ // way to accept.
160
+ const tool = this.forSurface(surface).find((candidate) => candidate.name === name);
161
+ if (!tool) {
162
+ throw new common_1.NotFoundException(`Unknown tool "${name}".`);
163
+ }
164
+ if (!this.permits(tool, context)) {
165
+ throw new common_1.ForbiddenException(`"${name}" requires ${tool.requires.join(', ')}, which this ${context.actor.kind === 'token' ? 'token' : 'user'} does not hold.`);
166
+ }
167
+ // Defaulted here rather than at each edge. Both shipped consumers
168
+ // already compensate for a missing arguments object (`args ?? {}` in
169
+ // the MCP adapter, `call.input ?? {}` in the run engine) — for a
170
+ // parameter this signature types as an object, that is a defence the
171
+ // registry owes them, not one each has to remember.
172
+ const args = input ?? {};
173
+ // Authorization first, validation second: a caller who may not run the
174
+ // tool learns nothing about its arguments, and the answer to an
175
+ // unauthorized call is the same whatever it sent.
176
+ const validation = (0, validate_tool_input_1.validateToolInput)(args, tool.inputSchema);
177
+ if (!validation.valid) {
178
+ throw new common_1.UnprocessableEntityException({
179
+ message: `Invalid arguments for "${name}".`,
180
+ issues: validation.errors.map(toIssue)
181
+ });
182
+ }
183
+ // Stamped here rather than at the edge that built the context, so the
184
+ // surface a handler sees is by construction the one its `surfaces` was
185
+ // narrowed against — a tool cannot be authorized as one consumer and
186
+ // rendered for the other. See `ToolContext.surface` for what a handler
187
+ // may and may not do with it.
188
+ return tool.handler(args, { ...context, surface });
189
+ }
190
+ /**
191
+ * Every resource visible to the actor, across providers — permission
192
+ * filtered, exactly as {@link visibleTo} filters tools.
193
+ */
194
+ async resources(context) {
195
+ return (await this.declaredResources(context)).filter((resource) => this.permitsResource(resource, context));
196
+ }
197
+ /**
198
+ * Read one resource. Providers are asked in registration order and the
199
+ * first that claims the URI wins; nobody claiming it is a 404.
200
+ *
201
+ * A declared resource's `requires` is checked **here, before any provider
202
+ * is asked for the bytes** — the same rule as {@link call}, for the same
203
+ * reason: `resources()` hiding a resource is a listing nicety, and a client
204
+ * is free to read a URI it was never shown. The extra enumeration is the
205
+ * price of the gate; `resources/read` is not a hot path, and a provider
206
+ * that declares no resources costs nothing.
207
+ */
208
+ async readResource(uri, context) {
209
+ const declared = (await this.declaredResources(context)).find((resource) => resource.uri === uri);
210
+ if (declared && !this.permitsResource(declared, context)) {
211
+ throw new common_1.ForbiddenException(`"${uri}" requires ${(declared.requires ?? []).join(', ')}, which this ${context.actor.kind === 'token' ? 'token' : 'user'} does not hold.`);
212
+ }
213
+ for (const provider of this.providers) {
214
+ const contents = await provider.readResource?.(uri, context);
215
+ if (contents) {
216
+ return contents;
217
+ }
218
+ }
219
+ throw new common_1.NotFoundException(`Unknown resource "${uri}".`);
220
+ }
221
+ /** Every resource every provider declares, before permission filtering. */
222
+ async declaredResources(context) {
223
+ const perProvider = await Promise.all(this.providers.map((provider) => provider.resources?.(context) ?? []));
224
+ return perProvider.flat();
225
+ }
226
+ /** Whether the actor holds every permission the tool requires. */
227
+ permits(tool, context) {
228
+ return tool.requires.every((permission) => context.can(permission));
229
+ }
230
+ /**
231
+ * Whether the actor holds every permission the resource requires. A
232
+ * resource that declares none is readable by anyone who reached the
233
+ * endpoint, which is what every shipped resource means today.
234
+ */
235
+ permitsResource(resource, context) {
236
+ return (resource.requires ?? []).every((permission) => context.can(permission));
237
+ }
238
+ /**
239
+ * Everything wrong with the registered catalogue, as human sentences.
240
+ *
241
+ * Reported all at once rather than throwing on the first, so a deploy that
242
+ * broke two things learns about both.
243
+ */
244
+ catalogueProblems() {
245
+ const problems = [];
246
+ const seen = new Set();
247
+ for (const provider of this.providers) {
248
+ for (const tool of provider.tools()) {
249
+ if (seen.has(tool.name)) {
250
+ problems.push(`duplicate tool name "${tool.name}" — two providers registered it. Tool names must be unique across plugins.`);
251
+ }
252
+ seen.add(tool.name);
253
+ problems.push(...this.toolProblems(tool));
254
+ }
255
+ }
256
+ return problems;
257
+ }
258
+ /** Everything wrong with one tool definition. */
259
+ toolProblems(tool) {
260
+ const problems = [];
261
+ if (!TOOL_NAME_PATTERN.test(tool.name)) {
262
+ problems.push(`"${tool.name}" is not a valid tool name — names are snake_case (${TOOL_NAME_PATTERN.source}).`);
263
+ }
264
+ if (tool.surfaces && tool.surfaces.length === 0) {
265
+ problems.push(`"${tool.name}" declares an empty \`surfaces\` array, so it is offered to neither consumer and callable by none. Omit the field to share it.`);
266
+ }
267
+ if (!Array.isArray(tool.requires)) {
268
+ problems.push(`"${tool.name}" has no \`requires\` array — a tool declares its permissions even when the answer is \`[]\`.`);
269
+ }
270
+ else {
271
+ for (const permission of tool.requires) {
272
+ if (!KNOWN_PERMISSIONS.has(permission)) {
273
+ problems.push(`"${tool.name}" requires "${permission}", which is not a permission this deployment defines. Use a PERMISSIONS.* constant — a raw literal goes stale silently and leaves the tool uncallable by everyone.`);
274
+ }
275
+ }
276
+ }
277
+ if (!isPlainObject(tool.inputSchema)) {
278
+ problems.push(`"${tool.name}" has no \`inputSchema\` object. Declare \`{ type: 'object', properties: {} }\` for a tool that takes no arguments.`);
279
+ }
280
+ else if (tool.inputSchema['type'] !== undefined &&
281
+ tool.inputSchema['type'] !== 'object') {
282
+ problems.push(`"${tool.name}" declares an \`inputSchema\` of type "${String(tool.inputSchema['type'])}" — tool arguments are always an object.`);
283
+ }
284
+ return problems;
285
+ }
286
+ };
287
+ exports.ToolRegistry = ToolRegistry;
288
+ exports.ToolRegistry = ToolRegistry = tslib_1.__decorate([
289
+ (0, common_1.Injectable)()
290
+ ], ToolRegistry);
@@ -0,0 +1,222 @@
1
+ import type { PermissionKey } from '@orthacms/identity-server';
2
+ /**
3
+ * A JSON Schema fragment. Tools declare their input shape as **plain JSON
4
+ * Schema** rather than a validator's object model, because most of this CMS's
5
+ * schema is runtime data — content types are defined in code and held in a
6
+ * registry, so their shapes are computed at boot, not declared as classes.
7
+ * `content/server`'s `docs/field-schema.ts` already produces exactly this for
8
+ * the OpenAPI document; the MCP tool schemas reuse it.
9
+ */
10
+ export type JsonSchema = Record<string, unknown>;
11
+ /** Who a tool call acts as. */
12
+ export type ToolActorKind = 'token' | 'user';
13
+ /**
14
+ * The identity a tool call runs under — the transport-neutral half of "who is
15
+ * asking". A bearer API token from the public API and a signed-in admin user
16
+ * driving the copilot are the same shape here, which is what lets one tool
17
+ * implementation serve both callers.
18
+ *
19
+ * `grantedPermissions` is resolved by whoever built the context (the MCP
20
+ * endpoint's bearer auth, or the copilot's RBAC lookup) and is the **only**
21
+ * input to the authorization decision — a tool never re-derives rights from
22
+ * `id` or `userId`.
23
+ */
24
+ export interface ToolActor {
25
+ /** Whether this is a bearer token acting as itself, or a signed-in user. */
26
+ kind: ToolActorKind;
27
+ /**
28
+ * The actor's own id — a token id or a user id. What a log line keys on.
29
+ * For a token this is deliberately **not** the minting user: a token acts
30
+ * as itself, so revoking it revokes its access whoever created it.
31
+ */
32
+ id: string;
33
+ /** Display name, for diagnostics and audit lines. */
34
+ displayName: string;
35
+ /** The `resource:action` keys this actor holds. */
36
+ grantedPermissions: ReadonlySet<string>;
37
+ /**
38
+ * The accountable human, when there is one — the user themselves, or the
39
+ * user who minted a token. **Attribution only, never authorization.**
40
+ */
41
+ userId: string | null;
42
+ }
43
+ /**
44
+ * Everything a tool handler is told about the call, beyond its own arguments.
45
+ * Framework-free by design: no request, no response, nothing HTTP. A tool
46
+ * written against this runs identically behind the MCP endpoint and inside the
47
+ * copilot's in-process tool loop.
48
+ */
49
+ export interface ToolContext {
50
+ /** Who is calling. */
51
+ actor: ToolActor;
52
+ /** The workspace this call acts in — always resolved before dispatch. */
53
+ workspaceId: string;
54
+ /**
55
+ * Aborted when the caller goes away, so a tool doing real I/O can give up
56
+ * instead of finishing work nobody will read.
57
+ *
58
+ * Optional because only one consumer has one: the copilot's run is a live
59
+ * SSE stream that ends when the browser disconnects, while an MCP call is a
60
+ * request/response and finishes either way. A tool that ignores it is
61
+ * correct — this is a courtesy, never a correctness boundary.
62
+ */
63
+ signal?: AbortSignal;
64
+ /**
65
+ * Which consumer dispatched this call — stamped by
66
+ * {@link ToolRegistry.call}, so it is always the surface the call was
67
+ * authorized against and never something the caller can assert.
68
+ *
69
+ * **For presentation only, never for authorization.** A tool offered to
70
+ * both surfaces may need to render the same fact differently for each — a
71
+ * download link is the session-gated admin route for the copilot and the
72
+ * bearer-fetchable `/v1` one for MCP, because the two callers hold
73
+ * different credentials. What a tool must *not* do is widen what it returns
74
+ * or skip a check on one surface: that is a difference in authority, and
75
+ * authority is `requires` plus {@link can}, which mean one thing for
76
+ * everybody. A tool that finds itself wanting `if (surface === 'copilot')`
77
+ * around a rule is two tools.
78
+ *
79
+ * Optional so a handler can be unit-tested with a hand-built context;
80
+ * absent, treat it as the copilot's admin-facing rendering.
81
+ */
82
+ surface?: ToolSurface;
83
+ /**
84
+ * Whether the actor holds a permission. Handlers use this for decisions
85
+ * **finer** than the tool's own `requires` gate — e.g. content reads let a
86
+ * writer see drafts, which is a widening inside one tool rather than a
87
+ * separate tool.
88
+ */
89
+ can(permission: PermissionKey): boolean;
90
+ }
91
+ /**
92
+ * What running a tool does to the system — the **authority** vocabulary, as
93
+ * distinct from the `readOnly`/`destructive` MCP *hints* below.
94
+ *
95
+ * `read` runs freely. `propose` produces a reviewable change a human accepts
96
+ * ([ADR-0005](../../../../docs/adr/0005-copilot-authority-model.md) §5) — its
97
+ * handler writes nothing, returning the change for its consumer to record.
98
+ * `apply` writes directly, gated only by the caller's own permissions
99
+ * ([ADR-0009](../../../../docs/adr/0009-copilot-applies-directly.md) §5 — it is
100
+ * offered exactly when a `read` tool with the same `requires` would be). It
101
+ * used to be off behind a workspace policy; ADR-0009 §4 deleted that policy,
102
+ * and an `apply` tool is now something a run performs, not something an
103
+ * operator opts into.
104
+ *
105
+ * Two vocabularies rather than one because they answer different questions.
106
+ * `readOnly` tells an MCP client what is safe to auto-approve; `effect` tells
107
+ * the *server* whether a handler's return value is a change or a result. A
108
+ * `propose` tool is not read-only in the MCP sense (it is part of a write
109
+ * flow), and a direct-write tool is not `propose` however destructive it is.
110
+ */
111
+ export type ToolEffect = 'read' | 'propose' | 'apply';
112
+ /**
113
+ * Which consumer of the registry a tool is offered to.
114
+ *
115
+ * Sharing the registry does **not** mean every tool suits every caller. The
116
+ * MCP content tools read through the public API's published-only services and
117
+ * attribute writes to a token; the copilot's read the admin's services (a
118
+ * viewer must see drafts) and write through propose-then-apply with the human
119
+ * as actor. Both are correct for their caller and wrong for the other, so the
120
+ * tool declares who it is for rather than a filter elsewhere guessing.
121
+ *
122
+ * Omitted means **both** — the honest default for a tool with no such tension
123
+ * (locales, media search), and the one that makes adding a genuinely shared
124
+ * tool the path of least resistance.
125
+ */
126
+ export type ToolSurface = 'mcp' | 'copilot';
127
+ /**
128
+ * What a tool returns. A plain JSON-serializable value — the transport decides
129
+ * how to present it (MCP wraps it as `structuredContent` plus a text rendering;
130
+ * the copilot's loop will feed it back to the model as a tool result).
131
+ */
132
+ export type ToolOutput = unknown;
133
+ /**
134
+ * One callable tool. The unit every consumer of the registry sees.
135
+ *
136
+ * `requires` is the authorization contract and is enforced **centrally** by
137
+ * {@link ToolRegistry.call}, exactly as `@RequirePermissions(...)` is enforced
138
+ * by a guard on the HTTP routes — a handler never checks its own gate, so a new
139
+ * tool cannot forget to. It is the direct analogue of the decorator, and the
140
+ * reason a `read`-scoped token gets a refusal on every write tool without a
141
+ * line of code here saying so.
142
+ */
143
+ export interface ToolDefinition {
144
+ /**
145
+ * Machine name, `snake_case`, unique across every provider. Namespaced by
146
+ * subject (`content_list`, `content_create`) so a second contributing
147
+ * plugin's tools cannot read as the first's.
148
+ */
149
+ name: string;
150
+ /** Short human title, shown by MCP clients in tool pickers. */
151
+ title: string;
152
+ /**
153
+ * What the tool does, written **for a model**. This is the only
154
+ * documentation the caller gets, so it carries the workflow rules the HTTP
155
+ * API states in prose: that a create yields a draft, that publish is a
156
+ * separate call, that an update merges.
157
+ */
158
+ description: string;
159
+ /** JSON Schema for the arguments object. */
160
+ inputSchema: JsonSchema;
161
+ /**
162
+ * Permissions the actor must hold — **all** of them. Checked before the
163
+ * handler runs, and used to hide the tool from actors who cannot call it.
164
+ */
165
+ requires: readonly PermissionKey[];
166
+ /**
167
+ * True when the tool cannot modify anything. Surfaces as MCP's
168
+ * `readOnlyHint`, which clients use to decide what to auto-approve.
169
+ */
170
+ readOnly: boolean;
171
+ /**
172
+ * What running this does to the system. Defaults to `read` when omitted,
173
+ * which is what every tool written before the copilot joined the registry
174
+ * meant — but say it explicitly on anything that writes.
175
+ */
176
+ effect?: ToolEffect;
177
+ /**
178
+ * Which consumers may be offered this tool. Omitted means both; see
179
+ * {@link ToolSurface} for why a tool would ever narrow it.
180
+ */
181
+ surfaces?: readonly ToolSurface[];
182
+ /**
183
+ * True when the tool can destroy data an actor would not get back.
184
+ * Surfaces as MCP's `destructiveHint`.
185
+ */
186
+ destructive?: boolean;
187
+ /** Runs the tool. Throws to signal failure; see `toToolError`. */
188
+ handler(input: Record<string, unknown>, context: ToolContext): Promise<ToolOutput>;
189
+ }
190
+ /** One readable resource — a document a client can pull in without a call. */
191
+ export interface ResourceDefinition {
192
+ /** Stable URI, e.g. `ortha://content-type/article`. */
193
+ uri: string;
194
+ /** Short human name. */
195
+ name: string;
196
+ /** What the resource holds. */
197
+ description: string;
198
+ /** MIME type of the contents, e.g. `application/json`. */
199
+ mimeType: string;
200
+ /**
201
+ * Permissions the actor must hold to list **or read** this resource — all
202
+ * of them, exactly as {@link ToolDefinition.requires}. Enforced centrally by
203
+ * {@link ToolRegistry}, so a resource cannot forget its own gate.
204
+ *
205
+ * Omitted means "no gate beyond reaching the endpoint", which is what every
206
+ * resource shipped today means: the content-type schemas are already
207
+ * narrowed to the workspace's own grants by the provider that builds them,
208
+ * and a resource that needs more than the workspace scope says so here
209
+ * rather than re-implementing the check.
210
+ */
211
+ requires?: readonly PermissionKey[];
212
+ }
213
+ /** The body of a resource read. */
214
+ export interface ResourceContents {
215
+ /** Echoes the requested URI. */
216
+ uri: string;
217
+ /** MIME type of {@link text}. */
218
+ mimeType: string;
219
+ /** The resource body. */
220
+ text: string;
221
+ }
222
+ //# sourceMappingURL=tool.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tool.d.ts","sourceRoot":"","sources":["../../src/lib/tool.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,aAAa,EAAE,MAAM,2BAA2B,CAAC;AAE/D;;;;;;;GAOG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;AAEjD,+BAA+B;AAC/B,MAAM,MAAM,aAAa,GAAG,OAAO,GAAG,MAAM,CAAC;AAE7C;;;;;;;;;;GAUG;AACH,MAAM,WAAW,SAAS;IACtB,4EAA4E;IAC5E,IAAI,EAAE,aAAa,CAAC;IACpB;;;;OAIG;IACH,EAAE,EAAE,MAAM,CAAC;IACX,qDAAqD;IACrD,WAAW,EAAE,MAAM,CAAC;IACpB,mDAAmD;IACnD,kBAAkB,EAAE,WAAW,CAAC,MAAM,CAAC,CAAC;IACxC;;;OAGG;IACH,MAAM,EAAE,MAAM,GAAG,IAAI,CAAC;CACzB;AAED;;;;;GAKG;AACH,MAAM,WAAW,WAAW;IACxB,sBAAsB;IACtB,KAAK,EAAE,SAAS,CAAC;IACjB,yEAAyE;IACzE,WAAW,EAAE,MAAM,CAAC;IACpB;;;;;;;;OAQG;IACH,MAAM,CAAC,EAAE,WAAW,CAAC;IACrB;;;;;;;;;;;;;;;;;OAiBG;IACH,OAAO,CAAC,EAAE,WAAW,CAAC;IACtB;;;;;OAKG;IACH,GAAG,CAAC,UAAU,EAAE,aAAa,GAAG,OAAO,CAAC;CAC3C;AAED;;;;;;;;;;;;;;;;;;;GAmBG;AACH,MAAM,MAAM,UAAU,GAAG,MAAM,GAAG,SAAS,GAAG,OAAO,CAAC;AAEtD;;;;;;;;;;;;;GAaG;AACH,MAAM,MAAM,WAAW,GAAG,KAAK,GAAG,SAAS,CAAC;AAE5C;;;;GAIG;AACH,MAAM,MAAM,UAAU,GAAG,OAAO,CAAC;AAEjC;;;;;;;;;GASG;AACH,MAAM,WAAW,cAAc;IAC3B;;;;OAIG;IACH,IAAI,EAAE,MAAM,CAAC;IACb,+DAA+D;IAC/D,KAAK,EAAE,MAAM,CAAC;IACd;;;;;OAKG;IACH,WAAW,EAAE,MAAM,CAAC;IACpB,4CAA4C;IAC5C,WAAW,EAAE,UAAU,CAAC;IACxB;;;OAGG;IACH,QAAQ,EAAE,SAAS,aAAa,EAAE,CAAC;IACnC;;;OAGG;IACH,QAAQ,EAAE,OAAO,CAAC;IAClB;;;;OAIG;IACH,MAAM,CAAC,EAAE,UAAU,CAAC;IACpB;;;OAGG;IACH,QAAQ,CAAC,EAAE,SAAS,WAAW,EAAE,CAAC;IAClC;;;OAGG;IACH,WAAW,CAAC,EAAE,OAAO,CAAC;IACtB,kEAAkE;IAClE,OAAO,CACH,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC9B,OAAO,EAAE,WAAW,GACrB,OAAO,CAAC,UAAU,CAAC,CAAC;CAC1B;AAED,8EAA8E;AAC9E,MAAM,WAAW,kBAAkB;IAC/B,uDAAuD;IACvD,GAAG,EAAE,MAAM,CAAC;IACZ,wBAAwB;IACxB,IAAI,EAAE,MAAM,CAAC;IACb,+BAA+B;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,0DAA0D;IAC1D,QAAQ,EAAE,MAAM,CAAC;IACjB;;;;;;;;;;OAUG;IACH,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,CAAC;CACvC;AAED,mCAAmC;AACnC,MAAM,WAAW,gBAAgB;IAC7B,gCAAgC;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,iCAAiC;IACjC,QAAQ,EAAE,MAAM,CAAC;IACjB,yBAAyB;IACzB,IAAI,EAAE,MAAM,CAAC;CAChB"}
@@ -0,0 +1,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Provides the shared {@link ToolRegistry} — one instance for the whole
3
+ * application.
4
+ *
5
+ * **Global, and imported rather than provided by either consumer.** The
6
+ * registry has two of them (the MCP endpoint and the copilot's run loop) and
7
+ * belongs to neither: if `McpModule` provided it, a deployment running the
8
+ * copilot without MCP would have no registry and no tools; if each provided its
9
+ * own, the two would hold different catalogues and a tool registered by a
10
+ * capability plugin would reach whichever happened to win.
11
+ *
12
+ * Nest caches a static module by class, so importing this from both yields the
13
+ * same instance — which is exactly the invariant the whole seam rests on.
14
+ *
15
+ * A capability plugin does **not** import this: it injects `ToolRegistry`
16
+ * `@Optional()`, because a deployment may run neither consumer and its tools
17
+ * then simply go unregistered.
18
+ */
19
+ export declare class ToolsModule {
20
+ }
21
+ //# sourceMappingURL=tools.module.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tools.module.d.ts","sourceRoot":"","sources":["../../src/lib/tools.module.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAKa,WAAW;CAAG"}
@@ -0,0 +1,34 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ToolsModule = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@nestjs/common");
6
+ const tool_registry_1 = require("./tool-registry");
7
+ /**
8
+ * Provides the shared {@link ToolRegistry} — one instance for the whole
9
+ * application.
10
+ *
11
+ * **Global, and imported rather than provided by either consumer.** The
12
+ * registry has two of them (the MCP endpoint and the copilot's run loop) and
13
+ * belongs to neither: if `McpModule` provided it, a deployment running the
14
+ * copilot without MCP would have no registry and no tools; if each provided its
15
+ * own, the two would hold different catalogues and a tool registered by a
16
+ * capability plugin would reach whichever happened to win.
17
+ *
18
+ * Nest caches a static module by class, so importing this from both yields the
19
+ * same instance — which is exactly the invariant the whole seam rests on.
20
+ *
21
+ * A capability plugin does **not** import this: it injects `ToolRegistry`
22
+ * `@Optional()`, because a deployment may run neither consumer and its tools
23
+ * then simply go unregistered.
24
+ */
25
+ let ToolsModule = class ToolsModule {
26
+ };
27
+ exports.ToolsModule = ToolsModule;
28
+ exports.ToolsModule = ToolsModule = tslib_1.__decorate([
29
+ (0, common_1.Global)(),
30
+ (0, common_1.Module)({
31
+ providers: [tool_registry_1.ToolRegistry],
32
+ exports: [tool_registry_1.ToolRegistry]
33
+ })
34
+ ], ToolsModule);
@@ -0,0 +1,36 @@
1
+ import type { JsonSchema } from './tool';
2
+ /** The outcome of checking one tool's arguments against its schema. */
3
+ export interface ToolInputValidation {
4
+ /** Whether the arguments satisfy the schema. */
5
+ valid: boolean;
6
+ /** Every problem found, as `path: message`. Empty when {@link valid}. */
7
+ errors: string[];
8
+ }
9
+ /**
10
+ * Validates a caller's tool arguments against the tool's declared JSON Schema.
11
+ *
12
+ * It lives beside {@link ToolDefinition} because that is the type whose
13
+ * `inputSchema` it interprets. It used to sit in the copilot's framework-free
14
+ * core, where only the copilot's run loop reached it — which is exactly why the
15
+ * MCP endpoint spent its whole life dispatching unvalidated arguments while the
16
+ * copilot refused them. Owned here, {@link ToolRegistry.call} applies it to
17
+ * **every** consumer, and a third one gets it without remembering to ask.
18
+ *
19
+ * **A deliberate subset, not a JSON Schema implementation.** It covers exactly
20
+ * what the generated tool schemas use — `type`, `properties`, `required`,
21
+ * `enum`, `items`, `additionalProperties`, and the numeric/length bounds — and
22
+ * *ignores* keywords it doesn't know (`anyOf`, `oneOf`, `$ref`, `format`,
23
+ * `pattern`) rather than guessing at them. A rule nested inside an `anyOf` is
24
+ * therefore **not** checked at all, which is the one thing a tool author must
25
+ * not read this as promising.
26
+ *
27
+ * That is tolerable because it is a **defence-in-depth** check, not a security
28
+ * boundary: `requires` is what decides whether a tool may run at all, and a
29
+ * handler still owns every rule about its own values. What this stops is a
30
+ * malformed call reaching a handler and turning a caller's mistake into an
31
+ * opaque 500. Every error it produces goes back to the caller as a
32
+ * `validation_failed` tool error naming the field, so a model can fix it and
33
+ * retry.
34
+ */
35
+ export declare function validateToolInput(input: unknown, schema: JsonSchema): ToolInputValidation;
36
+ //# sourceMappingURL=validate-tool-input.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"validate-tool-input.d.ts","sourceRoot":"","sources":["../../src/lib/validate-tool-input.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,QAAQ,CAAC;AAEzC,uEAAuE;AACvE,MAAM,WAAW,mBAAmB;IAChC,gDAAgD;IAChD,KAAK,EAAE,OAAO,CAAC;IACf,yEAAyE;IACzE,MAAM,EAAE,MAAM,EAAE,CAAC;CACpB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,OAAO,EACd,MAAM,EAAE,UAAU,GACnB,mBAAmB,CAIrB"}
@@ -0,0 +1,155 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.validateToolInput = validateToolInput;
4
+ /**
5
+ * Validates a caller's tool arguments against the tool's declared JSON Schema.
6
+ *
7
+ * It lives beside {@link ToolDefinition} because that is the type whose
8
+ * `inputSchema` it interprets. It used to sit in the copilot's framework-free
9
+ * core, where only the copilot's run loop reached it — which is exactly why the
10
+ * MCP endpoint spent its whole life dispatching unvalidated arguments while the
11
+ * copilot refused them. Owned here, {@link ToolRegistry.call} applies it to
12
+ * **every** consumer, and a third one gets it without remembering to ask.
13
+ *
14
+ * **A deliberate subset, not a JSON Schema implementation.** It covers exactly
15
+ * what the generated tool schemas use — `type`, `properties`, `required`,
16
+ * `enum`, `items`, `additionalProperties`, and the numeric/length bounds — and
17
+ * *ignores* keywords it doesn't know (`anyOf`, `oneOf`, `$ref`, `format`,
18
+ * `pattern`) rather than guessing at them. A rule nested inside an `anyOf` is
19
+ * therefore **not** checked at all, which is the one thing a tool author must
20
+ * not read this as promising.
21
+ *
22
+ * That is tolerable because it is a **defence-in-depth** check, not a security
23
+ * boundary: `requires` is what decides whether a tool may run at all, and a
24
+ * handler still owns every rule about its own values. What this stops is a
25
+ * malformed call reaching a handler and turning a caller's mistake into an
26
+ * opaque 500. Every error it produces goes back to the caller as a
27
+ * `validation_failed` tool error naming the field, so a model can fix it and
28
+ * retry.
29
+ */
30
+ function validateToolInput(input, schema) {
31
+ const errors = [];
32
+ check(input, schema, '', errors);
33
+ return { valid: errors.length === 0, errors };
34
+ }
35
+ /** Recursively checks one value against one (sub)schema. */
36
+ function check(value, schema, path, errors) {
37
+ const where = path || 'input';
38
+ const type = schema['type'];
39
+ if (typeof type === 'string' && !matchesType(value, type)) {
40
+ errors.push(`${where}: expected ${type}`);
41
+ // Bail out on this branch: every keyword below assumes the type held,
42
+ // and reporting "minLength failed" about a number is noise.
43
+ return;
44
+ }
45
+ const enumValues = schema['enum'];
46
+ if (Array.isArray(enumValues) && !enumValues.includes(value)) {
47
+ errors.push(`${where}: must be one of ${enumValues.join(', ')}`);
48
+ }
49
+ if (type === 'object' || isPlainObject(value)) {
50
+ checkObject(value, schema, path, errors);
51
+ }
52
+ if (Array.isArray(value)) {
53
+ checkArray(value, schema, path, errors);
54
+ }
55
+ if (typeof value === 'string') {
56
+ checkString(value, schema, where, errors);
57
+ }
58
+ if (typeof value === 'number') {
59
+ checkNumber(value, schema, where, errors);
60
+ }
61
+ }
62
+ /** `required`, `properties`, and `additionalProperties: false`. */
63
+ function checkObject(value, schema, path, errors) {
64
+ if (!isPlainObject(value)) {
65
+ return;
66
+ }
67
+ const required = schema['required'];
68
+ if (Array.isArray(required)) {
69
+ for (const key of required) {
70
+ if (typeof key === 'string' && !(key in value)) {
71
+ errors.push(`${path ? `${path}.` : ''}${key}: required`);
72
+ }
73
+ }
74
+ }
75
+ const properties = isPlainObject(schema['properties'])
76
+ ? schema['properties']
77
+ : {};
78
+ for (const [key, raw] of Object.entries(value)) {
79
+ const propertySchema = properties[key];
80
+ if (isPlainObject(propertySchema)) {
81
+ check(raw, propertySchema, path ? `${path}.${key}` : key, errors);
82
+ continue;
83
+ }
84
+ // Only complain about an unknown key when the schema actually closed
85
+ // the object. A schema that stays open is choosing to accept extras.
86
+ if (schema['additionalProperties'] === false) {
87
+ errors.push(`${path ? `${path}.` : ''}${key}: unexpected property`);
88
+ }
89
+ }
90
+ }
91
+ /** `items`, `minItems`, `maxItems`. */
92
+ function checkArray(value, schema, path, errors) {
93
+ const where = path || 'input';
94
+ const minItems = schema['minItems'];
95
+ if (typeof minItems === 'number' && value.length < minItems) {
96
+ errors.push(`${where}: expected at least ${minItems} items`);
97
+ }
98
+ const maxItems = schema['maxItems'];
99
+ if (typeof maxItems === 'number' && value.length > maxItems) {
100
+ errors.push(`${where}: expected at most ${maxItems} items`);
101
+ }
102
+ const items = schema['items'];
103
+ if (isPlainObject(items)) {
104
+ value.forEach((entry, index) => check(entry, items, `${path}[${index}]`, errors));
105
+ }
106
+ }
107
+ /** `minLength`, `maxLength`. */
108
+ function checkString(value, schema, where, errors) {
109
+ const minLength = schema['minLength'];
110
+ if (typeof minLength === 'number' && value.length < minLength) {
111
+ errors.push(`${where}: expected at least ${minLength} characters`);
112
+ }
113
+ const maxLength = schema['maxLength'];
114
+ if (typeof maxLength === 'number' && value.length > maxLength) {
115
+ errors.push(`${where}: expected at most ${maxLength} characters`);
116
+ }
117
+ }
118
+ /** `minimum`, `maximum`. */
119
+ function checkNumber(value, schema, where, errors) {
120
+ const minimum = schema['minimum'];
121
+ if (typeof minimum === 'number' && value < minimum) {
122
+ errors.push(`${where}: expected >= ${minimum}`);
123
+ }
124
+ const maximum = schema['maximum'];
125
+ if (typeof maximum === 'number' && value > maximum) {
126
+ errors.push(`${where}: expected <= ${maximum}`);
127
+ }
128
+ }
129
+ /** JSON Schema's type names, mapped onto JavaScript's. */
130
+ function matchesType(value, type) {
131
+ switch (type) {
132
+ case 'object':
133
+ return isPlainObject(value);
134
+ case 'array':
135
+ return Array.isArray(value);
136
+ case 'string':
137
+ return typeof value === 'string';
138
+ case 'integer':
139
+ return typeof value === 'number' && Number.isInteger(value);
140
+ case 'number':
141
+ return typeof value === 'number' && Number.isFinite(value);
142
+ case 'boolean':
143
+ return typeof value === 'boolean';
144
+ case 'null':
145
+ return value === null;
146
+ default:
147
+ // An unknown type name is not a failure to report against the
148
+ // value — it's a schema we don't understand. Accept and move on.
149
+ return true;
150
+ }
151
+ }
152
+ /** A non-null, non-array object — what JSON Schema calls `object`. */
153
+ function isPlainObject(value) {
154
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
155
+ }
package/package.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "name": "@orthacms/tools-server",
3
+ "version": "0.0.0-reserve.0",
4
+ "description": "@orthacms/tools-server — part of Ortha CMS.",
5
+ "license": "MIT",
6
+ "homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/tools/server",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ortha-source/ortha-cms.git",
10
+ "directory": "packages/tools/server"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/ortha-source/ortha-cms/issues"
14
+ },
15
+ "main": "./dist/index.js",
16
+ "types": "./dist/index.d.ts",
17
+ "exports": {
18
+ ".": {
19
+ "types": "./dist/index.d.ts",
20
+ "default": "./dist/index.js"
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist"
26
+ ],
27
+ "dependencies": {
28
+ "@nestjs/common": "^11.0.0",
29
+ "@orthacms/identity-server": "^0.0.1",
30
+ "tslib": "^2.3.0"
31
+ },
32
+ "publishConfig": {
33
+ "access": "public"
34
+ }
35
+ }