@zackbart/connecta 0.4.0 → 0.5.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.
- package/CHANGELOG.md +252 -0
- package/README.md +72 -17
- package/SECURITY.md +10 -6
- package/dist/activity.d.ts +8 -0
- package/dist/activity.d.ts.map +1 -1
- package/dist/activity.js +1 -0
- package/dist/activity.js.map +1 -1
- package/dist/connectors/api.d.ts +23 -0
- package/dist/connectors/api.d.ts.map +1 -1
- package/dist/connectors/api.js +13 -1
- package/dist/connectors/api.js.map +1 -1
- package/dist/connectors/remote-mcp.d.ts +27 -1
- package/dist/connectors/remote-mcp.d.ts.map +1 -1
- package/dist/connectors/remote-mcp.js +31 -0
- package/dist/connectors/remote-mcp.js.map +1 -1
- package/dist/credentials.d.ts +2 -1
- package/dist/credentials.d.ts.map +1 -1
- package/dist/credentials.js +4 -2
- package/dist/credentials.js.map +1 -1
- package/dist/execute.d.ts +4 -4
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.js.map +1 -1
- package/dist/executors/quickjs.d.ts.map +1 -1
- package/dist/executors/quickjs.js +32 -4
- package/dist/executors/quickjs.js.map +1 -1
- package/dist/index.d.ts +51 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +67 -1
- package/dist/index.js.map +1 -1
- package/dist/meta-tools.d.ts +25 -4
- package/dist/meta-tools.d.ts.map +1 -1
- package/dist/meta-tools.js +138 -24
- package/dist/meta-tools.js.map +1 -1
- package/dist/registry.d.ts +183 -2
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +293 -27
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +9 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +96 -12
- package/dist/server.js.map +1 -1
- package/dist/skills.d.ts +52 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +161 -1
- package/dist/skills.js.map +1 -1
- package/dist/storage/file.d.ts.map +1 -1
- package/dist/storage/file.js +19 -3
- package/dist/storage/file.js.map +1 -1
- package/dist/toolkits.d.ts +44 -0
- package/dist/toolkits.d.ts.map +1 -0
- package/dist/toolkits.js +134 -0
- package/dist/toolkits.js.map +1 -0
- package/dist/types.d.ts +20 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/ui.d.ts +29 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +100 -15
- package/dist/ui.js.map +1 -1
- package/dist/validate.d.ts +33 -1
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +32 -2
- package/dist/validate.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +5 -2
- package/src/activity.ts +9 -0
- package/src/connectors/api.ts +36 -1
- package/src/connectors/remote-mcp.ts +67 -0
- package/src/credentials.ts +6 -3
- package/src/execute.ts +4 -4
- package/src/executors/quickjs.ts +32 -4
- package/src/index.ts +141 -2
- package/src/meta-tools.ts +215 -40
- package/src/registry.ts +416 -29
- package/src/server.ts +130 -12
- package/src/skills.ts +184 -1
- package/src/storage/file.ts +18 -2
- package/src/toolkits.ts +215 -0
- package/src/types.ts +20 -1
- package/src/ui.ts +103 -14
- package/src/validate.ts +60 -2
- package/src/version.ts +1 -1
package/src/index.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { CredentialVault } from "./credentials.js";
|
|
2
2
|
import { Registry } from "./registry.js";
|
|
3
3
|
import { createFetchHandler } from "./server.js";
|
|
4
|
+
import { droppedBrandingUrls } from "./ui.js";
|
|
5
|
+
import { resolveToolkits, type ToolkitConfig } from "./toolkits.js";
|
|
4
6
|
import { memoryStorage } from "./storage/memory.js";
|
|
5
7
|
import { CONNECTA_VERSION } from "./version.js";
|
|
6
8
|
import type { ActivityReadGate, ActivityStore } from "./activity.js";
|
|
@@ -15,6 +17,36 @@ import type {
|
|
|
15
17
|
|
|
16
18
|
export interface ConnectaConfig {
|
|
17
19
|
connectors: Connector[];
|
|
20
|
+
/**
|
|
21
|
+
* Named scoped views over `connectors`, selected per client connection with
|
|
22
|
+
* `?toolkit=<name>` on the `/mcp` URL. One deployment belongs to one org;
|
|
23
|
+
* a toolkit is the slice of it a group of team members sees.
|
|
24
|
+
*
|
|
25
|
+
* ```ts
|
|
26
|
+
* toolkits: {
|
|
27
|
+
* support: { connectors: ["zendesk", "notion"] },
|
|
28
|
+
* exec: {
|
|
29
|
+
* connectors: ["zendesk", "notion", "gmail"],
|
|
30
|
+
* excludeTools: ["gmail.send_message"],
|
|
31
|
+
* },
|
|
32
|
+
* }
|
|
33
|
+
* ```
|
|
34
|
+
*
|
|
35
|
+
* Inside a toolkit-scoped session every meta-tool behaves as if out-of-scope
|
|
36
|
+
* connectors and tools do not exist, and an out-of-scope address fails
|
|
37
|
+
* exactly as a nonexistent one does. No `?toolkit=` ⇒ the full registry, so
|
|
38
|
+
* adding toolkits changes nothing for connections that don't ask for one; an
|
|
39
|
+
* unknown name is an error, never a silent fallback.
|
|
40
|
+
*
|
|
41
|
+
* Toolkits scope VISIBILITY, not identity: they do not decide *which* team
|
|
42
|
+
* member may select which toolkit. Gate that in `auth` (per-member binding is
|
|
43
|
+
* a follow-up).
|
|
44
|
+
*
|
|
45
|
+
* Definitions are validated at construction: an unknown connector id, an
|
|
46
|
+
* empty connector selection, an empty `includeTools`, a malformed tool
|
|
47
|
+
* address, or an address naming no tool on an in-code connector all throw.
|
|
48
|
+
*/
|
|
49
|
+
toolkits?: ToolkitConfig;
|
|
18
50
|
/**
|
|
19
51
|
* Privacy-minimal downstream tool activity storage. Writes are best-effort
|
|
20
52
|
* and never change tool results. Implement `list` to enable the Activity UI.
|
|
@@ -58,7 +90,9 @@ export interface ConnectaConfig {
|
|
|
58
90
|
toolCatalogStaleSeconds?: number;
|
|
59
91
|
/**
|
|
60
92
|
* Max inline result size (bytes) before call_tool/batch_call truncate and
|
|
61
|
-
* stash the full text for get_result paging.
|
|
93
|
+
* stash the full text for get_result paging. Must be a whole number of bytes
|
|
94
|
+
* >= 1; anything else (0, negative, fractional, NaN, Infinity) warns at
|
|
95
|
+
* startup and falls back to the default 50_000.
|
|
62
96
|
*/
|
|
63
97
|
maxResultBytes?: number;
|
|
64
98
|
/**
|
|
@@ -77,6 +111,22 @@ export interface ConnectaConfig {
|
|
|
77
111
|
* not explicitly ask to retry.
|
|
78
112
|
*/
|
|
79
113
|
defaultToolTimeoutMs?: number;
|
|
114
|
+
/**
|
|
115
|
+
* Deadline (ms) applied to each individual downstream probe/catalog call that
|
|
116
|
+
* the discovery meta-tools fan out — `list_connectors` (with `probe`),
|
|
117
|
+
* `search_tools`, and `describe_tools` — so a single hung connector can no
|
|
118
|
+
* longer stall the whole meta-tool call. **Defaults to a generous 30_000**,
|
|
119
|
+
* chosen to trip only on a pathological hang, not on a realistically slow
|
|
120
|
+
* probe, so having it on by default will not break existing deployments.
|
|
121
|
+
* Bounds one downstream call, not the whole fan-out: a connector that outruns
|
|
122
|
+
* it degrades to an unavailable/errored entry while the rest are unaffected.
|
|
123
|
+
*
|
|
124
|
+
* Does NOT apply to `call_tool`/`batch_call` — those carry their own budget
|
|
125
|
+
* via `defaultToolTimeoutMs` or a per-call `timeoutMs`. Note this bounds the
|
|
126
|
+
* caller-facing wait only; the underlying fetch is not currently aborted, so
|
|
127
|
+
* real cancellation of the downstream request is a deferred follow-up.
|
|
128
|
+
*/
|
|
129
|
+
probeTimeoutMs?: number;
|
|
80
130
|
serverInfo?: {
|
|
81
131
|
name?: string;
|
|
82
132
|
version?: string;
|
|
@@ -122,6 +172,83 @@ function normalizeAuth(auth: ConnectaConfig["auth"]): InboundAuth[] {
|
|
|
122
172
|
});
|
|
123
173
|
}
|
|
124
174
|
|
|
175
|
+
/**
|
|
176
|
+
* One-time construction warnings for deployment shapes that run fine but are
|
|
177
|
+
* usually unintended. Warning-only — never throws and never changes behavior;
|
|
178
|
+
* each condition emits at most one `logger.warn`. Iterates connectors once.
|
|
179
|
+
*/
|
|
180
|
+
function warnInsecureConfig(
|
|
181
|
+
config: ConnectaConfig,
|
|
182
|
+
inboundAuth: InboundAuth[],
|
|
183
|
+
logger: Logger,
|
|
184
|
+
): void {
|
|
185
|
+
const oauthConnectors = config.connectors.filter((c) => c.finishAuth);
|
|
186
|
+
const hasCredentialConnector = config.connectors.some((c) => c.credential);
|
|
187
|
+
|
|
188
|
+
// Open mode (no inbound auth) with connectors that expose credentials or
|
|
189
|
+
// downstream OAuth: any caller reaches everything, including the vault.
|
|
190
|
+
if (
|
|
191
|
+
inboundAuth.length === 0 &&
|
|
192
|
+
(hasCredentialConnector || oauthConnectors.length > 0)
|
|
193
|
+
) {
|
|
194
|
+
logger.warn(
|
|
195
|
+
"[connecta] running with no inbound authentication: any caller can " +
|
|
196
|
+
"invoke every connector and read or overwrite stored credentials. " +
|
|
197
|
+
"Configure `auth` (for example bearerToken(...) or Clerk) to gate access.",
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Unset publicUrl with OAuth connectors: the downstream redirect_uri is
|
|
202
|
+
// derived per-request from the attacker-influenced inbound Host header.
|
|
203
|
+
if (oauthConnectors.length > 0 && !config.publicUrl) {
|
|
204
|
+
logger.warn(
|
|
205
|
+
"[connecta] publicUrl is unset while OAuth connectors are configured: " +
|
|
206
|
+
"the downstream OAuth redirect_uri is derived per-request from the " +
|
|
207
|
+
"inbound Host header, so an attacker who controls that header can point " +
|
|
208
|
+
"it at their own host and capture the authorization code. Set " +
|
|
209
|
+
"`publicUrl` to a fixed https origin.",
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Toolkits with no inbound auth: a toolkit is a scoped VIEW selected by the
|
|
214
|
+
// caller, not an authentication boundary. With nothing gating /mcp, any
|
|
215
|
+
// caller picks any toolkit — or omits the parameter and sees everything.
|
|
216
|
+
if (inboundAuth.length === 0 && config.toolkits) {
|
|
217
|
+
logger.warn(
|
|
218
|
+
"[connecta] toolkits are configured but there is no inbound " +
|
|
219
|
+
"authentication: a toolkit is a scoped view a client selects with " +
|
|
220
|
+
"?toolkit=, not an access check, so any caller can choose any toolkit " +
|
|
221
|
+
"or omit the parameter and see every connector. Configure `auth` " +
|
|
222
|
+
"(for example bearerToken(...) or Clerk).",
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Branding URLs that failed their scheme gate. Rendering silently falls back
|
|
227
|
+
// (a bad URL must not take the page down), so this warning is the only way an
|
|
228
|
+
// operator learns their value never reached the page.
|
|
229
|
+
const dropped = droppedBrandingUrls(config.branding);
|
|
230
|
+
if (dropped.length > 0) {
|
|
231
|
+
logger.warn(
|
|
232
|
+
`[connecta] branding ${dropped.join(", ")} dropped: a branding URL is ` +
|
|
233
|
+
"used as an href, so it must be an absolute http(s) URL (favicon.href " +
|
|
234
|
+
"may also be a root-relative path). The default is rendered instead.",
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// OAuth connectors whose callback performs no state/CSRF check: the public
|
|
239
|
+
// /oauth/callback/<id> route would exchange any delivered code.
|
|
240
|
+
for (const connector of oauthConnectors) {
|
|
241
|
+
if (!connector.verifyState) {
|
|
242
|
+
logger.warn(
|
|
243
|
+
`[connecta] connector "${connector.id}" has an OAuth callback with no ` +
|
|
244
|
+
`state/CSRF check: /oauth/callback/${connector.id} will exchange any ` +
|
|
245
|
+
"delivered code. Implement `verifyState` (the shipped remoteMcp " +
|
|
246
|
+
"connector already does).",
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
125
252
|
export function createConnecta(config: ConnectaConfig): Connecta {
|
|
126
253
|
const storage = config.storage ?? memoryStorage();
|
|
127
254
|
const logger = config.logger ?? defaultLogger();
|
|
@@ -143,9 +270,16 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
143
270
|
toolCatalogStaleSeconds: config.toolCatalogStaleSeconds,
|
|
144
271
|
maxResultBytes: config.maxResultBytes,
|
|
145
272
|
});
|
|
273
|
+
// Throws on every structural mistake it can see (see resolveToolkits): a
|
|
274
|
+
// typo must not become a scope the operator never wrote. Note this is about
|
|
275
|
+
// the scope being *intended*, not about it being an access check — a toolkit
|
|
276
|
+
// scopes visibility, and `auth` remains the thing deciding who gets in.
|
|
277
|
+
const toolkits = resolveToolkits(config.toolkits, config.connectors);
|
|
278
|
+
const inboundAuth = normalizeAuth(config.auth);
|
|
279
|
+
warnInsecureConfig(config, inboundAuth, logger);
|
|
146
280
|
const handler = createFetchHandler({
|
|
147
281
|
registry,
|
|
148
|
-
auth:
|
|
282
|
+
auth: inboundAuth,
|
|
149
283
|
publicUrl: config.publicUrl,
|
|
150
284
|
serverInfo: {
|
|
151
285
|
...config.serverInfo,
|
|
@@ -158,9 +292,11 @@ export function createConnecta(config: ConnectaConfig): Connecta {
|
|
|
158
292
|
activityDeploymentId: config.activityDeploymentId,
|
|
159
293
|
executor: config.executor,
|
|
160
294
|
defaultToolTimeoutMs: config.defaultToolTimeoutMs,
|
|
295
|
+
probeTimeoutMs: config.probeTimeoutMs,
|
|
161
296
|
credentialVault,
|
|
162
297
|
deploymentInfo: config.deploymentInfo,
|
|
163
298
|
branding: config.branding,
|
|
299
|
+
...(toolkits ? { toolkits } : {}),
|
|
164
300
|
});
|
|
165
301
|
return {
|
|
166
302
|
fetch: (request, _env, ctx) =>
|
|
@@ -190,6 +326,9 @@ export { CONNECTA_VERSION } from "./version.js";
|
|
|
190
326
|
// the class itself, the credential vault, and the meta-tool/sandbox factories
|
|
191
327
|
// are internal factoring and are deliberately not part of the API surface.
|
|
192
328
|
export type { Registry } from "./registry.js";
|
|
329
|
+
// Config-as-code shapes for `ConnectaConfig.toolkits`. The resolved `Toolkit`
|
|
330
|
+
// and the `ScopedRegistry` that enforces it are internal factoring.
|
|
331
|
+
export type { ToolkitConfig, ToolkitDefinition } from "./toolkits.js";
|
|
193
332
|
|
|
194
333
|
export type { RemoteMcpOptions, RemoteMcpAuth } from "./connectors/remote-mcp.js";
|
|
195
334
|
export type { ApiOptions, ApiTool } from "./connectors/api.js";
|
package/src/meta-tools.ts
CHANGED
|
@@ -13,9 +13,20 @@ import {
|
|
|
13
13
|
messageLooksRetryable,
|
|
14
14
|
type CallErrorDetails,
|
|
15
15
|
} from "./errors.js";
|
|
16
|
-
import
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
import {
|
|
17
|
+
isValidMaxResultBytes,
|
|
18
|
+
MIN_MAX_RESULT_BYTES,
|
|
19
|
+
resolveMaxResultBytes,
|
|
20
|
+
type RegistryView,
|
|
21
|
+
} from "./registry.js";
|
|
22
|
+
import {
|
|
23
|
+
connectorGuide,
|
|
24
|
+
connectorSkillName,
|
|
25
|
+
hasConnectorGuides,
|
|
26
|
+
listSkills,
|
|
27
|
+
resolveSkill,
|
|
28
|
+
} from "./skills.js";
|
|
29
|
+
import type { ConnectorStatus, KVStorage, ToolDef } from "./types.js";
|
|
19
30
|
|
|
20
31
|
interface TextContent {
|
|
21
32
|
type: "text";
|
|
@@ -72,6 +83,42 @@ function normalizeTimeoutMs(value: number | undefined): number | undefined {
|
|
|
72
83
|
return Math.max(1, Math.trunc(value));
|
|
73
84
|
}
|
|
74
85
|
|
|
86
|
+
/**
|
|
87
|
+
* Generous default bound for a single downstream probe/catalog call in the
|
|
88
|
+
* list/search/describe fan-out. High enough to trip only on a pathological
|
|
89
|
+
* hang, not a realistically slow probe.
|
|
90
|
+
*/
|
|
91
|
+
const DEFAULT_PROBE_TIMEOUT_MS = 30_000;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reject `promise` after `ms` if it has not settled, so one hung downstream
|
|
95
|
+
* cannot stall a whole fan-out. NOTE: this bounds only the caller-facing wait —
|
|
96
|
+
* the registry probe methods take no AbortSignal, so the underlying fetch is
|
|
97
|
+
* NOT cancelled and keeps running in the background. Real cancellation
|
|
98
|
+
* (AbortSignal plumbed through the registry) is a deferred follow-up.
|
|
99
|
+
*/
|
|
100
|
+
function withTimeout<T>(
|
|
101
|
+
promise: Promise<T>,
|
|
102
|
+
ms: number,
|
|
103
|
+
label: string,
|
|
104
|
+
): Promise<T> {
|
|
105
|
+
return new Promise<T>((resolve, reject) => {
|
|
106
|
+
const timer = setTimeout(() => {
|
|
107
|
+
reject(new Error(`${label} timed out after ${ms}ms`));
|
|
108
|
+
}, ms);
|
|
109
|
+
promise.then(
|
|
110
|
+
(value) => {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
resolve(value);
|
|
113
|
+
},
|
|
114
|
+
(err) => {
|
|
115
|
+
clearTimeout(timer);
|
|
116
|
+
reject(err);
|
|
117
|
+
},
|
|
118
|
+
);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
75
122
|
/**
|
|
76
123
|
* How long to wait before the next attempt, or `undefined` for "don't retry".
|
|
77
124
|
*
|
|
@@ -114,19 +161,28 @@ function isContinuationByte(b: number): boolean {
|
|
|
114
161
|
* forward to the end of that codepoint instead so paging always advances.
|
|
115
162
|
* Assumes `offset` is itself a codepoint boundary (offsets are the prior
|
|
116
163
|
* `nextOffset`, which this function guarantees, and 0 is always a boundary).
|
|
164
|
+
*
|
|
165
|
+
* The return is always `> offset` while `offset < total`, whatever `end` is
|
|
166
|
+
* asked for. That is the belt-and-braces half of issue #32: cap validation
|
|
167
|
+
* keeps an empty window from arising in the first place, and this keeps an
|
|
168
|
+
* empty window from turning into a `nextOffset === offset` paging loop if one
|
|
169
|
+
* ever does. Exported for direct testing of that invariant.
|
|
117
170
|
*/
|
|
118
|
-
function alignEndToCharBoundary(
|
|
171
|
+
export function alignEndToCharBoundary(
|
|
119
172
|
bytes: Uint8Array,
|
|
120
173
|
offset: number,
|
|
121
174
|
end: number,
|
|
122
175
|
total: number,
|
|
123
176
|
): number {
|
|
124
177
|
if (end >= total) return total;
|
|
125
|
-
|
|
178
|
+
// A window that reaches no further than `offset` yields no bytes and no
|
|
179
|
+
// progress; widen it to one byte and let the codepoint walk below finish it.
|
|
180
|
+
const wanted = Math.max(end, offset + 1);
|
|
181
|
+
let e = wanted;
|
|
126
182
|
while (e > offset && isContinuationByte(bytes[e])) e--;
|
|
127
183
|
if (e === offset) {
|
|
128
184
|
// Window is narrower than the codepoint at `offset`; take the whole thing.
|
|
129
|
-
e =
|
|
185
|
+
e = wanted;
|
|
130
186
|
while (e < total && isContinuationByte(bytes[e])) e++;
|
|
131
187
|
}
|
|
132
188
|
return e;
|
|
@@ -274,6 +330,7 @@ export interface CallArgs {
|
|
|
274
330
|
export interface GetResultArgs {
|
|
275
331
|
id: string;
|
|
276
332
|
offset?: number;
|
|
333
|
+
/** Page size in bytes; a whole number >= 1. Defaults to the deployment cap. */
|
|
277
334
|
maxBytes?: number;
|
|
278
335
|
}
|
|
279
336
|
export interface BatchCall {
|
|
@@ -303,22 +360,30 @@ export interface SkillArgs {
|
|
|
303
360
|
/**
|
|
304
361
|
* The nine meta-tool handlers over a registry. Exported for direct testing;
|
|
305
362
|
* registerMetaTools() wires them onto an McpServer. `opts.maxResultBytes`
|
|
306
|
-
* overrides the registry's default result-size cap
|
|
363
|
+
* overrides the registry's default result-size cap (a connector's own
|
|
364
|
+
* `maxResultBytes` overrides it in turn); `opts.defaultToolTimeoutMs`
|
|
307
365
|
* supplies a deadline for calls that don't carry one. (execute_code, the
|
|
308
366
|
* optional tenth tool, is registered separately by registerExecuteTool.)
|
|
309
367
|
*/
|
|
310
368
|
export function createMetaTools(
|
|
311
|
-
registry:
|
|
369
|
+
registry: RegistryView,
|
|
312
370
|
baseUrl: string,
|
|
313
371
|
opts: {
|
|
314
372
|
maxResultBytes?: number;
|
|
315
373
|
/** Deadline applied when a call passes no `timeoutMs`. Off when unset. */
|
|
316
374
|
defaultToolTimeoutMs?: number;
|
|
375
|
+
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
376
|
+
probeTimeoutMs?: number;
|
|
317
377
|
activity?: ActivityRequestContext;
|
|
318
378
|
} = {},
|
|
319
379
|
) {
|
|
320
|
-
const
|
|
380
|
+
const globalCap = resolveMaxResultBytes(
|
|
381
|
+
opts.maxResultBytes,
|
|
382
|
+
registry.maxResultBytes,
|
|
383
|
+
);
|
|
321
384
|
const defaultToolTimeoutMs = normalizeTimeoutMs(opts.defaultToolTimeoutMs);
|
|
385
|
+
const probeTimeoutMs =
|
|
386
|
+
normalizeTimeoutMs(opts.probeTimeoutMs) ?? DEFAULT_PROBE_TIMEOUT_MS;
|
|
322
387
|
// createMetaTools() is called once per inbound MCP request. Sharing this
|
|
323
388
|
// identity lets remote connectors reuse one downstream client inside that
|
|
324
389
|
// request without leaking request-bound I/O into the next one.
|
|
@@ -402,6 +467,16 @@ export function createMetaTools(
|
|
|
402
467
|
);
|
|
403
468
|
}
|
|
404
469
|
const results = registry.resultsStorage();
|
|
470
|
+
// Result-size cap for THIS call: the connector's own override wins, then
|
|
471
|
+
// the deployment-wide value, then the built-in default (already folded
|
|
472
|
+
// into `globalCap`). Resolved per call so one batch_call can mix a
|
|
473
|
+
// tight-capped connector with siblings on the global cap. An override the
|
|
474
|
+
// registry already warned about at startup is dropped here, so the
|
|
475
|
+
// connector simply inherits `globalCap`.
|
|
476
|
+
const cap = resolveMaxResultBytes(
|
|
477
|
+
resolved.connector.maxResultBytes,
|
|
478
|
+
globalCap,
|
|
479
|
+
);
|
|
405
480
|
const fields = call.fields && call.fields.length > 0 ? call.fields : null;
|
|
406
481
|
// An explicit per-call deadline always wins; the config default only fills
|
|
407
482
|
// the gap, and stays off entirely when the deployment sets none.
|
|
@@ -592,6 +667,7 @@ export function createMetaTools(
|
|
|
592
667
|
|
|
593
668
|
return {
|
|
594
669
|
async skills(args: SkillArgs = {}): Promise<ToolResult> {
|
|
670
|
+
const connectors = registry.listConnectors();
|
|
595
671
|
if (!args.name) {
|
|
596
672
|
return {
|
|
597
673
|
content: [
|
|
@@ -599,19 +675,15 @@ export function createMetaTools(
|
|
|
599
675
|
type: "text",
|
|
600
676
|
text:
|
|
601
677
|
'Available skills. Fetch one with skills({ name: "<name>" }).\n\n' +
|
|
602
|
-
|
|
603
|
-
(skill) => `- \`${skill.name}\` — ${skill.description}
|
|
604
|
-
|
|
678
|
+
listSkills(connectors)
|
|
679
|
+
.map((skill) => `- \`${skill.name}\` — ${skill.description}`)
|
|
680
|
+
.join("\n"),
|
|
605
681
|
},
|
|
606
682
|
],
|
|
607
683
|
};
|
|
608
684
|
}
|
|
609
|
-
const skill =
|
|
610
|
-
if (!skill)
|
|
611
|
-
return errorResult(
|
|
612
|
-
`Unknown skill "${args.name}". Available: ${AVAILABLE_SKILLS.map((item) => item.name).join(", ")}.`,
|
|
613
|
-
);
|
|
614
|
-
}
|
|
685
|
+
const skill = resolveSkill(args.name, connectors);
|
|
686
|
+
if (!skill.found) return errorResult(skill.message);
|
|
615
687
|
return { content: [{ type: "text", text: skill.content }] };
|
|
616
688
|
},
|
|
617
689
|
|
|
@@ -622,25 +694,50 @@ export function createMetaTools(
|
|
|
622
694
|
const checkedAt = new Date().toISOString();
|
|
623
695
|
const statusStarted = Date.now();
|
|
624
696
|
const observed = registry.healthFor(c.id);
|
|
625
|
-
let status
|
|
626
|
-
|
|
627
|
-
:
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
697
|
+
let status:
|
|
698
|
+
| ConnectorStatus
|
|
699
|
+
| { state: "ok" | "error" | "unknown"; message?: string };
|
|
700
|
+
if (probe) {
|
|
701
|
+
try {
|
|
702
|
+
status = await withTimeout(
|
|
703
|
+
registry.statusFor(c.id, baseUrl, requestScope),
|
|
704
|
+
probeTimeoutMs,
|
|
705
|
+
`list_connectors probe of "${c.id}"`,
|
|
706
|
+
);
|
|
707
|
+
} catch (err) {
|
|
708
|
+
// A probe that outran probeTimeoutMs (or otherwise threw)
|
|
709
|
+
// degrades this connector to an error status rather than
|
|
710
|
+
// hanging the whole list_connectors call.
|
|
711
|
+
status = { state: "error", message: msg(err) };
|
|
712
|
+
}
|
|
713
|
+
} else {
|
|
714
|
+
// "error" comes from THIS view's own observations — a sibling
|
|
715
|
+
// toolkit's failure is not this session's experience — while
|
|
716
|
+
// ok/unknown may lean on the deployment-wide success signal, since
|
|
717
|
+
// "the connector answers at all" is a fact about the connector.
|
|
718
|
+
// Unscoped, the two are the same log, so this is unchanged there.
|
|
719
|
+
status = {
|
|
720
|
+
state:
|
|
721
|
+
observed?.consecutiveFailures &&
|
|
722
|
+
observed.consecutiveFailures > 0
|
|
723
|
+
? ("error" as const)
|
|
724
|
+
: registry.hasObservedSuccess(c.id) || c.kind === "api"
|
|
725
|
+
? ("ok" as const)
|
|
726
|
+
: ("unknown" as const),
|
|
727
|
+
...(observed?.lastError ? { message: observed.lastError } : {}),
|
|
728
|
+
};
|
|
729
|
+
}
|
|
637
730
|
let tools = registry.peekTools(c.id);
|
|
638
731
|
// An auth_required status may have just started OAuth. A second
|
|
639
732
|
// listTools probe would overwrite its state/verifier while returning
|
|
640
733
|
// the first (now stale) authorization URL.
|
|
641
734
|
if (probe && status.state === "ok") {
|
|
642
735
|
try {
|
|
643
|
-
tools = await
|
|
736
|
+
tools = await withTimeout(
|
|
737
|
+
registry.refreshTools(c.id, baseUrl, requestScope),
|
|
738
|
+
probeTimeoutMs,
|
|
739
|
+
`list_connectors catalog refresh of "${c.id}"`,
|
|
740
|
+
);
|
|
644
741
|
registry.recordSuccess(c.id, Date.now() - statusStarted);
|
|
645
742
|
} catch (err) {
|
|
646
743
|
status = { state: "error" as const, message: msg(err) };
|
|
@@ -682,12 +779,19 @@ export function createMetaTools(
|
|
|
682
779
|
connectorId: string;
|
|
683
780
|
connectorTitle?: string;
|
|
684
781
|
connectorDescription?: string;
|
|
782
|
+
connectorGuideSkill?: string;
|
|
685
783
|
tool: ToolDef;
|
|
686
784
|
score: number;
|
|
687
785
|
order: number;
|
|
688
786
|
}> = [];
|
|
689
787
|
const catalogs = await Promise.allSettled(
|
|
690
|
-
conns.map((c) =>
|
|
788
|
+
conns.map((c) =>
|
|
789
|
+
withTimeout(
|
|
790
|
+
registry.getTools(c.id, baseUrl, requestScope),
|
|
791
|
+
probeTimeoutMs,
|
|
792
|
+
`search_tools probe of "${c.id}"`,
|
|
793
|
+
),
|
|
794
|
+
),
|
|
691
795
|
);
|
|
692
796
|
let orderBase = 0;
|
|
693
797
|
catalogs.forEach((catalog, connectorIndex) => {
|
|
@@ -698,6 +802,9 @@ export function createMetaTools(
|
|
|
698
802
|
connectorId: c.id,
|
|
699
803
|
connectorTitle: c.title,
|
|
700
804
|
connectorDescription: c.description,
|
|
805
|
+
...(connectorGuide(c)
|
|
806
|
+
? { connectorGuideSkill: connectorSkillName(c.id) }
|
|
807
|
+
: {}),
|
|
701
808
|
tool: ranked.tool,
|
|
702
809
|
score: ranked.score,
|
|
703
810
|
order: orderBase + ranked.order,
|
|
@@ -712,6 +819,8 @@ export function createMetaTools(
|
|
|
712
819
|
id: string;
|
|
713
820
|
title?: string;
|
|
714
821
|
description?: string;
|
|
822
|
+
/** Skill name of this connector's usage guide, when it has one. */
|
|
823
|
+
guide?: string;
|
|
715
824
|
tools: Array<{
|
|
716
825
|
name: string;
|
|
717
826
|
address: string;
|
|
@@ -729,6 +838,9 @@ export function createMetaTools(
|
|
|
729
838
|
id: match.connectorId,
|
|
730
839
|
...(match.connectorTitle ? { title: match.connectorTitle } : {}),
|
|
731
840
|
description: match.connectorDescription,
|
|
841
|
+
...(match.connectorGuideSkill
|
|
842
|
+
? { guide: match.connectorGuideSkill }
|
|
843
|
+
: {}),
|
|
732
844
|
tools: [],
|
|
733
845
|
};
|
|
734
846
|
byConnector.set(match.connectorId, group);
|
|
@@ -791,7 +903,13 @@ export function createMetaTools(
|
|
|
791
903
|
),
|
|
792
904
|
];
|
|
793
905
|
const loaded = await Promise.allSettled(
|
|
794
|
-
connectorIds.map((id) =>
|
|
906
|
+
connectorIds.map((id) =>
|
|
907
|
+
withTimeout(
|
|
908
|
+
registry.getTools(id, baseUrl, requestScope),
|
|
909
|
+
probeTimeoutMs,
|
|
910
|
+
`describe_tools probe of "${id}"`,
|
|
911
|
+
),
|
|
912
|
+
),
|
|
795
913
|
);
|
|
796
914
|
const catalogs = new Map<string, ToolDef[] | Error>();
|
|
797
915
|
loaded.forEach((result, index) => {
|
|
@@ -827,6 +945,9 @@ export function createMetaTools(
|
|
|
827
945
|
tool.description,
|
|
828
946
|
args.fullDescriptions === true,
|
|
829
947
|
),
|
|
948
|
+
...(connectorGuide(resolved.connector)
|
|
949
|
+
? { guide: connectorSkillName(resolved.connector.id) }
|
|
950
|
+
: {}),
|
|
830
951
|
inputSchema: format === "json" ? schema : compactSchema(schema),
|
|
831
952
|
...(tool.outputSchema
|
|
832
953
|
? {
|
|
@@ -853,6 +974,20 @@ export function createMetaTools(
|
|
|
853
974
|
},
|
|
854
975
|
|
|
855
976
|
async getResult(args: GetResultArgs): Promise<ToolResult> {
|
|
977
|
+
// Client-supplied page size: a normal input-validation error, not a
|
|
978
|
+
// clamp. Callers arriving over MCP are rejected earlier by the
|
|
979
|
+
// registered zod schema and never reach this branch, so it exists for
|
|
980
|
+
// in-process callers of createMetaTools — which have no schema in front
|
|
981
|
+
// of them — and to keep the rule true of the handler on its own terms.
|
|
982
|
+
if (
|
|
983
|
+
args.maxBytes !== undefined &&
|
|
984
|
+
!isValidMaxResultBytes(args.maxBytes)
|
|
985
|
+
) {
|
|
986
|
+
return errorResult(
|
|
987
|
+
`Invalid maxBytes ${args.maxBytes}: must be a whole number of bytes ` +
|
|
988
|
+
`>= ${MIN_MAX_RESULT_BYTES}. Omit it to use the deployment default.`,
|
|
989
|
+
);
|
|
990
|
+
}
|
|
856
991
|
const results = registry.resultsStorage();
|
|
857
992
|
const stored = await results.get(`result:${args.id}`);
|
|
858
993
|
if (stored === null || stored === undefined) {
|
|
@@ -861,7 +996,11 @@ export function createMetaTools(
|
|
|
861
996
|
const bytes = enc.encode(stored);
|
|
862
997
|
const total = bytes.length;
|
|
863
998
|
const offset = Math.max(0, Math.trunc(args.offset ?? 0));
|
|
864
|
-
|
|
999
|
+
// Page size only: a stashed result carries no connector identity, so
|
|
1000
|
+
// get_result keeps the deployment-wide default when none is requested.
|
|
1001
|
+
// Both sides are validated by now — the argument above, `globalCap` at
|
|
1002
|
+
// intake — so `offset + maxBytes` always reaches past `offset`.
|
|
1003
|
+
const maxBytes = args.maxBytes ?? globalCap;
|
|
865
1004
|
// Align the slice end to a codepoint boundary so a multi-byte char is
|
|
866
1005
|
// never split across pages (which would emit U+FFFD on both sides).
|
|
867
1006
|
// `nextOffset` is this aligned end, so it is a valid boundary for the
|
|
@@ -1004,7 +1143,7 @@ const CALL_DESC =
|
|
|
1004
1143
|
const CALL_DESTRUCTIVE_DESC =
|
|
1005
1144
|
"Invoke any tool that is not explicitly annotated readOnlyHint: true, including unannotated, write-capable, or destructive tools. The MCP destructiveHint on this meta-tool lets the host request human approval before execution. Use only after reviewing the downstream tool schema and consequences.";
|
|
1006
1145
|
const GET_RESULT_DESC =
|
|
1007
|
-
"Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. Unknown/expired id is an error.";
|
|
1146
|
+
"Page a truncated result stashed by call_tool/batch_call. Input { id, offset?, maxBytes? } → { text, offset, nextOffset?, totalBytes } sliced by byte offset. maxBytes is a whole number of bytes >= 1 (omit for the deployment default). Unknown/expired id is an error.";
|
|
1008
1147
|
const BATCH_DESC =
|
|
1009
1148
|
"Use for 2–10 independent tools explicitly annotated readOnlyHint: true. Calls run in parallel with shared request-scoped clients; use execute_code when available instead for dependencies or in-sandbox reduction. Unannotated, write-capable, and destructive tools are refused. Batch timeout, safe retry, result mode, and diagnostics defaults may be overridden per call.";
|
|
1010
1149
|
const AUTHORIZE_DESC =
|
|
@@ -1012,6 +1151,37 @@ const AUTHORIZE_DESC =
|
|
|
1012
1151
|
const SKILLS_DESC =
|
|
1013
1152
|
'List or fetch concise guidance for choosing among Connecta meta-tools. Call skills({ name: "usage" }) once when the routing workflow is unfamiliar; do not refetch it in the same task.';
|
|
1014
1153
|
|
|
1154
|
+
/**
|
|
1155
|
+
* Sentences appended to a meta-tool description only when this connection
|
|
1156
|
+
* actually has connector guides. Tool descriptions are always-loaded context,
|
|
1157
|
+
* so a deployment with no guides gets every base description unchanged rather
|
|
1158
|
+
* than paying for text about a feature it does not use.
|
|
1159
|
+
*
|
|
1160
|
+
* Registration is per connection and reads the connection's own registry view,
|
|
1161
|
+
* so under a toolkit these sentences reflect the SCOPED connector set: a scoped
|
|
1162
|
+
* session whose connectors carry no guides sees the base descriptions, and
|
|
1163
|
+
* never learns from a tool description that guides exist out of scope.
|
|
1164
|
+
*/
|
|
1165
|
+
const GUIDE_NOTES = {
|
|
1166
|
+
skills:
|
|
1167
|
+
' skills({}) also lists this deployment\'s per-connector usage guides as "connector:<connectorId>"; fetch the guide for a connector before working with it for the first time.',
|
|
1168
|
+
search:
|
|
1169
|
+
" A connector group carrying `guide` has a usage guide; fetch it with skills({ name: <guide> }).",
|
|
1170
|
+
describe:
|
|
1171
|
+
" An entry carrying `guide` belongs to a connector with a usage guide; fetch it with skills({ name: <guide> }).",
|
|
1172
|
+
} as const;
|
|
1173
|
+
|
|
1174
|
+
/** `base`, plus its guide note when any VISIBLE connector carries a guide. */
|
|
1175
|
+
function describedFor(
|
|
1176
|
+
registry: RegistryView,
|
|
1177
|
+
base: string,
|
|
1178
|
+
note: keyof typeof GUIDE_NOTES,
|
|
1179
|
+
): string {
|
|
1180
|
+
return hasConnectorGuides(registry.listConnectors())
|
|
1181
|
+
? base + GUIDE_NOTES[note]
|
|
1182
|
+
: base;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1015
1185
|
/**
|
|
1016
1186
|
* Connecta refuses downstream tools that are not explicitly annotated
|
|
1017
1187
|
* read-only, so its own meta-tools must carry the same hints — otherwise a
|
|
@@ -1035,24 +1205,26 @@ const READ_ONLY_LOCAL = {
|
|
|
1035
1205
|
/** Register the nine meta-tools onto an McpServer instance. */
|
|
1036
1206
|
export function registerMetaTools(
|
|
1037
1207
|
server: McpServer,
|
|
1038
|
-
registry:
|
|
1208
|
+
registry: RegistryView,
|
|
1039
1209
|
ctx: {
|
|
1040
1210
|
baseUrl: string;
|
|
1041
1211
|
maxResultBytes?: number;
|
|
1042
1212
|
defaultToolTimeoutMs?: number;
|
|
1213
|
+
probeTimeoutMs?: number;
|
|
1043
1214
|
activity?: ActivityRequestContext;
|
|
1044
1215
|
},
|
|
1045
1216
|
): void {
|
|
1046
1217
|
const mt = createMetaTools(registry, ctx.baseUrl, {
|
|
1047
1218
|
maxResultBytes: ctx.maxResultBytes,
|
|
1048
1219
|
defaultToolTimeoutMs: ctx.defaultToolTimeoutMs,
|
|
1220
|
+
probeTimeoutMs: ctx.probeTimeoutMs,
|
|
1049
1221
|
activity: ctx.activity,
|
|
1050
1222
|
});
|
|
1051
1223
|
|
|
1052
1224
|
server.registerTool(
|
|
1053
1225
|
"skills",
|
|
1054
1226
|
{
|
|
1055
|
-
description: SKILLS_DESC,
|
|
1227
|
+
description: describedFor(registry, SKILLS_DESC, "skills"),
|
|
1056
1228
|
inputSchema: { name: z.string().optional() },
|
|
1057
1229
|
annotations: {
|
|
1058
1230
|
readOnlyHint: true,
|
|
@@ -1077,7 +1249,7 @@ export function registerMetaTools(
|
|
|
1077
1249
|
server.registerTool(
|
|
1078
1250
|
"search_tools",
|
|
1079
1251
|
{
|
|
1080
|
-
description: SEARCH_DESC,
|
|
1252
|
+
description: describedFor(registry, SEARCH_DESC, "search"),
|
|
1081
1253
|
inputSchema: {
|
|
1082
1254
|
query: z.string().optional(),
|
|
1083
1255
|
connector: z.string().optional(),
|
|
@@ -1094,7 +1266,7 @@ export function registerMetaTools(
|
|
|
1094
1266
|
server.registerTool(
|
|
1095
1267
|
"describe_tools",
|
|
1096
1268
|
{
|
|
1097
|
-
description: DESCRIBE_DESC,
|
|
1269
|
+
description: describedFor(registry, DESCRIBE_DESC, "describe"),
|
|
1098
1270
|
inputSchema: {
|
|
1099
1271
|
addresses: z.array(z.string()),
|
|
1100
1272
|
format: z.enum(["compact", "json"]).optional(),
|
|
@@ -1173,7 +1345,10 @@ export function registerMetaTools(
|
|
|
1173
1345
|
inputSchema: {
|
|
1174
1346
|
id: z.string(),
|
|
1175
1347
|
offset: z.number().int().nonnegative().optional(),
|
|
1176
|
-
|
|
1348
|
+
// Same rule as isValidMaxResultBytes, expressed for the wire: sharing
|
|
1349
|
+
// the floor constant keeps the schema from drifting away from the
|
|
1350
|
+
// in-handler check if MIN_MAX_RESULT_BYTES ever moves.
|
|
1351
|
+
maxBytes: z.number().int().min(MIN_MAX_RESULT_BYTES).optional(),
|
|
1177
1352
|
},
|
|
1178
1353
|
annotations: READ_ONLY_LOCAL,
|
|
1179
1354
|
},
|