Skip to content

Commit 4f8c2d1

Browse files
authored
feat(connectors): degrade + retry declarative instances on unreachable upstream (#3017) (#3049)
A provider factory can throw ConnectorUpstreamUnavailableError (spec) to mark an operational fault; the reconcile degrades that instance (visible husk with state/degradedReason, or old connector keeps serving on changed-config failures) and retries with exponential backoff plus on every metadata:reloaded. Configuration faults stay fatal at boot. The mcp provider classifies connect/tools-list failures as unavailable.
1 parent 21f75ce commit 4f8c2d1

13 files changed

Lines changed: 842 additions & 39 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@objectstack/spec': minor
3+
'@objectstack/service-automation': minor
4+
'@objectstack/connector-mcp': minor
5+
---
6+
7+
feat(connectors): degrade + retry declarative instances whose upstream is unreachable (#3017)
8+
9+
ADR-0097 kept every declarative-connector materialization failure fatal at
10+
boot. That is right for configuration faults (unknown provider, invalid
11+
`providerConfig`, unresolvable `credentialRef`, name conflict) but wrong for
12+
*operational* ones: a `provider: 'mcp'` instance must contact its MCP server
13+
(`tools/list`) to materialize, and a transient network blip aborted the whole
14+
app boot.
15+
16+
- **spec**: a provider factory can now throw
17+
`ConnectorUpstreamUnavailableError` (code `CONNECTOR_UPSTREAM_UNAVAILABLE`,
18+
structural guard `isConnectorUpstreamUnavailable`) to mark a failure as
19+
"upstream temporarily unreachable — degrade and retry" instead of fatal.
20+
- **service-automation**: the reconcile degrades such an instance in both boot
21+
and reload modes: it registers an action-less husk (`state: 'degraded'` +
22+
`degradedReason` on the `GET /connectors` descriptor) so the instance is
23+
visible instead of silently missing — or, on a changed-config
24+
re-materialization, keeps the old connector serving. A `connector_action`
25+
against a degraded instance fails with the reason and a "retries
26+
automatically" pointer. Degraded instances retry on an exponential backoff
27+
(5s → 5min, reset by config edits) and on every `metadata:reloaded`
28+
reconcile; recovery swaps the husk for the live connector atomically.
29+
Reconcile runs (boot / reload / retry timer) are now serialized.
30+
- **connector-mcp**: the `mcp` provider classifies connect / `tools/list`
31+
failures as upstream-unavailable; transport-shape validation stays a plain
32+
(fatal) throw.
33+
34+
Configuration faults remain loud boot failures — the carve-out is only for the
35+
unavailable marker.

docs/adr/0097-declarative-connector-instances.md

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,44 @@ and skipped**, never crashing the live server; a changed instance's old connecto
132132
keeps serving until its replacement materializes successfully. Boot keeps the
133133
"fail loudly" contract.
134134

135+
### Upstream availability: degrade, don't die (follow-up, landed — #3017)
136+
137+
The fail-loud contract deliberately distinguishes **configuration** faults from
138+
**operational** faults. Unknown provider, invalid `providerConfig`, unresolvable
139+
`credentialRef`, name conflict — authoring mistakes — stay fatal at boot. But a
140+
provider whose factory must contact an upstream to materialize (the `mcp`
141+
provider connects and calls `tools/list`) can fail because that upstream is
142+
*temporarily unreachable*; aborting the whole app boot for one integration's
143+
network blip turns a degraded connector into a total outage.
144+
145+
A factory signals the operational case by throwing an error carrying the
146+
`CONNECTOR_UPSTREAM_UNAVAILABLE` code (`ConnectorUpstreamUnavailableError` in
147+
`@objectstack/spec/integration`; the materializer's check is structural, not
148+
`instanceof`). The reconcile then — in both boot and reload modes — **degrades**
149+
that one instance instead of failing the run:
150+
151+
- With no live connector under the name, an action-less husk is registered
152+
(`state: 'degraded'` + `degradedReason` on the descriptor, `status: 'error'`
153+
on the def) so `GET /connectors` shows the instance honestly instead of it
154+
silently missing, and a `connector_action` dispatch fails with the reason and
155+
"the platform retries automatically" — never the generic wiring hint.
156+
- On a changed-config re-materialization, the previous live connector keeps
157+
serving untouched (the same guarantee every other reload failure gives).
158+
- Degraded instances are retried on an exponential backoff (5s doubling to a
159+
5-minute ceiling; a config edit resets it) and immediately by any reconcile
160+
(`metadata:reloaded`). Recovery replaces the husk atomically via
161+
`registerConnector`; an instance deleted while degraded is dropped, husk and
162+
pending retry included.
163+
164+
The `mcp` provider classifies connect / `tools/list` failures as unavailable;
165+
transport-shape validation stays a plain (fatal) throw. Lazy connect-on-first-
166+
dispatch was rejected: MCP actions ARE the server's `tools/list`, so a
167+
connector materialized without connecting would register a zero-action def —
168+
exactly the plausible-but-dead shape this ADR exists to kill.
169+
135170
### Deliberate scope boundaries
136171

137172
- **`providerConfig.spec` (openapi)** accepts an inline document, an http(s) URL, **or a package-relative file path** (#3016 follow-up). The connector still owns no filesystem access: the automation service injects a `loadPackageFile` capability into `ConnectorProviderContext` that resolves the ref against the declaring stack/package root (`packageRoot`, CLI default: the `objectstack.config.ts` directory) and **confines reads to that root** — absolute and `..`-escaping paths are rejected. Read/parse failures follow the reconcile policy above: fatal at boot, skipped on reload.
138173
- **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers.
139-
- **MCP at boot** connects during materialization, so an unreachable server fails boot; a fail-soft "optional" marker for boot-time materialization is a possible future refinement (runtime reloads are already soft).
174+
- **A live `provider: 'mcp'` showcase demo** remains deferred: materialization needs a reachable MCP server, and the natural in-repo target (`@objectstack/mcp`, the platform's own MCP endpoint) is not listening until after the automation plugin's `start()` — the demo would deterministically boot degraded and heal seconds later, making the dogfood CI gate timing-sensitive. It should land together with an in-repo MCP fixture server (or an explicit boot-ordering story for self-connection).
175+
- **The openapi provider's remote-URL spec fetch** still throws plain on network failure (fatal at boot). It can adopt the same `CONNECTOR_UPSTREAM_UNAVAILABLE` classification once operators ask for it — the mechanism is provider-agnostic.

packages/connectors/connector-mcp/src/mcp-provider.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import { describe, it, expect } from 'vitest';
88
import type { ConnectorProviderContext } from '@objectstack/spec/integration';
9+
import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration';
910
import type { McpClientLike, McpToolDescriptor, McpTransport } from './mcp-connector.js';
1011
import { createMcpProviderFactory, MCP_PROVIDER_KEY } from './mcp-provider.js';
1112

@@ -82,3 +83,50 @@ describe('mcp provider factory (ADR-0097)', () => {
8283
).rejects.toThrow(/kind must be 'stdio' or 'http'/);
8384
});
8485
});
86+
87+
// ── #3017 — fault classification: config stays fatal, upstream degrades ─────
88+
89+
describe('mcp provider fault classification (#3017)', () => {
90+
const stdio = { transport: { kind: 'stdio', command: 'my-mcp' } };
91+
92+
it('classifies a connect failure as upstream-unavailable (retryable), keeping the cause', async () => {
93+
const boom = new Error('connect ECONNREFUSED 127.0.0.1:9999');
94+
const clientFactory = async (): Promise<McpClientLike> => { throw boom; };
95+
const factory = createMcpProviderFactory({ clientFactory });
96+
97+
const err = await factory(ctx({ providerConfig: stdio })).then(
98+
() => { throw new Error('expected rejection'); },
99+
(e: unknown) => e,
100+
);
101+
expect(isConnectorUpstreamUnavailable(err)).toBe(true);
102+
expect((err as Error).message).toMatch(/'github' could not reach its MCP server/);
103+
expect((err as Error).message).toContain('ECONNREFUSED');
104+
expect((err as { cause?: unknown }).cause).toBe(boom);
105+
});
106+
107+
it('classifies a tools/list failure as upstream-unavailable and closes the client', async () => {
108+
let closed = false;
109+
const clientFactory = async (): Promise<McpClientLike> => ({
110+
listTools: async () => { throw new Error('request timed out'); },
111+
callTool: async () => ({}),
112+
close: async () => { closed = true; },
113+
});
114+
const factory = createMcpProviderFactory({ clientFactory });
115+
116+
const err = await factory(ctx({ providerConfig: stdio })).then(
117+
() => { throw new Error('expected rejection'); },
118+
(e: unknown) => e,
119+
);
120+
expect(isConnectorUpstreamUnavailable(err)).toBe(true);
121+
expect(closed).toBe(true); // discovery failure must not leak the connection
122+
});
123+
124+
it('keeps transport-shape faults plain — configuration errors stay fatal at boot', async () => {
125+
const factory = createMcpProviderFactory();
126+
const err = await factory(ctx({ providerConfig: {} })).then(
127+
() => { throw new Error('expected rejection'); },
128+
(e: unknown) => e,
129+
);
130+
expect(isConnectorUpstreamUnavailable(err)).toBe(false);
131+
});
132+
});

packages/connectors/connector-mcp/src/mcp-provider.ts

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

33
import type { ConnectorProviderFactory, ResolvedConnectorAuth } from '@objectstack/spec/integration';
4+
import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration';
45
import { createMcpConnector, type McpConnectorOptions, type McpTransport } from './mcp-connector.js';
56

67
/**
@@ -103,9 +104,13 @@ function normalizeTransport(
103104
* {@link createMcpConnector} builds for a hand-wired MCP connector — one action
104105
* per tool, dispatched to the server's `tools/call`.
105106
*
106-
* The connection is opened at materialization; an unreachable server or invalid
107-
* transport therefore fails boot loudly (ADR-0097 fail-loud contract). Prefer a
108-
* fail-soft plugin instantiation for an *optional* server.
107+
* The connection is opened at materialization. Faults are classified (#3017):
108+
* an invalid transport shape is a *configuration* fault and throws plain —
109+
* fatal at boot per the ADR-0097 fail-loud contract — while a connect /
110+
* `tools/list` failure (server down, refused, timed out) is an *operational*
111+
* fault and throws {@link ConnectorUpstreamUnavailableError}, which the
112+
* materializer turns into a degraded instance that is retried with backoff
113+
* instead of aborting the whole app boot.
109114
*/
110115
export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorProviderFactory {
111116
return async (ctx) => {
@@ -116,14 +121,26 @@ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorP
116121
: undefined;
117122
const include = includeList ? (toolName: string) => includeList.includes(toolName) : undefined;
118123

119-
const bundle = await createMcpConnector({
120-
name: ctx.name,
121-
label: ctx.label,
122-
description: ctx.description,
123-
transport,
124-
include,
125-
clientFactory: deps.clientFactory,
126-
});
124+
let bundle;
125+
try {
126+
bundle = await createMcpConnector({
127+
name: ctx.name,
128+
label: ctx.label,
129+
description: ctx.description,
130+
transport,
131+
include,
132+
clientFactory: deps.clientFactory,
133+
});
134+
} catch (err) {
135+
// Everything past transport validation is talking to the server (connect,
136+
// handshake, tools/list) — operational, hence retryable. A credential the
137+
// server rejects also lands here: indistinguishable from the outside, and
138+
// retrying it is loud (logged per attempt), never silent.
139+
throw new ConnectorUpstreamUnavailableError(
140+
`connector-mcp provider: connector '${ctx.name}' could not reach its MCP server: ${(err as Error).message}`,
141+
{ cause: err },
142+
);
143+
}
127144
return { def: bundle.def, handlers: bundle.handlers, close: bundle.close };
128145
};
129146
}

packages/services/service-automation/src/builtin/connector-nodes.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,18 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont
5757

5858
const handler = engine.resolveConnectorAction(cfg.connectorId, cfg.actionId);
5959
if (!handler) {
60+
// A degraded declarative instance (#3017) is registered but has no
61+
// handlers — say WHY dispatch fails and that recovery is automatic,
62+
// instead of the generic wiring hint below.
63+
const degraded = engine.getConnectorDegradedReason(cfg.connectorId);
64+
if (degraded) {
65+
return {
66+
success: false,
67+
error:
68+
`connector_action '${node.id}': connector '${cfg.connectorId}' is degraded — ${degraded}. `
69+
+ `Dispatch is unavailable until its upstream recovers; the platform retries automatically (#3017).`,
70+
};
71+
}
6072
return {
6173
success: false,
6274
error:

0 commit comments

Comments
 (0)