@orthacms/mcp-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 +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +16 -0
- package/dist/lib/http/mcp-auth.service.d.ts +50 -0
- package/dist/lib/http/mcp-auth.service.d.ts.map +1 -0
- package/dist/lib/http/mcp-auth.service.js +133 -0
- package/dist/lib/http/mcp.controller.d.ts +54 -0
- package/dist/lib/http/mcp.controller.d.ts.map +1 -0
- package/dist/lib/http/mcp.controller.js +178 -0
- package/dist/lib/mcp.module.d.ts +19 -0
- package/dist/lib/mcp.module.d.ts.map +1 -0
- package/dist/lib/mcp.module.js +45 -0
- package/dist/lib/mcp.tokens.d.ts +11 -0
- package/dist/lib/mcp.tokens.d.ts.map +1 -0
- package/dist/lib/mcp.tokens.js +15 -0
- package/dist/lib/protocol/build-mcp-server.d.ts +50 -0
- package/dist/lib/protocol/build-mcp-server.d.ts.map +1 -0
- package/dist/lib/protocol/build-mcp-server.js +248 -0
- package/dist/lib/types/mcp-config.d.ts +54 -0
- package/dist/lib/types/mcp-config.d.ts.map +1 -0
- package/dist/lib/types/mcp-config.js +2 -0
- package/dist/lib/utils/mcp-plugin.d.ts +31 -0
- package/dist/lib/utils/mcp-plugin.d.ts.map +1 -0
- package/dist/lib/utils/mcp-plugin.js +57 -0
- package/package.json +40 -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,7 @@
|
|
|
1
|
+
/** Public API of @orthacms/mcp-server. */
|
|
2
|
+
export { McpPlugin } from './lib/utils/mcp-plugin';
|
|
3
|
+
export type { McpPluginOptions, McpServerPluginDefinition } from './lib/utils/mcp-plugin';
|
|
4
|
+
export { McpModule } from './lib/mcp.module';
|
|
5
|
+
export { MCP_CONFIG, InjectMcpConfig } from './lib/mcp.tokens';
|
|
6
|
+
export type { McpPluginConfig } from './lib/types/mcp-config';
|
|
7
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,0CAA0C;AAE1C,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,YAAY,EACR,gBAAgB,EAChB,yBAAyB,EAC5B,MAAM,wBAAwB,CAAC;AAEhC,OAAO,EAAE,SAAS,EAAE,MAAM,kBAAkB,CAAC;AAC7C,OAAO,EAAE,UAAU,EAAE,eAAe,EAAE,MAAM,kBAAkB,CAAC;AAC/D,YAAY,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/** Public API of @orthacms/mcp-server. */
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.InjectMcpConfig = exports.MCP_CONFIG = exports.McpModule = exports.McpPlugin = void 0;
|
|
5
|
+
var mcp_plugin_1 = require("./lib/utils/mcp-plugin");
|
|
6
|
+
Object.defineProperty(exports, "McpPlugin", { enumerable: true, get: function () { return mcp_plugin_1.McpPlugin; } });
|
|
7
|
+
var mcp_module_1 = require("./lib/mcp.module");
|
|
8
|
+
Object.defineProperty(exports, "McpModule", { enumerable: true, get: function () { return mcp_module_1.McpModule; } });
|
|
9
|
+
var mcp_tokens_1 = require("./lib/mcp.tokens");
|
|
10
|
+
Object.defineProperty(exports, "MCP_CONFIG", { enumerable: true, get: function () { return mcp_tokens_1.MCP_CONFIG; } });
|
|
11
|
+
Object.defineProperty(exports, "InjectMcpConfig", { enumerable: true, get: function () { return mcp_tokens_1.InjectMcpConfig; } });
|
|
12
|
+
// The tool seam moved to `@orthacms/tools-server`. It is no longer MCP's to
|
|
13
|
+
// own: the copilot's in-process loop injects the same registry, and importing
|
|
14
|
+
// it from here would make a deployment that wants only the copilot pull the MCP
|
|
15
|
+
// SDK through this barrel. Import `ToolRegistry`, `ToolDefinition`,
|
|
16
|
+
// `ToolProvider`, `createToolContext` and `toToolError` from there.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ApiTokenService } from '@orthacms/identity-server';
|
|
2
|
+
import type { ToolContext } from '@orthacms/tools-server';
|
|
3
|
+
/** The headers this service reads, as Node delivers them. */
|
|
4
|
+
export interface McpRequestHeaders {
|
|
5
|
+
authorization?: string;
|
|
6
|
+
[key: string]: string | string[] | undefined;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Authenticates an MCP request and resolves the workspace it acts in, yielding
|
|
10
|
+
* the {@link ToolContext} every tool call runs under.
|
|
11
|
+
*
|
|
12
|
+
* **The same credential and the same rules as `/api/v1/*`** — deliberately, and
|
|
13
|
+
* to the letter. MCP is a second front door onto the content API, not a second
|
|
14
|
+
* security model: it takes the bearer tokens the admin's API Tokens page
|
|
15
|
+
* already mints, resolves them through the same `ApiTokenService.verify`, and
|
|
16
|
+
* derives permissions through the same `scopePermissions`. An operator revoking
|
|
17
|
+
* a token revokes its MCP access in the same instant, and there is no second
|
|
18
|
+
* credential store to audit.
|
|
19
|
+
*
|
|
20
|
+
* This class is the MCP counterpart of three guards that cannot be reused
|
|
21
|
+
* directly, because Nest gates *routes* and MCP is one route carrying many
|
|
22
|
+
* operations:
|
|
23
|
+
*
|
|
24
|
+
* - `ApiTokenGuard` → {@link authenticate}'s bearer half.
|
|
25
|
+
* - `ApiTokenWorkspaceGuard` → {@link authenticate}'s workspace half, rule for
|
|
26
|
+
* rule.
|
|
27
|
+
* - The per-route `@RequirePermissions(...)` → `ToolDefinition.requires`,
|
|
28
|
+
* enforced centrally by `ToolRegistry.call`.
|
|
29
|
+
*
|
|
30
|
+
* A **session cookie is not accepted**, exactly as on `/api/v1/*`. Cookies ride
|
|
31
|
+
* along ambiently, which is what makes cookie-authenticated writes CSRF-able; a
|
|
32
|
+
* bearer token never does. Accepting both here would reintroduce that on an
|
|
33
|
+
* endpoint whose whole purpose is letting an agent write content.
|
|
34
|
+
*/
|
|
35
|
+
export declare class McpAuthService {
|
|
36
|
+
private readonly tokens;
|
|
37
|
+
constructor(tokens: ApiTokenService);
|
|
38
|
+
/**
|
|
39
|
+
* Verify the bearer credential and resolve the target workspace.
|
|
40
|
+
*
|
|
41
|
+
* `workspaceQuery` is the optional `?workspaceId=` on the endpoint URL. It
|
|
42
|
+
* exists because MCP clients are configured with a **URL**, and several
|
|
43
|
+
* make custom headers awkward or impossible — while a multi-workspace token
|
|
44
|
+
* has to name one somehow. The header wins when both are present, and both
|
|
45
|
+
* are checked against the token's bucket identically, so the query string
|
|
46
|
+
* is a spelling of the same rule and never a way around it.
|
|
47
|
+
*/
|
|
48
|
+
authenticate(headers: McpRequestHeaders, workspaceQuery?: string): Promise<ToolContext>;
|
|
49
|
+
}
|
|
50
|
+
//# sourceMappingURL=mcp-auth.service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-auth.service.d.ts","sourceRoot":"","sources":["../../../src/lib/http/mcp-auth.service.ts"],"names":[],"mappings":"AAMA,OAAO,EAAE,eAAe,EAAoB,MAAM,2BAA2B,CAAC;AAK9E,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,wBAAwB,CAAC;AAM1D,6DAA6D;AAC7D,MAAM,WAAW,iBAAiB;IAC9B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC;CAChD;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,qBACa,cAAc;IACX,OAAO,CAAC,QAAQ,CAAC,MAAM;gBAAN,MAAM,EAAE,eAAe;IAEpD;;;;;;;;;OASG;IACG,YAAY,CACd,OAAO,EAAE,iBAAiB,EAC1B,cAAc,CAAC,EAAE,MAAM,GACxB,OAAO,CAAC,WAAW,CAAC;CAiC1B"}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.McpAuthService = 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 workspaces_server_1 = require("@orthacms/workspaces-server");
|
|
8
|
+
const tools_server_1 = require("@orthacms/tools-server");
|
|
9
|
+
/** The scheme the `Authorization` header must use, case-insensitively. */
|
|
10
|
+
const BEARER = 'bearer';
|
|
11
|
+
/**
|
|
12
|
+
* Authenticates an MCP request and resolves the workspace it acts in, yielding
|
|
13
|
+
* the {@link ToolContext} every tool call runs under.
|
|
14
|
+
*
|
|
15
|
+
* **The same credential and the same rules as `/api/v1/*`** — deliberately, and
|
|
16
|
+
* to the letter. MCP is a second front door onto the content API, not a second
|
|
17
|
+
* security model: it takes the bearer tokens the admin's API Tokens page
|
|
18
|
+
* already mints, resolves them through the same `ApiTokenService.verify`, and
|
|
19
|
+
* derives permissions through the same `scopePermissions`. An operator revoking
|
|
20
|
+
* a token revokes its MCP access in the same instant, and there is no second
|
|
21
|
+
* credential store to audit.
|
|
22
|
+
*
|
|
23
|
+
* This class is the MCP counterpart of three guards that cannot be reused
|
|
24
|
+
* directly, because Nest gates *routes* and MCP is one route carrying many
|
|
25
|
+
* operations:
|
|
26
|
+
*
|
|
27
|
+
* - `ApiTokenGuard` → {@link authenticate}'s bearer half.
|
|
28
|
+
* - `ApiTokenWorkspaceGuard` → {@link authenticate}'s workspace half, rule for
|
|
29
|
+
* rule.
|
|
30
|
+
* - The per-route `@RequirePermissions(...)` → `ToolDefinition.requires`,
|
|
31
|
+
* enforced centrally by `ToolRegistry.call`.
|
|
32
|
+
*
|
|
33
|
+
* A **session cookie is not accepted**, exactly as on `/api/v1/*`. Cookies ride
|
|
34
|
+
* along ambiently, which is what makes cookie-authenticated writes CSRF-able; a
|
|
35
|
+
* bearer token never does. Accepting both here would reintroduce that on an
|
|
36
|
+
* endpoint whose whole purpose is letting an agent write content.
|
|
37
|
+
*/
|
|
38
|
+
let McpAuthService = class McpAuthService {
|
|
39
|
+
tokens;
|
|
40
|
+
constructor(tokens) {
|
|
41
|
+
this.tokens = tokens;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Verify the bearer credential and resolve the target workspace.
|
|
45
|
+
*
|
|
46
|
+
* `workspaceQuery` is the optional `?workspaceId=` on the endpoint URL. It
|
|
47
|
+
* exists because MCP clients are configured with a **URL**, and several
|
|
48
|
+
* make custom headers awkward or impossible — while a multi-workspace token
|
|
49
|
+
* has to name one somehow. The header wins when both are present, and both
|
|
50
|
+
* are checked against the token's bucket identically, so the query string
|
|
51
|
+
* is a spelling of the same rule and never a way around it.
|
|
52
|
+
*/
|
|
53
|
+
async authenticate(headers, workspaceQuery) {
|
|
54
|
+
const secret = bearerFrom(headers.authorization);
|
|
55
|
+
if (!secret) {
|
|
56
|
+
throw new common_1.UnauthorizedException('Missing `Authorization: Bearer <token>` header.');
|
|
57
|
+
}
|
|
58
|
+
const token = await this.tokens.verify(secret);
|
|
59
|
+
if (!token) {
|
|
60
|
+
// Unknown, revoked, and expired are one flat 401 — the endpoint
|
|
61
|
+
// must not be usable to probe which tokens exist.
|
|
62
|
+
throw new common_1.UnauthorizedException('Invalid API token.');
|
|
63
|
+
}
|
|
64
|
+
const workspaceId = resolveWorkspace(token.workspaceIds, headerValue(headers[workspaces_server_1.WORKSPACE_HEADER]) ?? workspaceQuery);
|
|
65
|
+
return (0, tools_server_1.createToolContext)({
|
|
66
|
+
kind: 'token',
|
|
67
|
+
id: token.id,
|
|
68
|
+
displayName: token.name,
|
|
69
|
+
grantedPermissions: new Set((0, identity_server_1.scopePermissions)(token.scope)),
|
|
70
|
+
// Attribution only — the minting user's own role grants are
|
|
71
|
+
// never consulted, so revoking the token is always sufficient.
|
|
72
|
+
userId: token.createdBy ?? null
|
|
73
|
+
}, workspaceId);
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
exports.McpAuthService = McpAuthService;
|
|
77
|
+
exports.McpAuthService = McpAuthService = tslib_1.__decorate([
|
|
78
|
+
(0, common_1.Injectable)(),
|
|
79
|
+
tslib_1.__metadata("design:paramtypes", [identity_server_1.ApiTokenService])
|
|
80
|
+
], McpAuthService);
|
|
81
|
+
/**
|
|
82
|
+
* Which of the token's workspaces this request targets — the rule
|
|
83
|
+
* `ApiTokenWorkspaceGuard` applies, restated for a non-Nest call site:
|
|
84
|
+
*
|
|
85
|
+
* - named → must be well-formed (400) and in the bucket (403);
|
|
86
|
+
* - unnamed with a single-workspace token → that workspace, so the common case
|
|
87
|
+
* needs no configuration at all;
|
|
88
|
+
* - unnamed with a multi-workspace token → 400. Picking one silently would
|
|
89
|
+
* surface as "why is this empty?", which is a far worse failure than an
|
|
90
|
+
* error that says exactly what to do.
|
|
91
|
+
*/
|
|
92
|
+
function resolveWorkspace(bucket, requested) {
|
|
93
|
+
if (!requested) {
|
|
94
|
+
if (bucket.length === 1) {
|
|
95
|
+
return bucket[0];
|
|
96
|
+
}
|
|
97
|
+
throw new common_1.BadRequestException(`This token covers ${bucket.length} workspaces — name the one you want with the ${workspaces_server_1.WORKSPACE_HEADER} header or a ?workspaceId= query parameter on the MCP endpoint URL.`);
|
|
98
|
+
}
|
|
99
|
+
if (!workspaces_server_1.WORKSPACE_ID_PATTERN.test(requested)) {
|
|
100
|
+
throw new common_1.BadRequestException(`Malformed ${workspaces_server_1.WORKSPACE_HEADER}.`);
|
|
101
|
+
}
|
|
102
|
+
if (!bucket.includes(requested)) {
|
|
103
|
+
// Not-in-bucket and no-such-workspace are the same 403, so a token
|
|
104
|
+
// can't be used to probe which workspace ids exist.
|
|
105
|
+
throw new common_1.ForbiddenException('This token does not cover that workspace.');
|
|
106
|
+
}
|
|
107
|
+
return requested;
|
|
108
|
+
}
|
|
109
|
+
/** First value of a possibly-repeated header. */
|
|
110
|
+
function headerValue(raw) {
|
|
111
|
+
return Array.isArray(raw) ? raw[0] : raw;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* The raw token out of an `Authorization` header, or `undefined` when the
|
|
115
|
+
* header is absent, uses another scheme, or carries no value.
|
|
116
|
+
*
|
|
117
|
+
* Surrounding and repeated whitespace around the scheme is tolerated, because
|
|
118
|
+
* RFC 9110 allows it and clients emit it. Whitespace *inside* the value is not:
|
|
119
|
+
* a credential never contains any, so joining the pieces back together would
|
|
120
|
+
* invent a token the caller never sent — and then report the reinvention as
|
|
121
|
+
* `Invalid API token`, which reads as a wrong secret rather than a malformed
|
|
122
|
+
* header. Exactly one value after the scheme, or nothing.
|
|
123
|
+
*/
|
|
124
|
+
function bearerFrom(header) {
|
|
125
|
+
if (!header) {
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
|
128
|
+
const [scheme, ...rest] = header.trim().split(/\s+/);
|
|
129
|
+
if (scheme.toLowerCase() !== BEARER || rest.length !== 1) {
|
|
130
|
+
return undefined;
|
|
131
|
+
}
|
|
132
|
+
return rest[0].length > 0 ? rest[0] : undefined;
|
|
133
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from 'node:http';
|
|
2
|
+
import { ToolRegistry } from '@orthacms/tools-server';
|
|
3
|
+
import type { McpPluginConfig } from '../types/mcp-config';
|
|
4
|
+
import { McpAuthService } from './mcp-auth.service';
|
|
5
|
+
/** The express-shaped request this controller reads. */
|
|
6
|
+
type McpHttpRequest = IncomingMessage & {
|
|
7
|
+
body?: unknown;
|
|
8
|
+
query?: Record<string, unknown>;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* `POST /api/v1/mcp` — the **Model Context Protocol endpoint**. An external
|
|
12
|
+
* agent (Claude Desktop, Cursor, an SDK-built client) connects here and gets
|
|
13
|
+
* the CMS's content tools.
|
|
14
|
+
*
|
|
15
|
+
* **Stateless.** Every request carries its own bearer token and is authenticated
|
|
16
|
+
* from scratch, so there is no session to store, nothing to expire, and nothing
|
|
17
|
+
* requiring sticky routing across replicas — the endpoint scales exactly like
|
|
18
|
+
* the rest of `/api/v1`. The transport and protocol server are per-request and
|
|
19
|
+
* torn down with it, which is also what lets the tool list reflect *this*
|
|
20
|
+
* caller's scope.
|
|
21
|
+
*
|
|
22
|
+
* `GET` (the server-initiated SSE stream) and `DELETE` (session teardown) are
|
|
23
|
+
* answered **here** with a 405. Both exist to serve a persistent session, and
|
|
24
|
+
* there isn't one, which is exactly the case the specification reserves 405
|
|
25
|
+
* for. `@All()` routes them to this controller so the answer comes from the
|
|
26
|
+
* protocol layer rather than Nest's generic 404, which a client cannot
|
|
27
|
+
* interpret.
|
|
28
|
+
*
|
|
29
|
+
* The transport is deliberately not asked. Handed a `GET` in stateless mode it
|
|
30
|
+
* opens a standalone SSE stream and holds it **forever** — nothing on this
|
|
31
|
+
* endpoint ever pushes a server-initiated message, so the client waits on a
|
|
32
|
+
* connection that will never carry anything while a socket, a transport and a
|
|
33
|
+
* protocol server stay pinned per attempt. Answering 405 turns the commonest
|
|
34
|
+
* first-connection mistake into a sentence a client can act on.
|
|
35
|
+
*
|
|
36
|
+
* `@Public()` opts out of the session `AuthGuard`, exactly as the `/api/v1`
|
|
37
|
+
* controllers do — {@link McpAuthService} is the whole authentication story,
|
|
38
|
+
* and a session cookie is not accepted.
|
|
39
|
+
*
|
|
40
|
+
* Excluded from the OpenAPI document: JSON-RPC over one route is not describable
|
|
41
|
+
* as REST operations, and a single `POST /v1/mcp` entry in the reference would
|
|
42
|
+
* tell a reader nothing about the tools. The `AGENTS.md` documents the surface.
|
|
43
|
+
*/
|
|
44
|
+
export declare class McpController {
|
|
45
|
+
private readonly auth;
|
|
46
|
+
private readonly registry;
|
|
47
|
+
private readonly config;
|
|
48
|
+
private readonly logger;
|
|
49
|
+
constructor(auth: McpAuthService, registry: ToolRegistry, config: McpPluginConfig);
|
|
50
|
+
/** Handles one JSON-RPC exchange. */
|
|
51
|
+
handle(request: McpHttpRequest, response: ServerResponse): Promise<void>;
|
|
52
|
+
}
|
|
53
|
+
export {};
|
|
54
|
+
//# sourceMappingURL=mcp.controller.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp.controller.d.ts","sourceRoot":"","sources":["../../../src/lib/http/mcp.controller.ts"],"names":[],"mappings":"AAaA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AACjE,OAAO,EAAE,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAGtD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAC3D,OAAO,EAAE,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEpD,wDAAwD;AACxD,KAAK,cAAc,GAAG,eAAe,GAAG;IACpC,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CACnC,CAAC;AAEF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,qBAGa,aAAa;IAIlB,OAAO,CAAC,QAAQ,CAAC,IAAI;IACrB,OAAO,CAAC,QAAQ,CAAC,QAAQ;IACL,OAAO,CAAC,QAAQ,CAAC,MAAM;IAL/C,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAkC;gBAGpC,IAAI,EAAE,cAAc,EACpB,QAAQ,EAAE,YAAY,EACF,MAAM,EAAE,eAAe;IAGhE,qCAAqC;IAE/B,MAAM,CACD,OAAO,EAAE,cAAc,EACvB,QAAQ,EAAE,cAAc,GAChC,OAAO,CAAC,IAAI,CAAC;CAsFnB"}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var McpController_1;
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.McpController = void 0;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const common_1 = require("@nestjs/common");
|
|
7
|
+
const swagger_1 = require("@nestjs/swagger");
|
|
8
|
+
const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
|
|
9
|
+
const identity_server_1 = require("@orthacms/identity-server");
|
|
10
|
+
const tools_server_1 = require("@orthacms/tools-server");
|
|
11
|
+
const mcp_tokens_1 = require("../mcp.tokens");
|
|
12
|
+
const build_mcp_server_1 = require("../protocol/build-mcp-server");
|
|
13
|
+
const mcp_auth_service_1 = require("./mcp-auth.service");
|
|
14
|
+
/**
|
|
15
|
+
* `POST /api/v1/mcp` — the **Model Context Protocol endpoint**. An external
|
|
16
|
+
* agent (Claude Desktop, Cursor, an SDK-built client) connects here and gets
|
|
17
|
+
* the CMS's content tools.
|
|
18
|
+
*
|
|
19
|
+
* **Stateless.** Every request carries its own bearer token and is authenticated
|
|
20
|
+
* from scratch, so there is no session to store, nothing to expire, and nothing
|
|
21
|
+
* requiring sticky routing across replicas — the endpoint scales exactly like
|
|
22
|
+
* the rest of `/api/v1`. The transport and protocol server are per-request and
|
|
23
|
+
* torn down with it, which is also what lets the tool list reflect *this*
|
|
24
|
+
* caller's scope.
|
|
25
|
+
*
|
|
26
|
+
* `GET` (the server-initiated SSE stream) and `DELETE` (session teardown) are
|
|
27
|
+
* answered **here** with a 405. Both exist to serve a persistent session, and
|
|
28
|
+
* there isn't one, which is exactly the case the specification reserves 405
|
|
29
|
+
* for. `@All()` routes them to this controller so the answer comes from the
|
|
30
|
+
* protocol layer rather than Nest's generic 404, which a client cannot
|
|
31
|
+
* interpret.
|
|
32
|
+
*
|
|
33
|
+
* The transport is deliberately not asked. Handed a `GET` in stateless mode it
|
|
34
|
+
* opens a standalone SSE stream and holds it **forever** — nothing on this
|
|
35
|
+
* endpoint ever pushes a server-initiated message, so the client waits on a
|
|
36
|
+
* connection that will never carry anything while a socket, a transport and a
|
|
37
|
+
* protocol server stay pinned per attempt. Answering 405 turns the commonest
|
|
38
|
+
* first-connection mistake into a sentence a client can act on.
|
|
39
|
+
*
|
|
40
|
+
* `@Public()` opts out of the session `AuthGuard`, exactly as the `/api/v1`
|
|
41
|
+
* controllers do — {@link McpAuthService} is the whole authentication story,
|
|
42
|
+
* and a session cookie is not accepted.
|
|
43
|
+
*
|
|
44
|
+
* Excluded from the OpenAPI document: JSON-RPC over one route is not describable
|
|
45
|
+
* as REST operations, and a single `POST /v1/mcp` entry in the reference would
|
|
46
|
+
* tell a reader nothing about the tools. The `AGENTS.md` documents the surface.
|
|
47
|
+
*/
|
|
48
|
+
let McpController = McpController_1 = class McpController {
|
|
49
|
+
auth;
|
|
50
|
+
registry;
|
|
51
|
+
config;
|
|
52
|
+
logger = new common_1.Logger(McpController_1.name);
|
|
53
|
+
constructor(auth, registry, config) {
|
|
54
|
+
this.auth = auth;
|
|
55
|
+
this.registry = registry;
|
|
56
|
+
this.config = config;
|
|
57
|
+
}
|
|
58
|
+
/** Handles one JSON-RPC exchange. */
|
|
59
|
+
async handle(request, response) {
|
|
60
|
+
if (!this.config.enabled) {
|
|
61
|
+
// The controller is only registered when enabled, so this is a
|
|
62
|
+
// belt-and-braces guard against a future wiring change.
|
|
63
|
+
throw new common_1.ServiceUnavailableException('The MCP endpoint is disabled.');
|
|
64
|
+
}
|
|
65
|
+
// Authenticate BEFORE handing anything to the protocol layer: an
|
|
66
|
+
// unauthenticated caller must not be able to drive the JSON-RPC state
|
|
67
|
+
// machine at all, not even to `initialize`. A plain HTTP status is also
|
|
68
|
+
// the right answer here — a 401 is what tells an MCP client its
|
|
69
|
+
// credential is wrong, where a JSON-RPC error would read as a working
|
|
70
|
+
// connection returning a failure.
|
|
71
|
+
const context = await this.auth.authenticate(request.headers, workspaceQuery(request));
|
|
72
|
+
// After authentication, so a verb answer is never reachable without a
|
|
73
|
+
// credential — an unauthenticated probe learns 401 and nothing else.
|
|
74
|
+
if (request.method !== 'POST') {
|
|
75
|
+
methodNotAllowed(response);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const transport = new streamableHttp_js_1.StreamableHTTPServerTransport({
|
|
79
|
+
// Stateless: no session id is issued, and none is validated.
|
|
80
|
+
sessionIdGenerator: undefined,
|
|
81
|
+
// Answer with a single JSON body rather than opening an SSE stream.
|
|
82
|
+
// Nothing here streams — a tool call returns once — and a plain
|
|
83
|
+
// JSON response is what every client and every proxy handles best.
|
|
84
|
+
enableJsonResponse: true
|
|
85
|
+
});
|
|
86
|
+
const server = (0, build_mcp_server_1.buildMcpServer)(this.registry, context, { name: this.config.name, version: this.config.version }, {
|
|
87
|
+
callTimeoutMs: this.config.callTimeoutMs,
|
|
88
|
+
maxResultBytes: this.config.maxResultBytes
|
|
89
|
+
});
|
|
90
|
+
// Tear both down when the exchange ends, however it ends. Without this
|
|
91
|
+
// every request leaks a protocol server and its handler closures.
|
|
92
|
+
response.on('close', () => {
|
|
93
|
+
void transport.close();
|
|
94
|
+
void server.close();
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
await server.connect(transport);
|
|
98
|
+
// The body is already parsed by the host's express json middleware;
|
|
99
|
+
// handing it over avoids the transport re-reading a consumed stream.
|
|
100
|
+
await transport.handleRequest(request, response, request.body);
|
|
101
|
+
}
|
|
102
|
+
catch (error) {
|
|
103
|
+
this.logger.error(`MCP request failed for token ${context.actor.id}`, error instanceof Error ? error.stack : String(error));
|
|
104
|
+
if (!response.headersSent) {
|
|
105
|
+
response.writeHead(500, {
|
|
106
|
+
'content-type': 'application/json'
|
|
107
|
+
});
|
|
108
|
+
response.end(JSON.stringify({
|
|
109
|
+
jsonrpc: '2.0',
|
|
110
|
+
error: {
|
|
111
|
+
code: -32603,
|
|
112
|
+
message: 'Internal server error'
|
|
113
|
+
},
|
|
114
|
+
id: null
|
|
115
|
+
}));
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// The transport had already started writing, so there is no
|
|
119
|
+
// status left to set — but an unterminated response is a client
|
|
120
|
+
// waiting on a body that will never arrive until its socket
|
|
121
|
+
// times out. End it; a truncated answer is diagnosable and a
|
|
122
|
+
// hang is not.
|
|
123
|
+
response.end();
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
exports.McpController = McpController;
|
|
129
|
+
tslib_1.__decorate([
|
|
130
|
+
(0, common_1.All)(),
|
|
131
|
+
tslib_1.__param(0, (0, common_1.Req)()),
|
|
132
|
+
tslib_1.__param(1, (0, common_1.Res)()),
|
|
133
|
+
tslib_1.__metadata("design:type", Function),
|
|
134
|
+
tslib_1.__metadata("design:paramtypes", [Object, Function]),
|
|
135
|
+
tslib_1.__metadata("design:returntype", Promise)
|
|
136
|
+
], McpController.prototype, "handle", null);
|
|
137
|
+
exports.McpController = McpController = McpController_1 = tslib_1.__decorate([
|
|
138
|
+
(0, identity_server_1.Public)(),
|
|
139
|
+
(0, swagger_1.ApiExcludeController)(),
|
|
140
|
+
(0, common_1.Controller)('v1/mcp'),
|
|
141
|
+
tslib_1.__param(2, (0, common_1.Inject)(mcp_tokens_1.MCP_CONFIG)),
|
|
142
|
+
tslib_1.__metadata("design:paramtypes", [mcp_auth_service_1.McpAuthService,
|
|
143
|
+
tools_server_1.ToolRegistry, Object])
|
|
144
|
+
], McpController);
|
|
145
|
+
/** 405 for the verbs a stateless, non-streaming endpoint does not serve. */
|
|
146
|
+
function methodNotAllowed(response) {
|
|
147
|
+
response.writeHead(405, {
|
|
148
|
+
'content-type': 'application/json',
|
|
149
|
+
allow: 'POST'
|
|
150
|
+
});
|
|
151
|
+
response.end(JSON.stringify({
|
|
152
|
+
jsonrpc: '2.0',
|
|
153
|
+
error: {
|
|
154
|
+
code: -32000,
|
|
155
|
+
message: 'Method Not Allowed: this MCP endpoint is stateless and serves POST only. There is no server-initiated stream to open (GET) and no session to end (DELETE).'
|
|
156
|
+
},
|
|
157
|
+
id: null
|
|
158
|
+
}));
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* The optional `?workspaceId=` on the endpoint URL.
|
|
162
|
+
*
|
|
163
|
+
* Repeating it — `?workspaceId=a&workspaceId=b`, or the `?workspaceId[]=a`
|
|
164
|
+
* spelling — parses to an array, and treating that as "unnamed" made a
|
|
165
|
+
* single-workspace token quietly succeed against its own workspace while the
|
|
166
|
+
* caller had named two others. A request that names more than one workspace has
|
|
167
|
+
* no answer that is not a guess, so it is refused.
|
|
168
|
+
*/
|
|
169
|
+
function workspaceQuery(request) {
|
|
170
|
+
const raw = request.query?.['workspaceId'];
|
|
171
|
+
if (raw === undefined) {
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
if (typeof raw !== 'string') {
|
|
175
|
+
throw new common_1.BadRequestException('Repeat `?workspaceId=` names more than one workspace. Pass it exactly once, or use the x-workspace-id header.');
|
|
176
|
+
}
|
|
177
|
+
return raw;
|
|
178
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { DynamicModule } from '@nestjs/common';
|
|
2
|
+
import type { McpPluginConfig } from './types/mcp-config';
|
|
3
|
+
/**
|
|
4
|
+
* NestJS module for the MCP plugin. Registered globally, like every other
|
|
5
|
+
* plugin module here, so a capability plugin can inject {@link ToolRegistry}
|
|
6
|
+
* and contribute tools without an explicit import.
|
|
7
|
+
*
|
|
8
|
+
* **The registry is available even when the endpoint is disabled**, and the
|
|
9
|
+
* controller is the only thing the kill switch removes. Two reasons: the
|
|
10
|
+
* copilot's in-process tool loop consumes the same registry and has nothing to
|
|
11
|
+
* do with whether an *external* endpoint is exposed, and a contributing plugin
|
|
12
|
+
* should not have to care either way — it registers its tools unconditionally
|
|
13
|
+
* and the composition root decides who may reach them.
|
|
14
|
+
*/
|
|
15
|
+
export declare class McpModule {
|
|
16
|
+
/** Creates the global dynamic module around a validated config. */
|
|
17
|
+
static forRoot(config: McpPluginConfig): DynamicModule;
|
|
18
|
+
}
|
|
19
|
+
//# sourceMappingURL=mcp.module.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp.module.d.ts","sourceRoot":"","sources":["../../src/lib/mcp.module.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAU,MAAM,gBAAgB,CAAC;AAKvD,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAE1D;;;;;;;;;;;GAWG;AACH,qBACa,SAAS;IAClB,mEAAmE;IACnE,MAAM,CAAC,OAAO,CAAC,MAAM,EAAE,eAAe,GAAG,aAAa;CAgBzD"}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var McpModule_1;
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
exports.McpModule = void 0;
|
|
5
|
+
const tslib_1 = require("tslib");
|
|
6
|
+
const common_1 = require("@nestjs/common");
|
|
7
|
+
const tools_server_1 = require("@orthacms/tools-server");
|
|
8
|
+
const mcp_auth_service_1 = require("./http/mcp-auth.service");
|
|
9
|
+
const mcp_controller_1 = require("./http/mcp.controller");
|
|
10
|
+
const mcp_tokens_1 = require("./mcp.tokens");
|
|
11
|
+
/**
|
|
12
|
+
* NestJS module for the MCP plugin. Registered globally, like every other
|
|
13
|
+
* plugin module here, so a capability plugin can inject {@link ToolRegistry}
|
|
14
|
+
* and contribute tools without an explicit import.
|
|
15
|
+
*
|
|
16
|
+
* **The registry is available even when the endpoint is disabled**, and the
|
|
17
|
+
* controller is the only thing the kill switch removes. Two reasons: the
|
|
18
|
+
* copilot's in-process tool loop consumes the same registry and has nothing to
|
|
19
|
+
* do with whether an *external* endpoint is exposed, and a contributing plugin
|
|
20
|
+
* should not have to care either way — it registers its tools unconditionally
|
|
21
|
+
* and the composition root decides who may reach them.
|
|
22
|
+
*/
|
|
23
|
+
let McpModule = McpModule_1 = class McpModule {
|
|
24
|
+
/** Creates the global dynamic module around a validated config. */
|
|
25
|
+
static forRoot(config) {
|
|
26
|
+
return {
|
|
27
|
+
module: McpModule_1,
|
|
28
|
+
global: true,
|
|
29
|
+
// The registry is *imported*, not provided: it is shared with the
|
|
30
|
+
// copilot, and whichever consumer a deployment runs must see the
|
|
31
|
+
// same instance.
|
|
32
|
+
imports: [tools_server_1.ToolsModule],
|
|
33
|
+
controllers: config.enabled ? [mcp_controller_1.McpController] : [],
|
|
34
|
+
providers: [
|
|
35
|
+
{ provide: mcp_tokens_1.MCP_CONFIG, useValue: config },
|
|
36
|
+
mcp_auth_service_1.McpAuthService
|
|
37
|
+
],
|
|
38
|
+
exports: [mcp_tokens_1.MCP_CONFIG, tools_server_1.ToolsModule]
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
exports.McpModule = McpModule;
|
|
43
|
+
exports.McpModule = McpModule = McpModule_1 = tslib_1.__decorate([
|
|
44
|
+
(0, common_1.Module)({})
|
|
45
|
+
], McpModule);
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DI tokens and their inject decorators for the MCP plugin. Kept in a
|
|
3
|
+
* dependency-free module (imports only `@nestjs/common`) so providers can
|
|
4
|
+
* reference them without forming an import cycle with `mcp.module.ts` — the
|
|
5
|
+
* same arrangement as `copilot.tokens.ts`.
|
|
6
|
+
*/
|
|
7
|
+
/** Injection token for the resolved MCP configuration. */
|
|
8
|
+
export declare const MCP_CONFIG: unique symbol;
|
|
9
|
+
/** Parameter decorator that injects the MCP configuration. */
|
|
10
|
+
export declare const InjectMcpConfig: () => ParameterDecorator;
|
|
11
|
+
//# sourceMappingURL=mcp.tokens.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp.tokens.d.ts","sourceRoot":"","sources":["../../src/lib/mcp.tokens.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AAEH,0DAA0D;AAC1D,eAAO,MAAM,UAAU,eAAuB,CAAC;AAE/C,8DAA8D;AAC9D,eAAO,MAAM,eAAe,QAAO,kBAAwC,CAAC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.InjectMcpConfig = exports.MCP_CONFIG = void 0;
|
|
4
|
+
const common_1 = require("@nestjs/common");
|
|
5
|
+
/**
|
|
6
|
+
* DI tokens and their inject decorators for the MCP plugin. Kept in a
|
|
7
|
+
* dependency-free module (imports only `@nestjs/common`) so providers can
|
|
8
|
+
* reference them without forming an import cycle with `mcp.module.ts` — the
|
|
9
|
+
* same arrangement as `copilot.tokens.ts`.
|
|
10
|
+
*/
|
|
11
|
+
/** Injection token for the resolved MCP configuration. */
|
|
12
|
+
exports.MCP_CONFIG = Symbol('MCP_CONFIG');
|
|
13
|
+
/** Parameter decorator that injects the MCP configuration. */
|
|
14
|
+
const InjectMcpConfig = () => (0, common_1.Inject)(exports.MCP_CONFIG);
|
|
15
|
+
exports.InjectMcpConfig = InjectMcpConfig;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
2
|
+
import type { ToolRegistry } from '@orthacms/tools-server';
|
|
3
|
+
import type { ToolContext } from '@orthacms/tools-server';
|
|
4
|
+
/** Identity this server reports to clients during `initialize`. */
|
|
5
|
+
export interface McpServerInfo {
|
|
6
|
+
/** Server name shown in client UIs. */
|
|
7
|
+
name: string;
|
|
8
|
+
/** Server version. */
|
|
9
|
+
version: string;
|
|
10
|
+
}
|
|
11
|
+
/** The transport's own ceilings on one exchange. See {@link McpPluginConfig}. */
|
|
12
|
+
export interface McpServerLimits {
|
|
13
|
+
/** Ceiling on one `tools/call`, in milliseconds. */
|
|
14
|
+
callTimeoutMs: number;
|
|
15
|
+
/** Ceiling on the serialised size of one tool result, in bytes. */
|
|
16
|
+
maxResultBytes: number;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Build the MCP protocol server for **one** authenticated request.
|
|
20
|
+
*
|
|
21
|
+
* Per-request rather than one long-lived instance, because the tool list is a
|
|
22
|
+
* function of the caller: `tools/list` must show a `read`-scoped token a
|
|
23
|
+
* different set than a `full`-scoped one, and resources are pruned to the
|
|
24
|
+
* workspace's content grants. Binding the context into the handlers at
|
|
25
|
+
* construction is what makes that impossible to get wrong — there is no shared
|
|
26
|
+
* server whose handlers must remember to re-derive who is asking.
|
|
27
|
+
*
|
|
28
|
+
* The low-level `Server` is used rather than the SDK's `McpServer` helper
|
|
29
|
+
* deliberately: that helper takes Zod shapes, while every schema here is
|
|
30
|
+
* **generated JSON Schema** produced from the content registry at runtime.
|
|
31
|
+
* Converting generated JSON Schema into Zod purely to have the SDK convert it
|
|
32
|
+
* back is a lossy round-trip in service of nothing.
|
|
33
|
+
*
|
|
34
|
+
* ## Two ways a failure leaves this file, and why
|
|
35
|
+
*
|
|
36
|
+
* A `tools/call` failure rides out as an **`isError` result**; a `resources/*`
|
|
37
|
+
* failure rides out as a **JSON-RPC error**. That asymmetry is the protocol's,
|
|
38
|
+
* not an accident. A tool failure is an *outcome* the model is meant to read
|
|
39
|
+
* and act on ("title must be at most 200 characters"), so MCP models it as a
|
|
40
|
+
* successful call carrying `isError`. A resource read has no model in the loop
|
|
41
|
+
* — the client asked for bytes at a URI and either gets them or does not — so
|
|
42
|
+
* MCP models its failures as protocol errors, with `-32002` reserved for a URI
|
|
43
|
+
* that is not there.
|
|
44
|
+
*
|
|
45
|
+
* What both paths share is {@link toToolError}: whatever a handler throws is
|
|
46
|
+
* flattened the same way, so an unexpected error is opaque on either path and
|
|
47
|
+
* never puts a raw message on the wire.
|
|
48
|
+
*/
|
|
49
|
+
export declare function buildMcpServer(registry: ToolRegistry, context: ToolContext, info: McpServerInfo, limits: McpServerLimits): Server;
|
|
50
|
+
//# sourceMappingURL=build-mcp-server.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"build-mcp-server.d.ts","sourceRoot":"","sources":["../../../src/lib/protocol/build-mcp-server.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AASnE,OAAO,KAAK,EAAc,YAAY,EAAE,MAAM,wBAAwB,CAAC;AAEvE,OAAO,KAAK,EAAE,WAAW,EAAa,MAAM,wBAAwB,CAAC;AASrE,mEAAmE;AACnE,MAAM,WAAW,aAAa;IAC1B,uCAAuC;IACvC,IAAI,EAAE,MAAM,CAAC;IACb,sBAAsB;IACtB,OAAO,EAAE,MAAM,CAAC;CACnB;AAED,iFAAiF;AACjF,MAAM,WAAW,eAAe;IAC5B,oDAAoD;IACpD,aAAa,EAAE,MAAM,CAAC;IACtB,mEAAmE;IACnE,cAAc,EAAE,MAAM,CAAC;CAC1B;AAgBD;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,wBAAgB,cAAc,CAC1B,QAAQ,EAAE,YAAY,EACtB,OAAO,EAAE,WAAW,EACpB,IAAI,EAAE,aAAa,EACnB,MAAM,EAAE,eAAe,GACxB,MAAM,CAyGR"}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.buildMcpServer = buildMcpServer;
|
|
4
|
+
const index_js_1 = require("@modelcontextprotocol/sdk/server/index.js");
|
|
5
|
+
const types_js_1 = require("@modelcontextprotocol/sdk/types.js");
|
|
6
|
+
const tools_server_1 = require("@orthacms/tools-server");
|
|
7
|
+
/**
|
|
8
|
+
* MCP's own code for "the resource you named is not there". It is in the
|
|
9
|
+
* specification but not in the SDK's `ErrorCode` enum, so it is spelled out
|
|
10
|
+
* here rather than approximated with `InternalError`.
|
|
11
|
+
*/
|
|
12
|
+
const RESOURCE_NOT_FOUND = -32002;
|
|
13
|
+
/**
|
|
14
|
+
* Raised when {@link callWithinDeadline} stops waiting on a handler — because
|
|
15
|
+
* the deadline passed, or because the caller hung up.
|
|
16
|
+
*
|
|
17
|
+
* A class of its own so neither reaches {@link toToolError}, which would log it
|
|
18
|
+
* as an unhandled error with a stack. A client disconnecting is routine, and a
|
|
19
|
+
* deadline is this file's own decision; neither is a bug in a tool.
|
|
20
|
+
*/
|
|
21
|
+
class CallAbandoned extends Error {
|
|
22
|
+
reason;
|
|
23
|
+
constructor(reason) {
|
|
24
|
+
super(`The tool call was abandoned (${reason}).`);
|
|
25
|
+
this.reason = reason;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Build the MCP protocol server for **one** authenticated request.
|
|
30
|
+
*
|
|
31
|
+
* Per-request rather than one long-lived instance, because the tool list is a
|
|
32
|
+
* function of the caller: `tools/list` must show a `read`-scoped token a
|
|
33
|
+
* different set than a `full`-scoped one, and resources are pruned to the
|
|
34
|
+
* workspace's content grants. Binding the context into the handlers at
|
|
35
|
+
* construction is what makes that impossible to get wrong — there is no shared
|
|
36
|
+
* server whose handlers must remember to re-derive who is asking.
|
|
37
|
+
*
|
|
38
|
+
* The low-level `Server` is used rather than the SDK's `McpServer` helper
|
|
39
|
+
* deliberately: that helper takes Zod shapes, while every schema here is
|
|
40
|
+
* **generated JSON Schema** produced from the content registry at runtime.
|
|
41
|
+
* Converting generated JSON Schema into Zod purely to have the SDK convert it
|
|
42
|
+
* back is a lossy round-trip in service of nothing.
|
|
43
|
+
*
|
|
44
|
+
* ## Two ways a failure leaves this file, and why
|
|
45
|
+
*
|
|
46
|
+
* A `tools/call` failure rides out as an **`isError` result**; a `resources/*`
|
|
47
|
+
* failure rides out as a **JSON-RPC error**. That asymmetry is the protocol's,
|
|
48
|
+
* not an accident. A tool failure is an *outcome* the model is meant to read
|
|
49
|
+
* and act on ("title must be at most 200 characters"), so MCP models it as a
|
|
50
|
+
* successful call carrying `isError`. A resource read has no model in the loop
|
|
51
|
+
* — the client asked for bytes at a URI and either gets them or does not — so
|
|
52
|
+
* MCP models its failures as protocol errors, with `-32002` reserved for a URI
|
|
53
|
+
* that is not there.
|
|
54
|
+
*
|
|
55
|
+
* What both paths share is {@link toToolError}: whatever a handler throws is
|
|
56
|
+
* flattened the same way, so an unexpected error is opaque on either path and
|
|
57
|
+
* never puts a raw message on the wire.
|
|
58
|
+
*/
|
|
59
|
+
function buildMcpServer(registry, context, info, limits) {
|
|
60
|
+
const server = new index_js_1.Server(info, {
|
|
61
|
+
capabilities: { tools: {}, resources: {} }
|
|
62
|
+
});
|
|
63
|
+
server.setRequestHandler(types_js_1.ListToolsRequestSchema, () => ({
|
|
64
|
+
tools: registry.visibleTo(context, 'mcp').map((tool) => ({
|
|
65
|
+
name: tool.name,
|
|
66
|
+
title: tool.title,
|
|
67
|
+
description: tool.description,
|
|
68
|
+
inputSchema: tool.inputSchema,
|
|
69
|
+
annotations: {
|
|
70
|
+
title: tool.title,
|
|
71
|
+
readOnlyHint: tool.readOnly,
|
|
72
|
+
destructiveHint: tool.destructive ?? false
|
|
73
|
+
}
|
|
74
|
+
}))
|
|
75
|
+
}));
|
|
76
|
+
server.setRequestHandler(types_js_1.CallToolRequestSchema, async (request, extra) => {
|
|
77
|
+
const { name, arguments: args } = request.params;
|
|
78
|
+
let result;
|
|
79
|
+
try {
|
|
80
|
+
result = await callWithinDeadline(registry, name, args, context,
|
|
81
|
+
// The SDK aborts this when the client cancels the request or
|
|
82
|
+
// the exchange closes — the only cancellation signal this
|
|
83
|
+
// surface has, and previously dropped on the floor.
|
|
84
|
+
extra.signal, limits.callTimeoutMs);
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
// `isError` rather than a JSON-RPC error, and this is the whole
|
|
88
|
+
// point: a protocol error aborts the client's call, while an
|
|
89
|
+
// `isError` result is handed back to the *model*, which can read
|
|
90
|
+
// "title must be at most 200 characters" and fix its next call.
|
|
91
|
+
// Refusals land here too — a model that learns it lacks
|
|
92
|
+
// `content:publish` stops trying, instead of retrying blind.
|
|
93
|
+
if (error instanceof CallAbandoned) {
|
|
94
|
+
return errorResult(error.reason === 'timeout'
|
|
95
|
+
? {
|
|
96
|
+
status: 504,
|
|
97
|
+
code: 'timeout',
|
|
98
|
+
message: `"${name}" did not finish within ${limits.callTimeoutMs}ms and was abandoned. Retry, or narrow the request.`
|
|
99
|
+
}
|
|
100
|
+
: // Nobody is reading this: the exchange is already
|
|
101
|
+
// closed. It exists so the handler chain unwinds
|
|
102
|
+
// through one shape rather than two.
|
|
103
|
+
{
|
|
104
|
+
status: 499,
|
|
105
|
+
code: 'client_closed_request',
|
|
106
|
+
message: `"${name}" was abandoned because the caller disconnected.`
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
return errorResult((0, tools_server_1.toToolError)(error));
|
|
110
|
+
}
|
|
111
|
+
// Sized before either copy is handed to the transport, because the
|
|
112
|
+
// response costs roughly three times this on the way out: the text
|
|
113
|
+
// block, `structuredContent`, and the transport's own serialisation.
|
|
114
|
+
const text = stringify(result);
|
|
115
|
+
const bytes = Buffer.byteLength(text);
|
|
116
|
+
if (bytes > limits.maxResultBytes) {
|
|
117
|
+
return errorResult({
|
|
118
|
+
status: 413,
|
|
119
|
+
code: 'result_too_large',
|
|
120
|
+
message: `"${name}" returned ${bytes} bytes, over this endpoint's ${limits.maxResultBytes}-byte limit. Ask for less — a smaller page size, fewer fields, or a narrower filter.`
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
// Both spellings of the same value: `structuredContent` for
|
|
125
|
+
// clients that parse it, and the text block for models that
|
|
126
|
+
// only ever see `content`. Emitting one or the other would
|
|
127
|
+
// make the tool useless to half the ecosystem.
|
|
128
|
+
content: [{ type: 'text', text }],
|
|
129
|
+
structuredContent: asStructured(result)
|
|
130
|
+
};
|
|
131
|
+
});
|
|
132
|
+
server.setRequestHandler(types_js_1.ListResourcesRequestSchema, async () => {
|
|
133
|
+
try {
|
|
134
|
+
return { resources: [...(await registry.resources(context))] };
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
throw toMcpError(error);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
server.setRequestHandler(types_js_1.ReadResourceRequestSchema, async (request) => {
|
|
141
|
+
try {
|
|
142
|
+
const contents = await registry.readResource(request.params.uri, context);
|
|
143
|
+
return { contents: [contents] };
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
throw toMcpError(error);
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
return server;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Run one tool, giving up on it after `timeoutMs`.
|
|
153
|
+
*
|
|
154
|
+
* The handler is given a signal that fires for either reason — the deadline or
|
|
155
|
+
* the caller going away — so a tool doing real I/O can stop. A tool that
|
|
156
|
+
* ignores it keeps running: this bounds **the caller's wait**, which is the
|
|
157
|
+
* guarantee a request/response transport owes, and deliberately not the work,
|
|
158
|
+
* which nothing at this layer can end.
|
|
159
|
+
*/
|
|
160
|
+
async function callWithinDeadline(registry, name, args, context, callerSignal, timeoutMs) {
|
|
161
|
+
const controller = new AbortController();
|
|
162
|
+
const relay = () => controller.abort();
|
|
163
|
+
callerSignal?.addEventListener('abort', relay, { once: true });
|
|
164
|
+
let expired = false;
|
|
165
|
+
const timer = setTimeout(() => {
|
|
166
|
+
// Set before aborting, so the listener that fires as a consequence can
|
|
167
|
+
// tell a deadline from a caller who hung up.
|
|
168
|
+
expired = true;
|
|
169
|
+
controller.abort();
|
|
170
|
+
}, timeoutMs);
|
|
171
|
+
try {
|
|
172
|
+
const pending = registry.call(name, args, { ...context, signal: controller.signal }, 'mcp');
|
|
173
|
+
// The loser of the race still settles. Without this, a handler that
|
|
174
|
+
// fails *after* the deadline becomes an unhandled rejection and takes
|
|
175
|
+
// the process down — the exact failure mode this timeout exists to
|
|
176
|
+
// contain.
|
|
177
|
+
pending.catch(() => undefined);
|
|
178
|
+
return await Promise.race([
|
|
179
|
+
pending,
|
|
180
|
+
rejectWhenAborted(controller.signal, () => expired)
|
|
181
|
+
]);
|
|
182
|
+
}
|
|
183
|
+
finally {
|
|
184
|
+
clearTimeout(timer);
|
|
185
|
+
callerSignal?.removeEventListener('abort', relay);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/** A promise that never resolves and rejects once `signal` aborts. */
|
|
189
|
+
function rejectWhenAborted(signal, timedOut) {
|
|
190
|
+
return new Promise((_, reject) => {
|
|
191
|
+
const fail = () => reject(new CallAbandoned(timedOut() ? 'timeout' : 'disconnect'));
|
|
192
|
+
if (signal.aborted) {
|
|
193
|
+
fail();
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
signal.addEventListener('abort', fail, { once: true });
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
/** One tool failure, in the shape a model reads. */
|
|
200
|
+
function errorResult(failure) {
|
|
201
|
+
return {
|
|
202
|
+
isError: true,
|
|
203
|
+
content: [{ type: 'text', text: stringify(failure) }]
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Whatever a resource handler threw, as the JSON-RPC error the client gets.
|
|
208
|
+
*
|
|
209
|
+
* The flattened {@link ToolError} rides along as `data`, so a client reads the
|
|
210
|
+
* same `status` / `code` / `issues` it would from a tool — and an *unexpected*
|
|
211
|
+
* throw is reported opaquely, rather than putting a driver's message on the
|
|
212
|
+
* wire under a code that calls it an internal error.
|
|
213
|
+
*/
|
|
214
|
+
function toMcpError(error) {
|
|
215
|
+
const failure = (0, tools_server_1.toToolError)(error);
|
|
216
|
+
return new types_js_1.McpError(jsonRpcCodeFor(failure.status), failure.message, failure);
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* Status → JSON-RPC code. A refusal shares `-32002` with an absence on
|
|
220
|
+
* purpose: for *data* the answer is deliberately uniform (an ungranted content
|
|
221
|
+
* type reads exactly like one that does not exist), and `data.code` still says
|
|
222
|
+
* `forbidden` for a client that wants to tell them apart.
|
|
223
|
+
*/
|
|
224
|
+
function jsonRpcCodeFor(status) {
|
|
225
|
+
if (status === 403 || status === 404) {
|
|
226
|
+
return RESOURCE_NOT_FOUND;
|
|
227
|
+
}
|
|
228
|
+
if (status === 400 || status === 422) {
|
|
229
|
+
return types_js_1.ErrorCode.InvalidParams;
|
|
230
|
+
}
|
|
231
|
+
return types_js_1.ErrorCode.InternalError;
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* MCP's `structuredContent` must be a JSON **object**. Tool results are mostly
|
|
235
|
+
* objects already (a list envelope, an entry); anything else is boxed under
|
|
236
|
+
* `value` rather than dropped.
|
|
237
|
+
*/
|
|
238
|
+
function asStructured(result) {
|
|
239
|
+
return typeof result === 'object' &&
|
|
240
|
+
result !== null &&
|
|
241
|
+
!Array.isArray(result)
|
|
242
|
+
? result
|
|
243
|
+
: { value: result };
|
|
244
|
+
}
|
|
245
|
+
/** Pretty-printed JSON — the text rendering a model actually reads. */
|
|
246
|
+
function stringify(value) {
|
|
247
|
+
return JSON.stringify(value, null, 2) ?? 'null';
|
|
248
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/** Runtime configuration for the MCP plugin. */
|
|
2
|
+
export interface McpPluginConfig {
|
|
3
|
+
/**
|
|
4
|
+
* Whether the MCP endpoint is mounted at all.
|
|
5
|
+
*
|
|
6
|
+
* **Off by default.** The endpoint hands an external agent the same content
|
|
7
|
+
* CRUD a `full`-scope token has, and an operator who has not thought about
|
|
8
|
+
* that should not have it exposed because they upgraded. The same reasoning
|
|
9
|
+
* — and the same default — as the copilot's kill switch (ADR-0005 §10).
|
|
10
|
+
*/
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
/** Server name reported to MCP clients during `initialize`. */
|
|
13
|
+
name: string;
|
|
14
|
+
/** Server version reported to MCP clients. */
|
|
15
|
+
version: string;
|
|
16
|
+
/**
|
|
17
|
+
* Ceiling on **one** `tools/call`, in milliseconds.
|
|
18
|
+
*
|
|
19
|
+
* The registry deliberately has no deadline of its own — `ToolContext.signal`
|
|
20
|
+
* is documented as "a courtesy, never a correctness boundary" — so without
|
|
21
|
+
* this the only thing bounding a tool call is the query underneath it. When
|
|
22
|
+
* that blocks (a pool with no free connection, a database that went away),
|
|
23
|
+
* an MCP request hangs until the *client* gives up, holding a socket, a
|
|
24
|
+
* transport and a protocol server the whole time.
|
|
25
|
+
*
|
|
26
|
+
* The bound lives here rather than in the registry because it is a property
|
|
27
|
+
* of the transport: a request/response exchange owes its caller an answer,
|
|
28
|
+
* where the copilot's in-process loop has its own wall clock. Expiry is
|
|
29
|
+
* reported as a model-readable `isError` result (`504` / `timeout`), so an
|
|
30
|
+
* agent retries or narrows instead of stalling.
|
|
31
|
+
*
|
|
32
|
+
* **It abandons, it does not cancel.** The signal is passed to the handler,
|
|
33
|
+
* but a tool that ignores it keeps running to completion with nobody
|
|
34
|
+
* reading the answer. Bounding the *caller's* wait is the guarantee; ending
|
|
35
|
+
* the work is not one this layer can make.
|
|
36
|
+
*/
|
|
37
|
+
callTimeoutMs: number;
|
|
38
|
+
/**
|
|
39
|
+
* Ceiling on the serialised size of one tool result, in bytes.
|
|
40
|
+
*
|
|
41
|
+
* A result is emitted **twice** — pretty-printed as the text block a model
|
|
42
|
+
* reads, and again as `structuredContent` for clients that parse it — and
|
|
43
|
+
* the transport then serialises the whole response a third time. Peak
|
|
44
|
+
* memory is therefore a multiple of the payload, per concurrent request,
|
|
45
|
+
* and nothing in the tool catalogue caps how much a handler may return.
|
|
46
|
+
*
|
|
47
|
+
* Over the ceiling the call answers `413` / `result_too_large` naming the
|
|
48
|
+
* two sizes, which is strictly more useful to a model than the payload
|
|
49
|
+
* would have been: a result that does not fit here does not fit in its
|
|
50
|
+
* context window either, and the message tells it to narrow the query.
|
|
51
|
+
*/
|
|
52
|
+
maxResultBytes: number;
|
|
53
|
+
}
|
|
54
|
+
//# sourceMappingURL=mcp-config.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-config.d.ts","sourceRoot":"","sources":["../../../src/lib/types/mcp-config.ts"],"names":[],"mappings":"AAAA,gDAAgD;AAChD,MAAM,WAAW,eAAe;IAC5B;;;;;;;OAOG;IACH,OAAO,EAAE,OAAO,CAAC;IACjB,+DAA+D;IAC/D,IAAI,EAAE,MAAM,CAAC;IACb,8CAA8C;IAC9C,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;;;;;;;;;;;;;;OAoBG;IACH,aAAa,EAAE,MAAM,CAAC;IACtB;;;;;;;;;;;;;OAaG;IACH,cAAc,EAAE,MAAM,CAAC;CAC1B"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ServerPlugin } from '@orthacms/bootstrap-server';
|
|
2
|
+
import type { McpPluginConfig } from '../types/mcp-config';
|
|
3
|
+
/** The MCP plugin shape, with its config attached. */
|
|
4
|
+
export type McpServerPluginDefinition = ServerPlugin & {
|
|
5
|
+
mcpConfig: McpPluginConfig;
|
|
6
|
+
};
|
|
7
|
+
/** Options the host passes to {@link McpPlugin}. */
|
|
8
|
+
export interface McpPluginOptions {
|
|
9
|
+
/** Host config — kill switch plus the identity reported to clients. */
|
|
10
|
+
config: McpPluginConfig;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Creates the MCP plugin — the Model Context Protocol front door onto the CMS.
|
|
14
|
+
*
|
|
15
|
+
* Register it **after** `IdentityPlugin` (bearer tokens are verified through
|
|
16
|
+
* its `ApiTokenService`) and after every plugin that contributes tools, so the
|
|
17
|
+
* intent reads top-to-bottom. DI itself is order-independent: every plugin
|
|
18
|
+
* module is global, and contributors register into {@link ToolRegistry} during
|
|
19
|
+
* `onModuleInit`, which Nest runs once the whole graph is built.
|
|
20
|
+
*
|
|
21
|
+
* The plugin owns **no tables**, so it declares no migrations, and it owns no
|
|
22
|
+
* tools — `content/server` contributes those. Adding another capability's tools
|
|
23
|
+
* is a `ToolProvider` in that plugin plus nothing at all here.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```typescript
|
|
27
|
+
* McpPlugin({ config: config.plugins.mcp });
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
export declare function McpPlugin(options: McpPluginOptions): McpServerPluginDefinition;
|
|
31
|
+
//# sourceMappingURL=mcp-plugin.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"mcp-plugin.d.ts","sourceRoot":"","sources":["../../../src/lib/utils/mcp-plugin.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,4BAA4B,CAAC;AAE/D,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAE3D,sDAAsD;AACtD,MAAM,MAAM,yBAAyB,GAAG,YAAY,GAAG;IACnD,SAAS,EAAE,eAAe,CAAC;CAC9B,CAAC;AAEF,oDAAoD;AACpD,MAAM,WAAW,gBAAgB;IAC7B,uEAAuE;IACvE,MAAM,EAAE,eAAe,CAAC;CAC3B;AAqCD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,SAAS,CACrB,OAAO,EAAE,gBAAgB,GAC1B,yBAAyB,CAO3B"}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.McpPlugin = McpPlugin;
|
|
4
|
+
const mcp_module_1 = require("../mcp.module");
|
|
5
|
+
/**
|
|
6
|
+
* Validate the wiring **eagerly**, like every other plugin factory here: a
|
|
7
|
+
* blank server name or version is a misconfiguration that should fail at
|
|
8
|
+
* construction rather than surface as a malformed `initialize` response, where
|
|
9
|
+
* it is far more expensive to diagnose.
|
|
10
|
+
*/
|
|
11
|
+
function assertOptions(options) {
|
|
12
|
+
if (!options.config.name) {
|
|
13
|
+
throw new Error('McpPlugin requires a non-empty `config.name` — it is the server identity MCP clients display.');
|
|
14
|
+
}
|
|
15
|
+
if (!options.config.version) {
|
|
16
|
+
throw new Error('McpPlugin requires a non-empty `config.version`.');
|
|
17
|
+
}
|
|
18
|
+
assertPositiveInteger(options.config.callTimeoutMs, 'callTimeoutMs');
|
|
19
|
+
assertPositiveInteger(options.config.maxResultBytes, 'maxResultBytes');
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* A ceiling that is `0`, negative or `NaN` is worse than no ceiling: a zero
|
|
23
|
+
* timeout fails every call, and `Number(process.env[…]) || default` turns a
|
|
24
|
+
* typo into a silent default. Fail at construction, where the misconfiguration
|
|
25
|
+
* is one line away.
|
|
26
|
+
*/
|
|
27
|
+
function assertPositiveInteger(value, field) {
|
|
28
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
29
|
+
throw new Error(`McpPlugin requires \`config.${field}\` to be a positive integer, got ${String(value)}.`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Creates the MCP plugin — the Model Context Protocol front door onto the CMS.
|
|
34
|
+
*
|
|
35
|
+
* Register it **after** `IdentityPlugin` (bearer tokens are verified through
|
|
36
|
+
* its `ApiTokenService`) and after every plugin that contributes tools, so the
|
|
37
|
+
* intent reads top-to-bottom. DI itself is order-independent: every plugin
|
|
38
|
+
* module is global, and contributors register into {@link ToolRegistry} during
|
|
39
|
+
* `onModuleInit`, which Nest runs once the whole graph is built.
|
|
40
|
+
*
|
|
41
|
+
* The plugin owns **no tables**, so it declares no migrations, and it owns no
|
|
42
|
+
* tools — `content/server` contributes those. Adding another capability's tools
|
|
43
|
+
* is a `ToolProvider` in that plugin plus nothing at all here.
|
|
44
|
+
*
|
|
45
|
+
* @example
|
|
46
|
+
* ```typescript
|
|
47
|
+
* McpPlugin({ config: config.plugins.mcp });
|
|
48
|
+
* ```
|
|
49
|
+
*/
|
|
50
|
+
function McpPlugin(options) {
|
|
51
|
+
assertOptions(options);
|
|
52
|
+
return {
|
|
53
|
+
name: 'mcp',
|
|
54
|
+
module: mcp_module_1.McpModule.forRoot(options.config),
|
|
55
|
+
mcpConfig: options.config
|
|
56
|
+
};
|
|
57
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@orthacms/mcp-server",
|
|
3
|
+
"version": "0.0.0-reserve.0",
|
|
4
|
+
"description": "@orthacms/mcp-server — part of Ortha CMS.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"homepage": "https://github.com/ortha-source/ortha-cms/tree/main/packages/mcp/server",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/ortha-source/ortha-cms.git",
|
|
10
|
+
"directory": "packages/mcp/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
|
+
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
29
|
+
"@nestjs/common": "^11.0.0",
|
|
30
|
+
"@nestjs/swagger": "^11.4.6",
|
|
31
|
+
"@orthacms/bootstrap-server": "^0.0.1",
|
|
32
|
+
"@orthacms/identity-server": "^0.0.1",
|
|
33
|
+
"@orthacms/tools-server": "^0.0.1",
|
|
34
|
+
"@orthacms/workspaces-server": "^0.0.1",
|
|
35
|
+
"tslib": "^2.3.0"
|
|
36
|
+
},
|
|
37
|
+
"publishConfig": {
|
|
38
|
+
"access": "public"
|
|
39
|
+
}
|
|
40
|
+
}
|