@alfe.ai/gateway 0.1.0 → 0.1.2
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/health.js +473 -23
- package/package.json +7 -7
package/dist/health.js
CHANGED
|
@@ -6,7 +6,7 @@ import { promisify } from "node:util";
|
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import pino from "pino";
|
|
9
|
-
import { chmodSync, existsSync, readFileSync, unlinkSync } from "node:fs";
|
|
9
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
10
10
|
import { getEndpointFromToken, readConfig } from "@alfe.ai/config";
|
|
11
11
|
import crypto from "crypto";
|
|
12
12
|
import { parse } from "smol-toml";
|
|
@@ -14,7 +14,7 @@ import WebSocket from "ws";
|
|
|
14
14
|
import { createConnection, createServer } from "node:net";
|
|
15
15
|
import { IntegrationManager, IntegrationManagerAdapter, McpApplier, OpenClawApplier } from "@alfe.ai/integrations";
|
|
16
16
|
import { AgentApiClient } from "@alfe.ai/agent-api-client";
|
|
17
|
-
import { Manager } from "@alfe.ai/mcp-bundler";
|
|
17
|
+
import { Manager, McpBundler, defaultConnect } from "@alfe.ai/mcp-bundler";
|
|
18
18
|
import stream, { Readable } from "stream";
|
|
19
19
|
import util, { format } from "util";
|
|
20
20
|
import http from "http";
|
|
@@ -93,7 +93,9 @@ const ID_PREFIXES = {
|
|
|
93
93
|
role: "role",
|
|
94
94
|
directGrant: "dgr",
|
|
95
95
|
oauthConnection: "con",
|
|
96
|
+
channel: "chn",
|
|
96
97
|
oauthConnectionRequest: "crq",
|
|
98
|
+
deviceCode: "dvc",
|
|
97
99
|
attachment: "att",
|
|
98
100
|
run: "run",
|
|
99
101
|
request: "req",
|
|
@@ -259,31 +261,41 @@ var AlfeApiClient = class {
|
|
|
259
261
|
this.getToken = options.getToken;
|
|
260
262
|
this.onAuthFailure = options.onAuthFailure;
|
|
261
263
|
}
|
|
262
|
-
/**
|
|
263
|
-
|
|
264
|
+
/**
|
|
265
|
+
* Shared fetch logic — handles auth, 401, and network errors.
|
|
266
|
+
*
|
|
267
|
+
* `skipAuth` callers (public endpoints like OAuth device-code) opt out of
|
|
268
|
+
* the auth-header injection AND the synthetic 401 short-circuit. Without
|
|
269
|
+
* this opt-out, a CLI calling /auth/device-code (no token yet by design)
|
|
270
|
+
* would never reach the network — the `getToken: () => null` path would
|
|
271
|
+
* synthesize a 401 and fire onAuthFailure, breaking the entire flow.
|
|
272
|
+
*/
|
|
273
|
+
async _fetch(path, options, skipAuth = false) {
|
|
264
274
|
try {
|
|
265
|
-
const token = await this.getToken();
|
|
266
|
-
if (!token) {
|
|
267
|
-
this.onAuthFailure?.();
|
|
268
|
-
return {
|
|
269
|
-
ok: false,
|
|
270
|
-
result: {
|
|
271
|
-
ok: false,
|
|
272
|
-
error: "No auth token available",
|
|
273
|
-
status: 401
|
|
274
|
-
}
|
|
275
|
-
};
|
|
276
|
-
}
|
|
277
275
|
const url = `${this.apiBaseUrl}${path}`;
|
|
278
276
|
const headers = new Headers(options?.headers);
|
|
279
|
-
headers.set("Authorization", `Bearer ${token}`);
|
|
280
277
|
headers.set("Content-Type", "application/json");
|
|
281
278
|
headers.set("x-correlation-id", correlationId());
|
|
279
|
+
if (!skipAuth) {
|
|
280
|
+
const token = await this.getToken();
|
|
281
|
+
if (!token) {
|
|
282
|
+
this.onAuthFailure?.();
|
|
283
|
+
return {
|
|
284
|
+
ok: false,
|
|
285
|
+
result: {
|
|
286
|
+
ok: false,
|
|
287
|
+
error: "No auth token available",
|
|
288
|
+
status: 401
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
293
|
+
}
|
|
282
294
|
const res = await fetch(url, {
|
|
283
295
|
...options,
|
|
284
296
|
headers
|
|
285
297
|
});
|
|
286
|
-
if (res.status === 401) {
|
|
298
|
+
if (res.status === 401 && !skipAuth) {
|
|
287
299
|
this.onAuthFailure?.();
|
|
288
300
|
return {
|
|
289
301
|
ok: false,
|
|
@@ -331,6 +343,20 @@ var AlfeApiClient = class {
|
|
|
331
343
|
};
|
|
332
344
|
}
|
|
333
345
|
/**
|
|
346
|
+
* Make a request to a PUBLIC endpoint that does not require authentication.
|
|
347
|
+
* Skips both the Authorization header injection AND the onAuthFailure
|
|
348
|
+
* callback. Use for endpoints like /auth/device-code that the CLI hits
|
|
349
|
+
* before it has a token.
|
|
350
|
+
*/
|
|
351
|
+
async publicRequest(path, options) {
|
|
352
|
+
const result = await this._fetch(path, options, true);
|
|
353
|
+
if (!result.ok) return result.result;
|
|
354
|
+
return {
|
|
355
|
+
ok: true,
|
|
356
|
+
data: result.body.data
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
334
360
|
* Make an authenticated request that returns the body directly (no envelope unwrap).
|
|
335
361
|
* Use for APIs that don't use the @auriclabs/api-core response format (e.g. gateway).
|
|
336
362
|
*/
|
|
@@ -372,6 +398,55 @@ var AuthService = class {
|
|
|
372
398
|
deleteToken(tokenId) {
|
|
373
399
|
return this.client.request(`${this.prefix}/tokens/${tokenId}`, { method: "DELETE" });
|
|
374
400
|
}
|
|
401
|
+
/**
|
|
402
|
+
* Start a device-code flow. Called by the CLI when the user runs
|
|
403
|
+
* `alfe login` and picks the browser path. The returned `device_code`
|
|
404
|
+
* is the CLI's bearer secret for polling /auth/device-token; the
|
|
405
|
+
* `user_code` is what the user types/sees on the dashboard.
|
|
406
|
+
*
|
|
407
|
+
* Uses `publicRequest` because the CLI has no token yet — the standard
|
|
408
|
+
* `request` path would short-circuit with a synthetic 401.
|
|
409
|
+
*/
|
|
410
|
+
startDeviceCode(input) {
|
|
411
|
+
return this.client.publicRequest(`${this.prefix}/device-code`, {
|
|
412
|
+
method: "POST",
|
|
413
|
+
body: JSON.stringify(input)
|
|
414
|
+
});
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* Poll for the minted API key. Public endpoint. Returns:
|
|
418
|
+
* 200 → { api_key, tenantId, tokenExpiresAt } (success — write to config)
|
|
419
|
+
* 428 → still pending (caller should sleep `interval` and retry)
|
|
420
|
+
* 429 → polling too fast; caller should back off
|
|
421
|
+
* 410 → device-code expired; user must run `alfe login` again
|
|
422
|
+
* 400 → already redeemed (security: do not retry)
|
|
423
|
+
*
|
|
424
|
+
* Surface the HTTP-status code via the standard ApiResult error shape
|
|
425
|
+
* so callers can branch.
|
|
426
|
+
*/
|
|
427
|
+
pollDeviceToken(deviceCode) {
|
|
428
|
+
return this.client.publicRequest(`${this.prefix}/device-token`, {
|
|
429
|
+
method: "POST",
|
|
430
|
+
body: JSON.stringify({ device_code: deviceCode })
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
/** Dashboard-side. Fetch the device fingerprint for an approval card. */
|
|
434
|
+
lookupDeviceCode(userCode) {
|
|
435
|
+
return this.client.request(`${this.prefix}/device-code/lookup`, {
|
|
436
|
+
method: "POST",
|
|
437
|
+
body: JSON.stringify({ user_code: userCode })
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
/** Dashboard-side. Approve the pending device-code; CLI's next poll gets the key. */
|
|
441
|
+
approveDeviceCode(input) {
|
|
442
|
+
return this.client.request(`${this.prefix}/device-code/approve`, {
|
|
443
|
+
method: "POST",
|
|
444
|
+
body: JSON.stringify({
|
|
445
|
+
user_code: input.userCode,
|
|
446
|
+
expiresIn: input.expiresIn
|
|
447
|
+
})
|
|
448
|
+
});
|
|
449
|
+
}
|
|
375
450
|
getOnboardingStatus() {
|
|
376
451
|
return this.client.request(`${this.prefix}/onboarding/status`);
|
|
377
452
|
}
|
|
@@ -510,8 +585,9 @@ var IntegrationsService = class {
|
|
|
510
585
|
});
|
|
511
586
|
return this.client.request(`/integrations/scoped/${encodeURIComponent(integrationId)}?${params}`, { method: "DELETE" });
|
|
512
587
|
}
|
|
513
|
-
listIntegrations(agentId) {
|
|
514
|
-
|
|
588
|
+
listIntegrations(agentId, options) {
|
|
589
|
+
const qs = options?.includeInherited ? "?includeInherited=true" : "";
|
|
590
|
+
return this.client.request(`/integrations/agents/${agentId}${qs}`);
|
|
515
591
|
}
|
|
516
592
|
installIntegration(agentId, data) {
|
|
517
593
|
return this.client.request(`/integrations/agents/${agentId}`, {
|
|
@@ -4276,7 +4352,10 @@ enumValues({
|
|
|
4276
4352
|
Zhipu: "zhipu",
|
|
4277
4353
|
OpenRouter: "openrouter",
|
|
4278
4354
|
ElevenLabs: "elevenlabs",
|
|
4279
|
-
Twilio: "twilio"
|
|
4355
|
+
Twilio: "twilio",
|
|
4356
|
+
ClaudeMax: "claude-max",
|
|
4357
|
+
OpenAICodexMax: "openai-codex-max",
|
|
4358
|
+
GeminiMax: "gemini-max"
|
|
4280
4359
|
});
|
|
4281
4360
|
enumValues({
|
|
4282
4361
|
Month: "month",
|
|
@@ -4334,6 +4413,7 @@ enumValues({
|
|
|
4334
4413
|
Project: "project",
|
|
4335
4414
|
Agent: "agent"
|
|
4336
4415
|
});
|
|
4416
|
+
["alfe"].filter((id) => id !== "alfe");
|
|
4337
4417
|
enumValues({
|
|
4338
4418
|
Public: "public",
|
|
4339
4419
|
Hidden: "hidden"
|
|
@@ -21700,7 +21780,7 @@ var CommandRegistry = class {
|
|
|
21700
21780
|
* the gateway when needed and uses `callerScopes: ["operator.admin"]`.
|
|
21701
21781
|
* 3. Skip iteration if `pending.json` mtime hasn't changed (cheap fast path)
|
|
21702
21782
|
*/
|
|
21703
|
-
const execFileAsync$
|
|
21783
|
+
const execFileAsync$2 = promisify(execFile);
|
|
21704
21784
|
const APPROVE_TIMEOUT_MS = 1e4;
|
|
21705
21785
|
function resolveStateDir(override) {
|
|
21706
21786
|
if (override) return override;
|
|
@@ -21798,7 +21878,7 @@ function startPairingApprovalPoller(opts) {
|
|
|
21798
21878
|
const stateDir = resolveStateDir(opts.stateDir);
|
|
21799
21879
|
const intervalMs = opts.intervalMs ?? 3e4;
|
|
21800
21880
|
const exec = opts.exec ?? (async (file, args, { timeout }) => {
|
|
21801
|
-
const { stdout, stderr } = await execFileAsync$
|
|
21881
|
+
const { stdout, stderr } = await execFileAsync$2(file, args, { timeout });
|
|
21802
21882
|
return {
|
|
21803
21883
|
stdout,
|
|
21804
21884
|
stderr
|
|
@@ -21840,6 +21920,128 @@ function startPairingApprovalPoller(opts) {
|
|
|
21840
21920
|
};
|
|
21841
21921
|
}
|
|
21842
21922
|
//#endregion
|
|
21923
|
+
//#region src/openclaw-mcp-cleanup.ts
|
|
21924
|
+
/**
|
|
21925
|
+
* One-shot startup migration that drops stale `mcp.servers.*` entries
|
|
21926
|
+
* from openclaw.json on agents set up before the single-source-of-truth
|
|
21927
|
+
* refactor. Pre-cutover agents had two writers:
|
|
21928
|
+
*
|
|
21929
|
+
* - The old `post_activate.mjs` hook wrote `mcp.servers.<integrationId>`
|
|
21930
|
+
* directly (legacy style — e.g. QA Tester's `mcp.servers.atlassian`).
|
|
21931
|
+
* - The bundler manager mirror-wrote `mcp.servers.<integrationId>-<serverId>`
|
|
21932
|
+
* for entries it owned (newer style).
|
|
21933
|
+
*
|
|
21934
|
+
* Post-cutover the alfe store owns everything and the openclaw.json
|
|
21935
|
+
* mirror is gone. If the legacy entries linger they cause:
|
|
21936
|
+
* 1. claude-cli / codex-cli to native-spawn from openclaw.json AND the
|
|
21937
|
+
* daemon's bundler to spawn from the store → two mcp-atlassian
|
|
21938
|
+
* children, the second one likely missing credentials.
|
|
21939
|
+
* 2. The openclaw-mcp-bundler plugin's `hasNativeOpenclawMcpServers`
|
|
21940
|
+
* check returns true (mcp.servers non-empty) → plugin no-ops on
|
|
21941
|
+
* every backend → MiniMax-backed agents lose tools entirely.
|
|
21942
|
+
*
|
|
21943
|
+
* Strategy: on first daemon start after the upgrade, drop every key in
|
|
21944
|
+
* openclaw.json#mcp.servers that the alfe store ALSO holds (direct id
|
|
21945
|
+
* match) OR that maps to an alfe-store entry by integration owner
|
|
21946
|
+
* (catches the legacy `mcp.servers.<integrationId>` shape where the
|
|
21947
|
+
* new store uses `<integrationId>-<serverId>`). User-added private MCPs
|
|
21948
|
+
* (e.g. `mcp.servers.my-private-thing` with no matching alfe entry) are
|
|
21949
|
+
* preserved.
|
|
21950
|
+
*
|
|
21951
|
+
* Sentinel-gated at `~/.alfe/.openclaw-mirror-migrated` — runs exactly
|
|
21952
|
+
* once per agent.
|
|
21953
|
+
*/
|
|
21954
|
+
const execFileAsync$1 = promisify(execFile);
|
|
21955
|
+
const DEFAULT_SENTINEL_PATH = join(homedir(), ".alfe", ".openclaw-mirror-migrated");
|
|
21956
|
+
const defaultOpenclaw = {
|
|
21957
|
+
async listServers() {
|
|
21958
|
+
try {
|
|
21959
|
+
const { stdout } = await execFileAsync$1("openclaw", [
|
|
21960
|
+
"config",
|
|
21961
|
+
"get",
|
|
21962
|
+
"mcp.servers"
|
|
21963
|
+
], { timeout: 1e4 });
|
|
21964
|
+
const trimmed = stdout.trim();
|
|
21965
|
+
if (!trimmed) return [];
|
|
21966
|
+
const parsed = JSON.parse(trimmed);
|
|
21967
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return [];
|
|
21968
|
+
return Object.keys(parsed);
|
|
21969
|
+
} catch (err) {
|
|
21970
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
21971
|
+
if (/not\s+set|not\s+found|undefined|no such key/i.test(message)) return [];
|
|
21972
|
+
throw err;
|
|
21973
|
+
}
|
|
21974
|
+
},
|
|
21975
|
+
async unsetServer(key) {
|
|
21976
|
+
await execFileAsync$1("openclaw", [
|
|
21977
|
+
"config",
|
|
21978
|
+
"unset",
|
|
21979
|
+
`mcp.servers.${key}`
|
|
21980
|
+
], { timeout: 1e4 });
|
|
21981
|
+
}
|
|
21982
|
+
};
|
|
21983
|
+
/**
|
|
21984
|
+
* Run the cleanup. Idempotent — second call after the sentinel lands
|
|
21985
|
+
* returns immediately. Errors are caught and logged; the daemon must
|
|
21986
|
+
* not abort startup over a best-effort migration.
|
|
21987
|
+
*/
|
|
21988
|
+
async function runOpenclawMcpCleanup(opts) {
|
|
21989
|
+
const sentinelPath = opts.sentinelPath ?? DEFAULT_SENTINEL_PATH;
|
|
21990
|
+
const openclaw = opts.openclaw ?? defaultOpenclaw;
|
|
21991
|
+
if (existsSync(sentinelPath)) {
|
|
21992
|
+
opts.logger.debug({ sentinel: sentinelPath }, "openclaw.json mcp.servers cleanup already ran — skipping");
|
|
21993
|
+
return;
|
|
21994
|
+
}
|
|
21995
|
+
let cleanupErr;
|
|
21996
|
+
try {
|
|
21997
|
+
const existing = await openclaw.listServers();
|
|
21998
|
+
if (existing.length === 0) {
|
|
21999
|
+
opts.logger.debug({}, "openclaw.json#mcp.servers is empty — nothing to clean up");
|
|
22000
|
+
writeSentinel(sentinelPath);
|
|
22001
|
+
return;
|
|
22002
|
+
}
|
|
22003
|
+
const stored = opts.manager.listServers();
|
|
22004
|
+
const storeIds = new Set(stored.map((s) => s.id));
|
|
22005
|
+
const integrationOwners = /* @__PURE__ */ new Set();
|
|
22006
|
+
for (const s of stored) if (s.entry.owner.startsWith("integration:")) integrationOwners.add(s.entry.owner.slice(12));
|
|
22007
|
+
const toUnset = [];
|
|
22008
|
+
for (const key of existing) {
|
|
22009
|
+
if (storeIds.has(key)) {
|
|
22010
|
+
toUnset.push(key);
|
|
22011
|
+
continue;
|
|
22012
|
+
}
|
|
22013
|
+
if (integrationOwners.has(key)) toUnset.push(key);
|
|
22014
|
+
}
|
|
22015
|
+
if (toUnset.length === 0) {
|
|
22016
|
+
opts.logger.info({ existingKeys: existing }, "openclaw.json#mcp.servers has entries but none match alfe-managed integrations — preserving as user customizations");
|
|
22017
|
+
writeSentinel(sentinelPath);
|
|
22018
|
+
return;
|
|
22019
|
+
}
|
|
22020
|
+
for (const key of toUnset) try {
|
|
22021
|
+
await openclaw.unsetServer(key);
|
|
22022
|
+
opts.logger.info({ key }, "Cleaned up stale openclaw.json#mcp.servers entry");
|
|
22023
|
+
} catch (err) {
|
|
22024
|
+
opts.logger.warn({
|
|
22025
|
+
key,
|
|
22026
|
+
err: err instanceof Error ? err.message : String(err)
|
|
22027
|
+
}, "Failed to unset stale mcp.servers entry — continuing");
|
|
22028
|
+
}
|
|
22029
|
+
} catch (err) {
|
|
22030
|
+
cleanupErr = err;
|
|
22031
|
+
opts.logger.warn({ err: err instanceof Error ? err.message : String(err) }, "openclaw.json#mcp.servers cleanup hit an error — leaving sentinel un-touched so next start retries");
|
|
22032
|
+
}
|
|
22033
|
+
if (cleanupErr === void 0) writeSentinel(sentinelPath);
|
|
22034
|
+
}
|
|
22035
|
+
function writeSentinel(path) {
|
|
22036
|
+
try {
|
|
22037
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
22038
|
+
writeFileSync(path, `${(/* @__PURE__ */ new Date()).toISOString()}\n`, {
|
|
22039
|
+
encoding: "utf8",
|
|
22040
|
+
mode: 384
|
|
22041
|
+
});
|
|
22042
|
+
} catch {}
|
|
22043
|
+
}
|
|
22044
|
+
//#endregion
|
|
21843
22045
|
//#region src/daemon.ts
|
|
21844
22046
|
/**
|
|
21845
22047
|
* Alfe Gateway Daemon — main entry point.
|
|
@@ -21862,6 +22064,8 @@ let ipcServer = null;
|
|
|
21862
22064
|
let commandQueue;
|
|
21863
22065
|
let startedAt;
|
|
21864
22066
|
let integrationManager;
|
|
22067
|
+
let mcpBundler = null;
|
|
22068
|
+
let mcpManagerRef = null;
|
|
21865
22069
|
let aiProxyServer = null;
|
|
21866
22070
|
let runtimeProcess = null;
|
|
21867
22071
|
let aiProxyUrl = null;
|
|
@@ -22126,6 +22330,32 @@ async function startDaemon() {
|
|
|
22126
22330
|
apiUrl: config.apiEndpoint
|
|
22127
22331
|
});
|
|
22128
22332
|
const mcpManager = new Manager({ logger: logger$1 });
|
|
22333
|
+
mcpManagerRef = mcpManager;
|
|
22334
|
+
mcpBundler = new McpBundler({
|
|
22335
|
+
logger: logger$1,
|
|
22336
|
+
idleTtlMs: 0
|
|
22337
|
+
}, { connect: defaultConnect });
|
|
22338
|
+
await mcpManager.loadIntoBundler(mcpBundler);
|
|
22339
|
+
const warmBundler = (reason) => {
|
|
22340
|
+
if (!mcpBundler) return;
|
|
22341
|
+
mcpBundler.warmup().catch((err) => {
|
|
22342
|
+
logger$1.warn({
|
|
22343
|
+
err: err instanceof Error ? err.message : String(err),
|
|
22344
|
+
reason
|
|
22345
|
+
}, "MCP bundler warmup failed");
|
|
22346
|
+
});
|
|
22347
|
+
};
|
|
22348
|
+
warmBundler("startup");
|
|
22349
|
+
mcpManager.onChange(() => {
|
|
22350
|
+
warmBundler("store change");
|
|
22351
|
+
});
|
|
22352
|
+
logger$1.info("MCP bundler attached to manager — warming children");
|
|
22353
|
+
await runOpenclawMcpCleanup({
|
|
22354
|
+
manager: mcpManager,
|
|
22355
|
+
logger: logger$1
|
|
22356
|
+
}).catch((err) => {
|
|
22357
|
+
logger$1.warn({ err: err instanceof Error ? err.message : String(err) }, "openclaw.json mcp.servers cleanup threw — startup continues");
|
|
22358
|
+
});
|
|
22129
22359
|
integrationManager = new IntegrationManager({
|
|
22130
22360
|
runtimeAppliers,
|
|
22131
22361
|
mcpApplier: new McpApplier({
|
|
@@ -22203,8 +22433,15 @@ async function startDaemon() {
|
|
|
22203
22433
|
await runtimeProcess.stop();
|
|
22204
22434
|
logger$1.debug("Runtime process stopped");
|
|
22205
22435
|
}
|
|
22436
|
+
if (mcpBundler) {
|
|
22437
|
+
logger$1.debug("Stopping MCP bundler...");
|
|
22438
|
+
await mcpBundler.dispose();
|
|
22439
|
+
mcpBundler = null;
|
|
22440
|
+
logger$1.debug("MCP bundler stopped");
|
|
22441
|
+
}
|
|
22206
22442
|
logger$1.debug("Stopping MCP bundler manager...");
|
|
22207
22443
|
await mcpManager.dispose();
|
|
22444
|
+
mcpManagerRef = null;
|
|
22208
22445
|
logger$1.debug("MCP bundler manager stopped");
|
|
22209
22446
|
if (ipcServer) {
|
|
22210
22447
|
logger$1.debug("Stopping IPC server...");
|
|
@@ -22523,6 +22760,11 @@ function handlePluginRequest(method, params, pluginId) {
|
|
|
22523
22760
|
case "status": return Promise.resolve(handleStatus());
|
|
22524
22761
|
case "integration.list": return Promise.resolve(handleIntegrationList());
|
|
22525
22762
|
case "integration.report": return Promise.resolve(handleIntegrationReport(params, pluginId));
|
|
22763
|
+
case "mcp.list_tools": return Promise.resolve(handleMcpListTools(mcpBundler));
|
|
22764
|
+
case "mcp.call_tool": return handleMcpCallTool(mcpBundler, params);
|
|
22765
|
+
case "mcp.list_servers": return Promise.resolve(handleMcpListServers());
|
|
22766
|
+
case "mcp.add_server": return handleMcpAddServer(params);
|
|
22767
|
+
case "mcp.remove_server": return handleMcpRemoveServer(params);
|
|
22526
22768
|
default: return Promise.resolve({
|
|
22527
22769
|
ok: false,
|
|
22528
22770
|
error: {
|
|
@@ -22565,6 +22807,214 @@ function handleIntegrationList() {
|
|
|
22565
22807
|
payload: { integrations: integrationManager.list() }
|
|
22566
22808
|
};
|
|
22567
22809
|
}
|
|
22810
|
+
/**
|
|
22811
|
+
* Return the daemon-hosted MCP bundler's current namespaced tool catalog.
|
|
22812
|
+
* Called by the openclaw-mcp-bundler plugin on every tool-factory invocation;
|
|
22813
|
+
* cheap (in-memory snapshot, no I/O).
|
|
22814
|
+
*/
|
|
22815
|
+
function handleMcpListTools(bundler) {
|
|
22816
|
+
if (!bundler) return {
|
|
22817
|
+
ok: false,
|
|
22818
|
+
error: {
|
|
22819
|
+
code: "MCP_BUNDLER_UNAVAILABLE",
|
|
22820
|
+
message: "MCP bundler not initialized"
|
|
22821
|
+
}
|
|
22822
|
+
};
|
|
22823
|
+
return {
|
|
22824
|
+
ok: true,
|
|
22825
|
+
payload: { tools: bundler.listTools() }
|
|
22826
|
+
};
|
|
22827
|
+
}
|
|
22828
|
+
/**
|
|
22829
|
+
* Route a tool call to the appropriate MCP child via the daemon-hosted
|
|
22830
|
+
* bundler. `name` is the prefixed (`mcp__<server>__<tool>`) name; args is
|
|
22831
|
+
* the raw JSON object the LLM produced.
|
|
22832
|
+
*/
|
|
22833
|
+
/**
|
|
22834
|
+
* List every server entry in the alfe bundler store. Lets agents inspect
|
|
22835
|
+
* what they've already registered before adding a new one.
|
|
22836
|
+
*/
|
|
22837
|
+
function handleMcpListServers(manager = mcpManagerRef) {
|
|
22838
|
+
if (!manager) return {
|
|
22839
|
+
ok: false,
|
|
22840
|
+
error: {
|
|
22841
|
+
code: "MCP_MANAGER_UNAVAILABLE",
|
|
22842
|
+
message: "MCP manager not initialized"
|
|
22843
|
+
}
|
|
22844
|
+
};
|
|
22845
|
+
return {
|
|
22846
|
+
ok: true,
|
|
22847
|
+
payload: { servers: manager.listServers() }
|
|
22848
|
+
};
|
|
22849
|
+
}
|
|
22850
|
+
/**
|
|
22851
|
+
* Register a new MCP server in the alfe bundler store on behalf of the
|
|
22852
|
+
* agent. Owned as `'manual'` so the agent can later remove it without
|
|
22853
|
+
* an expectedOwner conflict — matches what `alfe mcp add` does from the
|
|
22854
|
+
* CLI. The daemon's bundler will pick the change up via the manager's
|
|
22855
|
+
* store watcher and spawn the child on the next tool-factory call.
|
|
22856
|
+
*/
|
|
22857
|
+
async function handleMcpAddServer(params, manager = mcpManagerRef) {
|
|
22858
|
+
if (!manager) return {
|
|
22859
|
+
ok: false,
|
|
22860
|
+
error: {
|
|
22861
|
+
code: "MCP_MANAGER_UNAVAILABLE",
|
|
22862
|
+
message: "MCP manager not initialized"
|
|
22863
|
+
}
|
|
22864
|
+
};
|
|
22865
|
+
const p = params;
|
|
22866
|
+
if (typeof p.id !== "string" || p.id.length === 0) return {
|
|
22867
|
+
ok: false,
|
|
22868
|
+
error: {
|
|
22869
|
+
code: "INVALID_PARAMS",
|
|
22870
|
+
message: "id is required (string)"
|
|
22871
|
+
}
|
|
22872
|
+
};
|
|
22873
|
+
const config = buildServerConfig(p);
|
|
22874
|
+
if (!config) return {
|
|
22875
|
+
ok: false,
|
|
22876
|
+
error: {
|
|
22877
|
+
code: "INVALID_PARAMS",
|
|
22878
|
+
message: "expected either { command, args?, env?, cwd? } for stdio or { url, transport, headers? } for remote"
|
|
22879
|
+
}
|
|
22880
|
+
};
|
|
22881
|
+
try {
|
|
22882
|
+
await manager.addServer(config, {
|
|
22883
|
+
id: p.id,
|
|
22884
|
+
owner: "manual"
|
|
22885
|
+
});
|
|
22886
|
+
return {
|
|
22887
|
+
ok: true,
|
|
22888
|
+
payload: { id: p.id }
|
|
22889
|
+
};
|
|
22890
|
+
} catch (err) {
|
|
22891
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
22892
|
+
logger$1.warn({
|
|
22893
|
+
id: p.id,
|
|
22894
|
+
err: message
|
|
22895
|
+
}, "mcp.add_server failed");
|
|
22896
|
+
return {
|
|
22897
|
+
ok: false,
|
|
22898
|
+
error: {
|
|
22899
|
+
code: "MCP_ADD_FAILED",
|
|
22900
|
+
message
|
|
22901
|
+
}
|
|
22902
|
+
};
|
|
22903
|
+
}
|
|
22904
|
+
}
|
|
22905
|
+
function buildServerConfig(p) {
|
|
22906
|
+
if (typeof p.command === "string" && p.command.length > 0) {
|
|
22907
|
+
const cfg = { command: p.command };
|
|
22908
|
+
if (Array.isArray(p.args) && p.args.every((a) => typeof a === "string")) cfg.args = p.args;
|
|
22909
|
+
if (p.env && typeof p.env === "object" && !Array.isArray(p.env)) {
|
|
22910
|
+
const env = {};
|
|
22911
|
+
for (const [k, v] of Object.entries(p.env)) if (typeof v === "string") env[k] = v;
|
|
22912
|
+
cfg.env = env;
|
|
22913
|
+
}
|
|
22914
|
+
if (typeof p.cwd === "string") cfg.cwd = p.cwd;
|
|
22915
|
+
return cfg;
|
|
22916
|
+
}
|
|
22917
|
+
if (typeof p.url === "string" && p.url.length > 0) {
|
|
22918
|
+
const transport = p.transport === "streamable-http" ? "streamable-http" : "sse";
|
|
22919
|
+
const cfg = {
|
|
22920
|
+
url: p.url,
|
|
22921
|
+
transport
|
|
22922
|
+
};
|
|
22923
|
+
if (p.headers && typeof p.headers === "object" && !Array.isArray(p.headers)) {
|
|
22924
|
+
const headers = {};
|
|
22925
|
+
for (const [k, v] of Object.entries(p.headers)) if (typeof v === "string") headers[k] = v;
|
|
22926
|
+
cfg.headers = headers;
|
|
22927
|
+
}
|
|
22928
|
+
return cfg;
|
|
22929
|
+
}
|
|
22930
|
+
return null;
|
|
22931
|
+
}
|
|
22932
|
+
/**
|
|
22933
|
+
* Drop a server entry the agent previously registered. Restricted to
|
|
22934
|
+
* `manual`-owned entries so the agent can't accidentally clobber
|
|
22935
|
+
* integration-installed or CLI-installed servers (the daemon owns
|
|
22936
|
+
* those; the agent can ask the user to uninstall an integration via
|
|
22937
|
+
* the dashboard).
|
|
22938
|
+
*/
|
|
22939
|
+
async function handleMcpRemoveServer(params, manager = mcpManagerRef) {
|
|
22940
|
+
if (!manager) return {
|
|
22941
|
+
ok: false,
|
|
22942
|
+
error: {
|
|
22943
|
+
code: "MCP_MANAGER_UNAVAILABLE",
|
|
22944
|
+
message: "MCP manager not initialized"
|
|
22945
|
+
}
|
|
22946
|
+
};
|
|
22947
|
+
const { id } = params;
|
|
22948
|
+
if (typeof id !== "string" || id.length === 0) return {
|
|
22949
|
+
ok: false,
|
|
22950
|
+
error: {
|
|
22951
|
+
code: "INVALID_PARAMS",
|
|
22952
|
+
message: "id is required (string)"
|
|
22953
|
+
}
|
|
22954
|
+
};
|
|
22955
|
+
try {
|
|
22956
|
+
return {
|
|
22957
|
+
ok: true,
|
|
22958
|
+
payload: { removed: await manager.removeServer(id, { expectedOwner: "manual" }) }
|
|
22959
|
+
};
|
|
22960
|
+
} catch (err) {
|
|
22961
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
22962
|
+
if (message.includes("owned by")) return {
|
|
22963
|
+
ok: false,
|
|
22964
|
+
error: {
|
|
22965
|
+
code: "MCP_OWNER_MISMATCH",
|
|
22966
|
+
message
|
|
22967
|
+
}
|
|
22968
|
+
};
|
|
22969
|
+
logger$1.warn({
|
|
22970
|
+
id,
|
|
22971
|
+
err: message
|
|
22972
|
+
}, "mcp.remove_server failed");
|
|
22973
|
+
return {
|
|
22974
|
+
ok: false,
|
|
22975
|
+
error: {
|
|
22976
|
+
code: "MCP_REMOVE_FAILED",
|
|
22977
|
+
message
|
|
22978
|
+
}
|
|
22979
|
+
};
|
|
22980
|
+
}
|
|
22981
|
+
}
|
|
22982
|
+
async function handleMcpCallTool(bundler, params) {
|
|
22983
|
+
if (!bundler) return {
|
|
22984
|
+
ok: false,
|
|
22985
|
+
error: {
|
|
22986
|
+
code: "MCP_BUNDLER_UNAVAILABLE",
|
|
22987
|
+
message: "MCP bundler not initialized"
|
|
22988
|
+
}
|
|
22989
|
+
};
|
|
22990
|
+
const { name, args } = params;
|
|
22991
|
+
if (typeof name !== "string" || name.length === 0) return {
|
|
22992
|
+
ok: false,
|
|
22993
|
+
error: {
|
|
22994
|
+
code: "INVALID_PARAMS",
|
|
22995
|
+
message: "name is required (string)"
|
|
22996
|
+
}
|
|
22997
|
+
};
|
|
22998
|
+
try {
|
|
22999
|
+
return {
|
|
23000
|
+
ok: true,
|
|
23001
|
+
payload: await bundler.callTool(name, args)
|
|
23002
|
+
};
|
|
23003
|
+
} catch (err) {
|
|
23004
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
23005
|
+
logger$1.warn({
|
|
23006
|
+
tool: name,
|
|
23007
|
+
err: message
|
|
23008
|
+
}, "mcp.call_tool failed");
|
|
23009
|
+
return {
|
|
23010
|
+
ok: false,
|
|
23011
|
+
error: {
|
|
23012
|
+
code: "MCP_CALL_FAILED",
|
|
23013
|
+
message
|
|
23014
|
+
}
|
|
23015
|
+
};
|
|
23016
|
+
}
|
|
23017
|
+
}
|
|
22568
23018
|
function handleIntegrationReport(params, pluginId) {
|
|
22569
23019
|
const { name, status, detail } = params;
|
|
22570
23020
|
if (!name || !status) return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@alfe.ai/gateway",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "Alfe local gateway daemon — persistent control plane for agent integrations",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -22,12 +22,12 @@
|
|
|
22
22
|
"pino-roll": "^1.2.0",
|
|
23
23
|
"smol-toml": ">=1.6.1",
|
|
24
24
|
"ws": "^8.18.0",
|
|
25
|
-
"@alfe.ai/agent-api-client": "^0.
|
|
26
|
-
"@alfe.ai/ai-proxy-local": "^0.0.
|
|
27
|
-
"@alfe.ai/config": "^0.0.
|
|
28
|
-
"@alfe.ai/integration-manifest": "^0.
|
|
29
|
-
"@alfe.ai/integrations": "^0.1.
|
|
30
|
-
"@alfe.ai/mcp-bundler": "^0.
|
|
25
|
+
"@alfe.ai/agent-api-client": "^0.2.0",
|
|
26
|
+
"@alfe.ai/ai-proxy-local": "^0.0.10",
|
|
27
|
+
"@alfe.ai/config": "^0.0.9",
|
|
28
|
+
"@alfe.ai/integration-manifest": "^0.2.0",
|
|
29
|
+
"@alfe.ai/integrations": "^0.1.2",
|
|
30
|
+
"@alfe.ai/mcp-bundler": "^0.2.0"
|
|
31
31
|
},
|
|
32
32
|
"license": "UNLICENSED",
|
|
33
33
|
"scripts": {
|