@jini-ai/mcp 0.3.4 → 0.4.1
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 +156 -0
- package/README.md +97 -22
- package/dist/agent-install/install.d.ts +20 -4
- package/dist/agent-install/install.js +11 -11
- package/dist/bin/serve.d.ts +25 -13
- package/dist/bin/serve.js +42 -21
- package/dist/client/client.d.ts +28 -8
- package/dist/client/client.js +25 -21
- package/dist/core/config.d.ts +57 -18
- package/dist/core/config.js +76 -48
- package/dist/core/index.d.ts +1 -2
- package/dist/core/index.js +1 -2
- package/dist/core/install-info.d.ts +4 -1
- package/dist/core/install-info.js +5 -5
- package/dist/core/tokens.d.ts +39 -13
- package/dist/core/tokens.js +54 -24
- package/dist/federation/adapter.http.d.ts +67 -0
- package/dist/federation/adapter.http.js +414 -0
- package/dist/federation/approvals/call-confirmation.d.ts +23 -0
- package/dist/federation/approvals/call-confirmation.js +124 -0
- package/dist/federation/approvals/index.d.ts +4 -0
- package/dist/federation/approvals/index.js +4 -0
- package/dist/federation/approvals/ports.d.ts +107 -0
- package/dist/federation/approvals/ports.js +2 -0
- package/dist/federation/approvals/revocation.d.ts +61 -0
- package/dist/federation/approvals/revocation.js +52 -0
- package/dist/federation/bootstrap.d.ts +100 -0
- package/dist/federation/bootstrap.js +182 -0
- package/dist/federation/config.d.ts +68 -0
- package/dist/federation/config.js +40 -0
- package/dist/federation/index.d.ts +14 -0
- package/dist/federation/index.js +14 -0
- package/dist/federation/mcp-protocol.d.ts +115 -0
- package/dist/federation/mcp-protocol.js +151 -0
- package/dist/federation/messages.d.ts +54 -0
- package/dist/federation/messages.js +35 -0
- package/dist/federation/ports.d.ts +287 -0
- package/dist/federation/ports.js +5 -0
- package/dist/federation/presets.d.ts +66 -0
- package/dist/federation/presets.js +30 -0
- package/dist/federation/refusal-notice.d.ts +198 -0
- package/dist/federation/refusal-notice.js +193 -0
- package/dist/federation/registrations.d.ts +171 -0
- package/dist/federation/registrations.js +181 -0
- package/dist/federation/reload.d.ts +112 -0
- package/dist/federation/reload.js +110 -0
- package/dist/federation/stdio/adapter.stdio.d.ts +157 -0
- package/dist/federation/stdio/adapter.stdio.js +526 -0
- package/dist/federation/stdio/default-connect.d.ts +29 -0
- package/dist/federation/stdio/default-connect.js +92 -0
- package/dist/federation/stdio/index.d.ts +4 -0
- package/dist/federation/stdio/index.js +4 -0
- package/dist/federation/stdio/stdio-launch-resolver.d.ts +139 -0
- package/dist/federation/stdio/stdio-launch-resolver.js +227 -0
- package/dist/federation/testing/adapter.memory.d.ts +177 -0
- package/dist/federation/testing/adapter.memory.js +189 -0
- package/dist/federation/testing/index.d.ts +3 -0
- package/dist/federation/testing/index.js +3 -0
- package/dist/federation/testing/tool-approvals.memory.d.ts +27 -0
- package/dist/federation/testing/tool-approvals.memory.js +38 -0
- package/dist/federation/tool-approvals.d.ts +97 -0
- package/dist/federation/tool-approvals.js +42 -0
- package/dist/federation/trust.d.ts +471 -0
- package/dist/federation/trust.js +707 -0
- package/dist/index.d.ts +175 -2
- package/dist/index.js +172 -2
- package/dist/server/daemon-client.d.ts +16 -4
- package/dist/server/daemon-client.js +41 -19
- package/dist/server/ports.d.ts +9 -0
- package/dist/server/ports.js +2 -0
- package/dist/server/resource-protocol.d.ts +14 -4
- package/dist/server/resource-protocol.js +6 -6
- package/dist/server/resources/active-resource.js +3 -3
- package/dist/server/sdk-transport.d.ts +11 -0
- package/dist/server/sdk-transport.js +31 -0
- package/dist/server/tool-protocol.d.ts +31 -9
- package/dist/server/tool-protocol.js +20 -18
- package/dist/server/tool-server.d.ts +60 -43
- package/dist/server/tool-server.js +51 -27
- package/dist/server/tools/component-catalog-tools.js +7 -7
- package/dist/server/tools/delegated-tool.d.ts +7 -4
- package/dist/server/tools/delegated-tool.js +18 -16
- package/dist/server/tools/run-tools.js +15 -15
- package/dist/server/tools/tool-catalog-tools.js +7 -7
- package/dist/tools/ask-choice/index.d.ts +123 -0
- package/dist/tools/ask-choice/index.js +98 -0
- package/dist/tools/ask-choice/pending.d.ts +11 -0
- package/dist/tools/ask-choice/pending.js +34 -0
- package/dist/tools/ask-choice/question.d.ts +16 -0
- package/dist/tools/ask-choice/question.js +60 -0
- package/dist/tools/ask-choice/schema.d.ts +91 -0
- package/dist/tools/ask-choice/schema.js +27 -0
- package/dist/tools/ask-choice/types.d.ts +106 -0
- package/dist/tools/ask-choice/types.js +2 -0
- package/package.json +43 -5
- package/dist/core/oauth.d.ts +0 -274
- package/dist/core/oauth.js +0 -667
- package/dist/core/secure-write.d.ts +0 -17
- package/dist/core/secure-write.js +0 -72
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ResolvedFederatedConnection } from "./config.js";
|
|
2
|
+
/**
|
|
3
|
+
* @file The core-owned, plugin-populated registry of federated MCP presets — the seam that lets a
|
|
4
|
+
* concrete vendor integration live OUTSIDE `src/assistant/` while still being part of the default
|
|
5
|
+
* boot.
|
|
6
|
+
*
|
|
7
|
+
* Why a registry rather than `bootstrap.ts` calling each preset by name: before 2026-07-30
|
|
8
|
+
* `bootstrap.ts` imported `resolveSupabaseMcpConnection` directly, which made "add a second vendor"
|
|
9
|
+
* an edit to core federation code and made core federation import a specific vendor's module. Both
|
|
10
|
+
* are the wrong direction. With this file the dependency points the other way — a preset imports
|
|
11
|
+
* core and announces itself; core never learns any vendor's name.
|
|
12
|
+
*
|
|
13
|
+
* The shape is the one this codebase already uses for exactly this problem: a module-level ordered
|
|
14
|
+
* list plus a typed `register*` function, a `reset*ForTests`, and a fold/resolve function —
|
|
15
|
+
* `page-head.ts`'s `registerPageHeadContributor`/`foldPageHead` and `routing.ts`'s
|
|
16
|
+
* `registerResolvePhase`/`registerNamedRoute`. Per §3, hooks and registries are
|
|
17
|
+
* exempt from the rule-of-two, so this is deliberately NOT a port with two adapters.
|
|
18
|
+
*
|
|
19
|
+
* This is NOT the plugin runtime. That mechanism exists to load sandboxed third-party code
|
|
20
|
+
* at runtime; this one wires a first-party module that ships in the repo and is imported by a
|
|
21
|
+
* composition root at boot. A preset here is ordinary reviewed code in this repository — the seam
|
|
22
|
+
* buys module-boundary honesty, not isolation.
|
|
23
|
+
*
|
|
24
|
+
* Architectural role:
|
|
25
|
+
* In-module registry. No I/O; resolution is delegated to whatever a preset registered.
|
|
26
|
+
* See docs/decisions/DR-003-trusted-federation-composition.md.
|
|
27
|
+
*/
|
|
28
|
+
/**
|
|
29
|
+
* A preset's resolver: read the operator's environment and either produce a connection or decline.
|
|
30
|
+
*
|
|
31
|
+
* `null` means "the operator has not configured this one", and is the expected, silent, overwhelming
|
|
32
|
+
* majority case — federation is off unless a site owner turns it on. THROWING means "the operator
|
|
33
|
+
* asked for this connection and got the settings wrong", which is an error worth surfacing; a preset
|
|
34
|
+
* must never paper over a half-configured connection by returning a broader one. `bootstrap.ts`
|
|
35
|
+
* isolates and logs a throw per preset so one vendor's typo cannot disable another's working
|
|
36
|
+
* connection.
|
|
37
|
+
*/
|
|
38
|
+
export type FederatedMcpPresetResolver = (required: {
|
|
39
|
+
env: NodeJS.ProcessEnv;
|
|
40
|
+
}) => ResolvedFederatedConnection | null;
|
|
41
|
+
export interface FederatedMcpPreset {
|
|
42
|
+
/** Stable identity of the PRESET MODULE (not of the connection it resolves). Used to keep a
|
|
43
|
+
* double import from registering the same vendor twice; see {@link registerFederatedMcpPreset}. */
|
|
44
|
+
readonly presetId: string;
|
|
45
|
+
readonly resolve: FederatedMcpPresetResolver;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Registers a preset, called once at composition-root boot by the preset's own module.
|
|
49
|
+
*
|
|
50
|
+
* Re-registering the same `presetId` REPLACES the earlier entry rather than appending. Unlike a
|
|
51
|
+
* `<head>` contributor, a duplicate here would resolve to two connections with the same
|
|
52
|
+
* `connectionId`, and `trust.ts` R1's collision assertion would then drop the second one whole —
|
|
53
|
+
* producing a confusing "registered, then refused for shadowing itself" log for what is really just
|
|
54
|
+
* a module imported from two places. Last registration wins, and registration order is otherwise
|
|
55
|
+
* preserved so a replacement does not silently reorder connections.
|
|
56
|
+
*/
|
|
57
|
+
export declare function registerFederatedMcpPreset({ preset }: {
|
|
58
|
+
preset: FederatedMcpPreset;
|
|
59
|
+
}): void;
|
|
60
|
+
/** Registration order. `bootstrap.ts` resolves in this order, and that order decides which
|
|
61
|
+
* connection claims a contested tool id first (R1). */
|
|
62
|
+
export declare function listFederatedMcpPresets(): readonly FederatedMcpPreset[];
|
|
63
|
+
/** Test-only reset of the module-level registry (mirrors `page-head.ts`'s
|
|
64
|
+
* `resetPageHeadRegistry`, though that one is no longer test-only — see its own doc). */
|
|
65
|
+
export declare function resetFederatedMcpPresetsForTests(_required: Record<string, never>): void;
|
|
66
|
+
//# sourceMappingURL=presets.d.ts.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
let presets = [];
|
|
2
|
+
/**
|
|
3
|
+
* Registers a preset, called once at composition-root boot by the preset's own module.
|
|
4
|
+
*
|
|
5
|
+
* Re-registering the same `presetId` REPLACES the earlier entry rather than appending. Unlike a
|
|
6
|
+
* `<head>` contributor, a duplicate here would resolve to two connections with the same
|
|
7
|
+
* `connectionId`, and `trust.ts` R1's collision assertion would then drop the second one whole —
|
|
8
|
+
* producing a confusing "registered, then refused for shadowing itself" log for what is really just
|
|
9
|
+
* a module imported from two places. Last registration wins, and registration order is otherwise
|
|
10
|
+
* preserved so a replacement does not silently reorder connections.
|
|
11
|
+
*/
|
|
12
|
+
export function registerFederatedMcpPreset({ preset }) {
|
|
13
|
+
const existing = presets.findIndex((candidate) => candidate.presetId === preset.presetId);
|
|
14
|
+
if (existing >= 0) {
|
|
15
|
+
presets[existing] = preset;
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
presets.push(preset);
|
|
19
|
+
}
|
|
20
|
+
/** Registration order. `bootstrap.ts` resolves in this order, and that order decides which
|
|
21
|
+
* connection claims a contested tool id first (R1). */
|
|
22
|
+
export function listFederatedMcpPresets() {
|
|
23
|
+
return presets;
|
|
24
|
+
}
|
|
25
|
+
/** Test-only reset of the module-level registry (mirrors `page-head.ts`'s
|
|
26
|
+
* `resetPageHeadRegistry`, though that one is no longer test-only — see its own doc). */
|
|
27
|
+
export function resetFederatedMcpPresetsForTests(_required) {
|
|
28
|
+
presets = [];
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=presets.js.map
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import type { FederationRefusalMessages } from "./messages.js";
|
|
2
|
+
import type { FederatedAdmissionReport, ToolRefusalReason } from "./trust.js";
|
|
3
|
+
/**
|
|
4
|
+
* @file The refusal channel's SECOND sink: the one that reaches the model.
|
|
5
|
+
*
|
|
6
|
+
* ---------------------------------------------------------------------------
|
|
7
|
+
* The defect this closes
|
|
8
|
+
* ---------------------------------------------------------------------------
|
|
9
|
+
* `trust.ts` already refuses nothing silently — `admitRemoteTools` returns a complete accounting,
|
|
10
|
+
* and `bootstrap.ts`'s `logFederatedAdmissionReport` writes every line of it. But a log is a sink
|
|
11
|
+
* with exactly one reader: whoever happened to be tailing the daemon's terminal at the moment it
|
|
12
|
+
* booted. Two parties who needed that accounting never got it:
|
|
13
|
+
*
|
|
14
|
+
* 1. THE OPERATOR, who allowed a tool for a connection and watched it not work. Served
|
|
15
|
+
* since by `federation-admissions-route.ts` and its admin proxy — a structured read
|
|
16
|
+
* of this same snapshot.
|
|
17
|
+
* 2. THE MODEL, which is this file. Asked in chat why it could not generate an image, the assistant
|
|
18
|
+
* INVENTED a cause, because a refused tool is simply absent: it is never registered, so
|
|
19
|
+
* `search_tools` cannot find it, `describe_tool` cannot describe it, and nothing anywhere in the
|
|
20
|
+
* catalog says "this exists and was withheld." Absence is indistinguishable from non-existence,
|
|
21
|
+
* and a model asked to explain an absence with no evidence will produce a plausible sentence.
|
|
22
|
+
*
|
|
23
|
+
* A tool-shaped fix does not work here, and that is the whole reason this is prompt text. A model
|
|
24
|
+
* that does not know it is missing anything never calls the tool that would tell it. The refusal
|
|
25
|
+
* has to arrive without a discovery step, which means the prompt.
|
|
26
|
+
*
|
|
27
|
+
* ---------------------------------------------------------------------------
|
|
28
|
+
* Why this is safe to prepend to every run
|
|
29
|
+
* ---------------------------------------------------------------------------
|
|
30
|
+
* R-A. NOTHING IS SAID WHEN NOTHING IS WRONG. A boot with no actionable refusal returns `""` and
|
|
31
|
+
* costs zero tokens — the overwhelmingly common case, and the reason this is not gated behind
|
|
32
|
+
* an env arm the way `capability-manifest-prefix.ts` is.
|
|
33
|
+
*
|
|
34
|
+
* R-B. `not-in-operator-allowlist` IS NOT REPORTED. It is the routine default-deny outcome of R2:
|
|
35
|
+
* a real server advertises tens of tools and an operator allowlists three, so this reason
|
|
36
|
+
* fires for every tool nobody asked for. Reporting it would put a wall of "you did not enable
|
|
37
|
+
* this" in front of the model on every turn and train it to ignore the whole block — the same
|
|
38
|
+
* failure `bootstrap.ts`'s `resolveRegisteredPresets` cites for staying silent about a preset
|
|
39
|
+
* that simply is not configured. What IS reported is every refusal where the operator's own
|
|
40
|
+
* intent and the gate's decision disagree.
|
|
41
|
+
*
|
|
42
|
+
* R-C. THE REMOTE'S BYTES ARE NEVER TRUSTED. A refused `remoteName` is third-party text on a direct
|
|
43
|
+
* path into the system prompt, and `invalid-remote-tool-name` is BY DEFINITION the refusal
|
|
44
|
+
* whose name failed {@link SAFE_REMOTE_NAME} — arbitrary attacker-chosen bytes. Every name
|
|
45
|
+
* here is re-checked against that pattern and replaced with a description rather than escaped
|
|
46
|
+
* when it fails, matching `trust.ts`'s own "refused rather than escaped, because every escaping
|
|
47
|
+
* scheme is a place to get it wrong later" for the identical reason one layer earlier.
|
|
48
|
+
*
|
|
49
|
+
* R-D. THE CHANNEL IS BOUNDED. A remote that advertises a thousand malformed tools cannot spend the
|
|
50
|
+
* model's context window through this file: the item list is capped and the omission is
|
|
51
|
+
* announced with the true total, so the model is never told a smaller number than the truth.
|
|
52
|
+
* Same discipline as `wrapUntrustedResult`'s announced truncation.
|
|
53
|
+
*
|
|
54
|
+
* R-E. NOTHING CALLABLE IS EVER REPORTED AS WITHHELD. A refused `remoteName` whose name is also in
|
|
55
|
+
* `admitted` is dropped — see {@link refusalItems} for the case that produces one and why the
|
|
56
|
+
* subtraction is by admitted set rather than by refusal reason.
|
|
57
|
+
*
|
|
58
|
+
* Architectural role:
|
|
59
|
+
* Pure functions. No I/O, no registry, no protocol — the same posture as `trust.ts`, and for the
|
|
60
|
+
* same reason: every rule above is a unit test.
|
|
61
|
+
* See docs/decisions/DR-001-federation-admission.md.
|
|
62
|
+
*/
|
|
63
|
+
/** One connection's boot admission accounting, exactly as `AttachFederatedToolsResult.reports`
|
|
64
|
+
* carries it and `federation-admissions-route.ts` serves it. Restated structurally rather than
|
|
65
|
+
* imported from `bootstrap.ts`: that module performs I/O and spawns child processes on import,
|
|
66
|
+
* and this file's whole point is being a pure reduction over the snapshot. */
|
|
67
|
+
export interface FederationAdmissionSnapshotEntry {
|
|
68
|
+
readonly connectionId: string;
|
|
69
|
+
readonly report: FederatedAdmissionReport;
|
|
70
|
+
}
|
|
71
|
+
/** What KIND of disagreement between operator intent and the gate's decision an item records.
|
|
72
|
+
* Three, not one, because the operator fix differs for each: a refusal needs a grant, an absent
|
|
73
|
+
* allowlist entry needs a correction, and an inert write grant needs a second listing. */
|
|
74
|
+
export type FederationRefusalKind = "refused" | "allowlisted-but-absent" | "write-allowed-but-not-allowlisted";
|
|
75
|
+
/** One thing the operator asked for that the running assistant does not have. */
|
|
76
|
+
export interface FederationRefusalItem {
|
|
77
|
+
readonly connectionId: string;
|
|
78
|
+
/** Already sanitized per R-C — safe to render and safe to put in a prompt. */
|
|
79
|
+
readonly remoteName: string;
|
|
80
|
+
readonly kind: FederationRefusalKind;
|
|
81
|
+
/** `null` for the two drift kinds, which are not gate refusals. */
|
|
82
|
+
readonly reason: ToolRefusalReason | null;
|
|
83
|
+
/** Operator-actionable prose: what happened AND what to do about it. Never a placeholder — a
|
|
84
|
+
* reason with no real fix is a reported refusal that is still useless. */
|
|
85
|
+
readonly explanation: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* R-C. A rejected name is replaced without quoting a single remote-chosen byte.
|
|
89
|
+
* Returns a name safe to place in a prompt, or {@link FederationRefusalMessages.unprintableName}.
|
|
90
|
+
*
|
|
91
|
+
* @complexity O(n) in the name length, bounded by the pattern's own 64-character ceiling.
|
|
92
|
+
*/
|
|
93
|
+
export declare function safeRemoteName({ remoteName, messages }: {
|
|
94
|
+
remoteName: unknown;
|
|
95
|
+
messages: FederationRefusalMessages;
|
|
96
|
+
}): string;
|
|
97
|
+
/**
|
|
98
|
+
* The operator-facing sentence for each refusal reason: what the gate did, and the one thing that
|
|
99
|
+
* changes it. Routine default-deny is excluded from boot enumeration per R-B, but an
|
|
100
|
+
* attempted tool still needs its specific refusal explained.
|
|
101
|
+
*
|
|
102
|
+
* Host wording names the concrete remediation; the reducer must not assume a UI or labels.
|
|
103
|
+
*
|
|
104
|
+
* The routine default-deny explanation is included for attempts — see R-B. Needed here
|
|
105
|
+
* because ATTEMPT-TIME reporting (`../federated-refusal-diagnosis.ts`) is not enumeration: R-B's
|
|
106
|
+
* "not news" argument only holds for a boot-time list the model reads whether it asked or not. A
|
|
107
|
+
* model that has actually NAMED this exact tool id in a call has, by doing so, already decided it
|
|
108
|
+
* wants it — telling it why that specific call failed is never the spam R-B guards against.
|
|
109
|
+
*
|
|
110
|
+
* The full per-reason explanation for a caller that already knows WHICH tool was attempted — as
|
|
111
|
+
* opposed to {@link refusalItems}'s boot-time enumeration, which drops `not-in-operator-allowlist`
|
|
112
|
+
* per R-B. Every other reason's text is the exact same string {@link FederationRefusalMessages.refusalExplanations} gives the
|
|
113
|
+
* boot prefix, so the two channels can never disagree about WHY a given reason code fired — only
|
|
114
|
+
* about which reasons are worth mentioning unprompted.
|
|
115
|
+
*
|
|
116
|
+
* @complexity O(1).
|
|
117
|
+
*/
|
|
118
|
+
export declare function explainFederatedToolRefusal({ reason, messages }: {
|
|
119
|
+
reason: ToolRefusalReason;
|
|
120
|
+
messages: FederationRefusalMessages;
|
|
121
|
+
}): string;
|
|
122
|
+
/**
|
|
123
|
+
* Every place this boot's admitted set disagrees with what the operator asked for.
|
|
124
|
+
*
|
|
125
|
+
* Shared by the prompt prefix below and by any operator-facing consumer that wants the same
|
|
126
|
+
* accounting as data rather than prose — one derivation, so the two can never disagree about which
|
|
127
|
+
* refusals count, the same property `classifyRemoteToolSurface` gives `trust.ts`.
|
|
128
|
+
*
|
|
129
|
+
* @param snapshot - `AttachFederatedToolsResult.reports`, verbatim.
|
|
130
|
+
* @returns Items in connection order, refusals before drift within each connection.
|
|
131
|
+
* @complexity O(c · t) in connections and their admitted/refused/drift entries — the per-connection
|
|
132
|
+
* admitted-name set R-E subtracts against is built once per connection, not per refusal.
|
|
133
|
+
* See docs/decisions/DR-001-federation-admission.md.
|
|
134
|
+
*/
|
|
135
|
+
export declare function summarizeFederatedRefusals({ snapshot, messages }: {
|
|
136
|
+
snapshot: readonly FederationAdmissionSnapshotEntry[];
|
|
137
|
+
messages: FederationRefusalMessages;
|
|
138
|
+
}): readonly FederationRefusalItem[];
|
|
139
|
+
/** One refused federated tool CALL, resolved by the exact id a caller attempted — the call-time
|
|
140
|
+
* counterpart to {@link FederationRefusalItem}, which is one row of the boot-time enumeration.
|
|
141
|
+
* Deliberately its own shape rather than a reused `FederationRefusalItem`: this one ALWAYS carries
|
|
142
|
+
* an explanation (R-B does not apply to an attempt — see {@link explainFederatedToolRefusal}), and
|
|
143
|
+
* it is resolved from one id on demand rather than built by iterating every refusal up front. */
|
|
144
|
+
export interface FederatedToolRefusalLookup {
|
|
145
|
+
readonly connectionId: string;
|
|
146
|
+
/** Already sanitized per R-C — safe to render and safe to put in a message a model reads. */
|
|
147
|
+
readonly remoteName: string;
|
|
148
|
+
readonly reason: ToolRefusalReason;
|
|
149
|
+
readonly explanation: string;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Resolves ONE attempted tool id against a boot's admission snapshot, for the caller that actually
|
|
153
|
+
* tried to run it — the call-time half of "every refusal is reportable, never silent" (R-B only ever
|
|
154
|
+
* silences the boot-time ENUMERATION of a routine default-deny, never an attempt naming the tool by
|
|
155
|
+
* id).
|
|
156
|
+
*
|
|
157
|
+
* Matches by re-minting {@link federatedToolId} for every refused `(connectionId, remoteName)` pair
|
|
158
|
+
* rather than parsing `toolId` apart: the mint is the one place that format is defined, and a second,
|
|
159
|
+
* independent parser is a second place the two could silently drift.
|
|
160
|
+
*
|
|
161
|
+
* @param toolId - The exact id a call attempted, e.g. `mcp__higgsfield__tiktok_publish`.
|
|
162
|
+
* @param snapshot - `AttachFederatedToolsResult.reports`, verbatim — the same shape
|
|
163
|
+
* {@link summarizeFederatedRefusals} accepts.
|
|
164
|
+
* @returns `null` when `toolId` is not a federated id this boot ever refused — either it was
|
|
165
|
+
* admitted (a caller reaching this function for an admitted id is a bug one layer up: an admitted
|
|
166
|
+
* tool is registered and its call would never throw `unknown tool` in the first place), or it is
|
|
167
|
+
* not a federated tool id this boot knows about at all (a native tool, or a genuinely
|
|
168
|
+
* unknown/hallucinated one).
|
|
169
|
+
* @complexity O(c · r) in connections and their refusals — the same bound
|
|
170
|
+
* {@link summarizeFederatedRefusals} accepts for the boot-time reduction, run once per failed call
|
|
171
|
+
* here rather than once per boot.
|
|
172
|
+
*/
|
|
173
|
+
export declare function findFederatedToolRefusal({ toolId, snapshot, messages }: {
|
|
174
|
+
toolId: string;
|
|
175
|
+
snapshot: readonly FederationAdmissionSnapshotEntry[];
|
|
176
|
+
messages: FederationRefusalMessages;
|
|
177
|
+
}): FederatedToolRefusalLookup | null;
|
|
178
|
+
/** One rendered bullet. The connection id is operator-authored and pattern-validated by
|
|
179
|
+
* `assertValidConnectionId` before any tool from it is ever admitted, so it needs no sanitizing of
|
|
180
|
+
* its own; the remote name already went through {@link safeRemoteName}.
|
|
181
|
+
*
|
|
182
|
+
* The prompt prefix, or `""` when this boot has nothing to report (R-A).
|
|
183
|
+
*
|
|
184
|
+
* Prepended to a run's prompt by `agent-daemon-server.ts` through the same
|
|
185
|
+
* `assemblePromptWithPluginPrefix` seam the capability manifest and agent-plugin prefixes already
|
|
186
|
+
* use — deliberately not a new delivery mechanism, and deliberately not a tool: see this file's
|
|
187
|
+
* header for why a model that does not know it is missing something never calls the tool that
|
|
188
|
+
* would tell it.
|
|
189
|
+
*
|
|
190
|
+
* @param snapshot - `AttachFederatedToolsResult.reports`, verbatim.
|
|
191
|
+
* @returns Prompt text, bounded per R-D, with the true total announced when the list is clipped.
|
|
192
|
+
* @complexity O(c · t), plus O(n) rendering bounded by {@link MAX_ITEMS_IN_PREFIX}.
|
|
193
|
+
*/
|
|
194
|
+
export declare function buildFederatedRefusalPrefix({ snapshot, messages }: {
|
|
195
|
+
snapshot: readonly FederationAdmissionSnapshotEntry[];
|
|
196
|
+
messages: FederationRefusalMessages;
|
|
197
|
+
}): string;
|
|
198
|
+
//# sourceMappingURL=refusal-notice.d.ts.map
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { federatedToolId } from "./trust.js";
|
|
2
|
+
/** R-C's gate, deliberately identical to `trust.ts`'s `REMOTE_TOOL_NAME_PATTERN`. Kept as its own
|
|
3
|
+
* literal rather than imported: that constant is private to the trust tier and exporting it to
|
|
4
|
+
* make a prompt-safety check reuse it would widen a security module's surface for a consumer that
|
|
5
|
+
* only needs the same ANSWER, not the same binding. The two must not drift — a name this file
|
|
6
|
+
* accepts that the gate would reject is a prompt-injection hole — which is why the equivalence is
|
|
7
|
+
* its own test rather than a comment. */
|
|
8
|
+
const SAFE_REMOTE_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,63}$/;
|
|
9
|
+
/** R-D's cap. Enough that a real misconfiguration is fully listed, small enough that a hostile
|
|
10
|
+
* server cannot buy prompt real estate by advertising junk. */
|
|
11
|
+
const MAX_ITEMS_IN_PREFIX = 10;
|
|
12
|
+
/**
|
|
13
|
+
* R-C. A rejected name is replaced without quoting a single remote-chosen byte.
|
|
14
|
+
* Returns a name safe to place in a prompt, or {@link FederationRefusalMessages.unprintableName}.
|
|
15
|
+
*
|
|
16
|
+
* @complexity O(n) in the name length, bounded by the pattern's own 64-character ceiling.
|
|
17
|
+
*/
|
|
18
|
+
export function safeRemoteName({ remoteName, messages }) {
|
|
19
|
+
if (typeof remoteName !== "string")
|
|
20
|
+
return messages.unprintableName;
|
|
21
|
+
return SAFE_REMOTE_NAME.test(remoteName) ? remoteName : messages.unprintableName;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The operator-facing sentence for each refusal reason: what the gate did, and the one thing that
|
|
25
|
+
* changes it. Routine default-deny is excluded from boot enumeration per R-B, but an
|
|
26
|
+
* attempted tool still needs its specific refusal explained.
|
|
27
|
+
*
|
|
28
|
+
* Host wording names the concrete remediation; the reducer must not assume a UI or labels.
|
|
29
|
+
*
|
|
30
|
+
* The routine default-deny explanation is included for attempts — see R-B. Needed here
|
|
31
|
+
* because ATTEMPT-TIME reporting (`../federated-refusal-diagnosis.ts`) is not enumeration: R-B's
|
|
32
|
+
* "not news" argument only holds for a boot-time list the model reads whether it asked or not. A
|
|
33
|
+
* model that has actually NAMED this exact tool id in a call has, by doing so, already decided it
|
|
34
|
+
* wants it — telling it why that specific call failed is never the spam R-B guards against.
|
|
35
|
+
*
|
|
36
|
+
* The full per-reason explanation for a caller that already knows WHICH tool was attempted — as
|
|
37
|
+
* opposed to {@link refusalItems}'s boot-time enumeration, which drops `not-in-operator-allowlist`
|
|
38
|
+
* per R-B. Every other reason's text is the exact same string {@link FederationRefusalMessages.refusalExplanations} gives the
|
|
39
|
+
* boot prefix, so the two channels can never disagree about WHY a given reason code fired — only
|
|
40
|
+
* about which reasons are worth mentioning unprompted.
|
|
41
|
+
*
|
|
42
|
+
* @complexity O(1).
|
|
43
|
+
*/
|
|
44
|
+
export function explainFederatedToolRefusal({ reason, messages }) {
|
|
45
|
+
return messages.refusalExplanations[reason];
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* One connection's gate refusals, minus R-B's routine default-deny and minus R-E's names that were
|
|
49
|
+
* admitted anyway.
|
|
50
|
+
*
|
|
51
|
+
* R-E. A NAME PRESENT IN `admitted` IS NEVER REPORTED AS WITHHELD (2026-09-07, MCP-01). A refused
|
|
52
|
+
* `remoteName` is not the same thing as an absent tool: `trust.ts`'s `admitRemoteToolName`
|
|
53
|
+
* admits the FIRST descriptor of a name and refuses only the REPEAT, so a server that
|
|
54
|
+
* advertises `image_lookup` twice puts that one name into `admitted` AND into `refused`. Every
|
|
55
|
+
* sentence {@link FederationRefusalMessages.prefixInstruction} attaches to an item — "NOT in `search_tools`",
|
|
56
|
+
* "`describe_tool` cannot describe them", "calling them is impossible" — is then false, and the
|
|
57
|
+
* model was told so on every turn for the life of the process, about a tool it can call. That
|
|
58
|
+
* is the invented-cause failure this whole file exists to prevent, produced by this file.
|
|
59
|
+
*
|
|
60
|
+
* Subtracting the ADMITTED SET rather than special-casing `duplicate-remote-tool-name` is
|
|
61
|
+
* deliberate: the property that makes an item true is "the model cannot call this", and the
|
|
62
|
+
* admitted set is what answers that directly. A reason-based skip would state the same rule in
|
|
63
|
+
* terms that stop being equivalent the moment the gate grows another refusal an admitted name
|
|
64
|
+
* can also collect. A duplicate whose name was NOT admitted (both descriptors refused) is
|
|
65
|
+
* still reported, correctly — the operator-facing admission report already computes unavailable names this same way, and this
|
|
66
|
+
* brings the model-facing arm to the accounting the operator-facing one already had.
|
|
67
|
+
*
|
|
68
|
+
* Per connection, never global: a federated tool id is namespaced by `connectionId`, so
|
|
69
|
+
* `vendor:image_lookup` being callable says nothing about `other-vendor:image_lookup`.
|
|
70
|
+
*/
|
|
71
|
+
function refusalItems(entry, messages) {
|
|
72
|
+
const admittedNames = new Set(entry.report.admitted.map((tool) => tool.remoteName));
|
|
73
|
+
const items = [];
|
|
74
|
+
// One combined `continue` rather than two, and a loop rather than `filter().map()`: falling past
|
|
75
|
+
// this guard is what narrows `refusal.reason` out of `not-in-operator-allowlist`, even though
|
|
76
|
+
// the host messages also carry that reason for attempt-time diagnosis. A `filter` predicate cannot narrow the element
|
|
77
|
+
// type for a following `map`, so that shape needs a cast to compile — a cast standing in for a
|
|
78
|
+
// guarantee the control flow already provides.
|
|
79
|
+
for (const refusal of entry.report.refused) {
|
|
80
|
+
if (refusal.reason === "not-in-operator-allowlist" || admittedNames.has(refusal.remoteName))
|
|
81
|
+
continue;
|
|
82
|
+
items.push({
|
|
83
|
+
connectionId: entry.connectionId,
|
|
84
|
+
remoteName: safeRemoteName({ remoteName: refusal.remoteName, messages: messages }),
|
|
85
|
+
kind: "refused",
|
|
86
|
+
reason: refusal.reason,
|
|
87
|
+
explanation: messages.refusalExplanations[refusal.reason],
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return items;
|
|
91
|
+
}
|
|
92
|
+
/** One connection's two config-drift lists — `trust.ts`'s own `allowlistedButAbsent` /
|
|
93
|
+
* `writeAllowedButNotAllowlisted`, which exist precisely so config that can never take effect is
|
|
94
|
+
* reported rather than silently inert. This file is where that reporting finally reaches a reader
|
|
95
|
+
* who is not tailing a terminal. */
|
|
96
|
+
function driftItems(entry, messages) {
|
|
97
|
+
const absent = entry.report.allowlistedButAbsent.map((name) => ({
|
|
98
|
+
connectionId: entry.connectionId,
|
|
99
|
+
remoteName: safeRemoteName({ remoteName: name, messages: messages }),
|
|
100
|
+
kind: "allowlisted-but-absent",
|
|
101
|
+
reason: null,
|
|
102
|
+
explanation: messages.absentExplanation,
|
|
103
|
+
}));
|
|
104
|
+
const inert = entry.report.writeAllowedButNotAllowlisted.map((name) => ({
|
|
105
|
+
connectionId: entry.connectionId,
|
|
106
|
+
remoteName: safeRemoteName({ remoteName: name, messages: messages }),
|
|
107
|
+
kind: "write-allowed-but-not-allowlisted",
|
|
108
|
+
reason: null,
|
|
109
|
+
explanation: messages.inertWriteGrantExplanation,
|
|
110
|
+
}));
|
|
111
|
+
return [...absent, ...inert];
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Every place this boot's admitted set disagrees with what the operator asked for.
|
|
115
|
+
*
|
|
116
|
+
* Shared by the prompt prefix below and by any operator-facing consumer that wants the same
|
|
117
|
+
* accounting as data rather than prose — one derivation, so the two can never disagree about which
|
|
118
|
+
* refusals count, the same property `classifyRemoteToolSurface` gives `trust.ts`.
|
|
119
|
+
*
|
|
120
|
+
* @param snapshot - `AttachFederatedToolsResult.reports`, verbatim.
|
|
121
|
+
* @returns Items in connection order, refusals before drift within each connection.
|
|
122
|
+
* @complexity O(c · t) in connections and their admitted/refused/drift entries — the per-connection
|
|
123
|
+
* admitted-name set R-E subtracts against is built once per connection, not per refusal.
|
|
124
|
+
* See docs/decisions/DR-001-federation-admission.md.
|
|
125
|
+
*/
|
|
126
|
+
export function summarizeFederatedRefusals({ snapshot, messages }) {
|
|
127
|
+
return snapshot.flatMap((entry) => [...refusalItems(entry, messages), ...driftItems(entry, messages)]);
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Resolves ONE attempted tool id against a boot's admission snapshot, for the caller that actually
|
|
131
|
+
* tried to run it — the call-time half of "every refusal is reportable, never silent" (R-B only ever
|
|
132
|
+
* silences the boot-time ENUMERATION of a routine default-deny, never an attempt naming the tool by
|
|
133
|
+
* id).
|
|
134
|
+
*
|
|
135
|
+
* Matches by re-minting {@link federatedToolId} for every refused `(connectionId, remoteName)` pair
|
|
136
|
+
* rather than parsing `toolId` apart: the mint is the one place that format is defined, and a second,
|
|
137
|
+
* independent parser is a second place the two could silently drift.
|
|
138
|
+
*
|
|
139
|
+
* @param toolId - The exact id a call attempted, e.g. `mcp__higgsfield__tiktok_publish`.
|
|
140
|
+
* @param snapshot - `AttachFederatedToolsResult.reports`, verbatim — the same shape
|
|
141
|
+
* {@link summarizeFederatedRefusals} accepts.
|
|
142
|
+
* @returns `null` when `toolId` is not a federated id this boot ever refused — either it was
|
|
143
|
+
* admitted (a caller reaching this function for an admitted id is a bug one layer up: an admitted
|
|
144
|
+
* tool is registered and its call would never throw `unknown tool` in the first place), or it is
|
|
145
|
+
* not a federated tool id this boot knows about at all (a native tool, or a genuinely
|
|
146
|
+
* unknown/hallucinated one).
|
|
147
|
+
* @complexity O(c · r) in connections and their refusals — the same bound
|
|
148
|
+
* {@link summarizeFederatedRefusals} accepts for the boot-time reduction, run once per failed call
|
|
149
|
+
* here rather than once per boot.
|
|
150
|
+
*/
|
|
151
|
+
export function findFederatedToolRefusal({ toolId, snapshot, messages }) {
|
|
152
|
+
for (const entry of snapshot) {
|
|
153
|
+
for (const refusal of entry.report.refused) {
|
|
154
|
+
if (federatedToolId({ connectionId: entry.connectionId, remoteName: refusal.remoteName }) !== toolId)
|
|
155
|
+
continue;
|
|
156
|
+
return {
|
|
157
|
+
connectionId: entry.connectionId,
|
|
158
|
+
remoteName: safeRemoteName({ remoteName: refusal.remoteName, messages: messages }),
|
|
159
|
+
reason: refusal.reason,
|
|
160
|
+
explanation: explainFederatedToolRefusal({ reason: refusal.reason, messages: messages }),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return null;
|
|
165
|
+
}
|
|
166
|
+
/** One rendered bullet. The connection id is operator-authored and pattern-validated by
|
|
167
|
+
* `assertValidConnectionId` before any tool from it is ever admitted, so it needs no sanitizing of
|
|
168
|
+
* its own; the remote name already went through {@link safeRemoteName}.
|
|
169
|
+
*
|
|
170
|
+
* The prompt prefix, or `""` when this boot has nothing to report (R-A).
|
|
171
|
+
*
|
|
172
|
+
* Prepended to a run's prompt by `agent-daemon-server.ts` through the same
|
|
173
|
+
* `assemblePromptWithPluginPrefix` seam the capability manifest and agent-plugin prefixes already
|
|
174
|
+
* use — deliberately not a new delivery mechanism, and deliberately not a tool: see this file's
|
|
175
|
+
* header for why a model that does not know it is missing something never calls the tool that
|
|
176
|
+
* would tell it.
|
|
177
|
+
*
|
|
178
|
+
* @param snapshot - `AttachFederatedToolsResult.reports`, verbatim.
|
|
179
|
+
* @returns Prompt text, bounded per R-D, with the true total announced when the list is clipped.
|
|
180
|
+
* @complexity O(c · t), plus O(n) rendering bounded by {@link MAX_ITEMS_IN_PREFIX}.
|
|
181
|
+
*/
|
|
182
|
+
export function buildFederatedRefusalPrefix({ snapshot, messages }) {
|
|
183
|
+
const items = summarizeFederatedRefusals({ snapshot: snapshot, messages: messages });
|
|
184
|
+
if (items.length === 0)
|
|
185
|
+
return "";
|
|
186
|
+
const shown = items.slice(0, MAX_ITEMS_IN_PREFIX);
|
|
187
|
+
const lines = [messages.prefixHeading, "", messages.prefixInstruction, "", ...shown.map(item => messages.refusalBullet(item))];
|
|
188
|
+
if (items.length > shown.length) {
|
|
189
|
+
lines.push(messages.omittedRefusals({ omitted: items.length - shown.length, total: items.length }));
|
|
190
|
+
}
|
|
191
|
+
return lines.join("\n");
|
|
192
|
+
}
|
|
193
|
+
//# sourceMappingURL=refusal-notice.js.map
|