u-foo 3.0.18 → 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.
- package/OPTIONAL_SKILLS/ufoo-bus-poll/SKILL.md +10 -6
- package/README.md +37 -17
- package/README.zh-CN.md +30 -19
- package/SKILLS/ufoo/SKILL.md +16 -8
- package/SKILLS/ufoo-bus/SKILL.md +16 -7
- package/bin/ufoo.js +19 -5
- package/package.json +2 -1
- package/src/app/chat/commandExecutor.js +11 -5
- package/src/app/cli/run.js +2 -3
- package/src/config.js +10 -0
- package/src/coordination/bus/subscriber.js +32 -0
- package/src/coordination/state/paths.js +4 -0
- package/src/runtime/contracts/eventContract.js +5 -0
- package/src/runtime/daemon/controlPlaneService.js +130 -12
- package/src/runtime/daemon/index.js +65 -0
- package/src/runtime/daemon/mcpConfigure.js +175 -0
- package/src/runtime/daemon/mcpControl.js +123 -0
- package/src/runtime/daemon/mcpHttpServer.js +412 -0
- package/src/runtime/daemon/mcpServer.js +94 -103
- package/src/runtime/daemon/mcpStdioProxy.js +247 -0
- package/src/runtime/daemon/projectRuntimeControlPlane.js +115 -0
- package/src/runtime/daemon/projectRuntimeGateway.js +358 -0
|
@@ -7,7 +7,10 @@ const { spawn } = require("child_process");
|
|
|
7
7
|
|
|
8
8
|
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
9
9
|
const { isRunning, socketPath } = require("./index");
|
|
10
|
-
const
|
|
10
|
+
const {
|
|
11
|
+
MCP_EXPOSED_SHARED_TOOLS,
|
|
12
|
+
createLocalProjectRuntimeGateway,
|
|
13
|
+
} = require("./projectRuntimeGateway");
|
|
11
14
|
const {
|
|
12
15
|
normalizeProjectRoot,
|
|
13
16
|
resolveGlobalControllerProjectRoot,
|
|
@@ -30,15 +33,8 @@ const {
|
|
|
30
33
|
const PACKAGE_ROOT = path.resolve(__dirname, "..", "..", "..");
|
|
31
34
|
const PACKAGE_JSON = require(path.join(PACKAGE_ROOT, "package.json"));
|
|
32
35
|
|
|
33
|
-
const EXPOSED_SHARED_TOOLS =
|
|
34
|
-
|
|
35
|
-
"read_bus_summary",
|
|
36
|
-
"read_prompt_history",
|
|
37
|
-
"read_open_decisions",
|
|
38
|
-
"list_agents",
|
|
39
|
-
"dispatch_message",
|
|
40
|
-
"ack_bus",
|
|
41
|
-
]);
|
|
36
|
+
const EXPOSED_SHARED_TOOLS = MCP_EXPOSED_SHARED_TOOLS;
|
|
37
|
+
const DEFAULT_PROJECT_RUNTIME_GATEWAY = createLocalProjectRuntimeGateway();
|
|
42
38
|
|
|
43
39
|
const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
44
40
|
{
|
|
@@ -65,6 +61,10 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
65
61
|
scoped_nickname: { type: "string" },
|
|
66
62
|
launch_mode: { type: "string" },
|
|
67
63
|
capabilities: { type: "object", additionalProperties: true },
|
|
64
|
+
client_instance_id: {
|
|
65
|
+
type: "string",
|
|
66
|
+
description: "Stable host-local instance id used to recover the same registration after transport restarts.",
|
|
67
|
+
},
|
|
68
68
|
},
|
|
69
69
|
additionalProperties: false,
|
|
70
70
|
},
|
|
@@ -75,10 +75,11 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
75
75
|
description: "Refresh a registered agent heartbeat in its project bus.",
|
|
76
76
|
input_schema: {
|
|
77
77
|
type: "object",
|
|
78
|
-
required: ["project_root", "subscriber"],
|
|
78
|
+
required: ["project_root", "subscriber", "agent_handle"],
|
|
79
79
|
properties: {
|
|
80
80
|
project_root: { type: "string" },
|
|
81
81
|
subscriber: { type: "string" },
|
|
82
|
+
agent_handle: { type: "string" },
|
|
82
83
|
},
|
|
83
84
|
additionalProperties: false,
|
|
84
85
|
},
|
|
@@ -89,10 +90,11 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
89
90
|
description: "Publish the caller agent activity state in its project bus metadata.",
|
|
90
91
|
input_schema: {
|
|
91
92
|
type: "object",
|
|
92
|
-
required: ["project_root", "subscriber", "activity_state"],
|
|
93
|
+
required: ["project_root", "subscriber", "agent_handle", "activity_state"],
|
|
93
94
|
properties: {
|
|
94
95
|
project_root: { type: "string" },
|
|
95
96
|
subscriber: { type: "string" },
|
|
97
|
+
agent_handle: { type: "string" },
|
|
96
98
|
activity_state: { type: "string" },
|
|
97
99
|
detail: { type: "string" },
|
|
98
100
|
since: { type: "string" },
|
|
@@ -106,10 +108,11 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
106
108
|
description: "Update the caller agent nickname or MCP metadata in its project bus.",
|
|
107
109
|
input_schema: {
|
|
108
110
|
type: "object",
|
|
109
|
-
required: ["project_root", "subscriber"],
|
|
111
|
+
required: ["project_root", "subscriber", "agent_handle"],
|
|
110
112
|
properties: {
|
|
111
113
|
project_root: { type: "string" },
|
|
112
114
|
subscriber: { type: "string" },
|
|
115
|
+
agent_handle: { type: "string" },
|
|
113
116
|
nickname: { type: "string" },
|
|
114
117
|
metadata: { type: "object", additionalProperties: true },
|
|
115
118
|
},
|
|
@@ -122,10 +125,11 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
122
125
|
description: "Read pending bus messages for the caller-owned subscriber queue without acknowledging them.",
|
|
123
126
|
input_schema: {
|
|
124
127
|
type: "object",
|
|
125
|
-
required: ["project_root", "subscriber"],
|
|
128
|
+
required: ["project_root", "subscriber", "agent_handle"],
|
|
126
129
|
properties: {
|
|
127
130
|
project_root: { type: "string" },
|
|
128
131
|
subscriber: { type: "string" },
|
|
132
|
+
agent_handle: { type: "string" },
|
|
129
133
|
limit: { type: "integer", minimum: 1 },
|
|
130
134
|
},
|
|
131
135
|
additionalProperties: false,
|
|
@@ -137,10 +141,11 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
137
141
|
description: "Keep a Codex App-compatible MCP tool call pending until the caller-owned bus queue receives messages after after_seq or the wait reaches its timeout.",
|
|
138
142
|
input_schema: {
|
|
139
143
|
type: "object",
|
|
140
|
-
required: ["project_root", "subscriber"],
|
|
144
|
+
required: ["project_root", "subscriber", "agent_handle"],
|
|
141
145
|
properties: {
|
|
142
146
|
project_root: { type: "string" },
|
|
143
147
|
subscriber: { type: "string" },
|
|
148
|
+
agent_handle: { type: "string" },
|
|
144
149
|
after_seq: {
|
|
145
150
|
type: "integer",
|
|
146
151
|
minimum: 0,
|
|
@@ -165,10 +170,11 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
165
170
|
description: "Queue an agent task status report through the project daemon report-control queue.",
|
|
166
171
|
input_schema: {
|
|
167
172
|
type: "object",
|
|
168
|
-
required: ["project_root", "subscriber", "task_id", "phase"],
|
|
173
|
+
required: ["project_root", "subscriber", "agent_handle", "task_id", "phase"],
|
|
169
174
|
properties: {
|
|
170
175
|
project_root: { type: "string" },
|
|
171
176
|
subscriber: { type: "string" },
|
|
177
|
+
agent_handle: { type: "string" },
|
|
172
178
|
task_id: { type: "string" },
|
|
173
179
|
phase: { type: "string", enum: ["start", "progress", "done", "error"] },
|
|
174
180
|
message: { type: "string" },
|
|
@@ -186,10 +192,11 @@ const CUSTOM_TOOL_DEFINITIONS = Object.freeze([
|
|
|
186
192
|
description: "Mark an MCP-registered agent inactive in its project bus.",
|
|
187
193
|
input_schema: {
|
|
188
194
|
type: "object",
|
|
189
|
-
required: ["project_root", "subscriber"],
|
|
195
|
+
required: ["project_root", "subscriber", "agent_handle"],
|
|
190
196
|
properties: {
|
|
191
197
|
project_root: { type: "string" },
|
|
192
198
|
subscriber: { type: "string" },
|
|
199
|
+
agent_handle: { type: "string" },
|
|
193
200
|
},
|
|
194
201
|
additionalProperties: false,
|
|
195
202
|
},
|
|
@@ -212,11 +219,16 @@ function withProjectRootSchema(schema, options = {}) {
|
|
|
212
219
|
type: "string",
|
|
213
220
|
description: "Caller-owned subscriber id returned by register_agent.",
|
|
214
221
|
},
|
|
222
|
+
agent_handle: {
|
|
223
|
+
type: "string",
|
|
224
|
+
description: "Opaque ownership capability returned by register_agent.",
|
|
225
|
+
},
|
|
215
226
|
...(cloned.properties || {}),
|
|
216
227
|
};
|
|
217
228
|
const required = Array.isArray(cloned.required) ? cloned.required.slice() : [];
|
|
218
229
|
if (!required.includes("project_root")) required.unshift("project_root");
|
|
219
230
|
if (options.requireSubscriber && !required.includes("subscriber")) required.push("subscriber");
|
|
231
|
+
if (options.requireAgentHandle && !required.includes("agent_handle")) required.push("agent_handle");
|
|
220
232
|
cloned.properties = properties;
|
|
221
233
|
cloned.required = required;
|
|
222
234
|
cloned.additionalProperties = false;
|
|
@@ -227,6 +239,7 @@ function toMcpTool(definition, options = {}) {
|
|
|
227
239
|
const inputSchema = options.projectScoped
|
|
228
240
|
? withProjectRootSchema(definition.input_schema, {
|
|
229
241
|
requireSubscriber: options.requireSubscriber,
|
|
242
|
+
requireAgentHandle: options.requireAgentHandle,
|
|
230
243
|
})
|
|
231
244
|
: cloneJson(definition.input_schema);
|
|
232
245
|
return {
|
|
@@ -243,6 +256,7 @@ function buildToolList() {
|
|
|
243
256
|
.map((tool) => toMcpTool(tool, {
|
|
244
257
|
projectScoped: tool.name !== "read_project_registry",
|
|
245
258
|
requireSubscriber: tool.name === "dispatch_message" || tool.name === "ack_bus",
|
|
259
|
+
requireAgentHandle: tool.name === "dispatch_message" || tool.name === "ack_bus",
|
|
246
260
|
}));
|
|
247
261
|
const custom = CUSTOM_TOOL_DEFINITIONS.map((tool) => toMcpTool(tool));
|
|
248
262
|
return [...custom, ...shared];
|
|
@@ -265,6 +279,8 @@ function stripMcpRoutingArgs(args = {}) {
|
|
|
265
279
|
delete next.project_root;
|
|
266
280
|
delete next.projectRoot;
|
|
267
281
|
delete next.subscriber;
|
|
282
|
+
delete next.agent_handle;
|
|
283
|
+
delete next.agentHandle;
|
|
268
284
|
return next;
|
|
269
285
|
}
|
|
270
286
|
|
|
@@ -408,12 +424,50 @@ async function ensureGlobalControllerDaemon(options = {}) {
|
|
|
408
424
|
async function handleMcpStatus(ctx = {}) {
|
|
409
425
|
const root = resolveGlobalControllerProjectRoot();
|
|
410
426
|
const projects = listRegisteredProjectRows();
|
|
427
|
+
let http = null;
|
|
428
|
+
if (typeof ctx.getMcpHttpStatus === "function") {
|
|
429
|
+
http = ctx.getMcpHttpStatus();
|
|
430
|
+
} else {
|
|
431
|
+
try {
|
|
432
|
+
const endpointPath = getUfooPaths(root).mcpEndpoint;
|
|
433
|
+
http = JSON.parse(fs.readFileSync(endpointPath, "utf8"));
|
|
434
|
+
const pidRunning = Number(http.pid) > 0 && (() => {
|
|
435
|
+
try {
|
|
436
|
+
process.kill(Number(http.pid), 0);
|
|
437
|
+
return true;
|
|
438
|
+
} catch {
|
|
439
|
+
return false;
|
|
440
|
+
}
|
|
441
|
+
})();
|
|
442
|
+
let healthRunning = false;
|
|
443
|
+
if (pidRunning && http.endpoint && typeof fetch === "function") {
|
|
444
|
+
const controller = new AbortController();
|
|
445
|
+
const timer = setTimeout(() => controller.abort(), 500);
|
|
446
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
447
|
+
try {
|
|
448
|
+
const healthUrl = new URL(http.endpoint);
|
|
449
|
+
healthUrl.pathname = "/health";
|
|
450
|
+
healthUrl.search = "";
|
|
451
|
+
const response = await fetch(healthUrl, { signal: controller.signal });
|
|
452
|
+
healthRunning = response.ok;
|
|
453
|
+
} catch {
|
|
454
|
+
healthRunning = false;
|
|
455
|
+
} finally {
|
|
456
|
+
clearTimeout(timer);
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
http.running = pidRunning && healthRunning;
|
|
460
|
+
} catch {
|
|
461
|
+
http = { running: false };
|
|
462
|
+
}
|
|
463
|
+
}
|
|
411
464
|
return {
|
|
412
465
|
ok: true,
|
|
413
466
|
global_controller_root: root,
|
|
414
467
|
global_controller_sock: socketPath(root),
|
|
415
468
|
global_controller_running: isRunning(root),
|
|
416
469
|
auto_start: ctx.autoStart !== false,
|
|
470
|
+
http,
|
|
417
471
|
project_count: projects.length,
|
|
418
472
|
projects,
|
|
419
473
|
};
|
|
@@ -421,48 +475,42 @@ async function handleMcpStatus(ctx = {}) {
|
|
|
421
475
|
|
|
422
476
|
async function handleRegisterAgent(ctx = {}, args = {}) {
|
|
423
477
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
424
|
-
return
|
|
478
|
+
return ctx.projectRuntimeGateway.call(projectRoot, "register_agent", args, ctx);
|
|
425
479
|
}
|
|
426
480
|
|
|
427
481
|
async function handleHeartbeatAgent(ctx = {}, args = {}) {
|
|
428
482
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
429
|
-
return
|
|
483
|
+
return ctx.projectRuntimeGateway.call(projectRoot, "heartbeat_agent", args, ctx);
|
|
430
484
|
}
|
|
431
485
|
|
|
432
486
|
async function handlePublishActivityState(ctx = {}, args = {}) {
|
|
433
487
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
434
|
-
return
|
|
488
|
+
return ctx.projectRuntimeGateway.call(projectRoot, "publish_activity_state", args, ctx);
|
|
435
489
|
}
|
|
436
490
|
|
|
437
491
|
async function handleUpdateAgentMetadata(ctx = {}, args = {}) {
|
|
438
492
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
439
|
-
return
|
|
493
|
+
return ctx.projectRuntimeGateway.call(projectRoot, "update_agent_metadata", args, ctx);
|
|
440
494
|
}
|
|
441
495
|
|
|
442
496
|
async function handlePollInbox(ctx = {}, args = {}) {
|
|
443
497
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
444
|
-
return
|
|
498
|
+
return ctx.projectRuntimeGateway.call(projectRoot, "poll_inbox", args, ctx);
|
|
445
499
|
}
|
|
446
500
|
|
|
447
501
|
async function handleWaitForMessage(ctx = {}, args = {}) {
|
|
448
502
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
449
|
-
return
|
|
450
|
-
signal: ctx.signal,
|
|
451
|
-
pollIntervalMs: ctx.waitPollIntervalMs,
|
|
452
|
-
heartbeatIntervalMs: ctx.waitHeartbeatIntervalMs,
|
|
453
|
-
now: ctx.waitNow,
|
|
454
|
-
sleep: ctx.waitSleep,
|
|
455
|
-
});
|
|
503
|
+
return ctx.projectRuntimeGateway.call(projectRoot, "wait_for_message", args, ctx);
|
|
456
504
|
}
|
|
457
505
|
|
|
458
506
|
async function handleReportAgentStatus(ctx = {}, args = {}) {
|
|
459
507
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
460
|
-
return
|
|
508
|
+
return ctx.projectRuntimeGateway.call(projectRoot, "report_agent_status", args, ctx);
|
|
461
509
|
}
|
|
462
510
|
|
|
463
511
|
async function handleUnregisterAgent(ctx = {}, args = {}) {
|
|
464
512
|
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
465
|
-
return
|
|
513
|
+
return ctx.projectRuntimeGateway.call(projectRoot, "unregister_agent", args, ctx);
|
|
466
514
|
}
|
|
467
515
|
|
|
468
516
|
function findCustomTool(name) {
|
|
@@ -481,23 +529,19 @@ async function invokeTool(name, args = {}, ctx = {}) {
|
|
|
481
529
|
throw err;
|
|
482
530
|
}
|
|
483
531
|
|
|
532
|
+
if (name !== "read_project_registry") {
|
|
533
|
+
const projectRoot = resolveRegisteredProjectRoot(args, ctx);
|
|
534
|
+
return ctx.projectRuntimeGateway.call(projectRoot, name, args, ctx);
|
|
535
|
+
}
|
|
536
|
+
|
|
484
537
|
const tool = assertToolAllowedForCallerTier(name, CALLER_TIERS.WORKER, {
|
|
485
538
|
tool_call_id: ctx.toolCallId,
|
|
486
539
|
});
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
:
|
|
490
|
-
const subscriber = String(args.subscriber || args.source || "").trim();
|
|
491
|
-
const toolArgs = stripMcpRoutingArgs(args);
|
|
492
|
-
if (name === "dispatch_message" && !toolArgs.source && subscriber) {
|
|
493
|
-
toolArgs.source = subscriber;
|
|
494
|
-
}
|
|
495
|
-
const toolCtx = {
|
|
496
|
-
projectRoot,
|
|
497
|
-
subscriber,
|
|
540
|
+
return tool.handler({
|
|
541
|
+
projectRoot: resolveGlobalControllerProjectRoot(),
|
|
542
|
+
subscriber: "",
|
|
498
543
|
caller_tier: CALLER_TIERS.WORKER,
|
|
499
|
-
};
|
|
500
|
-
return tool.handler(toolCtx, toolArgs);
|
|
544
|
+
}, stripMcpRoutingArgs(args));
|
|
501
545
|
}
|
|
502
546
|
|
|
503
547
|
class UfooMcpServer {
|
|
@@ -510,10 +554,10 @@ class UfooMcpServer {
|
|
|
510
554
|
waitHeartbeatIntervalMs: options.waitHeartbeatIntervalMs,
|
|
511
555
|
waitNow: options.waitNow,
|
|
512
556
|
waitSleep: options.waitSleep,
|
|
557
|
+
projectRuntimeGateway: options.projectRuntimeGateway || DEFAULT_PROJECT_RUNTIME_GATEWAY,
|
|
513
558
|
};
|
|
514
559
|
this.initialized = false;
|
|
515
560
|
this.startup = null;
|
|
516
|
-
this.registeredSubscribers = [];
|
|
517
561
|
this.activeToolCalls = new Map();
|
|
518
562
|
}
|
|
519
563
|
|
|
@@ -607,17 +651,6 @@ class UfooMcpServer {
|
|
|
607
651
|
} finally {
|
|
608
652
|
this.activeToolCalls.delete(id);
|
|
609
653
|
}
|
|
610
|
-
if (name === "register_agent" && result && result.subscriber && result.project_root) {
|
|
611
|
-
this.registeredSubscribers.push({
|
|
612
|
-
subscriber: result.subscriber,
|
|
613
|
-
projectRoot: result.project_root,
|
|
614
|
-
});
|
|
615
|
-
}
|
|
616
|
-
if (name === "unregister_agent" && result && result.subscriber) {
|
|
617
|
-
this.registeredSubscribers = this.registeredSubscribers.filter(
|
|
618
|
-
(entry) => entry.subscriber !== result.subscriber
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
654
|
return createJsonRpcResult(id, createMcpContent(result));
|
|
622
655
|
}
|
|
623
656
|
|
|
@@ -636,14 +669,6 @@ class UfooMcpServer {
|
|
|
636
669
|
active.abort();
|
|
637
670
|
}
|
|
638
671
|
this.activeToolCalls.clear();
|
|
639
|
-
for (const { subscriber, projectRoot } of this.registeredSubscribers) {
|
|
640
|
-
try {
|
|
641
|
-
controlPlane.unregisterAgent(projectRoot, { subscriber });
|
|
642
|
-
} catch {
|
|
643
|
-
// best-effort cleanup
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
this.registeredSubscribers = [];
|
|
647
672
|
}
|
|
648
673
|
}
|
|
649
674
|
|
|
@@ -652,52 +677,18 @@ function createUfooMcpServer(options = {}) {
|
|
|
652
677
|
}
|
|
653
678
|
|
|
654
679
|
async function runMcpServer(options = {}) {
|
|
655
|
-
const
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
const writeMessage = (message) => {
|
|
661
|
-
if (!message) return;
|
|
662
|
-
output.write(`${JSON.stringify(message)}\n`);
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
input.setEncoding("utf8");
|
|
666
|
-
input.on("data", (chunk) => {
|
|
667
|
-
buffer += chunk;
|
|
668
|
-
const lines = buffer.split(/\r?\n/);
|
|
669
|
-
buffer = lines.pop() || "";
|
|
670
|
-
for (const line of lines) {
|
|
671
|
-
if (!line.trim()) continue;
|
|
672
|
-
let request;
|
|
673
|
-
try {
|
|
674
|
-
request = JSON.parse(line);
|
|
675
|
-
} catch (err) {
|
|
676
|
-
writeMessage(createJsonRpcError(null, MCP_ERROR_CODES.PARSE_ERROR, err.message || "Parse error"));
|
|
677
|
-
continue;
|
|
678
|
-
}
|
|
679
|
-
server.handleRequest(request)
|
|
680
|
-
.then(writeMessage)
|
|
681
|
-
.catch((err) => {
|
|
682
|
-
writeMessage(createJsonRpcError(
|
|
683
|
-
Object.prototype.hasOwnProperty.call(request, "id") ? request.id : null,
|
|
684
|
-
MCP_ERROR_CODES.INTERNAL_ERROR,
|
|
685
|
-
err.message || String(err)
|
|
686
|
-
));
|
|
687
|
-
});
|
|
688
|
-
}
|
|
680
|
+
const { runMcpStdioProxy } = require("./mcpStdioProxy");
|
|
681
|
+
return runMcpStdioProxy({
|
|
682
|
+
...options,
|
|
683
|
+
ensureDaemon: () => ensureGlobalControllerDaemon(options),
|
|
689
684
|
});
|
|
690
|
-
|
|
691
|
-
input.on("end", () => server.cleanup());
|
|
692
|
-
input.on("close", () => server.cleanup());
|
|
693
|
-
|
|
694
|
-
return server;
|
|
695
685
|
}
|
|
696
686
|
|
|
697
687
|
module.exports = {
|
|
698
688
|
EXPOSED_SHARED_TOOLS,
|
|
699
689
|
CUSTOM_TOOL_DEFINITIONS,
|
|
700
690
|
buildToolList,
|
|
691
|
+
createMcpContent,
|
|
701
692
|
createUfooMcpServer,
|
|
702
693
|
ensureGlobalControllerDaemon,
|
|
703
694
|
invokeTool,
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
|
|
5
|
+
const { getUfooPaths } = require("../../coordination/state/paths");
|
|
6
|
+
const {
|
|
7
|
+
resolveGlobalControllerProjectRoot,
|
|
8
|
+
} = require("../projects");
|
|
9
|
+
const {
|
|
10
|
+
MCP_ERROR_CODES,
|
|
11
|
+
createJsonRpcError,
|
|
12
|
+
} = require("../contracts/mcpContract");
|
|
13
|
+
|
|
14
|
+
const DEFAULT_ENDPOINT_WAIT_MS = 5000;
|
|
15
|
+
const DEFAULT_ENDPOINT_POLL_MS = 50;
|
|
16
|
+
const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "::1", "[::1]"]);
|
|
17
|
+
|
|
18
|
+
function sleep(ms) {
|
|
19
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function validateEndpoint(raw = "") {
|
|
23
|
+
let parsed;
|
|
24
|
+
try {
|
|
25
|
+
parsed = new URL(String(raw || ""));
|
|
26
|
+
} catch {
|
|
27
|
+
throw new Error("Invalid global MCP endpoint");
|
|
28
|
+
}
|
|
29
|
+
if (parsed.protocol !== "http:" || !LOOPBACK_HOSTS.has(parsed.hostname) || parsed.pathname !== "/mcp") {
|
|
30
|
+
const err = new Error(`Refusing non-loopback global MCP endpoint: ${parsed.toString()}`);
|
|
31
|
+
err.code = "UFOO_MCP_INVALID_ENDPOINT";
|
|
32
|
+
throw err;
|
|
33
|
+
}
|
|
34
|
+
return parsed.toString();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readConnectionFiles(projectRoot) {
|
|
38
|
+
const paths = getUfooPaths(projectRoot);
|
|
39
|
+
const endpointRecord = JSON.parse(fs.readFileSync(paths.mcpEndpoint, "utf8"));
|
|
40
|
+
const endpoint = validateEndpoint(endpointRecord.endpoint);
|
|
41
|
+
const token = String(fs.readFileSync(paths.mcpToken, "utf8") || "").trim();
|
|
42
|
+
if (!token) {
|
|
43
|
+
const err = new Error("Global MCP bearer token is empty");
|
|
44
|
+
err.code = "UFOO_MCP_EMPTY_TOKEN";
|
|
45
|
+
throw err;
|
|
46
|
+
}
|
|
47
|
+
return { endpoint, token };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
class McpStdioProxy {
|
|
51
|
+
constructor(options = {}) {
|
|
52
|
+
this.input = options.input || process.stdin;
|
|
53
|
+
this.output = options.output || process.stdout;
|
|
54
|
+
this.errorOutput = options.errorOutput || process.stderr;
|
|
55
|
+
this.projectRoot = options.projectRoot || resolveGlobalControllerProjectRoot();
|
|
56
|
+
this.autoStart = options.autoStart !== false;
|
|
57
|
+
this.ensureDaemon = options.ensureDaemon || (async () => {});
|
|
58
|
+
this.fetch = options.fetch || globalThis.fetch;
|
|
59
|
+
this.endpoint = options.endpoint ? validateEndpoint(options.endpoint) : "";
|
|
60
|
+
this.token = String(options.token || "");
|
|
61
|
+
this.endpointWaitMs = Number(options.endpointWaitMs) || DEFAULT_ENDPOINT_WAIT_MS;
|
|
62
|
+
this.endpointPollMs = Number(options.endpointPollMs) || DEFAULT_ENDPOINT_POLL_MS;
|
|
63
|
+
this.sessionId = "";
|
|
64
|
+
this.startup = null;
|
|
65
|
+
this.initializing = null;
|
|
66
|
+
this.activeRequests = new Map();
|
|
67
|
+
this.closed = false;
|
|
68
|
+
this.buffer = "";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
writeMessage(message) {
|
|
72
|
+
if (!message || this.closed) return;
|
|
73
|
+
this.output.write(`${JSON.stringify(message)}\n`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
writeError(message) {
|
|
77
|
+
this.errorOutput.write(`[ufoo-mcp-proxy] ${String(message || "")}\n`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async connect() {
|
|
81
|
+
if (this.endpoint && this.token) return { endpoint: this.endpoint };
|
|
82
|
+
if (this.startup) return this.startup;
|
|
83
|
+
this.startup = (async () => {
|
|
84
|
+
if (this.autoStart) await this.ensureDaemon();
|
|
85
|
+
const deadline = Date.now() + this.endpointWaitMs;
|
|
86
|
+
let lastError = null;
|
|
87
|
+
do {
|
|
88
|
+
try {
|
|
89
|
+
const connection = readConnectionFiles(this.projectRoot);
|
|
90
|
+
this.endpoint = connection.endpoint;
|
|
91
|
+
this.token = connection.token;
|
|
92
|
+
return { endpoint: this.endpoint };
|
|
93
|
+
} catch (err) {
|
|
94
|
+
lastError = err;
|
|
95
|
+
}
|
|
96
|
+
if (!this.autoStart) break;
|
|
97
|
+
await sleep(this.endpointPollMs);
|
|
98
|
+
} while (Date.now() < deadline);
|
|
99
|
+
const err = new Error(
|
|
100
|
+
`Global MCP endpoint is unavailable: ${lastError ? lastError.message : "not started"}`
|
|
101
|
+
);
|
|
102
|
+
err.code = "UFOO_MCP_UNAVAILABLE";
|
|
103
|
+
throw err;
|
|
104
|
+
})();
|
|
105
|
+
try {
|
|
106
|
+
return await this.startup;
|
|
107
|
+
} catch (err) {
|
|
108
|
+
this.startup = null;
|
|
109
|
+
throw err;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async post(request, controller) {
|
|
114
|
+
await this.connect();
|
|
115
|
+
const headers = {
|
|
116
|
+
accept: "application/json, text/event-stream",
|
|
117
|
+
authorization: `Bearer ${this.token}`,
|
|
118
|
+
"content-type": "application/json",
|
|
119
|
+
};
|
|
120
|
+
if (this.sessionId) headers["mcp-session-id"] = this.sessionId;
|
|
121
|
+
const response = await this.fetch(this.endpoint, {
|
|
122
|
+
method: "POST",
|
|
123
|
+
headers,
|
|
124
|
+
body: JSON.stringify(request),
|
|
125
|
+
signal: controller.signal,
|
|
126
|
+
});
|
|
127
|
+
const nextSessionId = response.headers.get("mcp-session-id");
|
|
128
|
+
if (nextSessionId) this.sessionId = nextSessionId;
|
|
129
|
+
if (response.status === 202 || response.status === 204) return null;
|
|
130
|
+
const text = await response.text();
|
|
131
|
+
if (!text) {
|
|
132
|
+
if (response.ok) return null;
|
|
133
|
+
throw new Error(`Global MCP returned HTTP ${response.status}`);
|
|
134
|
+
}
|
|
135
|
+
let payload;
|
|
136
|
+
try {
|
|
137
|
+
payload = JSON.parse(text);
|
|
138
|
+
} catch {
|
|
139
|
+
throw new Error(`Global MCP returned invalid JSON (HTTP ${response.status})`);
|
|
140
|
+
}
|
|
141
|
+
return payload;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async forward(request) {
|
|
145
|
+
const hasId = Object.prototype.hasOwnProperty.call(request || {}, "id");
|
|
146
|
+
const id = hasId ? request.id : undefined;
|
|
147
|
+
const method = String((request && request.method) || "");
|
|
148
|
+
if (method !== "initialize" && this.initializing) {
|
|
149
|
+
await this.initializing;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const controller = new AbortController();
|
|
153
|
+
if (hasId) this.activeRequests.set(id, controller);
|
|
154
|
+
const operation = this.post(request, controller);
|
|
155
|
+
if (method === "initialize") this.initializing = operation;
|
|
156
|
+
try {
|
|
157
|
+
const response = await operation;
|
|
158
|
+
if (response) this.writeMessage(response);
|
|
159
|
+
return response;
|
|
160
|
+
} catch (err) {
|
|
161
|
+
if (hasId) {
|
|
162
|
+
const response = createJsonRpcError(
|
|
163
|
+
id,
|
|
164
|
+
MCP_ERROR_CODES.INTERNAL_ERROR,
|
|
165
|
+
err && err.message ? err.message : String(err),
|
|
166
|
+
{ code: err && err.code ? String(err.code) : "mcp_proxy_error" }
|
|
167
|
+
);
|
|
168
|
+
this.writeMessage(response);
|
|
169
|
+
return response;
|
|
170
|
+
}
|
|
171
|
+
this.writeError(err && err.message ? err.message : err);
|
|
172
|
+
return null;
|
|
173
|
+
} finally {
|
|
174
|
+
if (hasId) this.activeRequests.delete(id);
|
|
175
|
+
if (method === "initialize") this.initializing = null;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
handleLine(line) {
|
|
180
|
+
if (!line.trim()) return;
|
|
181
|
+
let request;
|
|
182
|
+
try {
|
|
183
|
+
request = JSON.parse(line);
|
|
184
|
+
} catch (err) {
|
|
185
|
+
this.writeMessage(createJsonRpcError(
|
|
186
|
+
null,
|
|
187
|
+
MCP_ERROR_CODES.PARSE_ERROR,
|
|
188
|
+
err.message || "Parse error"
|
|
189
|
+
));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
void this.forward(request);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
start() {
|
|
196
|
+
this.input.setEncoding("utf8");
|
|
197
|
+
this.input.on("data", (chunk) => {
|
|
198
|
+
this.buffer += chunk;
|
|
199
|
+
const lines = this.buffer.split(/\r?\n/);
|
|
200
|
+
this.buffer = lines.pop() || "";
|
|
201
|
+
for (const line of lines) this.handleLine(line);
|
|
202
|
+
});
|
|
203
|
+
this.input.on("end", () => {
|
|
204
|
+
void this.close();
|
|
205
|
+
});
|
|
206
|
+
this.input.on("close", () => {
|
|
207
|
+
void this.close();
|
|
208
|
+
});
|
|
209
|
+
return this;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async close() {
|
|
213
|
+
if (this.closed) return;
|
|
214
|
+
this.closed = true;
|
|
215
|
+
for (const controller of this.activeRequests.values()) controller.abort();
|
|
216
|
+
this.activeRequests.clear();
|
|
217
|
+
if (!this.endpoint || !this.token || !this.sessionId) return;
|
|
218
|
+
try {
|
|
219
|
+
await this.fetch(this.endpoint, {
|
|
220
|
+
method: "DELETE",
|
|
221
|
+
headers: {
|
|
222
|
+
accept: "application/json, text/event-stream",
|
|
223
|
+
authorization: `Bearer ${this.token}`,
|
|
224
|
+
"mcp-session-id": this.sessionId,
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
} catch {
|
|
228
|
+
// The adapter is disposable; the server will also close abandoned sessions.
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function createMcpStdioProxy(options = {}) {
|
|
234
|
+
return new McpStdioProxy(options);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function runMcpStdioProxy(options = {}) {
|
|
238
|
+
return createMcpStdioProxy(options).start();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
module.exports = {
|
|
242
|
+
McpStdioProxy,
|
|
243
|
+
createMcpStdioProxy,
|
|
244
|
+
readConnectionFiles,
|
|
245
|
+
runMcpStdioProxy,
|
|
246
|
+
validateEndpoint,
|
|
247
|
+
};
|