Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/graphql-network-prose.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-graphql": patch
---

A GraphQL liveness probe no longer reads a transport failure's prose as a dead credential. Classification matched any upstream message containing "permission", which an operating-system refusal also carries — `connect EACCES: permission denied` on a socket reported the connection as `expired` and asked the user to re-enter a secret that was never the problem. Prose is now consulted only when the failure is not a transport failure; an HTTP 401 or 403 still classifies on its status.
5 changes: 5 additions & 0 deletions .changeset/mcp-liveness-pooled-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-mcp": patch
---

The MCP liveness probe now takes the invocation pool's connection instead of dialling a second one. A probe of a local stdio server previously started a second child process, and the common local servers permit one instance only — Chrome DevTools MCP owns a browser and a debug port, Playwright MCP the same, `docker run -i` a container. The second child could not start, so the health check reported the connection broken while the server was up and serving tool calls. Because the UI re-probes every non-healthy verdict on every mount, each page load started one more child. A probe now reuses the pooled session or child, and an interrupted probe still tears down whatever it acquired.
19 changes: 19 additions & 0 deletions .changeset/oauth-refresh-evidence.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
"@executor-js/sdk": patch
---

Require evidence before a connection is marked permanently expired, and let the health probe refresh before it answers `expired`.

A refresher whose grant is refused now reads the stored refresh token again. When a peer instance rotated that token while the request was in flight, the call adopts the access token the peer persisted and records no rejection. Previously the loser of a concurrent refresh wrote `oauthReauthRequiredAt` onto a connection that still held a valid rotated refresh token. Every surface then answered `expired` without probing, and no tool call could refresh it again: only a re-authorization recovered it. The record is also skipped when `expires_at` moved forward during the grant, which is the same peer success read from the row.

`isPermanentTokenRejection` no longer reads 408, 425, or 429 as a definitive refusal. One rate-limited minute at a token endpoint therefore no longer ends a grant. Those statuses now behave like a 5xx response, and the next call retries.

`connections.checkHealth` re-mints the token once and probes again before it answers `expired` for an OAuth connection. A revoked token, an idle timeout shorter than the advertised lifetime, or a null `expires_at` therefore no longer shows a working connection as dead.

A connection whose integration declares no probe operation is now asked of the plugin first. A plugin that can answer without a spec — MCP lists its tools — gives a real verdict for its OAuth connections, which the credential-only branch never reached. Only when the plugin itself answers `unknown` does the credential-only verdict replace it, and that verdict is now computed from the values the probe already resolved, so nothing refreshes twice. A credential that resolves to nothing reads as `expired` there too, matching the plugins and heal-on-use.

A refresh response that omits `expires_in` no longer erases `expires_at`. RFC 6749 makes the field optional, so an authorization server that advertised a lifetime on the code exchange and omitted it on refresh used to disable proactive refresh for the rest of the connection's life. The mint now records the advertised lifetime in `provider_state.oauthTokenLifetimeMs`, and a refresh without `expires_in` derives the expiry from it.

A 403 scope shortfall on a probe now reads as `degraded` rather than `expired`: the credential authenticated, the grant is too narrow, and the remedy is a new consent rather than a reconnect.

The test authorization server's MCP resource endpoint (`serveOAuthTestServer` at `/mcp`) now speaks the JSON-RPC protocol honestly: it answers the request's own id, answers `tools/list` with an empty catalog, and stays silent for notifications. The previous canned reply used a fixed id, so any client that completed the handshake waited forever for its `tools/list` response and every catalog sync or liveness probe against the endpoint timed out at the discovery deadline — a limitation invisible while OAuth health checks never dialled, and exposed once they do.
5 changes: 5 additions & 0 deletions .changeset/probe-scope-shortfall.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@executor-js/plugin-openapi": patch
---

A health probe that meets a 403 scope shortfall now reads as `degraded` rather than `expired`. The credential authenticated; the grant is narrower than the probe operation needs, and the remedy is a new consent with wider scope — which the connection's missing-scope affordance already offers. The old verdict told the user the connection was dead and sent them through a reconnect that could not widen the grant. Classification passes the response headers as well as the body, so an RFC 6750 `WWW-Authenticate: Bearer error="insufficient_scope"` challenge is recognised too.
34 changes: 26 additions & 8 deletions packages/core/sdk/src/connections.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2575,6 +2575,11 @@ const makeHealthHarness = (options?: {
* `counters.probes`, so a Deferred-gated probe lets a test hold every
* in-flight health check open and count how many actually started. */
readonly probe?: Effect.Effect<typeof HealthCheckResult.Type, unknown>;
/** Answer `unknown` when core passes no declared spec, the way the protocol
* plugins do: they have no operation to dial, so core falls back to the
* credential-only verdict. Without this the harness plugin answers every
* check, and the fallback is unreachable. */
readonly declineWithoutSpec?: boolean;
}) => {
const counters = { probes: 0, resolves: 0 };
const hooks = {
Expand Down Expand Up @@ -2660,9 +2665,16 @@ const makeHealthHarness = (options?: {
? ToolResult.fail({ code: "upstream_error", message: "boom" })
: { ran: toolRow.name, value: credential.value },
),
checkHealth: () =>
checkHealth: ({ spec }) =>
Effect.suspend(() => {
counters.probes += 1;
if (options?.declineWithoutSpec === true && spec === undefined) {
return Effect.succeed({
status: "unknown" as const,
checkedAt: Date.now(),
detail: "No health check configured.",
});
}
return (
options?.probe ??
Effect.succeed({ status: "healthy" as const, checkedAt: Date.now(), detail: "probe ok" })
Expand Down Expand Up @@ -3298,12 +3310,15 @@ describe("credential-only health path", () => {
// parallel suite load the forked checks may not have finished their row
// loads yet, and the counter reads 0.
const entered = yield* Deferred.make<void>();
const { executor, counters, stamp, persisted, hooks } = yield* makeHealthHarness();
// No declared probe spec + an OAuth client on the row routes checkHealth
// down the credential-only path: the verdict is "the credential
// resolved", produced without invoking the plugin probe. That path runs
// behind the same in-flight gate as probing, so concurrent checks must
// collapse to ONE resolution.
const { executor, counters, stamp, persisted, hooks } = yield* makeHealthHarness({
declineWithoutSpec: true,
});
// No declared probe spec + an OAuth client on the row: the plugin is
// asked first, declines for want of an operation to dial, and core falls
// back to the credential-only verdict — "the credential resolved",
// produced from the SAME resolution the plugin's probe used, so nothing
// refreshes twice. That path runs behind the same in-flight gate as
// probing, so concurrent checks must collapse to ONE resolution.
yield* stamp({ oauth_client: "acme", expires_at: null });
hooks.onResolve = Deferred.succeed(entered, void 0).pipe(
Effect.andThen(Deferred.await(gate)),
Expand All @@ -3329,7 +3344,10 @@ describe("credential-only health path", () => {
expect(first.status).toBe("healthy");
expect(second.status).toBe("healthy");
expect(counters.resolves).toBe(1);
expect(counters.probes).toBe(0);
// Both checks shared ONE gate entry, so the plugin was asked once and
// declined once; the verdict both callers received is the fallback.
expect(counters.probes).toBe(1);
expect(first.detail).toBe("Credential resolved (no probe configured).");

const row = yield* persisted();
expect(row?.lastHealth?.status).toBe("healthy");
Expand Down
Loading