@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 +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +34 -0
- package/dist/lib/tool-context.d.ts +16 -0
- package/dist/lib/tool-context.d.ts.map +1 -0
- package/dist/lib/tool-context.js +23 -0
- package/dist/lib/tool-error.d.ts +28 -0
- package/dist/lib/tool-error.d.ts.map +1 -0
- package/dist/lib/tool-error.js +58 -0
- package/dist/lib/tool-provider.d.ts +40 -0
- package/dist/lib/tool-provider.d.ts.map +1 -0
- package/dist/lib/tool-provider.js +2 -0
- package/dist/lib/tool-registry.d.ts +137 -0
- package/dist/lib/tool-registry.d.ts.map +1 -0
- package/dist/lib/tool-registry.js +290 -0
- package/dist/lib/tool.d.ts +222 -0
- package/dist/lib/tool.d.ts.map +1 -0
- package/dist/lib/tool.js +2 -0
- package/dist/lib/tools.module.d.ts +21 -0
- package/dist/lib/tools.module.d.ts.map +1 -0
- package/dist/lib/tools.module.js +34 -0
- package/dist/lib/validate-tool-input.d.ts +36 -0
- package/dist/lib/validate-tool-input.d.ts.map +1 -0
- package/dist/lib/validate-tool-input.js +155 -0
- package/package.json +35 -0
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
package/dist/index.d.ts
ADDED
|
@@ -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,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"}
|
package/dist/lib/tool.js
ADDED
|
@@ -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
|
+
}
|