@agentprojectcontext/apx 1.53.6 → 1.54.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/package.json +1 -1
- package/src/core/channels/telegram/ask-callbacks.js +1 -0
- package/src/core/channels/telegram/dispatch.js +7 -1
- package/src/core/channels/telegram/reply.js +3 -1
- package/src/core/config/index.js +11 -1
- package/src/core/confirmation/adapters/telegram.js +16 -4
- package/src/core/confirmation/pending-store.js +30 -4
- package/src/core/http-tools/fetch.js +56 -6
- package/src/core/identity/telegram.js +11 -6
- package/src/core/mcp/runner.js +21 -2
- package/src/core/runtime-skills/apx/SKILL.md +1 -1
- package/src/core/runtime-skills/apx-mcp/SKILL.md +11 -4
- package/src/core/runtime-skills/apx-mcp-builder/SKILL.md +9 -2
- package/src/host/daemon/api/admin.js +39 -9
- package/src/host/daemon/api/mcps.js +15 -0
- package/src/host/daemon/api/shared.js +17 -21
- package/src/host/daemon/api/top-level.js +4 -2
- package/src/host/daemon/desktop-ws.js +3 -2
- package/src/interfaces/cli/commands/mcp.js +122 -5
- package/src/interfaces/cli/index.js +27 -6
- package/src/interfaces/web/package-lock.json +15 -15
package/package.json
CHANGED
|
@@ -56,12 +56,17 @@ export async function handleUpdate(self, u) {
|
|
|
56
56
|
// in-memory globalConfig in place so later messages in this daemon session
|
|
57
57
|
// see the update. The resulting block is injected into whichever agent
|
|
58
58
|
// answers (super-agent OR a routed project agent).
|
|
59
|
-
const { sender } = registerSender({
|
|
59
|
+
const { sender, claimedOwner } = registerSender({
|
|
60
60
|
cfg: self.globalConfig,
|
|
61
61
|
channelName: self.channel.name,
|
|
62
62
|
from: msg.from,
|
|
63
63
|
chatType: msg.chat?.type,
|
|
64
64
|
});
|
|
65
|
+
if (claimedOwner) {
|
|
66
|
+
// Trust-on-first-use: this sender just became owner of a previously
|
|
67
|
+
// ownerless private channel. Log it so an unexpected claim is visible.
|
|
68
|
+
self.log(`telegram[${self.channel.name}] owner claimed by user_id=${msg.from?.id} (${author}) — verify this is you`);
|
|
69
|
+
}
|
|
65
70
|
const relationshipBlock = buildRelationshipBlock(sender);
|
|
66
71
|
// Role-based tool gating for the super-agent path (guests → no tools).
|
|
67
72
|
const allowedTools = resolveAllowedTools(self.globalConfig, sender);
|
|
@@ -287,6 +292,7 @@ export async function handleUpdate(self, u) {
|
|
|
287
292
|
previousMessages,
|
|
288
293
|
target,
|
|
289
294
|
author,
|
|
295
|
+
authorId: msg.from?.id,
|
|
290
296
|
relationshipBlock,
|
|
291
297
|
allowedTools,
|
|
292
298
|
contextNote: slashed.handled ? slashed.contextNote : "",
|
|
@@ -99,13 +99,15 @@ export function buildStreamHandler(self, { chat_id, update_id, agentDisplay }) {
|
|
|
99
99
|
* inherit it. Throws on failure (caller decides abort-vs-error handling).
|
|
100
100
|
*/
|
|
101
101
|
export function runTelegramSuperAgent(self, {
|
|
102
|
-
chat_id, prompt, previousMessages, target, author, relationshipBlock,
|
|
102
|
+
chat_id, prompt, previousMessages, target, author, authorId, relationshipBlock,
|
|
103
103
|
allowedTools, contextNote, signal, onEvent,
|
|
104
104
|
}) {
|
|
105
105
|
const confirmAdapter = createTelegramConfirmAdapter({
|
|
106
106
|
token: resolveBotToken(self.channel),
|
|
107
107
|
chatId: chat_id,
|
|
108
108
|
pendingStore: getConfirmStore(),
|
|
109
|
+
// Only the user who triggered this turn may answer its confirmations.
|
|
110
|
+
guardActorId: authorId ?? null,
|
|
109
111
|
});
|
|
110
112
|
return runSuperAgent({
|
|
111
113
|
globalConfig: self.globalConfig,
|
package/src/core/config/index.js
CHANGED
|
@@ -218,6 +218,10 @@ function backupConfigBeforeLoss() {
|
|
|
218
218
|
const ts = new Date().toISOString().replace(/[:.]/g, "-");
|
|
219
219
|
const backup = `${CONFIG_PATH}.${ts}.bak`;
|
|
220
220
|
fs.copyFileSync(CONFIG_PATH, backup);
|
|
221
|
+
// config.json holds API keys + bot tokens — the backup must not be more
|
|
222
|
+
// permissive than the original. copyFileSync inherits the source mode on
|
|
223
|
+
// some platforms but not reliably, so pin it.
|
|
224
|
+
try { fs.chmodSync(backup, 0o600); } catch {}
|
|
221
225
|
return backup;
|
|
222
226
|
} catch {
|
|
223
227
|
return null;
|
|
@@ -280,8 +284,14 @@ export function writeConfig(cfg) {
|
|
|
280
284
|
if (cfg?._allowClear) delete cfg._allowClear;
|
|
281
285
|
|
|
282
286
|
const tmp = `${CONFIG_PATH}.tmp`;
|
|
283
|
-
|
|
287
|
+
// 0o600: config.json stores provider API keys and the Telegram bot token.
|
|
288
|
+
// Without an explicit mode it lands at 0o644 (world-readable) — any local
|
|
289
|
+
// user could read the secrets. Match daemon.token / clients.json.
|
|
290
|
+
fs.writeFileSync(tmp, JSON.stringify(cfg, null, 2) + "\n", { mode: 0o600 });
|
|
284
291
|
fs.renameSync(tmp, CONFIG_PATH);
|
|
292
|
+
// writeFileSync's mode only applies when the tmp file is created; if it
|
|
293
|
+
// pre-existed with looser perms the bits stick. Pin it after the rename.
|
|
294
|
+
try { fs.chmodSync(CONFIG_PATH, 0o600); } catch {}
|
|
285
295
|
}
|
|
286
296
|
|
|
287
297
|
// Normalise `model_fallback` to the new format (`models` as an ordered array
|
|
@@ -31,12 +31,15 @@ import { sendMessage, answerCallbackQuery as apiAnswerCallbackQuery, editMessage
|
|
|
31
31
|
const TIMEOUT_MS = 60_000; // 60 s — long enough for a human, short enough to not block forever
|
|
32
32
|
|
|
33
33
|
/**
|
|
34
|
-
* @param {{ token: string, chatId: string|number, pendingStore: ConfirmationPendingStore }} opts
|
|
34
|
+
* @param {{ token: string, chatId: string|number, pendingStore: ConfirmationPendingStore, guardActorId?: string|number|null }} opts
|
|
35
35
|
* @returns {{ requestConfirmation, handleCallbackQuery }}
|
|
36
36
|
*/
|
|
37
|
-
export function createTelegramConfirmAdapter({ token, chatId, pendingStore }) {
|
|
37
|
+
export function createTelegramConfirmAdapter({ token, chatId, pendingStore, guardActorId = null }) {
|
|
38
38
|
async function requestConfirmation(tool, _args, description) {
|
|
39
|
-
|
|
39
|
+
// Bind this confirmation to the user who triggered the turn. In a group
|
|
40
|
+
// chat the keyboard is visible to everyone, so without this any member
|
|
41
|
+
// could tap "Yes" and approve the initiator's destructive action.
|
|
42
|
+
const { correlationId, promise } = pendingStore.create({ timeoutMs: TIMEOUT_MS, guardActorId });
|
|
40
43
|
|
|
41
44
|
await sendConfirmKeyboard(token, chatId, description, correlationId, TIMEOUT_MS);
|
|
42
45
|
|
|
@@ -52,12 +55,21 @@ export function createTelegramConfirmAdapter({ token, chatId, pendingStore }) {
|
|
|
52
55
|
|
|
53
56
|
const [, correlationId, answer] = match;
|
|
54
57
|
const confirmed = answer === "yes";
|
|
58
|
+
const presserId = callbackQuery.from?.id ?? null;
|
|
59
|
+
|
|
60
|
+
// Reject a bystander's press without consuming the pending entry or wiping
|
|
61
|
+
// the keyboard — the authorized initiator can still answer. We still return
|
|
62
|
+
// true (the callback matched our namespace and is handled).
|
|
63
|
+
if (!pendingStore.isActorAllowed(correlationId, presserId)) {
|
|
64
|
+
await answerCallbackQuery(token, callbackQuery.id, "⛔ Not your confirmation");
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
55
67
|
|
|
56
68
|
// ACK the callback immediately to clear the loading spinner on the button.
|
|
57
69
|
// Fire-and-forget — a slow ACK is annoying but not fatal.
|
|
58
70
|
await answerCallbackQuery(token, callbackQuery.id, confirmed ? "✅ Confirmed" : "❌ Cancelled");
|
|
59
71
|
|
|
60
|
-
const resolved = pendingStore.resolve(correlationId, confirmed);
|
|
72
|
+
const resolved = pendingStore.resolve(correlationId, confirmed, presserId);
|
|
61
73
|
|
|
62
74
|
// If not resolved, the entry timed out or the process restarted — show "Expired"
|
|
63
75
|
// so the user knows the button is no longer actionable.
|
|
@@ -28,30 +28,56 @@ export class ConfirmationPendingStore {
|
|
|
28
28
|
* - correlationId: embed in the reply (button callback_data, SSE event…)
|
|
29
29
|
* - promise: resolves to true (confirmed) or false (denied / timeout)
|
|
30
30
|
*
|
|
31
|
+
* `guardActorId` (optional): when set, only resolve() calls that supply a
|
|
32
|
+
* matching actorId may answer this confirmation. Channels where the reply is
|
|
33
|
+
* visible to more than the initiator (a Telegram group's inline keyboard)
|
|
34
|
+
* pass the initiator's id so a bystander can't approve someone else's action.
|
|
35
|
+
* Channels whose transport is already 1:1/authenticated (web SSE + token,
|
|
36
|
+
* terminal) leave it null and behave exactly as before.
|
|
37
|
+
*
|
|
31
38
|
* After timeoutMs with no response the promise auto-resolves to false.
|
|
32
39
|
*/
|
|
33
|
-
create({ timeoutMs = 30_000 } = {}) {
|
|
40
|
+
create({ timeoutMs = 30_000, guardActorId = null } = {}) {
|
|
34
41
|
const correlationId = generateId();
|
|
42
|
+
const guard = guardActorId == null ? null : String(guardActorId);
|
|
35
43
|
|
|
36
44
|
const promise = new Promise((resolve) => {
|
|
37
45
|
const timer = setTimeout(() => {
|
|
38
46
|
this._pending.delete(correlationId);
|
|
39
47
|
resolve(false);
|
|
40
48
|
}, timeoutMs);
|
|
41
|
-
this._pending.set(correlationId, { resolve, timer });
|
|
49
|
+
this._pending.set(correlationId, { resolve, timer, guardActorId: guard });
|
|
42
50
|
});
|
|
43
51
|
|
|
44
52
|
return { correlationId, promise };
|
|
45
53
|
}
|
|
46
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Is `actorId` allowed to answer this confirmation? True when the entry is
|
|
57
|
+
* unknown (let resolve() report "expired"), unguarded, or the actor matches
|
|
58
|
+
* the guard. Lets an adapter reject a bystander's press WITHOUT consuming the
|
|
59
|
+
* pending entry, so the real initiator can still respond.
|
|
60
|
+
*/
|
|
61
|
+
isActorAllowed(correlationId, actorId) {
|
|
62
|
+
const entry = this._pending.get(correlationId);
|
|
63
|
+
if (!entry) return true;
|
|
64
|
+
if (entry.guardActorId == null) return true;
|
|
65
|
+
return actorId != null && String(actorId) === entry.guardActorId;
|
|
66
|
+
}
|
|
67
|
+
|
|
47
68
|
/**
|
|
48
69
|
* Resolve a pending confirmation.
|
|
49
70
|
* Returns true if found and resolved, false if not found (timed out, already
|
|
50
|
-
* resolved,
|
|
71
|
+
* resolved, stale button after a restart) OR if the entry is guarded and
|
|
72
|
+
* `actorId` doesn't match — in the mismatch case the entry is preserved so
|
|
73
|
+
* the authorized initiator can still answer.
|
|
51
74
|
*/
|
|
52
|
-
resolve(correlationId, value) {
|
|
75
|
+
resolve(correlationId, value, actorId) {
|
|
53
76
|
const entry = this._pending.get(correlationId);
|
|
54
77
|
if (!entry) return false;
|
|
78
|
+
if (entry.guardActorId != null && (actorId == null || String(actorId) !== entry.guardActorId)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
55
81
|
clearTimeout(entry.timer);
|
|
56
82
|
this._pending.delete(correlationId);
|
|
57
83
|
entry.resolve(value);
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
// Fetch resolver
|
|
16
16
|
// ---------------------------------------------------------------------------
|
|
17
17
|
|
|
18
|
+
import dns from "node:dns/promises";
|
|
19
|
+
import net from "node:net";
|
|
20
|
+
|
|
18
21
|
let _fetch = null;
|
|
19
22
|
|
|
20
23
|
async function getFetch() {
|
|
@@ -32,18 +35,65 @@ async function getFetch() {
|
|
|
32
35
|
const DEFAULT_TIMEOUT = 30000;
|
|
33
36
|
const MAX_BODY_BYTES = 5 * 1024 * 1024; // 5MB
|
|
34
37
|
|
|
35
|
-
// Block
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
// Block cloud metadata endpoints by name (they may resolve to public-looking
|
|
39
|
+
// IPs on some providers). IP-range blocking is done on the RESOLVED address
|
|
40
|
+
// below, not on the literal host string.
|
|
41
|
+
const BLOCKED_HOST_RE = /^(metadata\.google\.internal\.?|metadata\.goog\.?)$/i;
|
|
42
|
+
|
|
43
|
+
// True for an IPv4 string in a loopback / private / link-local / CGNAT range.
|
|
44
|
+
// Malformed input returns true (fail closed) — validateUrl only feeds this
|
|
45
|
+
// addresses from dns.lookup / net.isIP, so a non-parse means "don't trust it".
|
|
46
|
+
function isPrivateIpv4(ip) {
|
|
47
|
+
const parts = ip.split(".").map((n) => Number(n));
|
|
48
|
+
if (parts.length !== 4 || parts.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return true;
|
|
49
|
+
const [a, b] = parts;
|
|
50
|
+
if (a === 0 || a === 127) return true; // this-host / loopback
|
|
51
|
+
if (a === 10) return true; // private
|
|
52
|
+
if (a === 172 && b >= 16 && b <= 31) return true; // private
|
|
53
|
+
if (a === 192 && b === 168) return true; // private
|
|
54
|
+
if (a === 169 && b === 254) return true; // link-local (incl. cloud metadata 169.254.169.254)
|
|
55
|
+
if (a === 100 && b >= 64 && b <= 127) return true; // CGNAT
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isBlockedAddress(addr) {
|
|
60
|
+
if (!addr) return true;
|
|
61
|
+
const ip = String(addr).toLowerCase();
|
|
62
|
+
const mapped = ip.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/); // IPv4-mapped IPv6
|
|
63
|
+
if (mapped) return isPrivateIpv4(mapped[1]);
|
|
64
|
+
const fam = net.isIP(ip);
|
|
65
|
+
if (fam === 4) return isPrivateIpv4(ip);
|
|
66
|
+
if (fam === 6) {
|
|
67
|
+
if (ip === "::1" || ip === "::") return true; // loopback / unspecified
|
|
68
|
+
if (ip.startsWith("fc") || ip.startsWith("fd")) return true; // ULA fc00::/7
|
|
69
|
+
if (/^fe[89ab]/.test(ip)) return true; // link-local fe80::/10
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
return true; // not a recognizable IP → fail closed
|
|
73
|
+
}
|
|
38
74
|
|
|
39
|
-
|
|
75
|
+
// SSRF guard. Resolving the host to concrete IPs (rather than pattern-matching
|
|
76
|
+
// the literal string) defeats DNS names that point at internal ranges AND
|
|
77
|
+
// numeric encodings like http://2130706433 (= 127.0.0.1) that a regex misses.
|
|
78
|
+
// Residual: a rebind between this lookup and the actual connect is still
|
|
79
|
+
// theoretically possible; pinning the resolved IP would need a custom agent.
|
|
80
|
+
async function validateUrl(rawUrl) {
|
|
40
81
|
let parsed;
|
|
41
82
|
try { parsed = new URL(rawUrl); } catch { throw new Error("Invalid URL"); }
|
|
42
83
|
if (!["http:", "https:"].includes(parsed.protocol)) {
|
|
43
84
|
throw new Error(`Protocol "${parsed.protocol}" is not allowed; use http or https`);
|
|
44
85
|
}
|
|
45
86
|
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
46
|
-
if (BLOCKED_HOST_RE.test(host)
|
|
87
|
+
if (BLOCKED_HOST_RE.test(host)) {
|
|
88
|
+
throw new Error(`Requests to private or link-local addresses are blocked`);
|
|
89
|
+
}
|
|
90
|
+
let addresses;
|
|
91
|
+
try {
|
|
92
|
+
addresses = await dns.lookup(host, { all: true });
|
|
93
|
+
} catch {
|
|
94
|
+
throw new Error("Could not resolve host");
|
|
95
|
+
}
|
|
96
|
+
if (!addresses.length || addresses.some(({ address }) => isBlockedAddress(address))) {
|
|
47
97
|
throw new Error(`Requests to private or link-local addresses are blocked`);
|
|
48
98
|
}
|
|
49
99
|
}
|
|
@@ -72,7 +122,7 @@ async function readBody(response, jsonHint) {
|
|
|
72
122
|
|
|
73
123
|
async function doRequest({ url, method = "GET", headers = {}, body = null, timeout_ms = DEFAULT_TIMEOUT, json = false } = {}) {
|
|
74
124
|
if (!url) throw new Error("url required");
|
|
75
|
-
validateUrl(url);
|
|
125
|
+
await validateUrl(url);
|
|
76
126
|
const fetch = await getFetch();
|
|
77
127
|
|
|
78
128
|
const controller = new AbortController();
|
|
@@ -55,16 +55,18 @@ export function resolveSender({ cfg, channelName, from, chatType }) {
|
|
|
55
55
|
* - owner → "*" (all tools)
|
|
56
56
|
* - guest → [] (no tools; text only)
|
|
57
57
|
* - a role defined in telegram.roles → its `tools` ("*" or an array)
|
|
58
|
-
* - any other named role with no definition →
|
|
59
|
-
*
|
|
58
|
+
* - any other named role with no definition → [] (fail closed: a typo'd or
|
|
59
|
+
* removed role must NOT silently grant every tool. Define the role in
|
|
60
|
+
* telegram.roles to grant access.)
|
|
60
61
|
* Returns "*" or an array of tool names.
|
|
61
62
|
*/
|
|
62
63
|
export function resolveAllowedTools(cfg, sender) {
|
|
63
64
|
if (sender?.isOwner) return "*";
|
|
64
65
|
const def = cfg?.telegram?.roles?.[sender?.role];
|
|
65
66
|
if (def && def.tools !== undefined) return def.tools;
|
|
66
|
-
|
|
67
|
-
|
|
67
|
+
// Fail closed: guests and any sender whose role isn't explicitly defined get
|
|
68
|
+
// no tools. Only the owner and configured roles receive capabilities.
|
|
69
|
+
return [];
|
|
68
70
|
}
|
|
69
71
|
|
|
70
72
|
/**
|
|
@@ -108,7 +110,7 @@ export function registerSender({ cfg, channelName, from, chatType }) {
|
|
|
108
110
|
else if (!existing) kind = "guest";
|
|
109
111
|
else if (existing.last_seen?.slice(0, 10) !== now.slice(0, 10)) kind = "touch";
|
|
110
112
|
|
|
111
|
-
if (!kind) return { sender: base(), mutated: false };
|
|
113
|
+
if (!kind) return { sender: base(), mutated: false, claimedOwner: false };
|
|
112
114
|
|
|
113
115
|
if (kind === "claim") {
|
|
114
116
|
upsertTelegramChannel(disk, channelName, { owner_user_id: userId });
|
|
@@ -123,5 +125,8 @@ export function registerSender({ cfg, channelName, from, chatType }) {
|
|
|
123
125
|
upsertContact(disk, userId, { last_seen: now });
|
|
124
126
|
}
|
|
125
127
|
|
|
126
|
-
|
|
128
|
+
// Signal an owner-claim (trust-on-first-use) so the caller can surface it —
|
|
129
|
+
// whoever messages a fresh private channel first becomes owner, and that
|
|
130
|
+
// event must be auditable rather than silent.
|
|
131
|
+
return { sender: base(), mutated: true, claimedOwner: kind === "claim" };
|
|
127
132
|
}
|
package/src/core/mcp/runner.js
CHANGED
|
@@ -153,7 +153,9 @@ class McpProcess {
|
|
|
153
153
|
|
|
154
154
|
async listTools() {
|
|
155
155
|
await this._ensureInitialized();
|
|
156
|
-
return
|
|
156
|
+
return collectToolPages((cursor) =>
|
|
157
|
+
this._send("tools/list", cursor ? { cursor } : {})
|
|
158
|
+
);
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
async callTool(name, args) {
|
|
@@ -374,7 +376,9 @@ class HttpMcpClient {
|
|
|
374
376
|
|
|
375
377
|
async listTools() {
|
|
376
378
|
await this._ensureInitialized();
|
|
377
|
-
return
|
|
379
|
+
return collectToolPages((cursor) =>
|
|
380
|
+
this._rpc("tools/list", cursor ? { cursor } : {})
|
|
381
|
+
);
|
|
378
382
|
}
|
|
379
383
|
|
|
380
384
|
async callTool(name, args) {
|
|
@@ -399,6 +403,21 @@ class HttpMcpClient {
|
|
|
399
403
|
}
|
|
400
404
|
}
|
|
401
405
|
|
|
406
|
+
// tools/list is paginated (nextCursor). Follow every page and hand back a
|
|
407
|
+
// single merged { tools } result so callers never see partial catalogs.
|
|
408
|
+
const MAX_TOOL_PAGES = 32;
|
|
409
|
+
async function collectToolPages(fetchPage) {
|
|
410
|
+
const tools = [];
|
|
411
|
+
let cursor;
|
|
412
|
+
for (let i = 0; i < MAX_TOOL_PAGES; i++) {
|
|
413
|
+
const result = await fetchPage(cursor);
|
|
414
|
+
if (Array.isArray(result?.tools)) tools.push(...result.tools);
|
|
415
|
+
cursor = result?.nextCursor;
|
|
416
|
+
if (!cursor) break;
|
|
417
|
+
}
|
|
418
|
+
return { tools };
|
|
419
|
+
}
|
|
420
|
+
|
|
402
421
|
function parseFirstSseJson(raw) {
|
|
403
422
|
for (const block of raw.split(/\r?\n\r?\n/)) {
|
|
404
423
|
const dataLines = [];
|
|
@@ -21,7 +21,7 @@ If you can spawn a subagent natively in the current IDE (Claude Code, Cursor,
|
|
|
21
21
|
|-------|-----------|------|
|
|
22
22
|
| Delegate to external coding CLI | **apx-runtime** | `apx run <agent> --runtime claude-code\|codex\|...` |
|
|
23
23
|
| List/read/resume/summarise/continue sessions | **apx-sessions** | `apx session resume`, `apx sessions list`, "import a codex session" |
|
|
24
|
-
| Use a registered MCP tool | **apx-mcp** | `apx mcp run`, "call MCP filesystem", "MCP failing" |
|
|
24
|
+
| Use a registered MCP tool | **apx-mcp** | `apx mcp tools`, `apx mcp run`, "call MCP filesystem", "MCP failing" |
|
|
25
25
|
| Add/configure/use a project agent | **apx-agent** | "add an agent", vault import, per-agent model, agent memory |
|
|
26
26
|
| Register/list/configure a project | **apx-project** | "register this project", `apx project list`, per-project config |
|
|
27
27
|
| Per-project TODO list | **apx-task** | "add a task", "remind me to…", "what's pending" |
|
|
@@ -47,7 +47,12 @@ apx mcp remove github --scope runtime --project iacrmar
|
|
|
47
47
|
apx mcp enable filesystem --project iacrmar
|
|
48
48
|
apx mcp disable filesystem --project iacrmar
|
|
49
49
|
|
|
50
|
-
#
|
|
50
|
+
# Discover tools — list catalog, then inspect one tool's schema
|
|
51
|
+
apx mcp tools filesystem # table: tool name + description
|
|
52
|
+
apx mcp tools filesystem read_file # params (types, required) + run example
|
|
53
|
+
apx mcp tools filesystem --json # raw JSON with full inputSchema
|
|
54
|
+
|
|
55
|
+
# Call a tool through the daemon
|
|
51
56
|
apx mcp run filesystem read_file '{"path":"README.md"}'
|
|
52
57
|
```
|
|
53
58
|
|
|
@@ -95,13 +100,15 @@ apx mcp remove github # errors if github lives in runtime
|
|
|
95
100
|
|
|
96
101
|
```bash
|
|
97
102
|
apx mcp check --project iacrmar # scopes seen + which files exist
|
|
98
|
-
apx mcp
|
|
103
|
+
apx mcp tools <name> # spawn server + list its tools (proves init works)
|
|
104
|
+
apx mcp logs <name> # spawn/init event log + stderr tail
|
|
105
|
+
apx mcp run <name> <tool> '{...}' # call a tool for real
|
|
99
106
|
apx log -f # tail unified log for spawn errors
|
|
100
107
|
```
|
|
101
108
|
|
|
102
|
-
"Doesn't show tools" = command failed to start (missing env vars, package not found) or crashed during initialize.
|
|
109
|
+
"Doesn't show tools" = command failed to start (missing env vars, package not found) or crashed during initialize. `apx mcp logs <name>` shows the stderr tail; the unified log has the rest.
|
|
103
110
|
|
|
104
|
-
|
|
111
|
+
Standard workflow to use any MCP: `apx mcp tools <name>` → `apx mcp tools <name> <tool>` (copy the run example) → `apx mcp run <name> <tool> '<json>'`.
|
|
105
112
|
|
|
106
113
|
## Don't
|
|
107
114
|
|
|
@@ -151,10 +151,17 @@ apx mcp add github \
|
|
|
151
151
|
## Debugging
|
|
152
152
|
|
|
153
153
|
```bash
|
|
154
|
-
# Smoke test
|
|
154
|
+
# Smoke test — spawn the server and list its tool catalog
|
|
155
|
+
apx mcp tools my-server
|
|
156
|
+
|
|
157
|
+
# Inspect one tool's schema + copy-paste run example
|
|
158
|
+
apx mcp tools my-server search_inventory
|
|
159
|
+
|
|
160
|
+
# Call it for real
|
|
155
161
|
apx mcp run my-server search_inventory '{"query":"shoes"}'
|
|
156
162
|
|
|
157
|
-
# Spawn errors / stderr
|
|
163
|
+
# Spawn errors / stderr tail
|
|
164
|
+
apx mcp logs my-server
|
|
158
165
|
apx log -f
|
|
159
166
|
|
|
160
167
|
# Scopes / files / env APX sees
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
//
|
|
5
5
|
// Both are auth-gated (the global middleware applies).
|
|
6
6
|
import { readConfig } from "#core/config/index.js";
|
|
7
|
-
import {
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import os from "node:os";
|
|
10
10
|
import path from "node:path";
|
|
@@ -57,22 +57,52 @@ export function register(app, { scheduler, plugins, config, registries }) {
|
|
|
57
57
|
// platform lacks a usable picker — or none is installed — the endpoint
|
|
58
58
|
// returns 501 so the frontend can fall back to the inline directory list.
|
|
59
59
|
app.get("/admin/fs/pick-dir", (req, res) => {
|
|
60
|
-
|
|
60
|
+
// The prompt is attacker-controllable (query string). NEVER interpolate it
|
|
61
|
+
// into a shell string — use execFile (no shell) with an argv array, and
|
|
62
|
+
// pass the prompt out-of-band via an env var so it can't break out of any
|
|
63
|
+
// quoting context in the picker's own scripting language.
|
|
64
|
+
const prompt = String(req.query.prompt || "Select a folder");
|
|
61
65
|
const platform = process.platform;
|
|
62
|
-
|
|
66
|
+
const env = { ...process.env, APX_PICK_PROMPT: prompt };
|
|
67
|
+
let file;
|
|
68
|
+
let args;
|
|
63
69
|
if (platform === "darwin") {
|
|
64
70
|
// try/end-try makes cancel exit with code 0 + empty stdout so we can
|
|
65
|
-
// distinguish "cancelled" from "no picker available".
|
|
66
|
-
|
|
71
|
+
// distinguish "cancelled" from "no picker available". `system attribute`
|
|
72
|
+
// reads the env var, so the prompt never touches the AppleScript source.
|
|
73
|
+
file = "osascript";
|
|
74
|
+
args = [
|
|
75
|
+
"-e", "try",
|
|
76
|
+
"-e", 'POSIX path of (choose folder with prompt (system attribute "APX_PICK_PROMPT"))',
|
|
77
|
+
"-e", "on error",
|
|
78
|
+
"-e", 'return ""',
|
|
79
|
+
"-e", "end try",
|
|
80
|
+
];
|
|
67
81
|
} else if (platform === "linux") {
|
|
68
|
-
|
|
82
|
+
// zenity takes the title as a plain argv value — no shell, no escaping.
|
|
83
|
+
file = "zenity";
|
|
84
|
+
args = ["--file-selection", "--directory", `--title=${prompt}`];
|
|
69
85
|
} else if (platform === "win32") {
|
|
70
|
-
|
|
86
|
+
// Reference the prompt via $env: inside PowerShell rather than splicing it
|
|
87
|
+
// into the -Command string.
|
|
88
|
+
file = "powershell";
|
|
89
|
+
args = [
|
|
90
|
+
"-NoProfile",
|
|
91
|
+
"-Command",
|
|
92
|
+
"$f = (New-Object -ComObject Shell.Application).BrowseForFolder(0, $env:APX_PICK_PROMPT, 0, 0); if ($f) { $f.Self.Path }",
|
|
93
|
+
];
|
|
71
94
|
} else {
|
|
72
95
|
return res.status(501).json({ error: "Native folder picker not supported on this platform" });
|
|
73
96
|
}
|
|
74
|
-
|
|
75
|
-
if (err)
|
|
97
|
+
execFile(file, args, { timeout: 5 * 60 * 1000, env }, (err, stdout) => {
|
|
98
|
+
if (err) {
|
|
99
|
+
// Picker binary absent (e.g. no zenity) → let the frontend fall back to
|
|
100
|
+
// the inline directory list instead of surfacing a hard 500.
|
|
101
|
+
if (err.code === "ENOENT") {
|
|
102
|
+
return res.status(501).json({ error: "Native folder picker not available" });
|
|
103
|
+
}
|
|
104
|
+
return res.status(500).json({ error: err.message });
|
|
105
|
+
}
|
|
76
106
|
const picked = (stdout || "").trim().replace(/[\r\n]+$/g, "");
|
|
77
107
|
if (!picked) return res.json({ cancelled: true });
|
|
78
108
|
res.json({ path: picked.replace(/\/+$/, "") });
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// POST /projects/:pid/mcps?scope=shared|runtime|global (default: shared)
|
|
6
6
|
// DELETE /projects/:pid/mcps/:name?scope=… (default: shared)
|
|
7
7
|
// GET /projects/:pid/mcps/check
|
|
8
|
+
// GET /projects/:pid/mcps/:name/tools
|
|
8
9
|
// POST /projects/:pid/mcps/:name/call
|
|
9
10
|
import fs from "node:fs";
|
|
10
11
|
import path from "node:path";
|
|
@@ -190,6 +191,20 @@ export function register(app, { projects, registries, project }) {
|
|
|
190
191
|
});
|
|
191
192
|
});
|
|
192
193
|
|
|
194
|
+
// Full tool catalog — tools/list with input schemas, all pages merged.
|
|
195
|
+
// This is what `apx mcp tools` renders; /test below stays as the
|
|
196
|
+
// lightweight smoke check for the web UI card.
|
|
197
|
+
app.get("/projects/:pid/mcps/:name/tools", async (req, res) => {
|
|
198
|
+
const p = project(req, res);
|
|
199
|
+
if (!p) return;
|
|
200
|
+
try {
|
|
201
|
+
const result = await registries.for(p).listTools(req.params.name);
|
|
202
|
+
res.json({ tools: Array.isArray(result?.tools) ? result.tools : [] });
|
|
203
|
+
} catch (e) {
|
|
204
|
+
res.status(500).json({ error: e.message });
|
|
205
|
+
}
|
|
206
|
+
});
|
|
207
|
+
|
|
193
208
|
app.post("/projects/:pid/mcps/:name/call", async (req, res) => {
|
|
194
209
|
const p = project(req, res);
|
|
195
210
|
if (!p) return;
|
|
@@ -10,6 +10,7 @@ import { readAgents } from "#core/apc/parser.js";
|
|
|
10
10
|
import { agentMemoryPath } from "#core/agent/memory.js";
|
|
11
11
|
import { apcMemoryFile } from "#core/apc/paths.js";
|
|
12
12
|
import { CHANNELS } from "#core/constants/channels.js";
|
|
13
|
+
import { isKnownSpaRoute } from "./web.js";
|
|
13
14
|
|
|
14
15
|
export const nowIso = () =>
|
|
15
16
|
new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
|
|
@@ -51,34 +52,29 @@ export function traceIdMiddleware(req, res, next) {
|
|
|
51
52
|
// /pair/init and /admin/web-token both enforce localhost-only checks of
|
|
52
53
|
// their own — the auth middleware just gets out of their way.
|
|
53
54
|
const UNAUTHENTICATED_PREFIXES = ["/health", "/pair/", "/admin/web-token"];
|
|
54
|
-
|
|
55
|
-
//
|
|
56
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
"/health", "/admin", "/projects", "/telegram", "/engines", "/runtimes",
|
|
62
|
-
"/messages", "/sessions", "/tools", "/mcp", "/voice", "/tts", "/desktop", "/overlay",
|
|
63
|
-
"/transcribe", "/run", "/files", "/memory", "/env", "/pair", "/deck",
|
|
64
|
-
"/super-agent", "/identity", "/agents", "/tasks",
|
|
65
|
-
];
|
|
66
|
-
function isApiPath(p) {
|
|
67
|
-
for (const prefix of API_PREFIXES) {
|
|
68
|
-
if (p === prefix || p.startsWith(prefix + "/")) return true;
|
|
69
|
-
}
|
|
70
|
-
return false;
|
|
55
|
+
|
|
56
|
+
// Does this path look like a static asset (has a file extension)? Vite emits
|
|
57
|
+
// hashed, extension-bearing filenames (index-abc123.js, logo.svg, font.woff2),
|
|
58
|
+
// so an extension is a reliable "this is a bundle asset, not a data route"
|
|
59
|
+
// signal. Data routes (/skills, /projects, /p/0/tasks) have no extension.
|
|
60
|
+
function isStaticAssetPath(p) {
|
|
61
|
+
return path.extname(p) !== "";
|
|
71
62
|
}
|
|
63
|
+
|
|
72
64
|
function isUnauthenticatedPath(p, method = "GET") {
|
|
73
65
|
if (p === "/health") return true;
|
|
74
66
|
if (p === "/admin/web-token") return true;
|
|
75
67
|
for (const prefix of UNAUTHENTICATED_PREFIXES) {
|
|
76
68
|
if (p === prefix.replace(/\/$/, "") || p.startsWith(prefix)) return true;
|
|
77
69
|
}
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
|
|
70
|
+
// SPA bootstrap: the admin bundle loads before it holds a bearer, so a GET
|
|
71
|
+
// for a static asset or a known client-router route is served without auth —
|
|
72
|
+
// the bundle then fetches /admin/web-token. Everything else, including every
|
|
73
|
+
// data GET (/skills, /plugins, /embeddings, …), REQUIRES a token. This is an
|
|
74
|
+
// allowlist by construction: a new data route can never silently become
|
|
75
|
+
// public just because someone forgot to register its prefix (the old
|
|
76
|
+
// denylist failure mode).
|
|
77
|
+
if (method === "GET" && (isStaticAssetPath(p) || isKnownSpaRoute(p))) return true;
|
|
82
78
|
return false;
|
|
83
79
|
}
|
|
84
80
|
|
|
@@ -62,8 +62,9 @@ export function register(app, { projects, registries, resolveTopProject }) {
|
|
|
62
62
|
return res.status(500).json({ error: e.message });
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
|
+
const root = path.resolve(p.path);
|
|
65
66
|
const abs = path.resolve(p.path, rel);
|
|
66
|
-
if (!abs.startsWith(path.
|
|
67
|
+
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
67
68
|
return res.status(403).json({ error: "path escapes project root" });
|
|
68
69
|
if (!fs.existsSync(abs))
|
|
69
70
|
return res.status(404).json({ error: "not found" });
|
|
@@ -96,8 +97,9 @@ export function register(app, { projects, registries, resolveTopProject }) {
|
|
|
96
97
|
if (!rel) return res.status(400).json({ error: "path required" });
|
|
97
98
|
if (typeof content !== "string")
|
|
98
99
|
return res.status(400).json({ error: "content must be string" });
|
|
100
|
+
const root = path.resolve(p.path);
|
|
99
101
|
const abs = path.resolve(p.path, rel);
|
|
100
|
-
if (!abs.startsWith(path.
|
|
102
|
+
if (abs !== root && !abs.startsWith(root + path.sep))
|
|
101
103
|
return res.status(403).json({ error: "path escapes project root" });
|
|
102
104
|
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
103
105
|
fs.writeFileSync(abs, content);
|
|
@@ -16,8 +16,9 @@ export function setDesktopMessageHandler(fn) {
|
|
|
16
16
|
// routes do: a bearer token (master or paired client) carried on the upgrade
|
|
17
17
|
// request. The legitimate desktop window sends `Authorization: Bearer <token>`
|
|
18
18
|
// (src/interfaces/desktop/main.js); browser clients can pass `?token=`. Without
|
|
19
|
-
// this the daemon (
|
|
20
|
-
// the channel and drive the
|
|
19
|
+
// this, any client that can reach the daemon (loopback by default, but the LAN
|
|
20
|
+
// when host is set to 0.0.0.0) could open the channel and drive the
|
|
21
|
+
// super-agent. See QA BUG-WS-AUTH.
|
|
21
22
|
|
|
22
23
|
/** Path-gate: is this upgrade for the desktop (or legacy overlay) WS channel? */
|
|
23
24
|
export function isDesktopUpgradePath(url) {
|
|
@@ -159,13 +159,130 @@ export async function cmdMcpRun(args) {
|
|
|
159
159
|
process.stdout.write(JSON.stringify(result.result, null, 2) + "\n");
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
// Turn a JSON-Schema type into a short placeholder for the run-example JSON.
|
|
163
|
+
function placeholderFor(schema) {
|
|
164
|
+
const t = Array.isArray(schema?.type) ? schema.type[0] : schema?.type;
|
|
165
|
+
if (schema?.enum?.length) return schema.enum[0];
|
|
166
|
+
if (t === "number" || t === "integer") return 0;
|
|
167
|
+
if (t === "boolean") return false;
|
|
168
|
+
if (t === "array") return [];
|
|
169
|
+
if (t === "object") return {};
|
|
170
|
+
return `<${t || "string"}>`;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function schemaTypeLabel(schema) {
|
|
174
|
+
if (schema?.enum?.length) return schema.enum.join("|");
|
|
175
|
+
const t = Array.isArray(schema?.type) ? schema.type.join("|") : schema?.type;
|
|
176
|
+
return t || "any";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function firstLine(s) {
|
|
180
|
+
return String(s || "").split("\n")[0].trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function printToolDetail(mcpName, tool) {
|
|
184
|
+
console.log(`${mcpName} · ${tool.name}`);
|
|
185
|
+
if (tool.description) console.log(` ${tool.description.trim().replace(/\n/g, "\n ")}`);
|
|
186
|
+
|
|
187
|
+
const props = tool.inputSchema?.properties || {};
|
|
188
|
+
const required = new Set(tool.inputSchema?.required || []);
|
|
189
|
+
const keys = Object.keys(props);
|
|
190
|
+
console.log("");
|
|
191
|
+
if (keys.length === 0) {
|
|
192
|
+
console.log(" Params: (none)");
|
|
193
|
+
} else {
|
|
194
|
+
console.log(" Params:");
|
|
195
|
+
const nameW = Math.max(...keys.map((k) => k.length), 4) + 2;
|
|
196
|
+
const typeW = Math.max(...keys.map((k) => schemaTypeLabel(props[k]).length), 4) + 2;
|
|
197
|
+
for (const k of keys) {
|
|
198
|
+
const req = required.has(k) ? "(required)" : "(optional)";
|
|
199
|
+
console.log(
|
|
200
|
+
` ${k.padEnd(nameW)}${schemaTypeLabel(props[k]).padEnd(typeW)}${req} ${firstLine(props[k]?.description)}`
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Example invocation with the required params stubbed in.
|
|
206
|
+
const example = {};
|
|
207
|
+
for (const k of keys) {
|
|
208
|
+
if (required.has(k)) example[k] = placeholderFor(props[k]);
|
|
209
|
+
}
|
|
210
|
+
console.log("");
|
|
211
|
+
console.log(" Run:");
|
|
212
|
+
console.log(` apx mcp run ${mcpName} ${tool.name} '${JSON.stringify(example)}'`);
|
|
213
|
+
}
|
|
214
|
+
|
|
162
215
|
export async function cmdMcpTools(args) {
|
|
163
216
|
const name = args._[0];
|
|
164
|
-
if (!name) throw new Error("apx mcp tools:
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
217
|
+
if (!name) throw new Error("apx mcp tools: usage: apx mcp tools <name> [<tool>] [--json]");
|
|
218
|
+
const toolFilter = args._[1];
|
|
219
|
+
const pid = await resolveProjectId(args?.flags?.project);
|
|
220
|
+
const data = await http.get(`/projects/${pid}/mcps/${name}/tools`);
|
|
221
|
+
const tools = data.tools || [];
|
|
222
|
+
|
|
223
|
+
if (toolFilter) {
|
|
224
|
+
const tool = tools.find((t) => t.name === toolFilter);
|
|
225
|
+
if (!tool) {
|
|
226
|
+
const hint = tools.length
|
|
227
|
+
? `Available: ${tools.map((t) => t.name).join(", ")}`
|
|
228
|
+
: "(server reported no tools)";
|
|
229
|
+
throw new Error(`MCP "${name}" has no tool "${toolFilter}". ${hint}`);
|
|
230
|
+
}
|
|
231
|
+
if (args?.flags?.json) {
|
|
232
|
+
process.stdout.write(JSON.stringify(tool, null, 2) + "\n");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
printToolDetail(name, tool);
|
|
236
|
+
return;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (args?.flags?.json) {
|
|
240
|
+
process.stdout.write(JSON.stringify(tools, null, 2) + "\n");
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (tools.length === 0) {
|
|
244
|
+
console.log(`(MCP "${name}" reported no tools)`);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
const nameW = Math.max(...tools.map((t) => t.name.length), 4) + 2;
|
|
248
|
+
console.log(`${tools.length} tool${tools.length === 1 ? "" : "s"} — apx mcp tools ${name} <tool> for schema\n`);
|
|
249
|
+
console.log("TOOL".padEnd(nameW) + " DESCRIPTION");
|
|
250
|
+
for (const t of tools) {
|
|
251
|
+
console.log(t.name.padEnd(nameW) + " " + firstLine(t.description).slice(0, 100));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export async function cmdMcpLogs(args) {
|
|
256
|
+
const name = args._[0];
|
|
257
|
+
if (!name) throw new Error("apx mcp logs: missing <name>");
|
|
258
|
+
const pid = await resolveProjectId(args?.flags?.project);
|
|
259
|
+
const logs = await http.get(`/projects/${pid}/mcps/${name}/logs`);
|
|
260
|
+
if (args?.flags?.json) {
|
|
261
|
+
process.stdout.write(JSON.stringify(logs, null, 2) + "\n");
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
const target = logs.transport === "http"
|
|
265
|
+
? logs.url
|
|
266
|
+
: [logs.command, ...(logs.args || [])].filter(Boolean).join(" ");
|
|
267
|
+
console.log(`${name} (${logs.transport})${target ? " — " + target : ""}`);
|
|
268
|
+
if (logs.transport === "stdio") {
|
|
269
|
+
console.log(` running: ${logs.running ? "yes" : "no"} started: ${logs.started_at || "-"} last exit: ${logs.last_exit_code ?? "-"}`);
|
|
270
|
+
} else {
|
|
271
|
+
console.log(` started: ${logs.started_at || "-"} last error: ${logs.last_error || "-"}`);
|
|
272
|
+
}
|
|
273
|
+
if (logs.note) console.log(` ${logs.note}`);
|
|
274
|
+
if (logs.events?.length) {
|
|
275
|
+
console.log("\nEvents:");
|
|
276
|
+
for (const e of logs.events) {
|
|
277
|
+
console.log(` ${e.ts} [${e.level}] ${e.msg}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
if (logs.stderr_tail?.trim()) {
|
|
281
|
+
console.log("\nstderr tail:");
|
|
282
|
+
for (const line of logs.stderr_tail.trim().split("\n")) {
|
|
283
|
+
console.log(` ${line}`);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
169
286
|
}
|
|
170
287
|
|
|
171
288
|
export async function cmdMcpCheck(args = {}) {
|
|
@@ -46,6 +46,7 @@ import {
|
|
|
46
46
|
cmdMcpDisable,
|
|
47
47
|
cmdMcpRun,
|
|
48
48
|
cmdMcpTools,
|
|
49
|
+
cmdMcpLogs,
|
|
49
50
|
cmdMcpCheck,
|
|
50
51
|
} from "./commands/mcp.js";
|
|
51
52
|
import {
|
|
@@ -713,7 +714,8 @@ const HELP_TOPICS = new Map(Object.entries({
|
|
|
713
714
|
["enable <name>", "Enable a project-owned MCP server."],
|
|
714
715
|
["disable <name>", "Disable a project-owned MCP server."],
|
|
715
716
|
["run <name> <tool>", "Call one MCP tool."],
|
|
716
|
-
["tools <name>", "
|
|
717
|
+
["tools <name> [<tool>]", "List a server's tools, or show one tool's schema."],
|
|
718
|
+
["logs <name>", "Show spawn/init logs and stderr tail for a server."],
|
|
717
719
|
["check", "Audit source files, merge order, and conflicts."],
|
|
718
720
|
],
|
|
719
721
|
options: [["--project <name|id|path>", "Pin command to a specific project."]],
|
|
@@ -785,10 +787,27 @@ const HELP_TOPICS = new Map(Object.entries({
|
|
|
785
787
|
}),
|
|
786
788
|
"mcp tools": topic({
|
|
787
789
|
title: "apx mcp tools",
|
|
788
|
-
summary: "
|
|
789
|
-
usage: ["apx mcp tools <name> [--project <name|id|path>]"],
|
|
790
|
-
options: [
|
|
791
|
-
|
|
790
|
+
summary: "List an MCP server's tools, or show one tool's input schema with a ready-to-run example.",
|
|
791
|
+
usage: ["apx mcp tools <name> [<tool>] [--json] [--project <name|id|path>]"],
|
|
792
|
+
options: [
|
|
793
|
+
["--json", "Raw JSON output (full tool objects with inputSchema)."],
|
|
794
|
+
["--project <name|id|path>", "Pin command to a specific project."],
|
|
795
|
+
],
|
|
796
|
+
examples: [
|
|
797
|
+
"apx mcp tools filesystem",
|
|
798
|
+
"apx mcp tools filesystem read_file",
|
|
799
|
+
"apx mcp tools dokploy-mcp --json",
|
|
800
|
+
],
|
|
801
|
+
}),
|
|
802
|
+
"mcp logs": topic({
|
|
803
|
+
title: "apx mcp logs",
|
|
804
|
+
summary: "Show an MCP server's spawn/init event log and stderr tail — first stop when a server doesn't list tools.",
|
|
805
|
+
usage: ["apx mcp logs <name> [--json] [--project <name|id|path>]"],
|
|
806
|
+
options: [
|
|
807
|
+
["--json", "Raw JSON output."],
|
|
808
|
+
["--project <name|id|path>", "Pin command to a specific project."],
|
|
809
|
+
],
|
|
810
|
+
examples: ["apx mcp logs dokploy-mcp"],
|
|
792
811
|
}),
|
|
793
812
|
"mcp check": topic({
|
|
794
813
|
title: "apx mcp check",
|
|
@@ -2022,7 +2041,8 @@ function buildHelp(version) {
|
|
|
2022
2041
|
hCmd("apx mcp remove <name>", 36, ""),
|
|
2023
2042
|
hCmd("apx mcp enable/disable", 36, "<name>"),
|
|
2024
2043
|
hCmd("apx mcp run <name> <tool>", 36, "[<json-args>] call a tool through the daemon"),
|
|
2025
|
-
hCmd("apx mcp tools <name>", 36, "list
|
|
2044
|
+
hCmd("apx mcp tools <name>", 36, "[<tool>] list tools, or one tool's schema + run example"),
|
|
2045
|
+
hCmd("apx mcp logs <name>", 36, "spawn/init log + stderr tail"),
|
|
2026
2046
|
hCmd("apx mcp check", 36, "audit multi-source merge"),
|
|
2027
2047
|
|
|
2028
2048
|
hSec("Daemon Service"),
|
|
@@ -2372,6 +2392,7 @@ async function dispatch(cmd, rest) {
|
|
|
2372
2392
|
else if (sub === "disable") await cmdMcpDisable(a);
|
|
2373
2393
|
else if (sub === "run") await cmdMcpRun(a);
|
|
2374
2394
|
else if (sub === "tools") await cmdMcpTools(a);
|
|
2395
|
+
else if (sub === "logs") await cmdMcpLogs(a);
|
|
2375
2396
|
else if (sub === "check") await cmdMcpCheck(a);
|
|
2376
2397
|
else die(`unknown mcp subcommand: ${sub || "(none)"}`);
|
|
2377
2398
|
break;
|
|
@@ -1259,9 +1259,9 @@
|
|
|
1259
1259
|
}
|
|
1260
1260
|
},
|
|
1261
1261
|
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
|
1262
|
-
"version": "11.1.
|
|
1263
|
-
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.
|
|
1264
|
-
"integrity": "sha512
|
|
1262
|
+
"version": "11.1.9",
|
|
1263
|
+
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.9.tgz",
|
|
1264
|
+
"integrity": "sha512-sc/z0Cyti70bZa0ZU4sWfAElfovFb9Ni8tArJZLuklYWxegPiK3pDOql1Rq5H0FIRAW9LSQRG6OX4KqBldbhBA==",
|
|
1265
1265
|
"license": "MIT",
|
|
1266
1266
|
"funding": {
|
|
1267
1267
|
"type": "opencollective",
|
|
@@ -2059,9 +2059,9 @@
|
|
|
2059
2059
|
"license": "MIT"
|
|
2060
2060
|
},
|
|
2061
2061
|
"node_modules/@types/node": {
|
|
2062
|
-
"version": "26.0
|
|
2063
|
-
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.0.
|
|
2064
|
-
"integrity": "sha512-
|
|
2062
|
+
"version": "26.1.0",
|
|
2063
|
+
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz",
|
|
2064
|
+
"integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==",
|
|
2065
2065
|
"devOptional": true,
|
|
2066
2066
|
"license": "MIT",
|
|
2067
2067
|
"dependencies": {
|
|
@@ -2162,9 +2162,9 @@
|
|
|
2162
2162
|
}
|
|
2163
2163
|
},
|
|
2164
2164
|
"node_modules/baseline-browser-mapping": {
|
|
2165
|
-
"version": "2.10.
|
|
2166
|
-
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.
|
|
2167
|
-
"integrity": "sha512-
|
|
2165
|
+
"version": "2.10.41",
|
|
2166
|
+
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.41.tgz",
|
|
2167
|
+
"integrity": "sha512-WwS7MHhqGHHlaVsqRZnhvCEMS0owDX+SxRlve7JkuH7My1Ara3ZriTmCQupPfYjxMZ8I/tgxtJYr2t7taHaH4A==",
|
|
2168
2168
|
"dev": true,
|
|
2169
2169
|
"license": "Apache-2.0",
|
|
2170
2170
|
"bin": {
|
|
@@ -2580,9 +2580,9 @@
|
|
|
2580
2580
|
"license": "MIT"
|
|
2581
2581
|
},
|
|
2582
2582
|
"node_modules/electron-to-chromium": {
|
|
2583
|
-
"version": "1.5.
|
|
2584
|
-
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.
|
|
2585
|
-
"integrity": "sha512-
|
|
2583
|
+
"version": "1.5.385",
|
|
2584
|
+
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.385.tgz",
|
|
2585
|
+
"integrity": "sha512-78sa/M08MNAYHQfjoWMvOlKQqZ0ElhSm/L5HNUc96VZ3b+KvDVnngFm8sYQy0XrhTRgAhggHr5abA7yTvRdo4Q==",
|
|
2586
2586
|
"dev": true,
|
|
2587
2587
|
"license": "ISC"
|
|
2588
2588
|
},
|
|
@@ -3256,9 +3256,9 @@
|
|
|
3256
3256
|
"license": "ISC"
|
|
3257
3257
|
},
|
|
3258
3258
|
"node_modules/picomatch": {
|
|
3259
|
-
"version": "4.0.
|
|
3260
|
-
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.
|
|
3261
|
-
"integrity": "sha512-
|
|
3259
|
+
"version": "4.0.5",
|
|
3260
|
+
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
|
|
3261
|
+
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
|
|
3262
3262
|
"license": "MIT",
|
|
3263
3263
|
"engines": {
|
|
3264
3264
|
"node": ">=12"
|