@jini-ai/mcp 0.4.3 → 0.6.10
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 +76 -0
- package/dist/agent-install/install.js +102 -194
- package/dist/bin/serve.d.ts +4 -1
- package/dist/bin/serve.js +115 -43
- package/dist/client/client.js +71 -58
- package/dist/core/config.js +113 -149
- package/dist/core/tokens.js +29 -53
- package/dist/federation/adapter.http.js +3 -2
- package/dist/federation/approvals/call-confirmation.js +5 -70
- package/dist/federation/approvals/remembered-approval.d.ts +39 -0
- package/dist/federation/approvals/remembered-approval.js +98 -0
- package/dist/federation/approvals/revocation-reason.d.ts +9 -0
- package/dist/federation/approvals/revocation-reason.js +11 -0
- package/dist/federation/approvals/revocation.js +19 -13
- package/dist/federation/approvals/scope-option.d.ts +6 -0
- package/dist/federation/approvals/scope-option.js +6 -0
- package/dist/federation/stdio/adapter.stdio.js +14 -6
- package/dist/federation/stdio/default-connect.js +24 -27
- package/dist/federation/stdio/stdio-launch-resolver.d.ts +1 -1
- package/dist/federation/stdio/stdio-launch-resolver.js +13 -9
- package/dist/federation/tool-approvals.js +3 -1
- package/dist/federation/trust.d.ts +23 -2
- package/dist/federation/trust.js +71 -36
- package/dist/server/daemon-client.d.ts +2 -0
- package/dist/server/daemon-client.js +86 -69
- package/dist/server/tool-arguments.d.ts +46 -0
- package/dist/server/tool-arguments.js +111 -0
- package/dist/server/tool-protocol.d.ts +62 -7
- package/dist/server/tool-protocol.js +30 -40
- package/dist/server/tool-server.d.ts +19 -0
- package/dist/server/tool-server.js +91 -19
- package/dist/server/tools/delegated-tool.d.ts +14 -0
- package/dist/server/tools/delegated-tool.js +43 -4
- package/dist/server/tools/tool-catalog-tools.js +30 -8
- package/dist/tools/ask-choice/index.d.ts +213 -14
- package/dist/tools/ask-choice/index.js +90 -46
- package/dist/tools/ask-choice/question.d.ts +5 -1
- package/dist/tools/ask-choice/question.js +36 -2
- package/dist/tools/ask-choice/schema.d.ts +196 -10
- package/dist/tools/ask-choice/schema.js +33 -12
- package/dist/tools/ask-choice/types.d.ts +8 -0
- package/package.json +7 -5
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { federatedToolApprovalFingerprint } from "../tool-approvals.js";
|
|
1
|
+
import { approvalContext, cardOffers, isRemembered, rememberApproval } from "./remembered-approval.js";
|
|
3
2
|
const INLINE_ARGUMENT_MAX_LENGTH = 60;
|
|
4
3
|
function argumentDetail(name, value) {
|
|
5
4
|
if (typeof value === "string") {
|
|
@@ -17,7 +16,7 @@ export function buildFederatedCallConfirmSpec({ request, messages, errorCode },
|
|
|
17
16
|
if (!request.destructive && request.writeShapedInputs.length === 0) {
|
|
18
17
|
if (offers.offerChat)
|
|
19
18
|
alternatives.push({ id: "allow-chat", label: messages.allowChatLabel, choice: "chat" });
|
|
20
|
-
if (offers.offerAlways
|
|
19
|
+
if (offers.offerAlways)
|
|
21
20
|
alternatives.push({ id: "allow-always", label: messages.allowAlwaysLabel, choice: "always" });
|
|
22
21
|
}
|
|
23
22
|
return {
|
|
@@ -29,66 +28,6 @@ export function buildFederatedCallConfirmSpec({ request, messages, errorCode },
|
|
|
29
28
|
danger: request.destructive, confirmLabel: messages.confirmLabel, ...(alternatives.length ? { alternatives } : {}),
|
|
30
29
|
};
|
|
31
30
|
}
|
|
32
|
-
function approvalContext(context, request, approvals, fingerprintDomain) {
|
|
33
|
-
const fingerprint = federatedToolApprovalFingerprint({ identity: request, fingerprintDomain });
|
|
34
|
-
const conversationId = approvals?.conversationIdForRun?.({ runId: context.run.id });
|
|
35
|
-
return {
|
|
36
|
-
fingerprint,
|
|
37
|
-
chatKey: approvals?.chat && conversationId ? { conversationId, principalId: context.principal.id, connectionId: request.connectionId, toolName: request.remoteName, fingerprint } : undefined,
|
|
38
|
-
alwaysKey: approvals?.always && request.origin?.kind === "roster" ? { serverId: request.connectionId, toolName: request.remoteName } : undefined,
|
|
39
|
-
};
|
|
40
|
-
}
|
|
41
|
-
async function isRemembered(approvals, request, keys, options) {
|
|
42
|
-
if (request.writeShapedInputs.length > 0)
|
|
43
|
-
return false;
|
|
44
|
-
if (keys.alwaysKey && approvals.always) {
|
|
45
|
-
const saved = await approvals.always.find(keys.alwaysKey, options.scope === undefined ? {} : { scope: options.scope });
|
|
46
|
-
if (saved && saved.fingerprint === keys.fingerprint && !request.destructive)
|
|
47
|
-
return true;
|
|
48
|
-
if (saved)
|
|
49
|
-
await approvals.always.delete(keys.alwaysKey, options.scope === undefined ? {} : { scope: options.scope });
|
|
50
|
-
}
|
|
51
|
-
// Neither a matching chat grant nor an always grant can authorize permanent deletion.
|
|
52
|
-
if (request.destructive)
|
|
53
|
-
return false;
|
|
54
|
-
return keys.chatKey !== undefined && approvals.chat !== undefined && await approvals.chat.has(keys.chatKey);
|
|
55
|
-
}
|
|
56
|
-
function report(options, diagnostic) {
|
|
57
|
-
// Observability must never turn an explicit approval into a failed mutation.
|
|
58
|
-
try {
|
|
59
|
-
options.onPersistenceError?.(diagnostic);
|
|
60
|
-
}
|
|
61
|
-
catch { /* Host diagnostic failures are isolated. */ }
|
|
62
|
-
}
|
|
63
|
-
async function remember(input) {
|
|
64
|
-
const { context, request, keys, approvals, choice, options } = input;
|
|
65
|
-
// Defense in depth: a forged remember choice must not persist a destructive call's approval.
|
|
66
|
-
if (request.destructive || request.writeShapedInputs.length > 0)
|
|
67
|
-
return;
|
|
68
|
-
let grantedAt;
|
|
69
|
-
try {
|
|
70
|
-
grantedAt = nowIso({ clock: approvals.clock });
|
|
71
|
-
if (choice === "chat" && keys.chatKey && approvals.chat)
|
|
72
|
-
await approvals.chat.grant({ key: keys.chatKey, grantedAt });
|
|
73
|
-
else if (choice === "always" && keys.alwaysKey && approvals.always)
|
|
74
|
-
await approvals.always.upsert({
|
|
75
|
-
...keys.alwaysKey, fingerprint: keys.fingerprint, grantedByPrincipalId: context.principal.id, grantedAt,
|
|
76
|
-
}, options.scope === undefined ? {} : { scope: options.scope });
|
|
77
|
-
else
|
|
78
|
-
return;
|
|
79
|
-
}
|
|
80
|
-
catch (error) {
|
|
81
|
-
report(options, { toolId: request.toolId, choice, stage: "store", error });
|
|
82
|
-
return;
|
|
83
|
-
}
|
|
84
|
-
try {
|
|
85
|
-
await options.webhooks?.approvalRemembered({ principalId: context.principal.id,
|
|
86
|
-
connectionId: request.connectionId, toolName: request.remoteName, fingerprint: keys.fingerprint, scope: choice, grantedAt }, options.scope === undefined ? {} : { scope: options.scope });
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
report(options, { toolId: request.toolId, choice, stage: "webhook", error });
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
31
|
/** Remembered-approval orchestration over host-owned storage, presentation and permission ports.
|
|
93
32
|
* The host adapts the returned object-argument function to its federation runtime callback.
|
|
94
33
|
* Lookup failures fail closed. Save failures allow the approved call and are reported via a port.
|
|
@@ -105,19 +44,15 @@ export function createFederatedCallConfirmer(required, options = {}) {
|
|
|
105
44
|
const keys = approvalContext(context, request, approvals, required.fingerprintDomain);
|
|
106
45
|
if (approvals && await isRemembered(approvals, request, keys, options))
|
|
107
46
|
return { confirmed: true };
|
|
108
|
-
const
|
|
109
|
-
const
|
|
110
|
-
&& await approvals.mayAlwaysAllow({ principalId: context.principal.id }, options.scope === undefined ? {} : { scope: options.scope });
|
|
111
|
-
const spec = buildFederatedCallConfirmSpec({ request, messages: required.messages, errorCode: required.errorCode }, {
|
|
112
|
-
offers: { offerChat: rememberable && keys.chatKey !== undefined, offerAlways },
|
|
113
|
-
});
|
|
47
|
+
const offers = await cardOffers({ request, principalId: context.principal.id, keys, approvals, options });
|
|
48
|
+
const spec = buildFederatedCallConfirmSpec({ request, messages: required.messages, errorCode: required.errorCode }, { offers });
|
|
114
49
|
const outcome = await required.humanConfirm.ask({ context, surfaceExchanges: required.surfaceExchanges, spec });
|
|
115
50
|
if (!outcome.confirmed)
|
|
116
51
|
return outcome;
|
|
117
52
|
// Never trust a remember scope the host did not offer, even with a valid one-call answer.
|
|
118
53
|
const choice = spec.alternatives?.find(alternative => alternative.choice === outcome.choice)?.choice;
|
|
119
54
|
if (approvals && choice)
|
|
120
|
-
await
|
|
55
|
+
await rememberApproval({ context, request, keys, approvals, choice, options });
|
|
121
56
|
return { confirmed: true };
|
|
122
57
|
};
|
|
123
58
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { FederatedCallConfirmationRequest } from "../ports.js";
|
|
2
|
+
import type { ConversationToolApprovalKey } from "../tool-approvals.js";
|
|
3
|
+
import type { FederatedApprovalDeps, FederatedCallConfirmerOptions, FederatedCardOffers, FederatedConfirmationContext } from "./ports.js";
|
|
4
|
+
/** The lookup keys one call's remembered approvals are stored under. A key is absent when the
|
|
5
|
+
* host cannot persist that scope: no store, no conversation, or (always) not a roster origin. */
|
|
6
|
+
export interface ApprovalContext {
|
|
7
|
+
readonly fingerprint: string;
|
|
8
|
+
readonly chatKey: ConversationToolApprovalKey | undefined;
|
|
9
|
+
readonly alwaysKey: {
|
|
10
|
+
serverId: string;
|
|
11
|
+
toolName: string;
|
|
12
|
+
} | undefined;
|
|
13
|
+
}
|
|
14
|
+
/** @complexity O(1) beyond the fingerprint hash. */
|
|
15
|
+
export declare function approvalContext(context: FederatedConfirmationContext, request: FederatedCallConfirmationRequest, approvals: FederatedApprovalDeps | undefined, fingerprintDomain: string): ApprovalContext;
|
|
16
|
+
/** Whether a remembered grant lets this call skip its card. A write-shaped call never does. */
|
|
17
|
+
export declare function isRemembered(approvals: FederatedApprovalDeps, request: FederatedCallConfirmationRequest, keys: ApprovalContext, options: FederatedCallConfirmerOptions): Promise<boolean>;
|
|
18
|
+
/** The remember buttons this call's card may offer. A destructive or write-shaped call offers
|
|
19
|
+
* none, and the host's always permission is then not even consulted. */
|
|
20
|
+
export declare function cardOffers({ request, principalId, keys, approvals, options }: {
|
|
21
|
+
request: FederatedCallConfirmationRequest;
|
|
22
|
+
principalId: string;
|
|
23
|
+
keys: ApprovalContext;
|
|
24
|
+
approvals: FederatedApprovalDeps | undefined;
|
|
25
|
+
options: FederatedCallConfirmerOptions;
|
|
26
|
+
}): Promise<FederatedCardOffers>;
|
|
27
|
+
interface RememberInput {
|
|
28
|
+
context: FederatedConfirmationContext;
|
|
29
|
+
request: FederatedCallConfirmationRequest;
|
|
30
|
+
keys: ApprovalContext;
|
|
31
|
+
approvals: FederatedApprovalDeps;
|
|
32
|
+
choice: "chat" | "always";
|
|
33
|
+
options: FederatedCallConfirmerOptions;
|
|
34
|
+
}
|
|
35
|
+
/** Saves one explicitly chosen grant, then notifies. Save and notification failures are reported
|
|
36
|
+
* via `onPersistenceError` and never fail the already approved call. */
|
|
37
|
+
export declare function rememberApproval(input: RememberInput): Promise<void>;
|
|
38
|
+
export {};
|
|
39
|
+
//# sourceMappingURL=remembered-approval.d.ts.map
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { nowIso } from "@jini-ai/core/primitives";
|
|
2
|
+
import { federatedToolApprovalFingerprint } from "../tool-approvals.js";
|
|
3
|
+
import { scopeOption } from "./scope-option.js";
|
|
4
|
+
/** @complexity O(1) beyond the fingerprint hash. */
|
|
5
|
+
export function approvalContext(context, request, approvals, fingerprintDomain) {
|
|
6
|
+
const fingerprint = federatedToolApprovalFingerprint({ identity: request, fingerprintDomain });
|
|
7
|
+
const conversationId = approvals?.conversationIdForRun?.({ runId: context.run.id });
|
|
8
|
+
return {
|
|
9
|
+
fingerprint,
|
|
10
|
+
chatKey: approvals?.chat && conversationId ? { conversationId, principalId: context.principal.id, connectionId: request.connectionId, toolName: request.remoteName, fingerprint } : undefined,
|
|
11
|
+
alwaysKey: approvals?.always && request.origin?.kind === "roster" ? { serverId: request.connectionId, toolName: request.remoteName } : undefined,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** Whether a saved always grant authorizes this call. A saved grant that does not (stale
|
|
15
|
+
* fingerprint, or a destructive call) is removed so the operator's list stops claiming it is
|
|
16
|
+
* still valid. Lookup and delete failures propagate: remembered lookups fail closed. */
|
|
17
|
+
async function alwaysGrantAuthorizes(approvals, request, keys, options) {
|
|
18
|
+
if (!keys.alwaysKey || !approvals.always)
|
|
19
|
+
return false;
|
|
20
|
+
const saved = await approvals.always.find(keys.alwaysKey, scopeOption(options.scope));
|
|
21
|
+
if (saved && saved.fingerprint === keys.fingerprint && !request.destructive)
|
|
22
|
+
return true;
|
|
23
|
+
if (saved)
|
|
24
|
+
await approvals.always.delete(keys.alwaysKey, scopeOption(options.scope));
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
/** Whether a remembered grant lets this call skip its card. A write-shaped call never does. */
|
|
28
|
+
export async function isRemembered(approvals, request, keys, options) {
|
|
29
|
+
if (request.writeShapedInputs.length > 0)
|
|
30
|
+
return false;
|
|
31
|
+
if (await alwaysGrantAuthorizes(approvals, request, keys, options))
|
|
32
|
+
return true;
|
|
33
|
+
// Neither a matching chat grant nor an always grant can authorize permanent deletion.
|
|
34
|
+
if (request.destructive)
|
|
35
|
+
return false;
|
|
36
|
+
return keys.chatKey !== undefined && approvals.chat !== undefined && await approvals.chat.has(keys.chatKey);
|
|
37
|
+
}
|
|
38
|
+
/** Whether the card may offer "always": only with an always key, approval deps, and the host's
|
|
39
|
+
* per-principal permission. {@link cardOffers} has already excluded destructive and write-shaped calls. */
|
|
40
|
+
async function mayOfferAlways({ principalId, keys, approvals, options }) {
|
|
41
|
+
if (keys.alwaysKey === undefined || approvals === undefined)
|
|
42
|
+
return false;
|
|
43
|
+
return approvals.mayAlwaysAllow({ principalId }, scopeOption(options.scope));
|
|
44
|
+
}
|
|
45
|
+
/** The remember buttons this call's card may offer. A destructive or write-shaped call offers
|
|
46
|
+
* none, and the host's always permission is then not even consulted. */
|
|
47
|
+
export async function cardOffers({ request, principalId, keys, approvals, options }) {
|
|
48
|
+
if (request.destructive || request.writeShapedInputs.length > 0)
|
|
49
|
+
return { offerChat: false, offerAlways: false };
|
|
50
|
+
return { offerChat: keys.chatKey !== undefined, offerAlways: await mayOfferAlways({ principalId, keys, approvals, options }) };
|
|
51
|
+
}
|
|
52
|
+
function report(options, diagnostic) {
|
|
53
|
+
// Observability must never turn an explicit approval into a failed mutation.
|
|
54
|
+
try {
|
|
55
|
+
options.onPersistenceError?.(diagnostic);
|
|
56
|
+
}
|
|
57
|
+
catch { /* Host diagnostic failures are isolated. */ }
|
|
58
|
+
}
|
|
59
|
+
/** Writes the chosen grant; `false` when that scope has no key or store, so nothing was saved. */
|
|
60
|
+
async function persistGrant({ context, keys, approvals, choice, options }, grantedAt) {
|
|
61
|
+
if (choice === "chat" && keys.chatKey && approvals.chat) {
|
|
62
|
+
await approvals.chat.grant({ key: keys.chatKey, grantedAt });
|
|
63
|
+
return true;
|
|
64
|
+
}
|
|
65
|
+
if (choice === "always" && keys.alwaysKey && approvals.always) {
|
|
66
|
+
await approvals.always.upsert({
|
|
67
|
+
...keys.alwaysKey, fingerprint: keys.fingerprint, grantedByPrincipalId: context.principal.id, grantedAt,
|
|
68
|
+
}, scopeOption(options.scope));
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
/** Saves one explicitly chosen grant, then notifies. Save and notification failures are reported
|
|
74
|
+
* via `onPersistenceError` and never fail the already approved call. */
|
|
75
|
+
export async function rememberApproval(input) {
|
|
76
|
+
const { context, request, keys, choice, options } = input;
|
|
77
|
+
// Defense in depth: a forged remember choice must not persist a destructive call's approval.
|
|
78
|
+
if (request.destructive || request.writeShapedInputs.length > 0)
|
|
79
|
+
return;
|
|
80
|
+
let grantedAt;
|
|
81
|
+
try {
|
|
82
|
+
grantedAt = nowIso({ clock: input.approvals.clock });
|
|
83
|
+
if (!await persistGrant(input, grantedAt))
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
catch (error) {
|
|
87
|
+
report(options, { toolId: request.toolId, choice, stage: "store", error });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
try {
|
|
91
|
+
await options.webhooks?.approvalRemembered({ principalId: context.principal.id,
|
|
92
|
+
connectionId: request.connectionId, toolName: request.remoteName, fingerprint: keys.fingerprint, scope: choice, grantedAt }, scopeOption(options.scope));
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
report(options, { toolId: request.toolId, choice, stage: "webhook", error });
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
//# sourceMappingURL=remembered-approval.js.map
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ToolRefusalReason } from "../trust.js";
|
|
2
|
+
import type { ExternalMcpRevocationReason } from "./revocation.js";
|
|
3
|
+
/** Maps an admitted tool's refusal under the row's current grants to the revocation reason the
|
|
4
|
+
* gate reports. `refusalForAdmittedToolUnderCurrentGrants` no longer produces
|
|
5
|
+
* `"remote-declares-not-read-only"` (hints never refuse since G3), but the union still carries it,
|
|
6
|
+
* so the mapping keeps it rather than collapsing a write refusal into "tool-not-allowed".
|
|
7
|
+
* @complexity O(1). */
|
|
8
|
+
export declare function revocationReasonForToolRefusal(refusal: ToolRefusalReason | null): ExternalMcpRevocationReason | null;
|
|
9
|
+
//# sourceMappingURL=revocation-reason.d.ts.map
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Maps an admitted tool's refusal under the row's current grants to the revocation reason the
|
|
2
|
+
* gate reports. `refusalForAdmittedToolUnderCurrentGrants` no longer produces
|
|
3
|
+
* `"remote-declares-not-read-only"` (hints never refuse since G3), but the union still carries it,
|
|
4
|
+
* so the mapping keeps it rather than collapsing a write refusal into "tool-not-allowed".
|
|
5
|
+
* @complexity O(1). */
|
|
6
|
+
export function revocationReasonForToolRefusal(refusal) {
|
|
7
|
+
if (refusal === null)
|
|
8
|
+
return null;
|
|
9
|
+
return refusal === "remote-declares-not-read-only" ? "write-not-allowed" : "tool-not-allowed";
|
|
10
|
+
}
|
|
11
|
+
//# sourceMappingURL=revocation-reason.js.map
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { refusalForAdmittedToolUnderCurrentGrants } from "../trust.js";
|
|
2
|
+
import { revocationReasonForToolRefusal } from "./revocation-reason.js";
|
|
3
|
+
import { scopeOption } from "./scope-option.js";
|
|
2
4
|
/** Pure current-row check. Ordering matches admission semantics: identity before mutable grants. */
|
|
3
5
|
export function rosterRefusalFor({ record, call }) {
|
|
4
6
|
if (call.origin === undefined || call.origin.kind === "preset")
|
|
@@ -11,8 +13,21 @@ export function rosterRefusalFor({ record, call }) {
|
|
|
11
13
|
return "turned-off";
|
|
12
14
|
if (record.disconnected)
|
|
13
15
|
return "disconnected";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
+
return revocationReasonForToolRefusal(refusalForAdmittedToolUnderCurrentGrants({ tool: call, grants: record.grants }));
|
|
17
|
+
}
|
|
18
|
+
/** One roster read plus its refusal. A failed read or check is reported and fails closed as
|
|
19
|
+
* `"unverifiable"`, so the driver error never reaches the refusal. @complexity O(1). */
|
|
20
|
+
async function readRosterRefusal({ roster, connectionId, call }, { scope, report }) {
|
|
21
|
+
// A row that was read but cannot be judged keeps its label on the refusal.
|
|
22
|
+
let record = null;
|
|
23
|
+
try {
|
|
24
|
+
record = await roster.findByServerId({ serverId: connectionId }, scopeOption(scope));
|
|
25
|
+
return { record, reason: rosterRefusalFor({ record, call }) };
|
|
26
|
+
}
|
|
27
|
+
catch (error) {
|
|
28
|
+
report({ connectionId, stage: "store", error });
|
|
29
|
+
return { record, reason: "unverifiable" };
|
|
30
|
+
}
|
|
16
31
|
}
|
|
17
32
|
/** Revocation half of a connection gate; credential refresh/reauth checks are composed by the host.
|
|
18
33
|
* Presets have no roster row. Reads and notification failures never disclose driver errors.
|
|
@@ -27,21 +42,12 @@ export function createFederatedConnectionRevocationGate(required, optional = {})
|
|
|
27
42
|
return async ({ connectionId, call }) => {
|
|
28
43
|
if (call.origin?.kind === "preset")
|
|
29
44
|
return;
|
|
30
|
-
|
|
31
|
-
let reason;
|
|
32
|
-
try {
|
|
33
|
-
record = await required.roster.findByServerId({ serverId: connectionId }, optional.scope === undefined ? {} : { scope: optional.scope });
|
|
34
|
-
reason = rosterRefusalFor({ record, call });
|
|
35
|
-
}
|
|
36
|
-
catch (error) {
|
|
37
|
-
report({ connectionId, stage: "store", error });
|
|
38
|
-
reason = "unverifiable";
|
|
39
|
-
}
|
|
45
|
+
const { record, reason } = await readRosterRefusal({ roster: required.roster, connectionId, call }, { scope: optional.scope, report });
|
|
40
46
|
if (reason === null)
|
|
41
47
|
return;
|
|
42
48
|
const refusal = { serverId: connectionId, label: record?.label ?? null, remoteName: call.remoteName, reason };
|
|
43
49
|
try {
|
|
44
|
-
await optional.webhooks?.connectionRefused(refusal, optional.scope
|
|
50
|
+
await optional.webhooks?.connectionRefused(refusal, scopeOption(optional.scope));
|
|
45
51
|
}
|
|
46
52
|
catch (error) {
|
|
47
53
|
report({ connectionId, stage: "webhook", error });
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The opaque host storage partition as a port's options object: `{}` when absent, so a port
|
|
2
|
+
* never receives `{ scope: undefined }`. @complexity O(1). */
|
|
3
|
+
export declare function scopeOption(scope: string | undefined): {
|
|
4
|
+
scope?: string;
|
|
5
|
+
};
|
|
6
|
+
//# sourceMappingURL=scope-option.d.ts.map
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** The opaque host storage partition as a port's options object: `{}` when absent, so a port
|
|
2
|
+
* never receives `{ scope: undefined }`. @complexity O(1). */
|
|
3
|
+
export function scopeOption(scope) {
|
|
4
|
+
return scope === undefined ? {} : { scope };
|
|
5
|
+
}
|
|
6
|
+
//# sourceMappingURL=scope-option.js.map
|
|
@@ -493,19 +493,27 @@ const ELECTRON_RUN_AS_NODE = "ELECTRON_RUN_AS_NODE";
|
|
|
493
493
|
* @complexity O(k) in the number of inherited names.
|
|
494
494
|
*/
|
|
495
495
|
export function buildMcpChildEnv({ command, specEnv, }, { launchEnv = {}, platform = process.platform, parentEnv = process.env, execPath = process.execPath, } = {}) {
|
|
496
|
-
|
|
497
|
-
|
|
496
|
+
return { ...inheritedParentEnv(parentEnv, platform), ...carriedRunMode({ command, platform, parentEnv, execPath }), ...launchEnv, ...specEnv };
|
|
497
|
+
}
|
|
498
|
+
/** The allowlisted parent variables ({@link INHERITED_ENV_VARS}, plus
|
|
499
|
+
* {@link WIN32_INHERITED_ENV_VARS} on win32). Empty parent values are skipped. @complexity O(k). */
|
|
500
|
+
function inheritedParentEnv(parentEnv, platform) {
|
|
501
|
+
const names = platform === "win32" ? [...INHERITED_ENV_VARS, ...WIN32_INHERITED_ENV_VARS] : INHERITED_ENV_VARS;
|
|
498
502
|
const inherited = {};
|
|
499
503
|
for (const name of names) {
|
|
500
504
|
const value = parentEnv[name];
|
|
501
505
|
if (typeof value === "string" && value.length > 0)
|
|
502
506
|
inherited[name] = value;
|
|
503
507
|
}
|
|
508
|
+
return inherited;
|
|
509
|
+
}
|
|
510
|
+
/** `ELECTRON_RUN_AS_NODE` for a win32 child that is this very executable while this process runs
|
|
511
|
+
* with it set (see {@link buildMcpChildEnv}); `{}` for every other child. @complexity O(n) in the path length. */
|
|
512
|
+
function carriedRunMode({ command, platform, parentEnv, execPath }) {
|
|
504
513
|
const runMode = parentEnv[ELECTRON_RUN_AS_NODE];
|
|
505
|
-
if (
|
|
506
|
-
|
|
507
|
-
}
|
|
508
|
-
return { ...inherited, ...launchEnv, ...specEnv };
|
|
514
|
+
if (platform !== "win32" || typeof runMode !== "string" || runMode.length === 0)
|
|
515
|
+
return {};
|
|
516
|
+
return isSameWin32Path(command, execPath) ? { [ELECTRON_RUN_AS_NODE]: runMode } : {};
|
|
509
517
|
}
|
|
510
518
|
/** Whether two win32 paths name the same file: resolved, then compared case-insensitively, as
|
|
511
519
|
* Windows' own file system does. @complexity O(n) in the path length. */
|
|
@@ -58,35 +58,32 @@ async function connectMcpStdioSessionWithSpawnTimeout(connection, launch, resolv
|
|
|
58
58
|
const timeoutError = () => new Error(`connect timed out after ${timeoutMs}ms`);
|
|
59
59
|
let channel;
|
|
60
60
|
let timer;
|
|
61
|
-
try
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
})
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
61
|
+
// Promise `.catch`/`.finally` rather than try/catch/finally: same settle semantics, and no
|
|
62
|
+
// statement follows the rethrow, so the coverage report has no phantom post-throw range.
|
|
63
|
+
return Promise.race([
|
|
64
|
+
Promise.resolve().then(() => {
|
|
65
|
+
// Synchronous ports cannot be interrupted by a JS timer. Check elapsed time after each
|
|
66
|
+
// returns so a slow resolver cannot spawn, and a late spawn cannot begin the handshake.
|
|
67
|
+
const resolved = resolver.resolve({ spec: launch });
|
|
68
|
+
if (Date.now() >= deadline)
|
|
69
|
+
throw timeoutError();
|
|
70
|
+
channel = spawnChannel({ resolved });
|
|
71
|
+
if (Date.now() >= deadline)
|
|
72
|
+
throw timeoutError();
|
|
73
|
+
return connectMcpStdioSession({ channel, requestTimeoutMs: connection.config.callTimeoutMs, clientInfo, messages });
|
|
74
|
+
}),
|
|
75
|
+
// Not `unref()`ed, for the same reason as `adapter.stdio.ts`'s per-request timer: an unref'd
|
|
76
|
+
// timer would let the loop drain and abandon this race unsettled instead of rejecting. The
|
|
77
|
+
// `finally` below clears it either way, so it never outlives the connect attempt.
|
|
78
|
+
new Promise((_resolve, reject) => {
|
|
79
|
+
timer = setTimeout(() => reject(timeoutError()), timeoutMs);
|
|
80
|
+
}),
|
|
81
|
+
])
|
|
82
|
+
.catch((error) => {
|
|
83
83
|
// Whichever branch lost, the child process must not survive it.
|
|
84
84
|
channel?.close({});
|
|
85
85
|
throw error;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
if (timer)
|
|
89
|
-
clearTimeout(timer);
|
|
90
|
-
}
|
|
86
|
+
})
|
|
87
|
+
.finally(() => clearTimeout(timer));
|
|
91
88
|
}
|
|
92
89
|
//# sourceMappingURL=default-connect.js.map
|
|
@@ -74,8 +74,8 @@ export declare const IDENTITY_STDIO_LAUNCH_RESOLVER: McpStdioLaunchResolver;
|
|
|
74
74
|
/** {@link createBundledNodeLaunchResolver}'s config. Only `toolchainDir` and `npmRoot` are ever
|
|
75
75
|
* passed in production; the rest are test seams that default to the real process. */
|
|
76
76
|
export interface BundledNodeLaunchResolverConfig {
|
|
77
|
-
/** `toolchain env option`: holds `bin/` (shims), `npm-cache/`, `npm-prefix/`. */
|
|
78
77
|
readonly messages: FederationMessages;
|
|
78
|
+
/** `toolchain env option`: holds `bin/` (shims), `npm-cache/`, `npm-prefix/`. */
|
|
79
79
|
readonly toolchainDir: string;
|
|
80
80
|
/** `npm root env option`: the npm package root; `<npmRoot>/bin/npx-cli.js` must exist (checked
|
|
81
81
|
* by {@link stdioLaunchResolverFromEnv} before this is constructed, not here). */
|
|
@@ -44,6 +44,8 @@ export const IDENTITY_STDIO_LAUNCH_RESOLVER = {
|
|
|
44
44
|
return { command: spec.command, args: spec.args, cwd: spec.cwd, env: spec.env, launchEnv: {} };
|
|
45
45
|
},
|
|
46
46
|
};
|
|
47
|
+
/** The npm CLI script each toolchain command runs on Electron's Node; `node` runs its args as-is. */
|
|
48
|
+
const NODE_TOOLCHAIN_CLI = { npx: "npx-cli.js", npm: "npm-cli.js", node: null };
|
|
47
49
|
/** @complexity O(1). */
|
|
48
50
|
function matchNodeToolchainCommand(command, platform) {
|
|
49
51
|
const isWin32 = platform === "win32";
|
|
@@ -133,6 +135,15 @@ function findOnSearchDirs(command, searchDirs, pathModule, isExecutable) {
|
|
|
133
135
|
function isPathCommand(command, pathModule) {
|
|
134
136
|
return pathModule.isAbsolute(command) || command.includes("/") || (pathModule === path.win32 && command.includes("\\"));
|
|
135
137
|
}
|
|
138
|
+
/** The executable a non-toolchain `command` names, or `null` when nothing executable is there. A
|
|
139
|
+
* path command is checked only where the child will exec it (never searched); a bare name is
|
|
140
|
+
* searched on `searchDirs`. @complexity O(n) in `searchDirs.length`. */
|
|
141
|
+
function findExecutable({ command, cwd }, { pathModule, searchDirs, isExecutable }) {
|
|
142
|
+
if (!isPathCommand(command, pathModule))
|
|
143
|
+
return findOnSearchDirs(command, searchDirs, pathModule, isExecutable);
|
|
144
|
+
// A relative path runs against the child's own cwd, so that is where it has to exist.
|
|
145
|
+
return isExecutable({ candidatePath: pathModule.resolve(cwd ?? process.cwd(), command) }) ? command : null;
|
|
146
|
+
}
|
|
136
147
|
/**
|
|
137
148
|
* The desktop resolver: rewrites `npx`/`npm`/`node` to run on Electron's own Node via the bundled
|
|
138
149
|
* npm package, resolves any other bare command (`uvx`, `docker`, …) against a PATH widened with
|
|
@@ -158,18 +169,11 @@ export function createBundledNodeLaunchResolver({ messages, toolchainDir, npmRoo
|
|
|
158
169
|
resolve({ spec }) {
|
|
159
170
|
const nodeToolchainCommand = matchNodeToolchainCommand(spec.command, platform);
|
|
160
171
|
if (nodeToolchainCommand !== null) {
|
|
161
|
-
const cli = nodeToolchainCommand
|
|
172
|
+
const cli = NODE_TOOLCHAIN_CLI[nodeToolchainCommand];
|
|
162
173
|
const args = cli === null ? [...spec.args] : [pathModule.join(npmRoot, "bin", cli), ...spec.args];
|
|
163
174
|
return { command: execPath, args, cwd: spec.cwd, env: spec.env, launchEnv: nodeToolchainLaunchEnv, requestedCommand: spec.command };
|
|
164
175
|
}
|
|
165
|
-
|
|
166
|
-
// A relative path runs against the child's own cwd, so that is where it has to exist.
|
|
167
|
-
if (!isExecutable({ candidatePath: pathModule.resolve(spec.cwd ?? process.cwd(), spec.command) })) {
|
|
168
|
-
throw new McpLaunchUnavailableError({ message: messages.launchUnavailable({ command: spec.command, searchedDirs: searchDirs }) });
|
|
169
|
-
}
|
|
170
|
-
return { command: spec.command, args: [...spec.args], cwd: spec.cwd, env: spec.env, launchEnv: otherLaunchEnv };
|
|
171
|
-
}
|
|
172
|
-
const found = findOnSearchDirs(spec.command, searchDirs, pathModule, isExecutable);
|
|
176
|
+
const found = findExecutable({ command: spec.command, cwd: spec.cwd }, { pathModule, searchDirs, isExecutable });
|
|
173
177
|
if (found === null) {
|
|
174
178
|
throw new McpLaunchUnavailableError({ message: messages.launchUnavailable({ command: spec.command, searchedDirs: searchDirs }) });
|
|
175
179
|
}
|
|
@@ -6,7 +6,9 @@ function canonicalJson(value) {
|
|
|
6
6
|
if (value !== null && typeof value === "object") {
|
|
7
7
|
const entries = Object.entries(value)
|
|
8
8
|
.filter(([, child]) => child !== undefined)
|
|
9
|
-
.
|
|
9
|
+
// Object.entries never yields the same key twice, so two keys are never equal and a
|
|
10
|
+
// two-way comparison is a total order here.
|
|
11
|
+
.sort(([a], [b]) => (a < b ? -1 : 1));
|
|
10
12
|
return `{${entries.map(([key, child]) => `${JSON.stringify(key)}:${canonicalJson(child)}`).join(",")}}`;
|
|
11
13
|
}
|
|
12
14
|
return JSON.stringify(value) ?? "null";
|
|
@@ -297,6 +297,10 @@ export declare function writeShapedSchemaInputNames({ schema }: {
|
|
|
297
297
|
* SQL tools are classified by the statement, so SELECT/CREATE are not blocked by destructive hints.
|
|
298
298
|
* Unknown destructive tools keep their card until their irreversible behavior can be classified.
|
|
299
299
|
* This decision never grants admission, permissions or descriptor readOnly.
|
|
300
|
+
* The rule ORDER is the policy: delivery and system-prompt changes always card, even when named
|
|
301
|
+
* like a read; a read verb (after an assistant/agent prefix) skips the card unless the remote
|
|
302
|
+
* declares it destructive; assistant-access changes card; SQL is decided by its statements;
|
|
303
|
+
* only then do removal names and the destructive hint decide.
|
|
300
304
|
* @complexity O(n) in the serialized input size.
|
|
301
305
|
*/
|
|
302
306
|
export declare function federatedCallConfirmationForAction({ remoteName, annotations, args }: {
|
|
@@ -326,6 +330,23 @@ export declare function refusalForAdmittedToolUnderCurrentGrants({ tool, grants
|
|
|
326
330
|
readonly writeAllowedToolNames: readonly string[];
|
|
327
331
|
};
|
|
328
332
|
}): ToolRefusalReason | null;
|
|
333
|
+
/**
|
|
334
|
+
* R2 + R3 + R4 + R6, applied to one remote server's advertised surface. The gate.
|
|
335
|
+
*
|
|
336
|
+
* Order matters and is deliberate: name validity, then the operator allowlist, then the remote's
|
|
337
|
+
* own hints (including the write-list override), then the schema. The allowlist is consulted
|
|
338
|
+
* BEFORE the hints so that the common refusal ("we never asked for this tool") is the one reported,
|
|
339
|
+
* rather than a confusing complaint about the annotations of a tool nobody wanted anyway — and so a
|
|
340
|
+
* tool named in `writeAllowedToolNames` but not in `allowedToolNames` can NEVER be admitted by the
|
|
341
|
+
* write list alone : the allowlist check runs and refuses it before the write list is ever
|
|
342
|
+
* read.
|
|
343
|
+
*
|
|
344
|
+
* @param params.tools - The remote's `tools/list`, verbatim and untrusted.
|
|
345
|
+
* @param params.config - The site owner's connection config, the trusted side.
|
|
346
|
+
* @returns Every admitted tool plus a full accounting of what was refused and why.
|
|
347
|
+
* @complexity O(t) in the advertised tool count.
|
|
348
|
+
* See docs/decisions/DR-001-federation-admission.md.
|
|
349
|
+
*/
|
|
329
350
|
export declare function admitRemoteTools(params: {
|
|
330
351
|
tools: readonly RemoteToolDescriptor[];
|
|
331
352
|
config: FederatedMcpConnectionConfig;
|
|
@@ -390,10 +411,10 @@ export declare function describeRemoteToolSurface(params: {
|
|
|
390
411
|
*
|
|
391
412
|
* @complexity O(n) in the description length, capped.
|
|
392
413
|
*/
|
|
393
|
-
export declare function describeFederatedTool(
|
|
414
|
+
export declare function describeFederatedTool({ label, remoteName }: {
|
|
394
415
|
label: string;
|
|
395
416
|
remoteName: string;
|
|
396
|
-
},
|
|
417
|
+
}, { remoteDescription }?: {
|
|
397
418
|
remoteDescription?: string | undefined;
|
|
398
419
|
}): string;
|
|
399
420
|
/**
|