@casys/mcp-server 0.2.1
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/README.md +417 -0
- package/mod.ts +161 -0
- package/package.json +32 -0
- package/src/auth/config.ts +229 -0
- package/src/auth/jwt-provider.ts +175 -0
- package/src/auth/middleware.ts +170 -0
- package/src/auth/mod.ts +44 -0
- package/src/auth/presets.ts +129 -0
- package/src/auth/provider.ts +47 -0
- package/src/auth/scope-middleware.ts +59 -0
- package/src/auth/types.ts +69 -0
- package/src/concurrency/rate-limiter.ts +190 -0
- package/src/concurrency/request-queue.ts +140 -0
- package/src/concurrent-server.ts +1899 -0
- package/src/middleware/backpressure.ts +36 -0
- package/src/middleware/mod.ts +21 -0
- package/src/middleware/rate-limit.ts +45 -0
- package/src/middleware/runner.ts +63 -0
- package/src/middleware/types.ts +60 -0
- package/src/middleware/validation.ts +28 -0
- package/src/observability/metrics.ts +378 -0
- package/src/observability/mod.ts +20 -0
- package/src/observability/otel.ts +109 -0
- package/src/runtime/runtime.ts +220 -0
- package/src/runtime/types.ts +90 -0
- package/src/sampling/sampling-bridge.ts +191 -0
- package/src/security/channel-hmac.ts +140 -0
- package/src/security/csp.ts +87 -0
- package/src/security/message-signer.ts +223 -0
- package/src/types.ts +478 -0
- package/src/validation/schema-validator.ts +238 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenTelemetry Integration for @casys/mcp-server
|
|
3
|
+
*
|
|
4
|
+
* Provides tracing for tool calls, auth, and middleware pipeline.
|
|
5
|
+
*
|
|
6
|
+
* Enable with:
|
|
7
|
+
* - Deno: OTEL_DENO=true deno run --unstable-otel ...
|
|
8
|
+
* - Node.js: OTEL_ENABLED=true node ...
|
|
9
|
+
*
|
|
10
|
+
* @module lib/server/observability/otel
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type Span,
|
|
15
|
+
SpanStatusCode,
|
|
16
|
+
trace,
|
|
17
|
+
type Tracer,
|
|
18
|
+
} from "@opentelemetry/api";
|
|
19
|
+
import { env } from "../runtime/runtime.js";
|
|
20
|
+
|
|
21
|
+
let serverTracer: Tracer | null = null;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get or create the MCP server tracer
|
|
25
|
+
*/
|
|
26
|
+
export function getServerTracer(): Tracer {
|
|
27
|
+
if (!serverTracer) {
|
|
28
|
+
serverTracer = trace.getTracer("mcp.server", "0.8.0");
|
|
29
|
+
}
|
|
30
|
+
return serverTracer;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Span attributes for MCP tool calls
|
|
35
|
+
*/
|
|
36
|
+
export interface ToolCallSpanAttributes {
|
|
37
|
+
"mcp.tool.name": string;
|
|
38
|
+
"mcp.server.name"?: string;
|
|
39
|
+
"mcp.transport"?: string;
|
|
40
|
+
"mcp.session.id"?: string;
|
|
41
|
+
"mcp.auth.subject"?: string;
|
|
42
|
+
"mcp.auth.client_id"?: string;
|
|
43
|
+
[key: string]: string | number | boolean | undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Start a span for a tool call.
|
|
48
|
+
* Caller MUST call span.end() when done.
|
|
49
|
+
*/
|
|
50
|
+
export function startToolCallSpan(
|
|
51
|
+
toolName: string,
|
|
52
|
+
attributes: ToolCallSpanAttributes,
|
|
53
|
+
): Span {
|
|
54
|
+
const tracer = getServerTracer();
|
|
55
|
+
return tracer.startSpan(`mcp.tool.call ${toolName}`, { attributes });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Record a tool call result on a span and end it.
|
|
60
|
+
*/
|
|
61
|
+
export function endToolCallSpan(
|
|
62
|
+
span: Span,
|
|
63
|
+
success: boolean,
|
|
64
|
+
durationMs: number,
|
|
65
|
+
error?: string,
|
|
66
|
+
): void {
|
|
67
|
+
span.setAttribute("mcp.tool.duration_ms", durationMs);
|
|
68
|
+
span.setAttribute("mcp.tool.success", success);
|
|
69
|
+
|
|
70
|
+
if (error) {
|
|
71
|
+
span.setAttribute("mcp.tool.error", error);
|
|
72
|
+
span.recordException(new Error(error));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
span.setStatus({
|
|
76
|
+
code: success ? SpanStatusCode.OK : SpanStatusCode.ERROR,
|
|
77
|
+
message: error,
|
|
78
|
+
});
|
|
79
|
+
span.end();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Record an auth event as a fire-and-forget span.
|
|
84
|
+
*/
|
|
85
|
+
export function recordAuthEvent(
|
|
86
|
+
event: "verify" | "reject" | "cache_hit",
|
|
87
|
+
attributes: Record<string, string | number | boolean | undefined>,
|
|
88
|
+
): void {
|
|
89
|
+
const tracer = getServerTracer();
|
|
90
|
+
tracer.startActiveSpan(`mcp.auth.${event}`, { attributes }, (span) => {
|
|
91
|
+
span.setStatus({
|
|
92
|
+
code: event === "reject" ? SpanStatusCode.ERROR : SpanStatusCode.OK,
|
|
93
|
+
});
|
|
94
|
+
span.end();
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Check if OTEL is enabled.
|
|
100
|
+
* Deno: OTEL_DENO=true | Node.js: OTEL_ENABLED=true
|
|
101
|
+
*/
|
|
102
|
+
export function isOtelEnabled(): boolean {
|
|
103
|
+
try {
|
|
104
|
+
return env("OTEL_DENO") === "true" || env("OTEL_ENABLED") === "true";
|
|
105
|
+
} catch {
|
|
106
|
+
// Deno without --allow-env throws NotCapable
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// deno-lint-ignore-file no-process-global no-node-globals
|
|
2
|
+
/**
|
|
3
|
+
* Runtime adapter — Node.js implementation
|
|
4
|
+
*
|
|
5
|
+
* Implements the RuntimePort contract for Node.js.
|
|
6
|
+
* Drop-in replacement for runtime.ts (Deno) — swapped by build script.
|
|
7
|
+
*
|
|
8
|
+
* @see runtime-types.ts for the port contract
|
|
9
|
+
* @module lib/server/runtime.node
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { readFile } from "node:fs/promises";
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import type {
|
|
15
|
+
FetchHandler,
|
|
16
|
+
RuntimePort,
|
|
17
|
+
ServeHandle,
|
|
18
|
+
ServeOptions,
|
|
19
|
+
} from "./types.js";
|
|
20
|
+
|
|
21
|
+
// Re-export types so consumers import from a single module
|
|
22
|
+
export type { FetchHandler, ServeHandle, ServeOptions } from "./types.js";
|
|
23
|
+
|
|
24
|
+
class PayloadTooLargeError extends Error {
|
|
25
|
+
constructor(maxBytes: number) {
|
|
26
|
+
super(`Payload too large. Max ${maxBytes} bytes.`);
|
|
27
|
+
this.name = "PayloadTooLargeError";
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Get an environment variable.
|
|
33
|
+
*/
|
|
34
|
+
export function env(key: string): string | undefined {
|
|
35
|
+
return process.env[key];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read a UTF-8 text file.
|
|
40
|
+
* Returns null if the file does not exist.
|
|
41
|
+
*/
|
|
42
|
+
export async function readTextFile(path: string): Promise<string | null> {
|
|
43
|
+
try {
|
|
44
|
+
return await readFile(path, "utf-8");
|
|
45
|
+
} catch (err: unknown) {
|
|
46
|
+
if (
|
|
47
|
+
err && typeof err === "object" && "code" in err && err.code === "ENOENT"
|
|
48
|
+
) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Start an HTTP server with a fetch-style handler.
|
|
57
|
+
* Uses node:http with a Request/Response adapter (compatible with Hono).
|
|
58
|
+
*/
|
|
59
|
+
export function serve(
|
|
60
|
+
options: ServeOptions,
|
|
61
|
+
handler: FetchHandler,
|
|
62
|
+
): ServeHandle {
|
|
63
|
+
const hostname = options.hostname ?? "0.0.0.0";
|
|
64
|
+
const maxBodyBytes = options.maxBodyBytes ?? null;
|
|
65
|
+
|
|
66
|
+
const server = createServer(async (nodeReq, nodeRes) => {
|
|
67
|
+
try {
|
|
68
|
+
const contentLength = nodeReq.headers["content-length"];
|
|
69
|
+
if (maxBodyBytes !== null && contentLength) {
|
|
70
|
+
const length = Array.isArray(contentLength)
|
|
71
|
+
? Number(contentLength[0])
|
|
72
|
+
: Number(contentLength);
|
|
73
|
+
if (!Number.isNaN(length) && length > maxBodyBytes) {
|
|
74
|
+
nodeRes.writeHead(413);
|
|
75
|
+
nodeRes.end(`Payload too large. Max ${maxBodyBytes} bytes.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Convert Node.js IncomingMessage → Web Request
|
|
81
|
+
// Prefer Host header (correct behind reverse proxy) over bound hostname
|
|
82
|
+
const host = nodeReq.headers.host ?? `${hostname}:${options.port}`;
|
|
83
|
+
const url = `http://${host}${nodeReq.url ?? "/"}`;
|
|
84
|
+
const headers = new Headers();
|
|
85
|
+
for (const [key, value] of Object.entries(nodeReq.headers)) {
|
|
86
|
+
if (value) {
|
|
87
|
+
if (Array.isArray(value)) {
|
|
88
|
+
for (const v of value) headers.append(key, v);
|
|
89
|
+
} else {
|
|
90
|
+
headers.set(key, value);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const body = nodeReq.method !== "GET" && nodeReq.method !== "HEAD"
|
|
96
|
+
? await collectBody(nodeReq, maxBodyBytes)
|
|
97
|
+
: undefined;
|
|
98
|
+
|
|
99
|
+
const request = new Request(url, {
|
|
100
|
+
method: nodeReq.method ?? "GET",
|
|
101
|
+
headers,
|
|
102
|
+
body,
|
|
103
|
+
// @ts-ignore: duplex needed for streaming requests in Node 20+
|
|
104
|
+
duplex: body ? "half" : undefined,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Call the fetch handler (Hono, etc.)
|
|
108
|
+
const response = await handler(request);
|
|
109
|
+
|
|
110
|
+
// Convert Web Response → Node.js ServerResponse
|
|
111
|
+
// Use raw header entries to preserve duplicate Set-Cookie headers
|
|
112
|
+
const resHeaders: Record<string, string | string[]> = {};
|
|
113
|
+
response.headers.forEach((value, key) => {
|
|
114
|
+
const existing = resHeaders[key];
|
|
115
|
+
if (existing !== undefined) {
|
|
116
|
+
resHeaders[key] = Array.isArray(existing)
|
|
117
|
+
? [...existing, value]
|
|
118
|
+
: [existing, value];
|
|
119
|
+
} else {
|
|
120
|
+
resHeaders[key] = value;
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
nodeRes.writeHead(response.status, resHeaders);
|
|
124
|
+
|
|
125
|
+
if (response.body) {
|
|
126
|
+
const reader = response.body.getReader();
|
|
127
|
+
while (true) {
|
|
128
|
+
const { done, value } = await reader.read();
|
|
129
|
+
if (done) break;
|
|
130
|
+
nodeRes.write(value);
|
|
131
|
+
}
|
|
132
|
+
nodeRes.end();
|
|
133
|
+
} else {
|
|
134
|
+
nodeRes.end();
|
|
135
|
+
}
|
|
136
|
+
} catch (err) {
|
|
137
|
+
if (err instanceof PayloadTooLargeError) {
|
|
138
|
+
if (!nodeRes.headersSent) {
|
|
139
|
+
nodeRes.writeHead(413);
|
|
140
|
+
nodeRes.end(err.message);
|
|
141
|
+
}
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
console.error("[runtime.node] Request handler error:", err);
|
|
145
|
+
if (!nodeRes.headersSent) {
|
|
146
|
+
nodeRes.writeHead(500);
|
|
147
|
+
nodeRes.end("Internal Server Error");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
server.listen(options.port, hostname, () => {
|
|
153
|
+
if (options.onListen) {
|
|
154
|
+
options.onListen({ hostname, port: options.port });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
return {
|
|
159
|
+
shutdown: () =>
|
|
160
|
+
new Promise<void>((resolve, reject) => {
|
|
161
|
+
server.close((err) => (err ? reject(err) : resolve()));
|
|
162
|
+
}),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Unref a timer so it doesn't block process exit.
|
|
168
|
+
*/
|
|
169
|
+
export function unrefTimer(id: number): void {
|
|
170
|
+
// In Node.js, setTimeout returns a Timeout object (not a numeric ID).
|
|
171
|
+
// The caller passes it as `number` for Deno compat — we cast back.
|
|
172
|
+
try {
|
|
173
|
+
const timer = id as unknown as { unref?: () => void };
|
|
174
|
+
if (
|
|
175
|
+
typeof timer === "object" && timer && typeof timer.unref === "function"
|
|
176
|
+
) {
|
|
177
|
+
timer.unref();
|
|
178
|
+
}
|
|
179
|
+
} catch (err) {
|
|
180
|
+
console.warn("[runtime.node] Failed to unref timer:", err);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Compile-time contract check — ensures this module satisfies RuntimePort */
|
|
185
|
+
void ({ env, readTextFile, serve, unrefTimer } satisfies RuntimePort);
|
|
186
|
+
|
|
187
|
+
// ─── Internal helpers ────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
/** Collect request body from Node.js IncomingMessage */
|
|
190
|
+
function collectBody(
|
|
191
|
+
req: import("node:http").IncomingMessage,
|
|
192
|
+
maxBytes: number | null,
|
|
193
|
+
): Promise<Uint8Array> {
|
|
194
|
+
return new Promise((resolve, reject) => {
|
|
195
|
+
const chunks: Buffer[] = [];
|
|
196
|
+
let total = 0;
|
|
197
|
+
let rejected = false;
|
|
198
|
+
req.on("data", (chunk: Buffer) => {
|
|
199
|
+
if (rejected) return;
|
|
200
|
+
total += chunk.length;
|
|
201
|
+
if (maxBytes !== null && total > maxBytes) {
|
|
202
|
+
rejected = true;
|
|
203
|
+
req.destroy();
|
|
204
|
+
reject(new PayloadTooLargeError(maxBytes));
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
chunks.push(chunk);
|
|
208
|
+
});
|
|
209
|
+
req.on("end", () => {
|
|
210
|
+
if (!rejected) {
|
|
211
|
+
resolve(new Uint8Array(Buffer.concat(chunks)));
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
req.on("error", (err) => {
|
|
215
|
+
if (!rejected) {
|
|
216
|
+
reject(err);
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime Port — platform-agnostic contract
|
|
3
|
+
*
|
|
4
|
+
* Defines the interface that both Deno (runtime.ts) and Node.js (runtime.node.ts)
|
|
5
|
+
* must implement. This is the only file consumers need to understand the API shape.
|
|
6
|
+
*
|
|
7
|
+
* Pattern: each runtime file exports module-level functions that satisfy this port.
|
|
8
|
+
* The build script swaps runtime.ts → runtime.node.ts for Node.js distribution.
|
|
9
|
+
*
|
|
10
|
+
* @module lib/server/runtime-types
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
// ─── Environment ─────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Get an environment variable.
|
|
17
|
+
* Returns undefined if not set (never throws).
|
|
18
|
+
*/
|
|
19
|
+
export type EnvFn = (key: string) => string | undefined;
|
|
20
|
+
|
|
21
|
+
// ─── File System ─────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Read a UTF-8 text file.
|
|
25
|
+
* Returns null if the file does not exist (no throw on ENOENT/NotFound).
|
|
26
|
+
* Throws on other errors (permission denied, etc.).
|
|
27
|
+
*/
|
|
28
|
+
export type ReadTextFileFn = (path: string) => Promise<string | null>;
|
|
29
|
+
|
|
30
|
+
// ─── HTTP Server ─────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
/** Fetch-style request handler (Web standard) */
|
|
33
|
+
export type FetchHandler = (req: Request) => Response | Promise<Response>;
|
|
34
|
+
|
|
35
|
+
/** Options for starting an HTTP server */
|
|
36
|
+
export interface ServeOptions {
|
|
37
|
+
port: number;
|
|
38
|
+
hostname?: string;
|
|
39
|
+
onListen?: (info: { hostname: string; port: number }) => void;
|
|
40
|
+
/** Maximum request body size in bytes (optional, adapter-specific). */
|
|
41
|
+
maxBodyBytes?: number | null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Handle returned by serve(), used to shut down the server */
|
|
45
|
+
export interface ServeHandle {
|
|
46
|
+
shutdown(): Promise<void>;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Start an HTTP server with a fetch-style handler.
|
|
51
|
+
*
|
|
52
|
+
* Deno: wraps Deno.serve()
|
|
53
|
+
* Node.js: wraps node:http.createServer() with Request/Response adapter
|
|
54
|
+
*/
|
|
55
|
+
export type ServeFn = (
|
|
56
|
+
options: ServeOptions,
|
|
57
|
+
handler: FetchHandler,
|
|
58
|
+
) => ServeHandle;
|
|
59
|
+
|
|
60
|
+
// ─── Timers ──────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Unref a timer so it doesn't prevent process exit.
|
|
64
|
+
*
|
|
65
|
+
* Deno: Deno.unrefTimer(id)
|
|
66
|
+
* Node.js: timer.unref() on the Timeout object
|
|
67
|
+
*/
|
|
68
|
+
export type UnrefTimerFn = (id: number) => void;
|
|
69
|
+
|
|
70
|
+
// ─── Port interface ──────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Complete runtime port contract.
|
|
74
|
+
*
|
|
75
|
+
* Both runtime.ts (Deno) and runtime.node.ts (Node.js) must export
|
|
76
|
+
* functions matching these signatures. Use `satisfies RuntimePort`
|
|
77
|
+
* at the bottom of each implementation to enforce at compile time.
|
|
78
|
+
*
|
|
79
|
+
* @example
|
|
80
|
+
* ```typescript
|
|
81
|
+
* // At the bottom of runtime.ts / runtime.node.ts:
|
|
82
|
+
* export const _port = { env, readTextFile, serve, unrefTimer } satisfies RuntimePort;
|
|
83
|
+
* ```
|
|
84
|
+
*/
|
|
85
|
+
export interface RuntimePort {
|
|
86
|
+
env: EnvFn;
|
|
87
|
+
readTextFile: ReadTextFileFn;
|
|
88
|
+
serve: ServeFn;
|
|
89
|
+
unrefTimer: UnrefTimerFn;
|
|
90
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sampling Bridge for Bidirectional LLM Communication
|
|
3
|
+
*
|
|
4
|
+
* Enables MCP servers to delegate complex tasks back to the client's LLM
|
|
5
|
+
* via the sampling protocol. Manages pending requests with timeout and
|
|
6
|
+
* response routing.
|
|
7
|
+
*
|
|
8
|
+
* @module lib/server/sampling-bridge
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import type {
|
|
12
|
+
PromiseResolver,
|
|
13
|
+
SamplingClient,
|
|
14
|
+
SamplingParams,
|
|
15
|
+
SamplingResult,
|
|
16
|
+
} from "../types.js";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* SamplingBridge manages bidirectional sampling requests
|
|
20
|
+
*
|
|
21
|
+
* Features:
|
|
22
|
+
* - Request/response correlation via unique IDs
|
|
23
|
+
* - Automatic timeout for pending requests (60s default)
|
|
24
|
+
* - Promise-based async/await API
|
|
25
|
+
* - Support for both Anthropic and OpenAI sampling
|
|
26
|
+
*
|
|
27
|
+
* @example
|
|
28
|
+
* ```typescript
|
|
29
|
+
* const bridge = new SamplingBridge(samplingClient);
|
|
30
|
+
*
|
|
31
|
+
* // Delegate task to LLM
|
|
32
|
+
* const result = await bridge.requestSampling({
|
|
33
|
+
* messages: [{ role: 'user', content: 'Analyze this data...' }]
|
|
34
|
+
* });
|
|
35
|
+
* ```
|
|
36
|
+
*/
|
|
37
|
+
export class SamplingBridge {
|
|
38
|
+
private pendingRequests = new Map<number, PromiseResolver<SamplingResult>>();
|
|
39
|
+
private nextId = 1;
|
|
40
|
+
private samplingClient: SamplingClient;
|
|
41
|
+
private defaultTimeout = 60000; // 60 seconds
|
|
42
|
+
|
|
43
|
+
constructor(client: SamplingClient, options?: { timeout?: number }) {
|
|
44
|
+
this.samplingClient = client;
|
|
45
|
+
if (options?.timeout) {
|
|
46
|
+
this.defaultTimeout = options.timeout;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Request sampling from the client's LLM
|
|
52
|
+
*
|
|
53
|
+
* @param params - Sampling parameters (messages, model preferences, etc.)
|
|
54
|
+
* @param timeout - Override default timeout (ms)
|
|
55
|
+
* @returns Promise that resolves with sampling result
|
|
56
|
+
* @throws {Error} If request times out or client fails
|
|
57
|
+
*/
|
|
58
|
+
async requestSampling(
|
|
59
|
+
params: SamplingParams,
|
|
60
|
+
timeout?: number,
|
|
61
|
+
): Promise<SamplingResult> {
|
|
62
|
+
const id = this.nextId++;
|
|
63
|
+
const timeoutMs = timeout ?? this.defaultTimeout;
|
|
64
|
+
|
|
65
|
+
// Create timeout promise for race condition
|
|
66
|
+
let timeoutId: ReturnType<typeof setTimeout> | undefined;
|
|
67
|
+
const timeoutPromise = new Promise<never>((_, reject) => {
|
|
68
|
+
timeoutId = setTimeout(() => {
|
|
69
|
+
this.pendingRequests.delete(id);
|
|
70
|
+
reject(
|
|
71
|
+
new Error(`Sampling request ${id} timed out after ${timeoutMs}ms`),
|
|
72
|
+
);
|
|
73
|
+
}, timeoutMs);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Track pending request for potential external response handling
|
|
77
|
+
const resultPromise = new Promise<SamplingResult>((resolve, reject) => {
|
|
78
|
+
this.pendingRequests.set(id, { resolve, reject });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
try {
|
|
82
|
+
// Race between client response and timeout
|
|
83
|
+
// Also race with external response via handleResponse()
|
|
84
|
+
const clientPromise = this.samplingClient.createMessage(params);
|
|
85
|
+
const result = await Promise.race([
|
|
86
|
+
clientPromise.then((r) => {
|
|
87
|
+
// Resolve the pending request tracker as well
|
|
88
|
+
const pending = this.pendingRequests.get(id);
|
|
89
|
+
if (pending) {
|
|
90
|
+
pending.resolve(r);
|
|
91
|
+
}
|
|
92
|
+
return r;
|
|
93
|
+
}),
|
|
94
|
+
timeoutPromise,
|
|
95
|
+
resultPromise, // Allow external handleResponse() to resolve
|
|
96
|
+
]);
|
|
97
|
+
return result;
|
|
98
|
+
} catch (error) {
|
|
99
|
+
// Clean up pending request on error
|
|
100
|
+
this.pendingRequests.delete(id);
|
|
101
|
+
throw error;
|
|
102
|
+
} finally {
|
|
103
|
+
// Always clear timeout to prevent memory leak
|
|
104
|
+
if (timeoutId) {
|
|
105
|
+
clearTimeout(timeoutId);
|
|
106
|
+
}
|
|
107
|
+
// Clean up pending request
|
|
108
|
+
this.pendingRequests.delete(id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Handle sampling response from client (for bidirectional stdio)
|
|
114
|
+
*
|
|
115
|
+
* Used when MCP server receives sampling responses via stdin.
|
|
116
|
+
* Resolves the corresponding pending promise.
|
|
117
|
+
*
|
|
118
|
+
* @param id - Request ID from original sampling request
|
|
119
|
+
* @param result - Sampling result from client
|
|
120
|
+
*/
|
|
121
|
+
handleResponse(id: number, result: SamplingResult): void {
|
|
122
|
+
const pending = this.pendingRequests.get(id);
|
|
123
|
+
|
|
124
|
+
if (!pending) {
|
|
125
|
+
console.error(
|
|
126
|
+
`[SamplingBridge] Received response for unknown request: ${id}`,
|
|
127
|
+
);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
this.pendingRequests.delete(id);
|
|
132
|
+
pending.resolve(result);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Handle sampling error from client
|
|
137
|
+
*
|
|
138
|
+
* @param id - Request ID
|
|
139
|
+
* @param error - Error from client
|
|
140
|
+
*/
|
|
141
|
+
handleError(id: number, error: Error): void {
|
|
142
|
+
const pending = this.pendingRequests.get(id);
|
|
143
|
+
|
|
144
|
+
if (!pending) {
|
|
145
|
+
console.error(
|
|
146
|
+
`[SamplingBridge] Received error for unknown request: ${id}`,
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
this.pendingRequests.delete(id);
|
|
152
|
+
pending.reject(error);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get count of pending sampling requests
|
|
157
|
+
*/
|
|
158
|
+
getPendingCount(): number {
|
|
159
|
+
return this.pendingRequests.size;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Cancel all pending requests (useful for shutdown)
|
|
164
|
+
*/
|
|
165
|
+
cancelAll(): void {
|
|
166
|
+
for (const [id, pending] of this.pendingRequests.entries()) {
|
|
167
|
+
pending.reject(
|
|
168
|
+
new Error(`Sampling request ${id} cancelled (server shutdown)`),
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
this.pendingRequests.clear();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Get sampling client for direct access
|
|
176
|
+
*/
|
|
177
|
+
getClient(): SamplingClient {
|
|
178
|
+
return this.samplingClient;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Alias for requestSampling() - implements SamplingClient interface
|
|
183
|
+
* This allows the bridge to be used as a drop-in replacement for SamplingClient
|
|
184
|
+
*
|
|
185
|
+
* @param params - Sampling parameters
|
|
186
|
+
* @returns Sampling result
|
|
187
|
+
*/
|
|
188
|
+
createMessage(params: SamplingParams): Promise<SamplingResult> {
|
|
189
|
+
return this.requestSampling(params);
|
|
190
|
+
}
|
|
191
|
+
}
|