@opengeni/core 0.2.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/README.md +14 -0
- package/dist/index.d.ts +735 -0
- package/dist/index.js +2627 -0
- package/dist/index.js.map +1 -0
- package/package.json +55 -0
- package/src/access/index.ts +186 -0
- package/src/billing/limits.ts +207 -0
- package/src/dependencies.ts +70 -0
- package/src/domain/capabilities.ts +959 -0
- package/src/domain/environments.ts +115 -0
- package/src/domain/packs.ts +241 -0
- package/src/domain/resources.ts +221 -0
- package/src/domain/scheduled-tasks.ts +321 -0
- package/src/domain/sessions.ts +812 -0
- package/src/domain/workspace-members.ts +80 -0
- package/src/index.ts +59 -0
- package/src/managed-auth-type.ts +20 -0
- package/src/sandbox/fleet.ts +460 -0
- package/src/sandbox/routing.ts +127 -0
- package/src/sandbox-types.ts +61 -0
|
@@ -0,0 +1,959 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises";
|
|
2
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
3
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
4
|
+
import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
|
|
5
|
+
import { environmentsEncryptionKeyBytes, type Settings } from "@opengeni/config";
|
|
6
|
+
import {
|
|
7
|
+
CapabilityCatalogItem,
|
|
8
|
+
type AccessGrant,
|
|
9
|
+
type CapabilityCatalogResponse,
|
|
10
|
+
type CapabilityInstallation,
|
|
11
|
+
type CapabilityKind,
|
|
12
|
+
type CreateCapabilityCatalogItemRequest,
|
|
13
|
+
type EnableCapabilityRequest,
|
|
14
|
+
} from "@opengeni/contracts";
|
|
15
|
+
import {
|
|
16
|
+
decryptEnvironmentValue,
|
|
17
|
+
decryptedCapabilityHeaders,
|
|
18
|
+
disableCapabilityInstallation,
|
|
19
|
+
enableCapabilityInstallation,
|
|
20
|
+
enablePackInstallation,
|
|
21
|
+
encryptEnvironmentValue,
|
|
22
|
+
getCapabilityCatalogItem,
|
|
23
|
+
getCapabilityInstallation,
|
|
24
|
+
getPackInstallation,
|
|
25
|
+
getStoredCapabilityHeaderCiphertext,
|
|
26
|
+
getWorkspaceEnvironment,
|
|
27
|
+
listCapabilityCatalogItems,
|
|
28
|
+
listCapabilityInstallations,
|
|
29
|
+
listEnabledMcpCapabilityServers,
|
|
30
|
+
listPackInstallations,
|
|
31
|
+
mcpServerIdForCapability,
|
|
32
|
+
updatePackInstallationStatus,
|
|
33
|
+
upsertCapabilityCatalogItem,
|
|
34
|
+
type Database,
|
|
35
|
+
type EnabledMcpCapabilityServer,
|
|
36
|
+
} from "@opengeni/db";
|
|
37
|
+
import { HTTPException } from "hono/http-exception";
|
|
38
|
+
import { validateEnvironmentAttachment } from "./environments";
|
|
39
|
+
import { assertPackSandboxImageCompatible, listCapabilityPacks, listWorkspaceCapabilityPacks, resolveCapabilityPack } from "./packs";
|
|
40
|
+
|
|
41
|
+
const officialMcpRegistryUrl = "https://registry.modelcontextprotocol.io";
|
|
42
|
+
const firstPartyMcpServerIds = new Set(["opengeni", "files", "docs"]);
|
|
43
|
+
const mcpRegistryFetchTimeoutMs = 15000;
|
|
44
|
+
const mcpRegistryMaxPages = 3;
|
|
45
|
+
const mcpCapabilityProbeTimeoutMs = 15000;
|
|
46
|
+
const maxMcpCredentialHeaders = 16;
|
|
47
|
+
const maxMcpCredentialHeaderValueLength = 4096;
|
|
48
|
+
// RFC 9110 field-name token characters.
|
|
49
|
+
const mcpCredentialHeaderName = /^[A-Za-z0-9!#$%&'*+.^_`|~-]+$/;
|
|
50
|
+
|
|
51
|
+
export async function buildCapabilityCatalog(input: {
|
|
52
|
+
db: Database;
|
|
53
|
+
workspaceId: string;
|
|
54
|
+
settings: Settings;
|
|
55
|
+
}): Promise<CapabilityCatalogResponse> {
|
|
56
|
+
const [
|
|
57
|
+
persistedItems,
|
|
58
|
+
capabilityInstallations,
|
|
59
|
+
packInstallations,
|
|
60
|
+
workspacePacks,
|
|
61
|
+
bundledSkills,
|
|
62
|
+
] = await Promise.all([
|
|
63
|
+
listCapabilityCatalogItems(input.db, input.workspaceId),
|
|
64
|
+
listCapabilityInstallations(input.db, input.workspaceId),
|
|
65
|
+
listPackInstallations(input.db, input.workspaceId),
|
|
66
|
+
listWorkspaceCapabilityPacks(input.db, input.workspaceId),
|
|
67
|
+
discoverBundledSkills(),
|
|
68
|
+
]);
|
|
69
|
+
const capabilityInstallationById = new Map(capabilityInstallations.map((installation) => [installation.capabilityId, installation]));
|
|
70
|
+
const activePackIds = new Set(packInstallations.filter((installation) => installation.status === "active").map((installation) => installation.packId));
|
|
71
|
+
const builtInPackIds = new Set(listCapabilityPacks().map((pack) => pack.id));
|
|
72
|
+
const builtIns = [
|
|
73
|
+
...workspacePacks.map((pack) => packCatalogItem(pack, builtInPackIds.has(pack.id) ? "built_in" : "manual")),
|
|
74
|
+
...configuredMcpCatalogItems(input.settings),
|
|
75
|
+
...platformApiCatalogItems(),
|
|
76
|
+
...bundledSkills,
|
|
77
|
+
];
|
|
78
|
+
const items = dedupeCatalogItems([...builtIns, ...persistedItems])
|
|
79
|
+
.map((item) => applyCapabilityEnablement(item, capabilityInstallationById.get(item.id), activePackIds))
|
|
80
|
+
.sort(compareCatalogItems);
|
|
81
|
+
return {
|
|
82
|
+
items,
|
|
83
|
+
installations: capabilityInstallations,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export async function createCatalogItem(input: {
|
|
88
|
+
db: Database;
|
|
89
|
+
accountId: string;
|
|
90
|
+
workspaceId: string;
|
|
91
|
+
payload: CreateCapabilityCatalogItemRequest;
|
|
92
|
+
}): Promise<CapabilityCatalogItem> {
|
|
93
|
+
const id = input.payload.id?.trim() || generatedCapabilityId(input.payload);
|
|
94
|
+
if (id.startsWith("pack:")) {
|
|
95
|
+
throw new HTTPException(422, { message: "packs are managed by OpenGeni and cannot be manually created" });
|
|
96
|
+
}
|
|
97
|
+
const source = input.payload.source === "built_in" || input.payload.source === "configured" ? "manual" : input.payload.source;
|
|
98
|
+
const metadata = {
|
|
99
|
+
...input.payload.metadata,
|
|
100
|
+
...(input.payload.kind === "mcp" && input.payload.endpointUrl && !input.payload.metadata.mcpServerId
|
|
101
|
+
? { mcpServerId: mcpServerIdForCapability(id, input.payload.metadata) }
|
|
102
|
+
: {}),
|
|
103
|
+
};
|
|
104
|
+
return await upsertCapabilityCatalogItem(input.db, {
|
|
105
|
+
accountId: input.accountId,
|
|
106
|
+
workspaceId: input.workspaceId,
|
|
107
|
+
id,
|
|
108
|
+
kind: input.payload.kind,
|
|
109
|
+
source,
|
|
110
|
+
name: input.payload.name.trim(),
|
|
111
|
+
description: input.payload.description?.trim() || null,
|
|
112
|
+
category: input.payload.category.trim() || "custom",
|
|
113
|
+
tags: uniqueTags(input.payload.tags),
|
|
114
|
+
homepageUrl: input.payload.homepageUrl ?? null,
|
|
115
|
+
endpointUrl: input.payload.endpointUrl ?? null,
|
|
116
|
+
installUrl: input.payload.installUrl ?? null,
|
|
117
|
+
authModel: input.payload.authModel?.trim() || null,
|
|
118
|
+
metadata,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function enableCapability(input: {
|
|
123
|
+
db: Database;
|
|
124
|
+
grant: AccessGrant;
|
|
125
|
+
accountId: string;
|
|
126
|
+
workspaceId: string;
|
|
127
|
+
settings: Settings;
|
|
128
|
+
capabilityId: string;
|
|
129
|
+
payload: EnableCapabilityRequest;
|
|
130
|
+
probeMcpServer?: McpCapabilityProbe;
|
|
131
|
+
}): Promise<CapabilityInstallation> {
|
|
132
|
+
const item = await requireCatalogItem(input.db, input.workspaceId, input.settings, input.capabilityId);
|
|
133
|
+
if (item.kind === "mcp" && !item.runtime.available) {
|
|
134
|
+
throw new HTTPException(422, { message: "MCP capabilities need a remote streamable HTTP endpoint before they can be enabled" });
|
|
135
|
+
}
|
|
136
|
+
let installationMetadata = input.payload.metadata;
|
|
137
|
+
// Credential-header storage is written exclusively by this flow; strip the
|
|
138
|
+
// reserved keys from caller-provided config so the stored shape stays
|
|
139
|
+
// trustworthy and no plaintext credentials sneak in through config.headers.
|
|
140
|
+
const installationConfig: Record<string, unknown> = { ...input.payload.config };
|
|
141
|
+
delete installationConfig.headers;
|
|
142
|
+
delete installationConfig.headersEncrypted;
|
|
143
|
+
delete installationConfig.headerNames;
|
|
144
|
+
if (item.kind === "mcp") {
|
|
145
|
+
const headers = await resolveMcpCredentialHeaders(input, item);
|
|
146
|
+
assertRequiredMcpCredentialHeaders(item, headers);
|
|
147
|
+
installationMetadata = {
|
|
148
|
+
...installationMetadata,
|
|
149
|
+
...await validateMcpCapabilityConnection(item, input.probeMcpServer, headers ?? undefined),
|
|
150
|
+
};
|
|
151
|
+
if (headers) {
|
|
152
|
+
const key = requireCapabilityHeaderEncryption(input.settings);
|
|
153
|
+
installationConfig.headersEncrypted = Object.fromEntries(
|
|
154
|
+
Object.entries(headers).map(([name, value]) => [name, encryptEnvironmentValue(key, value)]),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
if (item.kind === "pack") {
|
|
159
|
+
const packId = packIdFromCapabilityId(item.id);
|
|
160
|
+
const pack = await resolveCapabilityPack(input.db, input.workspaceId, packId);
|
|
161
|
+
if (!pack) {
|
|
162
|
+
throw new HTTPException(404, { message: "pack not found" });
|
|
163
|
+
}
|
|
164
|
+
await assertPackSandboxImageCompatible(input.db, input.workspaceId, pack);
|
|
165
|
+
// The unified capability-enable path accepts an initial environment
|
|
166
|
+
// attachment (`payload.environmentId`), mirroring POST /packs/:id/enable:
|
|
167
|
+
// a request-supplied id is validated as a fresh attachment, otherwise the
|
|
168
|
+
// attachment stored by a previous enable is preserved and re-validated.
|
|
169
|
+
const existing = await getPackInstallation(input.db, input.workspaceId, packId);
|
|
170
|
+
const storedEnvironmentId = typeof existing?.metadata.environmentId === "string" ? existing.metadata.environmentId : undefined;
|
|
171
|
+
const requestedEnvironmentId = input.payload.environmentId;
|
|
172
|
+
const environmentId = requestedEnvironmentId ?? storedEnvironmentId;
|
|
173
|
+
if (pack.environment?.required && !environmentId) {
|
|
174
|
+
throw new HTTPException(422, {
|
|
175
|
+
message: `pack ${packId} requires an environment attachment; pass environmentId`,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
if (environmentId) {
|
|
179
|
+
if (requestedEnvironmentId) {
|
|
180
|
+
// A fresh attachment: validate it like the packs enable endpoint does.
|
|
181
|
+
// The grant holds workspace:admin here, which implies environments:use,
|
|
182
|
+
// so the attachment authorization succeeds for this caller.
|
|
183
|
+
const environment = await validateEnvironmentAttachment(
|
|
184
|
+
{ settings: input.settings, db: input.db },
|
|
185
|
+
input.grant,
|
|
186
|
+
input.workspaceId,
|
|
187
|
+
requestedEnvironmentId,
|
|
188
|
+
);
|
|
189
|
+
const missing = (pack.environment?.requiredVariables ?? [])
|
|
190
|
+
.filter((name) => !environment.variables.some((variable) => variable.name === name));
|
|
191
|
+
if (missing.length > 0) {
|
|
192
|
+
throw new HTTPException(422, { message: `environment is missing required variable(s): ${missing.join(", ")}` });
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
// The stored attachment was authorized at pack-enable time, but the
|
|
196
|
+
// environment may have been deleted or its variables changed since;
|
|
197
|
+
// re-validate it like the packs enable endpoint does.
|
|
198
|
+
const environment = await getWorkspaceEnvironment(input.db, input.workspaceId, environmentId);
|
|
199
|
+
if (!environment) {
|
|
200
|
+
throw new HTTPException(422, {
|
|
201
|
+
message: `the stored environment attachment for pack ${packId} no longer exists; re-enable it with environmentId`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
const missing = (pack.environment?.requiredVariables ?? [])
|
|
205
|
+
.filter((name) => !environment.variables.some((variable) => variable.name === name));
|
|
206
|
+
if (missing.length > 0) {
|
|
207
|
+
throw new HTTPException(422, { message: `environment is missing required variable(s): ${missing.join(", ")}` });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
await enablePackInstallation(input.db, {
|
|
212
|
+
accountId: input.accountId,
|
|
213
|
+
workspaceId: input.workspaceId,
|
|
214
|
+
packId,
|
|
215
|
+
metadata: {
|
|
216
|
+
...input.payload.metadata,
|
|
217
|
+
packVersion: pack.version,
|
|
218
|
+
...(environmentId ? { environmentId } : {}),
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
return await enableCapabilityInstallation(input.db, {
|
|
223
|
+
accountId: input.accountId,
|
|
224
|
+
workspaceId: input.workspaceId,
|
|
225
|
+
capabilityId: item.id,
|
|
226
|
+
kind: item.kind,
|
|
227
|
+
config: installationConfig,
|
|
228
|
+
metadata: installationMetadata,
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Resolves the plaintext credential headers an MCP enable should use: the
|
|
234
|
+
* validated headers from the request when provided, otherwise headers stored
|
|
235
|
+
* encrypted by a previous enable (so re-enabling never requires re-pasting
|
|
236
|
+
* credentials). Returns null when neither exists.
|
|
237
|
+
*/
|
|
238
|
+
async function resolveMcpCredentialHeaders(
|
|
239
|
+
input: { db: Database; workspaceId: string; settings: Settings; payload: EnableCapabilityRequest },
|
|
240
|
+
item: CapabilityCatalogItem,
|
|
241
|
+
): Promise<Record<string, string> | null> {
|
|
242
|
+
const provided = normalizedMcpCredentialHeaders(input.payload.headers);
|
|
243
|
+
if (provided) {
|
|
244
|
+
// Validate the key is configured before probing so a misconfigured
|
|
245
|
+
// deployment fails fast instead of after a successful remote probe.
|
|
246
|
+
requireCapabilityHeaderEncryption(input.settings);
|
|
247
|
+
return provided;
|
|
248
|
+
}
|
|
249
|
+
const storedCiphertext = await getStoredCapabilityHeaderCiphertext(input.db, input.workspaceId, item.id);
|
|
250
|
+
if (!storedCiphertext) {
|
|
251
|
+
return null;
|
|
252
|
+
}
|
|
253
|
+
const key = requireCapabilityHeaderEncryption(input.settings);
|
|
254
|
+
try {
|
|
255
|
+
return Object.fromEntries(Object.entries(storedCiphertext).map(([name, value]) => [name, decryptEnvironmentValue(key, value)]));
|
|
256
|
+
} catch {
|
|
257
|
+
throw new HTTPException(422, {
|
|
258
|
+
message: `stored credential headers for "${item.name}" could not be decrypted; supply them again in the enable request "headers" field`,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function normalizedMcpCredentialHeaders(headers: Record<string, string>): Record<string, string> | null {
|
|
264
|
+
const entries = Object.entries(headers).map(([name, value]) => [name.trim(), value] as const).filter(([name]) => name.length > 0);
|
|
265
|
+
if (entries.length === 0) {
|
|
266
|
+
return null;
|
|
267
|
+
}
|
|
268
|
+
if (entries.length > maxMcpCredentialHeaders) {
|
|
269
|
+
throw new HTTPException(422, { message: `an MCP capability supports at most ${maxMcpCredentialHeaders} credential headers` });
|
|
270
|
+
}
|
|
271
|
+
const seen = new Set<string>();
|
|
272
|
+
for (const [name, value] of entries) {
|
|
273
|
+
if (!mcpCredentialHeaderName.test(name)) {
|
|
274
|
+
throw new HTTPException(422, { message: `invalid credential header name: ${name}` });
|
|
275
|
+
}
|
|
276
|
+
const lower = name.toLowerCase();
|
|
277
|
+
if (seen.has(lower)) {
|
|
278
|
+
throw new HTTPException(422, { message: `duplicate credential header name: ${name}` });
|
|
279
|
+
}
|
|
280
|
+
seen.add(lower);
|
|
281
|
+
if (value.length === 0 || value.length > maxMcpCredentialHeaderValueLength) {
|
|
282
|
+
throw new HTTPException(422, { message: `credential header ${name} must be 1-${maxMcpCredentialHeaderValueLength} characters` });
|
|
283
|
+
}
|
|
284
|
+
// RFC 9110 §5.5: field values are HTAB / printable characters — reject
|
|
285
|
+
// all other control characters (they would also fail at the HTTP client).
|
|
286
|
+
// eslint-disable-next-line no-control-regex
|
|
287
|
+
if (/[\u0000-\u0008\u000A-\u001F\u007F]/.test(value)) {
|
|
288
|
+
throw new HTTPException(422, { message: `credential header ${name} contains forbidden control characters` });
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return Object.fromEntries(entries);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function assertRequiredMcpCredentialHeaders(item: CapabilityCatalogItem, headers: Record<string, string> | null): void {
|
|
295
|
+
const required = requiredCapabilityHeaders(item.metadata);
|
|
296
|
+
const names = new Set(Object.keys(headers ?? {}).map((name) => name.toLowerCase()));
|
|
297
|
+
const missing = required.filter((name) => !names.has(name.toLowerCase()));
|
|
298
|
+
if (missing.length > 0) {
|
|
299
|
+
throw new HTTPException(422, {
|
|
300
|
+
message: `MCP capability "${item.name}" requires credential header(s) ${missing.join(", ")}; pass them in the enable request "headers" field`,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
if (item.authModel && names.size === 0) {
|
|
304
|
+
throw new HTTPException(422, {
|
|
305
|
+
message: `MCP capability "${item.name}" requires credentials; pass them in the enable request "headers" field`,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function requiredCapabilityHeaders(metadata: Record<string, unknown>): string[] {
|
|
311
|
+
const value = metadata.requiredHeaders;
|
|
312
|
+
if (!Array.isArray(value)) {
|
|
313
|
+
return [];
|
|
314
|
+
}
|
|
315
|
+
return value.filter((name): name is string => typeof name === "string" && name.trim().length > 0).map((name) => name.trim());
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function requireCapabilityHeaderEncryption(settings: Settings): Uint8Array {
|
|
319
|
+
const key = environmentsEncryptionKeyBytes(settings);
|
|
320
|
+
if (!key) {
|
|
321
|
+
throw new HTTPException(503, { message: "MCP credential headers require OPENGENI_ENVIRONMENTS_ENCRYPTION_KEY" });
|
|
322
|
+
}
|
|
323
|
+
return key;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export type McpCapabilityProbeInput = {
|
|
327
|
+
id: string;
|
|
328
|
+
name: string;
|
|
329
|
+
url: string;
|
|
330
|
+
timeoutMs: number;
|
|
331
|
+
headers?: Record<string, string>;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
export type McpCapabilityProbeResult = {
|
|
335
|
+
toolCount: number;
|
|
336
|
+
};
|
|
337
|
+
|
|
338
|
+
export type McpCapabilityProbe = (input: McpCapabilityProbeInput) => Promise<McpCapabilityProbeResult>;
|
|
339
|
+
|
|
340
|
+
export async function validateMcpCapabilityConnection(
|
|
341
|
+
item: CapabilityCatalogItem,
|
|
342
|
+
probe: McpCapabilityProbe = probeStreamableHttpMcpServer,
|
|
343
|
+
headers?: Record<string, string>,
|
|
344
|
+
): Promise<Record<string, unknown>> {
|
|
345
|
+
if (item.kind !== "mcp") {
|
|
346
|
+
return {};
|
|
347
|
+
}
|
|
348
|
+
if (!item.endpointUrl || !item.runtime.mcpServerId) {
|
|
349
|
+
throw new HTTPException(422, { message: "MCP capabilities need a remote streamable HTTP endpoint before they can be enabled" });
|
|
350
|
+
}
|
|
351
|
+
try {
|
|
352
|
+
const result = await probe({
|
|
353
|
+
id: item.runtime.mcpServerId,
|
|
354
|
+
name: item.name,
|
|
355
|
+
url: item.endpointUrl,
|
|
356
|
+
timeoutMs: mcpCapabilityProbeTimeoutMs,
|
|
357
|
+
...(headers ? { headers } : {}),
|
|
358
|
+
});
|
|
359
|
+
return {
|
|
360
|
+
mcpConnectivity: {
|
|
361
|
+
status: "ok",
|
|
362
|
+
checkedAt: new Date().toISOString(),
|
|
363
|
+
toolCount: result.toolCount,
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
} catch (error) {
|
|
367
|
+
throw new HTTPException(422, {
|
|
368
|
+
message: `MCP capability "${item.name}" could not be enabled because OpenGeni could not initialize ${item.endpointUrl}: ${mcpProbeErrorMessage(error)}`,
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
async function probeStreamableHttpMcpServer(input: McpCapabilityProbeInput): Promise<McpCapabilityProbeResult> {
|
|
374
|
+
const controller = new AbortController();
|
|
375
|
+
const timeout = setTimeout(() => controller.abort(), input.timeoutMs);
|
|
376
|
+
const client = new Client({ name: "opengeni-capability-probe", version: "0.1.0" }, { capabilities: {} });
|
|
377
|
+
try {
|
|
378
|
+
const transport = new StreamableHTTPClientTransport(new URL(input.url), {
|
|
379
|
+
requestInit: {
|
|
380
|
+
signal: controller.signal,
|
|
381
|
+
...(input.headers ? { headers: input.headers } : {}),
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
await client.connect(transport as unknown as Transport, { timeout: input.timeoutMs, maxTotalTimeout: input.timeoutMs });
|
|
385
|
+
const tools = await client.listTools(undefined, { timeout: input.timeoutMs, maxTotalTimeout: input.timeoutMs });
|
|
386
|
+
return { toolCount: tools.tools.length };
|
|
387
|
+
} finally {
|
|
388
|
+
clearTimeout(timeout);
|
|
389
|
+
await client.close().catch(() => undefined);
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function mcpProbeErrorMessage(error: unknown): string {
|
|
394
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
395
|
+
return message.replace(/\s+/g, " ").trim().slice(0, 500) || "unknown error";
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
export async function disableCapability(input: {
|
|
399
|
+
db: Database;
|
|
400
|
+
accountId: string;
|
|
401
|
+
workspaceId: string;
|
|
402
|
+
settings: Settings;
|
|
403
|
+
capabilityId: string;
|
|
404
|
+
}): Promise<CapabilityInstallation> {
|
|
405
|
+
const item = await requireCatalogItem(input.db, input.workspaceId, input.settings, input.capabilityId);
|
|
406
|
+
if ((item.source === "built_in" || item.source === "configured") && item.kind !== "pack") {
|
|
407
|
+
throw new HTTPException(409, { message: "built-in and configured capabilities are always available; remove them from configuration to disable them" });
|
|
408
|
+
}
|
|
409
|
+
if (item.kind === "pack") {
|
|
410
|
+
await updatePackInstallationStatus(input.db, input.workspaceId, packIdFromCapabilityId(item.id), "disabled").catch(() => undefined);
|
|
411
|
+
if (!await getCapabilityInstallation(input.db, input.workspaceId, item.id)) {
|
|
412
|
+
await enableCapabilityInstallation(input.db, {
|
|
413
|
+
accountId: input.accountId,
|
|
414
|
+
workspaceId: input.workspaceId,
|
|
415
|
+
capabilityId: item.id,
|
|
416
|
+
kind: "pack",
|
|
417
|
+
metadata: {},
|
|
418
|
+
config: {},
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
} else if (!await getCapabilityInstallation(input.db, input.workspaceId, item.id)) {
|
|
422
|
+
throw new HTTPException(409, { message: "capability is not currently enabled" });
|
|
423
|
+
}
|
|
424
|
+
return await disableCapabilityInstallation(input.db, input.workspaceId, item.id);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export async function settingsWithEnabledCapabilityMcpServers(db: Database, workspaceId: string, settings: Settings): Promise<Settings> {
|
|
428
|
+
const enabled = await listEnabledMcpCapabilityServers(db, workspaceId);
|
|
429
|
+
return settingsWithMcpCapabilityServers(settings, enabled);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
export function settingsWithMcpCapabilityServers(settings: Settings, enabled: EnabledMcpCapabilityServer[]): Settings {
|
|
433
|
+
if (enabled.length === 0) {
|
|
434
|
+
return settings;
|
|
435
|
+
}
|
|
436
|
+
const encryptionKey = environmentsEncryptionKeyBytes(settings);
|
|
437
|
+
const existingIds = new Set(settings.mcpServers.map((server) => server.id));
|
|
438
|
+
const dynamicServers = enabled
|
|
439
|
+
.filter((server) => !existingIds.has(server.id))
|
|
440
|
+
.flatMap((server) => {
|
|
441
|
+
const headers = decryptedCapabilityHeaders(server, encryptionKey);
|
|
442
|
+
if (headers === "unavailable") {
|
|
443
|
+
// Without its credential headers this server can only fail auth at
|
|
444
|
+
// connect time and break agent turns; leave it out of the run.
|
|
445
|
+
return [];
|
|
446
|
+
}
|
|
447
|
+
return [{
|
|
448
|
+
id: server.id,
|
|
449
|
+
name: server.name,
|
|
450
|
+
url: server.url,
|
|
451
|
+
...(server.allowedTools ? { allowedTools: server.allowedTools } : {}),
|
|
452
|
+
...(server.timeoutMs ? { timeoutMs: server.timeoutMs } : {}),
|
|
453
|
+
cacheToolsList: server.cacheToolsList ?? false,
|
|
454
|
+
...(headers ? { headers } : {}),
|
|
455
|
+
}];
|
|
456
|
+
});
|
|
457
|
+
return dynamicServers.length ? { ...settings, mcpServers: [...settings.mcpServers, ...dynamicServers] } : settings;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
export async function discoverMcpRegistryCapabilities(input: {
|
|
461
|
+
query?: string;
|
|
462
|
+
limit?: number;
|
|
463
|
+
fetchImpl?: McpRegistryFetch;
|
|
464
|
+
timeoutMs?: number;
|
|
465
|
+
}): Promise<CapabilityCatalogItem[]> {
|
|
466
|
+
const query = (input.query ?? "").trim().toLowerCase();
|
|
467
|
+
const limit = Math.min(100, Math.max(1, Math.floor(input.limit ?? 50)));
|
|
468
|
+
const items: CapabilityCatalogItem[] = [];
|
|
469
|
+
const seen = new Set<string>();
|
|
470
|
+
const fetchOptions: { fetchImpl?: McpRegistryFetch; timeoutMs?: number } = {};
|
|
471
|
+
if (input.fetchImpl) {
|
|
472
|
+
fetchOptions.fetchImpl = input.fetchImpl;
|
|
473
|
+
}
|
|
474
|
+
if (input.timeoutMs !== undefined) {
|
|
475
|
+
fetchOptions.timeoutMs = input.timeoutMs;
|
|
476
|
+
}
|
|
477
|
+
let cursor: string | undefined;
|
|
478
|
+
let pages = 0;
|
|
479
|
+
|
|
480
|
+
while (items.length < limit && pages < mcpRegistryMaxPages) {
|
|
481
|
+
pages += 1;
|
|
482
|
+
const url = new URL("/v0.1/servers", officialMcpRegistryUrl);
|
|
483
|
+
url.searchParams.set("limit", String(limit));
|
|
484
|
+
url.searchParams.set("version", "latest");
|
|
485
|
+
if (query) {
|
|
486
|
+
url.searchParams.set("search", query);
|
|
487
|
+
}
|
|
488
|
+
if (cursor) {
|
|
489
|
+
url.searchParams.set("cursor", cursor);
|
|
490
|
+
}
|
|
491
|
+
const page = await fetchMcpRegistryPage(url, fetchOptions);
|
|
492
|
+
for (const entry of page.servers ?? []) {
|
|
493
|
+
const item = mcpRegistryEntryToCatalogItem(entry);
|
|
494
|
+
if (!item || seen.has(item.id)) {
|
|
495
|
+
continue;
|
|
496
|
+
}
|
|
497
|
+
if (query && !catalogSearchText(item).includes(query)) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
seen.add(item.id);
|
|
501
|
+
items.push(item);
|
|
502
|
+
if (items.length >= limit) {
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
cursor = typeof page.metadata?.nextCursor === "string" ? page.metadata.nextCursor : undefined;
|
|
507
|
+
if (!cursor) {
|
|
508
|
+
break;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
return items;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export { officialMcpRegistryUrl };
|
|
516
|
+
|
|
517
|
+
type McpRegistryFetch = (input: URL, init?: RequestInit) => Promise<Response>;
|
|
518
|
+
|
|
519
|
+
async function fetchMcpRegistryPage(url: URL, options: {
|
|
520
|
+
fetchImpl?: McpRegistryFetch;
|
|
521
|
+
timeoutMs?: number;
|
|
522
|
+
} = {}): Promise<McpRegistryPage> {
|
|
523
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
524
|
+
const controller = new AbortController();
|
|
525
|
+
const timeout = setTimeout(() => controller.abort(), options.timeoutMs ?? mcpRegistryFetchTimeoutMs);
|
|
526
|
+
try {
|
|
527
|
+
const response = await fetchImpl(url, { signal: controller.signal });
|
|
528
|
+
if (!response.ok) {
|
|
529
|
+
throw new HTTPException(502, { message: `MCP registry returned ${response.status}` });
|
|
530
|
+
}
|
|
531
|
+
return await response.json() as McpRegistryPage;
|
|
532
|
+
} catch (error) {
|
|
533
|
+
if (error instanceof HTTPException) {
|
|
534
|
+
throw error;
|
|
535
|
+
}
|
|
536
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
537
|
+
throw new HTTPException(504, { message: "MCP registry request timed out" });
|
|
538
|
+
}
|
|
539
|
+
throw new HTTPException(502, {
|
|
540
|
+
message: `MCP registry request failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
541
|
+
});
|
|
542
|
+
} finally {
|
|
543
|
+
clearTimeout(timeout);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
async function requireCatalogItem(db: Database, workspaceId: string, settings: Settings, capabilityId: string): Promise<CapabilityCatalogItem> {
|
|
548
|
+
const catalog = await buildCapabilityCatalog({ db, workspaceId, settings });
|
|
549
|
+
const item = catalog.items.find((candidate) => candidate.id === capabilityId) ?? await getCapabilityCatalogItem(db, workspaceId, capabilityId);
|
|
550
|
+
if (!item) {
|
|
551
|
+
throw new HTTPException(404, { message: "capability not found" });
|
|
552
|
+
}
|
|
553
|
+
return item;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function packCatalogItem(pack: ReturnType<typeof listCapabilityPacks>[number], source: "built_in" | "manual"): CapabilityCatalogItem {
|
|
557
|
+
return CapabilityCatalogItem.parse({
|
|
558
|
+
id: `pack:${pack.id}`,
|
|
559
|
+
kind: "pack",
|
|
560
|
+
source,
|
|
561
|
+
name: pack.name,
|
|
562
|
+
description: pack.description,
|
|
563
|
+
category: pack.category,
|
|
564
|
+
tags: [pack.role, pack.category, "pack"],
|
|
565
|
+
tools: pack.tools,
|
|
566
|
+
runtime: {
|
|
567
|
+
available: true,
|
|
568
|
+
notes: "Enables role-scoped tools, connectors, knowledge, and scheduled-task templates.",
|
|
569
|
+
},
|
|
570
|
+
metadata: {
|
|
571
|
+
packId: pack.id,
|
|
572
|
+
version: pack.version,
|
|
573
|
+
connectors: pack.connectors,
|
|
574
|
+
knowledge: pack.knowledge,
|
|
575
|
+
scheduledTaskTemplates: pack.scheduledTaskTemplates,
|
|
576
|
+
// Runtime composition surface only: skill names, never file content.
|
|
577
|
+
...(pack.sandboxImage ? { sandboxImage: pack.sandboxImage } : {}),
|
|
578
|
+
...(pack.skills.length > 0 ? { skills: pack.skills.map((skill) => skill.name) } : {}),
|
|
579
|
+
...pack.metadata,
|
|
580
|
+
},
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function configuredMcpCatalogItems(settings: Settings): CapabilityCatalogItem[] {
|
|
585
|
+
return settings.mcpServers.map((server) => CapabilityCatalogItem.parse({
|
|
586
|
+
id: `mcp:${server.id}`,
|
|
587
|
+
kind: "mcp",
|
|
588
|
+
source: firstPartyMcpServerIds.has(server.id) ? "built_in" : "configured",
|
|
589
|
+
name: server.name ?? server.id,
|
|
590
|
+
description: firstPartyMcpDescription(server.id),
|
|
591
|
+
category: firstPartyMcpServerIds.has(server.id) ? "platform" : "configured",
|
|
592
|
+
tags: ["mcp", ...(server.allowedTools?.length ? ["limited-tools"] : [])],
|
|
593
|
+
endpointUrl: server.url,
|
|
594
|
+
tools: [{ kind: "mcp", id: server.id }],
|
|
595
|
+
runtime: {
|
|
596
|
+
available: true,
|
|
597
|
+
mcpServerId: server.id,
|
|
598
|
+
transport: "streamable-http",
|
|
599
|
+
notes: firstPartyMcpServerIds.has(server.id) ? "Available from OpenGeni runtime configuration." : "Configured through OPENGENI_MCP_SERVERS.",
|
|
600
|
+
},
|
|
601
|
+
metadata: {
|
|
602
|
+
mcpServerId: server.id,
|
|
603
|
+
allowedTools: server.allowedTools ?? [],
|
|
604
|
+
cacheToolsList: server.cacheToolsList,
|
|
605
|
+
},
|
|
606
|
+
}));
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function platformApiCatalogItems(): CapabilityCatalogItem[] {
|
|
610
|
+
return [
|
|
611
|
+
{
|
|
612
|
+
id: "api:github-app",
|
|
613
|
+
name: "GitHub App",
|
|
614
|
+
description: "Repository discovery, scoped clone tokens, pushes, and pull requests.",
|
|
615
|
+
category: "source-control",
|
|
616
|
+
tags: ["api", "github", "repositories"],
|
|
617
|
+
endpointPath: "/v1/workspaces/{workspaceId}/github/app",
|
|
618
|
+
},
|
|
619
|
+
{
|
|
620
|
+
id: "api:documents",
|
|
621
|
+
name: "Document Knowledge Base",
|
|
622
|
+
description: "Upload, index, search, and attach knowledge bases to agents.",
|
|
623
|
+
category: "knowledge",
|
|
624
|
+
tags: ["api", "documents", "knowledge"],
|
|
625
|
+
endpointPath: "/v1/workspaces/{workspaceId}/document-bases",
|
|
626
|
+
},
|
|
627
|
+
{
|
|
628
|
+
id: "api:social",
|
|
629
|
+
name: "Social Accounts",
|
|
630
|
+
description: "Connect social accounts and ingest posts for marketing agents.",
|
|
631
|
+
category: "marketing",
|
|
632
|
+
tags: ["api", "social", "marketing"],
|
|
633
|
+
endpointPath: "/v1/workspaces/{workspaceId}/social/connections",
|
|
634
|
+
},
|
|
635
|
+
{
|
|
636
|
+
id: "api:scheduled-tasks",
|
|
637
|
+
name: "Scheduled Tasks",
|
|
638
|
+
description: "Run agents once, on intervals, or on calendar schedules.",
|
|
639
|
+
category: "automation",
|
|
640
|
+
tags: ["api", "schedules", "agents"],
|
|
641
|
+
endpointPath: "/v1/workspaces/{workspaceId}/scheduled-tasks",
|
|
642
|
+
},
|
|
643
|
+
].map((item) => CapabilityCatalogItem.parse({
|
|
644
|
+
id: item.id,
|
|
645
|
+
name: item.name,
|
|
646
|
+
description: item.description,
|
|
647
|
+
category: item.category,
|
|
648
|
+
tags: item.tags,
|
|
649
|
+
kind: "api",
|
|
650
|
+
source: "built_in",
|
|
651
|
+
runtime: {
|
|
652
|
+
available: true,
|
|
653
|
+
notes: "Available through the OpenGeni API.",
|
|
654
|
+
},
|
|
655
|
+
metadata: {
|
|
656
|
+
endpointPath: item.endpointPath,
|
|
657
|
+
},
|
|
658
|
+
}));
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async function discoverBundledSkills(): Promise<CapabilityCatalogItem[]> {
|
|
662
|
+
const skillsDir = new URL("../../../../packages/runtime/src/bundled_hashicorp_terraform_skills/", import.meta.url);
|
|
663
|
+
try {
|
|
664
|
+
const entries = await readdir(skillsDir, { withFileTypes: true });
|
|
665
|
+
const skills = await Promise.all(entries
|
|
666
|
+
.filter((entry) => entry.isDirectory())
|
|
667
|
+
.map(async (entry) => {
|
|
668
|
+
const skill = await readSkillMetadata(new URL(`${entry.name}/SKILL.md`, skillsDir), entry.name);
|
|
669
|
+
return CapabilityCatalogItem.parse({
|
|
670
|
+
id: `skill:${entry.name}`,
|
|
671
|
+
kind: "skill",
|
|
672
|
+
source: "built_in",
|
|
673
|
+
name: skill.name,
|
|
674
|
+
description: skill.description,
|
|
675
|
+
category: skill.category,
|
|
676
|
+
tags: ["skill", skill.category],
|
|
677
|
+
runtime: {
|
|
678
|
+
available: true,
|
|
679
|
+
notes: "Bundled into the sandbox skill library.",
|
|
680
|
+
},
|
|
681
|
+
metadata: {
|
|
682
|
+
path: `packages/runtime/src/bundled_hashicorp_terraform_skills/${entry.name}/SKILL.md`,
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
}));
|
|
686
|
+
return skills;
|
|
687
|
+
} catch {
|
|
688
|
+
return [];
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
async function readSkillMetadata(url: URL, fallbackName: string): Promise<{ name: string; description: string | null; category: string }> {
|
|
693
|
+
const content = await readFile(url, "utf8");
|
|
694
|
+
const frontMatter = content.match(/^---\n([\s\S]*?)\n---/);
|
|
695
|
+
const frontMatterBody = frontMatter?.[1] ?? "";
|
|
696
|
+
const name = frontMatterBody.match(/^name:\s*(.+)$/m)?.[1]?.trim() || fallbackName;
|
|
697
|
+
const blockDescription = frontMatterBody.match(/^description:\s*>-\s*\n([\s\S]*?)(?:\n[a-zA-Z_-]+:|\n?$)/m)?.[1]
|
|
698
|
+
?.split("\n")
|
|
699
|
+
.map((line) => line.trim())
|
|
700
|
+
.filter(Boolean)
|
|
701
|
+
.join(" ");
|
|
702
|
+
const inlineDescription = frontMatterBody.match(/^description:\s*(?!>-\s*$)(.+)$/m)?.[1]?.trim();
|
|
703
|
+
const description = blockDescription
|
|
704
|
+
|| inlineDescription
|
|
705
|
+
|| content.match(/^#\s+(.+)$/m)?.[1]?.trim()
|
|
706
|
+
|| null;
|
|
707
|
+
const lower = `${fallbackName} ${name} ${description ?? ""}`.toLowerCase();
|
|
708
|
+
const category = lower.includes("social") || lower.includes("marketing")
|
|
709
|
+
? "marketing"
|
|
710
|
+
: lower.includes("checkov") || lower.includes("terraform") || lower.includes("azure")
|
|
711
|
+
? "infrastructure"
|
|
712
|
+
: "general";
|
|
713
|
+
return { name, description, category };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function applyCapabilityEnablement(
|
|
717
|
+
item: CapabilityCatalogItem,
|
|
718
|
+
installation: CapabilityInstallation | undefined,
|
|
719
|
+
activePackIds: Set<string>,
|
|
720
|
+
): CapabilityCatalogItem {
|
|
721
|
+
if (item.kind === "pack") {
|
|
722
|
+
// Pack enablement lives in pack_installations regardless of whether the
|
|
723
|
+
// pack is built in or registered from a workspace manifest.
|
|
724
|
+
const enabled = activePackIds.has(packIdFromCapabilityId(item.id)) || installation?.status === "active";
|
|
725
|
+
return {
|
|
726
|
+
...item,
|
|
727
|
+
enabled,
|
|
728
|
+
enabledReason: enabled ? "enabled" : null,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
if (item.source === "built_in" || item.source === "configured") {
|
|
732
|
+
return {
|
|
733
|
+
...item,
|
|
734
|
+
enabled: true,
|
|
735
|
+
enabledReason: item.source === "configured" ? "configured" : "built in",
|
|
736
|
+
};
|
|
737
|
+
}
|
|
738
|
+
const activeInstallation = installation?.status === "active";
|
|
739
|
+
const enabled = !!activeInstallation && capabilityInstallationRuntimeReady(item, installation);
|
|
740
|
+
return {
|
|
741
|
+
...item,
|
|
742
|
+
enabled,
|
|
743
|
+
enabledReason: enabled ? "enabled" : null,
|
|
744
|
+
};
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function dedupeCatalogItems(items: CapabilityCatalogItem[]): CapabilityCatalogItem[] {
|
|
748
|
+
const byId = new Map<string, CapabilityCatalogItem>();
|
|
749
|
+
for (const item of items) {
|
|
750
|
+
byId.set(item.id, item);
|
|
751
|
+
}
|
|
752
|
+
return [...byId.values()];
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function compareCatalogItems(a: CapabilityCatalogItem, b: CapabilityCatalogItem): number {
|
|
756
|
+
return `${a.kind}:${a.category}:${a.name}`.localeCompare(`${b.kind}:${b.category}:${b.name}`);
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
function firstPartyMcpDescription(id: string): string | null {
|
|
760
|
+
if (id === "opengeni") {
|
|
761
|
+
return "First-party OpenGeni MCP tools for files, documents, schedules, and social analysis.";
|
|
762
|
+
}
|
|
763
|
+
if (id === "docs") {
|
|
764
|
+
return "Document-base search tools for indexed knowledge.";
|
|
765
|
+
}
|
|
766
|
+
if (id === "files") {
|
|
767
|
+
return "File download URL tools for sandbox-mounted file resources.";
|
|
768
|
+
}
|
|
769
|
+
return null;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function generatedCapabilityId(payload: CreateCapabilityCatalogItemRequest): string {
|
|
773
|
+
const source = [payload.kind, payload.name, payload.endpointUrl ?? payload.installUrl ?? payload.homepageUrl ?? ""].join(":");
|
|
774
|
+
return `${payload.kind}:${slugify(payload.name)}-${shortHash(source)}`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function publicRegistryCapabilityId(name: string, version: string, endpointUrl: string): string {
|
|
778
|
+
return `mcp-registry:${slugify(name)}-${shortHash(`${name}:${version}:${endpointUrl}`)}`;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function packIdFromCapabilityId(capabilityId: string): string {
|
|
782
|
+
return capabilityId.replace(/^pack:/, "");
|
|
783
|
+
}
|
|
784
|
+
|
|
785
|
+
function uniqueTags(tags: string[]): string[] {
|
|
786
|
+
return [...new Set(tags.map((tag) => tag.trim()).filter(Boolean))];
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
function slugify(value: string): string {
|
|
790
|
+
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "capability";
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function shortHash(value: string): string {
|
|
794
|
+
let hash = 0x811c9dc5;
|
|
795
|
+
for (let index = 0; index < value.length; index += 1) {
|
|
796
|
+
hash ^= value.charCodeAt(index);
|
|
797
|
+
hash = Math.imul(hash, 0x01000193);
|
|
798
|
+
}
|
|
799
|
+
return (hash >>> 0).toString(36).padStart(7, "0").slice(0, 7);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
type McpRegistryPage = {
|
|
803
|
+
servers?: McpRegistryEntry[];
|
|
804
|
+
metadata?: {
|
|
805
|
+
nextCursor?: string;
|
|
806
|
+
};
|
|
807
|
+
};
|
|
808
|
+
|
|
809
|
+
type McpRegistryEntry = {
|
|
810
|
+
server?: {
|
|
811
|
+
name?: string;
|
|
812
|
+
title?: string;
|
|
813
|
+
description?: string;
|
|
814
|
+
version?: string;
|
|
815
|
+
websiteUrl?: string;
|
|
816
|
+
repository?: {
|
|
817
|
+
url?: string;
|
|
818
|
+
};
|
|
819
|
+
remotes?: Array<{
|
|
820
|
+
type?: string;
|
|
821
|
+
url?: string;
|
|
822
|
+
headers?: Array<{
|
|
823
|
+
name?: string;
|
|
824
|
+
description?: string;
|
|
825
|
+
isRequired?: boolean;
|
|
826
|
+
isSecret?: boolean;
|
|
827
|
+
}>;
|
|
828
|
+
}>;
|
|
829
|
+
packages?: unknown[];
|
|
830
|
+
};
|
|
831
|
+
_meta?: {
|
|
832
|
+
"io.modelcontextprotocol.registry/official"?: {
|
|
833
|
+
status?: string;
|
|
834
|
+
isLatest?: boolean;
|
|
835
|
+
updatedAt?: string;
|
|
836
|
+
};
|
|
837
|
+
};
|
|
838
|
+
};
|
|
839
|
+
|
|
840
|
+
type McpRegistryRemote = NonNullable<NonNullable<McpRegistryEntry["server"]>["remotes"]>[number];
|
|
841
|
+
|
|
842
|
+
function mcpRegistryEntryToCatalogItem(entry: McpRegistryEntry): CapabilityCatalogItem | null {
|
|
843
|
+
const server = entry.server;
|
|
844
|
+
if (!server?.name) {
|
|
845
|
+
return null;
|
|
846
|
+
}
|
|
847
|
+
const official = entry._meta?.["io.modelcontextprotocol.registry/official"];
|
|
848
|
+
if (official?.status && official.status !== "active") {
|
|
849
|
+
return null;
|
|
850
|
+
}
|
|
851
|
+
if (official?.isLatest === false) {
|
|
852
|
+
return null;
|
|
853
|
+
}
|
|
854
|
+
const remote = server.remotes?.find((candidate) => candidate.type === "streamable-http" && candidate.url);
|
|
855
|
+
const endpointUrl = validUrl(remote?.url);
|
|
856
|
+
if (!remote || !endpointUrl) {
|
|
857
|
+
return null;
|
|
858
|
+
}
|
|
859
|
+
const version = server.version ?? "latest";
|
|
860
|
+
const id = publicRegistryCapabilityId(server.name, version, endpointUrl);
|
|
861
|
+
const homepageUrl = validUrl(server.websiteUrl) ?? validUrl(server.repository?.url);
|
|
862
|
+
const requiredHeaders = requiredRemoteHeaders(remote);
|
|
863
|
+
const mcpServerId = mcpServerIdForCapability(id, {});
|
|
864
|
+
return CapabilityCatalogItem.parse({
|
|
865
|
+
id,
|
|
866
|
+
kind: "mcp",
|
|
867
|
+
source: "public_registry",
|
|
868
|
+
name: server.title || server.name,
|
|
869
|
+
description: server.description ?? null,
|
|
870
|
+
category: "public-mcp",
|
|
871
|
+
tags: ["mcp", "public", "registry", ...(requiredHeaders.length ? ["requires-credentials"] : [])],
|
|
872
|
+
homepageUrl,
|
|
873
|
+
endpointUrl,
|
|
874
|
+
installUrl: homepageUrl,
|
|
875
|
+
authModel: requiredHeaders.length ? "credential_ref" : null,
|
|
876
|
+
tools: [{ kind: "mcp", id: mcpServerId }],
|
|
877
|
+
runtime: {
|
|
878
|
+
available: true,
|
|
879
|
+
mcpServerId,
|
|
880
|
+
transport: "streamable-http",
|
|
881
|
+
notes: requiredHeaders.length === 0
|
|
882
|
+
? "Remote MCP server from the official MCP Registry."
|
|
883
|
+
: `This MCP requires credential header(s) ${requiredHeaders.join(", ")} supplied in the enable request.`,
|
|
884
|
+
},
|
|
885
|
+
metadata: {
|
|
886
|
+
registry: "official_mcp_registry",
|
|
887
|
+
registryName: server.name,
|
|
888
|
+
version,
|
|
889
|
+
updatedAt: official?.updatedAt,
|
|
890
|
+
packages: server.packages ?? [],
|
|
891
|
+
requiredHeaders,
|
|
892
|
+
},
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function requiredRemoteHeaders(remote: McpRegistryRemote): string[] {
|
|
897
|
+
return (remote.headers ?? [])
|
|
898
|
+
.filter((header) => header.name && header.isRequired !== false)
|
|
899
|
+
.map((header) => header.name!.trim())
|
|
900
|
+
.filter(Boolean);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function validUrl(value: string | undefined): string | null {
|
|
904
|
+
if (!value) {
|
|
905
|
+
return null;
|
|
906
|
+
}
|
|
907
|
+
try {
|
|
908
|
+
return new URL(value).toString();
|
|
909
|
+
} catch {
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function catalogSearchText(item: CapabilityCatalogItem): string {
|
|
915
|
+
return [
|
|
916
|
+
item.name,
|
|
917
|
+
item.description,
|
|
918
|
+
item.category,
|
|
919
|
+
...item.tags,
|
|
920
|
+
item.endpointUrl,
|
|
921
|
+
item.homepageUrl,
|
|
922
|
+
item.installUrl,
|
|
923
|
+
JSON.stringify(item.metadata),
|
|
924
|
+
].filter(Boolean).join(" ").toLowerCase();
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function capabilityInstallationRuntimeReady(
|
|
928
|
+
item: CapabilityCatalogItem,
|
|
929
|
+
installation: CapabilityInstallation | undefined,
|
|
930
|
+
): boolean {
|
|
931
|
+
if (!installation || item.kind !== "mcp") {
|
|
932
|
+
return !!installation;
|
|
933
|
+
}
|
|
934
|
+
if (!item.runtime.available) {
|
|
935
|
+
return false;
|
|
936
|
+
}
|
|
937
|
+
if (!storedCredentialHeadersSatisfy(item, installation)) {
|
|
938
|
+
return false;
|
|
939
|
+
}
|
|
940
|
+
const connectivity = installation.metadata.mcpConnectivity;
|
|
941
|
+
return !!connectivity && typeof connectivity === "object" && "status" in connectivity && connectivity.status === "ok";
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Checks the redacted installation config (header names only) against the
|
|
946
|
+
* capability's declared credential requirements.
|
|
947
|
+
*/
|
|
948
|
+
function storedCredentialHeadersSatisfy(item: CapabilityCatalogItem, installation: CapabilityInstallation): boolean {
|
|
949
|
+
const storedNames = new Set(
|
|
950
|
+
(Array.isArray(installation.config.headerNames) ? installation.config.headerNames : [])
|
|
951
|
+
.filter((name): name is string => typeof name === "string")
|
|
952
|
+
.map((name) => name.toLowerCase()),
|
|
953
|
+
);
|
|
954
|
+
const required = requiredCapabilityHeaders(item.metadata);
|
|
955
|
+
if (required.some((name) => !storedNames.has(name.toLowerCase()))) {
|
|
956
|
+
return false;
|
|
957
|
+
}
|
|
958
|
+
return !item.authModel || storedNames.size > 0;
|
|
959
|
+
}
|