@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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ortha CMS contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,7 @@
1
+ # @orthacms/mcp-server
2
+
3
+ Part of [Ortha CMS](https://github.com/ortha-source/ortha-cms).
4
+
5
+ ```sh
6
+ npm install @orthacms/mcp-server
7
+ ```
@@ -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,2 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -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
+ }