@stigmer/mcp-server 3.0.8-dev.20260612122433

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.
Files changed (205) hide show
  1. package/LICENSE +190 -0
  2. package/README.md +157 -0
  3. package/cli/mcp-server-stigmer.d.ts +3 -0
  4. package/cli/mcp-server-stigmer.d.ts.map +1 -0
  5. package/cli/mcp-server-stigmer.js +201 -0
  6. package/cli/mcp-server-stigmer.js.map +1 -0
  7. package/config.d.ts +44 -0
  8. package/config.d.ts.map +1 -0
  9. package/config.js +92 -0
  10. package/config.js.map +1 -0
  11. package/domains/agents/apply.d.ts +4 -0
  12. package/domains/agents/apply.d.ts.map +1 -0
  13. package/domains/agents/apply.js +25 -0
  14. package/domains/agents/apply.js.map +1 -0
  15. package/domains/agents/delete.d.ts +3 -0
  16. package/domains/agents/delete.d.ts.map +1 -0
  17. package/domains/agents/delete.js +35 -0
  18. package/domains/agents/delete.js.map +1 -0
  19. package/domains/agents/fetch.d.ts +6 -0
  20. package/domains/agents/fetch.d.ts.map +1 -0
  21. package/domains/agents/fetch.js +24 -0
  22. package/domains/agents/fetch.js.map +1 -0
  23. package/domains/agents/resources.d.ts +5 -0
  24. package/domains/agents/resources.d.ts.map +1 -0
  25. package/domains/agents/resources.js +16 -0
  26. package/domains/agents/resources.js.map +1 -0
  27. package/domains/agents/tools.d.ts +5 -0
  28. package/domains/agents/tools.d.ts.map +1 -0
  29. package/domains/agents/tools.js +41 -0
  30. package/domains/agents/tools.js.map +1 -0
  31. package/domains/client.d.ts +53 -0
  32. package/domains/client.d.ts.map +1 -0
  33. package/domains/client.js +62 -0
  34. package/domains/client.js.map +1 -0
  35. package/domains/marshal.d.ts +8 -0
  36. package/domains/marshal.d.ts.map +1 -0
  37. package/domains/marshal.js +17 -0
  38. package/domains/marshal.js.map +1 -0
  39. package/domains/mcpservers/apply.d.ts +4 -0
  40. package/domains/mcpservers/apply.d.ts.map +1 -0
  41. package/domains/mcpservers/apply.js +26 -0
  42. package/domains/mcpservers/apply.js.map +1 -0
  43. package/domains/mcpservers/delete.d.ts +6 -0
  44. package/domains/mcpservers/delete.d.ts.map +1 -0
  45. package/domains/mcpservers/delete.js +42 -0
  46. package/domains/mcpservers/delete.js.map +1 -0
  47. package/domains/mcpservers/fetch.d.ts +7 -0
  48. package/domains/mcpservers/fetch.d.ts.map +1 -0
  49. package/domains/mcpservers/fetch.js +26 -0
  50. package/domains/mcpservers/fetch.js.map +1 -0
  51. package/domains/mcpservers/resources.d.ts +5 -0
  52. package/domains/mcpservers/resources.d.ts.map +1 -0
  53. package/domains/mcpservers/resources.js +16 -0
  54. package/domains/mcpservers/resources.js.map +1 -0
  55. package/domains/mcpservers/tools.d.ts +5 -0
  56. package/domains/mcpservers/tools.d.ts.map +1 -0
  57. package/domains/mcpservers/tools.js +39 -0
  58. package/domains/mcpservers/tools.js.map +1 -0
  59. package/domains/resourcehandler.d.ts +27 -0
  60. package/domains/resourcehandler.d.ts.map +1 -0
  61. package/domains/resourcehandler.js +32 -0
  62. package/domains/resourcehandler.js.map +1 -0
  63. package/domains/resourceuri.d.ts +34 -0
  64. package/domains/resourceuri.d.ts.map +1 -0
  65. package/domains/resourceuri.js +100 -0
  66. package/domains/resourceuri.js.map +1 -0
  67. package/domains/rpcerr.d.ts +8 -0
  68. package/domains/rpcerr.d.ts.map +1 -0
  69. package/domains/rpcerr.js +42 -0
  70. package/domains/rpcerr.js.map +1 -0
  71. package/domains/search/tools.d.ts +5 -0
  72. package/domains/search/tools.d.ts.map +1 -0
  73. package/domains/search/tools.js +125 -0
  74. package/domains/search/tools.js.map +1 -0
  75. package/domains/skills/delete.d.ts +6 -0
  76. package/domains/skills/delete.d.ts.map +1 -0
  77. package/domains/skills/delete.js +38 -0
  78. package/domains/skills/delete.js.map +1 -0
  79. package/domains/skills/fetch.d.ts +6 -0
  80. package/domains/skills/fetch.d.ts.map +1 -0
  81. package/domains/skills/fetch.js +28 -0
  82. package/domains/skills/fetch.js.map +1 -0
  83. package/domains/skills/resources.d.ts +5 -0
  84. package/domains/skills/resources.d.ts.map +1 -0
  85. package/domains/skills/resources.js +25 -0
  86. package/domains/skills/resources.js.map +1 -0
  87. package/domains/skills/tools.d.ts +5 -0
  88. package/domains/skills/tools.d.ts.map +1 -0
  89. package/domains/skills/tools.js +39 -0
  90. package/domains/skills/tools.js.map +1 -0
  91. package/domains/toolresult.d.ts +12 -0
  92. package/domains/toolresult.d.ts.map +1 -0
  93. package/domains/toolresult.js +30 -0
  94. package/domains/toolresult.js.map +1 -0
  95. package/domains/workflowexecutions/tools.d.ts +5 -0
  96. package/domains/workflowexecutions/tools.d.ts.map +1 -0
  97. package/domains/workflowexecutions/tools.js +80 -0
  98. package/domains/workflowexecutions/tools.js.map +1 -0
  99. package/domains/workflows/apply.d.ts +4 -0
  100. package/domains/workflows/apply.d.ts.map +1 -0
  101. package/domains/workflows/apply.js +30 -0
  102. package/domains/workflows/apply.js.map +1 -0
  103. package/domains/workflows/delete.d.ts +3 -0
  104. package/domains/workflows/delete.d.ts.map +1 -0
  105. package/domains/workflows/delete.js +35 -0
  106. package/domains/workflows/delete.js.map +1 -0
  107. package/domains/workflows/fetch.d.ts +6 -0
  108. package/domains/workflows/fetch.d.ts.map +1 -0
  109. package/domains/workflows/fetch.js +25 -0
  110. package/domains/workflows/fetch.js.map +1 -0
  111. package/domains/workflows/resources.d.ts +5 -0
  112. package/domains/workflows/resources.d.ts.map +1 -0
  113. package/domains/workflows/resources.js +16 -0
  114. package/domains/workflows/resources.js.map +1 -0
  115. package/domains/workflows/taskkinds.d.ts +5 -0
  116. package/domains/workflows/taskkinds.d.ts.map +1 -0
  117. package/domains/workflows/taskkinds.js +66 -0
  118. package/domains/workflows/taskkinds.js.map +1 -0
  119. package/domains/workflows/tools.d.ts +5 -0
  120. package/domains/workflows/tools.d.ts.map +1 -0
  121. package/domains/workflows/tools.js +35 -0
  122. package/domains/workflows/tools.js.map +1 -0
  123. package/domains/workflows/validate.d.ts +5 -0
  124. package/domains/workflows/validate.d.ts.map +1 -0
  125. package/domains/workflows/validate.js +113 -0
  126. package/domains/workflows/validate.js.map +1 -0
  127. package/gen/agent.d.ts +385 -0
  128. package/gen/agent.d.ts.map +1 -0
  129. package/gen/agent.js +170 -0
  130. package/gen/agent.js.map +1 -0
  131. package/gen/apply-runtime.d.ts +18 -0
  132. package/gen/apply-runtime.d.ts.map +1 -0
  133. package/gen/apply-runtime.js +50 -0
  134. package/gen/apply-runtime.js.map +1 -0
  135. package/gen/mcpserver.d.ts +289 -0
  136. package/gen/mcpserver.d.ts.map +1 -0
  137. package/gen/mcpserver.js +166 -0
  138. package/gen/mcpserver.js.map +1 -0
  139. package/gen/workflow.d.ts +805 -0
  140. package/gen/workflow.d.ts.map +1 -0
  141. package/gen/workflow.js +842 -0
  142. package/gen/workflow.js.map +1 -0
  143. package/index.d.ts +20 -0
  144. package/index.d.ts.map +1 -0
  145. package/index.js +58 -0
  146. package/index.js.map +1 -0
  147. package/logger.d.ts +20 -0
  148. package/logger.d.ts.map +1 -0
  149. package/logger.js +41 -0
  150. package/logger.js.map +1 -0
  151. package/package.json +43 -0
  152. package/server.d.ts +60 -0
  153. package/server.d.ts.map +1 -0
  154. package/server.js +366 -0
  155. package/server.js.map +1 -0
  156. package/src/cli/mcp-server-stigmer.ts +42 -0
  157. package/src/config.test.ts +88 -0
  158. package/src/config.ts +151 -0
  159. package/src/domains/agents/apply.ts +30 -0
  160. package/src/domains/agents/delete.ts +41 -0
  161. package/src/domains/agents/fetch.ts +33 -0
  162. package/src/domains/agents/resources.ts +20 -0
  163. package/src/domains/agents/tools.ts +68 -0
  164. package/src/domains/apply.integration.test.ts +220 -0
  165. package/src/domains/client.ts +95 -0
  166. package/src/domains/deletes.integration.test.ts +124 -0
  167. package/src/domains/marshal.ts +21 -0
  168. package/src/domains/mcpservers/apply.ts +36 -0
  169. package/src/domains/mcpservers/delete.ts +51 -0
  170. package/src/domains/mcpservers/fetch.ts +35 -0
  171. package/src/domains/mcpservers/resources.ts +20 -0
  172. package/src/domains/mcpservers/tools.ts +74 -0
  173. package/src/domains/reads.integration.test.ts +134 -0
  174. package/src/domains/resourcehandler.ts +90 -0
  175. package/src/domains/resources.integration.test.ts +139 -0
  176. package/src/domains/resourceuri.test.ts +97 -0
  177. package/src/domains/resourceuri.ts +124 -0
  178. package/src/domains/rpcerr.test.ts +62 -0
  179. package/src/domains/rpcerr.ts +46 -0
  180. package/src/domains/search/search.integration.test.ts +127 -0
  181. package/src/domains/search/tools.ts +160 -0
  182. package/src/domains/skills/delete.ts +44 -0
  183. package/src/domains/skills/fetch.ts +38 -0
  184. package/src/domains/skills/resources.ts +33 -0
  185. package/src/domains/skills/tools.ts +67 -0
  186. package/src/domains/toolresult.ts +33 -0
  187. package/src/domains/workflowexecutions/tools.ts +133 -0
  188. package/src/domains/workflows/apply.ts +40 -0
  189. package/src/domains/workflows/delete.ts +44 -0
  190. package/src/domains/workflows/fetch.ts +34 -0
  191. package/src/domains/workflows/resources.ts +20 -0
  192. package/src/domains/workflows/taskkinds.ts +103 -0
  193. package/src/domains/workflows/tools.ts +68 -0
  194. package/src/domains/workflows/validate.integration.test.ts +117 -0
  195. package/src/domains/workflows/validate.ts +144 -0
  196. package/src/domains/workflows/workflow-tools.integration.test.ts +148 -0
  197. package/src/gen/agent.ts +173 -0
  198. package/src/gen/apply-runtime.ts +52 -0
  199. package/src/gen/mcpserver.ts +163 -0
  200. package/src/gen/workflow.ts +858 -0
  201. package/src/http.integration.test.ts +140 -0
  202. package/src/index.ts +66 -0
  203. package/src/logger.ts +49 -0
  204. package/src/server.integration.test.ts +82 -0
  205. package/src/server.ts +414 -0
package/src/server.ts ADDED
@@ -0,0 +1,414 @@
1
+ // Server construction, tool registration, and transport entry points.
2
+ //
3
+ // Mirrors Go internal/server (server.go + http.go) plus the lifecycle helpers
4
+ // from pkg/mcpserver/run.go. The server is stateless: every per-request value
5
+ // (the credential, and thus the gRPC client) is derived from the transport's
6
+ // auth context, so the registration is identical regardless of transport.
7
+ //
8
+ // One structural difference from Go is called out in DD-008: the TS McpServer
9
+ // "assumes ownership" of a single transport, so `both` mode uses one McpServer
10
+ // per transport rather than sharing a single instance across stdio + HTTP.
11
+
12
+ import { createServer as createHttpServer, type IncomingMessage, type ServerResponse } from "node:http";
13
+ import { randomBytes, randomUUID } from "node:crypto";
14
+
15
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
16
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
17
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
18
+ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
19
+ import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
20
+
21
+ import type { Config } from "./config.js";
22
+ import { registerAgentResources } from "./domains/agents/resources.js";
23
+ import { registerAgentTools } from "./domains/agents/tools.js";
24
+ import type { BackendTarget } from "./domains/client.js";
25
+ import { registerMcpServerResources } from "./domains/mcpservers/resources.js";
26
+ import { registerMcpServerTools } from "./domains/mcpservers/tools.js";
27
+ import { registerSearchTools } from "./domains/search/tools.js";
28
+ import { registerSkillResources } from "./domains/skills/resources.js";
29
+ import { registerSkillTools } from "./domains/skills/tools.js";
30
+ import { registerWorkflowExecutionTools } from "./domains/workflowexecutions/tools.js";
31
+ import { registerTaskKindTools } from "./domains/workflows/taskkinds.js";
32
+ import { registerWorkflowResources } from "./domains/workflows/resources.js";
33
+ import { registerWorkflowTools } from "./domains/workflows/tools.js";
34
+ import { registerValidateWorkflowYamlTool } from "./domains/workflows/validate.js";
35
+ import { log } from "./logger.js";
36
+
37
+ /**
38
+ * Server version. Overridable at publish/build time; "dev" otherwise, matching
39
+ * the Go server's ldflags fallback.
40
+ */
41
+ export const SERVER_VERSION = process.env.STIGMER_MCP_VERSION || "dev";
42
+
43
+ /** Grace period for draining in-flight HTTP requests on shutdown. */
44
+ const HTTP_SHUTDOWN_GRACE_MS = 5_000;
45
+
46
+ /**
47
+ * Well-known location (RFC 9728 §3.1) of the OAuth 2.0 Protected Resource
48
+ * Metadata document. Served only when OAuth discovery is enabled.
49
+ */
50
+ const PROTECTED_RESOURCE_METADATA_PATH = "/.well-known/oauth-protected-resource";
51
+
52
+ /**
53
+ * Build a configured MCP server with every Stigmer tool registered. The backend
54
+ * target (address + startup credential) is captured in each handler's closure.
55
+ */
56
+ export function createServer(target: BackendTarget): McpServer {
57
+ const server = new McpServer({ name: "mcp-server-stigmer", version: SERVER_VERSION });
58
+ registerTools(server, target);
59
+ registerResources(server, target);
60
+ return server;
61
+ }
62
+
63
+ /**
64
+ * Wire up every domain's tools. Each domain returns the names it registered so
65
+ * the startup log's count and roster cannot drift from what is actually wired,
66
+ * matching the Go server's startup log shape.
67
+ */
68
+ function registerTools(server: McpServer, target: BackendTarget): void {
69
+ const tools = [
70
+ ...registerSearchTools(server, target),
71
+ ...registerAgentTools(server, target),
72
+ ...registerMcpServerTools(server, target),
73
+ ...registerSkillTools(server, target),
74
+ ...registerWorkflowTools(server, target),
75
+ ...registerValidateWorkflowYamlTool(server, target),
76
+ ...registerTaskKindTools(server, target),
77
+ ...registerWorkflowExecutionTools(server, target),
78
+ ];
79
+ log.info("tools registered", { count: tools.length, tools });
80
+ }
81
+
82
+ /**
83
+ * Wire up every domain's resource templates (the discovery-to-read surface).
84
+ * Like {@link registerTools}, each domain returns the names it registered so the
85
+ * startup log stays accurate.
86
+ */
87
+ function registerResources(server: McpServer, target: BackendTarget): void {
88
+ const resources = [
89
+ ...registerAgentResources(server, target),
90
+ ...registerMcpServerResources(server, target),
91
+ ...registerSkillResources(server, target),
92
+ ...registerWorkflowResources(server, target),
93
+ ];
94
+ log.info("resources registered", { count: resources.length, resources });
95
+ }
96
+
97
+ /**
98
+ * Serve over stdin/stdout until the client disconnects or `signal` aborts.
99
+ *
100
+ * Resolves on a clean disconnect (the MCP discovery probe connects, lists
101
+ * tools/resources, then closes stdin → EOF). Protocol-level errors are logged
102
+ * but do not terminate the process, mirroring the Go server treating EOF /
103
+ * broken pipe as a normal shutdown rather than a failure.
104
+ */
105
+ export async function serveStdio(server: McpServer, signal: AbortSignal): Promise<void> {
106
+ const transport = new StdioServerTransport();
107
+ await server.connect(transport);
108
+
109
+ return new Promise<void>((resolve) => {
110
+ const onAbort = () => void server.close();
111
+ signal.addEventListener("abort", onAbort, { once: true });
112
+
113
+ // The low-level Server's onclose/onerror are user hooks (not overwritten by
114
+ // connect), so they are the safe place to observe lifecycle transitions.
115
+ server.server.onclose = () => {
116
+ signal.removeEventListener("abort", onAbort);
117
+ resolve();
118
+ };
119
+ server.server.onerror = (err) => log.error("mcp protocol error", { error: err.message });
120
+ });
121
+ }
122
+
123
+ /** Builds a fresh, fully-registered server. One is created per MCP session. */
124
+ export type ServerFactory = () => McpServer;
125
+
126
+ /**
127
+ * Serve over Streamable HTTP until `signal` aborts.
128
+ *
129
+ * Each request carries its own credential via the Authorization header; the
130
+ * (non-validating) auth layer extracts it onto `req.auth`, which the transport
131
+ * surfaces to tool handlers as `extra.authInfo`. The application keeps no
132
+ * per-user state.
133
+ *
134
+ * Spike A established the concurrency model: the TS SDK binds one McpServer to
135
+ * one transport to one MCP session (a second `initialize` on a shared server
136
+ * fails with "Server already initialized"). Unlike Go — whose SDK multiplexes
137
+ * sessions over a single shared `*mcp.Server` — the TS server keeps a registry
138
+ * of `sessionId → transport`, each built from `makeServer` on `initialize`. The
139
+ * per-request Bearer passthrough is orthogonal and applies on every request.
140
+ *
141
+ * Each request is wrapped in access logging (16-hex request id, method, path,
142
+ * status, duration) and, when OAuth discovery is enabled, RFC 9728 metadata is
143
+ * served and a WWW-Authenticate challenge is attached to token-less requests.
144
+ * DNS-rebinding allow-lists are intentionally out of parity scope (the Go server
145
+ * has none).
146
+ */
147
+ export async function serveHttp(makeServer: ServerFactory, cfg: Config, signal: AbortSignal): Promise<void> {
148
+ const sessions = new Map<string, StreamableHTTPServerTransport>();
149
+
150
+ const httpServer = createHttpServer((req, res) => {
151
+ logAccess(req, res);
152
+ void routeRequest(req, res, sessions, makeServer, cfg);
153
+ });
154
+
155
+ const addr = `:${cfg.httpPort}`;
156
+
157
+ return new Promise<void>((resolve, reject) => {
158
+ httpServer.on("error", reject);
159
+ httpServer.listen(Number(cfg.httpPort), () => {
160
+ log.info("HTTP transport listening", { addr, auth_enabled: cfg.httpAuthEnabled });
161
+ });
162
+
163
+ signal.addEventListener(
164
+ "abort",
165
+ () => {
166
+ log.info("HTTP server shutting down", { grace_period_ms: HTTP_SHUTDOWN_GRACE_MS });
167
+ const force = setTimeout(() => httpServer.closeAllConnections?.(), HTTP_SHUTDOWN_GRACE_MS);
168
+ httpServer.close((err) => {
169
+ clearTimeout(force);
170
+ for (const transport of sessions.values()) void transport.close();
171
+ sessions.clear();
172
+ if (err) reject(err);
173
+ else resolve();
174
+ });
175
+ },
176
+ { once: true },
177
+ );
178
+ });
179
+ }
180
+
181
+ /**
182
+ * Serve stdio and HTTP concurrently. stdio binds a single server; HTTP builds
183
+ * one server per session via the factory. The first transport to settle aborts
184
+ * the other, mirroring Go's serveBoth.
185
+ */
186
+ export async function serveBoth(target: BackendTarget, cfg: Config, signal: AbortSignal): Promise<void> {
187
+ const linked = new AbortController();
188
+ const onParentAbort = () => linked.abort();
189
+ signal.addEventListener("abort", onParentAbort, { once: true });
190
+
191
+ const tasks = [
192
+ serveStdio(createServer(target), linked.signal),
193
+ serveHttp(() => createServer(target), cfg, linked.signal),
194
+ ];
195
+
196
+ try {
197
+ await Promise.race(tasks);
198
+ } finally {
199
+ linked.abort();
200
+ await Promise.allSettled(tasks);
201
+ signal.removeEventListener("abort", onParentAbort);
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Route an inbound HTTP request: liveness probe, the non-validating Bearer
207
+ * extraction, then delegation to the session's MCP transport (reusing an
208
+ * existing session or creating one for an `initialize` request).
209
+ *
210
+ * The token is never validated here — presence is the only check, and it is
211
+ * forwarded unchanged to stigmer-server which performs validation. This mirrors
212
+ * the Go authMiddleware exactly (inventory §4.2).
213
+ */
214
+ async function routeRequest(
215
+ req: IncomingMessage & { auth?: AuthInfo },
216
+ res: ServerResponse,
217
+ sessions: Map<string, StreamableHTTPServerTransport>,
218
+ makeServer: ServerFactory,
219
+ cfg: Config,
220
+ ): Promise<void> {
221
+ if (req.method === "GET" && req.url === "/health") {
222
+ res.writeHead(200, { "Content-Type": "application/json" });
223
+ res.end(`{"status":"ok"}\n`);
224
+ return;
225
+ }
226
+
227
+ // RFC 9728 Protected Resource Metadata — public, unauthenticated, and served
228
+ // only when OAuth discovery is enabled. CORS-open so browser-based clients
229
+ // (e.g. Claude Desktop's connector GUI) can discover the authorization server.
230
+ if (cfg.oauth.enabled && requestPath(req) === PROTECTED_RESOURCE_METADATA_PATH) {
231
+ serveProtectedResourceMetadata(req, res, cfg);
232
+ return;
233
+ }
234
+
235
+ // Non-validating Bearer passthrough, applied to EVERY request so each call's
236
+ // gRPC client uses that request's own credential.
237
+ if (cfg.httpAuthEnabled) {
238
+ const token = extractBearerToken(req);
239
+ if (token === "") {
240
+ const headers: Record<string, string> = { "Content-Type": "text/plain" };
241
+ // RFC 9728 §5.1: point OAuth-capable clients at the metadata document.
242
+ if (cfg.oauth.enabled) headers["WWW-Authenticate"] = bearerChallenge(cfg);
243
+ res.writeHead(401, headers);
244
+ res.end("missing or malformed Authorization: Bearer header");
245
+ return;
246
+ }
247
+ req.auth = { token, clientId: "stigmer-mcp-passthrough", scopes: [] };
248
+ }
249
+
250
+ const sessionId = headerValue(req, "mcp-session-id");
251
+
252
+ // Established session → dispatch to its transport.
253
+ if (sessionId !== undefined) {
254
+ const transport = sessions.get(sessionId);
255
+ if (transport === undefined) {
256
+ res.writeHead(404, { "Content-Type": "text/plain" });
257
+ res.end("unknown or expired MCP session");
258
+ return;
259
+ }
260
+ await transport.handleRequest(req, res);
261
+ return;
262
+ }
263
+
264
+ // No session → only an initialize POST may open one.
265
+ if (req.method !== "POST") {
266
+ res.writeHead(400, { "Content-Type": "text/plain" });
267
+ res.end("missing Mcp-Session-Id header");
268
+ return;
269
+ }
270
+
271
+ const body = await readJsonBody(req);
272
+ if (!isInitializeRequest(body)) {
273
+ res.writeHead(400, { "Content-Type": "text/plain" });
274
+ res.end("Bad Request: an initialize request is required to open a session");
275
+ return;
276
+ }
277
+
278
+ const transport: StreamableHTTPServerTransport = new StreamableHTTPServerTransport({
279
+ sessionIdGenerator: () => randomUUID(),
280
+ onsessioninitialized: (id) => {
281
+ sessions.set(id, transport);
282
+ },
283
+ onsessionclosed: (id) => {
284
+ sessions.delete(id);
285
+ },
286
+ });
287
+ transport.onclose = () => {
288
+ if (transport.sessionId !== undefined) sessions.delete(transport.sessionId);
289
+ };
290
+
291
+ await makeServer().connect(transport);
292
+ await transport.handleRequest(req, res, body);
293
+ }
294
+
295
+ /** Return a single header value, collapsing the array form Node may produce. */
296
+ function headerValue(req: IncomingMessage, name: string): string | undefined {
297
+ const v = req.headers[name];
298
+ return Array.isArray(v) ? v[0] : v;
299
+ }
300
+
301
+ /** Read and JSON-parse a request body (used to classify the initialize POST). */
302
+ function readJsonBody(req: IncomingMessage): Promise<unknown> {
303
+ return new Promise((resolve, reject) => {
304
+ const chunks: Buffer[] = [];
305
+ req.on("data", (chunk: Buffer) => chunks.push(chunk));
306
+ req.on("end", () => {
307
+ const raw = Buffer.concat(chunks).toString("utf8");
308
+ try {
309
+ resolve(raw === "" ? null : JSON.parse(raw));
310
+ } catch (err) {
311
+ reject(err);
312
+ }
313
+ });
314
+ req.on("error", reject);
315
+ });
316
+ }
317
+
318
+ /** Request path without the query string (mirrors Go's r.URL.Path). */
319
+ function requestPath(req: IncomingMessage): string {
320
+ const url = req.url ?? "/";
321
+ const q = url.indexOf("?");
322
+ return q === -1 ? url : url.slice(0, q);
323
+ }
324
+
325
+ /**
326
+ * Attach access logging to a request: on completion, log a 16-hex request id,
327
+ * method, path, status, and duration. Mirrors Go's requestLogger middleware.
328
+ */
329
+ function logAccess(req: IncomingMessage, res: ServerResponse): void {
330
+ const start = Date.now();
331
+ const requestId = randomBytes(8).toString("hex");
332
+ res.on("finish", () => {
333
+ log.info("http request", {
334
+ request_id: requestId,
335
+ method: req.method,
336
+ path: requestPath(req),
337
+ status: res.statusCode,
338
+ duration_ms: Date.now() - start,
339
+ });
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Serve the OAuth 2.0 Protected Resource Metadata document (RFC 9728), answering
345
+ * the CORS preflight (OPTIONS) and the GET. Mirrors the Go SDK's
346
+ * ProtectedResourceMetadataHandler output shape.
347
+ */
348
+ function serveProtectedResourceMetadata(
349
+ req: IncomingMessage,
350
+ res: ServerResponse,
351
+ cfg: Config,
352
+ ): void {
353
+ res.setHeader("Access-Control-Allow-Origin", "*");
354
+ if (req.method === "OPTIONS") {
355
+ res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS");
356
+ res.setHeader("Access-Control-Allow-Headers", "*");
357
+ res.writeHead(204);
358
+ res.end();
359
+ return;
360
+ }
361
+
362
+ const metadata: Record<string, unknown> = {
363
+ resource: cfg.oauth.resource,
364
+ authorization_servers: cfg.oauth.authorizationServers,
365
+ bearer_methods_supported: ["header"],
366
+ resource_name: "Stigmer MCP Server",
367
+ };
368
+ if (cfg.oauth.scopesSupported.length > 0) {
369
+ metadata.scopes_supported = cfg.oauth.scopesSupported;
370
+ }
371
+ res.writeHead(200, { "Content-Type": "application/json" });
372
+ res.end(JSON.stringify(metadata));
373
+ }
374
+
375
+ /**
376
+ * Build the WWW-Authenticate challenge pointing OAuth clients at this server's
377
+ * protected-resource-metadata document (RFC 9728 §5.1). Mirrors Go bearerChallenge.
378
+ */
379
+ function bearerChallenge(cfg: Config): string {
380
+ const metadataURL = cfg.oauth.resource.replace(/\/+$/, "") + PROTECTED_RESOURCE_METADATA_PATH;
381
+ const params = [`realm="stigmer"`, `resource_metadata="${metadataURL}"`];
382
+ if (cfg.oauth.scopesSupported.length > 0) {
383
+ params.push(`scope="${cfg.oauth.scopesSupported.join(" ")}"`);
384
+ }
385
+ return "Bearer " + params.join(", ");
386
+ }
387
+
388
+ /** Parse the "Authorization: Bearer <token>" header; "" when absent/malformed. */
389
+ function extractBearerToken(req: IncomingMessage): string {
390
+ const header = req.headers.authorization;
391
+ if (!header) return "";
392
+ const prefix = "Bearer ";
393
+ if (!header.startsWith(prefix)) return "";
394
+ return header.slice(prefix.length).trim();
395
+ }
396
+
397
+ /**
398
+ * Reports whether an error represents a clean client disconnect (EOF / broken
399
+ * pipe / abort) rather than a genuine failure, so discovery probes that connect
400
+ * and immediately disconnect do not cause a non-zero exit. Mirrors Go's
401
+ * isNormalShutdown.
402
+ */
403
+ export function isNormalShutdown(err: unknown): boolean {
404
+ if (err == null) return true;
405
+
406
+ const name = (err as { name?: string }).name;
407
+ if (name === "AbortError") return true;
408
+
409
+ const code = (err as NodeJS.ErrnoException).code;
410
+ if (code === "EPIPE" || code === "ABORT_ERR" || code === "ECONNRESET") return true;
411
+
412
+ const message = err instanceof Error ? err.message : String(err);
413
+ return message.includes("EOF") || message.includes("broken pipe");
414
+ }