@zackbart/connecta 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +249 -0
  4. package/SECURITY.md +25 -0
  5. package/assets/connecta-clay-hero.png +0 -0
  6. package/dist/activity.d.ts +82 -0
  7. package/dist/activity.d.ts.map +1 -0
  8. package/dist/activity.js +51 -0
  9. package/dist/activity.js.map +1 -0
  10. package/dist/auth/bearer.d.ts +10 -0
  11. package/dist/auth/bearer.d.ts.map +1 -0
  12. package/dist/auth/bearer.js +47 -0
  13. package/dist/auth/bearer.js.map +1 -0
  14. package/dist/auth/clerk.d.ts +20 -0
  15. package/dist/auth/clerk.d.ts.map +1 -0
  16. package/dist/auth/clerk.js +167 -0
  17. package/dist/auth/clerk.js.map +1 -0
  18. package/dist/auth/downstream-oauth.d.ts +78 -0
  19. package/dist/auth/downstream-oauth.d.ts.map +1 -0
  20. package/dist/auth/downstream-oauth.js +180 -0
  21. package/dist/auth/downstream-oauth.js.map +1 -0
  22. package/dist/catalog.d.ts +11 -0
  23. package/dist/catalog.d.ts.map +1 -0
  24. package/dist/catalog.js +144 -0
  25. package/dist/catalog.js.map +1 -0
  26. package/dist/connectors/api.d.ts +34 -0
  27. package/dist/connectors/api.d.ts.map +1 -0
  28. package/dist/connectors/api.js +36 -0
  29. package/dist/connectors/api.js.map +1 -0
  30. package/dist/connectors/remote-mcp.d.ts +33 -0
  31. package/dist/connectors/remote-mcp.d.ts.map +1 -0
  32. package/dist/connectors/remote-mcp.js +255 -0
  33. package/dist/connectors/remote-mcp.js.map +1 -0
  34. package/dist/credentials.d.ts +33 -0
  35. package/dist/credentials.d.ts.map +1 -0
  36. package/dist/credentials.js +177 -0
  37. package/dist/credentials.js.map +1 -0
  38. package/dist/execute.d.ts +40 -0
  39. package/dist/execute.d.ts.map +1 -0
  40. package/dist/execute.js +424 -0
  41. package/dist/execute.js.map +1 -0
  42. package/dist/executors/quickjs.d.ts +26 -0
  43. package/dist/executors/quickjs.d.ts.map +1 -0
  44. package/dist/executors/quickjs.js +267 -0
  45. package/dist/executors/quickjs.js.map +1 -0
  46. package/dist/favicon.d.ts +2 -0
  47. package/dist/favicon.d.ts.map +1 -0
  48. package/dist/favicon.js +49 -0
  49. package/dist/favicon.js.map +1 -0
  50. package/dist/index.d.ts +85 -0
  51. package/dist/index.d.ts.map +1 -0
  52. package/dist/index.js +73 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/mcp-result.d.ts +9 -0
  55. package/dist/mcp-result.d.ts.map +1 -0
  56. package/dist/mcp-result.js +35 -0
  57. package/dist/mcp-result.js.map +1 -0
  58. package/dist/meta-tools.d.ts +100 -0
  59. package/dist/meta-tools.d.ts.map +1 -0
  60. package/dist/meta-tools.js +797 -0
  61. package/dist/meta-tools.js.map +1 -0
  62. package/dist/node.d.ts +9 -0
  63. package/dist/node.d.ts.map +1 -0
  64. package/dist/node.js +57 -0
  65. package/dist/node.js.map +1 -0
  66. package/dist/registry.d.ts +78 -0
  67. package/dist/registry.d.ts.map +1 -0
  68. package/dist/registry.js +299 -0
  69. package/dist/registry.js.map +1 -0
  70. package/dist/server.d.ts +31 -0
  71. package/dist/server.d.ts.map +1 -0
  72. package/dist/server.js +543 -0
  73. package/dist/server.js.map +1 -0
  74. package/dist/skills.d.ts +8 -0
  75. package/dist/skills.d.ts.map +1 -0
  76. package/dist/skills.js +60 -0
  77. package/dist/skills.js.map +1 -0
  78. package/dist/storage/file.d.ts +8 -0
  79. package/dist/storage/file.d.ts.map +1 -0
  80. package/dist/storage/file.js +53 -0
  81. package/dist/storage/file.js.map +1 -0
  82. package/dist/storage/memory.d.ts +4 -0
  83. package/dist/storage/memory.d.ts.map +1 -0
  84. package/dist/storage/memory.js +29 -0
  85. package/dist/storage/memory.js.map +1 -0
  86. package/dist/types.d.ts +219 -0
  87. package/dist/types.d.ts.map +1 -0
  88. package/dist/types.js +3 -0
  89. package/dist/types.js.map +1 -0
  90. package/dist/ui.d.ts +91 -0
  91. package/dist/ui.d.ts.map +1 -0
  92. package/dist/ui.js +1031 -0
  93. package/dist/ui.js.map +1 -0
  94. package/package.json +96 -0
  95. package/src/activity.ts +144 -0
  96. package/src/auth/bearer.ts +54 -0
  97. package/src/auth/clerk.ts +226 -0
  98. package/src/auth/downstream-oauth.ts +202 -0
  99. package/src/catalog.ts +166 -0
  100. package/src/connectors/api.ts +80 -0
  101. package/src/connectors/remote-mcp.ts +324 -0
  102. package/src/credentials.ts +259 -0
  103. package/src/execute.ts +550 -0
  104. package/src/executors/quickjs.ts +321 -0
  105. package/src/favicon.ts +53 -0
  106. package/src/index.ts +197 -0
  107. package/src/mcp-result.ts +43 -0
  108. package/src/meta-tools.ts +1120 -0
  109. package/src/node.ts +62 -0
  110. package/src/registry.ts +400 -0
  111. package/src/server.ts +758 -0
  112. package/src/skills.ts +63 -0
  113. package/src/storage/file.ts +62 -0
  114. package/src/storage/memory.ts +34 -0
  115. package/src/types.ts +254 -0
  116. package/src/ui.ts +1121 -0
@@ -0,0 +1,202 @@
1
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
2
+ import type {
3
+ OAuthClientInformationMixed,
4
+ OAuthClientMetadata,
5
+ OAuthTokens,
6
+ } from "@modelcontextprotocol/sdk/shared/auth.js";
7
+ import type { KVStorage } from "../types.js";
8
+
9
+ /** A 256-bit random opaque value, hex-encoded — used for the OAuth `state`. */
10
+ function randomState(): string {
11
+ const bytes = new Uint8Array(32);
12
+ crypto.getRandomValues(bytes);
13
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
14
+ }
15
+
16
+ /** Length-safe, constant-time string compare (no early-exit on first mismatch). */
17
+ function timingSafeEqual(a: string, b: string): boolean {
18
+ if (a.length !== b.length) return false;
19
+ let diff = 0;
20
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
21
+ return diff === 0;
22
+ }
23
+
24
+ /**
25
+ * OAuthClientProvider implemented over KVStorage for a single downstream
26
+ * connector. Keys live in the connector's namespace as `oauth:<field>`.
27
+ *
28
+ * Headless twist: redirectToAuthorization() cannot navigate a user agent, so it
29
+ * STORES the authorization URL; the registry surfaces it as status
30
+ * "auth_required" and the operator opens it. The /oauth/callback/<id> route then
31
+ * drives transport.finishAuth(code).
32
+ */
33
+ export class KvOAuthProvider implements OAuthClientProvider {
34
+ /**
35
+ * The force-reauth generation this provider's flow started under, captured by
36
+ * the connector before it drives a connect. `null` until stamped — a provider
37
+ * used outside a connect flow (or in isolation) writes unconditionally.
38
+ */
39
+ private capturedGeneration: number | null = null;
40
+
41
+ constructor(
42
+ private readonly connectorId: string,
43
+ private readonly storage: KVStorage,
44
+ private readonly redirectUri: string,
45
+ ) {}
46
+
47
+ /**
48
+ * Stamp the force-reauth generation the current connect flow started under.
49
+ * Subsequent saveTokens/saveClientInformation writes are dropped if KV's
50
+ * generation has since advanced (a concurrent force wiped credentials) — see
51
+ * isStale(). Called by the connector once per connect, before c.connect().
52
+ */
53
+ captureGeneration(gen: number): void {
54
+ this.capturedGeneration = gen;
55
+ }
56
+
57
+ /**
58
+ * Whether a concurrent force re-auth advanced the KV generation past the one
59
+ * this flow captured. When true, this isolate's SDK is mid-flight against
60
+ * credentials that were just wiped — persisting them would resurrect the
61
+ * wiped-and-reauthorized connector for later isolates. Fails open (writes)
62
+ * when no generation was captured.
63
+ */
64
+ private async isStale(): Promise<boolean> {
65
+ if (this.capturedGeneration === null) return false;
66
+ return (await this.generation()) > this.capturedGeneration;
67
+ }
68
+
69
+ get redirectUrl(): string {
70
+ return this.redirectUri;
71
+ }
72
+
73
+ get clientMetadata(): OAuthClientMetadata {
74
+ return {
75
+ redirect_uris: [this.redirectUri],
76
+ client_name: "connecta",
77
+ grant_types: ["authorization_code", "refresh_token"],
78
+ response_types: ["code"],
79
+ token_endpoint_auth_method: "none",
80
+ };
81
+ }
82
+
83
+ async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
84
+ const raw = await this.storage.get("oauth:client");
85
+ return raw ? (JSON.parse(raw) as OAuthClientInformationMixed) : undefined;
86
+ }
87
+
88
+ async saveClientInformation(
89
+ info: OAuthClientInformationMixed,
90
+ ): Promise<void> {
91
+ // A concurrent force wiped credentials and bumped the generation while this
92
+ // flow ran — skip rather than re-register a client under revoked state. The
93
+ // in-memory SDK keeps this info for the current (doomed) connect, which the
94
+ // connector's post-connect fence then discards.
95
+ if (await this.isStale()) return;
96
+ await this.storage.set("oauth:client", JSON.stringify(info));
97
+ }
98
+
99
+ async tokens(): Promise<OAuthTokens | undefined> {
100
+ const raw = await this.storage.get("oauth:tokens");
101
+ return raw ? (JSON.parse(raw) as OAuthTokens) : undefined;
102
+ }
103
+
104
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
105
+ // Generation-guarded write. The dangerous case: an isolate mid-connect (or
106
+ // mid-refresh) whose SDK just minted fresh tokens against a still-valid
107
+ // grant, racing a force re-auth that wiped KV and bumped the generation.
108
+ // Persisting here would resurrect those tokens for later isolates to read.
109
+ // A silent skip (not a throw) is deliberate: throwing propagates out of the
110
+ // SDK's auth() and fails the in-flight request/connect noisily, whereas
111
+ // skipping lets the in-memory client finish its current operation while
112
+ // leaving KV wiped — the connector's generation check drops that client on
113
+ // its next call. Fails open when no generation was captured, so ordinary
114
+ // token refresh (no force) still persists.
115
+ if (await this.isStale()) return;
116
+ await this.storage.set("oauth:tokens", JSON.stringify(tokens));
117
+ }
118
+
119
+ /**
120
+ * OAuth `state`. The SDK calls this (when present) and appends the value to
121
+ * the authorization URL. We generate a fresh random value and persist it so
122
+ * the public /oauth/callback route can prove the callback belongs to a flow
123
+ * WE started. Without it, anyone holding a pending authorization URL could
124
+ * complete consent with their own account (login CSRF) — PKCE does not help,
125
+ * since the verifier belongs to connecta, not the attacker. Cleared in
126
+ * clearPending() once the flow completes.
127
+ */
128
+ async state(): Promise<string> {
129
+ const value = randomState();
130
+ await this.storage.set("oauth:state", value);
131
+ return value;
132
+ }
133
+
134
+ /**
135
+ * Constant-time check of a callback's `state` against the stored one-shot
136
+ * value. Absent stored state or absent candidate → false (fail closed).
137
+ */
138
+ async verifyState(candidate: string | null): Promise<boolean> {
139
+ const expected = await this.storage.get("oauth:state");
140
+ if (!expected || candidate === null) return false;
141
+ return timingSafeEqual(candidate, expected);
142
+ }
143
+
144
+ async saveCodeVerifier(verifier: string): Promise<void> {
145
+ await this.storage.set("oauth:verifier", verifier);
146
+ }
147
+
148
+ async codeVerifier(): Promise<string> {
149
+ const raw = await this.storage.get("oauth:verifier");
150
+ if (!raw) throw new Error(`No PKCE code verifier for "${this.connectorId}"`);
151
+ return raw;
152
+ }
153
+
154
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
155
+ await this.storage.set("oauth:pending", authorizationUrl.toString());
156
+ }
157
+
158
+ /** The stored authorization URL, if a flow is pending. */
159
+ async pendingAuthorizationUrl(): Promise<string | undefined> {
160
+ return (await this.storage.get("oauth:pending")) ?? undefined;
161
+ }
162
+
163
+ /** Clear one-shot flow state after the callback completes. */
164
+ async clearPending(): Promise<void> {
165
+ await this.storage.delete("oauth:pending");
166
+ await this.storage.delete("oauth:verifier");
167
+ await this.storage.delete("oauth:state");
168
+ }
169
+
170
+ /**
171
+ * Force-reauth generation. Monotonic counter shared across isolates via KV;
172
+ * NOT cleared by clearPending/invalidateCredentials — its whole job is to
173
+ * keep advancing so an isolate holding a client from a prior generation can
174
+ * notice it went stale. Defaults to 0 when never bumped.
175
+ */
176
+ async generation(): Promise<number> {
177
+ const raw = await this.storage.get("oauth:generation");
178
+ const n = raw ? Number(raw) : 0;
179
+ return Number.isFinite(n) ? n : 0;
180
+ }
181
+
182
+ /** Advance the generation (on force re-auth) and return the new value. */
183
+ async bumpGeneration(): Promise<number> {
184
+ const next = (await this.generation()) + 1;
185
+ await this.storage.set("oauth:generation", String(next));
186
+ return next;
187
+ }
188
+
189
+ async invalidateCredentials(
190
+ scope: "all" | "client" | "tokens" | "verifier" | "discovery",
191
+ ): Promise<void> {
192
+ if (scope === "all" || scope === "client") {
193
+ await this.storage.delete("oauth:client");
194
+ }
195
+ if (scope === "all" || scope === "tokens") {
196
+ await this.storage.delete("oauth:tokens");
197
+ }
198
+ if (scope === "all" || scope === "verifier") {
199
+ await this.storage.delete("oauth:verifier");
200
+ }
201
+ }
202
+ }
package/src/catalog.ts ADDED
@@ -0,0 +1,166 @@
1
+ import type { JsonSchema, ToolDef } from "./types.js";
2
+
3
+ const DEFAULT_DESCRIPTION_LENGTH = 240;
4
+
5
+ export function summarizeDescription(
6
+ text: string | undefined,
7
+ full: boolean,
8
+ ): string | undefined {
9
+ if (!text) return undefined;
10
+ if (full) return text;
11
+ const compact = text.replace(/\s+/g, " ").trim();
12
+ if (compact.length <= DEFAULT_DESCRIPTION_LENGTH) return compact;
13
+ return `${compact.slice(0, DEFAULT_DESCRIPTION_LENGTH - 1).trimEnd()}…`;
14
+ }
15
+
16
+ function normalized(text: string): string {
17
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
18
+ }
19
+
20
+ interface SearchDocument {
21
+ tool: ToolDef;
22
+ name: string;
23
+ description: string;
24
+ }
25
+
26
+ const searchDocuments = new WeakMap<ToolDef[], SearchDocument[]>();
27
+
28
+ function documentsFor(tools: ToolDef[]): SearchDocument[] {
29
+ let docs = searchDocuments.get(tools);
30
+ if (!docs) {
31
+ docs = tools.map((tool) => ({
32
+ tool,
33
+ name: normalized(tool.name),
34
+ description: normalized(tool.description ?? ""),
35
+ }));
36
+ searchDocuments.set(tools, docs);
37
+ }
38
+ return docs;
39
+ }
40
+
41
+ function scoreDocument(
42
+ doc: SearchDocument,
43
+ phrase: string,
44
+ terms: string[],
45
+ ): number | null {
46
+ if (!phrase) return 0;
47
+ const haystack = `${doc.name} ${doc.description}`;
48
+ if (!terms.every((term) => haystack.includes(term))) return null;
49
+
50
+ let score = 0;
51
+ if (doc.name === phrase) score += 1_000;
52
+ else if (doc.name.startsWith(phrase)) score += 800;
53
+ else if (doc.name.includes(phrase)) score += 600;
54
+ for (const term of terms) {
55
+ if (doc.name === term) score += 200;
56
+ else if (doc.name.startsWith(term)) score += 120;
57
+ else if (doc.name.includes(term)) score += 80;
58
+ if (doc.description.includes(term)) score += 10;
59
+ }
60
+ return score;
61
+ }
62
+
63
+ /** Rank a connector's tools while caching its normalized plain-data index. */
64
+ export function rankTools(
65
+ tools: ToolDef[],
66
+ query: string,
67
+ ): Array<{ tool: ToolDef; score: number; order: number }> {
68
+ const phrase = normalized(query);
69
+ const terms = phrase.split(/\s+/).filter(Boolean);
70
+ const ranked: Array<{ tool: ToolDef; score: number; order: number }> = [];
71
+ documentsFor(tools).forEach((doc, order) => {
72
+ const score = scoreDocument(doc, phrase, terms);
73
+ if (score !== null) ranked.push({ tool: doc.tool, score, order });
74
+ });
75
+ return ranked;
76
+ }
77
+
78
+ function refName(ref: string): string {
79
+ return ref.split("/").pop() ?? ref;
80
+ }
81
+
82
+ function renderSchema(
83
+ schema: unknown,
84
+ defs: Record<string, unknown>,
85
+ seen: Set<string>,
86
+ depth: number,
87
+ ): string {
88
+ if (depth > 4) return "…";
89
+ if (schema === null || typeof schema !== "object") {
90
+ return JSON.stringify(schema);
91
+ }
92
+ const s = schema as Record<string, unknown>;
93
+
94
+ if (typeof s.$ref === "string") {
95
+ const name = refName(s.$ref);
96
+ if (seen.has(name)) return name;
97
+ const target = defs[name];
98
+ if (target === undefined) return name;
99
+ seen.add(name);
100
+ const rendered = renderSchema(target, defs, seen, depth);
101
+ seen.delete(name);
102
+ return rendered;
103
+ }
104
+
105
+ const union = (s.oneOf ?? s.anyOf) as unknown[] | undefined;
106
+ if (Array.isArray(union)) {
107
+ return (
108
+ union.map((u) => renderSchema(u, defs, seen, depth + 1)).join(" | ") ||
109
+ "unknown"
110
+ );
111
+ }
112
+ if (Array.isArray(s.enum)) {
113
+ return s.enum.map((value) => JSON.stringify(value)).join(" | ");
114
+ }
115
+
116
+ const type = s.type;
117
+ if (type === "array" || s.items) {
118
+ const items = s.items
119
+ ? renderSchema(s.items, defs, seen, depth + 1)
120
+ : "unknown";
121
+ return `${items}[]`;
122
+ }
123
+ if (type === "object" || s.properties) {
124
+ const props = (s.properties ?? {}) as Record<string, unknown>;
125
+ const required = new Set(
126
+ (Array.isArray(s.required) ? s.required : []) as string[],
127
+ );
128
+ const keys = Object.keys(props);
129
+ if (keys.length === 0) return "{}";
130
+ return `{ ${keys
131
+ .map((key) => {
132
+ const optional = required.has(key) ? "" : "?";
133
+ const rendered = renderSchema(props[key], defs, seen, depth + 1);
134
+ const description = (
135
+ props[key] as Record<string, unknown> | null
136
+ )?.description;
137
+ const comment =
138
+ typeof description === "string" ? ` // ${description}` : "";
139
+ return `${key}${optional}: ${rendered}${comment}`;
140
+ })
141
+ .join(", ")} }`;
142
+ }
143
+ if (typeof type === "string") return type;
144
+ if (Array.isArray(type)) return type.join(" | ");
145
+ return JSON.stringify(schema);
146
+ }
147
+
148
+ const compactSchemas = new WeakMap<JsonSchema, string>();
149
+
150
+ /** Render and cache a compact TypeScript-like representation of JSON Schema. */
151
+ export function compactSchema(schema: JsonSchema): string {
152
+ const cached = compactSchemas.get(schema);
153
+ if (cached) return cached;
154
+ const defs = {
155
+ ...((schema.$defs as Record<string, unknown>) ?? {}),
156
+ ...((schema.definitions as Record<string, unknown>) ?? {}),
157
+ };
158
+ let rendered: string;
159
+ try {
160
+ rendered = renderSchema(schema, defs, new Set(), 0);
161
+ } catch {
162
+ rendered = JSON.stringify(schema);
163
+ }
164
+ compactSchemas.set(schema, rendered);
165
+ return rendered;
166
+ }
@@ -0,0 +1,80 @@
1
+ import type {
2
+ Connector,
3
+ ConnectorCredentialConfig,
4
+ ConnectorCredentialValues,
5
+ ConnectorContext,
6
+ CredentialTestResult,
7
+ JsonSchema,
8
+ ToolAnnotations,
9
+ ToolDef,
10
+ } from "../types.js";
11
+
12
+ export interface ApiTool {
13
+ name: string;
14
+ description?: string;
15
+ /** A plain JSON Schema object describing the tool input. */
16
+ inputSchema?: JsonSchema;
17
+ /** A plain JSON Schema object describing the tool's structured output. */
18
+ outputSchema?: JsonSchema;
19
+ /**
20
+ * Standard MCP-style behavior hints. Only an explicit readOnlyHint: true
21
+ * admits the tool to call_tool, batch_call, and execute_code.
22
+ */
23
+ annotations?: ToolAnnotations;
24
+ handler: (args: any, ctx: ConnectorContext) => Promise<unknown> | unknown;
25
+ }
26
+
27
+ export interface ApiOptions {
28
+ /** Human-readable display name; the connector id remains the address prefix. */
29
+ title?: string;
30
+ description?: string;
31
+ /** Optional operator-managed credential exposed through ctx.credential and /ui. */
32
+ credential?: ConnectorCredentialConfig;
33
+ /** Optional validation behind /ui's Test action. */
34
+ testCredential?: (
35
+ value: string,
36
+ ctx: ConnectorContext,
37
+ ) => Promise<CredentialTestResult>;
38
+ /** Optional validation for named multi-field credentials. */
39
+ testCredentials?: (
40
+ values: ConnectorCredentialValues,
41
+ ctx: ConnectorContext,
42
+ ) => Promise<CredentialTestResult>;
43
+ tools: ApiTool[];
44
+ }
45
+
46
+ /**
47
+ * A connector defined entirely in code: static tool defs + fetch handlers.
48
+ * Tool inputs are plain JSON Schema objects (bring your own zod-to-json-schema
49
+ * conversion if you prefer zod). call_tool JSON-wraps the handler's return.
50
+ */
51
+ export function api(id: string, opts: ApiOptions): Connector {
52
+ const defs: ToolDef[] = opts.tools.map((t) => ({
53
+ name: t.name,
54
+ description: t.description,
55
+ inputSchema: t.inputSchema,
56
+ outputSchema: t.outputSchema,
57
+ annotations: t.annotations,
58
+ }));
59
+ const byName = new Map(opts.tools.map((t) => [t.name, t]));
60
+ return {
61
+ id,
62
+ title: opts.title,
63
+ kind: "api",
64
+ description: opts.description,
65
+ credential: opts.credential,
66
+ testCredential: opts.testCredential,
67
+ testCredentials: opts.testCredentials,
68
+ staticTools: defs,
69
+ async listTools() {
70
+ return defs;
71
+ },
72
+ async callTool(name, args, ctx) {
73
+ const tool = byName.get(name);
74
+ if (!tool) {
75
+ throw new Error(`Unknown tool "${name}" on connector "${id}"`);
76
+ }
77
+ return tool.handler(args ?? {}, ctx);
78
+ },
79
+ };
80
+ }