@corbits/mcp 0.3.0 → 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.
Files changed (124) hide show
  1. package/README.md +120 -26
  2. package/dist/auth/callback.d.ts +14 -0
  3. package/dist/auth/callback.js +79 -0
  4. package/dist/auth/challenge.d.ts +9 -0
  5. package/dist/auth/challenge.js +52 -0
  6. package/dist/auth/contracts.d.ts +18 -0
  7. package/dist/auth/contracts.js +19 -0
  8. package/dist/auth/http.d.ts +2 -0
  9. package/dist/auth/http.js +2 -0
  10. package/dist/auth/index.d.ts +6 -0
  11. package/dist/auth/index.js +6 -0
  12. package/dist/auth/login.d.ts +62 -0
  13. package/dist/auth/login.js +175 -0
  14. package/dist/auth/oauth-http.d.ts +6 -0
  15. package/dist/auth/oauth-http.js +32 -0
  16. package/dist/auth/resource-server.d.ts +25 -0
  17. package/dist/auth/resource-server.js +94 -0
  18. package/dist/auth/url-policy.d.ts +10 -0
  19. package/dist/auth/url-policy.js +35 -0
  20. package/dist/client/resource-budget.d.ts +1 -0
  21. package/dist/client/resource-budget.js +1 -0
  22. package/dist/client/transport.d.ts +10 -0
  23. package/dist/client/transport.js +1 -0
  24. package/dist/client.d.ts +208 -26
  25. package/dist/client.js +1128 -90
  26. package/dist/extensions/apps.d.ts +58 -0
  27. package/dist/extensions/apps.js +55 -0
  28. package/dist/extensions/elicitation.d.ts +18 -0
  29. package/dist/extensions/elicitation.js +55 -0
  30. package/dist/extensions/index.d.ts +5 -0
  31. package/dist/extensions/index.js +5 -0
  32. package/dist/extensions/maturity.d.ts +44 -0
  33. package/dist/extensions/maturity.js +24 -0
  34. package/dist/extensions/skills/contracts.d.ts +53 -0
  35. package/dist/extensions/skills/contracts.js +1 -0
  36. package/dist/extensions/skills/index.d.ts +52 -0
  37. package/dist/extensions/skills/index.js +402 -0
  38. package/dist/extensions/tasks/contracts.d.ts +39 -0
  39. package/dist/extensions/tasks/contracts.js +1 -0
  40. package/dist/extensions/tasks/file-store.d.ts +12 -0
  41. package/dist/extensions/tasks/file-store.js +152 -0
  42. package/dist/extensions/tasks/index.d.ts +82 -0
  43. package/dist/extensions/tasks/index.js +221 -0
  44. package/dist/extensions/tasks/record.d.ts +3 -0
  45. package/dist/extensions/tasks/record.js +37 -0
  46. package/dist/hub/discover.d.ts +5 -1
  47. package/dist/hub/discover.js +36 -13
  48. package/dist/index.d.ts +3 -2
  49. package/dist/index.js +4 -2
  50. package/dist/interchange/client/capabilities.d.ts +4 -0
  51. package/dist/interchange/client/capabilities.js +53 -0
  52. package/dist/interchange/client/catalog.d.ts +14 -0
  53. package/dist/interchange/client/catalog.js +52 -0
  54. package/dist/interchange/client/contracts.d.ts +47 -0
  55. package/dist/interchange/client/contracts.js +1 -0
  56. package/dist/interchange/client/projection.d.ts +6 -0
  57. package/dist/interchange/client/projection.js +92 -0
  58. package/dist/interchange/client/skills.d.ts +16 -0
  59. package/dist/interchange/client/skills.js +87 -0
  60. package/dist/interchange/client.d.ts +8 -0
  61. package/dist/interchange/client.js +6 -0
  62. package/dist/interchange/server/contracts.d.ts +65 -0
  63. package/dist/interchange/server/contracts.js +1 -0
  64. package/dist/interchange/server.d.ts +5 -0
  65. package/dist/interchange/server.js +172 -0
  66. package/dist/naming.d.ts +2 -0
  67. package/dist/naming.js +7 -0
  68. package/dist/oauth-discovery.d.ts +107 -1
  69. package/dist/oauth-discovery.js +292 -56
  70. package/dist/protocol/content.d.ts +35 -0
  71. package/dist/protocol/content.js +64 -0
  72. package/dist/protocol/envelopes.d.ts +14 -0
  73. package/dist/protocol/envelopes.js +22 -0
  74. package/dist/protocol/errors.d.ts +26 -0
  75. package/dist/protocol/errors.js +31 -0
  76. package/dist/protocol/extensions.d.ts +34 -0
  77. package/dist/protocol/extensions.js +86 -0
  78. package/dist/protocol/headers.d.ts +12 -0
  79. package/dist/protocol/headers.js +128 -0
  80. package/dist/protocol/index.d.ts +11 -0
  81. package/dist/protocol/index.js +11 -0
  82. package/dist/protocol/json-schema.d.ts +8 -0
  83. package/dist/protocol/json-schema.js +152 -0
  84. package/dist/protocol/legacy.d.ts +12 -0
  85. package/dist/protocol/legacy.js +18 -0
  86. package/dist/protocol/methods.d.ts +116 -0
  87. package/dist/protocol/methods.js +78 -0
  88. package/dist/protocol/resource-budget.d.ts +9 -0
  89. package/dist/protocol/resource-budget.js +206 -0
  90. package/dist/protocol/schemas.d.ts +115 -0
  91. package/dist/protocol/schemas.js +553 -0
  92. package/dist/protocol/types.d.ts +295 -0
  93. package/dist/protocol/types.js +3 -0
  94. package/dist/protocol/ui.d.ts +3 -0
  95. package/dist/protocol/ui.js +18 -0
  96. package/dist/server/contracts.d.ts +67 -0
  97. package/dist/server/contracts.js +1 -0
  98. package/dist/server/errors.d.ts +6 -0
  99. package/dist/server/errors.js +21 -0
  100. package/dist/server/extensions.d.ts +7 -0
  101. package/dist/server/extensions.js +61 -0
  102. package/dist/server/fixtures.d.ts +2 -0
  103. package/dist/server/fixtures.js +34 -0
  104. package/dist/server/legacy.d.ts +9 -0
  105. package/dist/server/legacy.js +435 -0
  106. package/dist/server/tools.d.ts +17 -0
  107. package/dist/server/tools.js +93 -0
  108. package/dist/server.d.ts +14 -0
  109. package/dist/server.js +406 -0
  110. package/dist/sidecar-bundle.d.ts +7 -7
  111. package/dist/sidecar-bundle.js +36 -29
  112. package/dist/test-server.js +12 -3
  113. package/dist/tool.d.ts +8 -0
  114. package/dist/tool.js +35 -19
  115. package/dist/url.js +8 -3
  116. package/docs/conformance.md +135 -0
  117. package/docs/integration-status.md +98 -0
  118. package/docs/legacy-server.md +58 -0
  119. package/docs/migration.md +143 -0
  120. package/docs/public-api.md +106 -0
  121. package/examples/README.md +28 -0
  122. package/examples/standalone-client.ts +14 -0
  123. package/examples/standalone-server.ts +37 -0
  124. package/package.json +50 -15
@@ -0,0 +1,221 @@
1
+ import { validateElicitationResponse } from "../elicitation.js";
2
+ import { validateTask as validateWireTask, validateInputResponses, } from "../../protocol/index.js";
3
+ const terminal = (task) => ["completed", "failed", "cancelled"].includes(task.status);
4
+ const sameIdentity = (a, b) => a.tenantId === b.tenantId &&
5
+ a.principalId === b.principalId &&
6
+ a.authorizationContextId === b.authorizationContextId;
7
+ const fail = (message) => {
8
+ throw Object.assign(new Error(message), { code: -32602 });
9
+ };
10
+ const waitingTask = (task) => {
11
+ if (task.status === "input_required")
12
+ return task;
13
+ return fail("Task is not waiting for input");
14
+ };
15
+ export function createTaskRuntime(options) {
16
+ const { store, executor } = options;
17
+ if (store.durable !== true)
18
+ fail("A durable task store is required");
19
+ const now = options.now ?? Date.now;
20
+ const validateTask = options.validateTask ??
21
+ ((task) => {
22
+ validateWireTask(task, true);
23
+ });
24
+ async function load(identity, taskId) {
25
+ const record = await store.get(taskId);
26
+ if (!record ||
27
+ !sameIdentity(identity, record.identity) ||
28
+ (record.task.ttlMs !== null &&
29
+ Date.parse(record.task.createdAt) + record.task.ttlMs <= now()))
30
+ return fail("Unknown task");
31
+ return record;
32
+ }
33
+ async function update(identity, taskId, change) {
34
+ for (let attempt = 0; attempt < 16; attempt++) {
35
+ const record = await load(identity, taskId);
36
+ const next = change(structuredClone(record));
37
+ next.revision = record.revision + 1;
38
+ validateTask(next.task);
39
+ if (await store.compareAndSwap(taskId, record.revision, next))
40
+ return next;
41
+ }
42
+ throw new Error("Task concurrent update limit exceeded");
43
+ }
44
+ async function transition(identity, taskId, task, backend) {
45
+ return update(identity, taskId, (record) => {
46
+ if (terminal(record.task))
47
+ return fail("Terminal tasks are immutable");
48
+ if (task.taskId !== taskId || task.createdAt !== record.task.createdAt)
49
+ return fail("Task identity cannot change");
50
+ if (task.status === "input_required")
51
+ for (const [key, request] of Object.entries(task.inputRequests)) {
52
+ if (Object.hasOwn(record.answered, key) ||
53
+ (Object.hasOwn(record.issued, key) &&
54
+ JSON.stringify(record.issued[key]) !== JSON.stringify(request)))
55
+ fail("Task input key reused");
56
+ record.issued = { ...record.issued, [key]: structuredClone(request) };
57
+ }
58
+ record.task = structuredClone(task);
59
+ record.task.lastUpdatedAt = new Date(now()).toISOString();
60
+ if (backend)
61
+ record.backend = structuredClone(backend);
62
+ return record;
63
+ });
64
+ }
65
+ return {
66
+ async create(identity, params, ttlMs = 86_400_000) {
67
+ if (Object.values(identity).some((v) => !v))
68
+ fail("Task identity required");
69
+ if (ttlMs !== null && (!Number.isSafeInteger(ttlMs) || ttlMs <= 0))
70
+ fail("Invalid task TTL");
71
+ const timestamp = new Date(now()).toISOString();
72
+ const task = {
73
+ taskId: crypto.randomUUID(),
74
+ status: "working",
75
+ createdAt: timestamp,
76
+ lastUpdatedAt: timestamp,
77
+ ttlMs,
78
+ pollIntervalMs: 1000,
79
+ };
80
+ validateTask(task);
81
+ const record = {
82
+ identity: structuredClone(identity),
83
+ task,
84
+ revision: 0,
85
+ original: { method: "tools/call", params: structuredClone(params) },
86
+ backend: {},
87
+ issued: {},
88
+ answered: {},
89
+ cancelRequested: false,
90
+ dispatch: "pending",
91
+ };
92
+ await store.create(record);
93
+ // A crash or ambiguous start remains a durable pending intent for recover().
94
+ await executor.start(structuredClone(record));
95
+ await update(identity, task.taskId, (next) => ({
96
+ ...next,
97
+ dispatch: "started",
98
+ }));
99
+ return { resultType: "task", ...structuredClone(task) };
100
+ },
101
+ async get(identity, taskId) {
102
+ return {
103
+ ...(await load(identity, taskId)).task,
104
+ resultType: "complete",
105
+ };
106
+ },
107
+ transition,
108
+ async submitInput(identity, taskId, responses) {
109
+ validateInputResponses(responses);
110
+ const original = await load(identity, taskId);
111
+ await options.authorizeInput(identity, original, responses);
112
+ let accepted = {};
113
+ const next = await update(identity, taskId, (record) => {
114
+ accepted = {};
115
+ const task = waitingTask(record.task);
116
+ for (const [key, response] of Object.entries(responses))
117
+ if (Object.hasOwn(task.inputRequests, key) &&
118
+ !Object.hasOwn(record.answered, key)) {
119
+ const request = task.inputRequests[key];
120
+ if (request?.method === "elicitation/create")
121
+ validateElicitationResponse(request.params, response);
122
+ accepted = { ...accepted, [key]: structuredClone(response) };
123
+ record.answered = {
124
+ ...record.answered,
125
+ [key]: structuredClone(response),
126
+ };
127
+ delete task.inputRequests[key];
128
+ }
129
+ if (!Object.keys(accepted).length)
130
+ fail("Task is not waiting for input");
131
+ if (!Object.keys(task.inputRequests).length) {
132
+ const { inputRequests: _requests, status: _status, ...base } = task;
133
+ record.task = { ...base, status: "working" };
134
+ }
135
+ return record;
136
+ });
137
+ if (Object.keys(accepted).length)
138
+ await executor.submitInput(next, accepted);
139
+ return { resultType: "complete" };
140
+ },
141
+ async cancel(identity, taskId) {
142
+ const record = await update(identity, taskId, (next) => {
143
+ next.cancelRequested = true;
144
+ return next;
145
+ });
146
+ if (!terminal(record.task))
147
+ await executor.requestCancel(record);
148
+ return { resultType: "complete" };
149
+ },
150
+ async recover() {
151
+ await store.expire(now());
152
+ for (const record of await store.list())
153
+ if (!terminal(record.task)) {
154
+ let recovered;
155
+ try {
156
+ recovered = await executor.recover(record);
157
+ }
158
+ catch {
159
+ /* Persist truthful JSON-RPC recovery failure below. */
160
+ }
161
+ if (!recovered)
162
+ recovered = {
163
+ taskId: record.task.taskId,
164
+ createdAt: record.task.createdAt,
165
+ lastUpdatedAt: new Date(now()).toISOString(),
166
+ ttlMs: record.task.ttlMs,
167
+ status: "failed",
168
+ error: {
169
+ code: -32603,
170
+ message: "Task backend recovery unavailable; execution was not replayed",
171
+ },
172
+ };
173
+ await transition(record.identity, record.task.taskId, recovered);
174
+ }
175
+ },
176
+ };
177
+ }
178
+ export async function pollTask(options) {
179
+ await options.persist(options.taskId);
180
+ const answered = new Set();
181
+ const sleep = options.sleep ??
182
+ ((ms, signal) => new Promise((resolve, reject) => {
183
+ const abort = () => {
184
+ clearTimeout(timer);
185
+ reject(signal?.reason ?? new Error("Polling aborted"));
186
+ };
187
+ const timer = setTimeout(() => {
188
+ signal?.removeEventListener("abort", abort);
189
+ resolve();
190
+ }, ms);
191
+ if (signal?.aborted)
192
+ abort();
193
+ else
194
+ signal?.addEventListener("abort", abort, { once: true });
195
+ }));
196
+ for (let poll = 0; poll < (options.maxPolls ?? 10_000); poll++) {
197
+ options.signal?.throwIfAborted();
198
+ const task = await options.get(options.taskId);
199
+ if (task.taskId !== options.taskId)
200
+ fail("Task polling identity mismatch");
201
+ options.observe?.(task);
202
+ if (terminal(task))
203
+ return task;
204
+ if (task.status === "input_required") {
205
+ if (!options.input)
206
+ throw new Error("Task elicitation host unavailable");
207
+ const pending = Object.fromEntries(Object.entries(task.inputRequests).filter(([key]) => !answered.has(key)));
208
+ if (Object.keys(pending).length) {
209
+ const responses = await options.input(pending);
210
+ if (Object.keys(responses).some((key) => !Object.hasOwn(pending, key)))
211
+ fail("Unexpected task input response");
212
+ await options.update(options.taskId, responses);
213
+ for (const key of Object.keys(responses))
214
+ answered.add(key);
215
+ }
216
+ }
217
+ await sleep(Math.max(1, task.pollIntervalMs ?? 1000), options.signal);
218
+ }
219
+ throw new Error("Task polling limit exceeded; durable task remains available");
220
+ }
221
+ export { validateTaskRecord } from "./record.js";
@@ -0,0 +1,3 @@
1
+ import type { McpTaskRecord } from "./contracts.js";
2
+ /** Local persisted envelope boundary; wire payloads use common protocol validators. */
3
+ export declare function validateTaskRecord(value: unknown): McpTaskRecord;
@@ -0,0 +1,37 @@
1
+ import { type } from "arktype";
2
+ import { validateTask, validateInputRequests, validateInputResponses, } from "../../protocol/index.js";
3
+ const recordShape = type({
4
+ identity: {
5
+ tenantId: "string > 0",
6
+ principalId: "string > 0",
7
+ authorizationContextId: "string > 0",
8
+ },
9
+ task: "object",
10
+ revision: "number.integer >= 0",
11
+ original: { method: "'tools/call'", params: "object" },
12
+ backend: { "[string]": "string" },
13
+ issued: "object",
14
+ answered: "object",
15
+ cancelRequested: "boolean",
16
+ dispatch: "'pending' | 'started'",
17
+ });
18
+ /** Local persisted envelope boundary; wire payloads use common protocol validators. */
19
+ export function validateTaskRecord(value) {
20
+ const result = recordShape(value);
21
+ if (result instanceof type.errors)
22
+ throw new Error(`Invalid persisted task: ${result.summary}`);
23
+ validateTask(result.task, true);
24
+ validateInputRequests(result.issued);
25
+ validateInputResponses(result.answered);
26
+ const record = result;
27
+ for (const key of Object.keys(record.answered))
28
+ if (!Object.hasOwn(record.issued, key))
29
+ throw new Error("Answered input was never issued");
30
+ if (record.task.status === "input_required")
31
+ for (const key of Object.keys(record.task.inputRequests)) {
32
+ if (!Object.hasOwn(record.issued, key) ||
33
+ Object.hasOwn(record.answered, key))
34
+ throw new Error("Inconsistent outstanding task input");
35
+ }
36
+ return record;
37
+ }
@@ -3,7 +3,7 @@ import type { FetchLike } from "@intx/harness";
3
3
  import type { TenantEnv } from "@intx/hub-api";
4
4
  import { type CredentialCipher } from "@intx/types";
5
5
  import type { Hono, MiddlewareHandler } from "hono";
6
- import { type McpTool, type McpServerInfo } from "../client.js";
6
+ import { type McpCacheScope, type McpTool, type McpServerInfo } from "../client.js";
7
7
  export type MountMcpDiscoveryOpts = {
8
8
  readonly db: DB["db"];
9
9
  readonly cipher: CredentialCipher;
@@ -30,6 +30,8 @@ export type McpCredential = {
30
30
  readonly secret: string;
31
31
  /** The provider's API origin the secret is pinned to, when it has one. */
32
32
  readonly origin?: string;
33
+ /** Host-stored OAuth audience; extraOrigins never overrides this binding. */
34
+ readonly resource?: string;
33
35
  };
34
36
  /**
35
37
  * Read a tenant credential's decrypted secret and the origin it is pinned to.
@@ -46,6 +48,8 @@ export declare function readCredential(opts: {
46
48
  export type McpDiscovery = {
47
49
  readonly serverInfo: McpServerInfo;
48
50
  readonly tools: readonly McpTool[];
51
+ readonly ttlMs?: number;
52
+ readonly cacheScope?: McpCacheScope;
49
53
  };
50
54
  /**
51
55
  * Handshake and read a server's catalog over a fetch pinned to the
@@ -7,7 +7,7 @@ import { credentialAad } from "@intx/types";
7
7
  import { createOriginPinnedFetch, MCP_NO_TOKEN_SENTINEL, } from "@corbits/credential-http";
8
8
  import { type } from "arktype";
9
9
  import { and, eq } from "drizzle-orm";
10
- import { McpError, mcpInitialize, mcpListTools, } from "../client.js";
10
+ import { McpError, mcpConnect, mcpListTools, } from "../client.js";
11
11
  import { parseMcpEndpoint } from "../url.js";
12
12
  const DiscoverBody = type({
13
13
  url: "string > 0",
@@ -34,6 +34,7 @@ export async function readCredential(opts) {
34
34
  status: credential.status,
35
35
  expiresAt: credential.expiresAt,
36
36
  apiBaseUrl: provider.apiBaseUrl,
37
+ metadata: credential.metadata,
37
38
  })
38
39
  .from(credential)
39
40
  .innerJoin(provider, eq(provider.id, credential.providerId))
@@ -45,10 +46,23 @@ export async function readCredential(opts) {
45
46
  (row.expiresAt !== null && row.expiresAt.getTime() <= Date.now())) {
46
47
  return undefined;
47
48
  }
49
+ const metadata = type({ "oauthResource?": "string > 0" })(row.metadata ?? {});
50
+ if (metadata instanceof type.errors)
51
+ return undefined;
52
+ const resource = metadata.oauthResource;
53
+ if (resource !== undefined)
54
+ parseMcpEndpoint(resource);
48
55
  const secret = await opts.cipher.decrypt(row.secret, credentialAad(row.id, "secret"));
49
- return row.apiBaseUrl === null
50
- ? { secret }
51
- : { secret, origin: new URL(row.apiBaseUrl).origin };
56
+ return {
57
+ secret,
58
+ ...(row.apiBaseUrl !== null
59
+ ? { origin: parseMcpEndpoint(row.apiBaseUrl).origin }
60
+ : {}),
61
+ ...(resource !== undefined ? { resource } : {}),
62
+ };
63
+ }
64
+ /** Only this local class is safe to expose; peer McpError text is untrusted. */
65
+ class DiscoveryPolicyError extends McpError {
52
66
  }
53
67
  const HEADER_VALUE = /^[\t\x20-\x7e]*$/;
54
68
  /** A 3xx would send the bearer onward, so the pinned fetch's manual redirect
@@ -57,7 +71,7 @@ function refusingRedirects(inner) {
57
71
  return async (input, init) => {
58
72
  const response = await inner(input, init);
59
73
  if (response.status >= 300 && response.status < 400) {
60
- throw new McpError(`the MCP server answered a ${String(response.status)} redirect; refusing to follow it`);
74
+ throw new DiscoveryPolicyError(`the MCP server answered a ${String(response.status)} redirect; refusing to follow it`);
61
75
  }
62
76
  return response;
63
77
  };
@@ -70,23 +84,30 @@ const DISCOVERY_TIMEOUT_MS = 30_000;
70
84
  */
71
85
  export async function discoverMcpServer(args) {
72
86
  const target = parseMcpEndpoint(args.url);
87
+ if (args.credential?.resource !== undefined &&
88
+ args.credential.resource !== args.url) {
89
+ throw new DiscoveryPolicyError("credential resource binding does not match the MCP endpoint");
90
+ }
73
91
  const secret = args.credential?.secret;
74
92
  const token = secret === undefined || secret === MCP_NO_TOKEN_SENTINEL
75
93
  ? undefined
76
94
  : secret;
77
95
  const origin = token === undefined ? target.origin : args.credential?.origin;
78
96
  if (origin === undefined) {
79
- throw new McpError("the credential's provider has no API origin to pin to");
97
+ throw new DiscoveryPolicyError("the credential's provider has no API origin to pin to");
80
98
  }
81
99
  // A secret that is not a valid header value makes fetch throw a message
82
100
  // quoting it, so it is refused here with a fixed one.
83
101
  if (token !== undefined && !HEADER_VALUE.test(token)) {
84
- throw new McpError("the credential's secret is not a valid header value");
102
+ throw new DiscoveryPolicyError("the credential's secret is not a valid header value");
85
103
  }
86
- const extraOrigins = Object.entries(args.extraOrigins ?? {}).flatMap(([pinned, extra]) => (new URL(pinned).origin === origin ? extra : []));
87
- const allowed = [origin, ...extraOrigins.map((o) => new URL(o).origin)];
104
+ const extraOrigins = Object.entries(args.extraOrigins ?? {}).flatMap(([pinned, extra]) => parseMcpEndpoint(pinned).origin === origin ? extra : []);
105
+ const allowed = [
106
+ origin,
107
+ ...extraOrigins.map((o) => parseMcpEndpoint(o).origin),
108
+ ];
88
109
  if (!allowed.includes(target.origin)) {
89
- throw new McpError(`credential is pinned to ${origin}; refusing cross-origin request to ${target.origin}`);
110
+ throw new DiscoveryPolicyError(`credential is pinned to ${origin}; refusing cross-origin request to ${target.origin}`);
90
111
  }
91
112
  const pinned = refusingRedirects(createOriginPinnedFetch({
92
113
  origin,
@@ -99,7 +120,7 @@ export async function discoverMcpServer(args) {
99
120
  fetch: pinned,
100
121
  timeoutMs: args.timeoutMs ?? DISCOVERY_TIMEOUT_MS,
101
122
  };
102
- const session = await mcpInitialize(args.url, client);
123
+ const session = await mcpConnect(args.url, client);
103
124
  const tools = await mcpListTools(args.url, { ...client, session });
104
125
  return {
105
126
  serverInfo: {
@@ -109,6 +130,8 @@ export async function discoverMcpServer(args) {
109
130
  : {}),
110
131
  },
111
132
  tools,
133
+ ...(tools.ttlMs !== undefined ? { ttlMs: tools.ttlMs } : {}),
134
+ ...(tools.cacheScope !== undefined ? { cacheScope: tools.cacheScope } : {}),
112
135
  };
113
136
  }
114
137
  /**
@@ -157,12 +180,12 @@ export function mountMcpDiscovery(app, opts) {
157
180
  // Only this package's own messages are passed on: anything else, such
158
181
  // as a fetch error, may quote the material the request carried. An
159
182
  // upstream status is not echoed, so the route is no port-probing oracle.
160
- const error = cause instanceof McpError && cause.status === undefined
183
+ const error = cause instanceof DiscoveryPolicyError
161
184
  ? cause
162
185
  : new McpError(cause instanceof McpError
163
186
  ? "the server refused the request"
164
187
  : "the handshake failed");
165
- opts.onError?.(error, { url: body.url });
188
+ opts.onError?.(error, { url: `${target.origin}${target.pathname}` });
166
189
  return c.json({
167
190
  error: `the MCP server at ${target.origin} could not be discovered: ${error.message}`,
168
191
  }, 422);
package/dist/index.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { mcpTools, type McpServerConfig, type McpToolsEnv, type McpToolsOptions, } from "./tool.js";
2
2
  export { qualifiedName, isAskExempt, toolDescription } from "./naming.js";
3
- export { mcpInitialize, mcpListTools, mcpCallTool, McpError, McpToolSchema, type McpTool, type McpToolResult, type McpClientOptions, type McpSession, } from "./client.js";
4
- export { discoverMcpLoginEntry, registerMcpClient, mcpClientConfig, selectMcpScopes, type DiscoverMcpLoginEntryOptions, type McpAuthorizationServer, type McpClientConfigOptions, type McpClientRegistration, type McpLoginEntry, type RegisterMcpClientOptions, type SelectMcpScopesOptions, } from "./oauth-discovery.js";
3
+ export { MCP_PROTOCOL_VERSION, MCP_EXT_TASKS, MCP_EXT_UI, MCP_EXT_SKILLS, McpErrorCode, mcpConnect, mcpInitialize, mcpListTools, mcpCallTool, mcpContinue, mcpListPrompts, mcpGetPrompt, mcpListResources, mcpReadResource, mcpListResourceTemplates, mcpComplete, mcpListen, mcpGetTask, mcpUpdateTask, mcpCancelTask, mcpListSkills, mcpGetSkill, mcpReadResourceDirectory, McpError, McpToolSchema, type McpTool, type McpToolList, type McpToolResult, type McpToolCallResult, type McpInputRequired, type McpTask, type McpTaskHandle, type McpProgress, type McpPrompt, type McpPromptResult, type McpResource, type McpResourceReadResult, type McpResourceTemplate, type McpCompletion, type McpCompletionRef, type McpSubscriptionFilter, type McpNotification, type McpSkill, type McpSkillResult, type McpCacheScope, type McpClientOptions, type McpClientCapabilities, type McpSession, } from "./client.js";
4
+ export { discoverMcpLoginEntry, registerMcpClient, mcpClientConfig, mcpClientIdMetadataDocument, preferredMcpClientRegistration, resolveStoredMcpClient, assertAuthorizationIssuer, readAuthorizationCallback, selectMcpScopes, type DiscoverMcpLoginEntryOptions, type McpAuthorizationServer, type McpClientConfigOptions, type McpClientIdMetadataDocument, type McpClientRegistration, type McpClientRegistrationPlan, type McpLoginEntry, type RegisterMcpClientOptions, type SelectMcpScopesOptions, type StoredMcpClient, type AuthorizationIssuerOptions, } from "./oauth-discovery.js";
5
+ export * from "./auth/index.js";
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  export { mcpTools, } from "./tool.js";
2
2
  export { qualifiedName, isAskExempt, toolDescription } from "./naming.js";
3
- export { mcpInitialize, mcpListTools, mcpCallTool, McpError, McpToolSchema, } from "./client.js";
4
- export { discoverMcpLoginEntry, registerMcpClient, mcpClientConfig, selectMcpScopes, } from "./oauth-discovery.js";
3
+ export { MCP_PROTOCOL_VERSION, MCP_EXT_TASKS, MCP_EXT_UI, MCP_EXT_SKILLS, McpErrorCode, mcpConnect, mcpInitialize, mcpListTools, mcpCallTool, mcpContinue, mcpListPrompts, mcpGetPrompt, mcpListResources, mcpReadResource, mcpListResourceTemplates, mcpComplete, mcpListen, mcpGetTask, mcpUpdateTask, mcpCancelTask, mcpListSkills, mcpGetSkill, mcpReadResourceDirectory, McpError, McpToolSchema, } from "./client.js";
4
+ export { discoverMcpLoginEntry, registerMcpClient, mcpClientConfig, mcpClientIdMetadataDocument, preferredMcpClientRegistration, resolveStoredMcpClient, assertAuthorizationIssuer, readAuthorizationCallback, selectMcpScopes, } from "./oauth-discovery.js";
5
+ // Guarded OAuth host APIs; standalone client imports remain dependency-light.
6
+ export * from "./auth/index.js";
@@ -0,0 +1,4 @@
1
+ import type { McpClientOptions } from "../../client.js";
2
+ import type { McpHostServices } from "./contracts.js";
3
+ export declare function assertHostExtensions(extensions?: readonly string[], host?: McpHostServices): void;
4
+ export declare function hostClientOptions(host: McpHostServices | undefined, extensions?: readonly string[]): Pick<McpClientOptions, "clientCapabilities">;
@@ -0,0 +1,53 @@
1
+ import { type } from "arktype";
2
+ import { getAppHostRequirements } from "../../extensions/apps.js";
3
+ import { supportedHostExtensions } from "../../extensions/maturity.js";
4
+ import { createElicitationRuntime } from "../../extensions/elicitation.js";
5
+ import { MCP_EXT_TASKS, MCP_EXT_UI, MCP_EXT_SKILLS, } from "../../protocol/index.js";
6
+ // Host configuration boundary, not an MCP wire schema. Support is explicitly
7
+ // declared by the embedding host; we do not implement or certify its renderer.
8
+ const AppsHostSchema = type({
9
+ bridge: { mount: "Function", call: "Function" },
10
+ support: {
11
+ sandboxRenderer: "boolean",
12
+ cspEnforcement: "boolean",
13
+ authenticatedBridge: "boolean",
14
+ },
15
+ });
16
+ export function assertHostExtensions(extensions = [], host = {}) {
17
+ if (extensions.includes(MCP_EXT_TASKS) && host.adoptTask === undefined)
18
+ throw new Error("MCP tasks require a host adoption and delivery service");
19
+ if (extensions.includes(MCP_EXT_UI)) {
20
+ const apps = AppsHostSchema(host.apps);
21
+ if (apps instanceof type.errors ||
22
+ !supportedHostExtensions({ apps: apps.support }).includes(MCP_EXT_UI) ||
23
+ typeof host.presentResult !== "function") {
24
+ throw new Error("MCP Apps require an explicit host bridge, sandbox renderer, CSP enforcement, authenticated bridge, and presentation service");
25
+ }
26
+ }
27
+ if (extensions.includes(MCP_EXT_SKILLS))
28
+ throw new Error("Use createMcpSkillConsumer for skills; tool factories do not activate skills");
29
+ }
30
+ export function hostClientOptions(host, extensions = []) {
31
+ assertHostExtensions(extensions, host);
32
+ const elicitation = host?.elicitation === undefined
33
+ ? {}
34
+ : createElicitationRuntime(host.elicitation).capabilities;
35
+ const hasElicitation = Object.keys(elicitation).length > 0;
36
+ if (!hasElicitation && !extensions.includes(MCP_EXT_UI))
37
+ return {};
38
+ return {
39
+ clientCapabilities: {
40
+ ...(hasElicitation ? { elicitation } : {}),
41
+ ...(extensions.length === 0
42
+ ? {}
43
+ : {
44
+ extensions: Object.fromEntries(extensions.map((id) => [
45
+ id,
46
+ id === MCP_EXT_UI
47
+ ? { mimeTypes: [getAppHostRequirements().mimeType] }
48
+ : {},
49
+ ])),
50
+ }),
51
+ },
52
+ };
53
+ }
@@ -0,0 +1,14 @@
1
+ import { type McpTool } from "../../client.js";
2
+ import type { McpHostContext, McpHostServices } from "./contracts.js";
3
+ /** Expiry is host-computed from discovery time and server TTL. Private catalogs
4
+ * must remain scoped to one host authorization context, not a global cache. */
5
+ export interface McpCatalogSnapshot {
6
+ readonly tools: readonly McpTool[];
7
+ readonly expiresAt: number;
8
+ }
9
+ /** Install as host.validateCatalog. Refresh never mutates an active tool bundle:
10
+ * any changed entry requires a rebuilt factory and fresh approval derivation. */
11
+ export declare function createCatalogGuard(options: {
12
+ refresh: (context: McpHostContext) => Promise<McpCatalogSnapshot>;
13
+ now?: () => number;
14
+ }): NonNullable<McpHostServices["validateCatalog"]>;
@@ -0,0 +1,52 @@
1
+ import { type } from "arktype";
2
+ import { McpToolSchema } from "../../client.js";
3
+ /** Install as host.validateCatalog. Refresh never mutates an active tool bundle:
4
+ * any changed entry requires a rebuilt factory and fresh approval derivation. */
5
+ export function createCatalogGuard(options) {
6
+ const catalogs = new Map();
7
+ const now = options.now ?? Date.now;
8
+ return async (context, tool) => {
9
+ context.signal.throwIfAborted();
10
+ const key = JSON.stringify([context.server, context.url]);
11
+ let snapshot = await catalogs.get(key);
12
+ if (snapshot === undefined || snapshot.expiresAt <= now()) {
13
+ const pending = (async () => {
14
+ const refreshed = await options.refresh(context);
15
+ if (!Number.isFinite(refreshed.expiresAt) ||
16
+ refreshed.expiresAt <= now()) {
17
+ throw new Error("MCP catalog refresh returned an expired snapshot");
18
+ }
19
+ const parsed = McpToolSchema.array()(refreshed.tools);
20
+ if (parsed instanceof type.errors) {
21
+ throw new Error(`Invalid refreshed MCP catalog: ${parsed.summary}`);
22
+ }
23
+ return structuredClone(refreshed);
24
+ })();
25
+ catalogs.set(key, pending);
26
+ try {
27
+ snapshot = await pending;
28
+ }
29
+ catch (cause) {
30
+ if (catalogs.get(key) === pending)
31
+ catalogs.delete(key);
32
+ throw cause;
33
+ }
34
+ }
35
+ context.signal.throwIfAborted();
36
+ const matches = snapshot.tools.filter((candidate) => candidate.name === tool.name);
37
+ if (matches.length !== 1 || canonical(matches[0]) !== canonical(tool)) {
38
+ throw new Error("MCP catalog changed; rebuild the tool factory and re-evaluate approvals before calling");
39
+ }
40
+ };
41
+ }
42
+ function canonical(value) {
43
+ if (Array.isArray(value))
44
+ return `[${value.map(canonical).join(",")}]`;
45
+ if (value !== null && typeof value === "object") {
46
+ return `{${Object.entries(value)
47
+ .sort(([a], [b]) => a.localeCompare(b))
48
+ .map(([key, entry]) => `${JSON.stringify(key)}:${canonical(entry)}`)
49
+ .join(",")}}`;
50
+ }
51
+ return JSON.stringify(value) ?? "null";
52
+ }
@@ -0,0 +1,47 @@
1
+ import type { createAppHostBridge } from "../../extensions/apps.js";
2
+ import type { supportedHostExtensions } from "../../extensions/maturity.js";
3
+ import type { McpElicitationHost } from "../../extensions/elicitation.js";
4
+ import type { McpInputRequired, McpTaskHandle, McpTool, McpToolResult, mcpGetTask, mcpUpdateTask, mcpCancelTask } from "../../client.js";
5
+ /** Host-bound identity, never credentials or model-supplied routing. */
6
+ export interface McpHostContext {
7
+ readonly server: string;
8
+ readonly url: string;
9
+ readonly tool: string;
10
+ readonly callId: string;
11
+ readonly signal: AbortSignal;
12
+ }
13
+ export interface McpTaskControls {
14
+ get: () => ReturnType<typeof mcpGetTask>;
15
+ /** The host must collect and authorize responses to outstanding inputs. */
16
+ update: (responses: Parameters<typeof mcpUpdateTask>[2]) => ReturnType<typeof mcpUpdateTask>;
17
+ /** Cooperative intent, not proof that remote work stopped. */
18
+ cancel: () => ReturnType<typeof mcpCancelTask>;
19
+ }
20
+ /** Optional services are absent capabilities, never implicit approval. */
21
+ export interface McpHostServices {
22
+ /** Explicit host-provided Apps runtime, required only for UI opt-in. The host
23
+ * must use this bridge for UI mounting/tool calls and fulfill the support
24
+ * obligations; structural validation cannot certify a deployed renderer. */
25
+ apps?: {
26
+ readonly bridge: ReturnType<typeof createAppHostBridge>;
27
+ readonly support: NonNullable<Parameters<typeof supportedHostExtensions>[0]["apps"]>;
28
+ };
29
+ /** Concrete form/URL services; only these advertise elicitation support. */
30
+ elicitation?: McpElicitationHost;
31
+ /** Present elicitation to a user and authorize each answer. Undefined declines.
32
+ * This does not substitute for the Interchange tool invocation approval gate. */
33
+ requestInput?: (context: McpHostContext, pending: McpInputRequired) => Promise<Record<string, unknown> | undefined>;
34
+ /** Persist correlation and arrange eventual Agent.deliver before returning.
35
+ * Controls are ephemeral; durable recovery must reacquire a mediated client. */
36
+ adoptTask?: (context: McpHostContext, task: McpTaskHandle, controls: McpTaskControls) => Promise<{
37
+ correlationId: string;
38
+ }>;
39
+ /** Host-only rich content/UI channel; metadata is never model text.
40
+ * This callback alone does not imply Apps rendering support. When opting into
41
+ * UI, wire presentation to the explicitly supplied apps bridge. */
42
+ presentResult?: (context: McpHostContext, result: McpToolResult) => Promise<void>;
43
+ /** Check freshness in the host's identity partition before execution. Reject
44
+ * changed schemas/visibility/approval hints and rebuild the synchronous factory;
45
+ * never mutate definitions or grants underneath a running agent. */
46
+ validateCatalog?: (context: McpHostContext, tool: McpTool) => Promise<void>;
47
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,6 @@
1
+ import type { ToolResult } from "@intx/types/runtime";
2
+ import { type McpClientOptions, type McpToolCallResult } from "../../client.js";
3
+ import type { McpHostContext, McpHostServices } from "./contracts.js";
4
+ export declare function shapeMcpContent(content: unknown): string;
5
+ /** One lifecycle and rich-result projection for live and stored catalogs. */
6
+ export declare function projectToolResult(context: McpHostContext, initial: McpToolCallResult, client: McpClientOptions, host?: McpHostServices): Promise<ToolResult>;