@sema-agent/core 5.52.0 → 5.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/CHANGELOG.md +99 -0
- package/dist/agents/subagent.d.ts +4 -0
- package/dist/agents/subagent.js +1 -1
- package/dist/brain/anthropic.js +17 -4
- package/dist/core/a2a.js +12 -1
- package/dist/core/cache-break-detector.js +9 -3
- package/dist/core/mcp.d.ts +168 -5
- package/dist/core/mcp.js +200 -21
- package/dist/core/permission-rule-model.d.ts +140 -21
- package/dist/core/permission-rule-model.js +76 -17
- package/dist/core/permission-rule-org.d.ts +4 -3
- package/dist/core/permission-rule-org.js +12 -3
- package/dist/core/protocol-naming.d.ts +25 -2
- package/dist/core/protocol-naming.js +11 -0
- package/dist/core/runner/prepare-safety-scan.js +7 -0
- package/dist/core/runner/prepare-task.js +24 -3
- package/dist/core/runner/runtask.js +4 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/orchestration/workflow-types.d.ts +16 -6
- package/dist/orchestration/workflow-types.js +10 -4
- package/dist/orchestration/workflow.js +32 -6
- package/dist/stores/file/background-agent-store.js +1 -0
- package/dist/stores/file/checkpoint-store.d.ts +6 -2
- package/dist/stores/file/checkpoint-store.js +1 -0
- package/dist/stores/file/fs-atomic.d.ts +151 -10
- package/dist/stores/file/fs-atomic.js +208 -32
- package/dist/stores/file/index.d.ts +26 -3
- package/dist/stores/file/index.js +25 -2
- package/dist/stores/file/shared-ledger.d.ts +40 -5
- package/dist/stores/file/shared-ledger.js +24 -8
- package/dist/stores/file/workflow-run-store.d.ts +8 -1
- package/dist/stores/file/workflow-run-store.js +1 -0
- package/dist/tools/fs/bash-readonly-classifier.d.ts +71 -0
- package/dist/tools/fs/bash-readonly-classifier.js +58 -47
- package/package.json +1 -1
- package/test/export-surface.snapshot.json +3 -1
package/dist/core/mcp.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { MCP_NAMESPACE } from "./protocol-table.js";
|
|
2
|
-
import { mintNamespacePrefix, mintNamespacedToolName, normalizeNameSegment } from "./protocol-naming.js";
|
|
2
|
+
import { findNamespacePrefixCollision, mintNamespacePrefix, mintNamespacedToolName, normalizeNameSegment } from "./protocol-naming.js";
|
|
3
3
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
4
4
|
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
|
|
5
5
|
import { StreamableHTTPClientTransport, StreamableHTTPError } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
@@ -7,6 +7,7 @@ import { lstat, mkdir, writeFile } from "node:fs/promises";
|
|
|
7
7
|
import { tmpdir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
9
|
import { CallToolResultSchema, ElicitRequestSchema, ErrorCode, ListResourcesResultSchema, McpError } from "@modelcontextprotocol/sdk/types.js";
|
|
10
|
+
import { DEFAULT_REQUEST_TIMEOUT_MSEC } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
10
11
|
import { MCP_IMAGE_MAX_BASE64, sharpImageResizer } from "./image-downsample.js";
|
|
11
12
|
import { sliceHeadSafe, sliceTailSafe } from "./surrogate-safe-slice.js";
|
|
12
13
|
import { truncateError } from "./tool-errors.js";
|
|
@@ -663,6 +664,17 @@ export function mcpToolSchemaProblem(schema) {
|
|
|
663
664
|
return undefined;
|
|
664
665
|
}
|
|
665
666
|
export async function materializeMcpTools(specs, principal, onElicit, imageResizer, reminderDisclosure, mcpRevocations) {
|
|
667
|
+
{
|
|
668
|
+
const collision = findNamespacePrefixCollision(MCP_NAMESPACE, specs.map((s) => s.name));
|
|
669
|
+
if (collision) {
|
|
670
|
+
const [a, b] = collision.peers;
|
|
671
|
+
const e = new Error(a === b
|
|
672
|
+
? `MCP server "${inlineUntrusted(a, 120)}" is declared twice — each server needs its own name (both mount under "${collision.prefix}", so their tools would shadow each other and a refresh of one would unmount the other's).`
|
|
673
|
+
: `MCP server names "${inlineUntrusted(a, 120)}" and "${inlineUntrusted(b, 120)}" both mount under "${collision.prefix}" — the namespaced tool name keeps only [a-zA-Z0-9_-], so they are the same domain to this engine (their tools would shadow each other and a refresh of one would unmount the other's). Rename one.`);
|
|
674
|
+
e.code = "config.mcp_server_name_collision";
|
|
675
|
+
throw e;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
666
678
|
let revocationProbeFailed = false;
|
|
667
679
|
const isServerRevoked = (serverName) => {
|
|
668
680
|
if (mcpRevocations === undefined)
|
|
@@ -708,7 +720,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
|
|
|
708
720
|
if (r.status === "fulfilled") {
|
|
709
721
|
const s = r.value;
|
|
710
722
|
clients.push(s.client);
|
|
711
|
-
serverHandles.push({ name: spec.name, spec, client: s.client, health: s.health,
|
|
723
|
+
serverHandles.push({ name: spec.name, spec, client: s.client, health: s.health, tools: s.tools, listedRaw: s.listedTools });
|
|
712
724
|
tools.push(...s.tools);
|
|
713
725
|
toolAxes.push(...s.axes);
|
|
714
726
|
if (s.instructions) {
|
|
@@ -729,6 +741,8 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
|
|
|
729
741
|
resourceServers.push(s.resourceServer);
|
|
730
742
|
for (const d of s.dropped)
|
|
731
743
|
droppedTools.push({ server: inlineUntrusted(spec.name, 160), ...d });
|
|
744
|
+
if (s.listingIncomplete)
|
|
745
|
+
warnings.push(listingIncompleteWarning(spec.name, s.listingIncomplete));
|
|
732
746
|
statuses.push(s.status);
|
|
733
747
|
}
|
|
734
748
|
else {
|
|
@@ -770,12 +784,17 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
|
|
|
770
784
|
}
|
|
771
785
|
try {
|
|
772
786
|
const listed = await listToolsLenient(h.client);
|
|
773
|
-
|
|
774
|
-
const
|
|
787
|
+
const advertised = new Set(listed.tools.map((t) => t.name));
|
|
788
|
+
const retainedRaw = listed.incomplete !== undefined ? h.listedRaw.filter((t) => !advertised.has(t.name)) : [];
|
|
789
|
+
const mergedRaw = retainedRaw.length > 0 ? [...listed.tools, ...retainedRaw] : listed.tools;
|
|
790
|
+
cacheMcpToolMetadata(h.client, retainedRaw.length > 0 ? [...retainedRaw, ...listed.tools] : listed.tools);
|
|
791
|
+
const { serverTools, serverAxes, dropped } = intakeListedTools({ tools: mergedRaw }, h.spec, h.client, h.health, imageResizer, mcpDisclosure, isServerRevoked);
|
|
792
|
+
const priorNames = h.tools.map((t) => t.name);
|
|
775
793
|
const newNames = serverTools.map((t) => t.name);
|
|
776
|
-
const added = newNames.filter((n) => !
|
|
777
|
-
const removed =
|
|
778
|
-
h.
|
|
794
|
+
const added = newNames.filter((n) => !priorNames.includes(n));
|
|
795
|
+
const removed = priorNames.filter((n) => !newNames.includes(n));
|
|
796
|
+
h.tools = serverTools;
|
|
797
|
+
h.listedRaw = mergedRaw;
|
|
779
798
|
results.push({
|
|
780
799
|
server: h.name,
|
|
781
800
|
prefix: prefixOf(h.name),
|
|
@@ -786,6 +805,9 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
|
|
|
786
805
|
tools: serverTools,
|
|
787
806
|
axes: serverAxes,
|
|
788
807
|
dropped: dropped.map((d) => ({ server: inlineUntrusted(h.name, 160), ...d })),
|
|
808
|
+
...(listed.incomplete !== undefined
|
|
809
|
+
? { listingIncomplete: listed.incomplete, error: `tool listing incomplete — ${listingIncompleteNote(listed.incomplete)}` }
|
|
810
|
+
: {}),
|
|
789
811
|
});
|
|
790
812
|
}
|
|
791
813
|
catch (err) {
|
|
@@ -793,7 +815,7 @@ export async function materializeMcpTools(specs, principal, onElicit, imageResiz
|
|
|
793
815
|
server: h.name,
|
|
794
816
|
prefix: prefixOf(h.name),
|
|
795
817
|
status: "failed",
|
|
796
|
-
toolCount: h.
|
|
818
|
+
toolCount: h.tools.length,
|
|
797
819
|
added: [],
|
|
798
820
|
removed: [],
|
|
799
821
|
error: inlineUntrusted(namedMcpFailureText(err), 240),
|
|
@@ -864,6 +886,110 @@ function resourceLine(r) {
|
|
|
864
886
|
function serverNames(servers) {
|
|
865
887
|
return servers.map((r) => r.server).join(", ") || "(none)";
|
|
866
888
|
}
|
|
889
|
+
const MAX_MCP_LIST_PAGES = 20;
|
|
890
|
+
const MIN_MCP_PAGE_BUDGET_MS = 250;
|
|
891
|
+
function continuationPageFloorMs(budgetMs) {
|
|
892
|
+
return Math.max(1, Math.min(MIN_MCP_PAGE_BUDGET_MS, Math.floor(budgetMs / 10)));
|
|
893
|
+
}
|
|
894
|
+
export function listEntryFingerprint(entry) {
|
|
895
|
+
try {
|
|
896
|
+
const json = JSON.stringify(entry);
|
|
897
|
+
return typeof json === "string" ? json : undefined;
|
|
898
|
+
}
|
|
899
|
+
catch {
|
|
900
|
+
return undefined;
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const LIST_ENTRY_FINGERPRINT_MAX_CHARS = 8 * 1024;
|
|
904
|
+
const LOOP_REWIND_FINGERPRINT_MAX_CHARS = 1024 * 1024;
|
|
905
|
+
export async function walkMcpListPages(fetchPage, opts) {
|
|
906
|
+
const items = [];
|
|
907
|
+
const used = new Set();
|
|
908
|
+
const deadline = Date.now() + opts.budgetMs;
|
|
909
|
+
let cursor;
|
|
910
|
+
let pages = 0;
|
|
911
|
+
for (;;) {
|
|
912
|
+
const remainingMs = pages === 0 ? opts.budgetMs : deadline - Date.now();
|
|
913
|
+
if (pages > 0 && remainingMs < continuationPageFloorMs(opts.budgetMs)) {
|
|
914
|
+
return { items, incomplete: { reason: "budget_exhausted", pages, budgetMs: opts.budgetMs } };
|
|
915
|
+
}
|
|
916
|
+
let page;
|
|
917
|
+
try {
|
|
918
|
+
page = await fetchPage(cursor, remainingMs);
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
if (pages === 0 || opts.signal?.aborted)
|
|
922
|
+
throw err;
|
|
923
|
+
return { items, incomplete: { reason: "page_error", pages, error: err instanceof Error ? err.message : String(err) } };
|
|
924
|
+
}
|
|
925
|
+
opts.onPage?.();
|
|
926
|
+
const pageStart = items.length;
|
|
927
|
+
items.push(...page.items);
|
|
928
|
+
pages++;
|
|
929
|
+
if (page.cursorInvalid === true)
|
|
930
|
+
return { items, incomplete: { reason: "cursor_invalid", pages } };
|
|
931
|
+
const next = page.nextCursor;
|
|
932
|
+
if (next === undefined)
|
|
933
|
+
return { items };
|
|
934
|
+
if (used.has(next)) {
|
|
935
|
+
const fresh = [];
|
|
936
|
+
const fingerprintOf = opts.fingerprintOf;
|
|
937
|
+
if (fingerprintOf !== undefined) {
|
|
938
|
+
const before = new Set();
|
|
939
|
+
let keying = LOOP_REWIND_FINGERPRINT_MAX_CHARS;
|
|
940
|
+
const keyOf = (item) => {
|
|
941
|
+
if (keying <= 0)
|
|
942
|
+
return undefined;
|
|
943
|
+
const fp = fingerprintOf(item);
|
|
944
|
+
if (fp === undefined) {
|
|
945
|
+
keying -= LIST_ENTRY_FINGERPRINT_MAX_CHARS;
|
|
946
|
+
return undefined;
|
|
947
|
+
}
|
|
948
|
+
keying -= fp.length;
|
|
949
|
+
return fp.length <= LIST_ENTRY_FINGERPRINT_MAX_CHARS ? fp : undefined;
|
|
950
|
+
};
|
|
951
|
+
for (let i = 0; i < pageStart; i++) {
|
|
952
|
+
const fp = keyOf(items[i]);
|
|
953
|
+
if (fp !== undefined)
|
|
954
|
+
before.add(fp);
|
|
955
|
+
}
|
|
956
|
+
for (const item of page.items) {
|
|
957
|
+
const fp = keyOf(item);
|
|
958
|
+
if (fp !== undefined && before.has(fp))
|
|
959
|
+
continue;
|
|
960
|
+
fresh.push(item);
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
items.length = pageStart;
|
|
964
|
+
items.push(...fresh);
|
|
965
|
+
return { items, incomplete: { reason: "cursor_loop", pages } };
|
|
966
|
+
}
|
|
967
|
+
if (pages >= MAX_MCP_LIST_PAGES)
|
|
968
|
+
return { items, incomplete: { reason: "page_cap", pages } };
|
|
969
|
+
used.add(next);
|
|
970
|
+
cursor = next;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
function idleWatchdogTripText(idleMs) {
|
|
974
|
+
return `received no response for ${idleMs}ms (idle watchdog; set MCP_IDLE_TIMEOUT_STDIO / MCP_IDLE_TIMEOUT_HTTP to change this bound)`;
|
|
975
|
+
}
|
|
976
|
+
function listingIncompleteNote(flag) {
|
|
977
|
+
const pages = `${flag.pages} page${flag.pages === 1 ? "" : "s"}`;
|
|
978
|
+
if (flag.reason === "page_cap") {
|
|
979
|
+
return `[listing truncated: the ${MAX_MCP_LIST_PAGES}-page pagination limit was reached — more entries may exist beyond this listing]`;
|
|
980
|
+
}
|
|
981
|
+
if (flag.reason === "cursor_loop") {
|
|
982
|
+
return `[listing truncated: after ${pages} the server handed back a pagination cursor it had already used, so the walk stopped — more entries may exist beyond this listing]`;
|
|
983
|
+
}
|
|
984
|
+
if (flag.reason === "cursor_invalid") {
|
|
985
|
+
return `[listing truncated: after ${pages} the server sent a pagination cursor that is not a string, so the walk could not continue — more entries may exist beyond this listing]`;
|
|
986
|
+
}
|
|
987
|
+
if (flag.reason === "budget_exhausted") {
|
|
988
|
+
const budget = flag.budgetMs !== undefined ? `${flag.budgetMs}ms ` : "";
|
|
989
|
+
return `[listing truncated: after ${pages} the ${budget}time budget for this listing was too low to give the next page a usable request, so the client stopped without sending one — this is a client-side bound, not a server failure; more entries may exist beyond this listing]`;
|
|
990
|
+
}
|
|
991
|
+
return `[listing incomplete: the server failed mid-pagination after ${pages} (${inlineUntrusted(flag.error ?? "unknown error", 240)}) — only the pages retrieved before the failure are shown]`;
|
|
992
|
+
}
|
|
867
993
|
async function readDirViaExtension(rs, uri, signal, timeoutMs, watchdog) {
|
|
868
994
|
const resources = [];
|
|
869
995
|
let cursor;
|
|
@@ -907,10 +1033,13 @@ function renderDirChildren(server, uri, children, flags) {
|
|
|
907
1033
|
if (flags?.cursorInvalid) {
|
|
908
1034
|
notes.push(`[listing incomplete: the server rejected the pagination cursor (invalid or expired) — only the pages retrieved before the rejection are shown; more entries may exist beyond this listing]`);
|
|
909
1035
|
}
|
|
1036
|
+
if (flags?.listing)
|
|
1037
|
+
notes.push(listingIncompleteNote(flags.listing));
|
|
910
1038
|
const detailFlags = {
|
|
911
1039
|
...(flags?.truncated ? { truncated: true } : {}),
|
|
912
1040
|
...(flags?.incomplete !== undefined ? { incomplete: true, error: flags.incomplete } : {}),
|
|
913
1041
|
...(flags?.cursorInvalid ? { incomplete: true, cursorInvalid: true } : {}),
|
|
1042
|
+
...(flags?.listing ? { incomplete: true, listing: flags.listing } : {}),
|
|
914
1043
|
};
|
|
915
1044
|
const listing = children.length === 0
|
|
916
1045
|
? `(Empty directory: ${inlineUntrusted(uri)})`
|
|
@@ -948,6 +1077,7 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
|
|
|
948
1077
|
const sections = [];
|
|
949
1078
|
const all = [];
|
|
950
1079
|
const errors = [];
|
|
1080
|
+
const incomplete = [];
|
|
951
1081
|
for (const rs of targets) {
|
|
952
1082
|
if (isServerRevoked(rs.server)) {
|
|
953
1083
|
errors.push({ server: rs.server, error: "server revoked by the operator mid-session (request not sent)" });
|
|
@@ -962,16 +1092,30 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
|
|
|
962
1092
|
const idleMs = mcpIdleTimeoutMs(rs.transportKind);
|
|
963
1093
|
const watchdog = armMcpIdleWatchdog(rs.health, idleMs, signal);
|
|
964
1094
|
try {
|
|
965
|
-
const
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
1095
|
+
const walk = await walkMcpListPages(async (cursor, remainingMs) => {
|
|
1096
|
+
const page = await rs.client
|
|
1097
|
+
.listResources(cursor === undefined ? undefined : { cursor }, { signal: watchdog.combinedSignal, timeout: remainingMs })
|
|
1098
|
+
.catch((err) => {
|
|
1099
|
+
throw watchdog.idleSignal.reason === IDLE_WATCHDOG_ABORT_REASON ? new Error(idleWatchdogTripText(idleMs)) : err;
|
|
1100
|
+
});
|
|
1101
|
+
return { items: page.resources, ...(page.nextCursor !== undefined ? { nextCursor: page.nextCursor } : {}) };
|
|
1102
|
+
}, { budgetMs: mcpToolTimeoutMs(), onPage: () => watchdog.rearm(), fingerprintOf: listEntryFingerprint, ...(signal !== undefined ? { signal } : {}) });
|
|
1103
|
+
all.push(...walk.items.map((r) => ({ server: rs.server, ...r })));
|
|
1104
|
+
const lines = walk.items.map((r) => resourceLine(r));
|
|
1105
|
+
const body = lines.length ? lines.join("\n") : "(no resources)";
|
|
1106
|
+
if (walk.incomplete) {
|
|
1107
|
+
incomplete.push({ server: rs.server, ...walk.incomplete });
|
|
1108
|
+
sections.push(`[${rs.server}]\n${body}\n${listingIncompleteNote(walk.incomplete)}`);
|
|
1109
|
+
}
|
|
1110
|
+
else {
|
|
1111
|
+
sections.push(`[${rs.server}]\n${body}`);
|
|
1112
|
+
}
|
|
969
1113
|
}
|
|
970
1114
|
catch (err) {
|
|
971
1115
|
if (signal?.aborted)
|
|
972
1116
|
throw err;
|
|
973
1117
|
const msg = watchdog.idleSignal.reason === IDLE_WATCHDOG_ABORT_REASON
|
|
974
|
-
?
|
|
1118
|
+
? idleWatchdogTripText(idleMs)
|
|
975
1119
|
: err instanceof Error
|
|
976
1120
|
? err.message
|
|
977
1121
|
: String(err);
|
|
@@ -985,7 +1129,7 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
|
|
|
985
1129
|
const body = sections.join("\n\n");
|
|
986
1130
|
return {
|
|
987
1131
|
content: [{ type: "text", text: sections.length ? delimitUntrusted("mcp resources", body) : "(no MCP resources)" }],
|
|
988
|
-
details: { resources: all, ...(errors.length ? { errors } : {}) },
|
|
1132
|
+
details: { resources: all, ...(errors.length ? { errors } : {}), ...(incomplete.length ? { incomplete } : {}) },
|
|
989
1133
|
terminate: false,
|
|
990
1134
|
};
|
|
991
1135
|
},
|
|
@@ -1114,12 +1258,15 @@ function buildResourceTools(resourceServers, isServerRevoked = () => false) {
|
|
|
1114
1258
|
return renderDirChildren(server, uri, r.resources, { truncated: r.truncated, incomplete: r.incomplete, cursorInvalid: r.cursorInvalid });
|
|
1115
1259
|
watchdog.rearm();
|
|
1116
1260
|
}
|
|
1117
|
-
const
|
|
1118
|
-
|
|
1119
|
-
|
|
1261
|
+
const walk = await walkMcpListPages(async (cursor, remainingMs) => {
|
|
1262
|
+
const page = await rs.client
|
|
1263
|
+
.listResources(cursor === undefined ? undefined : { cursor }, { signal: watchdog.combinedSignal, timeout: remainingMs })
|
|
1264
|
+
.catch((err) => rethrowHonestMcpError(err, { server, what, timeoutMs: remainingMs, writeEffect: false, signal, attributeServer: true, idle: { signal: watchdog.idleSignal, idleMs } }));
|
|
1265
|
+
return { items: page.resources, ...(page.nextCursor !== undefined ? { nextCursor: page.nextCursor } : {}) };
|
|
1266
|
+
}, { budgetMs: timeoutMs, onPage: () => watchdog.rearm(), fingerprintOf: listEntryFingerprint, ...(signal !== undefined ? { signal } : {}) });
|
|
1120
1267
|
const dirPrefix = uri.endsWith("/") ? uri : `${uri}/`;
|
|
1121
|
-
const children =
|
|
1122
|
-
return renderDirChildren(server, uri, children);
|
|
1268
|
+
const children = walk.items.filter((r) => typeof r.uri === "string" && r.uri !== uri && r.uri.startsWith(dirPrefix));
|
|
1269
|
+
return renderDirChildren(server, uri, children, walk.incomplete ? { listing: walk.incomplete } : undefined);
|
|
1123
1270
|
}
|
|
1124
1271
|
finally {
|
|
1125
1272
|
watchdog.dispose();
|
|
@@ -1166,7 +1313,8 @@ const LenientListToolsResultSchema = {
|
|
|
1166
1313
|
});
|
|
1167
1314
|
}
|
|
1168
1315
|
const nextCursor = data.nextCursor;
|
|
1169
|
-
|
|
1316
|
+
const cursorInvalid = nextCursor !== undefined && nextCursor !== null && typeof nextCursor !== "string";
|
|
1317
|
+
return { success: true, data: { tools, ...(typeof nextCursor === "string" ? { nextCursor } : {}), ...(cursorInvalid ? { cursorInvalid: true } : {}) } };
|
|
1170
1318
|
},
|
|
1171
1319
|
};
|
|
1172
1320
|
export function parseCallToolResultLenient(data) {
|
|
@@ -1181,7 +1329,19 @@ export function parseCallToolResultLenient(data) {
|
|
|
1181
1329
|
}
|
|
1182
1330
|
const LENIENT_CALL_TOOL_RESULT_SCHEMA = { safeParse: parseCallToolResultLenient };
|
|
1183
1331
|
async function listToolsLenient(client, options) {
|
|
1184
|
-
|
|
1332
|
+
const walk = await walkMcpListPages(async (cursor, remainingMs) => {
|
|
1333
|
+
const page = await client.request({ method: "tools/list", params: cursor === undefined ? {} : { cursor } }, LenientListToolsResultSchema, { ...options, timeout: remainingMs });
|
|
1334
|
+
return {
|
|
1335
|
+
items: page.tools,
|
|
1336
|
+
...(page.nextCursor !== undefined ? { nextCursor: page.nextCursor } : {}),
|
|
1337
|
+
...(page.cursorInvalid === true ? { cursorInvalid: true } : {}),
|
|
1338
|
+
};
|
|
1339
|
+
}, {
|
|
1340
|
+
budgetMs: options?.timeout ?? DEFAULT_REQUEST_TIMEOUT_MSEC,
|
|
1341
|
+
fingerprintOf: listEntryFingerprint,
|
|
1342
|
+
...(options?.signal !== undefined ? { signal: options.signal } : {}),
|
|
1343
|
+
});
|
|
1344
|
+
return { tools: walk.items, ...(walk.incomplete !== undefined ? { incomplete: walk.incomplete } : {}) };
|
|
1185
1345
|
}
|
|
1186
1346
|
async function connectServer(spec, principal, onElicit, imageResizer, reminderDisclosure, isServerRevoked) {
|
|
1187
1347
|
const elicitOn = spec.elicitation === true && onElicit !== undefined;
|
|
@@ -1243,7 +1403,9 @@ async function connectServer(spec, principal, onElicit, imageResizer, reminderDi
|
|
|
1243
1403
|
tools: serverTools,
|
|
1244
1404
|
axes: serverAxes,
|
|
1245
1405
|
dropped,
|
|
1406
|
+
listedTools: listed.tools,
|
|
1246
1407
|
...(instructions ? { instructions } : {}),
|
|
1408
|
+
...(listed.incomplete !== undefined ? { listingIncomplete: listed.incomplete } : {}),
|
|
1247
1409
|
...(resourceInfo && (resourceInfo.listAllowed || resourceInfo.readAllowed) ? { resourceServer: { server: spec.name, client, health, transportKind: idleKindOf(spec), ...resourceInfo } } : {}),
|
|
1248
1410
|
};
|
|
1249
1411
|
}
|
|
@@ -1256,6 +1418,7 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
|
|
|
1256
1418
|
const serverTools = [];
|
|
1257
1419
|
const serverAxes = [];
|
|
1258
1420
|
const dropped = [];
|
|
1421
|
+
const mintedNames = new Map();
|
|
1259
1422
|
for (const t of listed.tools) {
|
|
1260
1423
|
if (spec.allowTools && !spec.allowTools.includes(t.name)) {
|
|
1261
1424
|
continue;
|
|
@@ -1278,6 +1441,17 @@ function intakeListedTools(listed, spec, client, health, imageResizer, reminderD
|
|
|
1278
1441
|
}
|
|
1279
1442
|
const remoteName = t.name;
|
|
1280
1443
|
const namespacedName = mintNamespacedToolName(MCP_NAMESPACE, spec.name, remoteName);
|
|
1444
|
+
const mintedBy = mintedNames.get(namespacedName);
|
|
1445
|
+
if (mintedBy !== undefined) {
|
|
1446
|
+
dropped.push({
|
|
1447
|
+
tool: inlineUntrusted(t.name),
|
|
1448
|
+
reason: mintedBy === t.name
|
|
1449
|
+
? `this server listed this tool name more than once in one listing; the first entry is mounted as "${namespacedName}" and the repeat is dropped (one name cannot denote two tools)`
|
|
1450
|
+
: `name collides with an earlier tool of this server: both mount as "${namespacedName}" after namespacing (only [a-zA-Z0-9_-] survives), and one name cannot denote two tools`,
|
|
1451
|
+
});
|
|
1452
|
+
continue;
|
|
1453
|
+
}
|
|
1454
|
+
mintedNames.set(namespacedName, t.name);
|
|
1281
1455
|
const hintAxis = mcpAxisFor(namespacedName, t.annotations);
|
|
1282
1456
|
const axis = applyCallerAxisOverride(namespacedName, hintAxis, spec.toolAxes?.[remoteName]);
|
|
1283
1457
|
if (axis)
|
|
@@ -1397,3 +1571,8 @@ function asServerWarning(spec, err) {
|
|
|
1397
1571
|
warning.code = "mcp.server_unavailable";
|
|
1398
1572
|
return warning;
|
|
1399
1573
|
}
|
|
1574
|
+
function listingIncompleteWarning(server, flag) {
|
|
1575
|
+
const warning = new Error(`mcp: server "${inlineUntrusted(server, 160)}" listed its tools INCOMPLETELY — ${listingIncompleteNote(flag)}. The tools beyond the ${flag.pages} page${flag.pages === 1 ? "" : "s"} retrieved are NOT mounted for this task.`);
|
|
1576
|
+
warning.code = "mcp.listing_incomplete";
|
|
1577
|
+
return warning;
|
|
1578
|
+
}
|
|
@@ -7,19 +7,36 @@
|
|
|
7
7
|
* semantics; who may mint one, where it is stored, and where in the gate it is consumed live in
|
|
8
8
|
* `permission-rule-store.ts`, `permission-rule-consent.ts` and `hooks.ts` respectively.
|
|
9
9
|
*
|
|
10
|
-
* ## The floor:
|
|
10
|
+
* ## The floor: what a rule may name, and what a rule may match
|
|
11
11
|
*
|
|
12
|
-
* Everything a shell can use to run a
|
|
13
|
-
*
|
|
14
|
-
* entirely: it falls back to the pre-existing chain and asks.
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
12
|
+
* Everything a shell can use to run a program the text does not NAME — substitution, subshells,
|
|
13
|
+
* backgrounding, escapes, line breaks, and **redirection** — puts the command outside this lane
|
|
14
|
+
* entirely: it falls back to the pre-existing chain and asks. Redirection specifically is a deliberate
|
|
15
|
+
* registered divergence from upstream (upstream strips redirections before matching; with this repo's
|
|
16
|
+
* write gate not covering the shell tool, stripping would turn a rule as innocuous as `Bash(ls)` into a
|
|
17
|
+
* licence for `ls > ~/.ssh/authorized_keys`).
|
|
18
18
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
19
|
+
* CONNECTORS are the one construct the lane does speak for, and only in the EXACT form. `./gradlew
|
|
20
|
+
* build && ./gradlew test` is one thing a person reads and approves in one glance, and the whole of it
|
|
21
|
+
* is written into the rule; nothing is admitted that the rule text does not spell out end to end. The
|
|
22
|
+
* PREFIX form stays single-command on both sides — as a rule BODY (`Bash(a && b:*)` is refused) and as
|
|
23
|
+
* a MATCH (`Bash(npm:*)` does not admit `npm test && curl evil.example`, which is the whole reason the
|
|
24
|
+
* two forms are separated here rather than sharing one matcher arm). Upstream reaches the same
|
|
25
|
+
* placement through a per-segment evaluation of its full decision chain, where a segment DENY is
|
|
26
|
+
* returned strictly before any whole-string allow; this lane's equivalent is that the deny/ask layer
|
|
27
|
+
* (`permission-rule-org.ts`) judges every segment and runs ahead of the allow lane at the gate.
|
|
28
|
+
*
|
|
29
|
+
* The floor is `parseLeadingCommandName` + `splitShellCompoundSegments` — the one simple-command parser
|
|
30
|
+
* and the one segmentation, both already shared with the read-only classifier, the reversibility probe,
|
|
31
|
+
* the coarse command-name policy and the skill tool specifier. A second tokenizer would drift, and drift
|
|
32
|
+
* on a loosening face shows up as a circumvention rather than as a test failure.
|
|
33
|
+
*
|
|
34
|
+
* argv[0] may carry a PATH prefix here (`./gradlew`, `/usr/bin/git`) — opt-in at the shared parser and
|
|
35
|
+
* used by this lane alone. The bare-name requirement belongs to the argv[0]-NAME filters, which compare
|
|
36
|
+
* one token against a name set; this lane compares the whole command line, so a path prefix is not a way
|
|
37
|
+
* past anything — it IS the text that was approved. No basename folding follows from it: `./gradlew`
|
|
38
|
+
* and `gradlew` stay two different commands to the matcher. The single place a basename is taken is the
|
|
39
|
+
* interpreter refusal below, which must read `/usr/bin/node` as `node`.
|
|
23
40
|
*
|
|
24
41
|
* ## Normalization order is load-bearing
|
|
25
42
|
*
|
|
@@ -121,11 +138,64 @@ export declare const MAX_RULE_TEXT_CHARS = 512;
|
|
|
121
138
|
* any program you like" — that is not a shape a single click can be understood to have authorized.
|
|
122
139
|
*
|
|
123
140
|
* The check is on argv[0] of the prefix, not on the prefix being exactly one word: `node -e:*` is the
|
|
124
|
-
* same licence spelled longer.
|
|
141
|
+
* same licence spelled longer. It is on argv[0]'s BASENAME, not its spelling: `/usr/bin/node:*` and
|
|
142
|
+
* `./node:*` grant precisely what `node:*` grants, and a table consulted with the full path would be a
|
|
143
|
+
* table any path prefix walks around (upstream takes the same basename before consulting its own
|
|
144
|
+
* interpreter set). Deliberately strict-side — it costs the ability to persist a rule like
|
|
125
145
|
* `python manage.py migrate:*` (which an import reports as skipped rather than dropping silently), and
|
|
126
146
|
* an EXACT rule naming a whole interpreter command line stays legal, since it authorizes one command.
|
|
147
|
+
*
|
|
148
|
+
* TWO groups, and the distinction matters when the table is next edited:
|
|
149
|
+
* · LANGUAGE interpreters (`node`, `python`, `ruby`, …) — the argument IS a program. This half is
|
|
150
|
+
* wider than upstream's own set, deliberately, and is the argument the paragraph above makes.
|
|
151
|
+
* · LAUNCHERS — a program whose argument is another program to run: the shells, the environment and
|
|
152
|
+
* privilege wrappers, the schedulers and resource wrappers, the tracers, and the shell BUILTINS
|
|
153
|
+
* that dispatch (`command`, `builtin`). This half is upstream's set, adopted verbatim rather than
|
|
154
|
+
* reasoned out row by row: it is the same question upstream answers with the same mechanism (its
|
|
155
|
+
* set is consulted on argv[0]'s basename too), and every row passes this table's own first axis —
|
|
156
|
+
* a body whose use is naming a program to execute is refused. They were missing, and each was one
|
|
157
|
+
* token in front of the licence the language half already refuses: `command node:*`, `timeout 9
|
|
158
|
+
* node:*` and `nice node:*` all grant exactly what `node:*` grants.
|
|
159
|
+
*
|
|
160
|
+
* KNOWN RESIDUAL, stated rather than hidden, and it is a property of the instrument rather than of this
|
|
161
|
+
* particular list: a positive list of NAMES cannot be made complete, and two independent review rounds
|
|
162
|
+
* enumerated escapes faster than rows could be added. Three shapes, none of which a longer list fixes:
|
|
163
|
+
* · a version- or distribution-suffixed spelling of a listed interpreter (`python3.12`, `nodejs`);
|
|
164
|
+
* · a launcher nobody listed — the set of programs that run another program has no boundary, and each
|
|
165
|
+
* round produced more of them;
|
|
166
|
+
* · a spelling that defeats the NAME question entirely: `/proc/self/exe` (basename `exe`, the running
|
|
167
|
+
* shell), a symlink or a renamed binary, a BusyBox applet, a dynamic loader invoked directly.
|
|
168
|
+
* Matching a family would need a pattern rather than a set, with its own false-positive surface
|
|
169
|
+
* (`node-gyp`, `timeout-monitor`), and no pattern addresses the third shape at all. What carries the
|
|
170
|
+
* residue is therefore NOT this table: it is the three standing fences — the org deny/ask layer runs
|
|
171
|
+
* ahead of this lane and cannot be silenced by it, a mandated ask is not rule-clearable, and the
|
|
172
|
+
* narrower exact candidate (plus minting nothing) is always on the same card. This table's job is to
|
|
173
|
+
* keep the OBVIOUS one-token licence off a card a person clicks once, not to be a boundary.
|
|
127
174
|
*/
|
|
128
175
|
export declare const BARE_INTERPRETER_NAMES: ReadonlySet<string>;
|
|
176
|
+
/**
|
|
177
|
+
* Shell KEYWORDS, refused as a segment's `argv[0]` everywhere this lane reads a command.
|
|
178
|
+
*
|
|
179
|
+
* The floor this lane stands on extracts the first TOKEN of a segment and calls it the command name.
|
|
180
|
+
* That identification is what every comparison here rests on — the interpreter refusal, the deny/ask
|
|
181
|
+
* layer's per-segment judgement, and a person's reading of the rule text. For a control structure it is
|
|
182
|
+
* simply false. `for x in once; do curl evil.example; done` splits into three segments whose first
|
|
183
|
+
* tokens are `for`, `do` and `done`; all three are ordinary bare words, so the floor accepts each,
|
|
184
|
+
* `curl` is named by nothing, and a published `deny Bash(curl:*)` matches none of them while bash runs
|
|
185
|
+
* curl. A whole loop body — any number of programs — hides behind three tokens that name no program.
|
|
186
|
+
*
|
|
187
|
+
* Refusing the keyword puts the whole command outside the lane, which is the honest answer: no rule can
|
|
188
|
+
* be minted for it and no rule matches it, so it asks. Recovering these shapes properly needs a real
|
|
189
|
+
* shell grammar (upstream has one — it parses to a syntax tree and reads the commands out of the
|
|
190
|
+
* structure, so a keyword is never mistaken for a program); a token-level lane cannot, and pretending
|
|
191
|
+
* otherwise is the loosening direction.
|
|
192
|
+
*
|
|
193
|
+
* `!` is here for the same reason with a sharper edge: it is a keyword that PREFIXES a real command, so
|
|
194
|
+
* `! node -e …` is the `node` licence the interpreter table exists to refuse, wearing one extra token.
|
|
195
|
+
* `{`/`}`/`[[`/`]]` are already refused by the floor's argv[0] metacharacter rule; listed anyway,
|
|
196
|
+
* because that rule belongs to another module and is not this lane's to depend on.
|
|
197
|
+
*/
|
|
198
|
+
export declare const SHELL_RESERVED_WORDS: ReadonlySet<string>;
|
|
129
199
|
/**
|
|
130
200
|
* design/185 §1 — the reviewed command/subcommand grammar the PREFIX suggestion is generated from
|
|
131
201
|
* (exactly the "reviewed command/subcommand grammar" the generator's history note names as the one
|
|
@@ -215,10 +285,46 @@ export declare function formatAllowRuleText(command: string, match: PersistedRul
|
|
|
215
285
|
* Does this rule's command pattern admit `command`?
|
|
216
286
|
*
|
|
217
287
|
* `command` is the raw tool argument: the floor and the folding happen HERE, in that order, so no
|
|
218
|
-
* caller can
|
|
219
|
-
*
|
|
288
|
+
* caller can match a command this lane has not read. Returns false for every redirection, substitution,
|
|
289
|
+
* subshell, backgrounded or escaped form.
|
|
290
|
+
*
|
|
291
|
+
* **A PREFIX rule never admits a compound.** This is the single load-bearing line of the connector
|
|
292
|
+
* widening, and it is checked on the MATCH side rather than left to the mint side: `Bash(npm:*)` is an
|
|
293
|
+
* ordinary, legitimately mintable rule, and if the "does this command start with `npm `" arm were
|
|
294
|
+
* allowed to see a compound at all, that rule would admit `npm test && curl evil.example` — one stored
|
|
295
|
+
* yes to a build command turned into a standing yes to whatever is chained behind it. An EXACT rule has
|
|
296
|
+
* no such reach by construction: it admits one string, the one it spells.
|
|
220
297
|
*/
|
|
221
298
|
export declare function ruleAdmitsCommand(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
|
|
299
|
+
/**
|
|
300
|
+
* The same match, asked the DENY/ASK layer's question — "does this rule speak about this program run?".
|
|
301
|
+
*
|
|
302
|
+
* Split from {@link ruleAdmitsCommand} because the two differ on one axis that decides real cases: a
|
|
303
|
+
* quoted operator. `curl "https://x/?a=1&b=2"` is ONE command bash runs, and the `&` in a query string
|
|
304
|
+
* is not a connector; the matching side refuses it anyway (a rule text is a spelling with no operator
|
|
305
|
+
* characters in it at all — the historical rule-face contract), and the deny side inheriting that
|
|
306
|
+
* refusal made `deny Bash(curl:*)` silent on the commonest spelling of the very program it names
|
|
307
|
+
* (adversarial round 3).
|
|
308
|
+
*
|
|
309
|
+
* The asymmetry only ever runs one way: this predicate reads MORE commands than the matching one, never
|
|
310
|
+
* fewer. A shape only the ALLOW side could read would be a standing approval no published policy could
|
|
311
|
+
* see — the inversion this whole ticket exists to prevent.
|
|
312
|
+
*/
|
|
313
|
+
export declare function ruleAdmitsProgramRun(rule: Pick<PersistedAllowRule, "match" | "command">, command: string): boolean;
|
|
314
|
+
/**
|
|
315
|
+
* The segments of `command` as the deny/ask layer must judge them, or `undefined` for a command this
|
|
316
|
+
* lane cannot read.
|
|
317
|
+
*
|
|
318
|
+
* A tightening rule speaks about a PROGRAM RUN, and a compound runs several. `Bash(curl:*)` published
|
|
319
|
+
* as a deny means "this machine does not make that call", and reading `npm test && curl evil.example`
|
|
320
|
+
* as one unmatched blob answered that with silence — the shape the widening above would otherwise make
|
|
321
|
+
* permanently approvable. Exported (rather than folded into a matcher here) because the layer that
|
|
322
|
+
* needs it holds the rules: this module owns what a command IS, `permission-rule-org.ts` owns what the
|
|
323
|
+
* organization says about each part of it.
|
|
324
|
+
*
|
|
325
|
+
* A single simple command yields a one-element list, so a deny that matched before matches identically.
|
|
326
|
+
*/
|
|
327
|
+
export declare function ruleLaneSegmentsOf(command: string): readonly string[] | undefined;
|
|
222
328
|
/**
|
|
223
329
|
* Is `path` inside (or equal to) `root`? Word-boundary containment on the path separator, so `/a` does
|
|
224
330
|
* not contain `/ab`. Both sides are expected to be canonical already.
|
|
@@ -271,13 +377,26 @@ export interface RuleSuggestion {
|
|
|
271
377
|
* shear a quoted segment — harmless in this direction, because the sheared pieces carry quote
|
|
272
378
|
* characters and can never equal a bare lexicon word; every suspicious shape lands on "no prefix".
|
|
273
379
|
*
|
|
274
|
-
* Every produced candidate must survive the round trip — parse as a rule
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
380
|
+
* Every produced candidate must survive the round trip — parse as a rule, come back as the match form
|
|
381
|
+
* this seat is offering, AND admit the very command it was minted from. Enforced on BOTH seats,
|
|
382
|
+
* fail-closed: a candidate that would not round-trip is silently not offered, since offering an option
|
|
383
|
+
* redemption would refuse is worse than offering one fewer.
|
|
384
|
+
*
|
|
385
|
+
* The FORM half of that check is not decoration. A command may end in the rule grammar's own prefix
|
|
386
|
+
* marker — `rm :*` is a legal thing to type — and wrapping it as an exact rule produces the text
|
|
387
|
+
* `Bash(rm :*)`, which the validator reads back as the PREFIX rule `Bash(rm:*)` over the command `rm`.
|
|
388
|
+
* Trusting the exact seat's own intent and labelling that result `match: "exact"` put a blanket
|
|
389
|
+
* every-`rm` rule on the card under the narrowest option's name, one click from being persisted (and,
|
|
390
|
+
* for a lexicon command like `git status :*`, emitted twice — once mislabelled, once as the real prefix
|
|
391
|
+
* candidate). The seat therefore believes the PARSER about what it got back, never its own request.
|
|
392
|
+
*
|
|
393
|
+
* A COMPOUND (`./gradlew build && ./gradlew test`) fills the exact seat and only that one: the offered
|
|
394
|
+
* rule spells the whole chain and admits exactly it. That is the shape this seat was missing — the
|
|
395
|
+
* ordinary build invocation is a connector chain, and a card that could offer nothing for it made every
|
|
396
|
+
* such command a fresh question forever, with no way for an answer to accumulate.
|
|
279
397
|
*
|
|
280
|
-
* Returns an empty array for anything the rule lane cannot speak for (
|
|
281
|
-
*
|
|
398
|
+
* Returns an empty array for anything the rule lane cannot speak for (redirections, substitutions,
|
|
399
|
+
* subshells, backgrounding) — the card then simply carries no "don't ask again" option, which is the
|
|
400
|
+
* honest answer.
|
|
282
401
|
*/
|
|
283
402
|
export declare function suggestRulesForCommand(command: string): RuleSuggestion[];
|