@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.
- package/LICENSE +190 -0
- package/README.md +157 -0
- package/cli/mcp-server-stigmer.d.ts +3 -0
- package/cli/mcp-server-stigmer.d.ts.map +1 -0
- package/cli/mcp-server-stigmer.js +201 -0
- package/cli/mcp-server-stigmer.js.map +1 -0
- package/config.d.ts +44 -0
- package/config.d.ts.map +1 -0
- package/config.js +92 -0
- package/config.js.map +1 -0
- package/domains/agents/apply.d.ts +4 -0
- package/domains/agents/apply.d.ts.map +1 -0
- package/domains/agents/apply.js +25 -0
- package/domains/agents/apply.js.map +1 -0
- package/domains/agents/delete.d.ts +3 -0
- package/domains/agents/delete.d.ts.map +1 -0
- package/domains/agents/delete.js +35 -0
- package/domains/agents/delete.js.map +1 -0
- package/domains/agents/fetch.d.ts +6 -0
- package/domains/agents/fetch.d.ts.map +1 -0
- package/domains/agents/fetch.js +24 -0
- package/domains/agents/fetch.js.map +1 -0
- package/domains/agents/resources.d.ts +5 -0
- package/domains/agents/resources.d.ts.map +1 -0
- package/domains/agents/resources.js +16 -0
- package/domains/agents/resources.js.map +1 -0
- package/domains/agents/tools.d.ts +5 -0
- package/domains/agents/tools.d.ts.map +1 -0
- package/domains/agents/tools.js +41 -0
- package/domains/agents/tools.js.map +1 -0
- package/domains/client.d.ts +53 -0
- package/domains/client.d.ts.map +1 -0
- package/domains/client.js +62 -0
- package/domains/client.js.map +1 -0
- package/domains/marshal.d.ts +8 -0
- package/domains/marshal.d.ts.map +1 -0
- package/domains/marshal.js +17 -0
- package/domains/marshal.js.map +1 -0
- package/domains/mcpservers/apply.d.ts +4 -0
- package/domains/mcpservers/apply.d.ts.map +1 -0
- package/domains/mcpservers/apply.js +26 -0
- package/domains/mcpservers/apply.js.map +1 -0
- package/domains/mcpservers/delete.d.ts +6 -0
- package/domains/mcpservers/delete.d.ts.map +1 -0
- package/domains/mcpservers/delete.js +42 -0
- package/domains/mcpservers/delete.js.map +1 -0
- package/domains/mcpservers/fetch.d.ts +7 -0
- package/domains/mcpservers/fetch.d.ts.map +1 -0
- package/domains/mcpservers/fetch.js +26 -0
- package/domains/mcpservers/fetch.js.map +1 -0
- package/domains/mcpservers/resources.d.ts +5 -0
- package/domains/mcpservers/resources.d.ts.map +1 -0
- package/domains/mcpservers/resources.js +16 -0
- package/domains/mcpservers/resources.js.map +1 -0
- package/domains/mcpservers/tools.d.ts +5 -0
- package/domains/mcpservers/tools.d.ts.map +1 -0
- package/domains/mcpservers/tools.js +39 -0
- package/domains/mcpservers/tools.js.map +1 -0
- package/domains/resourcehandler.d.ts +27 -0
- package/domains/resourcehandler.d.ts.map +1 -0
- package/domains/resourcehandler.js +32 -0
- package/domains/resourcehandler.js.map +1 -0
- package/domains/resourceuri.d.ts +34 -0
- package/domains/resourceuri.d.ts.map +1 -0
- package/domains/resourceuri.js +100 -0
- package/domains/resourceuri.js.map +1 -0
- package/domains/rpcerr.d.ts +8 -0
- package/domains/rpcerr.d.ts.map +1 -0
- package/domains/rpcerr.js +42 -0
- package/domains/rpcerr.js.map +1 -0
- package/domains/search/tools.d.ts +5 -0
- package/domains/search/tools.d.ts.map +1 -0
- package/domains/search/tools.js +125 -0
- package/domains/search/tools.js.map +1 -0
- package/domains/skills/delete.d.ts +6 -0
- package/domains/skills/delete.d.ts.map +1 -0
- package/domains/skills/delete.js +38 -0
- package/domains/skills/delete.js.map +1 -0
- package/domains/skills/fetch.d.ts +6 -0
- package/domains/skills/fetch.d.ts.map +1 -0
- package/domains/skills/fetch.js +28 -0
- package/domains/skills/fetch.js.map +1 -0
- package/domains/skills/resources.d.ts +5 -0
- package/domains/skills/resources.d.ts.map +1 -0
- package/domains/skills/resources.js +25 -0
- package/domains/skills/resources.js.map +1 -0
- package/domains/skills/tools.d.ts +5 -0
- package/domains/skills/tools.d.ts.map +1 -0
- package/domains/skills/tools.js +39 -0
- package/domains/skills/tools.js.map +1 -0
- package/domains/toolresult.d.ts +12 -0
- package/domains/toolresult.d.ts.map +1 -0
- package/domains/toolresult.js +30 -0
- package/domains/toolresult.js.map +1 -0
- package/domains/workflowexecutions/tools.d.ts +5 -0
- package/domains/workflowexecutions/tools.d.ts.map +1 -0
- package/domains/workflowexecutions/tools.js +80 -0
- package/domains/workflowexecutions/tools.js.map +1 -0
- package/domains/workflows/apply.d.ts +4 -0
- package/domains/workflows/apply.d.ts.map +1 -0
- package/domains/workflows/apply.js +30 -0
- package/domains/workflows/apply.js.map +1 -0
- package/domains/workflows/delete.d.ts +3 -0
- package/domains/workflows/delete.d.ts.map +1 -0
- package/domains/workflows/delete.js +35 -0
- package/domains/workflows/delete.js.map +1 -0
- package/domains/workflows/fetch.d.ts +6 -0
- package/domains/workflows/fetch.d.ts.map +1 -0
- package/domains/workflows/fetch.js +25 -0
- package/domains/workflows/fetch.js.map +1 -0
- package/domains/workflows/resources.d.ts +5 -0
- package/domains/workflows/resources.d.ts.map +1 -0
- package/domains/workflows/resources.js +16 -0
- package/domains/workflows/resources.js.map +1 -0
- package/domains/workflows/taskkinds.d.ts +5 -0
- package/domains/workflows/taskkinds.d.ts.map +1 -0
- package/domains/workflows/taskkinds.js +66 -0
- package/domains/workflows/taskkinds.js.map +1 -0
- package/domains/workflows/tools.d.ts +5 -0
- package/domains/workflows/tools.d.ts.map +1 -0
- package/domains/workflows/tools.js +35 -0
- package/domains/workflows/tools.js.map +1 -0
- package/domains/workflows/validate.d.ts +5 -0
- package/domains/workflows/validate.d.ts.map +1 -0
- package/domains/workflows/validate.js +113 -0
- package/domains/workflows/validate.js.map +1 -0
- package/gen/agent.d.ts +385 -0
- package/gen/agent.d.ts.map +1 -0
- package/gen/agent.js +170 -0
- package/gen/agent.js.map +1 -0
- package/gen/apply-runtime.d.ts +18 -0
- package/gen/apply-runtime.d.ts.map +1 -0
- package/gen/apply-runtime.js +50 -0
- package/gen/apply-runtime.js.map +1 -0
- package/gen/mcpserver.d.ts +289 -0
- package/gen/mcpserver.d.ts.map +1 -0
- package/gen/mcpserver.js +166 -0
- package/gen/mcpserver.js.map +1 -0
- package/gen/workflow.d.ts +805 -0
- package/gen/workflow.d.ts.map +1 -0
- package/gen/workflow.js +842 -0
- package/gen/workflow.js.map +1 -0
- package/index.d.ts +20 -0
- package/index.d.ts.map +1 -0
- package/index.js +58 -0
- package/index.js.map +1 -0
- package/logger.d.ts +20 -0
- package/logger.d.ts.map +1 -0
- package/logger.js +41 -0
- package/logger.js.map +1 -0
- package/package.json +43 -0
- package/server.d.ts +60 -0
- package/server.d.ts.map +1 -0
- package/server.js +366 -0
- package/server.js.map +1 -0
- package/src/cli/mcp-server-stigmer.ts +42 -0
- package/src/config.test.ts +88 -0
- package/src/config.ts +151 -0
- package/src/domains/agents/apply.ts +30 -0
- package/src/domains/agents/delete.ts +41 -0
- package/src/domains/agents/fetch.ts +33 -0
- package/src/domains/agents/resources.ts +20 -0
- package/src/domains/agents/tools.ts +68 -0
- package/src/domains/apply.integration.test.ts +220 -0
- package/src/domains/client.ts +95 -0
- package/src/domains/deletes.integration.test.ts +124 -0
- package/src/domains/marshal.ts +21 -0
- package/src/domains/mcpservers/apply.ts +36 -0
- package/src/domains/mcpservers/delete.ts +51 -0
- package/src/domains/mcpservers/fetch.ts +35 -0
- package/src/domains/mcpservers/resources.ts +20 -0
- package/src/domains/mcpservers/tools.ts +74 -0
- package/src/domains/reads.integration.test.ts +134 -0
- package/src/domains/resourcehandler.ts +90 -0
- package/src/domains/resources.integration.test.ts +139 -0
- package/src/domains/resourceuri.test.ts +97 -0
- package/src/domains/resourceuri.ts +124 -0
- package/src/domains/rpcerr.test.ts +62 -0
- package/src/domains/rpcerr.ts +46 -0
- package/src/domains/search/search.integration.test.ts +127 -0
- package/src/domains/search/tools.ts +160 -0
- package/src/domains/skills/delete.ts +44 -0
- package/src/domains/skills/fetch.ts +38 -0
- package/src/domains/skills/resources.ts +33 -0
- package/src/domains/skills/tools.ts +67 -0
- package/src/domains/toolresult.ts +33 -0
- package/src/domains/workflowexecutions/tools.ts +133 -0
- package/src/domains/workflows/apply.ts +40 -0
- package/src/domains/workflows/delete.ts +44 -0
- package/src/domains/workflows/fetch.ts +34 -0
- package/src/domains/workflows/resources.ts +20 -0
- package/src/domains/workflows/taskkinds.ts +103 -0
- package/src/domains/workflows/tools.ts +68 -0
- package/src/domains/workflows/validate.integration.test.ts +117 -0
- package/src/domains/workflows/validate.ts +144 -0
- package/src/domains/workflows/workflow-tools.integration.test.ts +148 -0
- package/src/gen/agent.ts +173 -0
- package/src/gen/apply-runtime.ts +52 -0
- package/src/gen/mcpserver.ts +163 -0
- package/src/gen/workflow.ts +858 -0
- package/src/http.integration.test.ts +140 -0
- package/src/index.ts +66 -0
- package/src/logger.ts +49 -0
- package/src/server.integration.test.ts +82 -0
- 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
|
+
}
|