@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.
@@ -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
+ }