@zackbart/connecta 0.8.1 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/CHANGELOG.md +90 -0
  2. package/README.md +9 -8
  3. package/SECURITY.md +5 -11
  4. package/dist/auth/downstream-oauth.d.ts +15 -6
  5. package/dist/auth/downstream-oauth.d.ts.map +1 -1
  6. package/dist/auth/downstream-oauth.js +60 -11
  7. package/dist/auth/downstream-oauth.js.map +1 -1
  8. package/dist/catalog-service.d.ts +8 -0
  9. package/dist/catalog-service.d.ts.map +1 -1
  10. package/dist/catalog-service.js +24 -2
  11. package/dist/catalog-service.js.map +1 -1
  12. package/dist/catalog.d.ts +34 -1
  13. package/dist/catalog.d.ts.map +1 -1
  14. package/dist/catalog.js +264 -40
  15. package/dist/catalog.js.map +1 -1
  16. package/dist/connectors/remote-mcp.d.ts +1 -1
  17. package/dist/connectors/remote-mcp.d.ts.map +1 -1
  18. package/dist/connectors/remote-mcp.js +65 -50
  19. package/dist/connectors/remote-mcp.js.map +1 -1
  20. package/dist/errors.d.ts +1 -1
  21. package/dist/errors.d.ts.map +1 -1
  22. package/dist/errors.js +1 -0
  23. package/dist/errors.js.map +1 -1
  24. package/dist/execute.d.ts +3 -1
  25. package/dist/execute.d.ts.map +1 -1
  26. package/dist/execute.js +50 -13
  27. package/dist/execute.js.map +1 -1
  28. package/dist/meta-tools.d.ts +1 -1
  29. package/dist/meta-tools.d.ts.map +1 -1
  30. package/dist/meta-tools.js +19 -17
  31. package/dist/meta-tools.js.map +1 -1
  32. package/dist/routes/mcp.d.ts.map +1 -1
  33. package/dist/routes/mcp.js +63 -44
  34. package/dist/routes/mcp.js.map +1 -1
  35. package/dist/routes/oauth.js +1 -1
  36. package/dist/routes/oauth.js.map +1 -1
  37. package/dist/routes/shared.d.ts +2 -2
  38. package/dist/routes/shared.d.ts.map +1 -1
  39. package/dist/skills.d.ts +2 -2
  40. package/dist/skills.d.ts.map +1 -1
  41. package/dist/skills.js +7 -7
  42. package/dist/skills.js.map +1 -1
  43. package/dist/types.d.ts +6 -2
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/package.json +3 -2
  48. package/src/auth/downstream-oauth.ts +106 -24
  49. package/src/catalog-service.ts +43 -0
  50. package/src/catalog.ts +327 -33
  51. package/src/connectors/remote-mcp.ts +96 -64
  52. package/src/errors.ts +2 -0
  53. package/src/execute.ts +55 -12
  54. package/src/meta-tools.ts +22 -18
  55. package/src/routes/mcp.ts +70 -44
  56. package/src/routes/oauth.ts +1 -1
  57. package/src/routes/shared.ts +2 -2
  58. package/src/skills.ts +7 -7
  59. package/src/types.ts +10 -2
  60. package/src/version.ts +1 -1
package/src/catalog.ts CHANGED
@@ -13,8 +13,19 @@ export function summarizeDescription(
13
13
  return `${compact.slice(0, DEFAULT_DESCRIPTION_LENGTH - 1).trimEnd()}…`;
14
14
  }
15
15
 
16
+ function lexicalTokens(text: string): string[] {
17
+ return text
18
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
19
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
20
+ .toLowerCase()
21
+ .replace(/[^a-z0-9]+/g, " ")
22
+ .trim()
23
+ .split(/\s+/)
24
+ .filter(Boolean);
25
+ }
26
+
16
27
  function normalized(text: string): string {
17
- return text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
28
+ return lexicalTokens(text).join(" ");
18
29
  }
19
30
 
20
31
  /**
@@ -75,7 +86,14 @@ export function lexicalSearchQuery(query: string): string {
75
86
  interface SearchDocument {
76
87
  tool: ToolDef;
77
88
  name: string;
78
- description: string;
89
+ nameTokens: string[];
90
+ descriptionTokens: string[];
91
+ }
92
+
93
+ interface SearchIndex {
94
+ documents: SearchDocument[];
95
+ nameTokenDocuments: ReadonlyMap<string, readonly ToolDef[]>;
96
+ descriptionTokenDocuments: ReadonlyMap<string, readonly ToolDef[]>;
79
97
  }
80
98
 
81
99
  export type LexicalMatchMode = "all" | "partial";
@@ -86,19 +104,174 @@ export interface RankedTool {
86
104
  order: number;
87
105
  }
88
106
 
89
- const searchDocuments = new WeakMap<ToolDef[], SearchDocument[]>();
107
+ const searchIndexes = new WeakMap<ToolDef[], SearchIndex>();
108
+
109
+ function indexFor(tools: ToolDef[]): SearchIndex {
110
+ let index = searchIndexes.get(tools);
111
+ if (!index) {
112
+ const nameTokenDocuments = new Map<string, ToolDef[]>();
113
+ const descriptionTokenDocuments = new Map<string, ToolDef[]>();
114
+ const addTokens = (
115
+ target: Map<string, ToolDef[]>,
116
+ tokens: string[],
117
+ tool: ToolDef,
118
+ ) => {
119
+ for (const token of new Set(tokens)) {
120
+ const documents = target.get(token) ?? [];
121
+ documents.push(tool);
122
+ target.set(token, documents);
123
+ }
124
+ };
125
+ const documents = tools.map((tool) => {
126
+ const nameTokens = lexicalTokens(tool.name);
127
+ const descriptionTokens = lexicalTokens(tool.description ?? "");
128
+ addTokens(nameTokenDocuments, nameTokens, tool);
129
+ addTokens(descriptionTokenDocuments, descriptionTokens, tool);
130
+ return {
131
+ tool,
132
+ name: nameTokens.join(" "),
133
+ nameTokens,
134
+ descriptionTokens,
135
+ };
136
+ });
137
+ index = {
138
+ documents,
139
+ nameTokenDocuments,
140
+ descriptionTokenDocuments,
141
+ };
142
+ searchIndexes.set(tools, index);
143
+ }
144
+ return index;
145
+ }
90
146
 
91
147
  function documentsFor(tools: ToolDef[]): SearchDocument[] {
92
- let docs = searchDocuments.get(tools);
93
- if (!docs) {
94
- docs = tools.map((tool) => ({
95
- tool,
96
- name: normalized(tool.name),
97
- description: normalized(tool.description ?? ""),
98
- }));
99
- searchDocuments.set(tools, docs);
148
+ return indexFor(tools).documents;
149
+ }
150
+
151
+ /**
152
+ * Whole-token equality is the ordinary lexical match. A deliberately narrow
153
+ * inflection check retains useful singular/plural and past-tense recall
154
+ * without bringing back arbitrary substring matches (`list` must not match
155
+ * `enlist`, and `record` must not match the noun `recording`).
156
+ */
157
+ function inflectionVariants(base: string): string[] {
158
+ return [
159
+ `${base}s`,
160
+ `${base}es`,
161
+ `${base}ed`,
162
+ ...(base.endsWith("e") ? [`${base}d`] : []),
163
+ ...(base.endsWith("y")
164
+ ? [
165
+ `${base.slice(0, -1)}ies`,
166
+ `${base.slice(0, -1)}ied`,
167
+ ]
168
+ : []),
169
+ ];
170
+ }
171
+
172
+ function matchingTokenCandidates(term: string): Set<string> {
173
+ const candidates = new Set([term, ...inflectionVariants(term)]);
174
+ const possibleBases = [
175
+ ...(term.endsWith("s") ? [term.slice(0, -1)] : []),
176
+ ...(term.endsWith("es") ? [term.slice(0, -2)] : []),
177
+ ...(term.endsWith("ed") ? [term.slice(0, -2)] : []),
178
+ ...(term.endsWith("d") ? [term.slice(0, -1)] : []),
179
+ ...(term.endsWith("ies")
180
+ ? [`${term.slice(0, -3)}y`]
181
+ : []),
182
+ ...(term.endsWith("ied")
183
+ ? [`${term.slice(0, -3)}y`]
184
+ : []),
185
+ ];
186
+ for (const base of possibleBases) {
187
+ if (base && inflectionVariants(base).includes(term)) {
188
+ candidates.add(base);
189
+ }
100
190
  }
101
- return docs;
191
+ return candidates;
192
+ }
193
+
194
+ export interface LexicalCorpusStatistics {
195
+ documentCount: number;
196
+ documentFrequency: ReadonlyMap<string, number>;
197
+ nameMatches: ReadonlyMap<string, ReadonlySet<ToolDef>>;
198
+ descriptionMatches: ReadonlyMap<string, ReadonlySet<ToolDef>>;
199
+ }
200
+
201
+ /**
202
+ * Compute query-specific document frequencies across every available catalog.
203
+ * The caller does this once per search and shares the result with each
204
+ * connector rank, so ubiquitous words contribute less than discriminative
205
+ * ones without making any action word a stopword.
206
+ */
207
+ export function lexicalCorpusStatistics(
208
+ toolSets: ToolDef[][],
209
+ query: string,
210
+ ): LexicalCorpusStatistics {
211
+ const terms = [...new Set(lexicalTokens(query))];
212
+ if (terms.length === 0) {
213
+ return {
214
+ documentCount: toolSets.reduce(
215
+ (total, tools) => total + tools.length,
216
+ 0,
217
+ ),
218
+ documentFrequency: new Map(),
219
+ nameMatches: new Map(),
220
+ descriptionMatches: new Map(),
221
+ };
222
+ }
223
+ const nameMatches = new Map<string, Set<ToolDef>>();
224
+ const descriptionMatches = new Map<string, Set<ToolDef>>();
225
+ for (const term of terms) {
226
+ const termNameMatches = new Set<ToolDef>();
227
+ const termDescriptionMatches = new Set<ToolDef>();
228
+ for (const tools of toolSets) {
229
+ const index = indexFor(tools);
230
+ for (const candidate of matchingTokenCandidates(term)) {
231
+ for (const tool of index.nameTokenDocuments.get(candidate) ?? []) {
232
+ termNameMatches.add(tool);
233
+ }
234
+ for (
235
+ const tool of
236
+ index.descriptionTokenDocuments.get(candidate) ?? []
237
+ ) {
238
+ termDescriptionMatches.add(tool);
239
+ }
240
+ }
241
+ }
242
+ nameMatches.set(term, termNameMatches);
243
+ descriptionMatches.set(term, termDescriptionMatches);
244
+ }
245
+ const documentFrequency = new Map(
246
+ terms.map((term) => [
247
+ term,
248
+ new Set([
249
+ ...(nameMatches.get(term) ?? []),
250
+ ...(descriptionMatches.get(term) ?? []),
251
+ ]).size,
252
+ ]),
253
+ );
254
+ return {
255
+ documentCount: toolSets.reduce(
256
+ (total, tools) => total + tools.length,
257
+ 0,
258
+ ),
259
+ documentFrequency,
260
+ nameMatches,
261
+ descriptionMatches,
262
+ };
263
+ }
264
+
265
+ function inverseDocumentFrequency(
266
+ term: string,
267
+ statistics: LexicalCorpusStatistics,
268
+ ): number {
269
+ const frequency = statistics.documentFrequency.get(term) ?? 0;
270
+ return Math.log(
271
+ 1 +
272
+ (statistics.documentCount - frequency + 0.5) /
273
+ (frequency + 0.5),
274
+ );
102
275
  }
103
276
 
104
277
  function scoreDocument(
@@ -106,30 +279,45 @@ function scoreDocument(
106
279
  phrase: string,
107
280
  terms: string[],
108
281
  mode: LexicalMatchMode,
282
+ statistics: LexicalCorpusStatistics,
109
283
  ): number | null {
110
284
  if (!phrase) return 0;
111
- const haystack = `${doc.name} ${doc.description}`;
112
- const matchedTerms = terms.filter((term) => haystack.includes(term));
285
+ const matchedTerms = terms.filter((term) =>
286
+ statistics.nameMatches.get(term)?.has(doc.tool) ||
287
+ statistics.descriptionMatches.get(term)?.has(doc.tool),
288
+ );
113
289
  if (mode === "all" && matchedTerms.length !== terms.length) return null;
114
- if (mode === "partial") {
115
- if (matchedTerms.length === 0) return null;
116
- const nameMatches = matchedTerms.filter((term) =>
117
- doc.name.includes(term),
118
- ).length;
119
- // Coverage wins first, then the number of those terms found in the tool
120
- // name. Catalog order breaks the remaining ties at the caller.
121
- return matchedTerms.length * 1_000 + nameMatches;
290
+ if (matchedTerms.length === 0) return null;
291
+
292
+ // Coverage remains meaningful in partial mode, but is IDF-weighted rather
293
+ // than a raw term count: one rare domain term can beat several ubiquitous
294
+ // catalog verbs.
295
+ let score = matchedTerms.reduce(
296
+ (total, term) =>
297
+ total + 4 * inverseDocumentFrequency(term, statistics),
298
+ 0,
299
+ );
300
+ const phraseWeight = terms.reduce(
301
+ (total, term) =>
302
+ total + inverseDocumentFrequency(term, statistics),
303
+ 0,
304
+ );
305
+ if (doc.name === phrase) score += 40 * phraseWeight;
306
+ else if (doc.name.startsWith(`${phrase} `)) score += 24 * phraseWeight;
307
+ else if (` ${doc.name} `.includes(` ${phrase} `)) {
308
+ score += 16 * phraseWeight;
122
309
  }
123
310
 
124
- let score = 0;
125
- if (doc.name === phrase) score += 1_000;
126
- else if (doc.name.startsWith(phrase)) score += 800;
127
- else if (doc.name.includes(phrase)) score += 600;
128
- for (const term of terms) {
129
- if (doc.name === term) score += 200;
130
- else if (doc.name.startsWith(term)) score += 120;
131
- else if (doc.name.includes(term)) score += 80;
132
- if (doc.description.includes(term)) score += 10;
311
+ for (const term of matchedTerms) {
312
+ const weight = inverseDocumentFrequency(term, statistics);
313
+ if (doc.nameTokens.includes(term)) score += 12 * weight;
314
+ else if (statistics.nameMatches.get(term)?.has(doc.tool)) {
315
+ score += 8 * weight;
316
+ }
317
+ if (doc.descriptionTokens.includes(term)) score += 3 * weight;
318
+ else if (statistics.descriptionMatches.get(term)?.has(doc.tool)) {
319
+ score += 1.5 * weight;
320
+ }
133
321
  }
134
322
  return score;
135
323
  }
@@ -139,12 +327,16 @@ export function rankTools(
139
327
  tools: ToolDef[],
140
328
  query: string,
141
329
  mode: LexicalMatchMode = "all",
330
+ statistics: LexicalCorpusStatistics = lexicalCorpusStatistics(
331
+ [tools],
332
+ query,
333
+ ),
142
334
  ): RankedTool[] {
143
335
  const phrase = normalized(query);
144
- const terms = phrase.split(/\s+/).filter(Boolean);
336
+ const terms = [...new Set(phrase.split(/\s+/).filter(Boolean))];
145
337
  const ranked: RankedTool[] = [];
146
338
  documentsFor(tools).forEach((doc, order) => {
147
- const score = scoreDocument(doc, phrase, terms, mode);
339
+ const score = scoreDocument(doc, phrase, terms, mode, statistics);
148
340
  if (score !== null) ranked.push({ tool: doc.tool, score, order });
149
341
  });
150
342
  return ranked;
@@ -300,3 +492,105 @@ export function compactSchema(schema: JsonSchema): string {
300
492
  compactSchemas.set(schema, rendered);
301
493
  return rendered;
302
494
  }
495
+
496
+ /** The property and required names a schema resolves to, or undefined. */
497
+ export interface SchemaObjectKeys {
498
+ properties: string[];
499
+ required: string[];
500
+ }
501
+
502
+ /**
503
+ * Walk a schema the way renderSchema does — composing `allOf` and resolving
504
+ * `$ref` against the root's `$defs`/`definitions` — and collect the top-level
505
+ * property names it would render. A shallow `Object.keys(schema.properties)`
506
+ * disagrees with the rendered compact schema for exactly the shapes real
507
+ * connectors emit (a top-level `$ref` to a `$defs` entry, or the OpenAPI
508
+ * "extend this base" `allOf`), which is worse than no metadata at all: it
509
+ * reports an empty field list for a tool that plainly has fields.
510
+ *
511
+ * Returns undefined when the schema is not an object shape at all — a union,
512
+ * array, enum, or unresolvable `$ref`. Absent metadata tells a caller to read
513
+ * the rendered schema instead; an empty array would claim the tool takes no
514
+ * fields.
515
+ */
516
+ export function schemaObjectKeys(
517
+ schema: JsonSchema | undefined,
518
+ ): SchemaObjectKeys | undefined {
519
+ if (!schema) return undefined;
520
+ const defs = {
521
+ ...(schema.$defs as Record<string, unknown>),
522
+ ...(schema.definitions as Record<string, unknown>),
523
+ };
524
+ try {
525
+ return objectKeys(schema, defs, new Set(), 0);
526
+ } catch {
527
+ return undefined;
528
+ }
529
+ }
530
+
531
+ /** Merge in declaration order, first occurrence winning, as renderSchema renders. */
532
+ function mergedKeys(
533
+ parts: readonly SchemaObjectKeys[],
534
+ ): SchemaObjectKeys | undefined {
535
+ if (parts.length === 0) return undefined;
536
+ return {
537
+ properties: [...new Set(parts.flatMap((part) => part.properties))],
538
+ required: [...new Set(parts.flatMap((part) => part.required))],
539
+ };
540
+ }
541
+
542
+ /** The key-collecting twin of renderSchema; the branch order must match it. */
543
+ function objectKeys(
544
+ schema: unknown,
545
+ defs: Record<string, unknown>,
546
+ seen: Set<string>,
547
+ depth: number,
548
+ ): SchemaObjectKeys | undefined {
549
+ if (depth > 4) return undefined;
550
+ if (schema === null || typeof schema !== "object") return undefined;
551
+ const s = schema as Record<string, unknown>;
552
+
553
+ if (Array.isArray(s.allOf)) {
554
+ const { allOf: _members, ...own } = s;
555
+ const parts = declaresShape(own)
556
+ ? [objectKeys(own, defs, seen, depth)]
557
+ : [];
558
+ for (const member of s.allOf) {
559
+ parts.push(objectKeys(member, defs, seen, depth + 1));
560
+ }
561
+ // An allOf whose members are not all object shapes renders as an
562
+ // intersection with a non-object half; no single key list describes it.
563
+ return parts.every((part) => part !== undefined)
564
+ ? mergedKeys(parts as SchemaObjectKeys[])
565
+ : undefined;
566
+ }
567
+
568
+ if (typeof s.$ref === "string") {
569
+ const name = refName(s.$ref);
570
+ if (seen.has(name)) return undefined;
571
+ const target = defs[name];
572
+ if (target === undefined) return undefined;
573
+ seen.add(name);
574
+ const resolved = objectKeys(target, defs, seen, depth);
575
+ seen.delete(name);
576
+ return resolved;
577
+ }
578
+
579
+ if (Array.isArray(s.oneOf ?? s.anyOf)) return undefined;
580
+ if (Array.isArray(s.enum)) return undefined;
581
+ if (s.const !== undefined) return undefined;
582
+ if (s.type === "array" || s.items) return undefined;
583
+ if (s.type === "object" || s.properties) {
584
+ const props = s.properties;
585
+ if (props === null || Array.isArray(props) || typeof props !== "object") {
586
+ return { properties: [], required: [] };
587
+ }
588
+ return {
589
+ properties: Object.keys(props as Record<string, unknown>),
590
+ required: Array.isArray(s.required)
591
+ ? s.required.filter((key): key is string => typeof key === "string")
592
+ : [],
593
+ };
594
+ }
595
+ return undefined;
596
+ }
@@ -1,13 +1,17 @@
1
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
- import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
3
- import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
1
+ import {
2
+ Client,
3
+ isInputRequiredResult,
4
+ specTypeSchemas,
5
+ StreamableHTTPClientTransport,
6
+ UnauthorizedError,
7
+ } from "@modelcontextprotocol/client";
4
8
  import type {
5
9
  FetchLike,
10
+ ListToolsResult,
11
+ StandardSchemaV1,
12
+ Tool,
6
13
  Transport,
7
- } from "@modelcontextprotocol/sdk/shared/transport.js";
8
- import { ListToolsResultSchema } from "@modelcontextprotocol/sdk/types.js";
9
- import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
10
- import { z } from "zod";
14
+ } from "@modelcontextprotocol/client";
11
15
  import { KvOAuthProvider } from "../auth/downstream-oauth.js";
12
16
  import { MAX_CATALOG_TOOLS } from "../catalog-limits.js";
13
17
  import { ConnectorCallError } from "../errors.js";
@@ -131,36 +135,29 @@ type ListedTool = Awaited<ReturnType<Client["listTools"]>>["tools"][number];
131
135
  * end-of-pagination as `null`. Only the cursor is widened; every tool and every
132
136
  * other result field still passes through the SDK's pinned schema.
133
137
  */
134
- const CompatibleListToolsResultSchema = ListToolsResultSchema.extend({
135
- nextCursor: z.string().nullable().optional(),
136
- });
137
-
138
- /**
139
- * Re-prime an SDK client's tool-metadata cache from the *full* walked catalog.
140
- *
141
- * The SDK's `Client.listTools()` caches one page at a time and **clears** the
142
- * output-schema validators and task-support sets before each replacement.
143
- * This walk uses `Client.request()` so it can make the narrow null-cursor
144
- * compatibility concession above, then primes the metadata exactly once from
145
- * the complete chain. Otherwise `callTool` would find no validator or task
146
- * requirement for earlier-page tools and enforcement would depend on where a
147
- * tool happened to land, which is not enforcement.
148
- *
149
- * So hand the whole aggregated list back deliberately, once, at the end. The
150
- * SDK types the method `private`, hence the cast; the SDK version is pinned
151
- * exactly and `test/remote-mcp-pagination.test.ts` asserts the method still
152
- * exists, so a bump that renames it fails CI rather than quietly restoring the
153
- * bug.
154
- */
155
- function primeToolMetadata(client: Client, tools: ListedTool[]): void {
156
- const prime = (
157
- client as unknown as {
158
- cacheToolMetadata?: (tools: ListedTool[]) => void;
159
- }
160
- ).cacheToolMetadata;
161
- if (typeof prime !== "function") return;
162
- prime.call(client, tools);
163
- }
138
+ const CompatibleListToolsResultSchema: StandardSchemaV1<
139
+ unknown,
140
+ ListToolsResult
141
+ > = {
142
+ "~standard": {
143
+ version: 1,
144
+ vendor: "connecta",
145
+ validate(value) {
146
+ const normalized =
147
+ typeof value === "object" &&
148
+ value !== null &&
149
+ "nextCursor" in value &&
150
+ value.nextCursor === null
151
+ ? (() => {
152
+ const copy = { ...value };
153
+ delete copy.nextCursor;
154
+ return copy;
155
+ })()
156
+ : value;
157
+ return specTypeSchemas.ListToolsResult["~standard"].validate(normalized);
158
+ },
159
+ },
160
+ };
164
161
 
165
162
  /**
166
163
  * True for a result-parse failure caused by the page's `nextCursor` itself.
@@ -171,13 +168,18 @@ function primeToolMetadata(client: Client, tools: ListedTool[]): void {
171
168
  */
172
169
  function isCursorShapeError(err: unknown): boolean {
173
170
  const issues = (err as { issues?: unknown } | null)?.issues;
174
- return (
171
+ if (
175
172
  Array.isArray(issues) &&
176
173
  issues.some((issue) => {
177
174
  const path = (issue as { path?: unknown }).path;
178
175
  return Array.isArray(path) && path[0] === "nextCursor";
179
176
  })
180
- );
177
+ ) {
178
+ return true;
179
+ }
180
+ // SDK v2 wraps Standard Schema failures in a ProtocolError and preserves the
181
+ // failing path in the message rather than exposing the validator's issues.
182
+ return msg(err).startsWith("Invalid result for tools/list: nextCursor:");
181
183
  }
182
184
 
183
185
  function msg(err: unknown): string {
@@ -393,6 +395,14 @@ export function redirectSafeFetch(
393
395
  interface ConnectionState {
394
396
  client: Client | null;
395
397
  transport: Transport | null;
398
+ /**
399
+ * The last complete raw catalog, retained only for this request scope.
400
+ *
401
+ * SDK v2 exposes `toolDefinition` as the public call-time seam for output
402
+ * validation and header mirroring, replacing the v1 private
403
+ * `cacheToolMetadata` reach-through.
404
+ */
405
+ toolDefinitions: Map<string, Tool>;
396
406
  connecting: Promise<void> | null;
397
407
  authRequired: boolean;
398
408
  provider: KvOAuthProvider | null;
@@ -511,6 +521,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
511
521
  state = {
512
522
  client: null,
513
523
  transport: null,
524
+ toolDefinitions: new Map(),
514
525
  connecting: null,
515
526
  authRequired: false,
516
527
  provider: null,
@@ -549,29 +560,23 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
549
560
  const url = new URL(opts.url);
550
561
  const guardedFetch = redirectSafeFetch(id, opts.redirects);
551
562
  if (opts.auth?.type === "oauth") {
552
- // The SDK class declares `sessionId` as an own `string | undefined`
553
- // property while its Transport interface declares it optional. They are
554
- // runtime-compatible; exact optional types only exposes that declaration
555
- // mismatch at this boundary.
556
563
  return new StreamableHTTPClientTransport(url, {
557
564
  authProvider: provider ?? newProvider(ctx),
558
565
  fetch: guardedFetch,
559
- }) as unknown as Transport;
566
+ });
560
567
  }
561
568
  const headers =
562
569
  opts.auth?.type === "headers" ? opts.auth.headers : undefined;
563
- return new StreamableHTTPClientTransport(
564
- url,
565
- {
566
- ...(headers ? { requestInit: { headers } } : {}),
567
- fetch: guardedFetch,
568
- },
569
- ) as unknown as Transport;
570
+ return new StreamableHTTPClientTransport(url, {
571
+ ...(headers ? { requestInit: { headers } } : {}),
572
+ fetch: guardedFetch,
573
+ });
570
574
  };
571
575
 
572
576
  const reset = (state: ConnectionState) => {
573
577
  state.client = null;
574
578
  state.transport = null;
579
+ state.toolDefinitions.clear();
575
580
  state.connecting = null;
576
581
  state.authRequired = false;
577
582
  state.provider = null;
@@ -652,13 +657,17 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
652
657
  throw operatorDisconnectedError();
653
658
  }
654
659
  provider?.captureGeneration(genAtStart);
655
- // The SDK defaults to AJV, which compiles every advertised outputSchema
656
- // with `new Function`. Cloudflare Workers prohibit dynamic code
657
- // generation, so a remote such as Stripe fails during tools/list unless
658
- // the SDK's edge-safe validator is selected explicitly.
660
+ // SDK v2 selects its validator by runtime export condition: AJV on
661
+ // Node and @cfworker/json-schema under workerd. The Workers-safe path
662
+ // no longer needs Connecta-specific wiring.
659
663
  const c = new Client(
660
664
  { name: "connecta", version: CONNECTA_VERSION },
661
- { jsonSchemaValidator: new CfWorkerJsonSchemaValidator() },
665
+ {
666
+ versionNegotiation: { mode: "auto" },
667
+ // Connecta has no interactive relay. Surface the result manually
668
+ // below as one structured, non-retryable connector failure.
669
+ inputRequired: { autoFulfill: false },
670
+ },
662
671
  );
663
672
  const t = buildTransport(ctx, provider);
664
673
  if (!ownsAttempt()) await abandon(t);
@@ -871,9 +880,9 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
871
880
  `Connector "${id}" kept advertising more tools/list pages after ${MAX_TOOL_PAGES} — refusing to page further.`,
872
881
  );
873
882
  }
874
- // Repair what the per-page listTools calls left behind before any of
875
- // these tools can be called. See primeToolMetadata.
876
- primeToolMetadata(client, listed);
883
+ // Publish definitions only after the full walk succeeds. A later-page
884
+ // failure must not leave a partial validation/header view behind.
885
+ state.toolDefinitions = new Map(listed.map((tool) => [tool.name, tool]));
877
886
  return listed.map((t) => ({
878
887
  name: t.name,
879
888
  ...(t.description !== undefined ? { description: t.description } : {}),
@@ -906,14 +915,32 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
906
915
  await ensureConnected(ctx, state);
907
916
  const client = state.client!;
908
917
  try {
909
- return await client.callTool(
918
+ const toolDefinition = state.toolDefinitions.get(name);
919
+ if (toolDefinition?.execution?.taskSupport === "required") {
920
+ throw new Error(
921
+ `Tool "${name}" requires task-based execution, which Connecta does not support.`,
922
+ );
923
+ }
924
+ const result = await client.callTool(
910
925
  {
911
926
  name,
912
927
  arguments: (args ?? {}) as Record<string, unknown>,
913
928
  },
914
- undefined,
915
- requestOptions(ctx),
929
+ {
930
+ ...requestOptions(ctx),
931
+ allowInputRequired: true,
932
+ ...(toolDefinition ? { toolDefinition } : {}),
933
+ },
916
934
  );
935
+ if (isInputRequiredResult(result)) {
936
+ throw new ConnectorCallError(
937
+ "input_required_unsupported",
938
+ `Connector "${id}" returned input_required for "${name}". ` +
939
+ "Connecta cannot relay multi-round-trip input yet; this " +
940
+ "capability is gated pending real host and downstream adoption.",
941
+ );
942
+ }
943
+ return result;
917
944
  } catch (err) {
918
945
  // A grant revoked after connect surfaces here, not in ensureConnected.
919
946
  if (err instanceof UnauthorizedError) {
@@ -939,6 +966,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
939
966
  const transport = state.transport;
940
967
  state.client = null;
941
968
  state.transport = null;
969
+ state.toolDefinitions.clear();
942
970
  state.connecting = null;
943
971
  state.authRequired = false;
944
972
  state.connectedGeneration = null;
@@ -979,7 +1007,7 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
979
1007
  }
980
1008
  },
981
1009
 
982
- async finishAuth(code, ctx) {
1010
+ async finishAuth(code, ctx, callbackParams) {
983
1011
  const state = stateFor(ctx);
984
1012
  const provider = getProvider(ctx, state);
985
1013
  // verifyState ran on this request-scoped provider first and captured the
@@ -987,7 +1015,11 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
987
1015
  // token write remains tagged with that older generation and is unreadable.
988
1016
  const t = (state.transport ??
989
1017
  buildTransport(ctx, provider)) as StreamableHTTPClientTransport;
990
- await t.finishAuth(code);
1018
+ if (callbackParams !== undefined) {
1019
+ await t.finishAuth(callbackParams);
1020
+ } else {
1021
+ await t.finishAuth(code);
1022
+ }
991
1023
  await provider.clearPending();
992
1024
  // Reset so the next use reconnects with the freshly stored tokens.
993
1025
  reset(state);