@corbits/mcp 0.3.1 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -26
- package/dist/auth/callback.d.ts +14 -0
- package/dist/auth/callback.js +79 -0
- package/dist/auth/challenge.d.ts +9 -0
- package/dist/auth/challenge.js +52 -0
- package/dist/auth/contracts.d.ts +18 -0
- package/dist/auth/contracts.js +19 -0
- package/dist/auth/http.d.ts +2 -0
- package/dist/auth/http.js +2 -0
- package/dist/auth/index.d.ts +6 -0
- package/dist/auth/index.js +6 -0
- package/dist/auth/login.d.ts +62 -0
- package/dist/auth/login.js +175 -0
- package/dist/auth/oauth-http.d.ts +6 -0
- package/dist/auth/oauth-http.js +32 -0
- package/dist/auth/resource-server.d.ts +25 -0
- package/dist/auth/resource-server.js +94 -0
- package/dist/auth/url-policy.d.ts +10 -0
- package/dist/auth/url-policy.js +35 -0
- package/dist/client/resource-budget.d.ts +1 -0
- package/dist/client/resource-budget.js +1 -0
- package/dist/client/transport.d.ts +10 -0
- package/dist/client/transport.js +1 -0
- package/dist/client.d.ts +208 -26
- package/dist/client.js +1128 -90
- package/dist/extensions/apps.d.ts +58 -0
- package/dist/extensions/apps.js +55 -0
- package/dist/extensions/elicitation.d.ts +18 -0
- package/dist/extensions/elicitation.js +55 -0
- package/dist/extensions/index.d.ts +5 -0
- package/dist/extensions/index.js +5 -0
- package/dist/extensions/maturity.d.ts +44 -0
- package/dist/extensions/maturity.js +24 -0
- package/dist/extensions/skills/contracts.d.ts +53 -0
- package/dist/extensions/skills/contracts.js +1 -0
- package/dist/extensions/skills/index.d.ts +52 -0
- package/dist/extensions/skills/index.js +402 -0
- package/dist/extensions/tasks/contracts.d.ts +39 -0
- package/dist/extensions/tasks/contracts.js +1 -0
- package/dist/extensions/tasks/file-store.d.ts +12 -0
- package/dist/extensions/tasks/file-store.js +152 -0
- package/dist/extensions/tasks/index.d.ts +82 -0
- package/dist/extensions/tasks/index.js +221 -0
- package/dist/extensions/tasks/record.d.ts +3 -0
- package/dist/extensions/tasks/record.js +37 -0
- package/dist/hub/discover.d.ts +5 -1
- package/dist/hub/discover.js +36 -13
- package/dist/index.d.ts +3 -2
- package/dist/index.js +4 -2
- package/dist/interchange/client/capabilities.d.ts +4 -0
- package/dist/interchange/client/capabilities.js +53 -0
- package/dist/interchange/client/catalog.d.ts +14 -0
- package/dist/interchange/client/catalog.js +52 -0
- package/dist/interchange/client/contracts.d.ts +47 -0
- package/dist/interchange/client/contracts.js +1 -0
- package/dist/interchange/client/projection.d.ts +6 -0
- package/dist/interchange/client/projection.js +92 -0
- package/dist/interchange/client/skills.d.ts +16 -0
- package/dist/interchange/client/skills.js +87 -0
- package/dist/interchange/client.d.ts +8 -0
- package/dist/interchange/client.js +6 -0
- package/dist/interchange/server/contracts.d.ts +65 -0
- package/dist/interchange/server/contracts.js +1 -0
- package/dist/interchange/server.d.ts +5 -0
- package/dist/interchange/server.js +172 -0
- package/dist/naming.d.ts +2 -0
- package/dist/naming.js +7 -0
- package/dist/oauth-discovery.d.ts +107 -1
- package/dist/oauth-discovery.js +292 -56
- package/dist/protocol/content.d.ts +35 -0
- package/dist/protocol/content.js +64 -0
- package/dist/protocol/envelopes.d.ts +14 -0
- package/dist/protocol/envelopes.js +22 -0
- package/dist/protocol/errors.d.ts +26 -0
- package/dist/protocol/errors.js +31 -0
- package/dist/protocol/extensions.d.ts +34 -0
- package/dist/protocol/extensions.js +86 -0
- package/dist/protocol/headers.d.ts +12 -0
- package/dist/protocol/headers.js +128 -0
- package/dist/protocol/index.d.ts +11 -0
- package/dist/protocol/index.js +11 -0
- package/dist/protocol/json-schema.d.ts +8 -0
- package/dist/protocol/json-schema.js +152 -0
- package/dist/protocol/legacy.d.ts +12 -0
- package/dist/protocol/legacy.js +18 -0
- package/dist/protocol/methods.d.ts +116 -0
- package/dist/protocol/methods.js +78 -0
- package/dist/protocol/resource-budget.d.ts +9 -0
- package/dist/protocol/resource-budget.js +206 -0
- package/dist/protocol/schemas.d.ts +115 -0
- package/dist/protocol/schemas.js +553 -0
- package/dist/protocol/types.d.ts +295 -0
- package/dist/protocol/types.js +3 -0
- package/dist/protocol/ui.d.ts +3 -0
- package/dist/protocol/ui.js +18 -0
- package/dist/server/contracts.d.ts +67 -0
- package/dist/server/contracts.js +1 -0
- package/dist/server/errors.d.ts +6 -0
- package/dist/server/errors.js +21 -0
- package/dist/server/extensions.d.ts +7 -0
- package/dist/server/extensions.js +61 -0
- package/dist/server/fixtures.d.ts +2 -0
- package/dist/server/fixtures.js +34 -0
- package/dist/server/legacy.d.ts +9 -0
- package/dist/server/legacy.js +435 -0
- package/dist/server/tools.d.ts +17 -0
- package/dist/server/tools.js +93 -0
- package/dist/server.d.ts +14 -0
- package/dist/server.js +406 -0
- package/dist/sidecar-bundle.d.ts +7 -7
- package/dist/sidecar-bundle.js +36 -29
- package/dist/test-server.js +12 -3
- package/dist/tool.d.ts +8 -0
- package/dist/tool.js +35 -19
- package/dist/url.js +8 -3
- package/docs/conformance.md +135 -0
- package/docs/integration-status.md +98 -0
- package/docs/legacy-server.md +58 -0
- package/docs/migration.md +143 -0
- package/docs/public-api.md +106 -0
- package/examples/README.md +28 -0
- package/examples/standalone-client.ts +14 -0
- package/examples/standalone-server.ts +37 -0
- package/package.json +40 -5
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { createElicitationRuntime } from "../../extensions/elicitation.js";
|
|
2
|
+
import { validateInputRequests, } from "../../protocol/index.js";
|
|
3
|
+
import { type } from "arktype";
|
|
4
|
+
import { mcpContinue, mcpGetTask, mcpUpdateTask, mcpCancelTask, } from "../../client.js";
|
|
5
|
+
const TextBlocks = type({ type: "'text'", text: "string" }).array();
|
|
6
|
+
export function shapeMcpContent(content) {
|
|
7
|
+
if (typeof content === "string")
|
|
8
|
+
return content;
|
|
9
|
+
const blocks = TextBlocks(content);
|
|
10
|
+
if (!(blocks instanceof type.errors))
|
|
11
|
+
return blocks.map((block) => block.text).join("\n");
|
|
12
|
+
return JSON.stringify(content) ?? "";
|
|
13
|
+
}
|
|
14
|
+
/** One lifecycle and rich-result projection for live and stored catalogs. */
|
|
15
|
+
export async function projectToolResult(context, initial, client, host = {}) {
|
|
16
|
+
let result = initial;
|
|
17
|
+
// Bound interactive loops independently of individual HTTP request timeouts.
|
|
18
|
+
for (let round = 0; round < 32; round++) {
|
|
19
|
+
context.signal.throwIfAborted();
|
|
20
|
+
if (result.resultType === "input_required") {
|
|
21
|
+
if (host.requestInput === undefined && host.elicitation === undefined)
|
|
22
|
+
return unavailable("unsupported", result);
|
|
23
|
+
if (result.method !== "tools/call")
|
|
24
|
+
throw new Error("MCP tool continuation changed method");
|
|
25
|
+
let responses;
|
|
26
|
+
if (host.requestInput !== undefined)
|
|
27
|
+
responses = await host.requestInput(context, result);
|
|
28
|
+
else {
|
|
29
|
+
const requests = result.inputRequests;
|
|
30
|
+
assertInputRequests(requests);
|
|
31
|
+
responses = await createElicitationRuntime(host.elicitation ?? {}).respond(requests, context.signal);
|
|
32
|
+
}
|
|
33
|
+
context.signal.throwIfAborted();
|
|
34
|
+
if (responses === undefined)
|
|
35
|
+
return unavailable("declined", result);
|
|
36
|
+
const next = await mcpContinue(context.url, result, responses, {
|
|
37
|
+
...client,
|
|
38
|
+
signal: context.signal,
|
|
39
|
+
});
|
|
40
|
+
if (next.resultType === "input_required" ||
|
|
41
|
+
next.resultType === "task" ||
|
|
42
|
+
"content" in next) {
|
|
43
|
+
result = next;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
throw new Error("MCP tool continuation returned a non-tool result");
|
|
47
|
+
}
|
|
48
|
+
if (result.resultType === "task") {
|
|
49
|
+
if (host.adoptTask === undefined)
|
|
50
|
+
return unavailable("unsupported", result);
|
|
51
|
+
const taskId = result.taskId;
|
|
52
|
+
// Do not retain the original invocation signal for the task's lifetime.
|
|
53
|
+
const { signal: _signal, ...taskClient } = client;
|
|
54
|
+
const adopted = await host.adoptTask(context, result, {
|
|
55
|
+
get: () => mcpGetTask(context.url, taskId, taskClient),
|
|
56
|
+
update: (responses) => mcpUpdateTask(context.url, taskId, responses, taskClient),
|
|
57
|
+
cancel: () => mcpCancelTask(context.url, taskId, taskClient),
|
|
58
|
+
});
|
|
59
|
+
if (adopted.correlationId.trim().length === 0)
|
|
60
|
+
throw new Error("MCP task host returned an empty correlation ID");
|
|
61
|
+
return {
|
|
62
|
+
callId: context.callId,
|
|
63
|
+
content: "Remote MCP task adopted by the host; awaiting its outcome.",
|
|
64
|
+
detail: { mcp: result, outcome: "pending" },
|
|
65
|
+
pendingMarker: {
|
|
66
|
+
status: "pending",
|
|
67
|
+
correlationId: adopted.correlationId,
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
await host.presentResult?.(context, result);
|
|
72
|
+
return {
|
|
73
|
+
callId: context.callId,
|
|
74
|
+
isError: result.isError === true,
|
|
75
|
+
content: shapeMcpContent(result.content),
|
|
76
|
+
detail: { mcp: result },
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
throw new Error("MCP continuation exceeded 32 input rounds");
|
|
80
|
+
function unavailable(outcome, pending) {
|
|
81
|
+
return {
|
|
82
|
+
callId: context.callId,
|
|
83
|
+
isError: true,
|
|
84
|
+
content: `MCP ${pending.resultType}: host handling ${outcome}.`,
|
|
85
|
+
detail: { mcp: pending, outcome },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Narrow using the sole shared wire validator, not a second schema. */
|
|
90
|
+
function assertInputRequests(value) {
|
|
91
|
+
validateInputRequests(value);
|
|
92
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type McpClientOptions } from "../../client.js";
|
|
2
|
+
import { type McpSkillOrigin, type McpSkillHostPolicy } from "../../extensions/skills/index.js";
|
|
3
|
+
/** Host-bound skill consumption. Supply a mediated client, never a raw token.
|
|
4
|
+
* Verification alone does not admit model context or authorize execution. */
|
|
5
|
+
export declare function createMcpSkillConsumer(options: {
|
|
6
|
+
origin: McpSkillOrigin;
|
|
7
|
+
url: string;
|
|
8
|
+
client: McpClientOptions;
|
|
9
|
+
host?: McpSkillHostPolicy;
|
|
10
|
+
}): {
|
|
11
|
+
/** Refetching a changed entry invalidates its previous approval. */
|
|
12
|
+
get: (uri: string) => Promise<import("../../extensions/skills/contracts.js").HeldSkill>;
|
|
13
|
+
verify: (held: import("../../extensions/skills/contracts.js").HeldSkill, uri?: string) => Promise<import("../../extensions/skills/contracts.js").VerifiedSkillBytes>;
|
|
14
|
+
activate: (held: import("../../extensions/skills/contracts.js").HeldSkill) => Promise<import("../../extensions/skills/contracts.js").SkillLease>;
|
|
15
|
+
clear: () => void;
|
|
16
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import { mcpGetSkill, mcpConnect, mcpReadResource, } from "../../client.js";
|
|
3
|
+
import { validateSkill, MCP_EXT_SKILLS } from "../../protocol/index.js";
|
|
4
|
+
import { createSkillLoader, } from "../../extensions/skills/index.js";
|
|
5
|
+
/** This host adapter accepts only one matching text/blob resource. The shared
|
|
6
|
+
* client validates the wire; this narrower shape is the byte-decoder boundary. */
|
|
7
|
+
const TextResource = type({ uri: "string", text: "string" });
|
|
8
|
+
const BlobResource = type({ uri: "string", blob: "string" });
|
|
9
|
+
/** Host-bound skill consumption. Supply a mediated client, never a raw token.
|
|
10
|
+
* Verification alone does not admit model context or authorize execution. */
|
|
11
|
+
export function createMcpSkillConsumer(options) {
|
|
12
|
+
const origin = Object.freeze(structuredClone(options.origin));
|
|
13
|
+
const client = {
|
|
14
|
+
...options.client,
|
|
15
|
+
extensions: [
|
|
16
|
+
...new Set([...(options.client.extensions ?? []), MCP_EXT_SKILLS]),
|
|
17
|
+
],
|
|
18
|
+
};
|
|
19
|
+
let ready;
|
|
20
|
+
function connect() {
|
|
21
|
+
ready ??= (async () => ({
|
|
22
|
+
...client,
|
|
23
|
+
session: client.session ?? (await mcpConnect(options.url, client)),
|
|
24
|
+
}))().catch((cause) => {
|
|
25
|
+
ready = undefined;
|
|
26
|
+
throw cause;
|
|
27
|
+
});
|
|
28
|
+
return ready;
|
|
29
|
+
}
|
|
30
|
+
const loader = createSkillLoader({
|
|
31
|
+
async get(_origin, uri) {
|
|
32
|
+
const result = await mcpGetSkill(options.url, uri, await connect());
|
|
33
|
+
return validateSkill(result.skill);
|
|
34
|
+
},
|
|
35
|
+
async read(_origin, uri, maxBytes) {
|
|
36
|
+
const result = await mcpReadResource(options.url, uri, {
|
|
37
|
+
...(await connect()),
|
|
38
|
+
maxDecodedBytes: maxBytes,
|
|
39
|
+
});
|
|
40
|
+
if (result.resultType !== "complete")
|
|
41
|
+
throw new Error("Skill resource requires unsupported host interaction");
|
|
42
|
+
if (result.contents.length !== 1)
|
|
43
|
+
throw new Error("Skill resource must contain exactly one matching item");
|
|
44
|
+
const item = result.contents[0];
|
|
45
|
+
const text = TextResource(item);
|
|
46
|
+
const blob = BlobResource(item);
|
|
47
|
+
let bytes;
|
|
48
|
+
if (!(text instanceof type.errors) &&
|
|
49
|
+
blob instanceof type.errors &&
|
|
50
|
+
text.uri === uri) {
|
|
51
|
+
if (Buffer.byteLength(text.text, "utf8") > maxBytes)
|
|
52
|
+
throw new Error("Skill resource exceeds manifest size");
|
|
53
|
+
bytes = new TextEncoder().encode(text.text);
|
|
54
|
+
}
|
|
55
|
+
else if (!(blob instanceof type.errors) &&
|
|
56
|
+
text instanceof type.errors &&
|
|
57
|
+
blob.uri === uri) {
|
|
58
|
+
// Avoid a repeated-group regex: engines can exhaust their matching
|
|
59
|
+
// budget on valid multi-megabyte blobs. Scan alphabet and padding separately.
|
|
60
|
+
const padding = blob.blob.indexOf("=");
|
|
61
|
+
if (blob.blob.length % 4 !== 0 ||
|
|
62
|
+
/[^A-Za-z0-9+/=]/u.test(blob.blob) ||
|
|
63
|
+
(padding !== -1 &&
|
|
64
|
+
(padding < blob.blob.length - 2 ||
|
|
65
|
+
!/^={1,2}$/u.test(blob.blob.slice(padding)))))
|
|
66
|
+
throw new Error("Invalid skill resource base64");
|
|
67
|
+
const decodedLength = (blob.blob.length / 4) * 3 -
|
|
68
|
+
(blob.blob.endsWith("==") ? 2 : blob.blob.endsWith("=") ? 1 : 0);
|
|
69
|
+
if (decodedLength > maxBytes)
|
|
70
|
+
throw new Error("Skill resource exceeds manifest size");
|
|
71
|
+
bytes = Uint8Array.from(atob(blob.blob), (character) => character.charCodeAt(0));
|
|
72
|
+
}
|
|
73
|
+
else
|
|
74
|
+
throw new Error("Skill resource must contain matching text or blob bytes");
|
|
75
|
+
if (bytes.byteLength > maxBytes)
|
|
76
|
+
throw new Error("Skill resource exceeds manifest size");
|
|
77
|
+
return bytes;
|
|
78
|
+
},
|
|
79
|
+
}, options.host);
|
|
80
|
+
return {
|
|
81
|
+
/** Refetching a changed entry invalidates its previous approval. */
|
|
82
|
+
get: (uri) => loader.get(origin, uri),
|
|
83
|
+
verify: loader.verify,
|
|
84
|
+
activate: loader.activate,
|
|
85
|
+
clear: loader.clear,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/** Remote MCP consumption through actual Interchange tool and host contracts. */
|
|
2
|
+
export { mcpTools, type McpServerConfig, type McpToolsEnv, type McpToolsOptions, } from "../tool.js";
|
|
3
|
+
export { mcpServers, SIDECAR_BUNDLE_ID, shapeMcpContent, type McpBoundServer, type McpServersConfig, type McpServersEnv, } from "../sidecar-bundle.js";
|
|
4
|
+
export type { McpHostContext, McpHostServices, McpTaskControls, } from "./client/contracts.js";
|
|
5
|
+
export { createCatalogGuard, type McpCatalogSnapshot, } from "./client/catalog.js";
|
|
6
|
+
export { createMcpSkillConsumer } from "./client/skills.js";
|
|
7
|
+
export { createSkillLoader, parseSkillFrontmatter, SKILL_LIMITS, } from "../extensions/skills/index.js";
|
|
8
|
+
export type * from "../extensions/skills/contracts.js";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
/** Remote MCP consumption through actual Interchange tool and host contracts. */
|
|
2
|
+
export { mcpTools, } from "../tool.js";
|
|
3
|
+
export { mcpServers, SIDECAR_BUNDLE_ID, shapeMcpContent, } from "../sidecar-bundle.js";
|
|
4
|
+
export { createCatalogGuard, } from "./client/catalog.js";
|
|
5
|
+
export { createMcpSkillConsumer } from "./client/skills.js";
|
|
6
|
+
export { createSkillLoader, parseSkillFrontmatter, SKILL_LIMITS, } from "../extensions/skills/index.js";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import type { Agent, ToolBundle } from "@intx/agent";
|
|
2
|
+
import type { ToolResult } from "@intx/types/runtime";
|
|
3
|
+
import type { McpIdentity, McpAuthPolicy } from "../../auth/contracts.js";
|
|
4
|
+
import type { McpOperationOutcome, McpRequestContext } from "../../server/contracts.js";
|
|
5
|
+
import type { McpToolBinding } from "../../server/tools.js";
|
|
6
|
+
type BaseExposure = {
|
|
7
|
+
definition: McpToolBinding["definition"];
|
|
8
|
+
tenantId: string;
|
|
9
|
+
resource: string;
|
|
10
|
+
action: string;
|
|
11
|
+
/** Host approval/grant floor. Mandatory even for a bare ToolBundle dispatcher. */
|
|
12
|
+
approve: (args: Record<string, unknown>, context: McpRequestContext) => Promise<boolean>;
|
|
13
|
+
/** Host must bind continuations to identity, operation, arguments and one-time state. */
|
|
14
|
+
continue?: (params: Record<string, unknown>, context: McpRequestContext) => Promise<McpOperationOutcome>;
|
|
15
|
+
};
|
|
16
|
+
export type InterchangeToolExposure = BaseExposure & {
|
|
17
|
+
kind: "tool";
|
|
18
|
+
bundle: Pick<ToolBundle, "definitions" | "run">;
|
|
19
|
+
toolName: string;
|
|
20
|
+
/** Explicit mapping for pending markers and full-fidelity detail; no implicit approval. */
|
|
21
|
+
projectResult?: (result: ToolResult, context: McpRequestContext) => Promise<McpOperationOutcome>;
|
|
22
|
+
};
|
|
23
|
+
export type InterchangeAgentExposure = BaseExposure & {
|
|
24
|
+
kind: "agent";
|
|
25
|
+
agent: Pick<Agent, "send">;
|
|
26
|
+
message: (args: Record<string, unknown>) => string;
|
|
27
|
+
suspended: (result: Extract<Awaited<ReturnType<Agent["send"]>>, {
|
|
28
|
+
type: "suspended";
|
|
29
|
+
}>, context: McpRequestContext) => Promise<McpOperationOutcome>;
|
|
30
|
+
};
|
|
31
|
+
/** Host-authenticated app pipeline; the adapter never forwards MCP bearer headers. */
|
|
32
|
+
export type InterchangeDispatch = (request: Request, identity: McpIdentity) => Promise<Response>;
|
|
33
|
+
export type InterchangeHttpExposure = BaseExposure & {
|
|
34
|
+
kind: "operation";
|
|
35
|
+
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
36
|
+
/** Fully bound path. No model-controlled tenant, host, path or authorization headers. */
|
|
37
|
+
path: string;
|
|
38
|
+
origin: string;
|
|
39
|
+
dispatch: InterchangeDispatch;
|
|
40
|
+
/** Authored projection, not automatic exposure of OpenAPI or an entire hub. */
|
|
41
|
+
body?: (args: Record<string, unknown>) => Record<string, unknown>;
|
|
42
|
+
};
|
|
43
|
+
export type WorkflowTrigger = {
|
|
44
|
+
runId: string;
|
|
45
|
+
address: string;
|
|
46
|
+
messageId: string;
|
|
47
|
+
};
|
|
48
|
+
export type InterchangeWorkflowExposure = BaseExposure & {
|
|
49
|
+
kind: "workflow";
|
|
50
|
+
deploymentId: string;
|
|
51
|
+
origin: string;
|
|
52
|
+
dispatch: InterchangeDispatch;
|
|
53
|
+
message: (args: Record<string, unknown>) => string;
|
|
54
|
+
/** Must durably persist dispatch intent; ambiguous dispatch is never automatically replayed. */
|
|
55
|
+
prepare: (args: Record<string, unknown>, context: McpRequestContext) => Promise<string>;
|
|
56
|
+
recordTrigger: (intentId: string, trigger: WorkflowTrigger, context: McpRequestContext) => Promise<void>;
|
|
57
|
+
/** Select only this message's result, or durably create a task bound to its correlation. */
|
|
58
|
+
selectResult: (trigger: WorkflowTrigger, context: McpRequestContext) => Promise<McpOperationOutcome>;
|
|
59
|
+
};
|
|
60
|
+
export type InterchangeExposure = InterchangeToolExposure | InterchangeAgentExposure | InterchangeHttpExposure | InterchangeWorkflowExposure;
|
|
61
|
+
export type InterchangeServerOptions = {
|
|
62
|
+
auth: McpAuthPolicy;
|
|
63
|
+
exposures: readonly InterchangeExposure[];
|
|
64
|
+
};
|
|
65
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { McpProviders } from "../server/contracts.js";
|
|
2
|
+
import type { InterchangeServerOptions } from "./server/contracts.js";
|
|
3
|
+
export type * from "./server/contracts.js";
|
|
4
|
+
/** Explicit allowlist only: no hub scanning, factory enumeration, or grant widening. */
|
|
5
|
+
export declare function createInterchangeProviders(options: InterchangeServerOptions): McpProviders;
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { type } from "arktype";
|
|
2
|
+
import { McpError, validateInstance } from "../protocol/index.js";
|
|
3
|
+
import { createToolProviders } from "../server/tools.js";
|
|
4
|
+
const Trigger = type({
|
|
5
|
+
runId: "string > 0",
|
|
6
|
+
address: "string > 0",
|
|
7
|
+
messageId: "string > 0",
|
|
8
|
+
});
|
|
9
|
+
const textResult = (text) => ({
|
|
10
|
+
content: [{ type: "text", text }],
|
|
11
|
+
});
|
|
12
|
+
const jsonResult = (value) => ({
|
|
13
|
+
content: [{ type: "text", text: JSON.stringify(value) }],
|
|
14
|
+
...(value !== null && typeof value === "object" && !Array.isArray(value)
|
|
15
|
+
? { structuredContent: value }
|
|
16
|
+
: {}),
|
|
17
|
+
});
|
|
18
|
+
function boundUrl(origin, path) {
|
|
19
|
+
const base = new URL(origin);
|
|
20
|
+
if (!/^https?:$/.test(base.protocol) ||
|
|
21
|
+
base.username ||
|
|
22
|
+
base.password ||
|
|
23
|
+
base.pathname !== "/" ||
|
|
24
|
+
base.search ||
|
|
25
|
+
base.hash)
|
|
26
|
+
throw new Error("Expected a fixed HTTP origin");
|
|
27
|
+
if (!path.startsWith("/") || path.startsWith("//") || /[\\?#{}]/u.test(path))
|
|
28
|
+
throw new Error("Expected a fully bound operation path");
|
|
29
|
+
for (const segment of path.split("/")) {
|
|
30
|
+
const decoded = decodeURIComponent(segment);
|
|
31
|
+
if (decoded === "." ||
|
|
32
|
+
decoded === ".." ||
|
|
33
|
+
/[/\\%]/u.test(decoded) ||
|
|
34
|
+
[...decoded].some((character) => character.charCodeAt(0) <= 32))
|
|
35
|
+
throw new Error("Unsafe operation path");
|
|
36
|
+
}
|
|
37
|
+
const url = new URL(path, base);
|
|
38
|
+
if (url.origin !== base.origin)
|
|
39
|
+
throw new Error("Operation origin mismatch");
|
|
40
|
+
return url;
|
|
41
|
+
}
|
|
42
|
+
async function responseJson(response) {
|
|
43
|
+
if (!response.ok)
|
|
44
|
+
throw new McpError("Interchange operation failed", 502, -32603);
|
|
45
|
+
if (response.status === 204)
|
|
46
|
+
return {};
|
|
47
|
+
const body = await response.text();
|
|
48
|
+
if (response.status === 202 && body.length === 0) {
|
|
49
|
+
const location = response.headers.get("location");
|
|
50
|
+
// Acceptance is not completion/cancellation; keep the polling hint without following it.
|
|
51
|
+
return {
|
|
52
|
+
status: 202,
|
|
53
|
+
accepted: true,
|
|
54
|
+
...(location === null ? {} : { location }),
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
if (response.headers.get("content-type")?.split(";")[0]?.trim() !==
|
|
58
|
+
"application/json")
|
|
59
|
+
throw new McpError("Unsupported Interchange response content type", 502, -32603);
|
|
60
|
+
return JSON.parse(body);
|
|
61
|
+
}
|
|
62
|
+
/** Explicit allowlist only: no hub scanning, factory enumeration, or grant widening. */
|
|
63
|
+
export function createInterchangeProviders(options) {
|
|
64
|
+
const bindings = options.exposures.map((exposure) => {
|
|
65
|
+
if (!exposure.tenantId || typeof exposure.approve !== "function")
|
|
66
|
+
throw new Error("Tenant binding and approval service required");
|
|
67
|
+
if (exposure.kind === "operation") {
|
|
68
|
+
boundUrl(exposure.origin, exposure.path);
|
|
69
|
+
if (exposure.method === "GET" && exposure.body)
|
|
70
|
+
throw new Error("GET request bodies are unsupported");
|
|
71
|
+
}
|
|
72
|
+
if (exposure.kind === "workflow")
|
|
73
|
+
boundUrl(exposure.origin, workflowPath(exposure));
|
|
74
|
+
if (exposure.kind === "tool") {
|
|
75
|
+
const selected = exposure.bundle.definitions.filter((definition) => definition.name === exposure.toolName);
|
|
76
|
+
if (selected.length !== 1)
|
|
77
|
+
throw new Error("Tool selection must identify one installed definition");
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
definition: exposure.definition,
|
|
81
|
+
resource: exposure.resource,
|
|
82
|
+
action: exposure.action,
|
|
83
|
+
visible: (context) => context.identity?.tenantId === exposure.tenantId,
|
|
84
|
+
async execute(args, context, params) {
|
|
85
|
+
if (!context.identity ||
|
|
86
|
+
context.identity.tenantId !== exposure.tenantId)
|
|
87
|
+
throw new McpError("Identity is not authorized for this binding", 403, -32602);
|
|
88
|
+
if (!(await exposure.approve(args, context)))
|
|
89
|
+
throw new McpError("Invocation approval required", 403, -32602);
|
|
90
|
+
context.signal.throwIfAborted();
|
|
91
|
+
if (params.inputResponses !== undefined ||
|
|
92
|
+
params.requestState !== undefined) {
|
|
93
|
+
if (!exposure.continue)
|
|
94
|
+
throw new McpError("Continuation unavailable", 400, -32602);
|
|
95
|
+
return exposure.continue(params, context);
|
|
96
|
+
}
|
|
97
|
+
switch (exposure.kind) {
|
|
98
|
+
case "tool": {
|
|
99
|
+
const selected = exposure.bundle.definitions.find((definition) => definition.name === exposure.toolName);
|
|
100
|
+
if (!selected)
|
|
101
|
+
throw new Error("Selected tool is no longer available");
|
|
102
|
+
validateInstance(selected.inputSchema, args);
|
|
103
|
+
const result = await exposure.bundle.run({
|
|
104
|
+
id: String(context.requestId),
|
|
105
|
+
name: exposure.toolName,
|
|
106
|
+
arguments: args,
|
|
107
|
+
}, context.signal);
|
|
108
|
+
if (exposure.projectResult)
|
|
109
|
+
return exposure.projectResult(result, context);
|
|
110
|
+
if (result.pendingMarker)
|
|
111
|
+
throw new McpError("Pending tool requires a lifecycle projection", 500, -32603);
|
|
112
|
+
// detail is deliberately host-only in Interchange; never leak it implicitly.
|
|
113
|
+
return {
|
|
114
|
+
...(typeof result.content === "string"
|
|
115
|
+
? textResult(result.content)
|
|
116
|
+
: jsonResult(result.content)),
|
|
117
|
+
...(result.isError === undefined
|
|
118
|
+
? {}
|
|
119
|
+
: { isError: result.isError }),
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
case "agent": {
|
|
123
|
+
// Agent.send aborts the waiter, not the shared reactor. Never close a shared agent.
|
|
124
|
+
const result = await exposure.agent.send(exposure.message(args), {
|
|
125
|
+
signal: context.signal,
|
|
126
|
+
});
|
|
127
|
+
return result.type === "reply"
|
|
128
|
+
? textResult(result.reply)
|
|
129
|
+
: exposure.suspended(result, context);
|
|
130
|
+
}
|
|
131
|
+
case "operation": {
|
|
132
|
+
const body = exposure.body?.(args);
|
|
133
|
+
const response = await exposure.dispatch(new Request(boundUrl(exposure.origin, exposure.path).href, {
|
|
134
|
+
method: exposure.method,
|
|
135
|
+
signal: context.signal,
|
|
136
|
+
redirect: "error",
|
|
137
|
+
...(body === undefined
|
|
138
|
+
? {}
|
|
139
|
+
: {
|
|
140
|
+
headers: { "Content-Type": "application/json" },
|
|
141
|
+
body: JSON.stringify(body),
|
|
142
|
+
}),
|
|
143
|
+
}), context.identity);
|
|
144
|
+
return jsonResult(await responseJson(response));
|
|
145
|
+
}
|
|
146
|
+
case "workflow": {
|
|
147
|
+
const intentId = await exposure.prepare(args, context);
|
|
148
|
+
if (!intentId)
|
|
149
|
+
throw new Error("Durable dispatch intent required");
|
|
150
|
+
context.signal.throwIfAborted();
|
|
151
|
+
const response = await exposure.dispatch(new Request(boundUrl(exposure.origin, workflowPath(exposure)).href, {
|
|
152
|
+
method: "POST",
|
|
153
|
+
signal: context.signal,
|
|
154
|
+
redirect: "error",
|
|
155
|
+
headers: { "Content-Type": "application/json" },
|
|
156
|
+
body: JSON.stringify({ content: exposure.message(args) }),
|
|
157
|
+
}), context.identity);
|
|
158
|
+
const trigger = Trigger(await responseJson(response));
|
|
159
|
+
if (trigger instanceof type.errors)
|
|
160
|
+
throw new Error("Invalid workflow trigger response");
|
|
161
|
+
await exposure.recordTrigger(intentId, trigger, context);
|
|
162
|
+
return exposure.selectResult(trigger, context);
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
},
|
|
166
|
+
};
|
|
167
|
+
});
|
|
168
|
+
return createToolProviders(bindings, options.auth);
|
|
169
|
+
}
|
|
170
|
+
function workflowPath(exposure) {
|
|
171
|
+
return `/api/tenants/${encodeURIComponent(exposure.tenantId)}/workflows/${encodeURIComponent(exposure.deploymentId)}/mail`;
|
|
172
|
+
}
|
package/dist/naming.d.ts
CHANGED
|
@@ -22,3 +22,5 @@ export declare function toolDescription(tool: McpTool, server: string): string;
|
|
|
22
22
|
* `destructiveHint: true` -- that flag wins no matter what is listed.
|
|
23
23
|
*/
|
|
24
24
|
export declare function isAskExempt(qualified: string, tool: McpTool, allowWithoutAsk: readonly string[]): boolean;
|
|
25
|
+
/** App-only tools never enter model definitions or the model dispatcher. */
|
|
26
|
+
export declare function isModelVisible(tool: McpTool): boolean;
|
package/dist/naming.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Naming and the ask floor, shared by the author-time `mcpTools` factory and
|
|
2
2
|
// the deployed `mcpServers` sidecar bundle so both project a server's catalog
|
|
3
3
|
// onto identical tool names and identical approval marks.
|
|
4
|
+
import { validateUiToolMeta } from "./protocol/ui.js";
|
|
5
|
+
import { isToolVisibleToModel } from "./extensions/apps.js";
|
|
4
6
|
import { parseMcpEndpoint } from "./url.js";
|
|
5
7
|
/** `<server>.<tool>`, the grantable unit: `tool:<server>.*` covers a server. */
|
|
6
8
|
export function qualifiedName(server, tool) {
|
|
@@ -57,3 +59,8 @@ export function isAskExempt(qualified, tool, allowWithoutAsk) {
|
|
|
57
59
|
return false;
|
|
58
60
|
return allowWithoutAsk.includes(qualified);
|
|
59
61
|
}
|
|
62
|
+
/** App-only tools never enter model definitions or the model dispatcher. */
|
|
63
|
+
export function isModelVisible(tool) {
|
|
64
|
+
const ui = tool._meta?.ui;
|
|
65
|
+
return isToolVisibleToModel(ui === undefined ? undefined : validateUiToolMeta(ui));
|
|
66
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { FetchLike } from "
|
|
1
|
+
import type { FetchLike } from "./auth/http.js";
|
|
2
2
|
import { type OAuthClientConfig } from "@corbits/oauth-core";
|
|
3
3
|
export type McpAuthorizationServer = {
|
|
4
4
|
issuer: string;
|
|
@@ -9,16 +9,26 @@ export type McpAuthorizationServer = {
|
|
|
9
9
|
scopesSupported?: readonly string[];
|
|
10
10
|
tokenEndpointAuthMethodsSupported?: readonly string[];
|
|
11
11
|
codeChallengeMethodsSupported?: readonly string[];
|
|
12
|
+
/** Present only when the server sent `client_id_metadata_document_supported`. */
|
|
13
|
+
clientIdMetadataDocumentSupported?: boolean;
|
|
14
|
+
/** Present only when the server sent `authorization_response_iss_parameter_supported`. */
|
|
15
|
+
authorizationResponseIssParameterSupported?: boolean;
|
|
12
16
|
};
|
|
13
17
|
export type McpLoginEntry = {
|
|
14
18
|
resourceUrl: string;
|
|
15
19
|
authorizationServer: McpAuthorizationServer;
|
|
16
20
|
resourceScopesSupported?: readonly string[];
|
|
21
|
+
/** Scope required by the actual MCP challenge, ahead of PRM defaults. */
|
|
22
|
+
challengeScope?: string;
|
|
17
23
|
};
|
|
18
24
|
export type DiscoverMcpLoginEntryOptions = {
|
|
19
25
|
resourceUrl: string;
|
|
20
26
|
fetchImpl?: FetchLike;
|
|
21
27
|
timeoutMs?: number;
|
|
28
|
+
/** Challenge retained from the actual MCP 401/403 response. */
|
|
29
|
+
wwwAuthenticate?: string;
|
|
30
|
+
/** Pre-PRM compatibility only; modern MCP requires protected-resource metadata. */
|
|
31
|
+
allowLegacyAuthorizationServerFallback?: boolean;
|
|
22
32
|
};
|
|
23
33
|
export type RegisterMcpClientOptions = {
|
|
24
34
|
registrationEndpoint: string;
|
|
@@ -26,6 +36,13 @@ export type RegisterMcpClientOptions = {
|
|
|
26
36
|
clientName: string;
|
|
27
37
|
grantTypesSupported?: readonly string[];
|
|
28
38
|
scope?: string;
|
|
39
|
+
/**
|
|
40
|
+
* OIDC defaults an omitted `application_type` to `"web"`, which rejects
|
|
41
|
+
* loopback redirect URIs. Loopback and other public clients use `"native"`.
|
|
42
|
+
*/
|
|
43
|
+
applicationType?: "native" | "web";
|
|
44
|
+
/** Recorded on the result so the caller can key the credential by issuer. */
|
|
45
|
+
issuer?: string;
|
|
29
46
|
fetchImpl?: FetchLike;
|
|
30
47
|
timeoutMs?: number;
|
|
31
48
|
};
|
|
@@ -33,6 +50,38 @@ export type McpClientRegistration = {
|
|
|
33
50
|
clientId: string;
|
|
34
51
|
grantTypes: string[];
|
|
35
52
|
scope?: string;
|
|
53
|
+
/** Set when `registerMcpClient` was given an issuer to persist with the credential. */
|
|
54
|
+
issuer?: string;
|
|
55
|
+
};
|
|
56
|
+
export type McpClientIdMetadataDocument = {
|
|
57
|
+
client_id: string;
|
|
58
|
+
client_name: string;
|
|
59
|
+
redirect_uris: string[];
|
|
60
|
+
grant_types: string[];
|
|
61
|
+
response_types: ["code"];
|
|
62
|
+
token_endpoint_auth_method: "none";
|
|
63
|
+
};
|
|
64
|
+
export type McpClientRegistrationPlan = {
|
|
65
|
+
mechanism: "pre-registered";
|
|
66
|
+
clientId: string;
|
|
67
|
+
} | {
|
|
68
|
+
mechanism: "client-id-metadata";
|
|
69
|
+
clientId: string;
|
|
70
|
+
} | {
|
|
71
|
+
mechanism: "dynamic";
|
|
72
|
+
registrationEndpoint: string;
|
|
73
|
+
};
|
|
74
|
+
export type StoredMcpClient = {
|
|
75
|
+
kind: "cimd" | "dynamic" | "pre-registered";
|
|
76
|
+
clientId: string;
|
|
77
|
+
/** Required for `dynamic` and `pre-registered`. CIMD credentials are portable. */
|
|
78
|
+
issuer?: string;
|
|
79
|
+
};
|
|
80
|
+
export type AuthorizationIssuerOptions = {
|
|
81
|
+
recordedIssuer: string;
|
|
82
|
+
/** Absent when the authorization response omitted `iss`. An empty string is present. */
|
|
83
|
+
iss?: string;
|
|
84
|
+
authorizationResponseIssParameterSupported?: boolean;
|
|
36
85
|
};
|
|
37
86
|
export type McpClientConfigOptions = {
|
|
38
87
|
clientId: string;
|
|
@@ -59,6 +108,63 @@ export declare function discoverMcpLoginEntry(opts: DiscoverMcpLoginEntryOptions
|
|
|
59
108
|
* rejects the request.
|
|
60
109
|
*/
|
|
61
110
|
export declare function registerMcpClient(opts: RegisterMcpClientOptions): Promise<McpClientRegistration>;
|
|
111
|
+
/**
|
|
112
|
+
* Build a Client ID Metadata Document whose `client_id` is the document URL.
|
|
113
|
+
* The caller hosts the returned JSON at that exact URL.
|
|
114
|
+
*/
|
|
115
|
+
export declare function mcpClientIdMetadataDocument(opts: {
|
|
116
|
+
metadataUrl: string;
|
|
117
|
+
clientName: string;
|
|
118
|
+
redirectUris: readonly string[];
|
|
119
|
+
grantTypes?: readonly string[];
|
|
120
|
+
}): McpClientIdMetadataDocument;
|
|
121
|
+
/**
|
|
122
|
+
* Registration priority: pre-registered client id, then a Client ID Metadata
|
|
123
|
+
* Document when the authorization server advertises support and a metadata
|
|
124
|
+
* URL is available, then RFC 7591. Anything else is an error.
|
|
125
|
+
*/
|
|
126
|
+
export declare function preferredMcpClientRegistration(opts: {
|
|
127
|
+
authorizationServer: Pick<McpAuthorizationServer, "clientIdMetadataDocumentSupported" | "registrationEndpoint">;
|
|
128
|
+
preRegisteredClientId?: string;
|
|
129
|
+
clientMetadataUrl?: string;
|
|
130
|
+
}): McpClientRegistrationPlan;
|
|
131
|
+
/**
|
|
132
|
+
* CIMD client ids are portable across issuers. Dynamic and pre-registered
|
|
133
|
+
* credentials are keyed by issuer and must be registered again when it changes.
|
|
134
|
+
* Issuers are compared exactly, including trailing slashes.
|
|
135
|
+
*/
|
|
136
|
+
export declare function resolveStoredMcpClient(issuer: string, stored: StoredMcpClient): {
|
|
137
|
+
clientId: string;
|
|
138
|
+
};
|
|
139
|
+
/**
|
|
140
|
+
* RFC 9207 via the MCP authorization spec. A present `iss` (including an
|
|
141
|
+
* empty string) must equal the recorded issuer with no normalization. When
|
|
142
|
+
* the server advertises `authorization_response_iss_parameter_supported`, a
|
|
143
|
+
* missing `iss` is rejected. Otherwise a missing `iss` is allowed.
|
|
144
|
+
*/
|
|
145
|
+
export declare function assertAuthorizationIssuer(opts: AuthorizationIssuerOptions): void;
|
|
146
|
+
/** Validate callback provenance without acting on code or terminal error fields.
|
|
147
|
+
* Callers may consume a one-use login only after this and their state check pass. */
|
|
148
|
+
export declare function assertAuthorizationCallbackIssuer(params: URLSearchParams | Record<string, string | undefined | null>, opts: Omit<AuthorizationIssuerOptions, "iss">): void;
|
|
149
|
+
/**
|
|
150
|
+
* Mix-up defense for metadata that names an authorization or token endpoint
|
|
151
|
+
* on a different origin than the issuer. RFC 9207 allows a missing `iss` when
|
|
152
|
+
* the server does not advertise `authorization_response_iss_parameter_supported`,
|
|
153
|
+
* but that would send the code and PKCE verifier to whichever token endpoint
|
|
154
|
+
* the metadata named. Cross-origin endpoints require a present `iss` equal to
|
|
155
|
+
* the recorded issuer. Same-origin issuers keep the ordinary missing-`iss` rule.
|
|
156
|
+
*/
|
|
157
|
+
export declare function assertCrossOriginAuthorizationIssuer(params: URLSearchParams | Record<string, string | undefined | null>, server: Pick<McpAuthorizationServer, "issuer" | "authorizationEndpoint" | "tokenEndpoint">): void;
|
|
158
|
+
/**
|
|
159
|
+
* Read an authorization callback. `iss` is checked before `error` or `code`
|
|
160
|
+
* is acted on, and this function does not redeem the code.
|
|
161
|
+
*/
|
|
162
|
+
export declare function readAuthorizationCallback(params: URLSearchParams | Record<string, string | undefined | null>, opts: {
|
|
163
|
+
recordedIssuer: string;
|
|
164
|
+
authorizationResponseIssParameterSupported?: boolean;
|
|
165
|
+
}): {
|
|
166
|
+
code: string;
|
|
167
|
+
};
|
|
62
168
|
export declare function selectMcpScopes(opts: SelectMcpScopesOptions): string[];
|
|
63
169
|
/**
|
|
64
170
|
* Build the OAuth client config for a discovered entry plus a registered
|