From 1bcba1ece4bfa949102e9ab28d8c937bb7eda313 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:00:07 +0000 Subject: [PATCH 1/5] fix(service-automation): a borrowed token refused at mcp tool discovery marks its connection, not the client secret An mcp instance with an authorization_code grant lists its tools with the token of its discovery organization's connection (ruled B). When the server refused that token, the refusal took the static-credential path: the client secret row was marked rejected and the instance held on a key a reconnect does not move. The refusal is now recorded on that connection (reconnect_required, the platform's sentence), the client secret is left alone, and the instance lists as the next connected organization or holds until a reconnect, which lists again. Co-Authored-By: Claude --- .../connector-mcp-authorization-door.test.ts | 92 ++++++++++- .../services/service-automation/src/plugin.ts | 145 +++++++++++++++++- 2 files changed, 225 insertions(+), 12 deletions(-) diff --git a/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts b/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts index 772e6e991e9..d2cc22cca14 100644 --- a/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts +++ b/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts @@ -23,7 +23,12 @@ import { SqlDriver } from '@objectstack/driver-sql'; import { SysSecret } from '@objectstack/platform-objects'; import { callWithBorrowedCredential } from '@objectstack/types'; import type { CryptoContext, CryptoHandle, ICryptoProvider } from '@objectstack/spec/contracts'; -import type { Connector, ConnectorProviderContext, ConnectorProviderFactory } from '@objectstack/spec/integration'; +import { + ConnectorCredentialRejectedError, + type Connector, + type ConnectorProviderContext, + type ConnectorProviderFactory, +} from '@objectstack/spec/integration'; import { AutomationServicePlugin } from './plugin.js'; import type { AutomationEngine } from './engine.js'; @@ -149,14 +154,25 @@ function notion(auth: Record = {}, transportUrl?: string) { * the real provider's `tools/list` does: an ask that names no organization. */ function mcpStandIn(lists = false) { - const state = { materialized: [] as ConnectorProviderContext[], sent: [] as string[], listedWith: [] as string[], refuse: new Set() }; + const state = { + materialized: [] as ConnectorProviderContext[], + sent: [] as string[], + listedWith: [] as string[], + refuse: new Set(), + /** What the next listings answer, in order (then `200`): a `403` is thrown as the real provider throws it. */ + listingAnswers: [] as number[], + }; const factory: ConnectorProviderFactory = async (ctx) => { state.materialized.push(ctx); if (lists) { - await callWithBorrowedCredential(ctx.credentialHandle!, async (auth) => { - state.listedWith.push(auth.type === 'bearer' ? auth.token : ''); - return { status: 200 }; + const listed = await callWithBorrowedCredential(ctx.credentialHandle!, async (auth) => { + const token = auth.type === 'bearer' ? auth.token : ''; + state.listedWith.push(token); + return { status: state.listingAnswers.shift() ?? 200 }; }, {}); + if (listed.status === 403) { + throw new ConnectorCredentialRejectedError(`connector-mcp provider: connector '${ctx.name}' was refused by its MCP server (HTTP 403)`, 403); + } } return { def: { @@ -385,6 +401,72 @@ describe('ADR-0143 D4, ruled B — the connection an mcp instance lists its tool }); }); +describe('ADR-0143 D4 — a borrowed token refused at tool discovery is its connection\'s, not the client secret\'s', () => { + const SECRET_REF = 'NOTION_CLIENT_SECRET'; + const STORED_SECRET = 'dummy-stored-secret-26702'; + const OPERATOR = { userId: 'usr_admin' } as never; + const secretRow = async (engine: AutomationEngine) => + (await engine.listConnectorCredentialRefs()).find((r) => r.ref === SECRET_REF); + const connectionOf = (engine: AutomationEngine, as: { userId: string; tenantId: string }) => + engine.getConnectorConnection({ connector: 'notion', context: as as never }); + + it('the discovery organization\'s connection needs reconnecting; a reconnect there lists again', async () => { + const { engine, provider, connect, call } = await boot([notion()], { lists: true }); + provider.state.listingAnswers.push(403); + await (await connect()).done(); + expect(provider.state.listedWith).toHaveLength(1); + + // The connection whose token was refused is the one marked, with the platform's sentence. + await vi.waitFor(async () => expect(await connectionOf(engine, ADMIN_A)).toMatchObject({ + status: 'reconnect_required', + statusReason: expect.stringContaining('refused the token of this connection while it listed its tools (HTTP 403)'), + })); + await vi.waitFor(() => expect(engine.getConnectorDegradedReason('notion')).toContain("needs to be reconnected for organization 'org_a'")); + expect(engine.getConnectorDegradedReason('notion')).toContain('while it listed its tools (HTTP 403)'); + await expect(call()).rejects.toMatchObject({ status: 503 }); + + // A reconnect by the same organization re-attempts discovery, and the instance serves. + await (await connect()).done(); + await vi.waitFor(() => expect(engine.getConnectorDegradedReason('notion')).toBeUndefined()); + expect(provider.state.listedWith).toHaveLength(2); + expect(await connectionOf(engine, ADMIN_A)).toMatchObject({ status: 'connected' }); + expect(await call()).toMatchObject({ status: 200 }); + }); + + it('the stored client secret stays `set`; the instance lists as the next organization, each dispatch keeps its own', async () => { + const { engine, provider, connect } = await boot([notion({ clientId: 'notion-app', credentialRef: SECRET_REF })], { lists: true }); + await engine.setConnectorCredential({ ref: SECRET_REF, value: STORED_SECRET, context: OPERATOR }); + await (await connect(undefined, ADMIN_A)).done(); + await (await connect(undefined, ADMIN_B)).done(); + expect(provider.state.materialized).toHaveLength(1); + + // The secret is set again: the instance materializes again, listing as org_a (the earliest), which is refused. + provider.state.listingAnswers.push(403); + await engine.setConnectorCredential({ ref: SECRET_REF, value: `${STORED_SECRET}-2`, context: OPERATOR }); + await vi.waitFor(() => expect(provider.state.materialized).toHaveLength(3)); + await vi.waitFor(() => expect(engine.getConnectorDegradedReason('notion')).toBeUndefined()); + + expect(await secretRow(engine)).toMatchObject({ status: 'set' }); + expect(await connectionOf(engine, ADMIN_A)).toMatchObject({ status: 'reconnect_required' }); + expect(await connectionOf(engine, ADMIN_B)).toMatchObject({ status: 'connected' }); + const dispatch = (tenantId: string) => + engine.resolveConnectorAction('notion', 'search')!({}, { automation: { tenantId } } as never); + expect(await dispatch('org_b')).toMatchObject({ status: 200 }); + await expect(dispatch('org_a')).rejects.toMatchObject({ status: 503, message: expect.stringContaining("needs to be reconnected for organization 'org_a'") }); + }); + + it('CONTROL: a client_credentials token the instance mints itself keeps the client secret\'s attribution', async () => { + const { engine, provider } = await boot([notion({ + grant: 'client_credentials', clientId: 'notion-app', tokenUrl: `${mcp.base}/auth/token`, credentialRef: SECRET_REF, + })], { lists: true }); + provider.state.listingAnswers.push(403); + await engine.setConnectorCredential({ ref: SECRET_REF, value: STORED_SECRET, context: OPERATOR }); + await vi.waitFor(() => expect(provider.state.listedWith).toHaveLength(1)); + await vi.waitFor(async () => expect(await secretRow(engine)).toMatchObject({ status: 'rejected' })); + expect(engine.getConnectorDegradedReason('notion')).toContain('HTTP 403'); + }); +}); + describe('ADR-0143 D2.1 — the pre-registered clientId form', () => { it('a declared clientId is used as is: no registration, the exchange authenticates with the declared secret', async () => { const { connect, ql } = await boot([notion({ clientId: 'hubspot-app', credentialRef: 'HUBSPOT_CLIENT_SECRET' })]); diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index 777a6953492..f1c1a548c4f 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -550,6 +550,10 @@ type ConnectionStateOfInstance = readonly reconnectRequired: readonly string[]; /** The organization whose connection is the oldest standing one: see {@link discoveryOrganizationOf}. */ readonly discovery?: string; + /** When that connection was made: a refusal of its token marks it only while it is still that connect. */ + readonly discoveryConnectedAt?: string; + /** The reason the first `reconnect_required` connection records, the platform's sentence. */ + readonly reconnectRequiredReason?: string; } | { readonly kind: 'unreadable' }; @@ -576,6 +580,21 @@ function discoveryOrganizationOf(rows: readonly ConnectorConnectionRecord[]): st return pick?.organizationId; } +/** + * ADR-0143 D4 — why the discovery organization's connection needs + * reconnecting: the upstream refused the token borrowed from it while the + * instance listed its tools (ruled B on #26702). The refusal is the + * connection's grant, not the instance's client secret, so it is recorded on + * the connection, and a reconnect there lists again. The platform's sentence, + * never the upstream's text. + */ +function discoveryTokenRefusedReason(connector: string, organizationId: string, status: number): string { + return `connector instance '${connector}' needs to be reconnected for organization '${organizationId}': its MCP server ` + + `refused the token of this connection while it listed its tools (HTTP ${status}), so the grant does not reach what ` + + 'the server serves. Reconnect required: an administrator connects it again, granting what the server asks for, and ' + + 'the instance lists its tools with the new connection (ADR-0143 D4)'; +} + /** * The handle a provider is given while it materializes an instance whose * materialization asks name {@link discoveryOrganizationOf}: until `settle()` @@ -2506,11 +2525,17 @@ export class AutomationServicePlugin implements Plugin { let state: ConnectionStateOfInstance; try { const rows = await this.connectorConnectionStoreOrNone().listFor(name); + const reconnectRequired = rows.filter((r) => r.status === 'reconnect_required').map((r) => r.organizationId).sort(); + const discovery = discoveryOrganizationOf(rows); + const discoveryConnectedAt = rows.find((r) => r.organizationId === discovery)?.connectedAt; + const reconnectRequiredReason = rows.find((r) => r.organizationId === reconnectRequired[0])?.statusReason; state = { kind: 'read', connected: rows.filter((r) => r.status === 'connected').map((r) => r.organizationId).sort(), - reconnectRequired: rows.filter((r) => r.status === 'reconnect_required').map((r) => r.organizationId).sort(), - discovery: discoveryOrganizationOf(rows), + reconnectRequired, + ...(discovery === undefined ? {} : { discovery }), + ...(discoveryConnectedAt === undefined ? {} : { discoveryConnectedAt }), + ...(reconnectRequiredReason === undefined ? {} : { reconnectRequiredReason }), }; } catch (err) { state = { kind: 'unreadable' }; @@ -2699,6 +2724,8 @@ export class AutomationServicePlugin implements Plugin { // The handle the provider is given while it materializes an `mcp` // `authorization_code` instance: see `askingDuringMaterializationAs`. let materializingHandle: ReturnType | undefined; + // Its connection, when the provider lists in its name: a refusal of that token is the connection's. + let discoveryConnection: { organizationId: string; connectedAt?: string } | undefined; if (entry.auth?.type === 'signed') { try { requestSigner = createConnectorRequestSigner(name, entry.auth, { @@ -2740,17 +2767,21 @@ export class AutomationServicePlugin implements Plugin { signature, existing, reason: state && state.reconnectRequired.length > 0 - ? reconnectRequiredReason(name, state.reconnectRequired[0]) + ? state.reconnectRequiredReason ?? reconnectRequiredReason(name, state.reconnectRequired[0]) : notConnectedReason(name), }); continue; } } credentialHandle = this.createGrantHandle(ctx, name, entry, entry.auth, credential?.value); - const discovery = entry.auth.grant === 'authorization_code' && provider === 'mcp' - ? (() => { const state = connectionStates.get(name); return state?.kind === 'read' ? state.discovery : undefined; })() - : undefined; - if (discovery !== undefined) materializingHandle = askingDuringMaterializationAs(credentialHandle, discovery); + const state = entry.auth.grant === 'authorization_code' && provider === 'mcp' ? connectionStates.get(name) : undefined; + if (state?.kind === 'read' && state.discovery !== undefined) { + materializingHandle = askingDuringMaterializationAs(credentialHandle, state.discovery); + discoveryConnection = { + organizationId: state.discovery, + ...(state.discoveryConnectedAt === undefined ? {} : { connectedAt: state.discoveryConnectedAt }), + }; + } } else if (entry.auth?.type === 'app-credential') { // ADR-0143 D2.2 — the vendor exchange the closed `vendor` names, // run from the app secret its `credentialRef` resolved to; the @@ -2839,6 +2870,24 @@ export class AutomationServicePlugin implements Plugin { // echoes the credential back must not carry it into the husk's // reason, a log record or the thrown boot error. const thrown = withoutCredentialThrown(err, credentialForms(credential?.value, basicUsernameOf(entry.auth))); + // ADR-0143 D4 — the upstream refused the token borrowed from the + // discovery organization's connection while the instance listed + // its tools: that connection's grant, not the client secret the + // instance's `credentialRef` names, so the connection is marked + // and the secret left alone (#26702). + if (isConnectorCredentialRejected(thrown) && discoveryConnection !== undefined) { + await this.holdConnectorForRefusedConnection(engine, ctx, { + name, + entry, + provider, + signature, + hasLive: existing !== undefined, + connection: discoveryConnection, + status: typeof (thrown as { status?: unknown }).status === 'number' ? (thrown as { status: number }).status : 403, + cause: thrown, + }); + continue; + } // [#27237] The upstream refused the credential (ruling on // #27237, comment 6470087293): degrade the instance and mark a // stored credential `rejected` — never a failed boot, never a @@ -3224,6 +3273,88 @@ export class AutomationServicePlugin implements Plugin { }); } + /** + * ADR-0143 D4 — the upstream refused the token an `mcp` instance borrowed + * from its discovery organization's connection (ruled B on #26702) while + * it listed its tools. That organization's connection is marked + * `reconnect_required`, with the platform's sentence, and the client + * secret the instance's `credentialRef` names is left as it is: it is not + * what the upstream refused. The connection's change moves the + * instance's signature, so the reconcile queued here lists as the next + * connected organization, or holds the instance "reconnect required" + * until a reconnect, which lists again. + * + * The connection is marked only while it is still the connect whose token + * was sent (same `connected_at`); a reconnect that landed since keeps its + * fresh grant. When the connection could not be marked, the instance is + * degraded on the #3017 backoff instead, so the refusal is retried rather + * than held on a key that nothing moves. A live connector keeps serving, + * as on every failed re-materialization. + */ + private async holdConnectorForRefusedConnection( + engine: AutomationEngine, + ctx: PluginContext, + info: { + name: string; + entry: DeclaredConnectorItem; + provider: string; + signature: string; + hasLive: boolean; + connection: { organizationId: string; connectedAt?: string }; + status: number; + cause: unknown; + }, + ): Promise { + const { name, connection } = info; + const reason = discoveryTokenRefusedReason(name, connection.organizationId, info.status); + let marked = false; + try { + const store = this.connectorConnectionStoreOrNone(); + const record = await store.read(connection.organizationId, name); + if (record && record.status === 'connected' && record.connectedAt === connection.connectedAt) { + await store.markReconnectRequired(record, reason); + marked = true; + } + } catch (err) { + ctx.logger.warn( + `[Automation] connector instance '${name}': the connection of organization '${connection.organizationId}' ` + + 'could not be marked to be reconnected; the instance retries.', + describeThrownForLog(err), + ); + } + if (!marked) { + this.degradeConnectorInstance(engine, ctx, { + name, + entry: info.entry, + provider: info.provider, + signature: info.signature, + hasLive: info.hasLive, + reason: `connector instance '${name}': its MCP server refused the token of organization ` + + `'${connection.organizationId}' while it listed its tools (HTTP ${info.status}); it retries`, + cause: info.cause, + }); + return; + } + this.degradedInstances.set(name, { attempts: 1, signature: info.signature, reason, held: true }); + if (!info.hasLive) { + try { + engine.registerDegradedConnector(this.buildDegradedHuskDef(name, info.entry), reason, 'declarative', info.cause, { kind: 'connection' }); + } catch (err) { + ctx.logger.warn( + `[Automation] could not register degraded husk for '${name}' — the instance stays absent from the ` + + 'connector registry until it is reconnected.', + describeThrownForLog(err), + ); + } + } + ctx.logger.warn( + `[Automation] connector instance '${name}' (provider '${info.provider}'): its MCP server refused the token of ` + + `organization '${connection.organizationId}' while it listed its tools (HTTP ${info.status}); that connection ` + + 'needs to be reconnected (ADR-0143 D4).', + ); + void this.materializeDeclaredConnectors(ctx, { fatal: false }).catch(() => { /* logged by the reconcile */ }); + } + /** * ADR-0143 D4 — hold an `authorization_code` instance with no working * connection degraded: "not connected" (never authorized) or "reconnect From 2c4eada2e6618ffd43af708e7743e9e4c79fc6b0 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:00:56 +0000 Subject: [PATCH 2/5] test(service-automation): a held husk has no action to resolve Co-Authored-By: Claude --- .../src/connector-mcp-authorization-door.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts b/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts index d2cc22cca14..f6ef84f8426 100644 --- a/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts +++ b/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts @@ -423,7 +423,7 @@ describe('ADR-0143 D4 — a borrowed token refused at tool discovery is its conn })); await vi.waitFor(() => expect(engine.getConnectorDegradedReason('notion')).toContain("needs to be reconnected for organization 'org_a'")); expect(engine.getConnectorDegradedReason('notion')).toContain('while it listed its tools (HTTP 403)'); - await expect(call()).rejects.toMatchObject({ status: 503 }); + expect(engine.resolveConnectorAction('notion', 'search')).toBeUndefined(); // A reconnect by the same organization re-attempts discovery, and the instance serves. await (await connect()).done(); From 351d372d963540b917fb330bdb30326ee595007a Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:03:01 +0000 Subject: [PATCH 3/5] test(service-automation): a reconnect landing during the refused listing keeps its grant Co-Authored-By: Claude --- .../connector-mcp-authorization-door.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts b/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts index f6ef84f8426..1a344ca1424 100644 --- a/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts +++ b/packages/services/service-automation/src/connector-mcp-authorization-door.test.ts @@ -161,6 +161,8 @@ function mcpStandIn(lists = false) { refuse: new Set(), /** What the next listings answer, in order (then `200`): a `403` is thrown as the real provider throws it. */ listingAnswers: [] as number[], + /** Run before a listing answers (e.g. another replica's reconnect landing meanwhile). */ + beforeListingAnswer: undefined as undefined | (() => Promise), }; const factory: ConnectorProviderFactory = async (ctx) => { state.materialized.push(ctx); @@ -168,6 +170,7 @@ function mcpStandIn(lists = false) { const listed = await callWithBorrowedCredential(ctx.credentialHandle!, async (auth) => { const token = auth.type === 'bearer' ? auth.token : ''; state.listedWith.push(token); + await state.beforeListingAnswer?.(); return { status: state.listingAnswers.shift() ?? 200 }; }, {}); if (listed.status === 403) { @@ -455,6 +458,21 @@ describe('ADR-0143 D4 — a borrowed token refused at tool discovery is its conn await expect(dispatch('org_a')).rejects.toMatchObject({ status: 503, message: expect.stringContaining("needs to be reconnected for organization 'org_a'") }); }); + it('a reconnect that landed while the refused token was listing keeps its fresh grant; the instance retries', async () => { + const { engine, provider, connect, ql } = await boot([notion()], { lists: true }); + provider.state.listingAnswers.push(403); + // Another replica's reconnect: the row is a new connect by the time the refusal is recorded. + provider.state.beforeListingAnswer = async () => { + provider.state.beforeListingAnswer = undefined; + const [row] = await rowsOf(ql); + await ql.update(CONNECTOR_CONNECTION_OBJECT, { id: row.id, connected_at: '2999-01-01T00:00:00.000Z' }, { context: SYSTEM } as never); + }; + await (await connect()).done(); + expect(provider.state.listedWith).toHaveLength(1); + expect(await connectionOf(engine, ADMIN_A)).toMatchObject({ status: 'connected' }); + expect(engine.getConnectorDegradedReason('notion')).toContain("refused the token of organization 'org_a' while it listed its tools (HTTP 403); it retries"); + }); + it('CONTROL: a client_credentials token the instance mints itself keeps the client secret\'s attribution', async () => { const { engine, provider } = await boot([notion({ grant: 'client_credentials', clientId: 'notion-app', tokenUrl: `${mcp.base}/auth/token`, credentialRef: SECRET_REF, From fe1191cdd593076e0071cf0e74376ec0dd6a3b85 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:04:21 +0000 Subject: [PATCH 4/5] chore(changeset): 26702 mcp discovery refusal marks the connection Co-Authored-By: Claude --- ...702-mcp-discovery-refusal-marks-connection.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/26702-mcp-discovery-refusal-marks-connection.md diff --git a/.changeset/26702-mcp-discovery-refusal-marks-connection.md b/.changeset/26702-mcp-discovery-refusal-marks-connection.md new file mode 100644 index 00000000000..d1d801290b6 --- /dev/null +++ b/.changeset/26702-mcp-discovery-refusal-marks-connection.md @@ -0,0 +1,16 @@ +--- +'@objectstack/service-automation': patch +--- + +fix(service-automation): a borrowed token refused at `mcp` tool discovery marks its connection, not the client secret + +Clause-②: no +Metadata-protocol: none + +An `mcp` instance with an `oauth2` `authorization_code` grant lists its tools with the token of its discovery organization's connection (ruled B on #26702). A discovery refusal of that connection-borrowed token is now attributed to the connection, not to the instance's client secret: + +- that organization's connection is marked `reconnect_required`, with a platform sentence naming the refusal (served as `statusReason` by the connection door and as the held instance's reason); +- the client secret its `credentialRef` names is left as it is (it was marked `rejected`, which setting it again was the only way to clear); +- the instance lists as the next connected organization, or is held "reconnect required" until a reconnect, and a reconnect by that organization lists again (before, the instance stayed held until a declaration edit or a restart). + +A reconnect that landed while the refused token was listing keeps its fresh grant: the connection is marked only while it is still the connect whose token was sent, and otherwise the instance retries on the backoff. Static credentials (`bearer`, `api-key`, `basic`) and tokens an instance mints itself (`client_credentials`, `app-credential`) keep the client secret's attribution, unchanged. From d21e1bac0bb5d9b4329962546ebb42d9db08e54f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Nov 2026 07:37:06 +0000 Subject: [PATCH 5/5] docs(connector-mcp): the credentialSent comment names the connection route a discovery 403 now takes Co-Authored-By: Claude --- .../connector-mcp/src/mcp-provider.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/packages/connectors/connector-mcp/src/mcp-provider.ts b/packages/connectors/connector-mcp/src/mcp-provider.ts index b55bf1026d5..f1eea11cb47 100644 --- a/packages/connectors/connector-mcp/src/mcp-provider.ts +++ b/packages/connectors/connector-mcp/src/mcp-provider.ts @@ -507,13 +507,17 @@ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorP // the server's answer was given to that credential. // // A grant-bearing instance's borrowed token (`ctx.credentialHandle`) - // reaches the server too, and is deliberately not counted here: the - // credential path a refusal below takes holds and marks the instance's - // `credentialRef` row, which for such an instance is its client secret — - // a value the listing never depended on — while the token that was sent - // belongs to an organization's connection, which that path neither marks - // nor re-attempts on a reconnect. Until the materializer has a route for - // it, such an instance keeps the plain fault. + // reaches the server too, and is deliberately not counted here. A 403 the + // server answers that token with at discovery (thrown above as + // `ConnectorCredentialRejectedError`; a 401 is refreshed once, then a 503) + // is routed by the materializer to the + // connection the token was borrowed from: for an `authorization_code` + // instance, the discovery organization's connection is marked + // `reconnect_required` and a reconnect there lists again, while the + // instance's `credentialRef` (its client secret) is left alone (#26702). + // The `include` / `toolsSnapshot` refusals below are not routed that way: + // with the borrowed token not counted here, such an instance keeps the + // plain fault for them (the accepted deviation of #28357). const credentialSent = transport.kind === 'http' && sendsInstanceAuth(ctx.auth); if (includeList) { // An entry naming a tool the server does not list is refused after the