@jini-ai/mcp 0.4.2 → 0.4.4
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 +4 -0
- package/README.md +1 -0
- package/dist/agent-install/chrome-devtools.d.ts +4 -0
- package/dist/agent-install/chrome-devtools.js +7 -0
- package/dist/agent-install/index.d.ts +1 -0
- package/dist/agent-install/index.js +1 -0
- package/dist/agent-install/install.js +102 -194
- package/dist/bin/serve.d.ts +4 -1
- package/dist/bin/serve.js +120 -45
- 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/bootstrap.d.ts +18 -0
- package/dist/federation/bootstrap.js +4 -4
- package/dist/federation/presets.d.ts +16 -17
- package/dist/federation/presets.js +35 -28
- 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 +69 -35
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/server/daemon-client.d.ts +2 -0
- package/dist/server/daemon-client.js +86 -69
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.js +1 -0
- package/dist/server/resources/site-guide-resource.d.ts +15 -0
- package/dist/server/resources/site-guide-resource.js +25 -0
- 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 +47 -0
- package/dist/server/tools/delegated-tool.js +35 -2
- 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 +6 -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
|
@@ -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
|
|
@@ -2,6 +2,7 @@ import { type Logger } from "@jini-ai/core/primitives";
|
|
|
2
2
|
import type { ToolRegistry } from "@jini-ai/core";
|
|
3
3
|
import type { ResolvedFederatedConnection } from "./config.js";
|
|
4
4
|
import type { McpSessionPort } from "./ports.js";
|
|
5
|
+
import { type FederatedMcpPresetRegistry } from "./presets.js";
|
|
5
6
|
import { type FederationDeps } from "./registrations.js";
|
|
6
7
|
import type { FederatedAdmissionReport } from "./trust.js";
|
|
7
8
|
export interface AttachFederatedToolsResult {
|
|
@@ -74,6 +75,7 @@ export interface AttachFederatedToolsResult {
|
|
|
74
75
|
* @complexity O(c · t) in connections and their advertised tools.
|
|
75
76
|
*/
|
|
76
77
|
export interface AttachFederatedMcpToolsParams {
|
|
78
|
+
readonly presetRegistry?: FederatedMcpPresetRegistry;
|
|
77
79
|
registry: ToolRegistry;
|
|
78
80
|
deps: FederationDeps;
|
|
79
81
|
connections?: readonly ResolvedFederatedConnection[] | undefined;
|
|
@@ -97,4 +99,20 @@ export interface AttachFederatedMcpToolsParams {
|
|
|
97
99
|
export type AttachFederatedMcpToolsRequiredArgs = Pick<AttachFederatedMcpToolsParams, "registry" | "deps" | "connect">;
|
|
98
100
|
export type AttachFederatedMcpToolsOptions = Omit<AttachFederatedMcpToolsParams, "registry" | "deps" | "connect">;
|
|
99
101
|
export declare function attachFederatedMcpTools(requiredArgs: AttachFederatedMcpToolsRequiredArgs, optionalArgs?: AttachFederatedMcpToolsOptions): Promise<AttachFederatedToolsResult>;
|
|
102
|
+
/**
|
|
103
|
+
* Asks every registered preset for a connection, in registration order.
|
|
104
|
+
*
|
|
105
|
+
* Errors are isolated PER PRESET rather than abandoning the whole resolution pass: an enabled-but-
|
|
106
|
+
* invalid Supabase config must not also disable a correctly-configured second vendor that happens to
|
|
107
|
+
* be registered after it. Each failure is loud — the operator meant to have that connection and does
|
|
108
|
+
* not — and still non-fatal, because host app's own assistant is unaffected either way.
|
|
109
|
+
*
|
|
110
|
+
* A preset that declines (`null`) is silent: "not configured" is the expected default state, and
|
|
111
|
+
* logging it every boot would train operators to ignore this channel.
|
|
112
|
+
*/
|
|
113
|
+
export declare function resolveFederatedMcpPresets({ env, logger, registry }: {
|
|
114
|
+
env: NodeJS.ProcessEnv;
|
|
115
|
+
logger: Pick<Logger, "warn">;
|
|
116
|
+
registry: FederatedMcpPresetRegistry;
|
|
117
|
+
}, _optional?: {}): ResolvedFederatedConnection[];
|
|
100
118
|
//# sourceMappingURL=bootstrap.d.ts.map
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { createConsoleLogger } from "@jini-ai/core/primitives";
|
|
2
|
-
import {
|
|
2
|
+
import { defaultFederatedMcpPresetRegistry } from "./presets.js";
|
|
3
3
|
import { federateSession } from "./registrations.js";
|
|
4
4
|
/**
|
|
5
5
|
* @file The composition root for outbound MCP federation: the one function
|
|
@@ -56,7 +56,7 @@ function withPresetOrigin(connection) {
|
|
|
56
56
|
function resolveFederationAttachInputs(params) {
|
|
57
57
|
const logger = params.logger ?? consoleLogger;
|
|
58
58
|
const connect = params.connect;
|
|
59
|
-
const presetConnections = params.connections ??
|
|
59
|
+
const presetConnections = params.connections ?? resolveFederatedMcpPresets({ env: params.env ?? process.env, logger, registry: params.presetRegistry ?? defaultFederatedMcpPresetRegistry });
|
|
60
60
|
const connections = [
|
|
61
61
|
...presetConnections.map((connection) => ({ connection: withPresetOrigin(connection), isPreset: true })),
|
|
62
62
|
...(params.extraConnections ?? []).map((connection) => ({ connection, isPreset: false })),
|
|
@@ -162,9 +162,9 @@ async function attachOneFederatedConnection(params) {
|
|
|
162
162
|
* A preset that declines (`null`) is silent: "not configured" is the expected default state, and
|
|
163
163
|
* logging it every boot would train operators to ignore this channel.
|
|
164
164
|
*/
|
|
165
|
-
function
|
|
165
|
+
export function resolveFederatedMcpPresets({ env, logger, registry }, _optional = {}) {
|
|
166
166
|
const connections = [];
|
|
167
|
-
for (const preset of
|
|
167
|
+
for (const preset of registry.list({})) {
|
|
168
168
|
try {
|
|
169
169
|
const connection = preset.resolve({ env: env });
|
|
170
170
|
if (connection)
|
|
@@ -44,23 +44,22 @@ export interface FederatedMcpPreset {
|
|
|
44
44
|
readonly presetId: string;
|
|
45
45
|
readonly resolve: FederatedMcpPresetResolver;
|
|
46
46
|
}
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
* preserved
|
|
56
|
-
*/
|
|
57
|
-
export declare function
|
|
47
|
+
export interface FederatedMcpPresetRegistry {
|
|
48
|
+
register(required: {
|
|
49
|
+
preset: FederatedMcpPreset;
|
|
50
|
+
}, optional?: Record<string, never>): void;
|
|
51
|
+
list(required: Record<string, never>, optional?: Record<string, never>): readonly FederatedMcpPreset[];
|
|
52
|
+
reset(required: Record<string, never>, optional?: Record<string, never>): void;
|
|
53
|
+
}
|
|
54
|
+
/** Each host owns one ordered registry. Replacement retains order; snapshots cannot mutate it.
|
|
55
|
+
* The preserved reset rationale below describes the legacy module registry; reset now clears
|
|
56
|
+
* only this instance. The published default registry is a compatibility adapter. */
|
|
57
|
+
export declare function createFederatedMcpPresetRegistry(_required: Record<string, never>, _optional?: Record<string, never>): FederatedMcpPresetRegistry;
|
|
58
|
+
/** Compatibility owner for published callers; hosts inject their own instance at boot. */
|
|
59
|
+
export declare const defaultFederatedMcpPresetRegistry: FederatedMcpPresetRegistry;
|
|
60
|
+
export declare function registerFederatedMcpPreset(required: {
|
|
58
61
|
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
|
+
}, optional?: {}): void;
|
|
62
63
|
export declare function listFederatedMcpPresets(): readonly FederatedMcpPreset[];
|
|
63
|
-
|
|
64
|
-
* `resetPageHeadRegistry`, though that one is no longer test-only — see its own doc). */
|
|
65
|
-
export declare function resetFederatedMcpPresetsForTests(_required: Record<string, never>): void;
|
|
64
|
+
export declare function resetFederatedMcpPresetsForTests(required: Record<string, never>): void;
|
|
66
65
|
//# sourceMappingURL=presets.d.ts.map
|
|
@@ -1,30 +1,37 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
*
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
1
|
+
/** Each host owns one ordered registry. Replacement retains order; snapshots cannot mutate it.
|
|
2
|
+
* The preserved reset rationale below describes the legacy module registry; reset now clears
|
|
3
|
+
* only this instance. The published default registry is a compatibility adapter. */
|
|
4
|
+
export function createFederatedMcpPresetRegistry(_required, _optional = {}) {
|
|
5
|
+
let presets = [];
|
|
6
|
+
return {
|
|
7
|
+
/**
|
|
8
|
+
* Registers a preset, called once at composition-root boot by the preset's own module.
|
|
9
|
+
*
|
|
10
|
+
* Re-registering the same `presetId` REPLACES the earlier entry rather than appending. Unlike a
|
|
11
|
+
* `<head>` contributor, a duplicate here would resolve to two connections with the same
|
|
12
|
+
* `connectionId`, and `trust.ts` R1's collision assertion would then drop the second one whole —
|
|
13
|
+
* producing a confusing "registered, then refused for shadowing itself" log for what is really just
|
|
14
|
+
* a module imported from two places. Last registration wins, and registration order is otherwise
|
|
15
|
+
* preserved so a replacement does not silently reorder connections.
|
|
16
|
+
*/
|
|
17
|
+
register({ preset }) {
|
|
18
|
+
const existing = presets.findIndex(candidate => candidate.presetId === preset.presetId);
|
|
19
|
+
if (existing >= 0)
|
|
20
|
+
presets[existing] = preset;
|
|
21
|
+
else
|
|
22
|
+
presets.push(preset);
|
|
23
|
+
},
|
|
24
|
+
/** Registration order. `bootstrap.ts` resolves in this order, and that order decides which
|
|
25
|
+
* connection claims a contested tool id first (R1). */
|
|
26
|
+
list: () => Object.freeze(presets.slice()),
|
|
27
|
+
/** Test-only reset of the module-level registry (mirrors `page-head.ts`'s
|
|
28
|
+
* `resetPageHeadRegistry`, though that one is no longer test-only — see its own doc). */
|
|
29
|
+
reset: () => { presets = []; },
|
|
30
|
+
};
|
|
29
31
|
}
|
|
32
|
+
/** Compatibility owner for published callers; hosts inject their own instance at boot. */
|
|
33
|
+
export const defaultFederatedMcpPresetRegistry = createFederatedMcpPresetRegistry({});
|
|
34
|
+
export function registerFederatedMcpPreset(required, optional = {}) { defaultFederatedMcpPresetRegistry.register(required, optional); }
|
|
35
|
+
export function listFederatedMcpPresets() { return defaultFederatedMcpPresetRegistry.list({}); }
|
|
36
|
+
export function resetFederatedMcpPresetsForTests(required) { defaultFederatedMcpPresetRegistry.reset(required); }
|
|
30
37
|
//# sourceMappingURL=presets.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). */
|