@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
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# @telorun/mcp-server
|
|
2
|
+
|
|
3
|
+
## 0.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 5288f6c: Initial release of the `mcp-server` module.
|
|
8
|
+
|
|
9
|
+
Adds five resource kinds for exposing a Model Context Protocol server from Telo
|
|
10
|
+
manifests: `Mcp.StdioServer` (stdio transport, `Telo.Service`), `Mcp.HttpEndpoint`
|
|
11
|
+
(Streamable-HTTP transport, `Telo.Mount` on `Http.Server`), and three passive
|
|
12
|
+
bundle kinds — `Mcp.Tools`, `Mcp.Resources`, `Mcp.Prompts` (`Telo.Type`). v1 ships
|
|
13
|
+
runtime dispatch for `Mcp.Tools`; `Resources` and `Prompts` are schema-only and
|
|
14
|
+
gain runtime in v2.
|
|
15
|
+
|
|
16
|
+
Bundles compose by reference: a transport's `tools:` array can reference multiple
|
|
17
|
+
bundles (entries are merged with cross-bundle duplicate detection at init), and a
|
|
18
|
+
single bundle can be referenced from both stdio and HTTP transports without
|
|
19
|
+
re-declaration. Each tool entry maps the MCP envelope (`request.{name, arguments,
|
|
20
|
+
meta, session}`) to any `Telo.Invocable` handler via CEL `inputs:` / `result:` /
|
|
21
|
+
`catches:` adapters — the handler stays oblivious to MCP.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# SUSTAINABLE USE LICENSE (Fair-code)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 DiglyAI
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to use, copy, modify, and distribute the Software for any purpose—including commercial purposes—subject to the following conditions:
|
|
6
|
+
|
|
7
|
+
1. ANTI-COMPETITION RESTRICTION: The Software may not be provided to third parties as a managed service, commercial SaaS (Software-as-a-Service), PaaS (Platform-as-a-Service), BaaS (Backend-as-a-Service), or similar offering where the primary value provided to the user is the functionality of the Software itself, without a separate commercial license from the copyright holder.
|
|
8
|
+
|
|
9
|
+
2. PERMITTED COMMERCIAL USE: You are free to use the Software to build, host, and monetize your own commercial applications, products, and services, provided such use does not violate Clause 1.
|
|
10
|
+
|
|
11
|
+
3. ATTRIBUTION: This copyright notice and license must be included in all copies or substantial portions of the Software.
|
|
12
|
+
|
|
13
|
+
4. CONTRIBUTIONS: Contributions to the Software are welcome and encouraged. By contributing, you agree that your contributions may be incorporated into the Software and distributed under this license.
|
|
14
|
+
|
|
15
|
+
5. DISCLAIMER: The Software is provided "as is", without warranty of any kind, express or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose and noninfringement. In no event shall the authors or copyright holders be liable for any claim, damages or other liability, whether in an action of contract, tort or otherwise, arising from, out of or in connection with the Software or the use or other dealings in the Software.
|
|
16
|
+
|
|
17
|
+
For commercial licensing, managed hosting exemptions, or enterprise inquiries, please contact DiglyAI.
|
package/README.md
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# MCP Server
|
|
2
|
+
|
|
3
|
+
Model Context Protocol (MCP) server resource kinds for Telo: stdio + Streamable HTTP transports, composable tool/resource/prompt bundles.
|
|
4
|
+
|
|
5
|
+
The module mirrors the `http-server` shape: **transport kinds**
|
|
6
|
+
(`Mcp.StdioServer`, `Mcp.HttpEndpoint`) own the listener and a session model;
|
|
7
|
+
**bundle kinds** (`Mcp.Tools`, `Mcp.Resources`, `Mcp.Prompts`) are passive
|
|
8
|
+
declarations that compose by reference. A single bundle can be referenced by
|
|
9
|
+
both stdio and HTTP transports without re-declaration.
|
|
10
|
+
|
|
11
|
+
## Capabilities at a glance
|
|
12
|
+
|
|
13
|
+
| Kind | Capability | Role |
|
|
14
|
+
| ------------------ | -------------- | ----------------------------------------------------------------------- |
|
|
15
|
+
| `Mcp.StdioServer` | `Telo.Service` | Listens on stdin/stdout (one client per process). Owns one SDK Server. |
|
|
16
|
+
| `Mcp.HttpEndpoint` | `Telo.Mount` | Mounts on `Http.Server` at a path. Per-session SDK Server (Streamable). |
|
|
17
|
+
| `Mcp.Tools` | `Telo.Type` | Bundle of tool entries dispatched via `tools/call`. |
|
|
18
|
+
| `Mcp.Resources` | `Telo.Type` | Bundle of resource entries (schema'd in v1, runtime in v2). |
|
|
19
|
+
| `Mcp.Prompts` | `Telo.Type` | Bundle of prompt entries (schema'd in v1, runtime in v2). |
|
|
20
|
+
|
|
21
|
+
## Minimal stdio example
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
kind: Telo.Application
|
|
25
|
+
metadata: { name: my-stdio-mcp }
|
|
26
|
+
targets: [Server]
|
|
27
|
+
---
|
|
28
|
+
kind: Telo.Import
|
|
29
|
+
metadata: { name: Mcp }
|
|
30
|
+
source: ../modules/mcp-server
|
|
31
|
+
---
|
|
32
|
+
kind: Telo.Import
|
|
33
|
+
metadata: { name: JS }
|
|
34
|
+
source: ../modules/javascript
|
|
35
|
+
---
|
|
36
|
+
kind: Mcp.StdioServer
|
|
37
|
+
metadata: { name: Server }
|
|
38
|
+
serverInfo: { name: my-stdio-mcp, version: 1.0.0 }
|
|
39
|
+
tools: [WeatherTools]
|
|
40
|
+
---
|
|
41
|
+
kind: Mcp.Tools
|
|
42
|
+
metadata: { name: WeatherTools }
|
|
43
|
+
entries:
|
|
44
|
+
- name: get_weather
|
|
45
|
+
description: Get current weather for a city.
|
|
46
|
+
argumentsSchema:
|
|
47
|
+
type: object
|
|
48
|
+
properties:
|
|
49
|
+
city: { type: string }
|
|
50
|
+
required: [city]
|
|
51
|
+
handler:
|
|
52
|
+
kind: JS.Script
|
|
53
|
+
name: GetWeatherImpl
|
|
54
|
+
inputs:
|
|
55
|
+
city: "${{ request.arguments.city }}"
|
|
56
|
+
result:
|
|
57
|
+
content:
|
|
58
|
+
- type: text
|
|
59
|
+
text: "${{ result.summary }}"
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
## Tool entries
|
|
63
|
+
|
|
64
|
+
Each `Mcp.Tools.entries[]` row declares everything MCP advertises plus the
|
|
65
|
+
glue that bridges between MCP envelopes and your handler's domain types:
|
|
66
|
+
|
|
67
|
+
| Field | Purpose |
|
|
68
|
+
| ----------------- | ------------------------------------------------------------------------------------------ |
|
|
69
|
+
| `name` | Tool identifier sent in `tools/call`. |
|
|
70
|
+
| `description` | Human-readable summary surfaced to MCP clients. |
|
|
71
|
+
| `argumentsSchema` | JSON Schema validated by the MCP SDK on every call. |
|
|
72
|
+
| `handler` | Any `Telo.Invocable` (e.g. `JS.Script`, `Run.Sequence`, `Ai.Text`). |
|
|
73
|
+
| `inputs` | CEL map: MCP request → handler input. Sees `request.{name,arguments,meta,session}`. |
|
|
74
|
+
| `result` | CEL map: handler output → full MCP `CallToolResult`. Sees `result` and `request`. |
|
|
75
|
+
| `catches` | Maps thrown `InvokeError`s into JSON-RPC error responses (distinct from `result.isError`). |
|
|
76
|
+
|
|
77
|
+
`isError` on `result` signals a _soft_ tool failure where the LLM should read
|
|
78
|
+
the failure as content. `catches:` is for actual `throw`s and produces a
|
|
79
|
+
JSON-RPC error.
|
|
80
|
+
|
|
81
|
+
## Composition
|
|
82
|
+
|
|
83
|
+
- **Multiple bundles into one transport.** `tools: [WeatherTools, DatabaseTools]` merges entries from both bundles. Duplicate names across bundles throw at init.
|
|
84
|
+
- **One bundle into multiple transports.** Reference the same `Mcp.Tools` from both `Mcp.StdioServer` and `Mcp.HttpEndpoint` — registrations are independent per-transport, no shared runtime state.
|
|
85
|
+
|
|
86
|
+
## Out of scope for v1
|
|
87
|
+
|
|
88
|
+
- `Mcp.Resources` and `Mcp.Prompts` runtime dispatch — schemas land in v1, controllers in v2.
|
|
89
|
+
- Streaming tool content / progress notifications.
|
|
90
|
+
- Streamable HTTP idle-session GC + max-sessions cap (sessions live until `Http.Server` shuts down).
|
|
91
|
+
- Server-initiated `sampling`, `roots`, OAuth.
|
|
92
|
+
- A polymorphic `Telo.Mount` dispatch protocol — `Mcp.HttpEndpoint` duck-types `register(app, prefix)` to satisfy `Http.Server`'s mount loop today.
|
|
93
|
+
|
|
94
|
+
See [docs/](./docs) for per-kind reference.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { type ControllerContext, type ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import type { FastifyInstance } from "fastify";
|
|
3
|
+
import { type ServerInfo } from "./registry.js";
|
|
4
|
+
interface HttpEndpointManifest {
|
|
5
|
+
metadata: {
|
|
6
|
+
name: string;
|
|
7
|
+
};
|
|
8
|
+
serverInfo: ServerInfo;
|
|
9
|
+
tools?: string[];
|
|
10
|
+
resources?: string[];
|
|
11
|
+
prompts?: string[];
|
|
12
|
+
}
|
|
13
|
+
export declare function register(_ctx: ControllerContext): Promise<void>;
|
|
14
|
+
export declare class McpHttpEndpoint {
|
|
15
|
+
private readonly resource;
|
|
16
|
+
private readonly ctx;
|
|
17
|
+
private readonly sessions;
|
|
18
|
+
private toolsBundles;
|
|
19
|
+
constructor(resource: HttpEndpointManifest, ctx: ResourceContext);
|
|
20
|
+
init(): Promise<void>;
|
|
21
|
+
/** Mount contract — duck-typed against Http.Server's mount loop. The
|
|
22
|
+
* signature matches Http.Api.register(); see plan §3 mount contract.
|
|
23
|
+
*
|
|
24
|
+
* Routes are declared with `app.route(...)` directly rather than via
|
|
25
|
+
* `app.register(plugin, { prefix })`. The latter is async (the plugin
|
|
26
|
+
* loads inside `app.ready()`); declaring the route synchronously removes
|
|
27
|
+
* any ordering coupling with the host's `app.listen()` call. Both
|
|
28
|
+
* `<prefix>` and `<prefix>/` are registered so trailing-slash variants
|
|
29
|
+
* both reach the handler. */
|
|
30
|
+
register(app: FastifyInstance, prefix?: string): void;
|
|
31
|
+
private handleRequest;
|
|
32
|
+
private createSession;
|
|
33
|
+
private closeAllSessions;
|
|
34
|
+
}
|
|
35
|
+
export declare function create(resource: HttpEndpointManifest, ctx: ResourceContext): Promise<McpHttpEndpoint>;
|
|
36
|
+
export {};
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
3
|
+
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
4
|
+
import { RuntimeError } from "@telorun/sdk";
|
|
5
|
+
import { buildServer } from "./registry.js";
|
|
6
|
+
export async function register(_ctx) { }
|
|
7
|
+
export class McpHttpEndpoint {
|
|
8
|
+
resource;
|
|
9
|
+
ctx;
|
|
10
|
+
sessions = new Map();
|
|
11
|
+
toolsBundles = [];
|
|
12
|
+
constructor(resource, ctx) {
|
|
13
|
+
this.resource = resource;
|
|
14
|
+
this.ctx = ctx;
|
|
15
|
+
}
|
|
16
|
+
async init() {
|
|
17
|
+
if ((this.resource.resources ?? []).length > 0 || (this.resource.prompts ?? []).length > 0) {
|
|
18
|
+
throw new RuntimeError("ERR_MCP_V2_NOT_IMPLEMENTED", `Mcp.HttpEndpoint[${this.resource.metadata.name}]: resources/prompts are schema-only in v1; runtime dispatch is v2 work`);
|
|
19
|
+
}
|
|
20
|
+
this.toolsBundles = (this.resource.tools ?? []).map((bundleName) => {
|
|
21
|
+
const inst = this.ctx.moduleContext.getInstance(bundleName);
|
|
22
|
+
if (!inst) {
|
|
23
|
+
throw new RuntimeError("ERR_MCP_BUNDLE_NOT_FOUND", `Mcp.HttpEndpoint[${this.resource.metadata.name}]: tools bundle '${bundleName}' not found in module scope`);
|
|
24
|
+
}
|
|
25
|
+
return inst;
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
/** Mount contract — duck-typed against Http.Server's mount loop. The
|
|
29
|
+
* signature matches Http.Api.register(); see plan §3 mount contract.
|
|
30
|
+
*
|
|
31
|
+
* Routes are declared with `app.route(...)` directly rather than via
|
|
32
|
+
* `app.register(plugin, { prefix })`. The latter is async (the plugin
|
|
33
|
+
* loads inside `app.ready()`); declaring the route synchronously removes
|
|
34
|
+
* any ordering coupling with the host's `app.listen()` call. Both
|
|
35
|
+
* `<prefix>` and `<prefix>/` are registered so trailing-slash variants
|
|
36
|
+
* both reach the handler. */
|
|
37
|
+
register(app, prefix = "") {
|
|
38
|
+
const handler = async (request, reply) => {
|
|
39
|
+
await this.handleRequest(request, reply);
|
|
40
|
+
};
|
|
41
|
+
const methods = ["POST", "GET", "DELETE"];
|
|
42
|
+
// Normalize prefix: strip a trailing slash unless the prefix is exactly
|
|
43
|
+
// "/", so a configured `/mcp/` doesn't expand to `/mcp//`.
|
|
44
|
+
const base = prefix && prefix !== "/" && prefix.endsWith("/") ? prefix.slice(0, -1) : prefix;
|
|
45
|
+
if (base && base !== "/") {
|
|
46
|
+
app.route({ method: methods, url: base, handler });
|
|
47
|
+
app.route({ method: methods, url: `${base}/`, handler });
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
app.route({ method: methods, url: "/", handler });
|
|
51
|
+
}
|
|
52
|
+
app.addHook("onClose", async () => {
|
|
53
|
+
await this.closeAllSessions();
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
async handleRequest(request, reply) {
|
|
57
|
+
const sessionHeader = (request.headers["mcp-session-id"] ?? "");
|
|
58
|
+
const body = request.body;
|
|
59
|
+
let record;
|
|
60
|
+
if (sessionHeader) {
|
|
61
|
+
record = this.sessions.get(sessionHeader);
|
|
62
|
+
if (!record) {
|
|
63
|
+
reply.code(404);
|
|
64
|
+
reply.header("Content-Type", "application/json");
|
|
65
|
+
reply.send({
|
|
66
|
+
jsonrpc: "2.0",
|
|
67
|
+
error: { code: -32001, message: "Mcp: unknown session" },
|
|
68
|
+
id: null,
|
|
69
|
+
});
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
else if (request.method === "POST" && body && isInitializeRequest(body)) {
|
|
74
|
+
record = await this.createSession();
|
|
75
|
+
}
|
|
76
|
+
else {
|
|
77
|
+
reply.code(400);
|
|
78
|
+
reply.header("Content-Type", "application/json");
|
|
79
|
+
reply.send({
|
|
80
|
+
jsonrpc: "2.0",
|
|
81
|
+
error: {
|
|
82
|
+
code: -32000,
|
|
83
|
+
message: "Mcp: missing Mcp-Session-Id header (or initialize request body)",
|
|
84
|
+
},
|
|
85
|
+
id: null,
|
|
86
|
+
});
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
// Hand the raw request/response off to the SDK transport. Fastify has
|
|
90
|
+
// already parsed the body, so we pass it explicitly — the transport will
|
|
91
|
+
// not re-read the stream.
|
|
92
|
+
reply.hijack();
|
|
93
|
+
await record.transport.handleRequest(request.raw, reply.raw, body);
|
|
94
|
+
}
|
|
95
|
+
async createSession() {
|
|
96
|
+
const sessionContext = { id: "", clientInfo: {}, capabilities: {} };
|
|
97
|
+
// Pre-allocate the SessionRecord shell so the onsessioninitialized
|
|
98
|
+
// closure can capture a stable object reference and register the session
|
|
99
|
+
// synchronously, before the transport writes the initialize response.
|
|
100
|
+
// Registering after `await transport.handleRequest()` would open a race
|
|
101
|
+
// where the client's follow-up request races with the registration.
|
|
102
|
+
const record = { context: sessionContext };
|
|
103
|
+
const transport = new StreamableHTTPServerTransport({
|
|
104
|
+
sessionIdGenerator: () => randomUUID(),
|
|
105
|
+
onsessioninitialized: (id) => {
|
|
106
|
+
sessionContext.id = id;
|
|
107
|
+
this.sessions.set(id, record);
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
record.transport = transport;
|
|
111
|
+
record.server = buildServer({
|
|
112
|
+
serverInfo: this.resource.serverInfo,
|
|
113
|
+
toolsBundles: this.toolsBundles,
|
|
114
|
+
sessionResolver: () => sessionContext,
|
|
115
|
+
ctx: this.ctx,
|
|
116
|
+
moduleContext: this.ctx.moduleContext,
|
|
117
|
+
});
|
|
118
|
+
transport.onclose = () => {
|
|
119
|
+
if (sessionContext.id) {
|
|
120
|
+
this.sessions.delete(sessionContext.id);
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
await record.server.connect(transport);
|
|
124
|
+
return record;
|
|
125
|
+
}
|
|
126
|
+
async closeAllSessions() {
|
|
127
|
+
const records = Array.from(this.sessions.values());
|
|
128
|
+
this.sessions.clear();
|
|
129
|
+
for (const record of records) {
|
|
130
|
+
const sessionId = record.context.id || "<unbound>";
|
|
131
|
+
try {
|
|
132
|
+
await record.transport.close();
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
await this.ctx.emitEvent(`${this.resource.metadata.name}.SessionCloseFailed`, {
|
|
136
|
+
sessionId,
|
|
137
|
+
stage: "transport",
|
|
138
|
+
error: errorPayload(err),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
await record.server.close();
|
|
143
|
+
}
|
|
144
|
+
catch (err) {
|
|
145
|
+
await this.ctx.emitEvent(`${this.resource.metadata.name}.SessionCloseFailed`, {
|
|
146
|
+
sessionId,
|
|
147
|
+
stage: "server",
|
|
148
|
+
error: errorPayload(err),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
function errorPayload(err) {
|
|
155
|
+
if (err instanceof Error) {
|
|
156
|
+
return { message: err.message, stack: err.stack, code: err.code };
|
|
157
|
+
}
|
|
158
|
+
return { message: String(err) };
|
|
159
|
+
}
|
|
160
|
+
export async function create(resource, ctx) {
|
|
161
|
+
return new McpHttpEndpoint(resource, ctx);
|
|
162
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ResourceInstance } from "@telorun/sdk";
|
|
2
|
+
/** Single catches: entry on a tool/resource/prompt entry. `when` is typed
|
|
3
|
+
* loosely because the manifest schema says `type: boolean` but the value at
|
|
4
|
+
* this point is a `CompiledValue` (when the user wrote `${{ ... }}`) or a
|
|
5
|
+
* bare boolean literal (when the user wrote `when: true`/`when: false`).
|
|
6
|
+
* Truthiness checks would mis-classify the literal `false` case as "no
|
|
7
|
+
* when field" — see matchCatch below. */
|
|
8
|
+
export interface CatchEntry {
|
|
9
|
+
code?: string;
|
|
10
|
+
when?: unknown;
|
|
11
|
+
error: {
|
|
12
|
+
code: number;
|
|
13
|
+
message: string;
|
|
14
|
+
data?: unknown;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/** Resolved entry handed to the registry — handler ref is captured before
|
|
18
|
+
* Phase 5 injection (kind/name) and the live instance is read after. */
|
|
19
|
+
export interface ResolvedToolEntry {
|
|
20
|
+
name: string;
|
|
21
|
+
description?: string;
|
|
22
|
+
argumentsSchema: Record<string, unknown>;
|
|
23
|
+
inputs: Record<string, unknown>;
|
|
24
|
+
result: Record<string, unknown>;
|
|
25
|
+
catches?: CatchEntry[];
|
|
26
|
+
handlerKind: string;
|
|
27
|
+
handlerName: string;
|
|
28
|
+
handler: ResourceInstance;
|
|
29
|
+
}
|
|
30
|
+
/** Module-context-shaped surface used by registry dispatch — matches the
|
|
31
|
+
* shape consumed by http-api-controller's dispatchReturns/dispatchCatches. */
|
|
32
|
+
export interface ModuleLikeContext {
|
|
33
|
+
expandWith: (v: unknown, ctx: Record<string, unknown>) => unknown;
|
|
34
|
+
}
|
|
35
|
+
interface InvokeError {
|
|
36
|
+
code: string;
|
|
37
|
+
message: string;
|
|
38
|
+
data?: unknown;
|
|
39
|
+
}
|
|
40
|
+
/** Pick the first `catches:` entry that matches the thrown InvokeError. An
|
|
41
|
+
* entry matches when *every* declared predicate passes: `code:` (if present)
|
|
42
|
+
* must equal the error's code AND `when:` (if present) must evaluate truthy.
|
|
43
|
+
* An entry with neither field is the catch-all and matches last. */
|
|
44
|
+
export declare function matchCatch(catches: CatchEntry[] | undefined, err: InvokeError, celCtx: Record<string, unknown>, moduleContext: ModuleLikeContext): CatchEntry | undefined;
|
|
45
|
+
export {};
|
package/dist/outcome.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/** Pick the first `catches:` entry that matches the thrown InvokeError. An
|
|
2
|
+
* entry matches when *every* declared predicate passes: `code:` (if present)
|
|
3
|
+
* must equal the error's code AND `when:` (if present) must evaluate truthy.
|
|
4
|
+
* An entry with neither field is the catch-all and matches last. */
|
|
5
|
+
export function matchCatch(catches, err, celCtx, moduleContext) {
|
|
6
|
+
if (!catches || catches.length === 0)
|
|
7
|
+
return undefined;
|
|
8
|
+
let fallback;
|
|
9
|
+
for (const entry of catches) {
|
|
10
|
+
if (entry.when === undefined && entry.code === undefined) {
|
|
11
|
+
fallback ??= entry;
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (entry.code !== undefined && entry.code !== err.code)
|
|
15
|
+
continue;
|
|
16
|
+
if (entry.when !== undefined && moduleContext.expandWith(entry.when, celCtx) !== true) {
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
return entry;
|
|
20
|
+
}
|
|
21
|
+
return fallback;
|
|
22
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ControllerContext, type ResourceContext } from "@telorun/sdk";
|
|
2
|
+
interface PromptsManifest {
|
|
3
|
+
metadata?: {
|
|
4
|
+
name?: string;
|
|
5
|
+
};
|
|
6
|
+
entries?: unknown[];
|
|
7
|
+
}
|
|
8
|
+
export declare function register(_ctx: ControllerContext): Promise<void>;
|
|
9
|
+
/** v2 runtime — schema-only in v1. See resources-controller.ts for the same
|
|
10
|
+
* pattern. */
|
|
11
|
+
export declare class McpPromptsBundle {
|
|
12
|
+
readonly bundleName: string;
|
|
13
|
+
readonly entries: unknown[];
|
|
14
|
+
constructor(bundleName: string, entries: unknown[]);
|
|
15
|
+
resolveEntries(): never;
|
|
16
|
+
}
|
|
17
|
+
export declare function create(resource: PromptsManifest, _ctx: ResourceContext): Promise<McpPromptsBundle>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { RuntimeError } from "@telorun/sdk";
|
|
2
|
+
export async function register(_ctx) { }
|
|
3
|
+
/** v2 runtime — schema-only in v1. See resources-controller.ts for the same
|
|
4
|
+
* pattern. */
|
|
5
|
+
export class McpPromptsBundle {
|
|
6
|
+
bundleName;
|
|
7
|
+
entries;
|
|
8
|
+
constructor(bundleName, entries) {
|
|
9
|
+
this.bundleName = bundleName;
|
|
10
|
+
this.entries = entries;
|
|
11
|
+
}
|
|
12
|
+
resolveEntries() {
|
|
13
|
+
throw new RuntimeError("ERR_MCP_V2_NOT_IMPLEMENTED", `Mcp.Prompts[${this.bundleName}]: runtime dispatch is v2 work`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export async function create(resource, _ctx) {
|
|
17
|
+
const bundleName = resource.metadata?.name;
|
|
18
|
+
if (!bundleName) {
|
|
19
|
+
throw new RuntimeError("ERR_MCP_PROMPTS_INVALID", "Mcp.Prompts: metadata.name is required");
|
|
20
|
+
}
|
|
21
|
+
return new McpPromptsBundle(bundleName, resource.entries ?? []);
|
|
22
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { type ResourceContext } from "@telorun/sdk";
|
|
3
|
+
import { type ModuleLikeContext } from "./outcome.js";
|
|
4
|
+
import type { McpToolsBundle } from "./tools-controller.js";
|
|
5
|
+
export interface ServerInfo {
|
|
6
|
+
name: string;
|
|
7
|
+
version: string;
|
|
8
|
+
}
|
|
9
|
+
export interface BuildOptions {
|
|
10
|
+
serverInfo: ServerInfo;
|
|
11
|
+
toolsBundles: McpToolsBundle[];
|
|
12
|
+
/** Per-session metadata exposed to CEL inputs as `request.session`. */
|
|
13
|
+
sessionResolver: () => SessionContext;
|
|
14
|
+
ctx: ResourceContext;
|
|
15
|
+
moduleContext: ModuleLikeContext;
|
|
16
|
+
}
|
|
17
|
+
export interface SessionContext {
|
|
18
|
+
id: string;
|
|
19
|
+
clientInfo: {
|
|
20
|
+
name?: string;
|
|
21
|
+
version?: string;
|
|
22
|
+
} | Record<string, unknown>;
|
|
23
|
+
capabilities: Record<string, unknown>;
|
|
24
|
+
}
|
|
25
|
+
/** Build a fully-wired SDK Server. For stdio the caller connects this once;
|
|
26
|
+
* for streamable HTTP a fresh Server is built per session, so this is called
|
|
27
|
+
* every time a new Mcp-Session-Id is minted. */
|
|
28
|
+
export declare function buildServer(opts: BuildOptions): Server;
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
2
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, } from "@modelcontextprotocol/sdk/types.js";
|
|
3
|
+
import { isInvokeError, RuntimeError } from "@telorun/sdk";
|
|
4
|
+
import { matchCatch } from "./outcome.js";
|
|
5
|
+
/** Merge entries from every bundle, throwing if two bundles register the same
|
|
6
|
+
* tool name. The plan calls for the analyzer to also catch this at compile
|
|
7
|
+
* time (§5.1 item 2) — this runtime guard backstops it until that lands. */
|
|
8
|
+
function mergeToolEntries(bundles) {
|
|
9
|
+
const byName = new Map();
|
|
10
|
+
const owners = new Map();
|
|
11
|
+
for (const bundle of bundles) {
|
|
12
|
+
for (const entry of bundle.resolveEntries()) {
|
|
13
|
+
const priorOwner = owners.get(entry.name);
|
|
14
|
+
if (priorOwner) {
|
|
15
|
+
throw new RuntimeError("ERR_MCP_TOOLS_DUPLICATE", `Mcp: duplicate tool name '${entry.name}' across bundles '${priorOwner}' and '${bundle.bundleName}'`);
|
|
16
|
+
}
|
|
17
|
+
owners.set(entry.name, bundle.bundleName);
|
|
18
|
+
byName.set(entry.name, entry);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return byName;
|
|
22
|
+
}
|
|
23
|
+
/** Build a fully-wired SDK Server. For stdio the caller connects this once;
|
|
24
|
+
* for streamable HTTP a fresh Server is built per session, so this is called
|
|
25
|
+
* every time a new Mcp-Session-Id is minted. */
|
|
26
|
+
export function buildServer(opts) {
|
|
27
|
+
const tools = mergeToolEntries(opts.toolsBundles);
|
|
28
|
+
const server = new Server({ name: opts.serverInfo.name, version: opts.serverInfo.version }, { capabilities: { tools: {} } });
|
|
29
|
+
const advertised = Array.from(tools.values()).map((t) => ({
|
|
30
|
+
name: t.name,
|
|
31
|
+
description: t.description,
|
|
32
|
+
inputSchema: t.argumentsSchema,
|
|
33
|
+
}));
|
|
34
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: advertised }));
|
|
35
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
36
|
+
const tool = tools.get(request.params.name);
|
|
37
|
+
if (!tool) {
|
|
38
|
+
throw new RuntimeError("ERR_MCP_UNKNOWN_TOOL", `Mcp: unknown tool '${request.params.name}'`);
|
|
39
|
+
}
|
|
40
|
+
const session = opts.sessionResolver();
|
|
41
|
+
const requestCtx = {
|
|
42
|
+
request: {
|
|
43
|
+
name: tool.name,
|
|
44
|
+
arguments: request.params.arguments ?? {},
|
|
45
|
+
meta: request.params._meta ?? {},
|
|
46
|
+
session: {
|
|
47
|
+
id: session.id,
|
|
48
|
+
clientInfo: session.clientInfo,
|
|
49
|
+
capabilities: session.capabilities,
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
const inputs = opts.moduleContext.expandWith(tool.inputs, requestCtx);
|
|
54
|
+
let handlerResult;
|
|
55
|
+
try {
|
|
56
|
+
handlerResult = await opts.ctx.invokeResolved(tool.handlerKind, tool.handlerName, tool.handler, { ...inputs, inputs });
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (!isInvokeError(err))
|
|
60
|
+
throw err;
|
|
61
|
+
const errPayload = { code: err.code, message: err.message, data: err.data };
|
|
62
|
+
const celCtx = { error: errPayload, request: requestCtx.request };
|
|
63
|
+
const matched = matchCatch(tool.catches, errPayload, celCtx, opts.moduleContext);
|
|
64
|
+
if (!matched) {
|
|
65
|
+
throw err;
|
|
66
|
+
}
|
|
67
|
+
const expanded = opts.moduleContext.expandWith(matched.error, celCtx);
|
|
68
|
+
const ipcError = new Error(expanded.message);
|
|
69
|
+
ipcError.code = expanded.code;
|
|
70
|
+
ipcError.data = expanded.data;
|
|
71
|
+
throw ipcError;
|
|
72
|
+
}
|
|
73
|
+
const resultCtx = { result: handlerResult, request: requestCtx.request };
|
|
74
|
+
const rendered = opts.moduleContext.expandWith(tool.result, resultCtx);
|
|
75
|
+
// Schema requires `content` to be present, but the value comes from CEL
|
|
76
|
+
// expansion at runtime so its type can't be enforced statically. Verify
|
|
77
|
+
// the expanded shape here.
|
|
78
|
+
if (!Array.isArray(rendered.content)) {
|
|
79
|
+
throw new RuntimeError("ERR_MCP_RESULT_INVALID", `Mcp: tool '${tool.name}' result.content is not an array of content blocks`);
|
|
80
|
+
}
|
|
81
|
+
return rendered;
|
|
82
|
+
});
|
|
83
|
+
return server;
|
|
84
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type ControllerContext, type ResourceContext } from "@telorun/sdk";
|
|
2
|
+
interface ResourcesManifest {
|
|
3
|
+
metadata?: {
|
|
4
|
+
name?: string;
|
|
5
|
+
};
|
|
6
|
+
entries?: unknown[];
|
|
7
|
+
}
|
|
8
|
+
export declare function register(_ctx: ControllerContext): Promise<void>;
|
|
9
|
+
/** v2 runtime — schema-only in v1. The bundle is created so transport refs
|
|
10
|
+
* resolve cleanly, but transports refuse to wire it up: `Mcp.StdioServer` /
|
|
11
|
+
* `Mcp.HttpEndpoint` throw at init() if `resources:` is non-empty, and
|
|
12
|
+
* `resolveEntries()` throws if anyone calls it directly. */
|
|
13
|
+
export declare class McpResourcesBundle {
|
|
14
|
+
readonly bundleName: string;
|
|
15
|
+
readonly entries: unknown[];
|
|
16
|
+
constructor(bundleName: string, entries: unknown[]);
|
|
17
|
+
resolveEntries(): never;
|
|
18
|
+
}
|
|
19
|
+
export declare function create(resource: ResourcesManifest, _ctx: ResourceContext): Promise<McpResourcesBundle>;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { RuntimeError } from "@telorun/sdk";
|
|
2
|
+
export async function register(_ctx) { }
|
|
3
|
+
/** v2 runtime — schema-only in v1. The bundle is created so transport refs
|
|
4
|
+
* resolve cleanly, but transports refuse to wire it up: `Mcp.StdioServer` /
|
|
5
|
+
* `Mcp.HttpEndpoint` throw at init() if `resources:` is non-empty, and
|
|
6
|
+
* `resolveEntries()` throws if anyone calls it directly. */
|
|
7
|
+
export class McpResourcesBundle {
|
|
8
|
+
bundleName;
|
|
9
|
+
entries;
|
|
10
|
+
constructor(bundleName, entries) {
|
|
11
|
+
this.bundleName = bundleName;
|
|
12
|
+
this.entries = entries;
|
|
13
|
+
}
|
|
14
|
+
resolveEntries() {
|
|
15
|
+
throw new RuntimeError("ERR_MCP_V2_NOT_IMPLEMENTED", `Mcp.Resources[${this.bundleName}]: runtime dispatch is v2 work`);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
export async function create(resource, _ctx) {
|
|
19
|
+
const bundleName = resource.metadata?.name;
|
|
20
|
+
if (!bundleName) {
|
|
21
|
+
throw new RuntimeError("ERR_MCP_RESOURCES_INVALID", "Mcp.Resources: metadata.name is required");
|
|
22
|
+
}
|
|
23
|
+
return new McpResourcesBundle(bundleName, resource.entries ?? []);
|
|
24
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { type ControllerContext, type ResourceContext } from "@telorun/sdk";
|
|
2
|
+
import { type ServerInfo } from "./registry.js";
|
|
3
|
+
interface StdioServerManifest {
|
|
4
|
+
metadata: {
|
|
5
|
+
name: string;
|
|
6
|
+
};
|
|
7
|
+
serverInfo: ServerInfo;
|
|
8
|
+
tools?: string[];
|
|
9
|
+
resources?: string[];
|
|
10
|
+
prompts?: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function register(_ctx: ControllerContext): Promise<void>;
|
|
13
|
+
export declare class McpStdioServer {
|
|
14
|
+
private readonly resource;
|
|
15
|
+
private readonly ctx;
|
|
16
|
+
private server;
|
|
17
|
+
private transport;
|
|
18
|
+
private releaseHold;
|
|
19
|
+
private session;
|
|
20
|
+
constructor(resource: StdioServerManifest, ctx: ResourceContext);
|
|
21
|
+
init(): Promise<void>;
|
|
22
|
+
run(): Promise<void>;
|
|
23
|
+
teardown(): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export declare function create(resource: StdioServerManifest, ctx: ResourceContext): Promise<McpStdioServer>;
|
|
26
|
+
export {};
|