@lobu/worker 6.1.1 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/embedded/just-bash-bootstrap.d.ts.map +1 -1
- package/dist/embedded/just-bash-bootstrap.js +26 -2
- package/dist/embedded/just-bash-bootstrap.js.map +1 -1
- package/dist/gateway/gateway-integration.js +4 -4
- package/dist/gateway/gateway-integration.js.map +1 -1
- package/dist/gateway/message-batcher.d.ts.map +1 -1
- package/dist/gateway/message-batcher.js +3 -5
- package/dist/gateway/message-batcher.js.map +1 -1
- package/dist/gateway/sse-client.d.ts +1 -0
- package/dist/gateway/sse-client.d.ts.map +1 -1
- package/dist/gateway/sse-client.js +8 -0
- package/dist/gateway/sse-client.js.map +1 -1
- package/dist/openclaw/worker.d.ts +0 -1
- package/dist/openclaw/worker.d.ts.map +1 -1
- package/dist/openclaw/worker.js +18 -75
- package/dist/openclaw/worker.js.map +1 -1
- package/dist/shared/tool-implementations.d.ts.map +1 -1
- package/dist/shared/tool-implementations.js +37 -13
- package/dist/shared/tool-implementations.js.map +1 -1
- package/package.json +14 -4
- package/src/__tests__/audio-provider-suggestions.test.ts +199 -0
- package/src/__tests__/custom-tools.test.ts +92 -0
- package/src/__tests__/embedded-just-bash-bootstrap.test.ts +128 -0
- package/src/__tests__/embedded-mcp-cli-bash.test.ts +179 -0
- package/src/__tests__/embedded-tools.test.ts +744 -0
- package/src/__tests__/exec-sandbox-extra.test.ts +0 -0
- package/src/__tests__/exec-sandbox.test.ts +550 -0
- package/src/__tests__/generated-media.test.ts +142 -0
- package/src/__tests__/instructions.test.ts +60 -0
- package/src/__tests__/mcp-cli-commands-extra.test.ts +478 -0
- package/src/__tests__/mcp-cli-commands.test.ts +383 -0
- package/src/__tests__/mcp-tool-call.test.ts +423 -0
- package/src/__tests__/memory-flush-harden.test.ts +367 -0
- package/src/__tests__/memory-flush-runtime.test.ts +138 -0
- package/src/__tests__/memory-flush.test.ts +64 -0
- package/src/__tests__/message-batcher.test.ts +247 -0
- package/src/__tests__/model-resolver-harden.test.ts +197 -0
- package/src/__tests__/model-resolver.test.ts +156 -0
- package/src/__tests__/processor-harden.test.ts +269 -0
- package/src/__tests__/processor.test.ts +225 -0
- package/src/__tests__/replace-base-prompt-identity.test.ts +41 -0
- package/src/__tests__/sandbox-leak-harden.test.ts +200 -0
- package/src/__tests__/sandbox-leak.test.ts +167 -0
- package/src/__tests__/setup.ts +102 -0
- package/src/__tests__/sse-client-harden.test.ts +588 -0
- package/src/__tests__/sse-client.test.ts +90 -0
- package/src/__tests__/tool-implementations.test.ts +196 -0
- package/src/__tests__/tool-policy-edge-cases.test.ts +263 -0
- package/src/__tests__/tool-policy.test.ts +269 -0
- package/src/__tests__/worker.test.ts +89 -0
- package/src/core/error-handler.ts +62 -0
- package/src/core/project-scanner.ts +65 -0
- package/src/core/types.ts +128 -0
- package/src/core/workspace.ts +89 -0
- package/src/embedded/exec-sandbox.ts +372 -0
- package/src/embedded/just-bash-bootstrap.ts +543 -0
- package/src/embedded/mcp-cli-commands.ts +402 -0
- package/src/gateway/gateway-integration.ts +298 -0
- package/src/gateway/message-batcher.ts +123 -0
- package/src/gateway/sse-client.ts +951 -0
- package/src/gateway/types.ts +68 -0
- package/src/index.ts +141 -0
- package/src/instructions/builder.ts +45 -0
- package/src/instructions/providers.ts +27 -0
- package/src/modules/lifecycle.ts +92 -0
- package/src/openclaw/custom-tools.ts +315 -0
- package/src/openclaw/instructions.ts +36 -0
- package/src/openclaw/model-resolver.ts +150 -0
- package/src/openclaw/plugin-loader.ts +427 -0
- package/src/openclaw/processor.ts +198 -0
- package/src/openclaw/sandbox-leak.ts +105 -0
- package/src/openclaw/session-context.ts +320 -0
- package/src/openclaw/tool-policy.ts +248 -0
- package/src/openclaw/tools.ts +277 -0
- package/src/openclaw/worker.ts +1847 -0
- package/src/server.ts +334 -0
- package/src/shared/audio-provider-suggestions.ts +132 -0
- package/src/shared/processor-utils.ts +33 -0
- package/src/shared/provider-auth-hints.ts +68 -0
- package/src/shared/tool-display-config.ts +75 -0
- package/src/shared/tool-implementations.ts +940 -0
- package/src/shared/worker-env-keys.ts +8 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-side MCP-as-CLI bootstrap for embedded deployment mode.
|
|
3
|
+
*
|
|
4
|
+
* Registers one `just-bash` custom command per MCP server (e.g. `lobu`,
|
|
5
|
+
* `gmail`). The agent invokes MCP tools via the sandboxed bash:
|
|
6
|
+
*
|
|
7
|
+
* lobu search_memory <<<'{"query":"foo"}'
|
|
8
|
+
* lobu --help
|
|
9
|
+
* lobu save_memory --schema
|
|
10
|
+
* lobu auth login
|
|
11
|
+
*
|
|
12
|
+
* Payload is read from `ctx.stdin` as JSON. If stdin is empty, falls back to
|
|
13
|
+
* `args[1]` as a JSON string (defense-in-depth for models that write the JSON
|
|
14
|
+
* inline).
|
|
15
|
+
*/
|
|
16
|
+
import type { McpStatus, McpToolDef } from "@lobu/core";
|
|
17
|
+
import { createLogger } from "@lobu/core";
|
|
18
|
+
import type { GatewayParams } from "../shared/tool-implementations";
|
|
19
|
+
import { callMcpTool } from "../shared/tool-implementations";
|
|
20
|
+
import { isDirectPackageInstallCommand } from "../openclaw/tool-policy";
|
|
21
|
+
|
|
22
|
+
const logger = createLogger("mcp-cli");
|
|
23
|
+
|
|
24
|
+
/** Names reserved by just-bash / POSIX shells that we must not shadow. */
|
|
25
|
+
const RESERVED_COMMAND_NAMES = new Set([
|
|
26
|
+
"cd",
|
|
27
|
+
"echo",
|
|
28
|
+
"export",
|
|
29
|
+
"test",
|
|
30
|
+
"true",
|
|
31
|
+
"false",
|
|
32
|
+
"pwd",
|
|
33
|
+
"set",
|
|
34
|
+
"unset",
|
|
35
|
+
"exit",
|
|
36
|
+
"source",
|
|
37
|
+
".",
|
|
38
|
+
":",
|
|
39
|
+
"[",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Mutable snapshot of MCP session state. CLI handlers read through `current`
|
|
44
|
+
* so that `auth login|check|logout` can refresh tools/state via `refresh()`
|
|
45
|
+
* without rebuilding the Bash instance. New servers discovered after startup
|
|
46
|
+
* are not retro-registered — they require a worker restart.
|
|
47
|
+
*/
|
|
48
|
+
export interface McpRuntimeState {
|
|
49
|
+
mcpTools: Record<string, McpToolDef[]>;
|
|
50
|
+
mcpStatus: McpStatus[];
|
|
51
|
+
mcpContext: Record<string, string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface McpRuntimeRef {
|
|
55
|
+
current: McpRuntimeState;
|
|
56
|
+
/** Re-fetch session context and return a fresh snapshot, or `null` on failure. */
|
|
57
|
+
refresh?: () => Promise<McpRuntimeState | null>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface McpCliCommand {
|
|
61
|
+
name: string;
|
|
62
|
+
execute: (
|
|
63
|
+
args: string[],
|
|
64
|
+
ctx: { stdin?: string; signal?: AbortSignal }
|
|
65
|
+
) => Promise<{ stdout: string; stderr: string; exitCode: number }>;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface McpCliDeps {
|
|
69
|
+
callTool: typeof callMcpTool;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const DEFAULT_DEPS: McpCliDeps = {
|
|
73
|
+
callTool: callMcpTool,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/** Check whether an MCP id would collide with a bash builtin or deny-prefix. */
|
|
77
|
+
export function isMcpIdReserved(mcpId: string): string | null {
|
|
78
|
+
if (RESERVED_COMMAND_NAMES.has(mcpId)) {
|
|
79
|
+
return `reserved bash builtin`;
|
|
80
|
+
}
|
|
81
|
+
// Probe against the package-install denylist using invocations that match
|
|
82
|
+
// its actual patterns (install/add/require/upgrade/etc.).
|
|
83
|
+
const probes = [
|
|
84
|
+
`${mcpId} install`,
|
|
85
|
+
`${mcpId} i`,
|
|
86
|
+
`${mcpId} add`,
|
|
87
|
+
`${mcpId} upgrade`,
|
|
88
|
+
`${mcpId} require`,
|
|
89
|
+
mcpId,
|
|
90
|
+
];
|
|
91
|
+
if (probes.some((p) => isDirectPackageInstallCommand(p))) {
|
|
92
|
+
return `matches package-install denylist`;
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function truncate(text: string, max: number): string {
|
|
98
|
+
if (!text) return "";
|
|
99
|
+
const clean = text.replace(/\s+/g, " ").trim();
|
|
100
|
+
return clean.length > max ? `${clean.slice(0, max - 1)}…` : clean;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderHelp(
|
|
104
|
+
mcpId: string,
|
|
105
|
+
state: McpRuntimeState
|
|
106
|
+
): { stdout: string; exitCode: number } {
|
|
107
|
+
const tools = state.mcpTools[mcpId] ?? [];
|
|
108
|
+
const status = state.mcpStatus.find((s) => s.id === mcpId);
|
|
109
|
+
const contextPrefix = state.mcpContext[mcpId];
|
|
110
|
+
const lines: string[] = [];
|
|
111
|
+
|
|
112
|
+
lines.push(`${mcpId} — MCP server CLI`);
|
|
113
|
+
if (contextPrefix) {
|
|
114
|
+
lines.push(contextPrefix);
|
|
115
|
+
}
|
|
116
|
+
lines.push("");
|
|
117
|
+
lines.push("Usage:");
|
|
118
|
+
lines.push(` ${mcpId} <tool> <<'EOF'`);
|
|
119
|
+
lines.push(` { ...json args... }`);
|
|
120
|
+
lines.push(` EOF`);
|
|
121
|
+
lines.push("");
|
|
122
|
+
lines.push(` ${mcpId} <tool> --schema # print the JSON schema`);
|
|
123
|
+
lines.push(` ${mcpId} --help # this message`);
|
|
124
|
+
if (status?.requiresAuth) {
|
|
125
|
+
lines.push(` ${mcpId} auth login|check|logout`);
|
|
126
|
+
}
|
|
127
|
+
lines.push("");
|
|
128
|
+
|
|
129
|
+
if (tools.length === 0) {
|
|
130
|
+
lines.push(
|
|
131
|
+
"(no tools discovered — the server may need authentication or configuration)"
|
|
132
|
+
);
|
|
133
|
+
} else {
|
|
134
|
+
lines.push("Tools:");
|
|
135
|
+
for (const tool of tools) {
|
|
136
|
+
const desc = truncate(tool.description ?? "", 80);
|
|
137
|
+
lines.push(` ${tool.name}${desc ? ` ${desc}` : ""}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return { stdout: `${lines.join("\n")}\n`, exitCode: 0 };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function findTool(
|
|
145
|
+
mcpId: string,
|
|
146
|
+
toolName: string,
|
|
147
|
+
state: McpRuntimeState
|
|
148
|
+
): McpToolDef | undefined {
|
|
149
|
+
return state.mcpTools[mcpId]?.find((t) => t.name === toolName);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function parsePayload(
|
|
153
|
+
stdin: string | undefined,
|
|
154
|
+
inlineArg: string | undefined
|
|
155
|
+
):
|
|
156
|
+
| { ok: true; payload: Record<string, unknown> }
|
|
157
|
+
| { ok: false; error: string } {
|
|
158
|
+
const raw = stdin?.trim() || inlineArg?.trim() || "";
|
|
159
|
+
if (!raw) {
|
|
160
|
+
return { ok: true, payload: {} };
|
|
161
|
+
}
|
|
162
|
+
try {
|
|
163
|
+
const parsed = JSON.parse(raw);
|
|
164
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
165
|
+
return { ok: false, error: "expected a JSON object payload" };
|
|
166
|
+
}
|
|
167
|
+
return { ok: true, payload: parsed as Record<string, unknown> };
|
|
168
|
+
} catch (err) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
error: `invalid JSON payload: ${err instanceof Error ? err.message : String(err)}`,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Build the execute handler for a single MCP server CLI.
|
|
178
|
+
* Exposed for unit testing.
|
|
179
|
+
*/
|
|
180
|
+
export function buildMcpServerHandler(
|
|
181
|
+
mcpId: string,
|
|
182
|
+
ref: McpRuntimeRef,
|
|
183
|
+
gw: GatewayParams,
|
|
184
|
+
deps: McpCliDeps = DEFAULT_DEPS
|
|
185
|
+
): McpCliCommand["execute"] {
|
|
186
|
+
return async (args, ctx) => {
|
|
187
|
+
const subcommand = args[0];
|
|
188
|
+
const state = ref.current;
|
|
189
|
+
|
|
190
|
+
if (!subcommand || subcommand === "--help" || subcommand === "-h") {
|
|
191
|
+
const { stdout, exitCode } = renderHelp(mcpId, state);
|
|
192
|
+
return { stdout, stderr: "", exitCode };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
if (subcommand === "auth") {
|
|
196
|
+
return runAuthSubcommand(mcpId, args.slice(1), gw, ref);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// <tool> --schema
|
|
200
|
+
if (args[1] === "--schema") {
|
|
201
|
+
const tool = findTool(mcpId, subcommand, state);
|
|
202
|
+
if (!tool) {
|
|
203
|
+
return {
|
|
204
|
+
stdout: "",
|
|
205
|
+
stderr: `unknown tool: ${subcommand}. Run \`${mcpId} --help\`.\n`,
|
|
206
|
+
exitCode: 2,
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
const schema = tool.inputSchema ?? {};
|
|
210
|
+
return {
|
|
211
|
+
stdout: `${JSON.stringify(schema, null, 2)}\n`,
|
|
212
|
+
stderr: "",
|
|
213
|
+
exitCode: 0,
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// <tool> [json]
|
|
218
|
+
const tool = findTool(mcpId, subcommand, state);
|
|
219
|
+
if (!tool) {
|
|
220
|
+
return {
|
|
221
|
+
stdout: "",
|
|
222
|
+
stderr: `unknown tool: ${subcommand}. Run \`${mcpId} --help\`.\n`,
|
|
223
|
+
exitCode: 2,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const parsed = parsePayload(ctx.stdin, args[1]);
|
|
228
|
+
if (!parsed.ok) {
|
|
229
|
+
return { stdout: "", stderr: `${parsed.error}\n`, exitCode: 2 };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
try {
|
|
233
|
+
const result = await deps.callTool(gw, mcpId, subcommand, parsed.payload);
|
|
234
|
+
const text = result.content
|
|
235
|
+
.filter((c) => c.type === "text")
|
|
236
|
+
.map((c) => c.text)
|
|
237
|
+
.join("\n");
|
|
238
|
+
return { stdout: text ? `${text}\n` : "", stderr: "", exitCode: 0 };
|
|
239
|
+
} catch (err) {
|
|
240
|
+
return {
|
|
241
|
+
stdout: "",
|
|
242
|
+
stderr: `${err instanceof Error ? err.message : String(err)}\n`,
|
|
243
|
+
exitCode: 1,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function refreshRef(
|
|
250
|
+
ref: McpRuntimeRef,
|
|
251
|
+
mcpId: string,
|
|
252
|
+
verb: string
|
|
253
|
+
): Promise<void> {
|
|
254
|
+
if (!ref.refresh) return;
|
|
255
|
+
try {
|
|
256
|
+
const fresh = await ref.refresh();
|
|
257
|
+
if (fresh) ref.current = fresh;
|
|
258
|
+
} catch (err) {
|
|
259
|
+
logger.warn(
|
|
260
|
+
`Failed to refresh MCP state after ${mcpId} auth ${verb}: ${err instanceof Error ? err.message : String(err)}`
|
|
261
|
+
);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
async function runAuthSubcommand(
|
|
266
|
+
mcpId: string,
|
|
267
|
+
args: string[],
|
|
268
|
+
gw: GatewayParams,
|
|
269
|
+
ref: McpRuntimeRef
|
|
270
|
+
): Promise<{ stdout: string; stderr: string; exitCode: number }> {
|
|
271
|
+
const verb = args[0];
|
|
272
|
+
// Lazy import to avoid a heavy dependency cycle in tests.
|
|
273
|
+
const impl = await import("../shared/tool-implementations");
|
|
274
|
+
|
|
275
|
+
if (verb === "login") {
|
|
276
|
+
const res = await impl.startMcpLogin(gw, { mcpId });
|
|
277
|
+
const text = extractText(res.content);
|
|
278
|
+
return {
|
|
279
|
+
stdout: `${summariseAuthStart(text, mcpId)}\n`,
|
|
280
|
+
stderr: "",
|
|
281
|
+
exitCode: 0,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (verb === "check") {
|
|
286
|
+
const res = await impl.checkMcpLogin(gw, { mcpId });
|
|
287
|
+
const text = extractText(res.content);
|
|
288
|
+
const parsed = tryJson(text);
|
|
289
|
+
if (parsed?.authenticated === true) {
|
|
290
|
+
await refreshRef(ref, mcpId, "check");
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
stdout: `${summariseAuthCheck(parsed, mcpId, text)}\n`,
|
|
294
|
+
stderr: "",
|
|
295
|
+
exitCode: 0,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (verb === "logout") {
|
|
300
|
+
const res = await impl.logoutMcp(gw, { mcpId });
|
|
301
|
+
const text = extractText(res.content);
|
|
302
|
+
// Tools that required auth are now unreachable — refresh so the next
|
|
303
|
+
// invocation sees the empty state.
|
|
304
|
+
await refreshRef(ref, mcpId, "logout");
|
|
305
|
+
return { stdout: `${text}\n`, stderr: "", exitCode: 0 };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return {
|
|
309
|
+
stdout: "",
|
|
310
|
+
stderr: `unknown auth subcommand: ${verb ?? "(none)"}. Use login|check|logout.\n`,
|
|
311
|
+
exitCode: 2,
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function extractText(content: Array<{ type: string; text?: string }>): string {
|
|
316
|
+
return content
|
|
317
|
+
.filter(
|
|
318
|
+
(c): c is { type: "text"; text: string } =>
|
|
319
|
+
c.type === "text" && typeof c.text === "string"
|
|
320
|
+
)
|
|
321
|
+
.map((c) => c.text)
|
|
322
|
+
.join("\n");
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export function summariseAuthStart(rawText: string, mcpId: string): string {
|
|
326
|
+
const parsed = tryJson(rawText);
|
|
327
|
+
if (!parsed) return rawText;
|
|
328
|
+
if (parsed.status === "already_authenticated") {
|
|
329
|
+
return JSON.stringify({ status: "already_authenticated", mcp_id: mcpId });
|
|
330
|
+
}
|
|
331
|
+
if (parsed.status === "login_started") {
|
|
332
|
+
const interactionPosted = Boolean(parsed.interaction_posted);
|
|
333
|
+
// If the link-button side-channel didn't fire, fall back to the raw payload
|
|
334
|
+
// so the verification URL + user_code remain reachable by the model.
|
|
335
|
+
if (!interactionPosted) return rawText;
|
|
336
|
+
return JSON.stringify({
|
|
337
|
+
status: "login_started",
|
|
338
|
+
mcp_id: mcpId,
|
|
339
|
+
interaction_posted: true,
|
|
340
|
+
message: `Login link sent directly to the user. Run \`${mcpId} auth check\` after they confirm.`,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return rawText;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export function summariseAuthCheck(
|
|
347
|
+
parsed: Record<string, unknown> | null,
|
|
348
|
+
mcpId: string,
|
|
349
|
+
fallback: string
|
|
350
|
+
): string {
|
|
351
|
+
if (!parsed) return fallback;
|
|
352
|
+
return JSON.stringify({
|
|
353
|
+
status: parsed.status ?? "unknown",
|
|
354
|
+
mcp_id: mcpId,
|
|
355
|
+
authenticated: parsed.authenticated ?? false,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function tryJson(text: string): Record<string, unknown> | null {
|
|
360
|
+
try {
|
|
361
|
+
const v = JSON.parse(text);
|
|
362
|
+
return v && typeof v === "object" && !Array.isArray(v)
|
|
363
|
+
? (v as Record<string, unknown>)
|
|
364
|
+
: null;
|
|
365
|
+
} catch {
|
|
366
|
+
return null;
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Build one command per MCP server in `ref.current.mcpStatus`, including
|
|
372
|
+
* servers that currently have no discovered tools (so `<server> auth login`
|
|
373
|
+
* still works for unauthenticated servers).
|
|
374
|
+
*/
|
|
375
|
+
export function buildMcpCliCommands(
|
|
376
|
+
ref: McpRuntimeRef,
|
|
377
|
+
gw: GatewayParams,
|
|
378
|
+
deps: Partial<McpCliDeps> = {}
|
|
379
|
+
): McpCliCommand[] {
|
|
380
|
+
const resolvedDeps: McpCliDeps = { ...DEFAULT_DEPS, ...deps };
|
|
381
|
+
const state = ref.current;
|
|
382
|
+
const serverIds = new Set<string>([
|
|
383
|
+
...Object.keys(state.mcpTools ?? {}),
|
|
384
|
+
...(state.mcpStatus ?? []).map((s) => s.id),
|
|
385
|
+
]);
|
|
386
|
+
|
|
387
|
+
const commands: McpCliCommand[] = [];
|
|
388
|
+
for (const mcpId of serverIds) {
|
|
389
|
+
const reserved = isMcpIdReserved(mcpId);
|
|
390
|
+
if (reserved) {
|
|
391
|
+
logger.warn(
|
|
392
|
+
`Skipping MCP CLI registration for "${mcpId}" — ${reserved}. Rename the MCP server to enable CLI mode.`
|
|
393
|
+
);
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
commands.push({
|
|
397
|
+
name: mcpId,
|
|
398
|
+
execute: buildMcpServerHandler(mcpId, ref, gw, resolvedDeps),
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
return commands;
|
|
402
|
+
}
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP implementation of WorkerTransport
|
|
3
|
+
* Sends worker responses to gateway via HTTP POST requests
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
createLogger,
|
|
8
|
+
retryWithBackoff,
|
|
9
|
+
type WorkerTransport,
|
|
10
|
+
type WorkerTransportConfig,
|
|
11
|
+
} from "@lobu/core";
|
|
12
|
+
import type { ResponseData } from "./types";
|
|
13
|
+
|
|
14
|
+
const logger = createLogger("http-worker-transport");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* HTTP transport for worker-to-gateway communication
|
|
18
|
+
* Implements retry logic and deduplication for streaming responses
|
|
19
|
+
*/
|
|
20
|
+
export class HttpWorkerTransport implements WorkerTransport {
|
|
21
|
+
private gatewayUrl: string;
|
|
22
|
+
private workerToken: string;
|
|
23
|
+
private userId: string;
|
|
24
|
+
private channelId: string;
|
|
25
|
+
private conversationId: string;
|
|
26
|
+
private originalMessageTs: string;
|
|
27
|
+
private botResponseTs?: string;
|
|
28
|
+
public processedMessageIds: string[] = [];
|
|
29
|
+
private jobId?: string;
|
|
30
|
+
private moduleData?: Record<string, unknown>;
|
|
31
|
+
private teamId: string;
|
|
32
|
+
private platform?: string;
|
|
33
|
+
private platformMetadata?: Record<string, unknown>;
|
|
34
|
+
private accumulatedStreamContent: string[] = [];
|
|
35
|
+
private lastStreamDelta: string = "";
|
|
36
|
+
|
|
37
|
+
constructor(config: WorkerTransportConfig) {
|
|
38
|
+
this.gatewayUrl = config.gatewayUrl;
|
|
39
|
+
this.workerToken = config.workerToken;
|
|
40
|
+
this.userId = config.userId;
|
|
41
|
+
this.channelId = config.channelId;
|
|
42
|
+
this.conversationId = config.conversationId;
|
|
43
|
+
this.originalMessageTs = config.originalMessageTs;
|
|
44
|
+
this.botResponseTs = config.botResponseTs;
|
|
45
|
+
this.teamId = config.teamId;
|
|
46
|
+
this.platform = config.platform;
|
|
47
|
+
this.platformMetadata = config.platformMetadata;
|
|
48
|
+
this.processedMessageIds = config.processedMessageIds || [];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
setJobId(jobId: string): void {
|
|
52
|
+
this.jobId = jobId;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
setModuleData(moduleData: Record<string, unknown>): void {
|
|
56
|
+
this.moduleData = moduleData;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async signalDone(finalDelta?: string): Promise<void> {
|
|
60
|
+
// Send final delta if there is one
|
|
61
|
+
if (finalDelta) {
|
|
62
|
+
await this.sendStreamDelta(finalDelta, false, true);
|
|
63
|
+
}
|
|
64
|
+
await this.signalCompletion();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async sendStreamDelta(
|
|
68
|
+
delta: string,
|
|
69
|
+
isFullReplacement: boolean = false,
|
|
70
|
+
isFinal: boolean = false
|
|
71
|
+
): Promise<void> {
|
|
72
|
+
let actualDelta = delta;
|
|
73
|
+
|
|
74
|
+
// Handle final result with deduplication
|
|
75
|
+
if (isFinal) {
|
|
76
|
+
logger.info(`🔍 Processing final result with deduplication`);
|
|
77
|
+
logger.info(`Final text length: ${delta.length} chars`);
|
|
78
|
+
const accumulatedStr = this.accumulatedStreamContent.join("");
|
|
79
|
+
const accumulatedLength = accumulatedStr.length;
|
|
80
|
+
logger.info(`Accumulated length: ${accumulatedLength} chars`);
|
|
81
|
+
|
|
82
|
+
// Check if final result is identical to what we've already sent
|
|
83
|
+
if (delta === accumulatedStr) {
|
|
84
|
+
logger.info(
|
|
85
|
+
`✅ Final result is identical to accumulated content - skipping duplicate`
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Check if accumulated content is a prefix of final result
|
|
91
|
+
if (delta.startsWith(accumulatedStr)) {
|
|
92
|
+
// Only send the missing part
|
|
93
|
+
actualDelta = delta.slice(accumulatedLength);
|
|
94
|
+
if (actualDelta.length === 0) {
|
|
95
|
+
logger.info(
|
|
96
|
+
`✅ Final result fully contained in accumulated content - skipping`
|
|
97
|
+
);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
logger.info(
|
|
101
|
+
`📝 Final result has ${actualDelta.length} new chars - sending delta only`
|
|
102
|
+
);
|
|
103
|
+
} else if (accumulatedLength > 0) {
|
|
104
|
+
const normalizedFinal = this.normalizeForComparison(delta);
|
|
105
|
+
const normalizedLastDelta = this.normalizeForComparison(
|
|
106
|
+
this.lastStreamDelta
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
if (
|
|
110
|
+
normalizedFinal.length > 0 &&
|
|
111
|
+
normalizedFinal === normalizedLastDelta
|
|
112
|
+
) {
|
|
113
|
+
logger.info(
|
|
114
|
+
`✅ Final result matches last streamed delta (normalized) - skipping duplicate`
|
|
115
|
+
);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Content differs - log warning and send full final result
|
|
120
|
+
logger.warn(`⚠️ Final result differs from accumulated content!`);
|
|
121
|
+
logger.warn(
|
|
122
|
+
`First 100 chars of accumulated: ${accumulatedStr.substring(0, 100)}`
|
|
123
|
+
);
|
|
124
|
+
logger.warn(`First 100 chars of final: ${delta.substring(0, 100)}`);
|
|
125
|
+
logger.info(`📤 Sending full final result (${delta.length} chars)`);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Track accumulated content for deduplication using array buffer (O(1) append)
|
|
130
|
+
if (!isFullReplacement) {
|
|
131
|
+
this.accumulatedStreamContent.push(actualDelta);
|
|
132
|
+
} else {
|
|
133
|
+
this.accumulatedStreamContent = [actualDelta];
|
|
134
|
+
}
|
|
135
|
+
this.lastStreamDelta = actualDelta;
|
|
136
|
+
|
|
137
|
+
await this.sendResponse(
|
|
138
|
+
this.buildBaseResponse({
|
|
139
|
+
delta: actualDelta,
|
|
140
|
+
moduleData: this.moduleData,
|
|
141
|
+
isFullReplacement,
|
|
142
|
+
})
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async signalCompletion(): Promise<void> {
|
|
147
|
+
await this.sendResponse(
|
|
148
|
+
this.buildBaseResponse({
|
|
149
|
+
processedMessageIds: this.processedMessageIds,
|
|
150
|
+
moduleData: this.moduleData,
|
|
151
|
+
})
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async signalError(error: Error, errorCode?: string): Promise<void> {
|
|
156
|
+
await this.sendResponse(
|
|
157
|
+
this.buildBaseResponse({
|
|
158
|
+
error: error.message,
|
|
159
|
+
...(errorCode && { errorCode }),
|
|
160
|
+
})
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async sendStatusUpdate(elapsedSeconds: number, state: string): Promise<void> {
|
|
165
|
+
await this.sendResponse(
|
|
166
|
+
this.buildBaseResponse({
|
|
167
|
+
statusUpdate: { elapsedSeconds, state },
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
async sendCustomEvent(
|
|
173
|
+
name: string,
|
|
174
|
+
data: Record<string, unknown>
|
|
175
|
+
): Promise<void> {
|
|
176
|
+
await this.sendResponse(
|
|
177
|
+
this.buildBaseResponse({
|
|
178
|
+
customEvent: { name, data },
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build base response payload with common fields shared across all response types
|
|
185
|
+
*/
|
|
186
|
+
private buildBaseResponse(
|
|
187
|
+
additionalFields?: Partial<ResponseData>
|
|
188
|
+
): ResponseData {
|
|
189
|
+
return {
|
|
190
|
+
messageId: this.originalMessageTs,
|
|
191
|
+
channelId: this.channelId,
|
|
192
|
+
conversationId: this.conversationId,
|
|
193
|
+
userId: this.userId,
|
|
194
|
+
teamId: this.teamId,
|
|
195
|
+
timestamp: Date.now(),
|
|
196
|
+
originalMessageId: this.originalMessageTs,
|
|
197
|
+
botResponseId: this.botResponseTs,
|
|
198
|
+
...additionalFields,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Build exec response payload with exec-specific fields
|
|
204
|
+
*/
|
|
205
|
+
private buildExecResponse(
|
|
206
|
+
execId: string,
|
|
207
|
+
additionalFields: Partial<ResponseData>
|
|
208
|
+
): ResponseData {
|
|
209
|
+
return this.buildBaseResponse({ execId, ...additionalFields });
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Send exec output (stdout/stderr) to gateway
|
|
214
|
+
*/
|
|
215
|
+
async sendExecOutput(
|
|
216
|
+
execId: string,
|
|
217
|
+
stream: "stdout" | "stderr",
|
|
218
|
+
content: string
|
|
219
|
+
): Promise<void> {
|
|
220
|
+
await this.sendResponse(
|
|
221
|
+
this.buildExecResponse(execId, { delta: content, execStream: stream })
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Send exec completion to gateway
|
|
227
|
+
*/
|
|
228
|
+
async sendExecComplete(execId: string, exitCode: number): Promise<void> {
|
|
229
|
+
await this.sendResponse(
|
|
230
|
+
this.buildExecResponse(execId, { execExitCode: exitCode })
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Send exec error to gateway
|
|
236
|
+
*/
|
|
237
|
+
async sendExecError(execId: string, errorMessage: string): Promise<void> {
|
|
238
|
+
await this.sendResponse(
|
|
239
|
+
this.buildExecResponse(execId, { error: errorMessage })
|
|
240
|
+
);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async sendResponse(data: ResponseData): Promise<void> {
|
|
244
|
+
const responseUrl = `${this.gatewayUrl}/worker/response`;
|
|
245
|
+
const basePayload = {
|
|
246
|
+
...data,
|
|
247
|
+
...(this.platform && !data.platform ? { platform: this.platform } : {}),
|
|
248
|
+
...(!data.platformMetadata && this.platformMetadata
|
|
249
|
+
? { platformMetadata: this.platformMetadata }
|
|
250
|
+
: {}),
|
|
251
|
+
};
|
|
252
|
+
const payload = this.jobId
|
|
253
|
+
? { jobId: this.jobId, ...basePayload }
|
|
254
|
+
: basePayload;
|
|
255
|
+
|
|
256
|
+
await retryWithBackoff(
|
|
257
|
+
async () => {
|
|
258
|
+
// Don't `JSON.stringify(payload)` just to truncate it for a log line —
|
|
259
|
+
// that's a full serialize-then-discard on the per-delta hot path
|
|
260
|
+
// (and platformMetadata can be large). Log the identifying fields only.
|
|
261
|
+
logger.info(
|
|
262
|
+
`[WORKER-HTTP] Sending to ${responseUrl}: messageId=${payload.messageId ?? ""}${
|
|
263
|
+
payload.delta ? ` deltaLength=${payload.delta.length}` : ""
|
|
264
|
+
}${payload.statusUpdate ? " statusUpdate" : ""}${payload.customEvent ? ` customEvent=${payload.customEvent.name}` : ""}`
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const response = await fetch(responseUrl, {
|
|
268
|
+
method: "POST",
|
|
269
|
+
headers: {
|
|
270
|
+
Authorization: `Bearer ${this.workerToken}`,
|
|
271
|
+
"Content-Type": "application/json",
|
|
272
|
+
},
|
|
273
|
+
body: JSON.stringify(payload),
|
|
274
|
+
signal: AbortSignal.timeout(30_000),
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
if (!response.ok) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Failed to send response to dispatcher: ${response.status} ${response.statusText}`
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
logger.debug("Response sent to dispatcher successfully");
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
maxRetries: 2,
|
|
287
|
+
baseDelay: 1000,
|
|
288
|
+
onRetry: (attempt, error) => {
|
|
289
|
+
logger.warn(`Failed to send response (attempt ${attempt}/2):`, error);
|
|
290
|
+
},
|
|
291
|
+
}
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private normalizeForComparison(text: string): string {
|
|
296
|
+
return text.replace(/\r\n/g, "\n").trim();
|
|
297
|
+
}
|
|
298
|
+
}
|