@opengeni/api-router 2.12.3 → 2.12.4-canary.1

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.
@@ -60,6 +60,11 @@ export declare function requireIntegrationsStateSecret(settings: Settings): stri
60
60
  export declare function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void;
61
61
  /** The official-Gmail profile's origin pins, kept exported for its tests. */
62
62
  export declare function assertGoogleAuthorizationServer(as: AuthorizationServerMetadata): void;
63
+ /** Inspect without credentials, registration, or running tools. */
64
+ export declare function inspectMcpAuthentication(resource: string, settings: Settings): Promise<{
65
+ kind: "oauth2" | "none" | "unknown";
66
+ message?: string;
67
+ }>;
63
68
  export declare function preferredOAuthSelfRegistration(as: Pick<AuthorizationServerMetadata, "clientIdMetadataDocumentSupported" | "registrationEndpoint">, clientSource: OAuthProviderProfile["clientSource"]): "cimd" | "dcr" | null;
64
69
  export declare function buildAuthorizationUrl(input: {
65
70
  endpoint: string;
@@ -82,8 +82,8 @@ export declare function scheduledTaskMcpSummary(task: ScheduledTask): {
82
82
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
83
83
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
84
84
  action: {
85
+ readonly sourceId?: never;
85
86
  kind: "agent_turn";
86
- sourceId?: never;
87
87
  sourceGeneration?: never;
88
88
  sourceLifecycleGeneration?: never;
89
89
  destination?: never;
@@ -205,8 +205,8 @@ export declare function boundScheduledTaskDetailMcp(task: ScheduledTask): {
205
205
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
206
206
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
207
207
  action: {
208
+ readonly sourceId?: never;
208
209
  kind: "agent_turn";
209
- sourceId?: never;
210
210
  sourceGeneration?: never;
211
211
  sourceLifecycleGeneration?: never;
212
212
  destination?: never;
@@ -389,8 +389,8 @@ export declare function boundScheduledTaskMcpPage(input: {
389
389
  runMode: "existing_session" | "new_session_per_run" | "reusable_session";
390
390
  overlapPolicy: "allow_concurrent" | "buffer_one" | "skip";
391
391
  action: {
392
+ readonly sourceId?: never;
392
393
  kind: "agent_turn";
393
- sourceId?: never;
394
394
  sourceGeneration?: never;
395
395
  sourceLifecycleGeneration?: never;
396
396
  destination?: never;
@@ -86,6 +86,7 @@ export declare function helloReportsDisplay(hello: Hello): boolean;
86
86
  export declare function helloDesktopUnavailableReason(hello: Hello): string | null;
87
87
  /** Whether the runner's current Hello advertises the op-stream engine. */
88
88
  export declare function helloReportsOpStream(hello: Hello): boolean;
89
+ export declare function helloRuntimeCapabilities(hello: Hello): Record<string, boolean>;
89
90
  /**
90
91
  * Reconcile `enrollments.has_display` (+ the capture-blocked reason) to what a Hello
91
92
  * reports. Resolves the enrollment (the accountId is the RLS principal + the
@@ -1,7 +1,7 @@
1
1
  import type { Settings } from "@opengeni/config";
2
2
  import { type Database, type LeaseSnapshot } from "@opengeni/db";
3
3
  import { type EstablishedSandboxSession } from "@opengeni/runtime/sandbox";
4
- import type { ObjectStorage } from "@opengeni/storage";
4
+ import { type ObjectStorage } from "@opengeni/storage";
5
5
  /** The sole API-direct cold->warming owner path used by Channel A and viewer
6
6
  * attach. It never publishes warm until archive identity, hydrated tree, command
7
7
  * routing, provider identity, and the selected rematerialization attempt all
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "2.12.3",
3
+ "version": "2.12.4-canary.1",
4
4
  "description": "OpenGeni HTTP surface: the Hono adapter/router (createApp), routes, MCP HTTP transport, and HTTP access adapters over @opengeni/core. An engine-distribution surface — its runtime closure includes engine-internal packages.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -43,24 +43,24 @@
43
43
  "@hono/zod-validator": "^0.7.6",
44
44
  "@llamaindex/liteparse": "2.14.2",
45
45
  "@modelcontextprotocol/sdk": "^1.29.0",
46
- "@opengeni/agent-proto": "^0.5.1",
47
- "@opengeni/artifact-tool": "^0.3.25",
48
- "@opengeni/capabilities": "^0.3.3",
49
- "@opengeni/codemode": "^0.5.5",
50
- "@opengeni/codex": "^0.2.22",
51
- "@opengeni/config": "^1.1.1",
52
- "@opengeni/contracts": "^3.0.1",
53
- "@opengeni/core": "^2.9.3",
54
- "@opengeni/db": "^4.3.2",
55
- "@opengeni/documents": "^0.8.26",
56
- "@opengeni/events": "^0.4.24",
57
- "@opengeni/github": "^0.7.9",
58
- "@opengeni/network": "^0.3.1",
59
- "@opengeni/observability": "^0.8.25",
60
- "@opengeni/runtime": "^2.5.2",
61
- "@opengeni/storage": "^0.2.126",
62
- "@opengeni/tool-gateway": "^0.1.6",
63
- "@opengeni/xai-subscription": "^0.1.4",
46
+ "@opengeni/agent-proto": "^0.5.1-canary.27",
47
+ "@opengeni/artifact-tool": "^0.3.26-canary.1",
48
+ "@opengeni/capabilities": "^0.3.3-canary.9",
49
+ "@opengeni/codemode": "^0.5.6-canary.1",
50
+ "@opengeni/codex": "^0.2.22-canary.9",
51
+ "@opengeni/config": "^1.1.2-canary.1",
52
+ "@opengeni/contracts": "^3.0.2-canary.1",
53
+ "@opengeni/core": "^2.9.4-canary.1",
54
+ "@opengeni/db": "^4.3.3-canary.1",
55
+ "@opengeni/documents": "^0.8.27-canary.1",
56
+ "@opengeni/events": "^0.4.25-canary.1",
57
+ "@opengeni/github": "^0.7.10-canary.1",
58
+ "@opengeni/network": "^0.3.1-canary.9",
59
+ "@opengeni/observability": "^0.8.26-canary.1",
60
+ "@opengeni/runtime": "^2.5.3-canary.1",
61
+ "@opengeni/storage": "^0.2.127-canary.1",
62
+ "@opengeni/tool-gateway": "^0.1.7-canary.1",
63
+ "@opengeni/xai-subscription": "^0.1.4-canary.9",
64
64
  "@temporalio/client": "^1.17.0",
65
65
  "better-auth": "^1.6.14",
66
66
  "hono": "^4.12.18",
@@ -0,0 +1,48 @@
1
+ import snapshot from "../../../../data/catalog/plugins-snapshot.json";
2
+ import { PluginManifest, pluginMcpUnavailableReason } from "@opengeni/contracts";
3
+ import { HTTPException } from "hono/http-exception";
4
+
5
+ /** Adapt only indexed portable components. Never silently drop required components. */
6
+ export function marketplacePlugin(url: string) {
7
+ const source = snapshot.sources.find((candidate) =>
8
+ candidate.entries.some((item) => item.sourceUrl === url),
9
+ );
10
+ const item = source?.entries.find((candidate) => candidate.sourceUrl === url);
11
+ if (!item || !source) return null;
12
+ if (!item.skills || !item.mcpServers)
13
+ throw new HTTPException(422, {
14
+ message: "This external plugin must be indexed before it can be installed.",
15
+ });
16
+ const servers = item.mcpServers
17
+ .filter((server) => pluginMcpUnavailableReason(server) === null)
18
+ .map((server, index) => ({
19
+ id: "marketplace-" + source.provider + "-" + item.name + "-" + index,
20
+ name: server.name,
21
+ url: server.endpoint!,
22
+ cacheToolsList: true,
23
+ }));
24
+ if (!item.skills.length && !servers.length)
25
+ throw new HTTPException(422, {
26
+ message: "This plugin has no skills or remote MCP servers that OpenGeni can install.",
27
+ });
28
+ const manifest = PluginManifest.parse({
29
+ schemaVersion: 1,
30
+ pluginKey: "marketplace/" + source.provider + "/" + item.name,
31
+ name: item.displayName,
32
+ description: item.description.slice(0, 4000),
33
+ version: item.version || source.revision,
34
+ components: [
35
+ ...item.skills.map((skill, index) => ({
36
+ key: "skill-" + index,
37
+ kind: "skill",
38
+ url: skill.sourceUrl,
39
+ })),
40
+ ...servers.map((server, index) => ({
41
+ key: "mcp-" + index,
42
+ kind: "mcp",
43
+ serverId: server.id,
44
+ })),
45
+ ],
46
+ });
47
+ return { manifest, servers, discovery: { ...item, provider: source.provider } };
48
+ }
@@ -961,10 +961,136 @@ export function assertGoogleAuthorizationServer(as: AuthorizationServerMetadata)
961
961
  }
962
962
  }
963
963
 
964
+ /** Inspect without credentials, registration, or running tools. */
965
+ export async function inspectMcpAuthentication(
966
+ resource: string,
967
+ settings: Settings,
968
+ ): Promise<{
969
+ kind: "oauth2" | "none" | "unknown";
970
+ message?: string;
971
+ }> {
972
+ const deadline = new OAuthStartDeadline(12_000);
973
+ try {
974
+ const response = await deadline.run("mcp_challenge", (signal) =>
975
+ fetchOAuth(resource, settings, {
976
+ method: "POST",
977
+ headers: {
978
+ accept: "application/json, text/event-stream",
979
+ "content-type": "application/json",
980
+ },
981
+ body: JSON.stringify({
982
+ jsonrpc: "2.0",
983
+ id: "auth-discovery",
984
+ method: "initialize",
985
+ params: {
986
+ protocolVersion: "2025-03-26",
987
+ capabilities: {},
988
+ clientInfo: { name: "OpenGeni", version: "1.0" },
989
+ },
990
+ }),
991
+ signal,
992
+ }),
993
+ );
994
+ const challenge = parseMcpOAuthChallenge(response.headers.get("www-authenticate"));
995
+ let initialized = false;
996
+ if (response.ok) {
997
+ initialized = await deadline.run("mcp_challenge", async () => {
998
+ const reader = response.body?.getReader();
999
+ if (!reader) return false;
1000
+ const decoder = new TextDecoder();
1001
+ let text = "";
1002
+ let bytes = 0;
1003
+ const sse = response.headers.get("content-type")?.includes("text/event-stream");
1004
+ try {
1005
+ while (true) {
1006
+ const chunk = await reader.read();
1007
+ if (chunk.done) return false;
1008
+ bytes += chunk.value.byteLength;
1009
+ if (bytes > 262144) return false;
1010
+ text += decoder.decode(chunk.value, { stream: true });
1011
+ const messages = sse
1012
+ ? text
1013
+ .split(/\r?\n/)
1014
+ .filter((line) => line.startsWith("data:"))
1015
+ .map((line) => line.slice(5).trim())
1016
+ : [text];
1017
+ for (const message of messages) {
1018
+ try {
1019
+ const value = JSON.parse(message);
1020
+ if (
1021
+ value.id === "auth-discovery" &&
1022
+ typeof value.result?.protocolVersion === "string" &&
1023
+ value.result?.serverInfo &&
1024
+ value.result?.capabilities
1025
+ )
1026
+ return true;
1027
+ } catch {
1028
+ /* Wait for a complete JSON message. */
1029
+ }
1030
+ }
1031
+ }
1032
+ } finally {
1033
+ await reader.cancel().catch(() => undefined);
1034
+ }
1035
+ });
1036
+ } else {
1037
+ await cancelResponseBody(response);
1038
+ }
1039
+ let metadataRead = false;
1040
+ let metadataAbsent = true;
1041
+ try {
1042
+ await resolveMcpOAuthDiscovery({
1043
+ resourceUrl: resource,
1044
+ challenge,
1045
+ fetchMetadata: async ({ url }) => {
1046
+ try {
1047
+ const result = await deadline.run("protected_resource_metadata", (signal) =>
1048
+ fetchOAuthMetadata(url, settings, signal),
1049
+ );
1050
+ metadataRead = true;
1051
+ if (result.status !== "absent") metadataAbsent = false;
1052
+ return result;
1053
+ } catch (error) {
1054
+ metadataAbsent = false;
1055
+ throw error;
1056
+ }
1057
+ },
1058
+ validateEndpoint: (url, label) => oauthEndpointUrl(url, settings, label),
1059
+ canonicalizeResource: canonicalOAuthResource,
1060
+ });
1061
+ return { kind: "oauth2" };
1062
+ } catch {
1063
+ // Public initialization does not exclude optional or tool-level OAuth.
1064
+ // Only explicit absence of metadata permits the unauthenticated path.
1065
+ if (
1066
+ initialized &&
1067
+ !challenge.scheme &&
1068
+ !challenge.resourceMetadata &&
1069
+ metadataRead &&
1070
+ metadataAbsent
1071
+ )
1072
+ return { kind: "none" };
1073
+ return {
1074
+ kind: "unknown",
1075
+ message:
1076
+ "This server's sign-in requirements could not be determined. Check its setup instructions.",
1077
+ };
1078
+ }
1079
+ } catch {
1080
+ return {
1081
+ kind: "unknown",
1082
+ message: "Could not check this server. Retry or consult its setup instructions.",
1083
+ };
1084
+ } finally {
1085
+ deadline.dispose();
1086
+ }
1087
+ }
1088
+
964
1089
  async function discoverMcpOAuth(
965
1090
  resource: string,
966
1091
  settings: Settings,
967
1092
  deadline: OAuthStartDeadline,
1093
+ knownChallenge?: WwwAuthenticateChallenge,
968
1094
  ): Promise<{
969
1095
  challenge: WwwAuthenticateChallenge;
970
1096
  prm: ProtectedResourceMetadata;
@@ -977,9 +1103,11 @@ async function discoverMcpOAuth(
977
1103
  metadataSha256: string;
978
1104
  };
979
1105
  }> {
980
- const challenge = await deadline.run("mcp_challenge", (signal) =>
981
- probeMcpChallenge(resource, settings, signal),
982
- );
1106
+ const challenge =
1107
+ knownChallenge ??
1108
+ (await deadline.run("mcp_challenge", (signal) =>
1109
+ probeMcpChallenge(resource, settings, signal),
1110
+ ));
983
1111
  try {
984
1112
  const discovery = await resolveMcpOAuthDiscovery({
985
1113
  resourceUrl: resource,
@@ -2703,6 +2703,7 @@ export function registerBrowserSessionRoutes(app: Hono, deps: ApiRouteDeps): voi
2703
2703
  operationResourcePolicySupported:
2704
2704
  enrollment.agentCapabilities.operationResourcePolicy === true,
2705
2705
  operationCpuQuotaSupported: enrollment.agentCapabilities.operationCpuQuota === true,
2706
+ transactionalFsWriteSupported: enrollment.agentCapabilities.transactionalFsWrite === true,
2706
2707
  ...(deps.settings.agentOpStreamEnabled === true &&
2707
2708
  enrollment.opStream === true &&
2708
2709
  deps.bus.getOpStreamConnection
@@ -16,9 +16,59 @@ import {
16
16
  officialMcpRegistryUrl,
17
17
  } from "@opengeni/core";
18
18
  import { boundedLimit } from "../http/common";
19
+ import { z } from "zod";
20
+ import pluginSnapshot from "../../../../data/catalog/plugins-snapshot.json";
21
+ const discoverablePlugins = pluginSnapshot.sources
22
+ .flatMap((source) => source.entries.map((entry) => ({ ...entry, provider: source.provider })))
23
+ .sort((a, b) => a.displayName.localeCompare(b.displayName) || a.id.localeCompare(b.id));
24
+ import { inspectMcpAuthentication } from "../integrations/oauth-client";
19
25
 
20
26
  export function registerCapabilityRoutes(app: Hono, deps: ApiRouteDeps): void {
21
27
  const { db, settings } = deps;
28
+ app.get("/v1/workspaces/:workspaceId/capabilities/discovery/plugins", async (c) => {
29
+ await requireAccessGrant(c, deps, c.req.param("workspaceId"), "workspace:read");
30
+ const query = (c.req.query("query") ?? "").trim().toLowerCase();
31
+ const provider = c.req.query("provider");
32
+ const id = c.req.query("id");
33
+ const offset = z.coerce
34
+ .number()
35
+ .int()
36
+ .min(0)
37
+ .max(10000)
38
+ .parse(c.req.query("offset") ?? 0);
39
+ const matches = discoverablePlugins.filter(
40
+ (item) =>
41
+ (!id || item.id === id) &&
42
+ (!provider || item.provider === provider) &&
43
+ query
44
+ .split(/\s+/)
45
+ .every((term) =>
46
+ [
47
+ item.displayName,
48
+ item.name,
49
+ item.description,
50
+ item.category,
51
+ item.author?.name,
52
+ ...item.keywords,
53
+ ...(item.components ?? []),
54
+ ]
55
+ .join(" ")
56
+ .toLowerCase()
57
+ .includes(term),
58
+ ),
59
+ );
60
+ const end = offset + 40;
61
+ return c.json({
62
+ items: matches.slice(offset, end),
63
+ total: matches.length,
64
+ nextOffset: end < matches.length ? end : null,
65
+ });
66
+ });
67
+ app.post("/v1/workspaces/:workspaceId/capabilities/discovery/mcp-auth", async (c) => {
68
+ await requireAccessGrant(c, deps, c.req.param("workspaceId"), "workspace:read");
69
+ const { url } = z.object({ url: z.string().url().max(2048) }).parse(await c.req.json());
70
+ return c.json(await inspectMcpAuthentication(url, settings));
71
+ });
22
72
 
23
73
  app.get("/v1/workspaces/:workspaceId/capabilities", async (c) => {
24
74
  const workspaceId = c.req.param("workspaceId");
@@ -979,6 +979,7 @@ export function registerComputerSessionRoutes(app: Hono, deps: ApiRouteDeps): vo
979
979
  operationResourcePolicySupported:
980
980
  enrollment.agentCapabilities.operationResourcePolicy === true,
981
981
  operationCpuQuotaSupported: enrollment.agentCapabilities.operationCpuQuota === true,
982
+ transactionalFsWriteSupported: enrollment.agentCapabilities.transactionalFsWrite === true,
982
983
  ...(deps.settings.agentOpStreamEnabled === true &&
983
984
  enrollment.opStream === true &&
984
985
  deps.bus.getOpStreamConnection
@@ -1,3 +1,7 @@
1
+ import pluginSnapshot from "../../../../data/catalog/plugins-snapshot.json";
2
+ import { marketplacePlugin } from "../integrations/marketplace-plugin";
3
+ import { mcpEndpointIdentity } from "@opengeni/contracts";
4
+ import { buildCapabilityCatalog, createCatalogItem } from "@opengeni/core";
1
5
  import { createHash } from "node:crypto";
2
6
 
3
7
  import {
@@ -101,11 +105,80 @@ export function registerPluginRoutes(
101
105
  await requireAccessGrant(c, deps, workspaceId, "workspace:read");
102
106
  return c.json(
103
107
  ListInstalledPluginsResponse.parse({
104
- plugins: await listInstalledPluginPackages(deps.db, workspaceId),
108
+ plugins: (await listInstalledPluginPackages(deps.db, workspaceId)).map((plugin) => {
109
+ const source = pluginSnapshot.sources.find((candidate) =>
110
+ plugin.pluginKey.startsWith("marketplace/" + candidate.provider + "/"),
111
+ );
112
+ const entry = source?.entries.find(
113
+ (candidate) =>
114
+ plugin.pluginKey === "marketplace/" + source!.provider + "/" + candidate.name,
115
+ );
116
+ return { ...plugin, logoUrl: plugin.logoUrl ?? entry?.logoUrl ?? null };
117
+ }),
105
118
  }),
106
119
  );
107
120
  });
108
121
 
122
+ app.get("/v1/workspaces/:workspaceId/plugins/details", async (c) => {
123
+ const workspaceId = c.req.param("workspaceId");
124
+ const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
125
+ const plugin = await getInstalledPluginPackage(
126
+ deps.db,
127
+ workspaceId,
128
+ c.req.query("pluginKey") ?? "",
129
+ );
130
+ if (!plugin || plugin.status === "disabled")
131
+ throw new HTTPException(404, { message: "Plugin is not installed" });
132
+ const stored = plugin.manifest as Record<string, any>;
133
+ if (stored.discovery) return c.json(stored.discovery);
134
+ const catalog = await buildCapabilityCatalog({
135
+ db: deps.db,
136
+ workspaceId,
137
+ settings: deps.settings,
138
+ subjectId: grant.subjectId,
139
+ });
140
+ const members = Array.isArray(stored.components) ? stored.components : [];
141
+ return c.json({
142
+ id: String(stored.pluginKey ?? "installed")
143
+ .replace("marketplace/", "")
144
+ .replace("/", ":"),
145
+ name: stored.name,
146
+ displayName: stored.name,
147
+ description: stored.description ?? "",
148
+ longDescription: stored.description ?? "",
149
+ provider: "custom",
150
+ category: stored.category ?? null,
151
+ logoUrl: null,
152
+ darkLogoUrl: null,
153
+ sourceUrl: stored.sourceUrl ?? null,
154
+ author: null,
155
+ version: stored.version,
156
+ skills: members
157
+ .filter((member) => member.kind === "skill")
158
+ .map((member) => ({
159
+ name: decodeURIComponent(member.url.split("/").filter(Boolean).at(-2) ?? member.key),
160
+ sourceUrl: member.url,
161
+ })),
162
+ mcpServers: members
163
+ .filter((member) => member.kind === "mcp")
164
+ .map((member) => {
165
+ const bom = Array.isArray(stored.bom)
166
+ ? stored.bom.find((entry: any) => entry.key === member.key)
167
+ : null;
168
+ const item = catalog.items.find((candidate) => candidate.id === bom?.capabilityId);
169
+ return {
170
+ name: item?.name ?? member.serverId,
171
+ transport: item?.transport ?? "http",
172
+ endpoint: item?.endpointUrl ?? null,
173
+ };
174
+ }),
175
+ components: [
176
+ ...new Set(members.map((member) => (member.kind === "skill" ? "skills" : member.kind))),
177
+ ],
178
+ installation: "installed",
179
+ });
180
+ });
181
+
109
182
  app.post("/v1/workspaces/:workspaceId/plugins/preview", async (c) => {
110
183
  const workspaceId = c.req.param("workspaceId");
111
184
  const grant = await requireAccessGrant(c, deps, workspaceId, "workspace:read");
@@ -329,15 +402,20 @@ async function resolvePluginPackage(input: {
329
402
  >;
330
403
  }): Promise<ResolvedPluginPackage> {
331
404
  let manifest;
405
+ const marketplace = marketplacePlugin(input.url);
332
406
  try {
333
- const bytes = await fetchIntegrationSourceDocument(
334
- input.transport,
335
- input.url,
336
- MAX_PLUGIN_MANIFEST_BYTES,
337
- );
338
- manifest = PluginManifest.parse(
339
- JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
340
- );
407
+ if (marketplace) {
408
+ manifest = marketplace.manifest;
409
+ } else {
410
+ const bytes = await fetchIntegrationSourceDocument(
411
+ input.transport,
412
+ input.url,
413
+ MAX_PLUGIN_MANIFEST_BYTES,
414
+ );
415
+ manifest = PluginManifest.parse(
416
+ JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)),
417
+ );
418
+ }
341
419
  } catch (error) {
342
420
  if (error instanceof HTTPException) throw error;
343
421
  throw new HTTPException(422, {
@@ -346,6 +424,14 @@ async function resolvePluginPackage(input: {
346
424
  }
347
425
  const manifestDigest = sha256(stableJson(manifest));
348
426
  const components: ResolvedPluginComponent[] = [];
427
+ const catalog = marketplace
428
+ ? await buildCapabilityCatalog({
429
+ db: input.deps.db,
430
+ workspaceId: input.workspaceId,
431
+ settings: input.deps.settings,
432
+ subjectId: input.subjectId,
433
+ })
434
+ : null;
349
435
  for (const component of manifest.components) {
350
436
  if (component.kind === "skill") {
351
437
  const resolved = await resolveSkillImport(component.url, input.github);
@@ -511,7 +597,62 @@ async function resolvePluginPackage(input: {
511
597
  });
512
598
  continue;
513
599
  }
514
- const server = configuredMcpServer(input.deps.settings.mcpServers, component.serverId);
600
+ const server = configuredMcpServer(
601
+ [...(marketplace?.servers ?? []), ...input.deps.settings.mcpServers],
602
+ component.serverId,
603
+ );
604
+ if (marketplace) {
605
+ const endpoint = mcpEndpointIdentity(server.url)!;
606
+ const matches = (catalog?.items ?? []).filter(
607
+ (item) => item.kind === "mcp" && mcpEndpointIdentity(item.endpointUrl) === endpoint,
608
+ );
609
+ // Prefer the original catalogue connection over legacy plugin-created duplicates.
610
+ matches.sort(
611
+ (a, b) =>
612
+ Number(a.id.startsWith("mcp:configured:marketplace-")) -
613
+ Number(b.id.startsWith("mcp:configured:marketplace-")) ||
614
+ Number(b.enabled) - Number(a.enabled) ||
615
+ a.id.localeCompare(b.id),
616
+ );
617
+ const existing = matches[0];
618
+ const capabilityId = existing?.id ?? "mcp:endpoint:" + sha256(endpoint).slice(0, 24);
619
+ const digest = sha256(stableJson({ endpoint, capabilityId }));
620
+ components.push({
621
+ preview: {
622
+ key: component.key,
623
+ kind: "mcp",
624
+ name: existing?.name ?? server.name ?? server.id,
625
+ capabilityId,
626
+ digest,
627
+ connectionRequired: !existing?.enabled,
628
+ connectionId: null,
629
+ instanceKey: null,
630
+ displayName: null,
631
+ facts: { endpointUrl: endpoint, connectionManagedSeparately: true },
632
+ },
633
+ install: async () => {
634
+ if (!existing)
635
+ await createCatalogItem({
636
+ db: input.deps.db,
637
+ accountId: input.accountId,
638
+ workspaceId: input.workspaceId,
639
+ payload: {
640
+ id: capabilityId,
641
+ kind: "mcp",
642
+ source: "manual",
643
+ name: server.name ?? server.id,
644
+ endpointUrl: endpoint,
645
+ category: "integrations",
646
+ tags: ["mcp"],
647
+ metadata: { authDiscovery: "unknown" },
648
+ },
649
+ });
650
+ // Connections are independently owned: installing/removing a plugin never signs in or disconnects an account.
651
+ return { facetInstallationIds: [] };
652
+ },
653
+ });
654
+ continue;
655
+ }
515
656
  const digest = sha256(stableJson(safeMcpFacts(server)));
516
657
  components.push({
517
658
  preview: {
@@ -584,7 +725,13 @@ async function resolvePluginPackage(input: {
584
725
  components: components.map((component) => component.preview),
585
726
  diff,
586
727
  }),
587
- storedManifest: { ...manifest, sourceUrl: input.url, manifestDigest, bom },
728
+ storedManifest: {
729
+ ...manifest,
730
+ ...(marketplace ? { discovery: marketplace.discovery } : {}),
731
+ sourceUrl: input.url,
732
+ manifestDigest,
733
+ bom,
734
+ },
588
735
  components,
589
736
  };
590
737
  }
@@ -11,6 +11,8 @@ import {
11
11
  } from "@opengeni/contracts";
12
12
  import {
13
13
  portableSkillCapabilityId,
14
+ createPublicSkillSearchClient,
15
+ PublicSkillSearchError,
14
16
  portableSkillPluginKey,
15
17
  requireAccessGrant,
16
18
  requireAccessGrantAuthorization,
@@ -48,6 +50,22 @@ export function registerSkillRoutes(
48
50
  ): void {
49
51
  registerSkillContentRoutes(app, deps);
50
52
  const github = overrides.github ?? createGitHubSkillSourceClient(deps.settings);
53
+ const discovery = createPublicSkillSearchClient(deps.settings);
54
+ app.get("/v1/workspaces/:workspaceId/skills/search", async (c) => {
55
+ await requireAccessGrant(c, deps, c.req.param("workspaceId"), "workspace:read");
56
+ try {
57
+ return c.json(await discovery.search({ query: c.req.query("q") ?? "", limit: 20 }));
58
+ } catch (error) {
59
+ if (!(error instanceof PublicSkillSearchError)) throw error;
60
+ if (error.retryAfterSeconds) c.header("Retry-After", String(error.retryAfterSeconds));
61
+ throw new HTTPException(
62
+ error.code === "invalid_query" ? 422 : error.code === "rate_limited" ? 429 : 503,
63
+ {
64
+ message: "Skill search is unavailable. Please try again.",
65
+ },
66
+ );
67
+ }
68
+ });
51
69
 
52
70
  app.get("/v1/workspaces/:workspaceId/skills", async (c) => {
53
71
  const workspaceId = c.req.param("workspaceId");
@@ -165,8 +183,7 @@ export function registerSkillRoutes(
165
183
  resolved.preview.contentSha256 !== payload.expectedContentSha256
166
184
  ) {
167
185
  throw new HTTPException(409, {
168
- message:
169
- "The Skill source changed after preview. Review the new commit and contents before installing.",
186
+ message: "The skill changed after preview. Review its contents again before installing.",
170
187
  });
171
188
  }
172
189
  const fileSummaryByPath = new Map(
@@ -727,6 +727,7 @@ async function withChannelAOperation<T>(
727
727
  operationResourcePolicySupported:
728
728
  enrollment.agentCapabilities.operationResourcePolicy === true,
729
729
  operationCpuQuotaSupported: enrollment.agentCapabilities.operationCpuQuota === true,
730
+ transactionalFsWriteSupported: enrollment.agentCapabilities.transactionalFsWrite === true,
730
731
  ...(settings.agentOpStreamEnabled === true &&
731
732
  enrollment?.opStream === true &&
732
733
  bus.getOpStreamConnection
@@ -112,6 +112,7 @@ function runtimeFor(settings: Settings, enrollment: EnrollmentRecord): MachineVi
112
112
  browserBridge: capability("browserBridge"),
113
113
  operationResourcePolicy: capability("operationResourcePolicy"),
114
114
  operationCpuQuota: capability("operationCpuQuota"),
115
+ transactionalFsWrite: capability("transactionalFsWrite"),
115
116
  },
116
117
  update,
117
118
  };