@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,146 @@
1
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
2
+ import {
3
+ CallToolRequestSchema,
4
+ ListToolsRequestSchema,
5
+ } from "@modelcontextprotocol/sdk/types.js";
6
+ import { isInvokeError, type ResourceContext, RuntimeError } from "@telorun/sdk";
7
+
8
+ import { matchCatch, type ModuleLikeContext, type ResolvedToolEntry } from "./outcome.js";
9
+ import type { McpToolsBundle } from "./tools-controller.js";
10
+
11
+ export interface ServerInfo {
12
+ name: string;
13
+ version: string;
14
+ }
15
+
16
+ export interface BuildOptions {
17
+ serverInfo: ServerInfo;
18
+ toolsBundles: McpToolsBundle[];
19
+ /** Per-session metadata exposed to CEL inputs as `request.session`. */
20
+ sessionResolver: () => SessionContext;
21
+ ctx: ResourceContext;
22
+ moduleContext: ModuleLikeContext;
23
+ }
24
+
25
+ export interface SessionContext {
26
+ id: string;
27
+ clientInfo: { name?: string; version?: string } | Record<string, unknown>;
28
+ capabilities: Record<string, unknown>;
29
+ }
30
+
31
+ /** Merge entries from every bundle, throwing if two bundles register the same
32
+ * tool name. The plan calls for the analyzer to also catch this at compile
33
+ * time (§5.1 item 2) — this runtime guard backstops it until that lands. */
34
+ function mergeToolEntries(bundles: McpToolsBundle[]): Map<string, ResolvedToolEntry> {
35
+ const byName = new Map<string, ResolvedToolEntry>();
36
+ const owners = new Map<string, string>();
37
+ for (const bundle of bundles) {
38
+ for (const entry of bundle.resolveEntries()) {
39
+ const priorOwner = owners.get(entry.name);
40
+ if (priorOwner) {
41
+ throw new RuntimeError(
42
+ "ERR_MCP_TOOLS_DUPLICATE",
43
+ `Mcp: duplicate tool name '${entry.name}' across bundles '${priorOwner}' and '${bundle.bundleName}'`,
44
+ );
45
+ }
46
+ owners.set(entry.name, bundle.bundleName);
47
+ byName.set(entry.name, entry);
48
+ }
49
+ }
50
+ return byName;
51
+ }
52
+
53
+ /** Build a fully-wired SDK Server. For stdio the caller connects this once;
54
+ * for streamable HTTP a fresh Server is built per session, so this is called
55
+ * every time a new Mcp-Session-Id is minted. */
56
+ export function buildServer(opts: BuildOptions): Server {
57
+ const tools = mergeToolEntries(opts.toolsBundles);
58
+
59
+ const server = new Server(
60
+ { name: opts.serverInfo.name, version: opts.serverInfo.version },
61
+ { capabilities: { tools: {} } },
62
+ );
63
+
64
+ const advertised = Array.from(tools.values()).map((t) => ({
65
+ name: t.name,
66
+ description: t.description,
67
+ inputSchema: t.argumentsSchema as Record<string, unknown>,
68
+ }));
69
+
70
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: advertised }));
71
+
72
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
73
+ const tool = tools.get(request.params.name);
74
+ if (!tool) {
75
+ throw new RuntimeError(
76
+ "ERR_MCP_UNKNOWN_TOOL",
77
+ `Mcp: unknown tool '${request.params.name}'`,
78
+ );
79
+ }
80
+
81
+ const session = opts.sessionResolver();
82
+ const requestCtx: Record<string, unknown> = {
83
+ request: {
84
+ name: tool.name,
85
+ arguments: request.params.arguments ?? {},
86
+ meta: (request.params as { _meta?: unknown })._meta ?? {},
87
+ session: {
88
+ id: session.id,
89
+ clientInfo: session.clientInfo,
90
+ capabilities: session.capabilities,
91
+ },
92
+ },
93
+ };
94
+
95
+ const inputs = opts.moduleContext.expandWith(tool.inputs, requestCtx) as Record<
96
+ string,
97
+ unknown
98
+ >;
99
+
100
+ let handlerResult: unknown;
101
+ try {
102
+ handlerResult = await opts.ctx.invokeResolved(
103
+ tool.handlerKind,
104
+ tool.handlerName,
105
+ tool.handler,
106
+ { ...inputs, inputs },
107
+ );
108
+ } catch (err) {
109
+ if (!isInvokeError(err)) throw err;
110
+ const errPayload = { code: err.code, message: err.message, data: err.data };
111
+ const celCtx = { error: errPayload, request: requestCtx.request };
112
+ const matched = matchCatch(tool.catches, errPayload, celCtx, opts.moduleContext);
113
+ if (!matched) {
114
+ throw err;
115
+ }
116
+ const expanded = opts.moduleContext.expandWith(matched.error, celCtx) as {
117
+ code: number;
118
+ message: string;
119
+ data?: unknown;
120
+ };
121
+ const ipcError: Error & { code?: number; data?: unknown } = new Error(expanded.message);
122
+ ipcError.code = expanded.code;
123
+ ipcError.data = expanded.data;
124
+ throw ipcError;
125
+ }
126
+
127
+ const resultCtx = { result: handlerResult, request: requestCtx.request };
128
+ const rendered = opts.moduleContext.expandWith(tool.result, resultCtx) as Record<
129
+ string,
130
+ unknown
131
+ >;
132
+
133
+ // Schema requires `content` to be present, but the value comes from CEL
134
+ // expansion at runtime so its type can't be enforced statically. Verify
135
+ // the expanded shape here.
136
+ if (!Array.isArray((rendered as { content?: unknown }).content)) {
137
+ throw new RuntimeError(
138
+ "ERR_MCP_RESULT_INVALID",
139
+ `Mcp: tool '${tool.name}' result.content is not an array of content blocks`,
140
+ );
141
+ }
142
+ return rendered as { content: unknown[] };
143
+ });
144
+
145
+ return server;
146
+ }
@@ -0,0 +1,40 @@
1
+ import { type ControllerContext, type ResourceContext, RuntimeError } from "@telorun/sdk";
2
+
3
+ interface ResourcesManifest {
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. The bundle is created so transport refs
11
+ * resolve cleanly, but transports refuse to wire it up: `Mcp.StdioServer` /
12
+ * `Mcp.HttpEndpoint` throw at init() if `resources:` is non-empty, and
13
+ * `resolveEntries()` throws if anyone calls it directly. */
14
+ export class McpResourcesBundle {
15
+ constructor(
16
+ public readonly bundleName: string,
17
+ public readonly entries: unknown[],
18
+ ) {}
19
+
20
+ resolveEntries(): never {
21
+ throw new RuntimeError(
22
+ "ERR_MCP_V2_NOT_IMPLEMENTED",
23
+ `Mcp.Resources[${this.bundleName}]: runtime dispatch is v2 work`,
24
+ );
25
+ }
26
+ }
27
+
28
+ export async function create(
29
+ resource: ResourcesManifest,
30
+ _ctx: ResourceContext,
31
+ ): Promise<McpResourcesBundle> {
32
+ const bundleName = resource.metadata?.name;
33
+ if (!bundleName) {
34
+ throw new RuntimeError(
35
+ "ERR_MCP_RESOURCES_INVALID",
36
+ "Mcp.Resources: metadata.name is required",
37
+ );
38
+ }
39
+ return new McpResourcesBundle(bundleName, resource.entries ?? []);
40
+ }
@@ -0,0 +1,125 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { Readable, Writable } from "node:stream";
3
+
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import type { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
+ import { type ControllerContext, type ResourceContext, RuntimeError } from "@telorun/sdk";
7
+
8
+ import { buildServer, type SessionContext, type ServerInfo } from "./registry.js";
9
+ import type { McpToolsBundle } from "./tools-controller.js";
10
+
11
+ interface StdioServerManifest {
12
+ metadata: { name: string };
13
+ serverInfo: ServerInfo;
14
+ tools?: string[];
15
+ resources?: string[];
16
+ prompts?: string[];
17
+ }
18
+
19
+ export async function register(_ctx: ControllerContext): Promise<void> {}
20
+
21
+ export class McpStdioServer {
22
+ private server: Server | null = null;
23
+ private transport: StdioServerTransport | null = null;
24
+ private releaseHold: (() => void) | null = null;
25
+ private session: SessionContext;
26
+
27
+ constructor(
28
+ private readonly resource: StdioServerManifest,
29
+ private readonly ctx: ResourceContext,
30
+ ) {
31
+ // stdio has no transport-level session id; mint a stable synthetic UUID at
32
+ // construction so request.session.id is always defined for CEL inputs.
33
+ this.session = { id: randomUUID(), clientInfo: {}, capabilities: {} };
34
+ }
35
+
36
+ async init() {
37
+ if ((this.resource.resources ?? []).length > 0 || (this.resource.prompts ?? []).length > 0) {
38
+ throw new RuntimeError(
39
+ "ERR_MCP_V2_NOT_IMPLEMENTED",
40
+ `Mcp.StdioServer[${this.resource.metadata.name}]: resources/prompts are schema-only in v1; runtime dispatch is v2 work`,
41
+ );
42
+ }
43
+
44
+ const 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.StdioServer[${this.resource.metadata.name}]: tools bundle '${bundleName}' not found in module scope`,
50
+ );
51
+ }
52
+ return inst;
53
+ });
54
+
55
+ this.server = buildServer({
56
+ serverInfo: this.resource.serverInfo,
57
+ toolsBundles,
58
+ sessionResolver: () => this.session,
59
+ ctx: this.ctx,
60
+ moduleContext: this.ctx.moduleContext,
61
+ });
62
+ }
63
+
64
+ async run(): Promise<void> {
65
+ if (!this.server) {
66
+ throw new Error("Mcp.StdioServer.run() called before init()");
67
+ }
68
+
69
+ this.releaseHold = this.ctx.acquireHold();
70
+ try {
71
+ // ResourceContext types stdin/stdout as the structural NodeJS.ReadableStream
72
+ // / NodeJS.WritableStream interfaces, while the MCP SDK accepts the
73
+ // concrete node:stream `Readable` / `Writable` classes. process.stdin
74
+ // and process.stdout satisfy both shapes; a single cast is enough here.
75
+ this.transport = new StdioServerTransport(
76
+ this.ctx.stdin as unknown as Readable,
77
+ this.ctx.stdout as unknown as Writable,
78
+ );
79
+
80
+ // The transport's `onclose` fires when stdin reaches EOF (the parent
81
+ // closed the pipe). Releasing the hold then lets the kernel exit.
82
+ this.transport.onclose = () => {
83
+ if (this.releaseHold) {
84
+ this.releaseHold();
85
+ this.releaseHold = null;
86
+ }
87
+ };
88
+
89
+ await this.server.connect(this.transport);
90
+
91
+ await this.ctx.emitEvent(`${this.resource.metadata.name}.Listening`, {
92
+ transport: "stdio",
93
+ sessionId: this.session.id,
94
+ });
95
+ } catch (error) {
96
+ if (this.releaseHold) {
97
+ this.releaseHold();
98
+ this.releaseHold = null;
99
+ }
100
+ throw error;
101
+ }
102
+ }
103
+
104
+ async teardown(): Promise<void> {
105
+ if (this.transport) {
106
+ await this.transport.close();
107
+ this.transport = null;
108
+ }
109
+ if (this.server) {
110
+ await this.server.close();
111
+ this.server = null;
112
+ }
113
+ if (this.releaseHold) {
114
+ this.releaseHold();
115
+ this.releaseHold = null;
116
+ }
117
+ }
118
+ }
119
+
120
+ export async function create(
121
+ resource: StdioServerManifest,
122
+ ctx: ResourceContext,
123
+ ): Promise<McpStdioServer> {
124
+ return new McpStdioServer(resource, ctx);
125
+ }
@@ -0,0 +1,151 @@
1
+ import type { ControllerContext, ResourceContext, ResourceInstance } from "@telorun/sdk";
2
+ import { RuntimeError } from "@telorun/sdk";
3
+
4
+ import type { CatchEntry, ResolvedToolEntry } from "./outcome.js";
5
+
6
+ /** Manifest shape pre-Phase-5. The handler field is still {kind, name} (or a
7
+ * bare name string) at create() time; Phase 5 swaps it for a live
8
+ * ResourceInstance before consumers (transports) read .entries. */
9
+ interface RawToolEntry {
10
+ name?: string;
11
+ description?: string;
12
+ argumentsSchema?: Record<string, unknown>;
13
+ handler?: unknown;
14
+ inputs?: Record<string, unknown>;
15
+ result?: Record<string, unknown>;
16
+ catches?: CatchEntry[];
17
+ }
18
+
19
+ interface ToolsManifest {
20
+ metadata?: { name?: string; module?: string };
21
+ entries?: RawToolEntry[];
22
+ }
23
+
24
+ interface CapturedRef {
25
+ kind: string;
26
+ name: string;
27
+ }
28
+
29
+ export async function register(_ctx: ControllerContext): Promise<void> {}
30
+
31
+ /** Passive bundle exposed via ctx.moduleContext.getInstance(name). The
32
+ * transport (StdioServer / HttpEndpoint) reads `.entries` after Phase 5
33
+ * injection — handler refs in each entry are then live ResourceInstances. */
34
+ export class McpToolsBundle {
35
+ constructor(
36
+ public readonly bundleName: string,
37
+ private readonly raw: RawToolEntry[],
38
+ private readonly captured: Map<RawToolEntry, CapturedRef>,
39
+ private readonly ctx: ResourceContext,
40
+ ) {}
41
+
42
+ /** Resolve the raw entries into ResolvedToolEntry records suitable for
43
+ * registry consumption. Called from a transport's init() — at that point
44
+ * Phase 5 has injected live handler instances over the captured object refs.
45
+ * String-form refs are resolved here via moduleContext.getInstance(). */
46
+ resolveEntries(): ResolvedToolEntry[] {
47
+ const seen = new Set<string>();
48
+ const resolved: ResolvedToolEntry[] = [];
49
+ for (const raw of this.raw) {
50
+ const name = raw.name;
51
+ if (!name) {
52
+ throw new RuntimeError(
53
+ "ERR_MCP_TOOLS_INVALID",
54
+ `Mcp.Tools[${this.bundleName}]: entry is missing 'name'`,
55
+ );
56
+ }
57
+ if (seen.has(name)) {
58
+ throw new RuntimeError(
59
+ "ERR_MCP_TOOLS_DUPLICATE",
60
+ `Mcp.Tools[${this.bundleName}]: duplicate tool name '${name}' within the same bundle`,
61
+ );
62
+ }
63
+ seen.add(name);
64
+
65
+ const ref = this.captured.get(raw);
66
+ if (!ref) {
67
+ throw new RuntimeError(
68
+ "ERR_MCP_TOOLS_NO_HANDLER",
69
+ `Mcp.Tools[${this.bundleName}]: tool '${name}' has no handler reference`,
70
+ );
71
+ }
72
+
73
+ let handlerInstance: ResourceInstance | undefined;
74
+ if (typeof raw.handler === "string") {
75
+ // moduleContext.getInstance(name) throws a plain Error when the
76
+ // resource is missing. Wrap it so misconfigured string refs surface
77
+ // as the MCP-specific ERR_MCP_TOOLS_HANDLER_UNRESOLVED with the
78
+ // bundle/tool location included.
79
+ try {
80
+ handlerInstance = this.ctx.moduleContext.getInstance(ref.name) as ResourceInstance;
81
+ } catch (err) {
82
+ throw new RuntimeError(
83
+ "ERR_MCP_TOOLS_HANDLER_UNRESOLVED",
84
+ `Mcp.Tools[${this.bundleName}]: tool '${name}' handler '${ref.name}' not found: ${err instanceof Error ? err.message : String(err)}`,
85
+ );
86
+ }
87
+ } else if (raw.handler && typeof raw.handler === "object") {
88
+ // Phase 5 injection has replaced the {kind, name} ref with the live
89
+ // ResourceInstance — unless the referenced resource was missing, in
90
+ // which case Phase 5 leaves the {kind, name} object in place. Detect
91
+ // that case via the absence of `invoke` below.
92
+ handlerInstance = raw.handler as ResourceInstance;
93
+ }
94
+ if (!handlerInstance || typeof (handlerInstance as { invoke?: unknown }).invoke !== "function") {
95
+ throw new RuntimeError(
96
+ "ERR_MCP_TOOLS_HANDLER_UNRESOLVED",
97
+ `Mcp.Tools[${this.bundleName}]: tool '${name}' handler '${ref.kind || "?"}.${ref.name}' did not resolve to a live Invocable — Phase 5 injection may have failed`,
98
+ );
99
+ }
100
+
101
+ resolved.push({
102
+ name,
103
+ description: raw.description,
104
+ argumentsSchema: (raw.argumentsSchema ?? {}) as Record<string, unknown>,
105
+ inputs: (raw.inputs ?? {}) as Record<string, unknown>,
106
+ result: (raw.result ?? {}) as Record<string, unknown>,
107
+ catches: raw.catches,
108
+ handlerKind: ref.kind,
109
+ handlerName: ref.name,
110
+ handler: handlerInstance,
111
+ });
112
+ }
113
+ return resolved;
114
+ }
115
+ }
116
+
117
+ export async function create(
118
+ resource: ToolsManifest,
119
+ ctx: ResourceContext,
120
+ ): Promise<McpToolsBundle> {
121
+ const bundleName = resource.metadata?.name;
122
+ if (!bundleName) {
123
+ throw new RuntimeError(
124
+ "ERR_MCP_TOOLS_INVALID",
125
+ "Mcp.Tools: metadata.name is required",
126
+ );
127
+ }
128
+ const entries = resource.entries ?? [];
129
+
130
+ const captured = new Map<RawToolEntry, CapturedRef>();
131
+ for (const entry of entries) {
132
+ const handler = entry.handler;
133
+ if (!handler) {
134
+ throw new RuntimeError(
135
+ "ERR_MCP_TOOLS_INVALID",
136
+ `Mcp.Tools[${bundleName}]: tool '${entry.name ?? "<unnamed>"}' missing handler`,
137
+ );
138
+ }
139
+ if (typeof handler === "object") {
140
+ captured.set(entry, ctx.resolveChildren(handler));
141
+ } else if (typeof handler === "string") {
142
+ // Bare name reference (oneOf: string). Phase 5 injection only replaces
143
+ // {kind, name} object refs — string refs survive as the resource name.
144
+ // We capture the name here so the dispatcher can still look up the
145
+ // instance via moduleContext.getInstance() at invoke time.
146
+ captured.set(entry, { kind: "", name: handler });
147
+ }
148
+ }
149
+
150
+ return new McpToolsBundle(bundleName, entries, captured, ctx);
151
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "../../../tsconfig.base.json",
3
+ "files": [],
4
+ "include": ["src/**/*.ts"],
5
+ "compilerOptions": {
6
+ "strict": true,
7
+ "esModuleInterop": true
8
+ },
9
+ "references": [
10
+ {
11
+ "path": "tsconfig.lib.json"
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src"
6
+ },
7
+ "exclude": ["node_modules", "dist", "**/*spec.ts"],
8
+ "include": ["src/**/*.ts"]
9
+ }