@zackbart/connecta 0.7.0 → 0.7.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 +38 -0
- package/dist/connectors/remote-mcp.d.ts +24 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +91 -9
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js +10 -8
- package/dist/execute.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +25 -0
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +115 -9
- package/dist/meta-tools.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +9 -1
- package/dist/server.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
- package/src/connectors/remote-mcp.ts +141 -10
- package/src/execute.ts +18 -7
- package/src/index.ts +5 -1
- package/src/meta-tools.ts +151 -17
- package/src/server.ts +9 -1
- package/src/version.ts +1 -1
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
2
2
|
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
3
3
|
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
-
import type {
|
|
4
|
+
import type {
|
|
5
|
+
FetchLike,
|
|
6
|
+
Transport,
|
|
7
|
+
} from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
5
8
|
import { CfWorkerJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/cfworker";
|
|
6
9
|
import { KvOAuthProvider } from "../auth/downstream-oauth.js";
|
|
7
10
|
import { ConnectorCallError } from "../errors.js";
|
|
@@ -18,6 +21,8 @@ export type RemoteMcpAuth =
|
|
|
18
21
|
| { type: "headers"; headers: Record<string, string> }
|
|
19
22
|
| { type: "oauth" };
|
|
20
23
|
|
|
24
|
+
export type RemoteMcpRedirectPolicy = "none" | "same-origin";
|
|
25
|
+
|
|
21
26
|
export interface RemoteMcpOptions {
|
|
22
27
|
url: string;
|
|
23
28
|
/** Human-readable display name; the connector id remains the address prefix. */
|
|
@@ -37,6 +42,14 @@ export interface RemoteMcpOptions {
|
|
|
37
42
|
*/
|
|
38
43
|
usageGuide?: string;
|
|
39
44
|
auth?: RemoteMcpAuth;
|
|
45
|
+
/**
|
|
46
|
+
* Downstream HTTP redirect policy. Defaults to `"none"`: every redirect is
|
|
47
|
+
* rejected. `"same-origin"` follows at most five redirects while preserving
|
|
48
|
+
* standard 301/302/303/307/308 method semantics. Cross-origin redirects and
|
|
49
|
+
* HTTPS downgrades are always refused, so credentials never cross the
|
|
50
|
+
* configured request's origin.
|
|
51
|
+
*/
|
|
52
|
+
redirects?: RemoteMcpRedirectPolicy;
|
|
40
53
|
/**
|
|
41
54
|
* Refuse to connect to a non-`https://` `url` at construction (default
|
|
42
55
|
* false). Loopback hosts (`localhost`, `127.0.0.1`, `[::1]`) are always
|
|
@@ -208,6 +221,127 @@ function isLoopbackHost(hostname: string): boolean {
|
|
|
208
221
|
);
|
|
209
222
|
}
|
|
210
223
|
|
|
224
|
+
export const MAX_REMOTE_REDIRECT_HOPS = 5;
|
|
225
|
+
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
226
|
+
const BODY_HEADERS = [
|
|
227
|
+
"content-encoding",
|
|
228
|
+
"content-language",
|
|
229
|
+
"content-length",
|
|
230
|
+
"content-location",
|
|
231
|
+
"content-type",
|
|
232
|
+
"transfer-encoding",
|
|
233
|
+
];
|
|
234
|
+
|
|
235
|
+
export class RemoteMcpRedirectError extends ConnectorCallError {
|
|
236
|
+
constructor(connectorId: string, reason: string) {
|
|
237
|
+
super(
|
|
238
|
+
"connector_call_failed",
|
|
239
|
+
`Connector "${connectorId}" redirect policy rejected the downstream response: ${reason}.`,
|
|
240
|
+
);
|
|
241
|
+
this.name = "RemoteMcpRedirectError";
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function redirectedInit(init: RequestInit, status: number): RequestInit {
|
|
246
|
+
const method = (init.method ?? "GET").toUpperCase();
|
|
247
|
+
const becomesGet =
|
|
248
|
+
(status === 303 && method !== "GET" && method !== "HEAD") ||
|
|
249
|
+
((status === 301 || status === 302) && method === "POST");
|
|
250
|
+
if (!becomesGet) return init;
|
|
251
|
+
const headers = new Headers(init.headers);
|
|
252
|
+
for (const name of BODY_HEADERS) headers.delete(name);
|
|
253
|
+
return { ...init, method: "GET", body: undefined, headers };
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Wrap fetch with explicit, bounded redirect handling.
|
|
258
|
+
*
|
|
259
|
+
* The starting URL of each fetch call is trusted by its caller (the configured
|
|
260
|
+
* MCP endpoint, or an OAuth URL discovered by the pinned SDK). Only Location
|
|
261
|
+
* values are policy-controlled here. No rejected target is ever fetched, so
|
|
262
|
+
* arbitrary static header names receive the same protection as Authorization.
|
|
263
|
+
*/
|
|
264
|
+
export function redirectSafeFetch(
|
|
265
|
+
connectorId: string,
|
|
266
|
+
policy: RemoteMcpRedirectPolicy = "none",
|
|
267
|
+
baseFetch: FetchLike = fetch,
|
|
268
|
+
): FetchLike {
|
|
269
|
+
return async (input, initialInit = {}) => {
|
|
270
|
+
let current = new URL(input);
|
|
271
|
+
let init = initialInit;
|
|
272
|
+
const seen = new Set<string>([current.href]);
|
|
273
|
+
let hops = 0;
|
|
274
|
+
|
|
275
|
+
while (true) {
|
|
276
|
+
const response = await baseFetch(current, {
|
|
277
|
+
...init,
|
|
278
|
+
redirect: "manual",
|
|
279
|
+
});
|
|
280
|
+
if (!REDIRECT_STATUSES.has(response.status)) return response;
|
|
281
|
+
|
|
282
|
+
const location = response.headers.get("location");
|
|
283
|
+
await response.body?.cancel().catch(() => {});
|
|
284
|
+
if (!location) {
|
|
285
|
+
throw new RemoteMcpRedirectError(
|
|
286
|
+
connectorId,
|
|
287
|
+
`HTTP ${response.status} carried no Location header`,
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
if (policy === "none") {
|
|
291
|
+
throw new RemoteMcpRedirectError(
|
|
292
|
+
connectorId,
|
|
293
|
+
`HTTP ${response.status} redirects are disabled`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
if (hops >= MAX_REMOTE_REDIRECT_HOPS) {
|
|
297
|
+
throw new RemoteMcpRedirectError(
|
|
298
|
+
connectorId,
|
|
299
|
+
`the redirect chain exceeded ${MAX_REMOTE_REDIRECT_HOPS} hops`,
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
let next: URL;
|
|
304
|
+
try {
|
|
305
|
+
next = new URL(location, current);
|
|
306
|
+
} catch {
|
|
307
|
+
throw new RemoteMcpRedirectError(
|
|
308
|
+
connectorId,
|
|
309
|
+
`HTTP ${response.status} carried an invalid Location header`,
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
if (current.protocol === "https:" && next.protocol !== "https:") {
|
|
313
|
+
throw new RemoteMcpRedirectError(
|
|
314
|
+
connectorId,
|
|
315
|
+
"an HTTPS-to-HTTP downgrade is not allowed",
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
if (next.origin !== current.origin) {
|
|
319
|
+
throw new RemoteMcpRedirectError(
|
|
320
|
+
connectorId,
|
|
321
|
+
"a cross-origin redirect is not allowed",
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
if (next.username || next.password) {
|
|
325
|
+
throw new RemoteMcpRedirectError(
|
|
326
|
+
connectorId,
|
|
327
|
+
"a redirect target containing URL credentials is not allowed",
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
if (seen.has(next.href)) {
|
|
331
|
+
throw new RemoteMcpRedirectError(
|
|
332
|
+
connectorId,
|
|
333
|
+
"the redirect chain loops",
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
seen.add(next.href);
|
|
338
|
+
hops++;
|
|
339
|
+
init = redirectedInit(init, response.status);
|
|
340
|
+
current = next;
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
211
345
|
interface ConnectionState {
|
|
212
346
|
client: Client | null;
|
|
213
347
|
transport: Transport | null;
|
|
@@ -324,30 +458,27 @@ export function remoteMcp(id: string, opts: RemoteMcpOptions): Connector {
|
|
|
324
458
|
return state.provider;
|
|
325
459
|
};
|
|
326
460
|
|
|
327
|
-
// NOTE: StreamableHTTPClientTransport speaks over fetch, which transparently
|
|
328
|
-
// follows 3xx redirects. A malicious or compromised downstream MCP could
|
|
329
|
-
// redirect to an internal address (e.g. http://169.254.169.254/…) and fetch
|
|
330
|
-
// would re-issue the request — potentially carrying static auth headers. The
|
|
331
|
-
// scheme check above only guards the first hop; a fully robust guard (manual
|
|
332
|
-
// redirect handling + per-hop re-validation + stripping auth headers cross-
|
|
333
|
-
// origin) lives in the SDK transport and is deferred to a future non-patch
|
|
334
|
-
// release rather than reimplemented here.
|
|
335
461
|
const buildTransport = (
|
|
336
462
|
ctx: ConnectorContext,
|
|
337
463
|
state: ConnectionState,
|
|
338
464
|
): Transport => {
|
|
339
465
|
if (opts._transportFactory) return opts._transportFactory(ctx);
|
|
340
466
|
const url = new URL(opts.url);
|
|
467
|
+
const guardedFetch = redirectSafeFetch(id, opts.redirects);
|
|
341
468
|
if (opts.auth?.type === "oauth") {
|
|
342
469
|
return new StreamableHTTPClientTransport(url, {
|
|
343
470
|
authProvider: getProvider(ctx, state),
|
|
471
|
+
fetch: guardedFetch,
|
|
344
472
|
});
|
|
345
473
|
}
|
|
346
474
|
const headers =
|
|
347
475
|
opts.auth?.type === "headers" ? opts.auth.headers : undefined;
|
|
348
476
|
return new StreamableHTTPClientTransport(
|
|
349
477
|
url,
|
|
350
|
-
|
|
478
|
+
{
|
|
479
|
+
...(headers ? { requestInit: { headers } } : {}),
|
|
480
|
+
fetch: guardedFetch,
|
|
481
|
+
},
|
|
351
482
|
);
|
|
352
483
|
};
|
|
353
484
|
|
package/src/execute.ts
CHANGED
|
@@ -3,6 +3,9 @@ import { z } from "zod";
|
|
|
3
3
|
import { compactSchema, rankTools, summarizeDescription } from "./catalog.js";
|
|
4
4
|
import { recordToolActivity, type ActivityRequestContext } from "./activity.js";
|
|
5
5
|
import {
|
|
6
|
+
assertDiscoveryResultSize,
|
|
7
|
+
discoveryAddresses,
|
|
8
|
+
discoverySearchLimit,
|
|
6
9
|
errorResult,
|
|
7
10
|
jsonResult,
|
|
8
11
|
serializeResultText,
|
|
@@ -369,7 +372,7 @@ export async function buildSandboxProviders(
|
|
|
369
372
|
}
|
|
370
373
|
matches.sort((a, b) => b.score - a.score || a.order - b.order);
|
|
371
374
|
const offset = Math.max(0, Math.trunc(args.offset ?? 0));
|
|
372
|
-
const limit =
|
|
375
|
+
const limit = discoverySearchLimit(args.limit);
|
|
373
376
|
const page = matches.slice(offset, offset + limit).map((match) => {
|
|
374
377
|
const input = match.tool.inputSchema ?? { type: "object" };
|
|
375
378
|
return {
|
|
@@ -404,7 +407,7 @@ export async function buildSandboxProviders(
|
|
|
404
407
|
offset + page.length < matches.length
|
|
405
408
|
? offset + page.length
|
|
406
409
|
: undefined;
|
|
407
|
-
|
|
410
|
+
const result = {
|
|
408
411
|
tools: page,
|
|
409
412
|
total: matches.length,
|
|
410
413
|
offset,
|
|
@@ -412,6 +415,11 @@ export async function buildSandboxProviders(
|
|
|
412
415
|
hasMore: nextOffset !== undefined,
|
|
413
416
|
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
414
417
|
};
|
|
418
|
+
assertDiscoveryResultSize(
|
|
419
|
+
result,
|
|
420
|
+
"Request a smaller limit, omit fullDescriptions, or use compact schemas.",
|
|
421
|
+
);
|
|
422
|
+
return result;
|
|
415
423
|
},
|
|
416
424
|
describe: async (raw: unknown) => {
|
|
417
425
|
const args = (raw ?? {}) as {
|
|
@@ -419,12 +427,10 @@ export async function buildSandboxProviders(
|
|
|
419
427
|
format?: "compact" | "json";
|
|
420
428
|
fullDescriptions?: boolean;
|
|
421
429
|
};
|
|
422
|
-
|
|
423
|
-
throw new Error("addresses must be an array");
|
|
424
|
-
}
|
|
430
|
+
const addresses = discoveryAddresses(args.addresses);
|
|
425
431
|
const format = args.format ?? "compact";
|
|
426
|
-
|
|
427
|
-
tools:
|
|
432
|
+
const result = {
|
|
433
|
+
tools: addresses.map((rawAddress) => {
|
|
428
434
|
const address = String(rawAddress);
|
|
429
435
|
const resolved = registry.resolveAddress(address);
|
|
430
436
|
if (!resolved) {
|
|
@@ -457,6 +463,11 @@ export async function buildSandboxProviders(
|
|
|
457
463
|
};
|
|
458
464
|
}),
|
|
459
465
|
};
|
|
466
|
+
assertDiscoveryResultSize(
|
|
467
|
+
result,
|
|
468
|
+
'Split the address list or use format: "compact".',
|
|
469
|
+
);
|
|
470
|
+
return result;
|
|
460
471
|
},
|
|
461
472
|
},
|
|
462
473
|
});
|
package/src/index.ts
CHANGED
|
@@ -574,7 +574,11 @@ export type {
|
|
|
574
574
|
CredentialHealthRecord,
|
|
575
575
|
} from "./credential-health.js";
|
|
576
576
|
|
|
577
|
-
export type {
|
|
577
|
+
export type {
|
|
578
|
+
RemoteMcpOptions,
|
|
579
|
+
RemoteMcpAuth,
|
|
580
|
+
RemoteMcpRedirectPolicy,
|
|
581
|
+
} from "./connectors/remote-mcp.js";
|
|
578
582
|
export type { ApiOptions, ApiTool } from "./connectors/api.js";
|
|
579
583
|
export type {
|
|
580
584
|
Connector,
|
package/src/meta-tools.ts
CHANGED
|
@@ -65,12 +65,126 @@ function msg(err: unknown): string {
|
|
|
65
65
|
return err instanceof Error ? err.message : String(err);
|
|
66
66
|
}
|
|
67
67
|
|
|
68
|
-
const DEFAULT_SEARCH_LIMIT = 25;
|
|
68
|
+
export const DEFAULT_SEARCH_LIMIT = 25;
|
|
69
|
+
/**
|
|
70
|
+
* A discovery page is for choosing the next tool, not exporting the catalog.
|
|
71
|
+
* One hundred leaves room for broad browsing while keeping each deliberate
|
|
72
|
+
* page far below the catalog sizes Connecta supports.
|
|
73
|
+
*/
|
|
74
|
+
export const MAX_SEARCH_LIMIT = 100;
|
|
75
|
+
/** Same one-request work bound for address-based discovery. */
|
|
76
|
+
export const MAX_DESCRIBE_ADDRESSES = 100;
|
|
77
|
+
/**
|
|
78
|
+
* Final UTF-8 ceiling for a generated search/describe response. The count
|
|
79
|
+
* limits are the ordinary guard; this catches unusually large full schemas or
|
|
80
|
+
* descriptions that make even a bounded page expensive.
|
|
81
|
+
*/
|
|
82
|
+
export const MAX_DISCOVERY_RESULT_BYTES = 256_000;
|
|
69
83
|
const enc = new TextEncoder();
|
|
70
84
|
const dec = new TextDecoder();
|
|
71
85
|
|
|
72
86
|
type ErrorDetails = CallErrorDetails;
|
|
73
87
|
|
|
88
|
+
export class DiscoveryPolicyError extends Error {
|
|
89
|
+
constructor(
|
|
90
|
+
readonly code: "invalid_args" | "result_too_large",
|
|
91
|
+
message: string,
|
|
92
|
+
) {
|
|
93
|
+
super(message);
|
|
94
|
+
this.name = "DiscoveryPolicyError";
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** Validate before ranking so a huge page request does no proportional work. */
|
|
99
|
+
export function discoverySearchLimit(value: unknown): number {
|
|
100
|
+
if (value === undefined) return DEFAULT_SEARCH_LIMIT;
|
|
101
|
+
if (
|
|
102
|
+
typeof value !== "number" ||
|
|
103
|
+
!Number.isInteger(value) ||
|
|
104
|
+
value < 1 ||
|
|
105
|
+
value > MAX_SEARCH_LIMIT
|
|
106
|
+
) {
|
|
107
|
+
throw new DiscoveryPolicyError(
|
|
108
|
+
"invalid_args",
|
|
109
|
+
`limit must be a whole number from 1 through ${MAX_SEARCH_LIMIT}. Page through larger catalogs with offset.`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
return value;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Validate the raw list so duplicate addresses consume the same bound. */
|
|
116
|
+
export function discoveryAddresses(value: unknown): unknown[] {
|
|
117
|
+
if (!Array.isArray(value)) {
|
|
118
|
+
throw new DiscoveryPolicyError(
|
|
119
|
+
"invalid_args",
|
|
120
|
+
"addresses must be an array.",
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
if (value.length > MAX_DESCRIBE_ADDRESSES) {
|
|
124
|
+
throw new DiscoveryPolicyError(
|
|
125
|
+
"invalid_args",
|
|
126
|
+
`addresses must contain at most ${MAX_DESCRIBE_ADDRESSES} entries. Split a larger list across describe_tools calls.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
return value;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Serialize once and count the exact bytes jsonResult would emit. */
|
|
133
|
+
function boundedDiscoveryText(
|
|
134
|
+
value: unknown,
|
|
135
|
+
hint: string,
|
|
136
|
+
): string {
|
|
137
|
+
const text = JSON.stringify(value, null, 2);
|
|
138
|
+
if (text === undefined) {
|
|
139
|
+
throw new TypeError("Discovery result is not JSON-serializable.");
|
|
140
|
+
}
|
|
141
|
+
const bytes = enc.encode(text).length;
|
|
142
|
+
if (bytes > MAX_DISCOVERY_RESULT_BYTES) {
|
|
143
|
+
throw new DiscoveryPolicyError(
|
|
144
|
+
"result_too_large",
|
|
145
|
+
`Discovery result is ${bytes} UTF-8 bytes, over the ${MAX_DISCOVERY_RESULT_BYTES}-byte ceiling. ${hint}`,
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
return text;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Apply the same final result guard to code-mode discovery helpers. */
|
|
152
|
+
export function assertDiscoveryResultSize(
|
|
153
|
+
value: unknown,
|
|
154
|
+
hint: string,
|
|
155
|
+
): void {
|
|
156
|
+
boundedDiscoveryText(value, hint);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function discoveryErrorResult(error: DiscoveryPolicyError): ToolResult {
|
|
160
|
+
const result = jsonResult({
|
|
161
|
+
error: {
|
|
162
|
+
code: error.code,
|
|
163
|
+
message: error.message,
|
|
164
|
+
retryable: false,
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
result.isError = true;
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function discoveryResult(value: unknown, hint: string): ToolResult {
|
|
172
|
+
try {
|
|
173
|
+
const text = boundedDiscoveryText(value, hint);
|
|
174
|
+
return {
|
|
175
|
+
content: [{ type: "text", text }],
|
|
176
|
+
...(value !== null && typeof value === "object" && !Array.isArray(value)
|
|
177
|
+
? { structuredContent: value as Record<string, unknown> }
|
|
178
|
+
: {}),
|
|
179
|
+
};
|
|
180
|
+
} catch (err) {
|
|
181
|
+
if (err instanceof DiscoveryPolicyError) {
|
|
182
|
+
return discoveryErrorResult(err);
|
|
183
|
+
}
|
|
184
|
+
throw err;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
74
188
|
/**
|
|
75
189
|
* The longest the engine will park a synchronous inbound request in *waiting
|
|
76
190
|
* alone*. The engine already treats ~15 s as the outer bound of one reasonable
|
|
@@ -939,7 +1053,15 @@ export function createMetaTools(
|
|
|
939
1053
|
|
|
940
1054
|
async searchTools(args: SearchArgs): Promise<ToolResult> {
|
|
941
1055
|
const q = args.query ?? "";
|
|
942
|
-
|
|
1056
|
+
let limit: number;
|
|
1057
|
+
try {
|
|
1058
|
+
limit = discoverySearchLimit(args.limit);
|
|
1059
|
+
} catch (err) {
|
|
1060
|
+
if (err instanceof DiscoveryPolicyError) {
|
|
1061
|
+
return discoveryErrorResult(err);
|
|
1062
|
+
}
|
|
1063
|
+
throw err;
|
|
1064
|
+
}
|
|
943
1065
|
const offset = Math.max(0, Math.trunc(args.offset ?? 0));
|
|
944
1066
|
const conns = args.connector
|
|
945
1067
|
? [registry.getConnector(args.connector)].filter(
|
|
@@ -1050,17 +1172,28 @@ export function createMetaTools(
|
|
|
1050
1172
|
offset + page.length < matches.length
|
|
1051
1173
|
? offset + page.length
|
|
1052
1174
|
: undefined;
|
|
1053
|
-
return
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1175
|
+
return discoveryResult(
|
|
1176
|
+
{
|
|
1177
|
+
connectors: groups,
|
|
1178
|
+
total: matches.length,
|
|
1179
|
+
offset,
|
|
1180
|
+
limit,
|
|
1181
|
+
hasMore: nextOffset !== undefined,
|
|
1182
|
+
...(nextOffset !== undefined ? { nextOffset } : {}),
|
|
1183
|
+
},
|
|
1184
|
+
"Request a smaller limit, omit fullDescriptions, or use compact schemas.",
|
|
1185
|
+
);
|
|
1061
1186
|
},
|
|
1062
1187
|
|
|
1063
1188
|
async describeTools(args: DescribeArgs): Promise<ToolResult> {
|
|
1189
|
+
try {
|
|
1190
|
+
discoveryAddresses(args.addresses);
|
|
1191
|
+
} catch (err) {
|
|
1192
|
+
if (err instanceof DiscoveryPolicyError) {
|
|
1193
|
+
return discoveryErrorResult(err);
|
|
1194
|
+
}
|
|
1195
|
+
throw err;
|
|
1196
|
+
}
|
|
1064
1197
|
const format = args.format ?? "compact";
|
|
1065
1198
|
const resolved = args.addresses.map((address) => ({
|
|
1066
1199
|
address,
|
|
@@ -1131,7 +1264,10 @@ export function createMetaTools(
|
|
|
1131
1264
|
...(tool.annotations ? { annotations: tool.annotations } : {}),
|
|
1132
1265
|
};
|
|
1133
1266
|
});
|
|
1134
|
-
return
|
|
1267
|
+
return discoveryResult(
|
|
1268
|
+
{ tools: out },
|
|
1269
|
+
'Split the address list or use format: "compact".',
|
|
1270
|
+
);
|
|
1135
1271
|
},
|
|
1136
1272
|
|
|
1137
1273
|
async callTool(args: CallArgs): Promise<ToolResult> {
|
|
@@ -1327,10 +1463,8 @@ export function createMetaTools(
|
|
|
1327
1463
|
|
|
1328
1464
|
const LIST_DESC =
|
|
1329
1465
|
"List connectors with status, cached tool count, and recent real-call health. Use probe=false for a fast inventory; use probe=true (default) only to diagnose live health or authorization.";
|
|
1330
|
-
const SEARCH_DESC =
|
|
1331
|
-
|
|
1332
|
-
const DESCRIBE_DESC =
|
|
1333
|
-
'Inspect known tool addresses when search_tools did not include a sufficient schema. Returns descriptions, input/output schemas, and behavior annotations; format "compact" is the default.';
|
|
1466
|
+
const SEARCH_DESC = `Start here when a tool address is unknown. Exact/name matches rank above description matches; an empty query browses all. Pages contain at most ${MAX_SEARCH_LIMIT} tools. includeSchemas="compact" usually removes the describe_tools round trip.`;
|
|
1467
|
+
const DESCRIBE_DESC = `Inspect up to ${MAX_DESCRIBE_ADDRESSES} known tool addresses when search_tools did not include a sufficient schema. Returns descriptions, input/output schemas, and behavior annotations; format "compact" is the default.`;
|
|
1334
1468
|
const CALL_DESC =
|
|
1335
1469
|
'Use for one tool explicitly annotated readOnlyHint: true. For 2–10 independent read-only calls use batch_call; for dependent steps or data reduction use execute_code when available. Unannotated, write-capable, and destructive tools are refused and require call_destructive_tool. fields selects JSON dot-paths, resultMode "value" unwraps results, timeoutMs sets a deadline, safe maxRetries are annotation-gated, diagnostics adds timing, and large results page through get_result.';
|
|
1336
1470
|
const CALL_DESTRUCTIVE_DESC =
|
|
@@ -1444,7 +1578,7 @@ export function registerMetaTools(
|
|
|
1444
1578
|
inputSchema: {
|
|
1445
1579
|
query: z.string().optional(),
|
|
1446
1580
|
connector: z.string().optional(),
|
|
1447
|
-
limit: z.number().int().positive().optional(),
|
|
1581
|
+
limit: z.number().int().positive().max(MAX_SEARCH_LIMIT).optional(),
|
|
1448
1582
|
offset: z.number().int().nonnegative().optional(),
|
|
1449
1583
|
fullDescriptions: z.boolean().optional(),
|
|
1450
1584
|
includeSchemas: z.enum(["compact", "json"]).optional(),
|
|
@@ -1459,7 +1593,7 @@ export function registerMetaTools(
|
|
|
1459
1593
|
{
|
|
1460
1594
|
description: describedFor(registry, DESCRIBE_DESC, "describe"),
|
|
1461
1595
|
inputSchema: {
|
|
1462
|
-
addresses: z.array(z.string()),
|
|
1596
|
+
addresses: z.array(z.string()).max(MAX_DESCRIBE_ADDRESSES),
|
|
1463
1597
|
format: z.enum(["compact", "json"]).optional(),
|
|
1464
1598
|
fullDescriptions: z.boolean().optional(),
|
|
1465
1599
|
},
|
package/src/server.ts
CHANGED
|
@@ -1088,7 +1088,15 @@ export function createFetchHandler(
|
|
|
1088
1088
|
// Canonicalize the legacy bookmark while upgrading it so an old /ui URL
|
|
1089
1089
|
// reaches the new Connections entry point in one permanent redirect.
|
|
1090
1090
|
const targetPath = path === "/ui" ? "/" : url.pathname;
|
|
1091
|
-
|
|
1091
|
+
// Assign the path and query onto the configured URL instead of resolving
|
|
1092
|
+
// attacker-controlled text against it. A pathname beginning with `//`
|
|
1093
|
+
// (including a backslash form normalized by URL parsing) is an authority
|
|
1094
|
+
// when passed to `new URL(value, base)` and would otherwise replace the
|
|
1095
|
+
// deployment host.
|
|
1096
|
+
const target = new URL(publicUrl);
|
|
1097
|
+
target.pathname = targetPath;
|
|
1098
|
+
target.search = url.search;
|
|
1099
|
+
target.hash = "";
|
|
1092
1100
|
return withSecurityHeaders(
|
|
1093
1101
|
new Response(null, {
|
|
1094
1102
|
status: 308,
|
package/src/version.ts
CHANGED