@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.
- package/CHANGELOG.md +13 -0
- package/LICENSE +21 -0
- package/README.md +249 -0
- package/SECURITY.md +25 -0
- package/assets/connecta-clay-hero.png +0 -0
- package/dist/activity.d.ts +82 -0
- package/dist/activity.d.ts.map +1 -0
- package/dist/activity.js +51 -0
- package/dist/activity.js.map +1 -0
- package/dist/auth/bearer.d.ts +10 -0
- package/dist/auth/bearer.d.ts.map +1 -0
- package/dist/auth/bearer.js +47 -0
- package/dist/auth/bearer.js.map +1 -0
- package/dist/auth/clerk.d.ts +20 -0
- package/dist/auth/clerk.d.ts.map +1 -0
- package/dist/auth/clerk.js +167 -0
- package/dist/auth/clerk.js.map +1 -0
- package/dist/auth/downstream-oauth.d.ts +78 -0
- package/dist/auth/downstream-oauth.d.ts.map +1 -0
- package/dist/auth/downstream-oauth.js +180 -0
- package/dist/auth/downstream-oauth.js.map +1 -0
- package/dist/catalog.d.ts +11 -0
- package/dist/catalog.d.ts.map +1 -0
- package/dist/catalog.js +144 -0
- package/dist/catalog.js.map +1 -0
- package/dist/connectors/api.d.ts +34 -0
- package/dist/connectors/api.d.ts.map +1 -0
- package/dist/connectors/api.js +36 -0
- package/dist/connectors/api.js.map +1 -0
- package/dist/connectors/remote-mcp.d.ts +33 -0
- package/dist/connectors/remote-mcp.d.ts.map +1 -0
- package/dist/connectors/remote-mcp.js +255 -0
- package/dist/connectors/remote-mcp.js.map +1 -0
- package/dist/credentials.d.ts +33 -0
- package/dist/credentials.d.ts.map +1 -0
- package/dist/credentials.js +177 -0
- package/dist/credentials.js.map +1 -0
- package/dist/execute.d.ts +40 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.js +424 -0
- package/dist/execute.js.map +1 -0
- package/dist/executors/quickjs.d.ts +26 -0
- package/dist/executors/quickjs.d.ts.map +1 -0
- package/dist/executors/quickjs.js +267 -0
- package/dist/executors/quickjs.js.map +1 -0
- package/dist/favicon.d.ts +2 -0
- package/dist/favicon.d.ts.map +1 -0
- package/dist/favicon.js +49 -0
- package/dist/favicon.js.map +1 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-result.d.ts +9 -0
- package/dist/mcp-result.d.ts.map +1 -0
- package/dist/mcp-result.js +35 -0
- package/dist/mcp-result.js.map +1 -0
- package/dist/meta-tools.d.ts +100 -0
- package/dist/meta-tools.d.ts.map +1 -0
- package/dist/meta-tools.js +797 -0
- package/dist/meta-tools.js.map +1 -0
- package/dist/node.d.ts +9 -0
- package/dist/node.d.ts.map +1 -0
- package/dist/node.js +57 -0
- package/dist/node.js.map +1 -0
- package/dist/registry.d.ts +78 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +299 -0
- package/dist/registry.js.map +1 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +543 -0
- package/dist/server.js.map +1 -0
- package/dist/skills.d.ts +8 -0
- package/dist/skills.d.ts.map +1 -0
- package/dist/skills.js +60 -0
- package/dist/skills.js.map +1 -0
- package/dist/storage/file.d.ts +8 -0
- package/dist/storage/file.d.ts.map +1 -0
- package/dist/storage/file.js +53 -0
- package/dist/storage/file.js.map +1 -0
- package/dist/storage/memory.d.ts +4 -0
- package/dist/storage/memory.d.ts.map +1 -0
- package/dist/storage/memory.js +29 -0
- package/dist/storage/memory.js.map +1 -0
- package/dist/types.d.ts +219 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/ui.d.ts +91 -0
- package/dist/ui.d.ts.map +1 -0
- package/dist/ui.js +1031 -0
- package/dist/ui.js.map +1 -0
- package/package.json +96 -0
- package/src/activity.ts +144 -0
- package/src/auth/bearer.ts +54 -0
- package/src/auth/clerk.ts +226 -0
- package/src/auth/downstream-oauth.ts +202 -0
- package/src/catalog.ts +166 -0
- package/src/connectors/api.ts +80 -0
- package/src/connectors/remote-mcp.ts +324 -0
- package/src/credentials.ts +259 -0
- package/src/execute.ts +550 -0
- package/src/executors/quickjs.ts +321 -0
- package/src/favicon.ts +53 -0
- package/src/index.ts +197 -0
- package/src/mcp-result.ts +43 -0
- package/src/meta-tools.ts +1120 -0
- package/src/node.ts +62 -0
- package/src/registry.ts +400 -0
- package/src/server.ts +758 -0
- package/src/skills.ts +63 -0
- package/src/storage/file.ts +62 -0
- package/src/storage/memory.ts +34 -0
- package/src/types.ts +254 -0
- 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
|
+
}
|