@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.
@@ -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 { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
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
- headers ? { requestInit: { headers } } : undefined,
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 = Math.max(1, Math.trunc(args.limit ?? 25));
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
- return {
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
- if (!Array.isArray(args.addresses)) {
423
- throw new Error("addresses must be an array");
424
- }
430
+ const addresses = discoveryAddresses(args.addresses);
425
431
  const format = args.format ?? "compact";
426
- return {
427
- tools: args.addresses.map((rawAddress) => {
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 { RemoteMcpOptions, RemoteMcpAuth } from "./connectors/remote-mcp.js";
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
- const limit = Math.max(1, Math.trunc(args.limit ?? DEFAULT_SEARCH_LIMIT));
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 jsonResult({
1054
- connectors: groups,
1055
- total: matches.length,
1056
- offset,
1057
- limit,
1058
- hasMore: nextOffset !== undefined,
1059
- ...(nextOffset !== undefined ? { nextOffset } : {}),
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 jsonResult({ tools: out });
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
- 'Start here when a tool address is unknown. Exact/name matches rank above description matches; an empty query browses all. includeSchemas="compact" usually removes the describe_tools round trip.';
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
- const target = new URL(`${targetPath}${url.search}`, publicUrl);
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
@@ -4,4 +4,4 @@
4
4
  * a bump that forgets this file fails the build rather than shipping a stale
5
5
  * version to `/health` and to downstream MCP handshakes.
6
6
  */
7
- export const CONNECTA_VERSION = "0.7.0";
7
+ export const CONNECTA_VERSION = "0.7.1";