@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.
- package/CHANGELOG.md +90 -0
- package/README.md +9 -8
- package/SECURITY.md +5 -11
- package/dist/auth/downstream-oauth.d.ts +15 -6
- package/dist/auth/downstream-oauth.d.ts.map +1 -1
- package/dist/auth/downstream-oauth.js +60 -11
- package/dist/auth/downstream-oauth.js.map +1 -1
- package/dist/catalog-service.d.ts +8 -0
- package/dist/catalog-service.d.ts.map +1 -1
- package/dist/catalog-service.js +24 -2
- package/dist/catalog-service.js.map +1 -1
- package/dist/catalog.d.ts +34 -1
- package/dist/catalog.d.ts.map +1 -1
- package/dist/catalog.js +264 -40
- package/dist/catalog.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +1 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +65 -50
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/errors.d.ts +1 -1
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +1 -0
- package/dist/errors.js.map +1 -1
- package/dist/execute.d.ts +3 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +50 -13
- package/dist/execute.js.map +1 -1
- package/dist/meta-tools.d.ts +1 -1
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +19 -17
- package/dist/meta-tools.js.map +1 -1
- package/dist/routes/mcp.d.ts.map +1 -1
- package/dist/routes/mcp.js +63 -44
- package/dist/routes/mcp.js.map +1 -1
- package/dist/routes/oauth.js +1 -1
- package/dist/routes/oauth.js.map +1 -1
- package/dist/routes/shared.d.ts +2 -2
- package/dist/routes/shared.d.ts.map +1 -1
- package/dist/skills.d.ts +2 -2
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +7 -7
- package/dist/skills.js.map +1 -1
- package/dist/types.d.ts +6 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -2
- package/src/auth/downstream-oauth.ts +106 -24
- package/src/catalog-service.ts +43 -0
- package/src/catalog.ts +327 -33
- package/src/connectors/remote-mcp.ts +96 -64
- package/src/errors.ts +2 -0
- package/src/execute.ts +55 -12
- package/src/meta-tools.ts +22 -18
- package/src/routes/mcp.ts +70 -44
- package/src/routes/oauth.ts +1 -1
- package/src/routes/shared.ts +2 -2
- package/src/skills.ts +7 -7
- package/src/types.ts +10 -2
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
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
|
|
112
|
-
|
|
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 (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
else if (
|
|
132
|
-
|
|
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 {
|
|
2
|
-
|
|
3
|
-
|
|
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/
|
|
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
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
-
})
|
|
566
|
+
});
|
|
560
567
|
}
|
|
561
568
|
const headers =
|
|
562
569
|
opts.auth?.type === "headers" ? opts.auth.headers : undefined;
|
|
563
|
-
return new StreamableHTTPClientTransport(
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
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
|
-
//
|
|
656
|
-
//
|
|
657
|
-
//
|
|
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
|
-
{
|
|
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
|
-
//
|
|
875
|
-
//
|
|
876
|
-
|
|
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
|
-
|
|
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
|
-
|
|
915
|
-
|
|
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
|
-
|
|
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);
|