@casys/mcp-server 0.8.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,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
+ }