@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/server.ts
CHANGED
|
@@ -11,7 +11,8 @@ import type {
|
|
|
11
11
|
} from "./activity.js";
|
|
12
12
|
import { InvalidActivityCursorError } from "./activity.js";
|
|
13
13
|
import type { CredentialVault } from "./credentials.js";
|
|
14
|
-
import type
|
|
14
|
+
import { ScopedRegistry, type Registry, type RegistryView } from "./registry.js";
|
|
15
|
+
import { TOOLKIT_NAME_RE, type Toolkit } from "./toolkits.js";
|
|
15
16
|
import type {
|
|
16
17
|
ConnectorCredentialConfig,
|
|
17
18
|
ConnectorCredentialValues,
|
|
@@ -49,18 +50,33 @@ export interface ServerOptions {
|
|
|
49
50
|
deploymentInfo?: Record<string, unknown>;
|
|
50
51
|
/** Deadline for call_tool/batch_call calls that pass no timeoutMs. Off when unset. */
|
|
51
52
|
defaultToolTimeoutMs?: number;
|
|
53
|
+
/** Per-connector deadline for the list/search/describe probe fan-out. Default 30_000. */
|
|
54
|
+
probeTimeoutMs?: number;
|
|
52
55
|
/** When set, the execute_code meta-tool is registered on top of the nine. */
|
|
53
56
|
executor?: Executor;
|
|
54
57
|
/** Encrypted connector-credential storage backing the authenticated /ui controls. */
|
|
55
58
|
credentialVault?: CredentialVault;
|
|
56
59
|
/** Optional browser UI and OAuth result-page labels. */
|
|
57
60
|
branding?: ConnectaBranding;
|
|
61
|
+
/**
|
|
62
|
+
* Validated named scopes, selected per connection with `?toolkit=<name>` on
|
|
63
|
+
* `/mcp`. Omit (or leave empty) and every connection sees the full registry.
|
|
64
|
+
*/
|
|
65
|
+
toolkits?: ReadonlyMap<string, Toolkit>;
|
|
58
66
|
}
|
|
59
67
|
|
|
60
68
|
function msg(err: unknown): string {
|
|
61
69
|
return err instanceof Error ? err.message : String(err);
|
|
62
70
|
}
|
|
63
71
|
|
|
72
|
+
/** Per-request base64 nonce for the /ui page's inline scripts (Node 20+ and Workers). */
|
|
73
|
+
function uiScriptNonce(): string {
|
|
74
|
+
const bytes = crypto.getRandomValues(new Uint8Array(16));
|
|
75
|
+
let binary = "";
|
|
76
|
+
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
77
|
+
return btoa(binary);
|
|
78
|
+
}
|
|
79
|
+
|
|
64
80
|
function escapeHtml(s: string): string {
|
|
65
81
|
return s
|
|
66
82
|
.replaceAll("&", "&")
|
|
@@ -231,9 +247,12 @@ function withSecurityHeaders(
|
|
|
231
247
|
headers.set("Strict-Transport-Security", "max-age=31536000");
|
|
232
248
|
}
|
|
233
249
|
if (path === "/ui") {
|
|
234
|
-
//
|
|
235
|
-
//
|
|
236
|
-
|
|
250
|
+
// The /ui GET response ships its own nonce-based script CSP (which already
|
|
251
|
+
// includes frame-ancestors 'none'); only fall back to the framing-only
|
|
252
|
+
// directive when no CSP is present (e.g. HTTPS redirects, error responses).
|
|
253
|
+
if (!headers.has("Content-Security-Policy")) {
|
|
254
|
+
headers.set("Content-Security-Policy", "frame-ancestors 'none'");
|
|
255
|
+
}
|
|
237
256
|
headers.set("X-Frame-Options", "DENY");
|
|
238
257
|
}
|
|
239
258
|
return new Response(response.body, {
|
|
@@ -493,11 +512,81 @@ async function handleCredentialRequest(
|
|
|
493
512
|
return privateJson({ error: "method not allowed" }, { status: 405 });
|
|
494
513
|
}
|
|
495
514
|
|
|
515
|
+
/** Length beyond which a rejected toolkit name is not echoed back. */
|
|
516
|
+
const MAX_ECHOED_TOOLKIT_NAME = 64;
|
|
517
|
+
|
|
518
|
+
/** What one MCP connection may see: the full registry, or one toolkit's view. */
|
|
519
|
+
interface McpScope {
|
|
520
|
+
registry: RegistryView;
|
|
521
|
+
/** Set only under `?toolkit=`; recorded on activity events. */
|
|
522
|
+
toolkitId?: string;
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Resolve `?toolkit=<name>` into the registry view this connection may see.
|
|
527
|
+
*
|
|
528
|
+
* - absent → the full registry, byte-identical to a deployment with no toolkits
|
|
529
|
+
* - known → a `ScopedRegistry` over that toolkit (the one enforcement point)
|
|
530
|
+
* - anything else, including `?toolkit=` with an empty value → an explicit
|
|
531
|
+
* error. Never a silent fallback to the full registry.
|
|
532
|
+
*
|
|
533
|
+
* The error deliberately does not enumerate the configured toolkits: the name
|
|
534
|
+
* selects a scope, so a wrong guess gets a flat "unknown", not a directory.
|
|
535
|
+
*/
|
|
536
|
+
function resolveToolkitScope(
|
|
537
|
+
url: URL,
|
|
538
|
+
registry: Registry,
|
|
539
|
+
toolkits: ReadonlyMap<string, Toolkit> | undefined,
|
|
540
|
+
):
|
|
541
|
+
| { ok: true; scope: McpScope }
|
|
542
|
+
| { ok: false; response: Response } {
|
|
543
|
+
const requested = url.searchParams.get("toolkit");
|
|
544
|
+
if (requested === null) return { ok: true, scope: { registry } };
|
|
545
|
+
const toolkit = toolkits?.get(requested);
|
|
546
|
+
if (toolkit) {
|
|
547
|
+
return {
|
|
548
|
+
ok: true,
|
|
549
|
+
scope: {
|
|
550
|
+
registry: new ScopedRegistry(registry, toolkit),
|
|
551
|
+
toolkitId: toolkit.name,
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
}
|
|
555
|
+
const label =
|
|
556
|
+
requested.length <= MAX_ECHOED_TOOLKIT_NAME &&
|
|
557
|
+
TOOLKIT_NAME_RE.test(requested)
|
|
558
|
+
? `"${requested}"`
|
|
559
|
+
: "requested";
|
|
560
|
+
return {
|
|
561
|
+
ok: false,
|
|
562
|
+
response: new Response(
|
|
563
|
+
JSON.stringify({
|
|
564
|
+
jsonrpc: "2.0",
|
|
565
|
+
id: null,
|
|
566
|
+
error: {
|
|
567
|
+
code: -32600,
|
|
568
|
+
message:
|
|
569
|
+
`Unknown toolkit ${label}. Check the ?toolkit= value in this ` +
|
|
570
|
+
"deployment's MCP endpoint URL with the operator.",
|
|
571
|
+
},
|
|
572
|
+
}),
|
|
573
|
+
{
|
|
574
|
+
status: 404,
|
|
575
|
+
headers: {
|
|
576
|
+
"Content-Type": "application/json",
|
|
577
|
+
"Cache-Control": "no-store",
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
),
|
|
581
|
+
};
|
|
582
|
+
}
|
|
583
|
+
|
|
496
584
|
async function serveMcp(
|
|
497
585
|
request: Request,
|
|
498
586
|
opts: ServerOptions,
|
|
499
587
|
baseUrl: string,
|
|
500
588
|
actor: ActivityActor,
|
|
589
|
+
scope: McpScope,
|
|
501
590
|
runtimeContext?: RuntimeExecutionContext,
|
|
502
591
|
): Promise<Response> {
|
|
503
592
|
// Fresh McpServer + transport per request (SDK ≥1.26 requirement), stateless.
|
|
@@ -513,19 +602,24 @@ async function serveMcp(
|
|
|
513
602
|
...(opts.activityDeploymentId
|
|
514
603
|
? { deploymentId: opts.activityDeploymentId }
|
|
515
604
|
: {}),
|
|
605
|
+
...(scope.toolkitId ? { toolkitId: scope.toolkitId } : {}),
|
|
516
606
|
...(runtimeContext?.waitUntil
|
|
517
607
|
? { defer: runtimeContext.waitUntil.bind(runtimeContext) }
|
|
518
608
|
: {}),
|
|
519
609
|
logger: opts.logger,
|
|
520
610
|
}
|
|
521
611
|
: undefined;
|
|
522
|
-
|
|
612
|
+
// `scope.registry` is the connection's VIEW — the full registry, or one
|
|
613
|
+
// toolkit's ScopedRegistry. Nothing below may reach for `opts.registry`.
|
|
614
|
+
const registry = scope.registry;
|
|
615
|
+
registerMetaTools(server, registry, {
|
|
523
616
|
baseUrl,
|
|
524
617
|
activity,
|
|
525
618
|
defaultToolTimeoutMs: opts.defaultToolTimeoutMs,
|
|
619
|
+
probeTimeoutMs: opts.probeTimeoutMs,
|
|
526
620
|
});
|
|
527
621
|
if (opts.executor) {
|
|
528
|
-
registerExecuteTool(server,
|
|
622
|
+
registerExecuteTool(server, registry, {
|
|
529
623
|
baseUrl,
|
|
530
624
|
executor: opts.executor,
|
|
531
625
|
logger: opts.logger,
|
|
@@ -693,9 +787,22 @@ export function createFetchHandler(
|
|
|
693
787
|
// Open shell — carries no data; data comes only from the gated /ui/data.
|
|
694
788
|
const uiAuth = auth.find((provider) => provider.uiAuth)?.uiAuth;
|
|
695
789
|
const mcpUrl = new URL("/mcp", baseUrl).toString();
|
|
696
|
-
|
|
790
|
+
// Nonce the page's inline script (and the Clerk loader). 'strict-dynamic'
|
|
791
|
+
// lets scripts the nonced Clerk loader injects at runtime execute; the
|
|
792
|
+
// https:/'unsafe-inline' fallbacks are ignored by CSP3 browsers that
|
|
793
|
+
// honour the nonce and only cover legacy ones. No default-src, so Clerk's
|
|
794
|
+
// style/font/network needs and the page's inline <style> stay unrestricted
|
|
795
|
+
// — only script execution, the XSS sink, is gated.
|
|
796
|
+
const nonce = uiScriptNonce();
|
|
797
|
+
return new Response(renderUiHtml(uiAuth, mcpUrl, opts.branding, nonce), {
|
|
697
798
|
status: 200,
|
|
698
|
-
headers: {
|
|
799
|
+
headers: {
|
|
800
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
801
|
+
"Content-Security-Policy":
|
|
802
|
+
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'; ` +
|
|
803
|
+
"object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
|
|
804
|
+
"X-Content-Type-Options": "nosniff",
|
|
805
|
+
},
|
|
699
806
|
});
|
|
700
807
|
}
|
|
701
808
|
|
|
@@ -757,11 +864,22 @@ export function createFetchHandler(
|
|
|
757
864
|
}
|
|
758
865
|
|
|
759
866
|
if (path === "/mcp") {
|
|
867
|
+
// Authenticate BEFORE resolving ?toolkit=: an unauthenticated caller
|
|
868
|
+
// must not be able to probe which toolkit names exist.
|
|
760
869
|
const authz = await authorize(request, baseUrl, auth);
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
return withMcpCors(
|
|
870
|
+
if (!authz.ok) return withMcpCors(authz.response);
|
|
871
|
+
const selected = resolveToolkitScope(url, registry, opts.toolkits);
|
|
872
|
+
if (!selected.ok) return withMcpCors(selected.response);
|
|
873
|
+
return withMcpCors(
|
|
874
|
+
await serveMcp(
|
|
875
|
+
request,
|
|
876
|
+
opts,
|
|
877
|
+
baseUrl,
|
|
878
|
+
authz.actor,
|
|
879
|
+
selected.scope,
|
|
880
|
+
runtimeContext,
|
|
881
|
+
),
|
|
882
|
+
);
|
|
765
883
|
}
|
|
766
884
|
|
|
767
885
|
// Connector-owned public routes, dispatched last: a connector can add a
|
package/src/skills.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { Connector } from "./types.js";
|
|
2
|
+
|
|
1
3
|
export const CONNECTA_INSTRUCTIONS =
|
|
2
4
|
'Connecta exposes many integrations behind meta-tools. When an address is unknown, start with search_tools and includeSchemas="compact"; use describe_tools only when that schema is insufficient. Use call_tool for one explicitly read-only call, batch_call for 2–10 independent explicitly read-only calls, and execute_code (when available) only for dependent read-only steps, loops, joins, or reducing large results. Unannotated, write-capable, and destructive tools must use call_destructive_tool individually. Use authorize_connector only after auth_required and get_result only for truncated results. For the detailed workflow, call skills({ name: "usage" }) once per task.';
|
|
3
5
|
|
|
@@ -53,11 +55,192 @@ async () => {
|
|
|
53
55
|
\`\`\`
|
|
54
56
|
`;
|
|
55
57
|
|
|
58
|
+
/**
|
|
59
|
+
* Appended to USAGE_SKILL only when the deployment actually has at least one
|
|
60
|
+
* connector guide. A deployment with none — every deployment that has not
|
|
61
|
+
* adopted the feature — keeps the base guide byte-for-byte, rather than paying
|
|
62
|
+
* context for an instruction to fetch guides that do not exist.
|
|
63
|
+
*/
|
|
64
|
+
export const CONNECTOR_GUIDES_SECTION = `
|
|
65
|
+
## Per-connector guides
|
|
66
|
+
|
|
67
|
+
Some connectors here ship their own usage guide — preferred tools, address quirks, pagination conventions, rate-limit etiquette, query patterns. \`skills({})\` lists each one as \`connector:<connectorId>\`; fetch it with \`skills({ name: "connector:<connectorId>" })\`. \`search_tools\` and \`describe_tools\` set \`guide\` on matches whose connector has one. Read a connector's guide before working with it for the first time in a task.
|
|
68
|
+
`;
|
|
69
|
+
|
|
70
|
+
/** True when at least one of `connectors` carries a usage guide. */
|
|
71
|
+
export function hasConnectorGuides(connectors: readonly Connector[]): boolean {
|
|
72
|
+
return connectors.some(
|
|
73
|
+
(connector) => connectorGuide(connector) !== undefined,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The built-in usage guide, plus the guides section when there is one to point at. */
|
|
78
|
+
export function usageSkill(connectors: readonly Connector[]): string {
|
|
79
|
+
return hasConnectorGuides(connectors)
|
|
80
|
+
? USAGE_SKILL + CONNECTOR_GUIDES_SECTION
|
|
81
|
+
: USAGE_SKILL;
|
|
82
|
+
}
|
|
83
|
+
|
|
56
84
|
export const AVAILABLE_SKILLS = [
|
|
57
85
|
{
|
|
58
86
|
name: "usage",
|
|
59
87
|
description:
|
|
60
88
|
"How to choose among Connecta discovery, direct, batch, destructive, and code-mode tools.",
|
|
61
|
-
content:
|
|
89
|
+
content: usageSkill,
|
|
62
90
|
},
|
|
63
91
|
] as const;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Namespace for operator-authored per-connector guides. Built-in skill names
|
|
95
|
+
* are bare identifiers and never contain ":", so `connector:<id>` cannot
|
|
96
|
+
* collide with one — not even when a connector's id is literally "usage".
|
|
97
|
+
* The prefixed form is the ONLY way to reach a connector guide: a bare
|
|
98
|
+
* connector id is never resolved, so nothing shadows anything silently.
|
|
99
|
+
*/
|
|
100
|
+
export const CONNECTOR_SKILL_PREFIX = "connector:";
|
|
101
|
+
|
|
102
|
+
/** The skill name that fetches `connector`'s guide. */
|
|
103
|
+
export function connectorSkillName(connectorId: string): string {
|
|
104
|
+
return `${CONNECTOR_SKILL_PREFIX}${connectorId}`;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** The connector's guide, or undefined when it declares none (or a blank one). */
|
|
108
|
+
export function connectorGuide(connector: Connector): string | undefined {
|
|
109
|
+
const guide = connector.usageGuide;
|
|
110
|
+
return guide && guide.trim() !== "" ? guide : undefined;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const SUMMARY_LENGTH = 120;
|
|
114
|
+
|
|
115
|
+
/** A `---`/`***`/`___` rule, which also opens and closes YAML frontmatter. */
|
|
116
|
+
const RULE_RE = /^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
117
|
+
|
|
118
|
+
/** A fenced code block's delimiter. */
|
|
119
|
+
const FENCE_RE = /^\s*(?:```|~~~)/;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Markup that carries no summary text of its own: horizontal rules, HTML
|
|
123
|
+
* comments, and table rows. Skipped so a guide that opens with one is
|
|
124
|
+
* summarized by its first real line instead of by punctuation.
|
|
125
|
+
*/
|
|
126
|
+
const NOT_SUMMARY_RE = /^\s*(?:<!--|\|)|^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/;
|
|
127
|
+
|
|
128
|
+
/** Drop a leading YAML frontmatter block — metadata, not summary text. */
|
|
129
|
+
function withoutFrontmatter(lines: string[]): string[] {
|
|
130
|
+
let start = 0;
|
|
131
|
+
while (start < lines.length && lines[start].trim() === "") start++;
|
|
132
|
+
if (start >= lines.length || !RULE_RE.test(lines[start])) return lines;
|
|
133
|
+
const close = lines.findIndex((line, i) => i > start && RULE_RE.test(line));
|
|
134
|
+
return close === -1 ? lines : lines.slice(close + 1);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* One line describing a guide, for the cheap list view: the guide's first
|
|
139
|
+
* meaningful line (heading marks and list bullets stripped), falling back to
|
|
140
|
+
* the connector's own description when the guide opens with nothing but
|
|
141
|
+
* markup.
|
|
142
|
+
*/
|
|
143
|
+
function summarizeGuide(connector: Connector, guide: string): string {
|
|
144
|
+
let inFence = false;
|
|
145
|
+
for (const raw of withoutFrontmatter(guide.split("\n"))) {
|
|
146
|
+
if (FENCE_RE.test(raw)) {
|
|
147
|
+
inFence = !inFence;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
if (inFence) continue;
|
|
151
|
+
if (raw.trim() === "" || NOT_SUMMARY_RE.test(raw)) continue;
|
|
152
|
+
const line = raw
|
|
153
|
+
// `\s*` (not `\s+`) so a bare `#` strips to nothing and is skipped, and
|
|
154
|
+
// an unspaced `#Heading` is still read as a heading.
|
|
155
|
+
.replace(/^\s*#{1,6}\s*/, "")
|
|
156
|
+
.replace(/^\s*[-*+]\s+/, "")
|
|
157
|
+
.replace(/\s+/g, " ")
|
|
158
|
+
.trim();
|
|
159
|
+
if (line === "") continue;
|
|
160
|
+
return line.length <= SUMMARY_LENGTH
|
|
161
|
+
? line
|
|
162
|
+
: `${line.slice(0, SUMMARY_LENGTH - 1).trimEnd()}…`;
|
|
163
|
+
}
|
|
164
|
+
return connector.description ?? `Usage guide for "${connector.id}".`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface SkillListing {
|
|
168
|
+
name: string;
|
|
169
|
+
description: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Every fetchable skill: the built-in guides plus one entry per connector that
|
|
174
|
+
* carries a usage guide. Derived from the connector list passed in — the single
|
|
175
|
+
* place guide visibility is decided. The `skills` meta-tool passes its
|
|
176
|
+
* connection's `registry.listConnectors()`, so a toolkit-scoped session lists
|
|
177
|
+
* only in-scope guides, and `resolveSkill` below reports an out-of-scope
|
|
178
|
+
* `connector:<id>` exactly as it reports an unknown connector.
|
|
179
|
+
*/
|
|
180
|
+
export function listSkills(connectors: readonly Connector[]): SkillListing[] {
|
|
181
|
+
const listing: SkillListing[] = AVAILABLE_SKILLS.map((skill) => ({
|
|
182
|
+
name: skill.name,
|
|
183
|
+
description: skill.description,
|
|
184
|
+
}));
|
|
185
|
+
for (const connector of connectors) {
|
|
186
|
+
const guide = connectorGuide(connector);
|
|
187
|
+
if (!guide) continue;
|
|
188
|
+
listing.push({
|
|
189
|
+
name: connectorSkillName(connector.id),
|
|
190
|
+
description: summarizeGuide(connector, guide),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
return listing;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export type SkillLookup =
|
|
197
|
+
{ found: true; content: string } | { found: false; message: string };
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Resolve one skill name. Built-in names match exactly; connector guides are
|
|
201
|
+
* reachable only through the `connector:` prefix. Every miss — unknown name,
|
|
202
|
+
* unknown connector, connector without a guide — is an explicit error, never a
|
|
203
|
+
* silent fallback to the generic guide.
|
|
204
|
+
*/
|
|
205
|
+
export function resolveSkill(
|
|
206
|
+
name: string,
|
|
207
|
+
connectors: readonly Connector[],
|
|
208
|
+
): SkillLookup {
|
|
209
|
+
const builtIn = AVAILABLE_SKILLS.find((skill) => skill.name === name);
|
|
210
|
+
if (builtIn) return { found: true, content: builtIn.content(connectors) };
|
|
211
|
+
const available = () =>
|
|
212
|
+
listSkills(connectors)
|
|
213
|
+
.map((skill) => skill.name)
|
|
214
|
+
.join(", ");
|
|
215
|
+
if (name.startsWith(CONNECTOR_SKILL_PREFIX)) {
|
|
216
|
+
const id = name.slice(CONNECTOR_SKILL_PREFIX.length);
|
|
217
|
+
const connector = connectors.find((c) => c.id === id);
|
|
218
|
+
if (!connector) {
|
|
219
|
+
return {
|
|
220
|
+
found: false,
|
|
221
|
+
message: `Unknown connector "${id}". Available skills: ${available()}.`,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
const guide = connectorGuide(connector);
|
|
225
|
+
if (!guide) {
|
|
226
|
+
return {
|
|
227
|
+
found: false,
|
|
228
|
+
message: `Connector "${id}" has no usage guide. Available skills: ${available()}.`,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
return { found: true, content: guide };
|
|
232
|
+
}
|
|
233
|
+
const bare = connectors.find((c) => c.id === name);
|
|
234
|
+
if (bare) {
|
|
235
|
+
return {
|
|
236
|
+
found: false,
|
|
237
|
+
message: connectorGuide(bare)
|
|
238
|
+
? `Unknown skill "${name}". Connector guides are fetched as "${connectorSkillName(name)}". Available: ${available()}.`
|
|
239
|
+
: `Connector "${name}" has no usage guide. Available skills: ${available()}.`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
return {
|
|
243
|
+
found: false,
|
|
244
|
+
message: `Unknown skill "${name}". Available: ${available()}.`,
|
|
245
|
+
};
|
|
246
|
+
}
|
package/src/storage/file.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
chmodSync,
|
|
2
3
|
existsSync,
|
|
3
4
|
mkdirSync,
|
|
4
5
|
readFileSync,
|
|
@@ -28,8 +29,20 @@ export function fileStorage(
|
|
|
28
29
|
opts: FileStorageOptions = {},
|
|
29
30
|
): KVStorage {
|
|
30
31
|
const logger: Logger = opts.logger ?? console;
|
|
32
|
+
// The state file holds downstream OAuth access/refresh tokens in cleartext,
|
|
33
|
+
// so keep it owner-only. Repair is best-effort: chmod is a no-op or throws on
|
|
34
|
+
// non-POSIX filesystems, and a loose mode must never keep the store from
|
|
35
|
+
// starting.
|
|
36
|
+
const tighten = () => {
|
|
37
|
+
try {
|
|
38
|
+
chmodSync(path, 0o600);
|
|
39
|
+
} catch {
|
|
40
|
+
// Non-POSIX filesystem or a race on the file — leave the mode as-is.
|
|
41
|
+
}
|
|
42
|
+
};
|
|
31
43
|
let data: Record<string, Entry> = {};
|
|
32
44
|
if (existsSync(path)) {
|
|
45
|
+
tighten();
|
|
33
46
|
try {
|
|
34
47
|
data = JSON.parse(readFileSync(path, "utf8")) as Record<string, Entry>;
|
|
35
48
|
} catch (error) {
|
|
@@ -60,10 +73,13 @@ export function fileStorage(
|
|
|
60
73
|
}
|
|
61
74
|
const persist = () => {
|
|
62
75
|
const dir = dirname(path);
|
|
63
|
-
if (dir) mkdirSync(dir, { recursive: true });
|
|
76
|
+
if (dir) mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
64
77
|
const tmp = `${path}.tmp`;
|
|
65
|
-
|
|
78
|
+
// 0o600 on the tmp file; the atomic rename below preserves it, so the live
|
|
79
|
+
// state file is never briefly world-readable.
|
|
80
|
+
writeFileSync(tmp, JSON.stringify(data), { mode: 0o600 });
|
|
66
81
|
renameSync(tmp, path);
|
|
82
|
+
tighten();
|
|
67
83
|
};
|
|
68
84
|
const fresh = (key: string): Entry | null => {
|
|
69
85
|
const e = data[key];
|
package/src/toolkits.ts
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Toolkits: named, operator-defined scoped views over one deployment's
|
|
2
|
+
// registry, selected per client connection with `?toolkit=<name>` on /mcp.
|
|
3
|
+
//
|
|
4
|
+
// A connecta deployment belongs to an ORG; a toolkit is the view a GROUP OF
|
|
5
|
+
// TEAM MEMBERS inside that org gets — a "support" toolkit seeing Zendesk and
|
|
6
|
+
// Notion, an "exec" toolkit that also sees Gmail. This module only *defines and
|
|
7
|
+
// validates* scopes. Enforcement lives in one place: `ScopedRegistry`
|
|
8
|
+
// (src/registry.ts), which every meta-tool inherits through `RegistryView`.
|
|
9
|
+
|
|
10
|
+
import type { Connector } from "./types.js";
|
|
11
|
+
|
|
12
|
+
/** Toolkit names share the connector-id grammar: URL-safe, no separators. */
|
|
13
|
+
export const TOOLKIT_NAME_RE = /^[a-z0-9_-]+$/;
|
|
14
|
+
|
|
15
|
+
/** One named scope, declared in `ConnectaConfig.toolkits` (config as code). */
|
|
16
|
+
export interface ToolkitDefinition {
|
|
17
|
+
/** Connector ids this toolkit may see. Required, and at least one. */
|
|
18
|
+
connectors: string[];
|
|
19
|
+
/**
|
|
20
|
+
* Optional finer grain: full tool addresses (`"<connectorId>.<toolName>"`).
|
|
21
|
+
* Naming ANY address of a connector narrows that connector to exactly the
|
|
22
|
+
* addresses named; connectors with no entry here keep their whole tool list.
|
|
23
|
+
*/
|
|
24
|
+
includeTools?: string[];
|
|
25
|
+
/** Optional tool addresses to hide, applied after `includeTools`. */
|
|
26
|
+
excludeTools?: string[];
|
|
27
|
+
/** Operator note. Never sent to clients — this is documentation for config. */
|
|
28
|
+
description?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `ConnectaConfig.toolkits` — toolkit name → definition. */
|
|
32
|
+
export type ToolkitConfig = Record<string, ToolkitDefinition>;
|
|
33
|
+
|
|
34
|
+
/** A validated toolkit: the visibility predicate the scoped registry consults. */
|
|
35
|
+
export interface Toolkit {
|
|
36
|
+
readonly name: string;
|
|
37
|
+
readonly description?: string;
|
|
38
|
+
/** True when `connectorId` is inside this toolkit's scope. */
|
|
39
|
+
hasConnector(connectorId: string): boolean;
|
|
40
|
+
/** True when `<connectorId>.<toolName>` is inside this toolkit's scope. */
|
|
41
|
+
hasTool(connectorId: string, toolName: string): boolean;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Split `"<connectorId>.<toolName>"` on the FIRST dot — connector ids contain
|
|
46
|
+
* no dots, so a downstream tool name may. Returns null for a malformed address.
|
|
47
|
+
*/
|
|
48
|
+
export function splitAddress(
|
|
49
|
+
address: string,
|
|
50
|
+
): { connectorId: string; toolName: string } | null {
|
|
51
|
+
const dot = address.indexOf(".");
|
|
52
|
+
if (dot <= 0 || dot === address.length - 1) return null;
|
|
53
|
+
return {
|
|
54
|
+
connectorId: address.slice(0, dot),
|
|
55
|
+
toolName: address.slice(dot + 1),
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Group tool addresses by connector id, validating each against the toolkit. */
|
|
60
|
+
function toolFilter(
|
|
61
|
+
name: string,
|
|
62
|
+
addresses: string[] | undefined,
|
|
63
|
+
connectorIds: ReadonlySet<string>,
|
|
64
|
+
staticTools: ReadonlyMap<string, ReadonlySet<string>>,
|
|
65
|
+
field: "includeTools" | "excludeTools",
|
|
66
|
+
): Map<string, Set<string>> {
|
|
67
|
+
const byConnector = new Map<string, Set<string>>();
|
|
68
|
+
if (addresses !== undefined && !Array.isArray(addresses)) {
|
|
69
|
+
// A bare string would otherwise iterate character by character and produce
|
|
70
|
+
// a stream of confusing address errors; anything else would throw "not
|
|
71
|
+
// iterable" from deep inside the loop. Name the field instead.
|
|
72
|
+
throw new Error(
|
|
73
|
+
`Toolkit "${name}" ${field} must be an array of "<connectorId>.<toolName>" addresses.`,
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
if (
|
|
77
|
+
addresses !== undefined &&
|
|
78
|
+
addresses.length === 0 &&
|
|
79
|
+
field === "includeTools"
|
|
80
|
+
) {
|
|
81
|
+
// An empty allowlist reads as "only these tools" but would behave as "all
|
|
82
|
+
// of them" — the one shape here that fails OPEN. (An empty excludeTools is
|
|
83
|
+
// an honest no-op and is allowed.)
|
|
84
|
+
throw new Error(
|
|
85
|
+
`Toolkit "${name}" has an empty includeTools: remove it to expose every tool, or list the addresses this toolkit may use.`,
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
for (const address of addresses ?? []) {
|
|
89
|
+
const parts = splitAddress(address);
|
|
90
|
+
if (!parts) {
|
|
91
|
+
throw new Error(
|
|
92
|
+
`Toolkit "${name}" ${field} entry "${address}" is not a tool address: expected "<connectorId>.<toolName>".`,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (!connectorIds.has(parts.connectorId)) {
|
|
96
|
+
// A typo here would silently do nothing, quietly widening the scope the
|
|
97
|
+
// operator believes they wrote. Fail at construction instead.
|
|
98
|
+
throw new Error(
|
|
99
|
+
`Toolkit "${name}" ${field} entry "${address}" names connector "${parts.connectorId}", which is not in this toolkit's connectors list.`,
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
// Static-only, exactly like the registry's convention checks: an in-code
|
|
103
|
+
// connector's tool list is known now, so a misspelled name — an exclude
|
|
104
|
+
// that silently excludes nothing — is caught. Remote catalogs are fetched
|
|
105
|
+
// lazily over the network and cannot be checked at construction.
|
|
106
|
+
const known = staticTools.get(parts.connectorId);
|
|
107
|
+
if (known && !known.has(parts.toolName)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
`Toolkit "${name}" ${field} entry "${address}" names no tool on connector "${parts.connectorId}".`,
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
const tools = byConnector.get(parts.connectorId) ?? new Set<string>();
|
|
113
|
+
tools.add(parts.toolName);
|
|
114
|
+
byConnector.set(parts.connectorId, tools);
|
|
115
|
+
}
|
|
116
|
+
return byConnector;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validate one toolkit definition against the deployment's connectors.
|
|
121
|
+
*
|
|
122
|
+
* Structural mistakes THROW at construction rather than warn: a typo'd id in
|
|
123
|
+
* an allowlist is a scope the operator did not write, and a scope nobody wrote
|
|
124
|
+
* is not one an operator can reason about. (A toolkit scopes visibility, not
|
|
125
|
+
* identity — it is not itself an access check; see the module header and
|
|
126
|
+
* documentation.md §16.) Tool names are checked only for connectors that expose
|
|
127
|
+
* `staticTools` (i.e. `api()`); a remote connector's catalog is fetched lazily
|
|
128
|
+
* over the network and is unknown at construction time.
|
|
129
|
+
*/
|
|
130
|
+
function resolveToolkit(
|
|
131
|
+
name: string,
|
|
132
|
+
definition: ToolkitDefinition,
|
|
133
|
+
known: ReadonlySet<string>,
|
|
134
|
+
staticTools: ReadonlyMap<string, ReadonlySet<string>>,
|
|
135
|
+
): Toolkit {
|
|
136
|
+
if (!TOOLKIT_NAME_RE.test(name)) {
|
|
137
|
+
throw new Error(
|
|
138
|
+
`Invalid toolkit name "${name}": must match ${TOOLKIT_NAME_RE.source}`,
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
if (
|
|
142
|
+
!Array.isArray(definition.connectors) ||
|
|
143
|
+
definition.connectors.length === 0
|
|
144
|
+
) {
|
|
145
|
+
throw new Error(
|
|
146
|
+
`Toolkit "${name}" selects no connectors: list at least one connector id in "connectors".`,
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
const connectorIds = new Set<string>();
|
|
150
|
+
for (const id of definition.connectors) {
|
|
151
|
+
if (!known.has(id)) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`Toolkit "${name}" references unknown connector "${id}".`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
connectorIds.add(id);
|
|
157
|
+
}
|
|
158
|
+
const includes = toolFilter(
|
|
159
|
+
name,
|
|
160
|
+
definition.includeTools,
|
|
161
|
+
connectorIds,
|
|
162
|
+
staticTools,
|
|
163
|
+
"includeTools",
|
|
164
|
+
);
|
|
165
|
+
const excludes = toolFilter(
|
|
166
|
+
name,
|
|
167
|
+
definition.excludeTools,
|
|
168
|
+
connectorIds,
|
|
169
|
+
staticTools,
|
|
170
|
+
"excludeTools",
|
|
171
|
+
);
|
|
172
|
+
return {
|
|
173
|
+
name,
|
|
174
|
+
...(definition.description ? { description: definition.description } : {}),
|
|
175
|
+
hasConnector: (connectorId) => connectorIds.has(connectorId),
|
|
176
|
+
hasTool: (connectorId, toolName) => {
|
|
177
|
+
if (!connectorIds.has(connectorId)) return false;
|
|
178
|
+
const include = includes.get(connectorId);
|
|
179
|
+
if (include && !include.has(toolName)) return false;
|
|
180
|
+
return !excludes.get(connectorId)?.has(toolName);
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Validate every declared toolkit against the connector set. Returns undefined
|
|
187
|
+
* when no toolkits are configured, so an existing deployment keeps exactly its
|
|
188
|
+
* current (unscoped) behavior.
|
|
189
|
+
*/
|
|
190
|
+
export function resolveToolkits(
|
|
191
|
+
toolkits: ToolkitConfig | undefined,
|
|
192
|
+
connectors: readonly Connector[],
|
|
193
|
+
): ReadonlyMap<string, Toolkit> | undefined {
|
|
194
|
+
if (!toolkits) return undefined;
|
|
195
|
+
// Object.entries (not a keyed lookup) so no config key — `__proto__` and
|
|
196
|
+
// friends included — can ever resolve through the prototype chain. Names are
|
|
197
|
+
// then held in a Map, which has no prototype to pollute.
|
|
198
|
+
const entries = Object.entries(toolkits);
|
|
199
|
+
if (entries.length === 0) return undefined;
|
|
200
|
+
const known = new Set(connectors.map((connector) => connector.id));
|
|
201
|
+
const staticTools = new Map<string, ReadonlySet<string>>();
|
|
202
|
+
for (const connector of connectors) {
|
|
203
|
+
if (connector.staticTools) {
|
|
204
|
+
staticTools.set(
|
|
205
|
+
connector.id,
|
|
206
|
+
new Set(connector.staticTools.map((tool) => tool.name)),
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const resolved = new Map<string, Toolkit>();
|
|
211
|
+
for (const [name, definition] of entries) {
|
|
212
|
+
resolved.set(name, resolveToolkit(name, definition, known, staticTools));
|
|
213
|
+
}
|
|
214
|
+
return resolved;
|
|
215
|
+
}
|