@opengeni/api-router 0.12.1 → 0.12.5

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/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  createApp
3
- } from "./chunk-S2N4252E.js";
3
+ } from "./chunk-3BHOMOSD.js";
4
4
 
5
5
  // src/index.ts
6
6
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@opengeni/api-router",
3
- "version": "0.12.1",
3
+ "version": "0.12.5",
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": {
@@ -44,17 +44,17 @@
44
44
  "@modelcontextprotocol/sdk": "^1.29.0",
45
45
  "@opengeni/agent-proto": "^0.3.0",
46
46
  "@opengeni/codex": "^0.2.7",
47
- "@opengeni/config": "^0.7.8",
48
- "@opengeni/contracts": "^0.20.1",
49
- "@opengeni/core": "^0.12.1",
50
- "@opengeni/db": "^0.13.1",
51
- "@opengeni/documents": "^0.2.38",
52
- "@opengeni/events": "^0.3.29",
53
- "@opengeni/github": "^0.3.20",
47
+ "@opengeni/config": "^0.7.10",
48
+ "@opengeni/contracts": "^0.21.0",
49
+ "@opengeni/core": "^0.12.5",
50
+ "@opengeni/db": "^0.13.4",
51
+ "@opengeni/documents": "^0.2.41",
52
+ "@opengeni/events": "^0.3.32",
53
+ "@opengeni/github": "^0.3.22",
54
54
  "@opengeni/network": "^0.1.1",
55
55
  "@opengeni/observability": "^0.3.0",
56
- "@opengeni/runtime": "^0.13.12",
57
- "@opengeni/storage": "^0.2.32",
56
+ "@opengeni/runtime": "^0.14.0",
57
+ "@opengeni/storage": "^0.2.34",
58
58
  "@temporalio/client": "^1.17.0",
59
59
  "better-auth": "^1.6.14",
60
60
  "hono": "^4.12.18",
package/src/app.ts CHANGED
@@ -3,6 +3,7 @@ import {
3
3
  configuredAllowedModels,
4
4
  configuredAllowedReasoningEfforts,
5
5
  configuredModels,
6
+ withCodexCatalogProvider,
6
7
  } from "@opengeni/config";
7
8
  import {
8
9
  ClientConfig,
@@ -322,18 +323,21 @@ export function createApp(deps: AppDependencies): Hono {
322
323
 
323
324
  app.get("/v1/config/client", (c) => {
324
325
  c.header("cache-control", "no-store");
326
+ const catalogSettings = deps.settings.codexSubscriptionEnabled
327
+ ? withCodexCatalogProvider(deps.settings)
328
+ : deps.settings;
325
329
  return c.json(
326
330
  ClientConfig.parse({
327
331
  deploymentRevision: deps.settings.deploymentRevision,
328
332
  apiContractRevision: OPENGENI_API_CONTRACT_REVISION,
329
333
  ...(deps.settings.serverVersion ? { serverVersion: deps.settings.serverVersion } : {}),
330
- defaultModel: canonicalizeConfiguredModelId(deps.settings, deps.settings.openaiModel),
331
- allowedModels: configuredAllowedModels(deps.settings),
334
+ defaultModel: canonicalizeConfiguredModelId(catalogSettings, catalogSettings.openaiModel),
335
+ allowedModels: configuredAllowedModels(catalogSettings),
332
336
  // Provider-grouped model list for the picker. configuredModels() carries the
333
337
  // union of the built-in allow-list and every registry provider's models, in
334
338
  // selection order (default model first); project each to the client-safe
335
339
  // ClientModel shape (ConfiguredModel.providerId → ClientModel.provider).
336
- models: configuredModels(deps.settings).map(projectClientModel),
340
+ models: configuredModels(catalogSettings).map(projectClientModel),
337
341
  defaultReasoningEffort: deps.settings.openaiReasoningEffort,
338
342
  allowedReasoningEfforts: configuredAllowedReasoningEfforts(deps.settings),
339
343
  mcpServers: deps.settings.mcpServers.map((server) => ({
@@ -957,8 +961,8 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
957
961
  label: "/v1/workspaces/:workspaceId/connections/oauth/start",
958
962
  },
959
963
  {
960
- pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot$/,
961
- label: "/v1/workspaces/:workspaceId/connections/slack-bot",
964
+ pattern: /^\/v1\/workspaces\/[^/]+\/connections\/slack-bot\/install$/,
965
+ label: "/v1/workspaces/:workspaceId/connections/slack-bot/install",
962
966
  },
963
967
  {
964
968
  pattern: /^\/v1\/workspaces\/[^/]+\/connections\/[^/]+$/,
@@ -973,6 +977,10 @@ const routeLabelPatterns: Array<{ pattern: RegExp; label: string }> = [
973
977
  pattern: /^\/v1\/integrations\/oauth\/client-metadata\.json$/,
974
978
  label: "/v1/integrations/oauth/client-metadata.json",
975
979
  },
980
+ {
981
+ pattern: /^\/v1\/integrations\/slack\/callback$/,
982
+ label: "/v1/integrations/slack/callback",
983
+ },
976
984
  {
977
985
  pattern: /^\/v1\/enrollments\/device\/start$/,
978
986
  label: "/v1/enrollments/device/start",
package/src/http/auth.ts CHANGED
@@ -49,7 +49,8 @@ function isAuthExempt(c: Context, settings: Settings): boolean {
49
49
  }
50
50
  if (
51
51
  path === "/v1/integrations/oauth/callback" ||
52
- path === "/v1/integrations/oauth/client-metadata.json"
52
+ path === "/v1/integrations/oauth/client-metadata.json" ||
53
+ path === "/v1/integrations/slack/callback"
53
54
  ) {
54
55
  return true;
55
56
  }
@@ -3,7 +3,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/
3
3
  import type { Transport } from "@modelcontextprotocol/sdk/shared/transport.js";
4
4
  import { parseIntegrationsOauthClientsJson, type Settings } from "@opengeni/config";
5
5
  import { OAuthStartResponse, type OAuthStartRequest } from "@opengeni/contracts";
6
- import { requireEnvironmentEncryption } from "@opengeni/core";
6
+ import { hasPermission, requireEnvironmentEncryption } from "@opengeni/core";
7
7
  import type { Observability } from "@opengeni/observability";
8
8
  import {
9
9
  consumeIntegrationOAuthStateNonce,
@@ -11,6 +11,7 @@ import {
11
11
  decryptEnvironmentValue,
12
12
  encryptEnvironmentValue,
13
13
  getConnectionMetadata,
14
+ getWorkspaceGrant,
14
15
  listConnectionsMetadata,
15
16
  loadIntegrationOAuthClient,
16
17
  normalizeBearerScheme,
@@ -33,6 +34,8 @@ import { HTTPException } from "hono/http-exception";
33
34
  import { canonicalProviderDomain } from "./provider-domain";
34
35
 
35
36
  export const oauthStateTtlMs = 10 * 60 * 1000;
37
+ export const OFFICIAL_SLACK_MCP_URL = "https://mcp.slack.com/mcp";
38
+ const SLACK_OAUTH_ORIGIN = "https://slack.com";
36
39
  export { OAUTH_MAX_RESPONSE_BYTES } from "@opengeni/network";
37
40
 
38
41
  type OAuthClientDeps = {
@@ -139,9 +142,12 @@ export async function startMcpOAuth(
139
142
  ): Promise<OAuthStartResponse> {
140
143
  const { db, settings } = deps;
141
144
  const mcpUrl = canonicalMcpResource(context.payload.mcpUrl ?? context.payload.resource);
142
- const providerDomain = canonicalProviderDomain(
143
- context.payload.providerDomain ?? new URL(mcpUrl).hostname,
144
- );
145
+ const officialSlackResource = mcpUrl === OFFICIAL_SLACK_MCP_URL;
146
+ const providerDomain = officialSlackResource
147
+ ? "slack.com"
148
+ : canonicalProviderDomain(context.payload.providerDomain ?? new URL(mcpUrl).hostname);
149
+ const personalSlack = officialSlackResource || providerDomain === "slack.com";
150
+ assertPersonalSlackOAuthStart(settings, context.payload, mcpUrl, personalSlack);
145
151
  const returnPath = safeReturnPath(context.payload.returnPath ?? "/integrations");
146
152
  const baseUrl = integrationBaseUrl(settings.publicBaseUrl, context.requestUrl);
147
153
  const redirectUri = `${baseUrl}/v1/integrations/oauth/callback`;
@@ -157,6 +163,9 @@ export async function startMcpOAuth(
157
163
  }
158
164
 
159
165
  const discovery = await discoverMcpOAuth(mcpUrl, settings);
166
+ if (personalSlack && !isLocalTestEnvironment(settings.environment)) {
167
+ assertSlackAuthorizationServer(discovery.as);
168
+ }
160
169
  const resource = discovery.prm.resource ? canonicalOAuthResource(discovery.prm.resource) : mcpUrl;
161
170
  const verifier = randomPkceVerifier();
162
171
  const authorizeScopes = chooseAuthorizeScopes(
@@ -230,6 +239,7 @@ export async function completeMcpOAuthCallback(
230
239
  }
231
240
  try {
232
241
  state = readOAuthState(input.state, settings);
242
+ await requireOAuthCallbackGrant(db, state);
233
243
  if (!input.code) {
234
244
  return {
235
245
  redirectTo: callbackReturnPath(state.returnPath, "error", { reason: "missing_code" }),
@@ -287,6 +297,7 @@ export async function completeMcpOAuthCallback(
287
297
  ...(verification.tools ? { mcpTools: verification.tools } : {}),
288
298
  };
289
299
  const credentialEncrypted = encryptEnvironmentValue(key, JSON.stringify(credential));
300
+ await requireOAuthCallbackGrant(db, state);
290
301
  const connection = await runCallbackStage("persist", "persist_failed", () =>
291
302
  state.connectionId
292
303
  ? updateConnection(db, {
@@ -294,6 +305,7 @@ export async function completeMcpOAuthCallback(
294
305
  connectionId: state.connectionId,
295
306
  visibleToSubjectId: state.subjectId,
296
307
  expectedVersion: state.connectionVersion,
308
+ subjectId: state.subjectId,
297
309
  providerDomain: state.providerDomain,
298
310
  kind: "oauth2",
299
311
  status: "active",
@@ -306,7 +318,7 @@ export async function completeMcpOAuthCallback(
306
318
  : createConnection(db, {
307
319
  accountId: state.accountId,
308
320
  workspaceId: state.workspaceId,
309
- subjectId: null,
321
+ subjectId: state.subjectId,
310
322
  providerDomain: state.providerDomain,
311
323
  kind: "oauth2",
312
324
  credentialEncrypted,
@@ -356,6 +368,56 @@ export function requireIntegrationsStateSecret(settings: Settings): string {
356
368
  return secret;
357
369
  }
358
370
 
371
+ async function requireOAuthCallbackGrant(db: Database, state: OAuthStatePayload): Promise<void> {
372
+ const grant = await getWorkspaceGrant(db, state.subjectId, state.workspaceId);
373
+ if (
374
+ !grant ||
375
+ grant.accountId !== state.accountId ||
376
+ !hasPermission(grant.permissions, "connections:write")
377
+ ) {
378
+ throw new HTTPException(403, {
379
+ message: "OAuth subject no longer has permission to write this workspace connection",
380
+ });
381
+ }
382
+ }
383
+
384
+ function assertPersonalSlackOAuthStart(
385
+ settings: Settings,
386
+ payload: OAuthStartRequest,
387
+ mcpUrl: string,
388
+ personalSlack: boolean,
389
+ ): void {
390
+ if (!personalSlack) return;
391
+ if (payload.oauthClient) {
392
+ throw new HTTPException(422, {
393
+ message: "Slack OAuth client credentials are deployment-managed",
394
+ });
395
+ }
396
+ if (payload.providerDomain && canonicalProviderDomain(payload.providerDomain) !== "slack.com") {
397
+ throw new HTTPException(422, { message: "Slack provider identity does not match slack.com" });
398
+ }
399
+ if (!isLocalTestEnvironment(settings.environment) && mcpUrl !== OFFICIAL_SLACK_MCP_URL) {
400
+ throw new HTTPException(422, {
401
+ message: `personal Slack OAuth must use ${OFFICIAL_SLACK_MCP_URL}`,
402
+ });
403
+ }
404
+ if (!settings.slackClientId?.trim() || !settings.slackClientSecret?.trim()) {
405
+ throw new HTTPException(503, {
406
+ message:
407
+ "personal Slack OAuth requires OPENGENI_SLACK_CLIENT_ID and OPENGENI_SLACK_CLIENT_SECRET",
408
+ });
409
+ }
410
+ }
411
+
412
+ export function assertSlackAuthorizationServer(as: AuthorizationServerMetadata): void {
413
+ const urls = [as.issuer, as.authorizationServer, as.authorizationEndpoint, as.tokenEndpoint];
414
+ if (urls.some((value) => new URL(value).origin !== SLACK_OAUTH_ORIGIN)) {
415
+ throw new HTTPException(422, {
416
+ message: "Slack MCP authorization metadata did not remain bound to slack.com",
417
+ });
418
+ }
419
+ }
420
+
359
421
  async function discoverMcpOAuth(
360
422
  resource: string,
361
423
  settings: Settings,
@@ -656,6 +718,18 @@ function operatorClientEntryFor(
656
718
  settings: Settings,
657
719
  candidates: string[],
658
720
  ): ReturnType<typeof parseIntegrationsOauthClientsJson>[string] | null {
721
+ const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
722
+ if (
723
+ normalizedCandidates.has(SLACK_OAUTH_ORIGIN) &&
724
+ settings.slackClientId?.trim() &&
725
+ settings.slackClientSecret?.trim()
726
+ ) {
727
+ return {
728
+ clientId: settings.slackClientId.trim(),
729
+ clientSecret: settings.slackClientSecret.trim(),
730
+ tokenEndpointAuthMethod: "client_secret_post",
731
+ };
732
+ }
659
733
  const configured = parseIntegrationsOauthClientsJson(settings.integrationsOauthClientsJson);
660
734
  const exactKeys = uniqueStrings(
661
735
  candidates.flatMap((candidate) => [candidate, normalizedIssuerKey(candidate)]),
@@ -666,7 +740,6 @@ function operatorClientEntryFor(
666
740
  return entry;
667
741
  }
668
742
  }
669
- const normalizedCandidates = new Set(candidates.map(normalizedIssuerKey));
670
743
  for (const [key, entry] of Object.entries(configured)) {
671
744
  if (normalizedCandidates.has(normalizedIssuerKey(key))) {
672
745
  return entry;
@@ -743,13 +816,23 @@ async function existingOAuthConnectionForStart(
743
816
  },
744
817
  ) {
745
818
  if (input.connectionId) {
746
- return await getConnectionMetadata(db, input.workspaceId, input.connectionId, input.subjectId);
819
+ const connection = await getConnectionMetadata(
820
+ db,
821
+ input.workspaceId,
822
+ input.connectionId,
823
+ input.subjectId,
824
+ );
825
+ return connection?.subjectId === input.subjectId &&
826
+ connection.kind === "oauth2" &&
827
+ connection.providerDomain === input.providerDomain
828
+ ? connection
829
+ : null;
747
830
  }
748
831
  const visible = await listConnectionsMetadata(db, input.workspaceId, input.subjectId);
749
832
  return (
750
833
  visible.find(
751
834
  (connection) =>
752
- connection.subjectId === null &&
835
+ connection.subjectId === input.subjectId &&
753
836
  connection.kind === "oauth2" &&
754
837
  connection.status === "active" &&
755
838
  connection.providerDomain === input.providerDomain,
@@ -836,6 +919,9 @@ function readOAuthState(state: string, settings: Settings): OAuthStatePayload {
836
919
  };
837
920
  const connectionId = stringValue(payload.connectionId);
838
921
  const connectionVersion = numberValue(payload.connectionVersion);
922
+ if (Boolean(connectionId) !== Boolean(connectionVersion)) {
923
+ throw new HTTPException(400, { message: "invalid OAuth reconnect state" });
924
+ }
839
925
  return {
840
926
  ...parsed,
841
927
  ...(connectionId ? { connectionId } : {}),
@@ -22,6 +22,7 @@ import {
22
22
  getSession,
23
23
  recordAuditEvent,
24
24
  releaseSlackBotPostOperationClaim,
25
+ setConnectionStatus,
25
26
  type Database,
26
27
  } from "@opengeni/db";
27
28
  import { readResponseJsonBounded, type FetchLike } from "@opengeni/network";
@@ -43,6 +44,55 @@ export type VerifiedOpenGeniSlackBot = {
43
44
  metadata: OpenGeniSlackBotConnectionMetadata;
44
45
  };
45
46
 
47
+ export async function exchangeOpenGeniSlackAuthorizationCode(
48
+ input: {
49
+ code: string;
50
+ clientId: string;
51
+ clientSecret: string;
52
+ redirectUri: string;
53
+ },
54
+ fetchImpl: FetchLike = fetch,
55
+ ): Promise<string> {
56
+ const body = new URLSearchParams({
57
+ code: input.code,
58
+ client_id: input.clientId,
59
+ client_secret: input.clientSecret,
60
+ redirect_uri: input.redirectUri,
61
+ });
62
+ let response: Response;
63
+ try {
64
+ response = await fetchImpl(`${SLACK_API_BASE}oauth.v2.access`, {
65
+ method: "POST",
66
+ headers: {
67
+ accept: "application/json",
68
+ "content-type": "application/x-www-form-urlencoded",
69
+ },
70
+ body: body.toString(),
71
+ redirect: "error",
72
+ signal: AbortSignal.timeout(SLACK_TIMEOUT_MS),
73
+ });
74
+ } catch {
75
+ throw new HTTPException(502, { message: "Slack installation token exchange failed" });
76
+ }
77
+ if (!response.ok) {
78
+ throw new HTTPException(502, { message: "Slack installation token exchange failed" });
79
+ }
80
+ const payload = await readResponseJsonBounded<unknown>(
81
+ response,
82
+ SLACK_RESPONSE_MAX_BYTES,
83
+ "Slack OAuth response",
84
+ );
85
+ const record = slackRecord(payload);
86
+ if (!record || record.ok !== true) {
87
+ throw new SlackBotProviderError(slackString(record?.error) || "oauth_exchange_failed");
88
+ }
89
+ const accessToken = slackString(record.access_token);
90
+ if (!accessToken?.startsWith("xoxb-")) {
91
+ throw new HTTPException(502, { message: "Slack installation did not return a bot token" });
92
+ }
93
+ return accessToken;
94
+ }
95
+
46
96
  export type SlackBotReceipt = {
47
97
  credentialRole: typeof OPENGENI_SLACK_BOT_CREDENTIAL_ROLE;
48
98
  credentialLabel: typeof OPENGENI_SLACK_BOT_CREDENTIAL_LABEL;
@@ -70,6 +120,30 @@ export class SlackBotProviderError extends Error {
70
120
  }
71
121
  }
72
122
 
123
+ const SLACK_CREDENTIAL_REJECTION_CODES = new Set([
124
+ "account_inactive",
125
+ "invalid_auth",
126
+ "not_authed",
127
+ "token_expired",
128
+ "token_revoked",
129
+ ]);
130
+
131
+ function slackCredentialRejected(error: unknown): error is SlackBotProviderError {
132
+ return error instanceof SlackBotProviderError && SLACK_CREDENTIAL_REJECTION_CODES.has(error.code);
133
+ }
134
+
135
+ export type SlackBotCredentialVerificationFailureReason = "scope_mismatch" | "identity_mismatch";
136
+
137
+ export class SlackBotCredentialVerificationError extends HTTPException {
138
+ constructor(
139
+ readonly failureReason: SlackBotCredentialVerificationFailureReason,
140
+ message: string,
141
+ ) {
142
+ super(422, { message });
143
+ this.name = "SlackBotCredentialVerificationError";
144
+ }
145
+ }
146
+
73
147
  /**
74
148
  * Validates a write-only xoxb credential before it can enter encrypted storage.
75
149
  * Slack does not expose the app's display_information name to this scope set;
@@ -95,14 +169,18 @@ export async function verifyOpenGeniSlackBotCredential(
95
169
  });
96
170
  const user = slackRecord(userResponse.payload.user);
97
171
  if (!user || user.is_bot !== true || user.deleted === true) {
98
- throw new HTTPException(422, { message: "Slack credential must identify an active bot user" });
172
+ throw new SlackBotCredentialVerificationError(
173
+ "identity_mismatch",
174
+ "Slack credential must identify an active bot user",
175
+ );
99
176
  }
100
177
  const profile = slackRecord(user.profile);
101
178
  const displayName = slackString(profile?.display_name) || slackString(profile?.real_name);
102
179
  if (displayName !== "OpenGeni") {
103
- throw new HTTPException(422, {
104
- message: 'Slack bot display name must be exactly "OpenGeni"',
105
- });
180
+ throw new SlackBotCredentialVerificationError(
181
+ "identity_mismatch",
182
+ 'Slack bot display name must be exactly "OpenGeni"',
183
+ );
106
184
  }
107
185
 
108
186
  return {
@@ -355,7 +433,18 @@ export class OpenGeniSlackBotClient {
355
433
  method: string,
356
434
  params: Record<string, string>,
357
435
  ): Promise<SlackPayload> {
358
- return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
436
+ try {
437
+ return (await slackApiFetchWithHeaders(this.fetchImpl, method, headers, params)).payload;
438
+ } catch (error) {
439
+ if (slackCredentialRejected(error)) {
440
+ await setConnectionStatus(this.db, this.context.workspaceId, "needs_reauth", error.code, {
441
+ id: this.connection.id,
442
+ version: this.connection.version,
443
+ subjectId: null,
444
+ }).catch(() => false);
445
+ }
446
+ throw error;
447
+ }
359
448
  }
360
449
 
361
450
  private async withAudit<T extends Record<string, unknown>>(
@@ -561,15 +650,19 @@ function assertExactOpenGeniSlackBotScopes(grantedScopes: string[]): void {
561
650
  ...(forbidden.length ? [`forbidden: ${forbidden.join(", ")}`] : []),
562
651
  ...(unsupported.length ? [`unsupported: ${unsupported.join(", ")}`] : []),
563
652
  ];
564
- throw new HTTPException(422, {
565
- message: `Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`,
566
- });
653
+ throw new SlackBotCredentialVerificationError(
654
+ "scope_mismatch",
655
+ `Slack bot scopes must exactly match the OpenGeni manifest (${facts.join("; ")})`,
656
+ );
567
657
  }
568
658
  }
569
659
 
570
660
  function parseGrantedScopes(header: string | null): string[] {
571
661
  if (!header) {
572
- throw new HTTPException(422, { message: "Slack did not report granted bot scopes" });
662
+ throw new SlackBotCredentialVerificationError(
663
+ "scope_mismatch",
664
+ "Slack did not report granted bot scopes",
665
+ );
573
666
  }
574
667
  return [
575
668
  ...new Set(
package/src/mcp/server.ts CHANGED
@@ -776,6 +776,26 @@ function registerToolspaceProxyTools(server: McpServer, surface: ToolspaceMcpSur
776
776
  if (!surface) {
777
777
  return;
778
778
  }
779
+ // McpServer installs its tools/list handler lazily on the first registered
780
+ // tool. A legitimate empty Toolspace surface (no selected proxyable servers,
781
+ // no active turn, or all optional upstreams unavailable) must therefore seed
782
+ // and disable one invisible tool; otherwise `ogtool list` receives JSON-RPC
783
+ // "Method not found" instead of the valid `{ tools: [] }` response.
784
+ if (surface.tools.length === 0) {
785
+ server
786
+ .registerTool(
787
+ "__opengeni_empty_toolspace_surface__",
788
+ {
789
+ description: "Internal disabled placeholder for an empty Toolspace surface.",
790
+ inputSchema: z4.object({}),
791
+ },
792
+ async () => ({
793
+ content: [{ type: "text" as const, text: '{"unavailable":true}' }],
794
+ }),
795
+ )
796
+ .disable();
797
+ return;
798
+ }
779
799
  for (const tool of surface.tools) {
780
800
  server.registerTool(
781
801
  tool.name,
@@ -367,7 +367,13 @@ async function resolveToolListing(input: {
367
367
  sessionId,
368
368
  rootSessionId,
369
369
  turn: activeTurn,
370
- }).catch(() => null);
370
+ }).catch((error) => {
371
+ deps.observability?.warn("toolspace upstream connection failed", {
372
+ serverId,
373
+ ...toolspaceErrorAttributes(error),
374
+ });
375
+ return null;
376
+ });
371
377
  if (!connection) {
372
378
  aggregateBudget.replace(serverId, []);
373
379
  return;
@@ -375,7 +381,13 @@ async function resolveToolListing(input: {
375
381
  try {
376
382
  const listed = await connection.client
377
383
  .listTools(undefined, toolspaceRequestOptions(config))
378
- .catch(() => ({ tools: [] }));
384
+ .catch((error) => {
385
+ deps.observability?.warn("toolspace upstream tool list failed", {
386
+ serverId,
387
+ ...toolspaceErrorAttributes(error),
388
+ });
389
+ return { tools: [] };
390
+ });
379
391
  let boundedTools: readonly McpTool[];
380
392
  try {
381
393
  boundedTools = assertMcpToolListWithinBounds(listed.tools as McpTool[]) as McpTool[];
@@ -494,7 +506,18 @@ async function connectToolspaceServer(input: {
494
506
  rootSessionId: string;
495
507
  turn: SessionTurn;
496
508
  }): Promise<ConnectedToolspaceServer> {
497
- const guardedFetch = guardedMcpFetch(input.deps.settings, undiciFetch);
509
+ // npm Undici's dispatcher transport is not reliable under Bun. The worker's
510
+ // model-visible MCP path already uses Bun's native fetch while retaining the
511
+ // same pre-request destination-policy check; Toolspace must do the same or an
512
+ // embedded Bun API can expose a server to the model while silently dropping
513
+ // that exact server from `ogtool list`.
514
+ const useBunNativeFetch = !!process.versions.bun;
515
+ const mcpFetchImpl: FetchLike = useBunNativeFetch
516
+ ? globalThis.fetch.bind(globalThis)
517
+ : undiciFetch;
518
+ const guardedFetch = guardedMcpFetch(input.deps.settings, mcpFetchImpl, {
519
+ ...(useBunNativeFetch ? { pinResolvedDestination: false } : {}),
520
+ });
498
521
  const baseFetch: FetchLike = input.config.connectionRef
499
522
  ? connectionBrokerFetch(guardedFetch, input)
500
523
  : guardedFetch;
@@ -730,6 +753,24 @@ function toolspaceAuditSummary(value: unknown): {
730
753
  };
731
754
  }
732
755
 
756
+ function toolspaceErrorAttributes(error: unknown): {
757
+ errorClass: string;
758
+ errorCode?: string;
759
+ errorMessage?: string;
760
+ } {
761
+ if (!(error instanceof Error)) {
762
+ return { errorClass: typeof error };
763
+ }
764
+ const code = (error as Error & { code?: unknown }).code;
765
+ return {
766
+ errorClass: error.name,
767
+ ...(typeof code === "string" ? { errorCode: code } : {}),
768
+ ...(error.message
769
+ ? { errorMessage: error.message.replaceAll(/[\r\n]+/gu, " ").slice(0, 512) }
770
+ : {}),
771
+ };
772
+ }
773
+
733
774
  type ToolspaceReservation =
734
775
  | { status: "ok"; turn: SessionTurn }
735
776
  | { status: "no_active_turn" }
@@ -844,8 +885,17 @@ export function connectionBrokerFetch(
844
885
  if (!connectionRef) {
845
886
  return baseFetch;
846
887
  }
847
- const resolveCredential = input.deps.connectionCredentials?.mcpCredentials
848
- ? buildHostConnectionTokenResolver(input.deps.connectionCredentials.mcpCredentials, {
888
+ const credentialSubjectId =
889
+ input.turn.initiator.kind === "subject" ? input.turn.initiator.subjectId : undefined;
890
+ if (connectionRef.subjectScope === "subject" && !credentialSubjectId) {
891
+ throw new Error(
892
+ `subject-owned connection for MCP server ${input.config.id} requires a human turn initiator`,
893
+ );
894
+ }
895
+ const hostCredentialPort = input.deps.connectionCredentials?.mcpCredentials;
896
+ const resolverSubjectId = hostCredentialPort ? input.grant.subjectId : credentialSubjectId;
897
+ const resolveCredential = hostCredentialPort
898
+ ? buildHostConnectionTokenResolver(hostCredentialPort, {
849
899
  accountId: input.grant.accountId,
850
900
  workspaceId: input.grant.workspaceId,
851
901
  sessionId: input.sessionId,
@@ -868,7 +918,7 @@ export function connectionBrokerFetch(
868
918
  destinationUrl,
869
919
  forceRefresh: false,
870
920
  ...(request.toolName ? { toolName: request.toolName } : {}),
871
- subjectId: input.grant.subjectId,
921
+ ...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
872
922
  });
873
923
  if (first.status === "auth_needed") {
874
924
  return await authNeededFetchResponse(input, request, first);
@@ -886,7 +936,7 @@ export function connectionBrokerFetch(
886
936
  destinationUrl,
887
937
  forceRefresh: true,
888
938
  ...(request.toolName ? { toolName: request.toolName } : {}),
889
- subjectId: input.grant.subjectId,
939
+ ...(resolverSubjectId ? { subjectId: resolverSubjectId } : {}),
890
940
  });
891
941
  if (refreshed.status === "auth_needed") {
892
942
  return await authNeededFetchResponse(input, request, refreshed);