u-foo 3.0.17 → 3.0.19

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,175 @@
1
+ "use strict";
2
+
3
+ const fs = require("fs");
4
+ const os = require("os");
5
+ const path = require("path");
6
+
7
+ const {
8
+ readConnectionFiles,
9
+ } = require("./mcpStdioProxy");
10
+ const {
11
+ resolveGlobalControllerProjectRoot,
12
+ } = require("../projects");
13
+
14
+ const MANAGED_BLOCK_START = "# >>> ufoo MCP (managed)";
15
+ const MANAGED_BLOCK_END = "# <<< ufoo MCP (managed)";
16
+
17
+ function tomlString(value = "") {
18
+ return JSON.stringify(String(value || ""));
19
+ }
20
+
21
+ function codexConfigPath(options = {}) {
22
+ if (options.configPath) return options.configPath;
23
+ const codexHome = String(options.codexHome || process.env.CODEX_HOME || "").trim();
24
+ return path.join(codexHome || path.join(os.homedir(), ".codex"), "config.toml");
25
+ }
26
+
27
+ function buildCodexManagedBlock(connection) {
28
+ return [
29
+ MANAGED_BLOCK_START,
30
+ "[mcp_servers.ufoo]",
31
+ `url = ${tomlString(connection.endpoint)}`,
32
+ `http_headers = { Authorization = ${tomlString(`Bearer ${connection.token}`)} }`,
33
+ "tool_timeout_sec = 610",
34
+ "enabled = true",
35
+ MANAGED_BLOCK_END,
36
+ ].join("\n");
37
+ }
38
+
39
+ function removeManagedBlock(text = "") {
40
+ const escapedStart = MANAGED_BLOCK_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
41
+ const escapedEnd = MANAGED_BLOCK_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
42
+ return String(text || "").replace(
43
+ new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?\\n${escapedEnd}(?=\\n|$)`, "g"),
44
+ ""
45
+ );
46
+ }
47
+
48
+ function findTomlSections(text = "") {
49
+ const sections = [];
50
+ const pattern = /^\s*\[([^\]]+)\]\s*(?:#.*)?$/gm;
51
+ let match;
52
+ while ((match = pattern.exec(text)) !== null) {
53
+ sections.push({
54
+ header: match[1].trim(),
55
+ start: match.index,
56
+ contentStart: pattern.lastIndex,
57
+ });
58
+ }
59
+ return sections.map((section, index) => ({
60
+ ...section,
61
+ end: index + 1 < sections.length ? sections[index + 1].start : text.length,
62
+ }));
63
+ }
64
+
65
+ function isUfooMainSection(header = "") {
66
+ return /^(?:mcp_servers\.ufoo|mcp_servers\."ufoo")$/.test(String(header || ""));
67
+ }
68
+
69
+ function isUfooStdioEnvSection(header = "") {
70
+ return /^(?:mcp_servers\.ufoo|mcp_servers\."ufoo")\.env$/.test(String(header || ""));
71
+ }
72
+
73
+ function removeLegacyUfooTransportSections(text = "") {
74
+ const sections = findTomlSections(text);
75
+ const ranges = sections
76
+ .filter((section) => isUfooMainSection(section.header) || isUfooStdioEnvSection(section.header))
77
+ .map((section) => [section.start, section.end])
78
+ .sort((a, b) => b[0] - a[0]);
79
+ let next = text;
80
+ for (const [start, end] of ranges) {
81
+ next = `${next.slice(0, start)}${next.slice(end)}`;
82
+ }
83
+ return next;
84
+ }
85
+
86
+ function renderCodexConfig(existing, connection) {
87
+ const withoutManaged = removeManagedBlock(existing);
88
+ const withoutLegacy = removeLegacyUfooTransportSections(withoutManaged);
89
+ const trimmed = withoutLegacy.trimEnd();
90
+ return `${trimmed ? `${trimmed}\n\n` : ""}${buildCodexManagedBlock(connection)}\n`;
91
+ }
92
+
93
+ function configureCodexMcp(options = {}) {
94
+ const projectRoot = options.projectRoot || resolveGlobalControllerProjectRoot();
95
+ const connection = options.connection || readConnectionFiles(projectRoot);
96
+ const target = codexConfigPath(options);
97
+ const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
98
+ const next = renderCodexConfig(existing, connection);
99
+ if (options.dryRun === true) {
100
+ const redacted = renderCodexConfig(existing, {
101
+ ...connection,
102
+ token: "<redacted>",
103
+ });
104
+ return {
105
+ ok: true,
106
+ dry_run: true,
107
+ target,
108
+ transport: "streamable_http",
109
+ endpoint: connection.endpoint,
110
+ changed: next !== existing,
111
+ content: redacted,
112
+ };
113
+ }
114
+
115
+ fs.mkdirSync(path.dirname(target), { recursive: true, mode: 0o700 });
116
+ let backup = "";
117
+ if (fs.existsSync(target) && next !== existing) {
118
+ backup = `${target}.ufoo-backup-${Date.now()}`;
119
+ fs.copyFileSync(target, backup);
120
+ try {
121
+ fs.chmodSync(backup, 0o600);
122
+ } catch {
123
+ // Best effort for filesystems without POSIX modes.
124
+ }
125
+ }
126
+ fs.writeFileSync(target, next, { encoding: "utf8", mode: 0o600 });
127
+ try {
128
+ fs.chmodSync(target, 0o600);
129
+ } catch {
130
+ // Best effort for filesystems without POSIX modes.
131
+ }
132
+ return {
133
+ ok: true,
134
+ dry_run: false,
135
+ target,
136
+ backup: backup || null,
137
+ transport: "streamable_http",
138
+ endpoint: connection.endpoint,
139
+ changed: next !== existing,
140
+ };
141
+ }
142
+
143
+ function runMcpConfigureCli(host, options = {}) {
144
+ const normalized = String(host || "").trim().toLowerCase();
145
+ if (normalized !== "codex") {
146
+ const err = new Error(
147
+ `Direct HTTP auto-configuration is verified only for Codex App/CLI/IDE; keep ${normalized || "this host"} on the stateless "ufoo mcp" stdio proxy`
148
+ );
149
+ err.code = "unsupported_mcp_host_config";
150
+ throw err;
151
+ }
152
+ const result = configureCodexMcp(options);
153
+ if (options.dryRun === true) {
154
+ process.stdout.write(result.content);
155
+ } else {
156
+ process.stdout.write(`Configured Codex MCP at ${result.target}\n`);
157
+ process.stdout.write(`Transport: Streamable HTTP ${result.endpoint}\n`);
158
+ if (result.backup) process.stdout.write(`Backup: ${result.backup}\n`);
159
+ process.stdout.write("Restart Codex App/CLI/IDE to load the shared MCP configuration.\n");
160
+ }
161
+ return result;
162
+ }
163
+
164
+ module.exports = {
165
+ MANAGED_BLOCK_END,
166
+ MANAGED_BLOCK_START,
167
+ buildCodexManagedBlock,
168
+ codexConfigPath,
169
+ configureCodexMcp,
170
+ findTomlSections,
171
+ removeLegacyUfooTransportSections,
172
+ removeManagedBlock,
173
+ renderCodexConfig,
174
+ runMcpConfigureCli,
175
+ };
@@ -0,0 +1,123 @@
1
+ "use strict";
2
+
3
+ const net = require("net");
4
+
5
+ const {
6
+ IPC_REQUEST_TYPES,
7
+ IPC_RESPONSE_TYPES,
8
+ } = require("../contracts/eventContract");
9
+ const {
10
+ isRunning,
11
+ socketPath,
12
+ } = require("./index");
13
+ const {
14
+ resolveGlobalControllerProjectRoot,
15
+ } = require("../projects");
16
+
17
+ function requestMcpControl(operation, options = {}) {
18
+ const projectRoot = options.projectRoot || resolveGlobalControllerProjectRoot();
19
+ const checkRunning = options.isRunning || isRunning;
20
+ const resolveSocketPath = options.socketPath || socketPath;
21
+ const connect = options.connect || ((target) => net.createConnection(target));
22
+ const requestType = operation === "restart"
23
+ ? IPC_REQUEST_TYPES.MCP_RESTART
24
+ : IPC_REQUEST_TYPES.MCP_STATUS;
25
+ if (!checkRunning(projectRoot)) {
26
+ const err = new Error("Global controller daemon is not running");
27
+ err.code = "global_daemon_not_running";
28
+ return Promise.reject(err);
29
+ }
30
+
31
+ return new Promise((resolve, reject) => {
32
+ const client = connect(resolveSocketPath(projectRoot));
33
+ let buffer = "";
34
+ let settled = false;
35
+ const timeoutMs = Number(options.timeoutMs) || 10000;
36
+ let timer = null;
37
+
38
+ const cleanup = () => {
39
+ if (timer) clearTimeout(timer);
40
+ timer = null;
41
+ client.removeAllListeners();
42
+ try {
43
+ client.end();
44
+ } catch {
45
+ // ignore
46
+ }
47
+ };
48
+ const finishResolve = (value) => {
49
+ if (settled) return;
50
+ settled = true;
51
+ cleanup();
52
+ resolve(value);
53
+ };
54
+ const finishReject = (err) => {
55
+ if (settled) return;
56
+ settled = true;
57
+ cleanup();
58
+ reject(err);
59
+ };
60
+
61
+ timer = setTimeout(() => {
62
+ finishReject(Object.assign(new Error("MCP control request timed out"), {
63
+ code: "mcp_control_timeout",
64
+ }));
65
+ }, timeoutMs);
66
+ if (typeof timer.unref === "function") timer.unref();
67
+
68
+ client.on("connect", () => {
69
+ client.write(`${JSON.stringify({ type: requestType })}\n`);
70
+ });
71
+ client.on("data", (chunk) => {
72
+ buffer += chunk.toString("utf8");
73
+ const lines = buffer.split(/\r?\n/);
74
+ buffer = lines.pop() || "";
75
+ for (const line of lines) {
76
+ if (!line.trim()) continue;
77
+ let response;
78
+ try {
79
+ response = JSON.parse(line);
80
+ } catch {
81
+ continue;
82
+ }
83
+ if (response.type === IPC_RESPONSE_TYPES.ERROR) {
84
+ const err = new Error(response.error || "MCP control failed");
85
+ err.code = response.code || "mcp_control_error";
86
+ finishReject(err);
87
+ return;
88
+ }
89
+ if (response.type === IPC_RESPONSE_TYPES.RESPONSE && response.data?.mcp) {
90
+ finishResolve(response.data);
91
+ return;
92
+ }
93
+ }
94
+ });
95
+ client.once("error", finishReject);
96
+ client.once("close", () => {
97
+ finishReject(Object.assign(new Error("Global controller closed the MCP control request"), {
98
+ code: "mcp_control_closed",
99
+ }));
100
+ });
101
+ });
102
+ }
103
+
104
+ async function runMcpControlCli(operation, options = {}) {
105
+ const result = await requestMcpControl(operation, options);
106
+ if (options.json === true) {
107
+ process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
108
+ return result;
109
+ }
110
+ const status = result.mcp || {};
111
+ process.stdout.write(`MCP ${status.running ? "running" : "stopped"}\n`);
112
+ if (status.endpoint) process.stdout.write(`Endpoint: ${status.endpoint}\n`);
113
+ if (status.pid) process.stdout.write(`PID: ${status.pid}\n`);
114
+ process.stdout.write(`Sessions: ${status.session_count || 0}\n`);
115
+ process.stdout.write(`Active requests: ${status.active_request_count || 0}\n`);
116
+ process.stdout.write(`Active waits: ${status.active_wait_count || 0}\n`);
117
+ return result;
118
+ }
119
+
120
+ module.exports = {
121
+ requestMcpControl,
122
+ runMcpControlCli,
123
+ };
@@ -0,0 +1,412 @@
1
+ "use strict";
2
+
3
+ const crypto = require("crypto");
4
+ const fs = require("fs");
5
+ const http = require("http");
6
+ const path = require("path");
7
+
8
+ const { Server } = require("@modelcontextprotocol/sdk/server/index.js");
9
+ const {
10
+ CallToolRequestSchema,
11
+ ListToolsRequestSchema,
12
+ isInitializeRequest,
13
+ } = require("@modelcontextprotocol/sdk/types.js");
14
+ const {
15
+ StreamableHTTPServerTransport,
16
+ } = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
17
+
18
+ const { getUfooPaths } = require("../../coordination/state/paths");
19
+ const {
20
+ buildToolList,
21
+ createMcpContent,
22
+ invokeTool,
23
+ } = require("./mcpServer");
24
+ const {
25
+ createSocketProjectRuntimeGateway,
26
+ } = require("./projectRuntimeGateway");
27
+
28
+ const PACKAGE_JSON = require("../../../package.json");
29
+ const DEFAULT_MCP_HOST = "127.0.0.1";
30
+ const DEFAULT_MCP_PORT = 47631;
31
+ const MAX_REQUEST_BODY_BYTES = 2 * 1024 * 1024;
32
+ const LOCAL_HOSTNAMES = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
33
+
34
+ function ensurePrivateFile(filePath, createValue) {
35
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
36
+ try {
37
+ fs.chmodSync(path.dirname(filePath), 0o700);
38
+ } catch {
39
+ // Best effort for filesystems without POSIX modes.
40
+ }
41
+ if (!fs.existsSync(filePath)) {
42
+ fs.writeFileSync(filePath, createValue(), { encoding: "utf8", mode: 0o600, flag: "wx" });
43
+ }
44
+ try {
45
+ fs.chmodSync(filePath, 0o600);
46
+ } catch {
47
+ // Best effort for filesystems without POSIX modes.
48
+ }
49
+ return String(fs.readFileSync(filePath, "utf8") || "").trim();
50
+ }
51
+
52
+ function loadOrCreateMcpToken(tokenPath) {
53
+ const token = ensurePrivateFile(tokenPath, () => `${crypto.randomBytes(32).toString("base64url")}\n`);
54
+ if (!token) {
55
+ const err = new Error(`MCP bearer token is empty: ${tokenPath}`);
56
+ err.code = "UFOO_MCP_EMPTY_TOKEN";
57
+ throw err;
58
+ }
59
+ return token;
60
+ }
61
+
62
+ function safeTokenEquals(left, right) {
63
+ const a = Buffer.from(String(left || ""), "utf8");
64
+ const b = Buffer.from(String(right || ""), "utf8");
65
+ return a.length === b.length && crypto.timingSafeEqual(a, b);
66
+ }
67
+
68
+ function parseBearerToken(header = "") {
69
+ const match = String(header || "").match(/^Bearer[ \t]+(.+)$/i);
70
+ return match ? match[1].trim() : "";
71
+ }
72
+
73
+ function isAllowedOrigin(origin = "") {
74
+ const value = String(origin || "").trim();
75
+ if (!value) return true;
76
+ try {
77
+ const parsed = new URL(value);
78
+ return (parsed.protocol === "http:" || parsed.protocol === "https:")
79
+ && LOCAL_HOSTNAMES.has(parsed.hostname);
80
+ } catch {
81
+ return false;
82
+ }
83
+ }
84
+
85
+ function isAllowedHost(hostHeader = "", port) {
86
+ const value = String(hostHeader || "").trim();
87
+ if (!value) return false;
88
+ try {
89
+ const parsed = new URL(`http://${value}`);
90
+ if (!LOCAL_HOSTNAMES.has(parsed.hostname)) return false;
91
+ if (!parsed.port) return Number(port) === 80;
92
+ return Number(parsed.port) === Number(port);
93
+ } catch {
94
+ return false;
95
+ }
96
+ }
97
+
98
+ function jsonResponse(res, statusCode, payload, headers = {}) {
99
+ if (res.headersSent) return;
100
+ const body = JSON.stringify(payload);
101
+ res.writeHead(statusCode, {
102
+ "content-type": "application/json; charset=utf-8",
103
+ "content-length": Buffer.byteLength(body),
104
+ ...headers,
105
+ });
106
+ res.end(body);
107
+ }
108
+
109
+ function rpcError(res, statusCode, message, code = -32000) {
110
+ jsonResponse(res, statusCode, {
111
+ jsonrpc: "2.0",
112
+ error: { code, message },
113
+ id: null,
114
+ });
115
+ }
116
+
117
+ function readJsonBody(req, maxBytes = MAX_REQUEST_BODY_BYTES) {
118
+ return new Promise((resolve, reject) => {
119
+ const chunks = [];
120
+ let size = 0;
121
+ req.on("data", (chunk) => {
122
+ size += chunk.length;
123
+ if (size > maxBytes) {
124
+ const err = new Error("MCP request body is too large");
125
+ err.code = "UFOO_MCP_BODY_TOO_LARGE";
126
+ reject(err);
127
+ req.destroy();
128
+ return;
129
+ }
130
+ chunks.push(chunk);
131
+ });
132
+ req.on("end", () => {
133
+ try {
134
+ const text = Buffer.concat(chunks).toString("utf8");
135
+ resolve(text ? JSON.parse(text) : undefined);
136
+ } catch (err) {
137
+ err.code = "UFOO_MCP_INVALID_JSON";
138
+ reject(err);
139
+ }
140
+ });
141
+ req.on("error", reject);
142
+ });
143
+ }
144
+
145
+ class GlobalMcpHttpServer {
146
+ constructor(options = {}) {
147
+ this.host = options.host || DEFAULT_MCP_HOST;
148
+ this.port = Number.isInteger(options.port) ? options.port : DEFAULT_MCP_PORT;
149
+ this.projectRoot = options.projectRoot;
150
+ const paths = getUfooPaths(this.projectRoot);
151
+ this.tokenPath = options.tokenPath || paths.mcpToken;
152
+ this.endpointPath = options.endpointPath || paths.mcpEndpoint;
153
+ this.token = options.token || "";
154
+ this.log = typeof options.log === "function" ? options.log : () => {};
155
+ this.projectRuntimeGateway = options.projectRuntimeGateway
156
+ || createSocketProjectRuntimeGateway();
157
+ this.validateProjectRoot = options.validateProjectRoot !== false;
158
+ this.sessions = new Map();
159
+ this.activeRequests = new Map();
160
+ this.httpRequestCount = 0;
161
+ this.server = null;
162
+ this.startedAt = "";
163
+ this.stopping = false;
164
+ }
165
+
166
+ get endpoint() {
167
+ return `http://${this.host}:${this.port}/mcp`;
168
+ }
169
+
170
+ getStatus() {
171
+ let activeWaits = 0;
172
+ for (const request of this.activeRequests.values()) {
173
+ if (request.tool === "wait_for_message") activeWaits += 1;
174
+ }
175
+ return {
176
+ running: Boolean(this.server && this.server.listening),
177
+ pid: process.pid,
178
+ version: PACKAGE_JSON.version || "0.0.0",
179
+ endpoint: this.endpoint,
180
+ session_count: this.sessions.size,
181
+ active_request_count: this.activeRequests.size,
182
+ active_wait_count: activeWaits,
183
+ http_request_count: this.httpRequestCount,
184
+ started_at: this.startedAt || null,
185
+ };
186
+ }
187
+
188
+ createProtocolServer() {
189
+ const protocolServer = new Server({
190
+ name: "ufoo-global-mcp",
191
+ version: PACKAGE_JSON.version || "0.0.0",
192
+ }, {
193
+ capabilities: {
194
+ tools: { listChanged: false },
195
+ },
196
+ instructions: "Route every project-scoped tool through a project_root returned by read_project_registry.",
197
+ });
198
+
199
+ protocolServer.setRequestHandler(ListToolsRequestSchema, async () => ({
200
+ tools: buildToolList(),
201
+ }));
202
+
203
+ protocolServer.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
204
+ const name = String(request.params.name || "").trim();
205
+ const args = request.params.arguments && typeof request.params.arguments === "object"
206
+ ? request.params.arguments
207
+ : {};
208
+ const requestKey = `${extra.sessionId || "no-session"}:${String(extra.requestId)}`;
209
+ this.activeRequests.set(requestKey, {
210
+ tool: name,
211
+ started_at: new Date().toISOString(),
212
+ });
213
+ try {
214
+ const result = await invokeTool(name, args, {
215
+ projectRuntimeGateway: this.projectRuntimeGateway,
216
+ validateProjectRoot: this.validateProjectRoot,
217
+ toolCallId: extra.requestId,
218
+ signal: extra.signal,
219
+ getMcpHttpStatus: () => this.getStatus(),
220
+ });
221
+ return createMcpContent(result);
222
+ } finally {
223
+ this.activeRequests.delete(requestKey);
224
+ }
225
+ });
226
+ return protocolServer;
227
+ }
228
+
229
+ createSession() {
230
+ let transport;
231
+ const protocolServer = this.createProtocolServer();
232
+ transport = new StreamableHTTPServerTransport({
233
+ sessionIdGenerator: () => crypto.randomUUID(),
234
+ enableJsonResponse: true,
235
+ onsessioninitialized: (sessionId) => {
236
+ this.sessions.set(sessionId, { transport, protocolServer });
237
+ },
238
+ });
239
+ transport.onclose = () => {
240
+ const sessionId = transport.sessionId;
241
+ if (sessionId) this.sessions.delete(sessionId);
242
+ };
243
+ return { transport, protocolServer };
244
+ }
245
+
246
+ authenticate(req, res) {
247
+ if (!isAllowedHost(req.headers.host, this.port)) {
248
+ rpcError(res, 403, "Forbidden host");
249
+ return false;
250
+ }
251
+ if (!isAllowedOrigin(req.headers.origin)) {
252
+ rpcError(res, 403, "Forbidden origin");
253
+ return false;
254
+ }
255
+ const provided = parseBearerToken(req.headers.authorization);
256
+ if (!provided || !safeTokenEquals(provided, this.token)) {
257
+ rpcError(res, 401, "Unauthorized", -32001);
258
+ return false;
259
+ }
260
+ return true;
261
+ }
262
+
263
+ async handleMcpRequest(req, res) {
264
+ if (!this.authenticate(req, res)) return;
265
+ const sessionId = String(req.headers["mcp-session-id"] || "").trim();
266
+ let session = sessionId ? this.sessions.get(sessionId) : null;
267
+
268
+ if (req.method === "POST") {
269
+ let body;
270
+ try {
271
+ body = await readJsonBody(req);
272
+ } catch (err) {
273
+ const status = err && err.code === "UFOO_MCP_BODY_TOO_LARGE" ? 413 : 400;
274
+ rpcError(res, status, err.message || "Invalid MCP request", -32700);
275
+ return;
276
+ }
277
+ if (!session && !sessionId && isInitializeRequest(body)) {
278
+ session = this.createSession();
279
+ await session.protocolServer.connect(session.transport);
280
+ } else if (!session) {
281
+ rpcError(res, 400, "Bad Request: no valid MCP session");
282
+ return;
283
+ }
284
+ await session.transport.handleRequest(req, res, body);
285
+ return;
286
+ }
287
+
288
+ if ((req.method === "GET" || req.method === "DELETE") && session) {
289
+ await session.transport.handleRequest(req, res);
290
+ return;
291
+ }
292
+
293
+ rpcError(res, sessionId ? 400 : 405, sessionId
294
+ ? "Bad Request: no valid MCP session"
295
+ : "Method not allowed");
296
+ }
297
+
298
+ async handleHttpRequest(req, res) {
299
+ this.httpRequestCount += 1;
300
+ const requestUrl = new URL(req.url || "/", `http://${req.headers.host || "localhost"}`);
301
+ if (requestUrl.pathname === "/health") {
302
+ jsonResponse(res, 200, {
303
+ ok: true,
304
+ service: "ufoo-global-mcp",
305
+ version: PACKAGE_JSON.version || "0.0.0",
306
+ });
307
+ return;
308
+ }
309
+ if (requestUrl.pathname !== "/mcp") {
310
+ jsonResponse(res, 404, { ok: false, error: "not_found" });
311
+ return;
312
+ }
313
+ try {
314
+ await this.handleMcpRequest(req, res);
315
+ } catch (err) {
316
+ this.log(`MCP HTTP request failed: ${err.message || err}`);
317
+ rpcError(res, 500, "Internal MCP server error", -32603);
318
+ }
319
+ }
320
+
321
+ writeEndpointFile() {
322
+ fs.mkdirSync(path.dirname(this.endpointPath), { recursive: true, mode: 0o700 });
323
+ const payload = {
324
+ version: 1,
325
+ endpoint: this.endpoint,
326
+ token_path: this.tokenPath,
327
+ pid: process.pid,
328
+ started_at: this.startedAt,
329
+ };
330
+ fs.writeFileSync(this.endpointPath, `${JSON.stringify(payload, null, 2)}\n`, {
331
+ encoding: "utf8",
332
+ mode: 0o600,
333
+ });
334
+ try {
335
+ fs.chmodSync(this.endpointPath, 0o600);
336
+ } catch {
337
+ // Best effort for filesystems without POSIX modes.
338
+ }
339
+ }
340
+
341
+ async start() {
342
+ if (this.server && this.server.listening) return this.getStatus();
343
+ if (this.host !== "127.0.0.1" && this.host !== "::1") {
344
+ const err = new Error(`MCP HTTP host must be loopback, got: ${this.host}`);
345
+ err.code = "UFOO_MCP_NON_LOOPBACK_HOST";
346
+ throw err;
347
+ }
348
+ this.token = this.token || loadOrCreateMcpToken(this.tokenPath);
349
+ this.server = http.createServer((req, res) => {
350
+ void this.handleHttpRequest(req, res);
351
+ });
352
+ this.server.on("clientError", (_err, socket) => {
353
+ if (socket.writable) socket.end("HTTP/1.1 400 Bad Request\r\n\r\n");
354
+ });
355
+ await new Promise((resolve, reject) => {
356
+ const onError = (err) => {
357
+ this.server.off("listening", onListening);
358
+ reject(err);
359
+ };
360
+ const onListening = () => {
361
+ this.server.off("error", onError);
362
+ resolve();
363
+ };
364
+ this.server.once("error", onError);
365
+ this.server.once("listening", onListening);
366
+ this.server.listen(this.port, this.host);
367
+ });
368
+ const address = this.server.address();
369
+ if (address && typeof address === "object") this.port = address.port;
370
+ this.startedAt = new Date().toISOString();
371
+ this.writeEndpointFile();
372
+ this.log(`MCP Streamable HTTP listening at ${this.endpoint}`);
373
+ return this.getStatus();
374
+ }
375
+
376
+ async stop() {
377
+ if (this.stopping) return;
378
+ this.stopping = true;
379
+ const sessions = Array.from(this.sessions.values());
380
+ this.sessions.clear();
381
+ await Promise.allSettled(sessions.map(async ({ transport, protocolServer }) => {
382
+ await transport.close();
383
+ await protocolServer.close();
384
+ }));
385
+ if (this.server) {
386
+ const server = this.server;
387
+ this.server = null;
388
+ await new Promise((resolve) => server.close(() => resolve()));
389
+ }
390
+ try {
391
+ if (fs.existsSync(this.endpointPath)) fs.unlinkSync(this.endpointPath);
392
+ } catch {
393
+ // Best effort; a PID mismatch is still visible in status diagnostics.
394
+ }
395
+ this.stopping = false;
396
+ }
397
+ }
398
+
399
+ function createGlobalMcpHttpServer(options = {}) {
400
+ return new GlobalMcpHttpServer(options);
401
+ }
402
+
403
+ module.exports = {
404
+ DEFAULT_MCP_HOST,
405
+ DEFAULT_MCP_PORT,
406
+ GlobalMcpHttpServer,
407
+ createGlobalMcpHttpServer,
408
+ isAllowedHost,
409
+ isAllowedOrigin,
410
+ loadOrCreateMcpToken,
411
+ parseBearerToken,
412
+ };