@telorun/mcp-server 0.2.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/CHANGELOG.md +21 -0
- package/LICENSE +17 -0
- package/README.md +94 -0
- package/dist/http-endpoint-controller.d.ts +36 -0
- package/dist/http-endpoint-controller.js +162 -0
- package/dist/outcome.d.ts +45 -0
- package/dist/outcome.js +22 -0
- package/dist/prompts-controller.d.ts +18 -0
- package/dist/prompts-controller.js +22 -0
- package/dist/registry.d.ts +28 -0
- package/dist/registry.js +84 -0
- package/dist/resources-controller.d.ts +20 -0
- package/dist/resources-controller.js +24 -0
- package/dist/stdio-server-controller.d.ts +26 -0
- package/dist/stdio-server-controller.js +89 -0
- package/dist/tools-controller.d.ts +43 -0
- package/dist/tools-controller.js +99 -0
- package/package.json +66 -0
- package/src/http-endpoint-controller.ts +204 -0
- package/src/outcome.ts +69 -0
- package/src/prompts-controller.ts +38 -0
- package/src/registry.ts +146 -0
- package/src/resources-controller.ts +40 -0
- package/src/stdio-server-controller.ts +125 -0
- package/src/tools-controller.ts +151 -0
- package/tsconfig.json +14 -0
- package/tsconfig.lib.json +9 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
|
+
import { RuntimeError } from "@telorun/sdk";
|
|
4
|
+
import { buildServer } from "./registry.js";
|
|
5
|
+
export async function register(_ctx) { }
|
|
6
|
+
export class McpStdioServer {
|
|
7
|
+
resource;
|
|
8
|
+
ctx;
|
|
9
|
+
server = null;
|
|
10
|
+
transport = null;
|
|
11
|
+
releaseHold = null;
|
|
12
|
+
session;
|
|
13
|
+
constructor(resource, ctx) {
|
|
14
|
+
this.resource = resource;
|
|
15
|
+
this.ctx = ctx;
|
|
16
|
+
// stdio has no transport-level session id; mint a stable synthetic UUID at
|
|
17
|
+
// construction so request.session.id is always defined for CEL inputs.
|
|
18
|
+
this.session = { id: randomUUID(), clientInfo: {}, capabilities: {} };
|
|
19
|
+
}
|
|
20
|
+
async init() {
|
|
21
|
+
if ((this.resource.resources ?? []).length > 0 || (this.resource.prompts ?? []).length > 0) {
|
|
22
|
+
throw new RuntimeError("ERR_MCP_V2_NOT_IMPLEMENTED", `Mcp.StdioServer[${this.resource.metadata.name}]: resources/prompts are schema-only in v1; runtime dispatch is v2 work`);
|
|
23
|
+
}
|
|
24
|
+
const toolsBundles = (this.resource.tools ?? []).map((bundleName) => {
|
|
25
|
+
const inst = this.ctx.moduleContext.getInstance(bundleName);
|
|
26
|
+
if (!inst) {
|
|
27
|
+
throw new RuntimeError("ERR_MCP_BUNDLE_NOT_FOUND", `Mcp.StdioServer[${this.resource.metadata.name}]: tools bundle '${bundleName}' not found in module scope`);
|
|
28
|
+
}
|
|
29
|
+
return inst;
|
|
30
|
+
});
|
|
31
|
+
this.server = buildServer({
|
|
32
|
+
serverInfo: this.resource.serverInfo,
|
|
33
|
+
toolsBundles,
|
|
34
|
+
sessionResolver: () => this.session,
|
|
35
|
+
ctx: this.ctx,
|
|
36
|
+
moduleContext: this.ctx.moduleContext,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
async run() {
|
|
40
|
+
if (!this.server) {
|
|
41
|
+
throw new Error("Mcp.StdioServer.run() called before init()");
|
|
42
|
+
}
|
|
43
|
+
this.releaseHold = this.ctx.acquireHold();
|
|
44
|
+
try {
|
|
45
|
+
// ResourceContext types stdin/stdout as the structural NodeJS.ReadableStream
|
|
46
|
+
// / NodeJS.WritableStream interfaces, while the MCP SDK accepts the
|
|
47
|
+
// concrete node:stream `Readable` / `Writable` classes. process.stdin
|
|
48
|
+
// and process.stdout satisfy both shapes; a single cast is enough here.
|
|
49
|
+
this.transport = new StdioServerTransport(this.ctx.stdin, this.ctx.stdout);
|
|
50
|
+
// The transport's `onclose` fires when stdin reaches EOF (the parent
|
|
51
|
+
// closed the pipe). Releasing the hold then lets the kernel exit.
|
|
52
|
+
this.transport.onclose = () => {
|
|
53
|
+
if (this.releaseHold) {
|
|
54
|
+
this.releaseHold();
|
|
55
|
+
this.releaseHold = null;
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
await this.server.connect(this.transport);
|
|
59
|
+
await this.ctx.emitEvent(`${this.resource.metadata.name}.Listening`, {
|
|
60
|
+
transport: "stdio",
|
|
61
|
+
sessionId: this.session.id,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (this.releaseHold) {
|
|
66
|
+
this.releaseHold();
|
|
67
|
+
this.releaseHold = null;
|
|
68
|
+
}
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
async teardown() {
|
|
73
|
+
if (this.transport) {
|
|
74
|
+
await this.transport.close();
|
|
75
|
+
this.transport = null;
|
|
76
|
+
}
|
|
77
|
+
if (this.server) {
|
|
78
|
+
await this.server.close();
|
|
79
|
+
this.server = null;
|
|
80
|
+
}
|
|
81
|
+
if (this.releaseHold) {
|
|
82
|
+
this.releaseHold();
|
|
83
|
+
this.releaseHold = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export async function create(resource, ctx) {
|
|
88
|
+
return new McpStdioServer(resource, ctx);
|
|
89
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { ControllerContext, ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { CatchEntry, ResolvedToolEntry } from "./outcome.js";
|
|
3
|
+
/** Manifest shape pre-Phase-5. The handler field is still {kind, name} (or a
|
|
4
|
+
* bare name string) at create() time; Phase 5 swaps it for a live
|
|
5
|
+
* ResourceInstance before consumers (transports) read .entries. */
|
|
6
|
+
interface RawToolEntry {
|
|
7
|
+
name?: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
argumentsSchema?: Record<string, unknown>;
|
|
10
|
+
handler?: unknown;
|
|
11
|
+
inputs?: Record<string, unknown>;
|
|
12
|
+
result?: Record<string, unknown>;
|
|
13
|
+
catches?: CatchEntry[];
|
|
14
|
+
}
|
|
15
|
+
interface ToolsManifest {
|
|
16
|
+
metadata?: {
|
|
17
|
+
name?: string;
|
|
18
|
+
module?: string;
|
|
19
|
+
};
|
|
20
|
+
entries?: RawToolEntry[];
|
|
21
|
+
}
|
|
22
|
+
interface CapturedRef {
|
|
23
|
+
kind: string;
|
|
24
|
+
name: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function register(_ctx: ControllerContext): Promise<void>;
|
|
27
|
+
/** Passive bundle exposed via ctx.moduleContext.getInstance(name). The
|
|
28
|
+
* transport (StdioServer / HttpEndpoint) reads `.entries` after Phase 5
|
|
29
|
+
* injection — handler refs in each entry are then live ResourceInstances. */
|
|
30
|
+
export declare class McpToolsBundle {
|
|
31
|
+
readonly bundleName: string;
|
|
32
|
+
private readonly raw;
|
|
33
|
+
private readonly captured;
|
|
34
|
+
private readonly ctx;
|
|
35
|
+
constructor(bundleName: string, raw: RawToolEntry[], captured: Map<RawToolEntry, CapturedRef>, ctx: ResourceContext);
|
|
36
|
+
/** Resolve the raw entries into ResolvedToolEntry records suitable for
|
|
37
|
+
* registry consumption. Called from a transport's init() — at that point
|
|
38
|
+
* Phase 5 has injected live handler instances over the captured object refs.
|
|
39
|
+
* String-form refs are resolved here via moduleContext.getInstance(). */
|
|
40
|
+
resolveEntries(): ResolvedToolEntry[];
|
|
41
|
+
}
|
|
42
|
+
export declare function create(resource: ToolsManifest, ctx: ResourceContext): Promise<McpToolsBundle>;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { RuntimeError } from "@telorun/sdk";
|
|
2
|
+
export async function register(_ctx) { }
|
|
3
|
+
/** Passive bundle exposed via ctx.moduleContext.getInstance(name). The
|
|
4
|
+
* transport (StdioServer / HttpEndpoint) reads `.entries` after Phase 5
|
|
5
|
+
* injection — handler refs in each entry are then live ResourceInstances. */
|
|
6
|
+
export class McpToolsBundle {
|
|
7
|
+
bundleName;
|
|
8
|
+
raw;
|
|
9
|
+
captured;
|
|
10
|
+
ctx;
|
|
11
|
+
constructor(bundleName, raw, captured, ctx) {
|
|
12
|
+
this.bundleName = bundleName;
|
|
13
|
+
this.raw = raw;
|
|
14
|
+
this.captured = captured;
|
|
15
|
+
this.ctx = ctx;
|
|
16
|
+
}
|
|
17
|
+
/** Resolve the raw entries into ResolvedToolEntry records suitable for
|
|
18
|
+
* registry consumption. Called from a transport's init() — at that point
|
|
19
|
+
* Phase 5 has injected live handler instances over the captured object refs.
|
|
20
|
+
* String-form refs are resolved here via moduleContext.getInstance(). */
|
|
21
|
+
resolveEntries() {
|
|
22
|
+
const seen = new Set();
|
|
23
|
+
const resolved = [];
|
|
24
|
+
for (const raw of this.raw) {
|
|
25
|
+
const name = raw.name;
|
|
26
|
+
if (!name) {
|
|
27
|
+
throw new RuntimeError("ERR_MCP_TOOLS_INVALID", `Mcp.Tools[${this.bundleName}]: entry is missing 'name'`);
|
|
28
|
+
}
|
|
29
|
+
if (seen.has(name)) {
|
|
30
|
+
throw new RuntimeError("ERR_MCP_TOOLS_DUPLICATE", `Mcp.Tools[${this.bundleName}]: duplicate tool name '${name}' within the same bundle`);
|
|
31
|
+
}
|
|
32
|
+
seen.add(name);
|
|
33
|
+
const ref = this.captured.get(raw);
|
|
34
|
+
if (!ref) {
|
|
35
|
+
throw new RuntimeError("ERR_MCP_TOOLS_NO_HANDLER", `Mcp.Tools[${this.bundleName}]: tool '${name}' has no handler reference`);
|
|
36
|
+
}
|
|
37
|
+
let handlerInstance;
|
|
38
|
+
if (typeof raw.handler === "string") {
|
|
39
|
+
// moduleContext.getInstance(name) throws a plain Error when the
|
|
40
|
+
// resource is missing. Wrap it so misconfigured string refs surface
|
|
41
|
+
// as the MCP-specific ERR_MCP_TOOLS_HANDLER_UNRESOLVED with the
|
|
42
|
+
// bundle/tool location included.
|
|
43
|
+
try {
|
|
44
|
+
handlerInstance = this.ctx.moduleContext.getInstance(ref.name);
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
throw new RuntimeError("ERR_MCP_TOOLS_HANDLER_UNRESOLVED", `Mcp.Tools[${this.bundleName}]: tool '${name}' handler '${ref.name}' not found: ${err instanceof Error ? err.message : String(err)}`);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
else if (raw.handler && typeof raw.handler === "object") {
|
|
51
|
+
// Phase 5 injection has replaced the {kind, name} ref with the live
|
|
52
|
+
// ResourceInstance — unless the referenced resource was missing, in
|
|
53
|
+
// which case Phase 5 leaves the {kind, name} object in place. Detect
|
|
54
|
+
// that case via the absence of `invoke` below.
|
|
55
|
+
handlerInstance = raw.handler;
|
|
56
|
+
}
|
|
57
|
+
if (!handlerInstance || typeof handlerInstance.invoke !== "function") {
|
|
58
|
+
throw new RuntimeError("ERR_MCP_TOOLS_HANDLER_UNRESOLVED", `Mcp.Tools[${this.bundleName}]: tool '${name}' handler '${ref.kind || "?"}.${ref.name}' did not resolve to a live Invocable — Phase 5 injection may have failed`);
|
|
59
|
+
}
|
|
60
|
+
resolved.push({
|
|
61
|
+
name,
|
|
62
|
+
description: raw.description,
|
|
63
|
+
argumentsSchema: (raw.argumentsSchema ?? {}),
|
|
64
|
+
inputs: (raw.inputs ?? {}),
|
|
65
|
+
result: (raw.result ?? {}),
|
|
66
|
+
catches: raw.catches,
|
|
67
|
+
handlerKind: ref.kind,
|
|
68
|
+
handlerName: ref.name,
|
|
69
|
+
handler: handlerInstance,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return resolved;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export async function create(resource, ctx) {
|
|
76
|
+
const bundleName = resource.metadata?.name;
|
|
77
|
+
if (!bundleName) {
|
|
78
|
+
throw new RuntimeError("ERR_MCP_TOOLS_INVALID", "Mcp.Tools: metadata.name is required");
|
|
79
|
+
}
|
|
80
|
+
const entries = resource.entries ?? [];
|
|
81
|
+
const captured = new Map();
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
const handler = entry.handler;
|
|
84
|
+
if (!handler) {
|
|
85
|
+
throw new RuntimeError("ERR_MCP_TOOLS_INVALID", `Mcp.Tools[${bundleName}]: tool '${entry.name ?? "<unnamed>"}' missing handler`);
|
|
86
|
+
}
|
|
87
|
+
if (typeof handler === "object") {
|
|
88
|
+
captured.set(entry, ctx.resolveChildren(handler));
|
|
89
|
+
}
|
|
90
|
+
else if (typeof handler === "string") {
|
|
91
|
+
// Bare name reference (oneOf: string). Phase 5 injection only replaces
|
|
92
|
+
// {kind, name} object refs — string refs survive as the resource name.
|
|
93
|
+
// We capture the name here so the dispatcher can still look up the
|
|
94
|
+
// instance via moduleContext.getInstance() at invoke time.
|
|
95
|
+
captured.set(entry, { kind: "", name: handler });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return new McpToolsBundle(bundleName, entries, captured, ctx);
|
|
99
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@telorun/mcp-server",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Telo MCP Server module — Model Context Protocol server resource kinds for Telo manifests.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"telo",
|
|
7
|
+
"mcp",
|
|
8
|
+
"model-context-protocol",
|
|
9
|
+
"server",
|
|
10
|
+
"tools",
|
|
11
|
+
"stdio"
|
|
12
|
+
],
|
|
13
|
+
"author": "Bartosz Pasiński <bartosz.pasinski@codenet.pl>",
|
|
14
|
+
"license": "SEE LICENSE IN LICENSE",
|
|
15
|
+
"repository": {
|
|
16
|
+
"type": "git",
|
|
17
|
+
"url": "git+https://github.com/telorun/telo.git",
|
|
18
|
+
"directory": "modules/mcp-server/nodejs"
|
|
19
|
+
},
|
|
20
|
+
"homepage": "https://github.com/telorun/telo#readme",
|
|
21
|
+
"bugs": {
|
|
22
|
+
"url": "https://github.com/telorun/telo/issues"
|
|
23
|
+
},
|
|
24
|
+
"type": "module",
|
|
25
|
+
"main": "./dist/index.js",
|
|
26
|
+
"module": "./dist/index.js",
|
|
27
|
+
"exports": {
|
|
28
|
+
"./stdio-server": {
|
|
29
|
+
"types": "./dist/stdio-server-controller.d.ts",
|
|
30
|
+
"bun": "./src/stdio-server-controller.ts",
|
|
31
|
+
"import": "./dist/stdio-server-controller.js"
|
|
32
|
+
},
|
|
33
|
+
"./http-endpoint": {
|
|
34
|
+
"types": "./dist/http-endpoint-controller.d.ts",
|
|
35
|
+
"bun": "./src/http-endpoint-controller.ts",
|
|
36
|
+
"import": "./dist/http-endpoint-controller.js"
|
|
37
|
+
},
|
|
38
|
+
"./tools": {
|
|
39
|
+
"types": "./dist/tools-controller.d.ts",
|
|
40
|
+
"bun": "./src/tools-controller.ts",
|
|
41
|
+
"import": "./dist/tools-controller.js"
|
|
42
|
+
},
|
|
43
|
+
"./resources": {
|
|
44
|
+
"types": "./dist/resources-controller.d.ts",
|
|
45
|
+
"bun": "./src/resources-controller.ts",
|
|
46
|
+
"import": "./dist/resources-controller.js"
|
|
47
|
+
},
|
|
48
|
+
"./prompts": {
|
|
49
|
+
"types": "./dist/prompts-controller.d.ts",
|
|
50
|
+
"bun": "./src/prompts-controller.ts",
|
|
51
|
+
"import": "./dist/prompts-controller.js"
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
"dependencies": {
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
56
|
+
"@telorun/sdk": "0.7.0"
|
|
57
|
+
},
|
|
58
|
+
"devDependencies": {
|
|
59
|
+
"@types/node": "^20.0.0",
|
|
60
|
+
"fastify": "^5.7.2",
|
|
61
|
+
"typescript": "^5.0.0"
|
|
62
|
+
},
|
|
63
|
+
"scripts": {
|
|
64
|
+
"build": "tsc -p tsconfig.lib.json"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
5
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
+
import { type ControllerContext, type ResourceContext, RuntimeError } from "@telorun/sdk";
|
|
7
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
|
|
8
|
+
|
|
9
|
+
import { buildServer, type SessionContext, type ServerInfo } from "./registry.js";
|
|
10
|
+
import type { McpToolsBundle } from "./tools-controller.js";
|
|
11
|
+
|
|
12
|
+
interface HttpEndpointManifest {
|
|
13
|
+
metadata: { name: string };
|
|
14
|
+
serverInfo: ServerInfo;
|
|
15
|
+
tools?: string[];
|
|
16
|
+
resources?: string[];
|
|
17
|
+
prompts?: string[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface SessionRecord {
|
|
21
|
+
server: Server;
|
|
22
|
+
transport: StreamableHTTPServerTransport;
|
|
23
|
+
context: SessionContext;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function register(_ctx: ControllerContext): Promise<void> {}
|
|
27
|
+
|
|
28
|
+
export class McpHttpEndpoint {
|
|
29
|
+
private readonly sessions = new Map<string, SessionRecord>();
|
|
30
|
+
private toolsBundles: McpToolsBundle[] = [];
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly resource: HttpEndpointManifest,
|
|
34
|
+
private readonly ctx: ResourceContext,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
async init() {
|
|
38
|
+
if ((this.resource.resources ?? []).length > 0 || (this.resource.prompts ?? []).length > 0) {
|
|
39
|
+
throw new RuntimeError(
|
|
40
|
+
"ERR_MCP_V2_NOT_IMPLEMENTED",
|
|
41
|
+
`Mcp.HttpEndpoint[${this.resource.metadata.name}]: resources/prompts are schema-only in v1; runtime dispatch is v2 work`,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
this.toolsBundles = (this.resource.tools ?? []).map((bundleName) => {
|
|
45
|
+
const inst = this.ctx.moduleContext.getInstance(bundleName) as McpToolsBundle | undefined;
|
|
46
|
+
if (!inst) {
|
|
47
|
+
throw new RuntimeError(
|
|
48
|
+
"ERR_MCP_BUNDLE_NOT_FOUND",
|
|
49
|
+
`Mcp.HttpEndpoint[${this.resource.metadata.name}]: tools bundle '${bundleName}' not found in module scope`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
return inst;
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Mount contract — duck-typed against Http.Server's mount loop. The
|
|
57
|
+
* signature matches Http.Api.register(); see plan §3 mount contract.
|
|
58
|
+
*
|
|
59
|
+
* Routes are declared with `app.route(...)` directly rather than via
|
|
60
|
+
* `app.register(plugin, { prefix })`. The latter is async (the plugin
|
|
61
|
+
* loads inside `app.ready()`); declaring the route synchronously removes
|
|
62
|
+
* any ordering coupling with the host's `app.listen()` call. Both
|
|
63
|
+
* `<prefix>` and `<prefix>/` are registered so trailing-slash variants
|
|
64
|
+
* both reach the handler. */
|
|
65
|
+
register(app: FastifyInstance, prefix = "") {
|
|
66
|
+
const handler = async (request: FastifyRequest, reply: FastifyReply) => {
|
|
67
|
+
await this.handleRequest(request, reply);
|
|
68
|
+
};
|
|
69
|
+
const methods = ["POST", "GET", "DELETE"];
|
|
70
|
+
|
|
71
|
+
// Normalize prefix: strip a trailing slash unless the prefix is exactly
|
|
72
|
+
// "/", so a configured `/mcp/` doesn't expand to `/mcp//`.
|
|
73
|
+
const base =
|
|
74
|
+
prefix && prefix !== "/" && prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
75
|
+
|
|
76
|
+
if (base && base !== "/") {
|
|
77
|
+
app.route({ method: methods, url: base, handler });
|
|
78
|
+
app.route({ method: methods, url: `${base}/`, handler });
|
|
79
|
+
} else {
|
|
80
|
+
app.route({ method: methods, url: "/", handler });
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
app.addHook("onClose", async () => {
|
|
84
|
+
await this.closeAllSessions();
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private async handleRequest(request: FastifyRequest, reply: FastifyReply) {
|
|
89
|
+
const sessionHeader = (request.headers["mcp-session-id"] ?? "") as string;
|
|
90
|
+
const body = request.body as Record<string, unknown> | undefined;
|
|
91
|
+
|
|
92
|
+
let record: SessionRecord | undefined;
|
|
93
|
+
if (sessionHeader) {
|
|
94
|
+
record = this.sessions.get(sessionHeader);
|
|
95
|
+
if (!record) {
|
|
96
|
+
reply.code(404);
|
|
97
|
+
reply.header("Content-Type", "application/json");
|
|
98
|
+
reply.send({
|
|
99
|
+
jsonrpc: "2.0",
|
|
100
|
+
error: { code: -32001, message: "Mcp: unknown session" },
|
|
101
|
+
id: null,
|
|
102
|
+
});
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
} else if (request.method === "POST" && body && isInitializeRequest(body)) {
|
|
106
|
+
record = await this.createSession();
|
|
107
|
+
} else {
|
|
108
|
+
reply.code(400);
|
|
109
|
+
reply.header("Content-Type", "application/json");
|
|
110
|
+
reply.send({
|
|
111
|
+
jsonrpc: "2.0",
|
|
112
|
+
error: {
|
|
113
|
+
code: -32000,
|
|
114
|
+
message: "Mcp: missing Mcp-Session-Id header (or initialize request body)",
|
|
115
|
+
},
|
|
116
|
+
id: null,
|
|
117
|
+
});
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Hand the raw request/response off to the SDK transport. Fastify has
|
|
122
|
+
// already parsed the body, so we pass it explicitly — the transport will
|
|
123
|
+
// not re-read the stream.
|
|
124
|
+
reply.hijack();
|
|
125
|
+
await record.transport.handleRequest(request.raw, reply.raw, body);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private async createSession(): Promise<SessionRecord> {
|
|
129
|
+
const sessionContext: SessionContext = { id: "", clientInfo: {}, capabilities: {} };
|
|
130
|
+
|
|
131
|
+
// Pre-allocate the SessionRecord shell so the onsessioninitialized
|
|
132
|
+
// closure can capture a stable object reference and register the session
|
|
133
|
+
// synchronously, before the transport writes the initialize response.
|
|
134
|
+
// Registering after `await transport.handleRequest()` would open a race
|
|
135
|
+
// where the client's follow-up request races with the registration.
|
|
136
|
+
const record = { context: sessionContext } as SessionRecord;
|
|
137
|
+
|
|
138
|
+
const transport = new StreamableHTTPServerTransport({
|
|
139
|
+
sessionIdGenerator: () => randomUUID(),
|
|
140
|
+
onsessioninitialized: (id: string) => {
|
|
141
|
+
sessionContext.id = id;
|
|
142
|
+
this.sessions.set(id, record);
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
record.transport = transport;
|
|
146
|
+
|
|
147
|
+
record.server = buildServer({
|
|
148
|
+
serverInfo: this.resource.serverInfo,
|
|
149
|
+
toolsBundles: this.toolsBundles,
|
|
150
|
+
sessionResolver: () => sessionContext,
|
|
151
|
+
ctx: this.ctx,
|
|
152
|
+
moduleContext: this.ctx.moduleContext,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
transport.onclose = () => {
|
|
156
|
+
if (sessionContext.id) {
|
|
157
|
+
this.sessions.delete(sessionContext.id);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
await record.server.connect(transport);
|
|
162
|
+
return record;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private async closeAllSessions(): Promise<void> {
|
|
166
|
+
const records = Array.from(this.sessions.values());
|
|
167
|
+
this.sessions.clear();
|
|
168
|
+
for (const record of records) {
|
|
169
|
+
const sessionId = record.context.id || "<unbound>";
|
|
170
|
+
try {
|
|
171
|
+
await record.transport.close();
|
|
172
|
+
} catch (err) {
|
|
173
|
+
await this.ctx.emitEvent(`${this.resource.metadata.name}.SessionCloseFailed`, {
|
|
174
|
+
sessionId,
|
|
175
|
+
stage: "transport",
|
|
176
|
+
error: errorPayload(err),
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
try {
|
|
180
|
+
await record.server.close();
|
|
181
|
+
} catch (err) {
|
|
182
|
+
await this.ctx.emitEvent(`${this.resource.metadata.name}.SessionCloseFailed`, {
|
|
183
|
+
sessionId,
|
|
184
|
+
stage: "server",
|
|
185
|
+
error: errorPayload(err),
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function errorPayload(err: unknown): { message: string; stack?: string; code?: string } {
|
|
193
|
+
if (err instanceof Error) {
|
|
194
|
+
return { message: err.message, stack: err.stack, code: (err as { code?: string }).code };
|
|
195
|
+
}
|
|
196
|
+
return { message: String(err) };
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
export async function create(
|
|
200
|
+
resource: HttpEndpointManifest,
|
|
201
|
+
ctx: ResourceContext,
|
|
202
|
+
): Promise<McpHttpEndpoint> {
|
|
203
|
+
return new McpHttpEndpoint(resource, ctx);
|
|
204
|
+
}
|
package/src/outcome.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
/** Single catches: entry on a tool/resource/prompt entry. `when` is typed
|
|
4
|
+
* loosely because the manifest schema says `type: boolean` but the value at
|
|
5
|
+
* this point is a `CompiledValue` (when the user wrote `${{ ... }}`) or a
|
|
6
|
+
* bare boolean literal (when the user wrote `when: true`/`when: false`).
|
|
7
|
+
* Truthiness checks would mis-classify the literal `false` case as "no
|
|
8
|
+
* when field" — see matchCatch below. */
|
|
9
|
+
export interface CatchEntry {
|
|
10
|
+
code?: string;
|
|
11
|
+
when?: unknown;
|
|
12
|
+
error: {
|
|
13
|
+
code: number;
|
|
14
|
+
message: string;
|
|
15
|
+
data?: unknown;
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Resolved entry handed to the registry — handler ref is captured before
|
|
20
|
+
* Phase 5 injection (kind/name) and the live instance is read after. */
|
|
21
|
+
export interface ResolvedToolEntry {
|
|
22
|
+
name: string;
|
|
23
|
+
description?: string;
|
|
24
|
+
argumentsSchema: Record<string, unknown>;
|
|
25
|
+
inputs: Record<string, unknown>;
|
|
26
|
+
result: Record<string, unknown>;
|
|
27
|
+
catches?: CatchEntry[];
|
|
28
|
+
handlerKind: string;
|
|
29
|
+
handlerName: string;
|
|
30
|
+
handler: ResourceInstance;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Module-context-shaped surface used by registry dispatch — matches the
|
|
34
|
+
* shape consumed by http-api-controller's dispatchReturns/dispatchCatches. */
|
|
35
|
+
export interface ModuleLikeContext {
|
|
36
|
+
expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface InvokeError {
|
|
40
|
+
code: string;
|
|
41
|
+
message: string;
|
|
42
|
+
data?: unknown;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Pick the first `catches:` entry that matches the thrown InvokeError. An
|
|
46
|
+
* entry matches when *every* declared predicate passes: `code:` (if present)
|
|
47
|
+
* must equal the error's code AND `when:` (if present) must evaluate truthy.
|
|
48
|
+
* An entry with neither field is the catch-all and matches last. */
|
|
49
|
+
export function matchCatch(
|
|
50
|
+
catches: CatchEntry[] | undefined,
|
|
51
|
+
err: InvokeError,
|
|
52
|
+
celCtx: Record<string, unknown>,
|
|
53
|
+
moduleContext: ModuleLikeContext,
|
|
54
|
+
): CatchEntry | undefined {
|
|
55
|
+
if (!catches || catches.length === 0) return undefined;
|
|
56
|
+
let fallback: CatchEntry | undefined;
|
|
57
|
+
for (const entry of catches) {
|
|
58
|
+
if (entry.when === undefined && entry.code === undefined) {
|
|
59
|
+
fallback ??= entry;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
if (entry.code !== undefined && entry.code !== err.code) continue;
|
|
63
|
+
if (entry.when !== undefined && moduleContext.expandWith(entry.when, celCtx) !== true) {
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
return entry;
|
|
67
|
+
}
|
|
68
|
+
return fallback;
|
|
69
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { type ControllerContext, type ResourceContext, RuntimeError } from "@telorun/sdk";
|
|
2
|
+
|
|
3
|
+
interface PromptsManifest {
|
|
4
|
+
metadata?: { name?: string };
|
|
5
|
+
entries?: unknown[];
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function register(_ctx: ControllerContext): Promise<void> {}
|
|
9
|
+
|
|
10
|
+
/** v2 runtime — schema-only in v1. See resources-controller.ts for the same
|
|
11
|
+
* pattern. */
|
|
12
|
+
export class McpPromptsBundle {
|
|
13
|
+
constructor(
|
|
14
|
+
public readonly bundleName: string,
|
|
15
|
+
public readonly entries: unknown[],
|
|
16
|
+
) {}
|
|
17
|
+
|
|
18
|
+
resolveEntries(): never {
|
|
19
|
+
throw new RuntimeError(
|
|
20
|
+
"ERR_MCP_V2_NOT_IMPLEMENTED",
|
|
21
|
+
`Mcp.Prompts[${this.bundleName}]: runtime dispatch is v2 work`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function create(
|
|
27
|
+
resource: PromptsManifest,
|
|
28
|
+
_ctx: ResourceContext,
|
|
29
|
+
): Promise<McpPromptsBundle> {
|
|
30
|
+
const bundleName = resource.metadata?.name;
|
|
31
|
+
if (!bundleName) {
|
|
32
|
+
throw new RuntimeError(
|
|
33
|
+
"ERR_MCP_PROMPTS_INVALID",
|
|
34
|
+
"Mcp.Prompts: metadata.name is required",
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
return new McpPromptsBundle(bundleName, resource.entries ?? []);
|
|
38
|
+
}
|