From c65f114ed1850f8c9137a1d007d20ec457e7d206 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 11:31:20 +0000 Subject: [PATCH 1/4] feat(connectors): degrade + retry declarative instances on unreachable upstream (#3017) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0097 made every declarative-connector materialization failure fatal at boot — right for configuration faults, wrong for operational ones: a provider: 'mcp' instance must contact its MCP server (tools/list) to materialize, so a transient network blip aborted the whole app boot. - spec: ConnectorUpstreamUnavailableError (code CONNECTOR_UPSTREAM_UNAVAILABLE, structural guard isConnectorUpstreamUnavailable) lets a provider factory mark a failure as 'upstream temporarily unreachable — degrade and retry' instead of fatal. New integration/connector-provider-errors.ts; api-surface +3. - service-automation: the reconcile degrades such instances in BOTH modes. With no live connector under the name it registers an action-less husk — state: 'degraded' + degradedReason on the GET /connectors descriptor, status: 'error' on the def — so the instance stays visible instead of silently missing; connector_action dispatch fails with the reason and a 'retries automatically' pointer. On a changed-config re-materialization the old connector keeps serving. Degraded instances retry on an exponential backoff (5s doubling to 5min, reset by config edits) and on every metadata:reloaded reconcile; recovery swaps the husk atomically. Reconcile runs (boot / reload / retry timer) are serialized; destroy() cancels the retry loop. - connector-mcp: connect / tools/list failures are classified unavailable; transport-shape validation stays a plain (fatal) throw. Configuration faults (unknown provider, invalid providerConfig, unresolvable credentialRef, name conflicts) keep the ADR-0097 fail-loud contract, verified by the existing tests. ADR-0097 gains an 'Upstream availability' section; the deferred live-mcp showcase demo and the openapi remote-URL classification are recorded as scope boundaries. Tests: 10 new reconcile cases (degrade visibility, pointed dispatch error, fake-timer recovery, exponential backoff, reload retry, removal while degraded, changed-config keeps old serving, revert cancels retry, shutdown cancels retry, plain-throw still fatal); 3 new mcp-provider classification cases; 3 new spec error-contract cases. Refs #3017 (mechanism; showcase demo deferred — see ADR scope boundaries) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pbmw3pMqNJfPbkvwh9Fkjs --- .changeset/adr-0097-mcp-nonfatal-boot.md | 35 +++ .../0097-declarative-connector-instances.md | 38 ++- .../connector-mcp/src/mcp-provider.test.ts | 48 +++ .../connector-mcp/src/mcp-provider.ts | 39 ++- .../src/builtin/connector-nodes.ts | 12 + .../src/connector-materialization.test.ts | 282 +++++++++++++++++- .../services/service-automation/src/engine.ts | 94 ++++-- .../services/service-automation/src/plugin.ts | 219 +++++++++++++- packages/spec/api-surface.json | 3 + .../connector-provider-errors.test.ts | 38 +++ .../integration/connector-provider-errors.ts | 64 ++++ .../src/integration/connector-provider.ts | 8 +- packages/spec/src/integration/index.ts | 1 + 13 files changed, 842 insertions(+), 39 deletions(-) create mode 100644 .changeset/adr-0097-mcp-nonfatal-boot.md create mode 100644 packages/spec/src/integration/connector-provider-errors.test.ts create mode 100644 packages/spec/src/integration/connector-provider-errors.ts diff --git a/.changeset/adr-0097-mcp-nonfatal-boot.md b/.changeset/adr-0097-mcp-nonfatal-boot.md new file mode 100644 index 0000000000..9ff9f71ea6 --- /dev/null +++ b/.changeset/adr-0097-mcp-nonfatal-boot.md @@ -0,0 +1,35 @@ +--- +'@objectstack/spec': minor +'@objectstack/service-automation': minor +'@objectstack/connector-mcp': minor +--- + +feat(connectors): degrade + retry declarative instances whose upstream is unreachable (#3017) + +ADR-0097 kept every declarative-connector materialization failure fatal at +boot. That is right for configuration faults (unknown provider, invalid +`providerConfig`, unresolvable `credentialRef`, name conflict) but wrong for +*operational* ones: a `provider: 'mcp'` instance must contact its MCP server +(`tools/list`) to materialize, and a transient network blip aborted the whole +app boot. + +- **spec**: a provider factory can now throw + `ConnectorUpstreamUnavailableError` (code `CONNECTOR_UPSTREAM_UNAVAILABLE`, + structural guard `isConnectorUpstreamUnavailable`) to mark a failure as + "upstream temporarily unreachable — degrade and retry" instead of fatal. +- **service-automation**: the reconcile degrades such an instance in both boot + and reload modes: it registers an action-less husk (`state: 'degraded'` + + `degradedReason` on the `GET /connectors` descriptor) so the instance is + visible instead of silently missing — or, on a changed-config + re-materialization, keeps the old connector serving. A `connector_action` + against a degraded instance fails with the reason and a "retries + automatically" pointer. Degraded instances retry on an exponential backoff + (5s → 5min, reset by config edits) and on every `metadata:reloaded` + reconcile; recovery swaps the husk for the live connector atomically. + Reconcile runs (boot / reload / retry timer) are now serialized. +- **connector-mcp**: the `mcp` provider classifies connect / `tools/list` + failures as upstream-unavailable; transport-shape validation stays a plain + (fatal) throw. + +Configuration faults remain loud boot failures — the carve-out is only for the +unavailable marker. diff --git a/docs/adr/0097-declarative-connector-instances.md b/docs/adr/0097-declarative-connector-instances.md index d4e44e571e..89a7039289 100644 --- a/docs/adr/0097-declarative-connector-instances.md +++ b/docs/adr/0097-declarative-connector-instances.md @@ -132,8 +132,44 @@ and skipped**, never crashing the live server; a changed instance's old connecto keeps serving until its replacement materializes successfully. Boot keeps the "fail loudly" contract. +### Upstream availability: degrade, don't die (follow-up, landed — #3017) + +The fail-loud contract deliberately distinguishes **configuration** faults from +**operational** faults. Unknown provider, invalid `providerConfig`, unresolvable +`credentialRef`, name conflict — authoring mistakes — stay fatal at boot. But a +provider whose factory must contact an upstream to materialize (the `mcp` +provider connects and calls `tools/list`) can fail because that upstream is +*temporarily unreachable*; aborting the whole app boot for one integration's +network blip turns a degraded connector into a total outage. + +A factory signals the operational case by throwing an error carrying the +`CONNECTOR_UPSTREAM_UNAVAILABLE` code (`ConnectorUpstreamUnavailableError` in +`@objectstack/spec/integration`; the materializer's check is structural, not +`instanceof`). The reconcile then — in both boot and reload modes — **degrades** +that one instance instead of failing the run: + +- With no live connector under the name, an action-less husk is registered + (`state: 'degraded'` + `degradedReason` on the descriptor, `status: 'error'` + on the def) so `GET /connectors` shows the instance honestly instead of it + silently missing, and a `connector_action` dispatch fails with the reason and + "the platform retries automatically" — never the generic wiring hint. +- On a changed-config re-materialization, the previous live connector keeps + serving untouched (the same guarantee every other reload failure gives). +- Degraded instances are retried on an exponential backoff (5s doubling to a + 5-minute ceiling; a config edit resets it) and immediately by any reconcile + (`metadata:reloaded`). Recovery replaces the husk atomically via + `registerConnector`; an instance deleted while degraded is dropped, husk and + pending retry included. + +The `mcp` provider classifies connect / `tools/list` failures as unavailable; +transport-shape validation stays a plain (fatal) throw. Lazy connect-on-first- +dispatch was rejected: MCP actions ARE the server's `tools/list`, so a +connector materialized without connecting would register a zero-action def — +exactly the plausible-but-dead shape this ADR exists to kill. + ### Deliberate scope boundaries - **`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. - **MCP credentials** ride the transport (ADR-0024); for an http transport a resolved `auth` is folded into the request headers. -- **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). +- **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). +- **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. diff --git a/packages/connectors/connector-mcp/src/mcp-provider.test.ts b/packages/connectors/connector-mcp/src/mcp-provider.test.ts index c64da7e9fe..639ebc7c7d 100644 --- a/packages/connectors/connector-mcp/src/mcp-provider.test.ts +++ b/packages/connectors/connector-mcp/src/mcp-provider.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest'; import type { ConnectorProviderContext } from '@objectstack/spec/integration'; +import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration'; import type { McpClientLike, McpToolDescriptor, McpTransport } from './mcp-connector.js'; import { createMcpProviderFactory, MCP_PROVIDER_KEY } from './mcp-provider.js'; @@ -82,3 +83,50 @@ describe('mcp provider factory (ADR-0097)', () => { ).rejects.toThrow(/kind must be 'stdio' or 'http'/); }); }); + +// ── #3017 — fault classification: config stays fatal, upstream degrades ───── + +describe('mcp provider fault classification (#3017)', () => { + const stdio = { transport: { kind: 'stdio', command: 'my-mcp' } }; + + it('classifies a connect failure as upstream-unavailable (retryable), keeping the cause', async () => { + const boom = new Error('connect ECONNREFUSED 127.0.0.1:9999'); + const clientFactory = async (): Promise => { throw boom; }; + const factory = createMcpProviderFactory({ clientFactory }); + + const err = await factory(ctx({ providerConfig: stdio })).then( + () => { throw new Error('expected rejection'); }, + (e: unknown) => e, + ); + expect(isConnectorUpstreamUnavailable(err)).toBe(true); + expect((err as Error).message).toMatch(/'github' could not reach its MCP server/); + expect((err as Error).message).toContain('ECONNREFUSED'); + expect((err as { cause?: unknown }).cause).toBe(boom); + }); + + it('classifies a tools/list failure as upstream-unavailable and closes the client', async () => { + let closed = false; + const clientFactory = async (): Promise => ({ + listTools: async () => { throw new Error('request timed out'); }, + callTool: async () => ({}), + close: async () => { closed = true; }, + }); + const factory = createMcpProviderFactory({ clientFactory }); + + const err = await factory(ctx({ providerConfig: stdio })).then( + () => { throw new Error('expected rejection'); }, + (e: unknown) => e, + ); + expect(isConnectorUpstreamUnavailable(err)).toBe(true); + expect(closed).toBe(true); // discovery failure must not leak the connection + }); + + it('keeps transport-shape faults plain — configuration errors stay fatal at boot', async () => { + const factory = createMcpProviderFactory(); + const err = await factory(ctx({ providerConfig: {} })).then( + () => { throw new Error('expected rejection'); }, + (e: unknown) => e, + ); + expect(isConnectorUpstreamUnavailable(err)).toBe(false); + }); +}); diff --git a/packages/connectors/connector-mcp/src/mcp-provider.ts b/packages/connectors/connector-mcp/src/mcp-provider.ts index d26e76caec..04924e02b9 100644 --- a/packages/connectors/connector-mcp/src/mcp-provider.ts +++ b/packages/connectors/connector-mcp/src/mcp-provider.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import type { ConnectorProviderFactory, ResolvedConnectorAuth } from '@objectstack/spec/integration'; +import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration'; import { createMcpConnector, type McpConnectorOptions, type McpTransport } from './mcp-connector.js'; /** @@ -103,9 +104,13 @@ function normalizeTransport( * {@link createMcpConnector} builds for a hand-wired MCP connector — one action * per tool, dispatched to the server's `tools/call`. * - * The connection is opened at materialization; an unreachable server or invalid - * transport therefore fails boot loudly (ADR-0097 fail-loud contract). Prefer a - * fail-soft plugin instantiation for an *optional* server. + * The connection is opened at materialization. Faults are classified (#3017): + * an invalid transport shape is a *configuration* fault and throws plain — + * fatal at boot per the ADR-0097 fail-loud contract — while a connect / + * `tools/list` failure (server down, refused, timed out) is an *operational* + * fault and throws {@link ConnectorUpstreamUnavailableError}, which the + * materializer turns into a degraded instance that is retried with backoff + * instead of aborting the whole app boot. */ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorProviderFactory { return async (ctx) => { @@ -116,14 +121,26 @@ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorP : undefined; const include = includeList ? (toolName: string) => includeList.includes(toolName) : undefined; - const bundle = await createMcpConnector({ - name: ctx.name, - label: ctx.label, - description: ctx.description, - transport, - include, - clientFactory: deps.clientFactory, - }); + let bundle; + try { + bundle = await createMcpConnector({ + name: ctx.name, + label: ctx.label, + description: ctx.description, + transport, + include, + clientFactory: deps.clientFactory, + }); + } catch (err) { + // Everything past transport validation is talking to the server (connect, + // handshake, tools/list) — operational, hence retryable. A credential the + // server rejects also lands here: indistinguishable from the outside, and + // retrying it is loud (logged per attempt), never silent. + throw new ConnectorUpstreamUnavailableError( + `connector-mcp provider: connector '${ctx.name}' could not reach its MCP server: ${(err as Error).message}`, + { cause: err }, + ); + } return { def: bundle.def, handlers: bundle.handlers, close: bundle.close }; }; } diff --git a/packages/services/service-automation/src/builtin/connector-nodes.ts b/packages/services/service-automation/src/builtin/connector-nodes.ts index f12d4bc051..4e9d3e66f9 100644 --- a/packages/services/service-automation/src/builtin/connector-nodes.ts +++ b/packages/services/service-automation/src/builtin/connector-nodes.ts @@ -57,6 +57,18 @@ export function registerConnectorNodes(engine: AutomationEngine, ctx: PluginCont const handler = engine.resolveConnectorAction(cfg.connectorId, cfg.actionId); if (!handler) { + // A degraded declarative instance (#3017) is registered but has no + // handlers — say WHY dispatch fails and that recovery is automatic, + // instead of the generic wiring hint below. + const degraded = engine.getConnectorDegradedReason(cfg.connectorId); + if (degraded) { + return { + success: false, + error: + `connector_action '${node.id}': connector '${cfg.connectorId}' is degraded — ${degraded}. ` + + `Dispatch is unavailable until its upstream recovers; the platform retries automatically (#3017).`, + }; + } return { success: false, error: diff --git a/packages/services/service-automation/src/connector-materialization.test.ts b/packages/services/service-automation/src/connector-materialization.test.ts index b3ffc5f31b..1587d5914f 100644 --- a/packages/services/service-automation/src/connector-materialization.test.ts +++ b/packages/services/service-automation/src/connector-materialization.test.ts @@ -12,14 +12,20 @@ import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import * as path from 'node:path'; -import { describe, it, expect, afterAll } from 'vitest'; +import { describe, it, expect, afterAll, afterEach, vi } from 'vitest'; import { LiteKernel } from '@objectstack/core'; import type { ConnectorProviderContext, ConnectorProviderFactory, ConnectorInstanceAuth, } from '@objectstack/spec/integration'; -import { AutomationServicePlugin, createPackageFileLoader, type CredentialResolver } from './plugin.js'; +import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration'; +import { + AutomationServicePlugin, + createPackageFileLoader, + DECLARATIVE_RETRY_BASE_MS, + type CredentialResolver, +} from './plugin.js'; import type { AutomationEngine } from './engine.js'; const flush = () => new Promise((r) => setTimeout(r, 0)); @@ -534,3 +540,275 @@ describe('#3016 — file-ref materialization policy (fatal at boot, soft on relo await kernel.shutdown(); }); }); + +// ── #3017 — upstream unavailable: degrade + retry, never a dead boot ───────── +// +// A provider factory that throws the CONNECTOR_UPSTREAM_UNAVAILABLE marker +// (connector-mcp does, when its MCP server is unreachable) is an *operational* +// fault: the instance is registered as a degraded, action-less husk — visible +// via GET /connectors, dispatch fails with a pointed error — and retried with +// exponential backoff plus on every reconcile. Configuration faults (plain +// throws) keep the ADR-0097 fail-loud contract, asserted above. + +/** + * A provider factory whose availability is a switch: while `down`, it throws + * the unavailable marker (as connector-mcp does for an unreachable server); + * once up, it materializes a one-action connector. Counts invocations and + * records closes. + */ +function makeFlakyUpstreamProvider(opts: { down: boolean }) { + const state = { down: opts.down, calls: 0 }; + const closed: string[] = []; + const factory: ConnectorProviderFactory = (ctx) => { + state.calls++; + if (state.down) { + throw new ConnectorUpstreamUnavailableError( + `connector '${ctx.name}' could not reach its MCP server: connect ECONNREFUSED`, + ); + } + return { + def: { + name: ctx.name, + label: ctx.label, + type: 'api', + authentication: { type: 'none' }, + actions: [{ key: 'ping', label: 'Ping' }], + }, + handlers: { ping: async () => ({ ok: true }) }, + close: async () => { closed.push(ctx.name); }, + }; + }; + return { factory, state, closed }; +} + +/** + * Like {@link bootReloadable} but with NO real-timer flushes, so it can run + * entirely under `vi.useFakeTimers()` — the boot reconcile is awaited inside + * the plugin's start() and the reload reconcile inside the hook trigger, so + * nothing here depends on a timer tick. + */ +async function bootDegradable(initial: unknown[], factory: ConnectorProviderFactory) { + const state = { declared: initial }; + let captured: any; + const kernel = new LiteKernel({ logger: { level: 'silent' } } as never); + kernel.use(new AutomationServicePlugin()); + kernel.use({ + name: 'test.harness', + type: 'standard' as const, + version: '1.0.0', + dependencies: ['com.objectstack.service-automation'], + async init(ctx: any) { + captured = ctx; + ctx.registerService('objectql', { + registry: { listItems: (type: string) => (type === 'connector' ? state.declared : []) }, + }); + ctx.getService('automation').registerConnectorProvider('fake', factory); + }, + async start() {}, + } as never); + await kernel.bootstrap(); + const reload = async (next: unknown[]) => { + state.declared = next; + await captured.trigger('metadata:reloaded'); + }; + return { kernel, engine: automationOf(kernel), reload }; +} + +describe('#3017 — upstream-unavailable degrade + retry', () => { + afterEach(() => { + vi.useRealTimers(); + }); + + it('boot survives an unreachable upstream: the instance registers DEGRADED, not dead, not fatal', async () => { + const { factory, state } = makeFlakyUpstreamProvider({ down: true }); + const { kernel, engine } = await bootDegradable([providerConnector('gh_mcp')], factory); + + // Boot did not throw; the husk is visible and honest. + const desc = engine.getConnectorDescriptors().find((d) => d.name === 'gh_mcp'); + expect(desc?.state).toBe('degraded'); + expect(desc?.origin).toBe('declarative'); + expect(desc?.actions).toEqual([]); + expect(desc?.degradedReason).toContain('ECONNREFUSED'); + expect(engine.getConnectorDegradedReason('gh_mcp')).toContain('ECONNREFUSED'); + // No handlers exist yet — dispatch cannot resolve an action on the husk. + expect(engine.resolveConnectorAction('gh_mcp', 'ping')).toBeUndefined(); + expect(state.calls).toBe(1); + + await kernel.shutdown(); + }); + + it('a connector_action against a degraded instance fails with the reason, not the generic wiring hint', async () => { + const { factory } = makeFlakyUpstreamProvider({ down: true }); + const { kernel, engine } = await bootDegradable([providerConnector('gh_mcp')], factory); + + engine.registerFlow('call_mcp', { + name: 'call_mcp', + label: 'Call MCP', + type: 'autolaunched', + nodes: [ + { id: 'start', type: 'start', label: 'Start' }, + { + id: 'call', + type: 'connector_action', + label: 'Ping', + connectorConfig: { connectorId: 'gh_mcp', actionId: 'ping', input: {} }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'call' }, + { id: 'e2', source: 'call', target: 'end' }, + ], + } as never); + + const result = await engine.execute('call_mcp'); + expect(result.success).toBe(false); + expect(JSON.stringify(result)).toMatch(/is degraded/); + expect(JSON.stringify(result)).toMatch(/ECONNREFUSED/); + + await kernel.shutdown(); + }); + + it('recovers on the backoff timer once the upstream is back', async () => { + vi.useFakeTimers(); + const { factory, state } = makeFlakyUpstreamProvider({ down: true }); + const { kernel, engine } = await bootDegradable([providerConnector('gh_mcp')], factory); + expect(state.calls).toBe(1); + expect(engine.getConnectorDegradedReason('gh_mcp')).toBeDefined(); + + state.down = false; + await vi.advanceTimersByTimeAsync(DECLARATIVE_RETRY_BASE_MS); + + expect(state.calls).toBe(2); // the retry ran + const desc = engine.getConnectorDescriptors().find((d) => d.name === 'gh_mcp'); + expect(desc?.state).toBe('ready'); + expect(desc?.degradedReason).toBeUndefined(); + expect(desc?.actions.map((a) => a.key)).toEqual(['ping']); + expect(engine.getConnectorDegradedReason('gh_mcp')).toBeUndefined(); + expect(engine.resolveConnectorAction('gh_mcp', 'ping')).toBeDefined(); + + await kernel.shutdown(); + }); + + it('backs off exponentially while the upstream stays down', async () => { + vi.useFakeTimers(); + const { factory, state } = makeFlakyUpstreamProvider({ down: true }); + const { kernel } = await bootDegradable([providerConnector('gh_mcp')], factory); + expect(state.calls).toBe(1); // boot attempt + + // Retry 1 fires after BASE. + await vi.advanceTimersByTimeAsync(DECLARATIVE_RETRY_BASE_MS); + expect(state.calls).toBe(2); + + // Retry 2 is now scheduled at 2·BASE: not yet at +BASE … + await vi.advanceTimersByTimeAsync(DECLARATIVE_RETRY_BASE_MS); + expect(state.calls).toBe(2); + // … but fires by +2·BASE. + await vi.advanceTimersByTimeAsync(DECLARATIVE_RETRY_BASE_MS); + expect(state.calls).toBe(3); + + await kernel.shutdown(); + }); + + it('a metadata reload retries a degraded instance immediately and can recover it', async () => { + const { factory, state } = makeFlakyUpstreamProvider({ down: true }); + const { kernel, engine, reload } = await bootDegradable([providerConnector('gh_mcp')], factory); + expect(engine.getConnectorDegradedReason('gh_mcp')).toBeDefined(); + + state.down = false; + await reload([providerConnector('gh_mcp')]); // unchanged entry — but degraded, so retried + expect(state.calls).toBe(2); + expect(engine.getConnectorDegradedReason('gh_mcp')).toBeUndefined(); + expect(engine.getConnectorDescriptors().find((d) => d.name === 'gh_mcp')?.state).toBe('ready'); + + await kernel.shutdown(); + }); + + it('a degraded instance removed from the stack is dropped entirely (husk + pending retry)', async () => { + vi.useFakeTimers(); + const { factory, state } = makeFlakyUpstreamProvider({ down: true }); + const { kernel, engine, reload } = await bootDegradable([providerConnector('gh_mcp')], factory); + expect(engine.getRegisteredConnectors()).toContain('gh_mcp'); + + await reload([]); + expect(engine.getRegisteredConnectors()).not.toContain('gh_mcp'); + // No zombie retries: the upstream coming back must not re-register it. + state.down = false; + await vi.advanceTimersByTimeAsync(DECLARATIVE_RETRY_BASE_MS * 10); + expect(engine.getRegisteredConnectors()).not.toContain('gh_mcp'); + + await kernel.shutdown(); + }); + + it('a changed entry whose new upstream is unreachable keeps the OLD connector serving, then swaps on recovery', async () => { + vi.useFakeTimers(); + const { factory, state, closed } = makeFlakyUpstreamProvider({ down: false }); + const { kernel, engine, reload } = await bootDegradable( + [providerConnector('gh_mcp', { providerConfig: { url: 'https://old' } })], + factory, + ); + expect(engine.getConnectorDescriptors().find((d) => d.name === 'gh_mcp')?.state).toBe('ready'); + + // Publish a config change while the new upstream is down. + state.down = true; + await reload([providerConnector('gh_mcp', { providerConfig: { url: 'https://new' } })]); + + // The old connector keeps serving — never degraded out from under a flow. + const desc = engine.getConnectorDescriptors().find((d) => d.name === 'gh_mcp'); + expect(desc?.state).toBe('ready'); + expect(engine.resolveConnectorAction('gh_mcp', 'ping')).toBeDefined(); + expect(closed).toEqual([]); // old connection untouched + + // Upstream recovers → the pending retry swaps in the new materialization. + state.down = false; + await vi.advanceTimersByTimeAsync(DECLARATIVE_RETRY_BASE_MS); + expect(closed).toEqual(['gh_mcp']); // old torn down only after the new one succeeded + expect(engine.getConnectorDescriptors().find((d) => d.name === 'gh_mcp')?.state).toBe('ready'); + + await kernel.shutdown(); + }); + + it('reverting a pending change back to the live configuration cancels the retry', async () => { + vi.useFakeTimers(); + const { factory, state } = makeFlakyUpstreamProvider({ down: false }); + const { kernel, engine, reload } = await bootDegradable( + [providerConnector('gh_mcp', { providerConfig: { url: 'https://old' } })], + factory, + ); + const callsAfterBoot = state.calls; + + state.down = true; + await reload([providerConnector('gh_mcp', { providerConfig: { url: 'https://new' } })]); + const callsAfterFailedChange = state.calls; + expect(callsAfterFailedChange).toBe(callsAfterBoot + 1); + + // Revert to the exact live config: the live connector already IS the + // desired state — the pending retry must be cancelled, not fired. + await reload([providerConnector('gh_mcp', { providerConfig: { url: 'https://old' } })]); + await vi.advanceTimersByTimeAsync(DECLARATIVE_RETRY_BASE_MS * 10); + expect(state.calls).toBe(callsAfterFailedChange); // no further attempts + expect(engine.getConnectorDescriptors().find((d) => d.name === 'gh_mcp')?.state).toBe('ready'); + + await kernel.shutdown(); + }); + + it('shutdown cancels any pending retry', async () => { + vi.useFakeTimers(); + const { factory, state } = makeFlakyUpstreamProvider({ down: true }); + const { kernel } = await bootDegradable([providerConnector('gh_mcp')], factory); + expect(state.calls).toBe(1); + + await kernel.shutdown(); + await vi.advanceTimersByTimeAsync(DECLARATIVE_RETRY_BASE_MS * 10); + expect(state.calls).toBe(1); // destroyed plugin never retried + }); + + it('a PLAIN factory throw at boot is still fatal — the carve-out is only for the unavailable marker', async () => { + const factory: ConnectorProviderFactory = () => { + throw new Error('providerConfig.transport is malformed'); + }; + await expect(bootDegradable([providerConnector('gh_mcp')], factory)).rejects.toThrow( + /failed to materialize connector instance 'gh_mcp'/, + ); + }); +}); diff --git a/packages/services/service-automation/src/engine.ts b/packages/services/service-automation/src/engine.ts index f57cd26766..66e7eb758b 100644 --- a/packages/services/service-automation/src/engine.ts +++ b/packages/services/service-automation/src/engine.ts @@ -170,6 +170,16 @@ export type ConnectorActionHandler = ( */ export type ConnectorOrigin = 'plugin' | 'declarative'; +/** + * Whether a registered connector is dispatchable (#3017). `ready` — the normal + * state: actions and handlers are live. `degraded` — a declarative instance + * whose provider factory could not reach its upstream (e.g. an MCP server was + * unreachable at boot): it is registered so `GET /connectors` shows it honestly + * instead of it silently missing, but it exposes no actions and every dispatch + * fails with a clear error until the materializer's retry succeeds. + */ +export type ConnectorState = 'ready' | 'degraded'; + /** * A connector registered on the engine: its validated {@link Connector} * definition plus the handler for each action it declares. @@ -179,6 +189,10 @@ export interface RegisteredConnector { readonly handlers: Record; /** How this connector was registered (ADR-0097 §4). Defaults to `plugin`. */ readonly origin: ConnectorOrigin; + /** Dispatchability (#3017). `registerConnector` always yields `ready`. */ + readonly state: ConnectorState; + /** Why the connector is degraded — set only when `state` is `degraded`. */ + readonly degradedReason?: string; } // The connector **provider** contract (ADR-0097) — ConnectorProviderFactory, @@ -254,6 +268,15 @@ export interface ConnectorDescriptor { * from an inert catalog descriptor, which never reaches this list). */ readonly origin: ConnectorOrigin; + /** + * Dispatchability (#3017): `ready` — actions are live; `degraded` — the + * instance's upstream was unreachable when the provider factory ran, so it + * currently exposes no actions and cannot dispatch. The platform retries + * degraded instances automatically; `degradedReason` says what failed. + */ + readonly state: ConnectorState; + /** Why the connector is degraded — present only when `state` is `degraded`. */ + readonly degradedReason?: string; } // ─── Core Automation Engine ───────────────────────────────────────── @@ -827,28 +850,50 @@ export class AutomationEngine implements IAutomationService { ); } } - const existing = this.connectors.get(parsed.name); - if (existing) { - if (existing.origin !== origin) { - const describe = (o: ConnectorOrigin) => - o === 'plugin' - ? 'a plugin (engine.registerConnector)' - : 'a declarative provider-bound `connectors:` instance'; - throw new Error( - `Connector name conflict: '${parsed.name}' is already registered by ${describe(existing.origin)} ` + - `and cannot also be registered by ${describe(origin)}. A declarative provider-bound instance and a ` + - `plugin-registered connector must not share a name — there is no silent precedence (ADR-0097 §4). ` + - `Rename one of them.`, - ); - } - this.logger.warn(`Connector '${parsed.name}' replaced`); - } - this.connectors.set(parsed.name, { def: parsed, handlers, origin }); + this.assertSameOriginOrFree(parsed.name, origin); + this.connectors.set(parsed.name, { def: parsed, handlers, origin, state: 'ready' }); this.logger.info( `Connector registered: ${parsed.name} (${Object.keys(handlers).length} action handlers, origin: ${origin})`, ); } + /** + * Register a connector in the **degraded** state (#3017): its provider + * factory could not reach the upstream it materializes from (an MCP server, + * a remote spec), so there are no actions and no handlers yet. Registering + * the husk — instead of leaving the name absent — keeps the instance honest: + * `GET /connectors` shows it with `state: 'degraded'` + the reason, and a + * `connector_action` dispatching to it fails with a pointed error rather + * than "unknown connector". The materializer retries and replaces this + * registration via {@link registerConnector} once the upstream is back. + * Same cross-origin collision rule as {@link registerConnector} (ADR-0097 §4). + */ + registerDegradedConnector(def: Connector, reason: string, origin: ConnectorOrigin = 'declarative'): void { + const parsed = ConnectorSchema.parse(def); + this.assertSameOriginOrFree(parsed.name, origin); + this.connectors.set(parsed.name, { def: parsed, handlers: {}, origin, state: 'degraded', degradedReason: reason }); + this.logger.warn(`Connector registered DEGRADED: ${parsed.name} (origin: ${origin}) — ${reason}`); + } + + /** Enforce the ADR-0097 §4 two-sources-of-truth rule; warn on same-origin replace. */ + private assertSameOriginOrFree(name: string, origin: ConnectorOrigin): void { + const existing = this.connectors.get(name); + if (!existing) return; + if (existing.origin !== origin) { + const describe = (o: ConnectorOrigin) => + o === 'plugin' + ? 'a plugin (engine.registerConnector)' + : 'a declarative provider-bound `connectors:` instance'; + throw new Error( + `Connector name conflict: '${name}' is already registered by ${describe(existing.origin)} ` + + `and cannot also be registered by ${describe(origin)}. A declarative provider-bound instance and a ` + + `plugin-registered connector must not share a name — there is no silent precedence (ADR-0097 §4). ` + + `Rename one of them.`, + ); + } + this.logger.warn(`Connector '${name}' replaced`); + } + /** Unregister a connector (hot-unplug). */ unregisterConnector(name: string): void { this.connectors.delete(name); @@ -894,6 +939,17 @@ export class AutomationEngine implements IAutomationService { return this.connectors.get(name)?.origin; } + /** + * The degraded-state reason for a connector, or `undefined` when the + * connector is `ready` (or not registered at all). Lets the + * `connector_action` node distinguish "degraded, retrying" from "no such + * connector/action" in its failure message (#3017). + */ + getConnectorDegradedReason(name: string): string | undefined { + const reg = this.connectors.get(name); + return reg?.state === 'degraded' ? (reg.degradedReason ?? 'upstream unavailable') : undefined; + } + /** * Register a connector **provider factory** (ADR-0097 §2). A connector * plugin (e.g. `@objectstack/connector-openapi`) calls this at `init()` under @@ -934,13 +990,15 @@ export class AutomationEngine implements IAutomationService { * Handlers are omitted — they are runtime code, not metadata. */ getConnectorDescriptors(): ConnectorDescriptor[] { - return [...this.connectors.values()].map(({ def, origin }) => ({ + return [...this.connectors.values()].map(({ def, origin, state, degradedReason }) => ({ name: def.name, label: def.label, type: def.type, description: def.description, icon: def.icon, origin, + state, + degradedReason, actions: (def.actions ?? []).map((a) => ({ key: a.key, label: a.label, diff --git a/packages/services/service-automation/src/plugin.ts b/packages/services/service-automation/src/plugin.ts index fa47591997..05f3efa4e4 100644 --- a/packages/services/service-automation/src/plugin.ts +++ b/packages/services/service-automation/src/plugin.ts @@ -3,10 +3,12 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import type { IJobService } from '@objectstack/spec/contracts'; import type { + Connector, ConnectorInstanceAuth, ResolvedConnectorAuth, ConnectorProviderContext, } from '@objectstack/spec/integration'; +import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration'; import { AutomationEngine } from './engine.js'; import { installBuiltinNodes, rearmSuspendedWaitTimers } from './builtin/index.js'; import { SysAutomationRun } from './sys-automation-run.object.js'; @@ -139,6 +141,19 @@ export type CredentialResolver = (ref: string) => string | undefined | Promise typeof process !== 'undefined' ? process.env?.[ref] : undefined; +/** + * Retry backoff for **degraded** declarative instances (#3017) — provider-bound + * entries whose upstream (e.g. an MCP server) was unreachable at + * materialization. First retry after {@link DECLARATIVE_RETRY_BASE_MS}, doubling + * per consecutive failure up to {@link DECLARATIVE_RETRY_MAX_MS}, so a server + * that comes up seconds after the app recovers fast while a long outage doesn't + * generate connection spam. A `metadata:reloaded` reconcile also retries + * immediately, and a config edit to the entry resets the backoff. + */ +export const DECLARATIVE_RETRY_BASE_MS = 5_000; +/** Ceiling for the degraded-instance retry backoff (#3017). */ +export const DECLARATIVE_RETRY_MAX_MS = 300_000; + /** * Deterministic JSON stringify (keys sorted at every level) so a signature is * stable regardless of authored key order — two materialization inputs that @@ -293,6 +308,20 @@ export class AutomationServicePlugin implements Plugin { * child process leaks. */ private materializedConnectors = new Map void | Promise }>(); + /** + * Degraded declarative instances (#3017): provider-bound entries whose + * upstream was unreachable at materialization. Keyed by connector name; + * `attempts` drives the retry backoff, `signature` detects a config edit + * (which resets the backoff), `reason` is what the registered husk surfaces. + * Disjoint from {@link materializedConnectors} EXCEPT when a *changed* + * entry's re-materialization failed — then the old live connector keeps + * serving (still tracked there) while the retry is pending here. + */ + private degradedInstances = new Map(); + private declarativeRetryTimer?: ReturnType; + /** Serializes reconcile runs — see {@link materializeDeclaredConnectors}. */ + private reconcileQueue: Promise = Promise.resolve(); + private destroyed = false; constructor(options: AutomationServicePluginOptions = {}) { this.options = options; @@ -458,6 +487,10 @@ export class AutomationServicePlugin implements Plugin { // boot loudly — a metadata platform shipping a plausible-but-dead connector is // the worst failure mode. A start()-phase throw is fatal under both LiteKernel // and ObjectKernel (rollbackOnFailure defaults on), so the operator sees it. + // One carve-out (#3017): a factory signalling its UPSTREAM is temporarily + // unreachable (CONNECTOR_UPSTREAM_UNAVAILABLE) degrades that one instance — + // registered action-less + retried with backoff — instead of failing boot; + // an operational blip in one integration must not take the whole app down. await this.materializeDeclaredConnectors(ctx, { fatal: true }); // ── Runtime re-bind: re-sync flow triggers on 'metadata:reloaded' ────── @@ -588,10 +621,34 @@ export class AutomationServicePlugin implements Plugin { * * Reads the same ObjectQL registry the descriptor audit uses; without one * there is nothing declared, hence nothing to reconcile. + * + * **Upstream-availability exception (#3017):** a provider factory that + * throws the `CONNECTOR_UPSTREAM_UNAVAILABLE` marker (e.g. `connector-mcp` + * when the MCP server is unreachable) is an *operational* fault, not a + * configuration fault — in BOTH modes the instance is **degraded** instead + * of failing the run: registered as an action-less husk (visible via + * `GET /connectors`, dispatch errors clearly) when nothing live holds the + * name, or the previous live connector keeps serving on a changed-config + * re-materialize. Degraded instances are retried with exponential backoff + * ({@link DECLARATIVE_RETRY_BASE_MS}) and on every reconcile. */ - private async materializeDeclaredConnectors(ctx: PluginContext, opts: { fatal: boolean }): Promise { + private materializeDeclaredConnectors(ctx: PluginContext, opts: { fatal: boolean }): Promise { + // Serialize runs: boot, `metadata:reloaded`, and the degraded-retry + // timer can all request a reconcile; concurrent runs would race the + // registry and the tracked maps. Each caller still observes its own + // run's failure (a fatal boot error propagates), while the chain itself + // is never left poisoned for the next caller. + const run = this.reconcileQueue.then(() => this.reconcileDeclaredConnectors(ctx, opts)); + this.reconcileQueue = run.catch(() => undefined); + return run; + } + + private async reconcileDeclaredConnectors(ctx: PluginContext, opts: { fatal: boolean }): Promise { const engine = this.engine; - if (!engine) return; + if (!engine || this.destroyed) return; + // This run supersedes any pending degraded-instance retry; it is + // rescheduled at the end if instances remain degraded. + this.clearDeclarativeRetryTimer(); let declared: unknown[] = []; try { @@ -632,13 +689,32 @@ export class AutomationServicePlugin implements Plugin { if (desired.has(name)) continue; await this.dematerializeConnector(engine, name, tracked, ctx); } + // Degraded instances (#3017) whose entry vanished / was disabled are + // dropped too: unregister the husk (idempotent — a kept-serving old + // connector was already torn down above) and forget the retry. + for (const name of [...this.degradedInstances.keys()]) { + if (desired.has(name)) continue; + this.degradedInstances.delete(name); + try { engine.unregisterConnector(name); } catch { /* ignore */ } + ctx.logger.info(`[Automation] dropped degraded connector instance '${name}' (no longer declared)`); + } // 2. Add new instances and re-materialize changed ones (signature diff). const resolver = this.options.credentialResolver ?? defaultEnvCredentialResolver; let changed = 0; for (const [name, { entry, signature }] of desired) { const existing = this.materializedConnectors.get(name); - if (existing && existing.signature === signature) continue; // unchanged — leave the live connector as-is + if (existing && existing.signature === signature) { + // Unchanged — leave the live connector as-is. If a retry was + // pending for a *changed* config that has since been reverted, + // the live connector is already the desired one: cancel it. + if (this.degradedInstances.delete(name)) { + ctx.logger.info( + `[Automation] connector instance '${name}' reverted to its live configuration; pending retry cancelled (#3017)`, + ); + } + continue; + } const provider = entry.provider; @@ -690,6 +766,20 @@ export class AutomationServicePlugin implements Plugin { try { materialization = await factory(providerCtx); } catch (err) { + // #3017 — an *operational* fault (upstream unreachable) degrades + // the one instance instead of failing the run, in BOTH modes; + // only configuration faults keep the fail-loud contract below. + if (isConnectorUpstreamUnavailable(err)) { + this.degradeConnectorInstance(engine, ctx, { + name, + entry, + provider, + signature, + hasLive: existing !== undefined, + reason: (err as Error).message, + }); + continue; + } fail( `[Automation] failed to materialize connector instance '${name}' via provider '${provider}': ` + `${(err as Error).message} (ADR-0097).`, @@ -710,10 +800,13 @@ export class AutomationServicePlugin implements Plugin { const def = { ...materialization.def, name }; engine.registerConnector(def, materialization.handlers, 'declarative'); this.materializedConnectors.set(name, { signature, close: materialization.close }); + // Success clears any pending degraded retry — including replacing a + // registered husk (registerConnector above overwrote it). + const recovered = this.degradedInstances.delete(name); changed++; ctx.logger.info( - `[Automation] ${existing ? 're-' : ''}materialized connector instance '${name}' via provider '${provider}' ` + - `(${def.actions?.length ?? 0} action(s))`, + `[Automation] ${recovered ? 'recovered' : existing ? 're-materialized' : 'materialized'} ` + + `connector instance '${name}' via provider '${provider}' (${def.actions?.length ?? 0} action(s))`, ); } @@ -722,6 +815,117 @@ export class AutomationServicePlugin implements Plugin { `[Automation] materialized ${changed} provider-bound connector instance(s) (ADR-0097)`, ); } + + // #3017 — instances still degraded after this run retry on a backoff. + this.scheduleDeclarativeRetry(ctx); + } + + /** + * Track one instance as degraded (#3017) and make the failure honest: + * unless a previously-materialized connector still holds the name (a + * changed-config re-materialize failure — the old connector keeps serving, + * same guarantee as every other reload failure), register an action-less + * husk so `GET /connectors` shows the instance with `state: 'degraded'` + * and a `connector_action` dispatch fails with the reason, rather than the + * instance silently missing. A config edit (signature change) resets the + * backoff — it is a different upstream/config now. + */ + private degradeConnectorInstance( + engine: AutomationEngine, + ctx: PluginContext, + info: { + name: string; + entry: DeclaredConnectorItem; + provider: string; + signature: string; + hasLive: boolean; + reason: string; + }, + ): void { + const prior = this.degradedInstances.get(info.name); + const attempts = prior && prior.signature === info.signature ? prior.attempts + 1 : 1; + this.degradedInstances.set(info.name, { attempts, signature: info.signature, reason: info.reason }); + + if (!info.hasLive) { + try { + engine.registerDegradedConnector( + this.buildDegradedHuskDef(info.name, info.entry), + info.reason, + 'declarative', + ); + } catch (err) { + // Can't even register the husk (e.g. the entry's def no longer + // parses) — the retry bookkeeping above still drives recovery. + ctx.logger.warn( + `[Automation] could not register degraded husk for '${info.name}': ${(err as Error).message}`, + ); + } + } + ctx.logger.error( + `[Automation] connector instance '${info.name}' (provider '${info.provider}') upstream unavailable — ` + + (info.hasLive + ? 'the previously-materialized connector keeps serving' + : 'instance registered degraded (no actions)') + + `; retrying with backoff, attempt ${attempts} (#3017): ${info.reason}`, + ); + } + + /** The action-less `status: 'error'` def a degraded instance registers (#3017). */ + private buildDegradedHuskDef(name: string, entry: DeclaredConnectorItem): Connector { + return { + name, + label: entry.label ?? name, + description: entry.description, + icon: entry.icon, + type: (typeof entry.type === 'string' ? entry.type : 'api') as Connector['type'], + // 'error' is the ConnectorStatusSchema value for "has errors" — the + // husk is honest metadata, not a dispatchable connector. + status: 'error', + enabled: true, + authentication: { type: 'none' }, + connectionTimeoutMs: 30000, + requestTimeoutMs: 30000, + actions: [], + }; + } + + /** Backoff for degraded-instance retries (#3017): base · 2^(attempts-1), capped. */ + private static declarativeRetryDelayMs(attempts: number): number { + return Math.min(DECLARATIVE_RETRY_BASE_MS * 2 ** Math.max(0, attempts - 1), DECLARATIVE_RETRY_MAX_MS); + } + + private clearDeclarativeRetryTimer(): void { + if (this.declarativeRetryTimer !== undefined) { + clearTimeout(this.declarativeRetryTimer); + this.declarativeRetryTimer = undefined; + } + } + + /** + * Arm one retry timer for the soonest-due degraded instance (#3017). The + * retry is a full soft reconcile — it re-reads the registry, so it also + * picks up whatever else changed — and reconcile re-arms the timer while + * anything stays degraded. `unref()` (where available) keeps the timer from + * holding the process open. + */ + private scheduleDeclarativeRetry(ctx: PluginContext): void { + this.clearDeclarativeRetryTimer(); + if (this.destroyed || this.degradedInstances.size === 0) return; + const delay = Math.min( + ...[...this.degradedInstances.values()].map((d) => + AutomationServicePlugin.declarativeRetryDelayMs(d.attempts), + ), + ); + const timer = setTimeout(() => { + this.declarativeRetryTimer = undefined; + // Always soft: a background retry must never crash a running server. + void this.materializeDeclaredConnectors(ctx, { fatal: false }); + }, delay); + (timer as unknown as { unref?: () => void }).unref?.(); + this.declarativeRetryTimer = timer; + ctx.logger.info( + `[Automation] ${this.degradedInstances.size} degraded connector instance(s); next retry in ${delay}ms (#3017)`, + ); } /** Unregister and tear down one materialized declarative connector (ADR-0097). */ @@ -920,6 +1124,11 @@ export class AutomationServicePlugin implements Plugin { } async destroy(): Promise { + // Stop the degraded-instance retry loop first (#3017): mark destroyed so + // an already-queued reconcile no-ops, and cancel any armed timer. + this.destroyed = true; + this.clearDeclarativeRetryTimer(); + this.degradedInstances.clear(); // Tear down materialized provider-bound connectors (ADR-0097) — e.g. an // MCP connection's close — in reverse registration order, best-effort, so // no socket / child process leaks. The engine (and its registry) is dropped diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index dd3f84e32e..4dc5762013 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3776,6 +3776,7 @@ "ApiVersionConfigSchema (const)", "BuildConfig (type)", "BuildConfigSchema (const)", + "CONNECTOR_UPSTREAM_UNAVAILABLE (const)", "CdcConfig (type)", "CdcConfigSchema (const)", "CircuitBreakerConfig (type)", @@ -3803,6 +3804,7 @@ "ConnectorTriggerSchema (const)", "ConnectorType (type)", "ConnectorTypeSchema (const)", + "ConnectorUpstreamUnavailableError (class)", "ConsumerConfig (type)", "ConsumerConfigSchema (const)", "DataSyncConfig (type)", @@ -3927,6 +3929,7 @@ "githubPublicConnectorExample (const)", "googleDriveConnectorExample (const)", "hubspotConnectorExample (const)", + "isConnectorUpstreamUnavailable (function)", "kafkaConnectorExample (const)", "mongoConnectorExample (const)", "postgresConnectorExample (const)", diff --git a/packages/spec/src/integration/connector-provider-errors.test.ts b/packages/spec/src/integration/connector-provider-errors.test.ts new file mode 100644 index 0000000000..304550a9d3 --- /dev/null +++ b/packages/spec/src/integration/connector-provider-errors.test.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// +// #3017 — the upstream-unavailable classification a provider factory uses to +// tell the materializer "degrade + retry this instance" instead of "fail boot". + +import { describe, it, expect } from 'vitest'; +import { + CONNECTOR_UPSTREAM_UNAVAILABLE, + ConnectorUpstreamUnavailableError, + isConnectorUpstreamUnavailable, +} from './connector-provider-errors'; + +describe('#3017 — connector provider upstream-unavailable classification', () => { + it('the error carries the marker code, a stable name, and the cause', () => { + const cause = new Error('connect ECONNREFUSED 127.0.0.1:9999'); + const err = new ConnectorUpstreamUnavailableError('mcp server unreachable', { cause }); + expect(err).toBeInstanceOf(Error); + expect(err.code).toBe(CONNECTOR_UPSTREAM_UNAVAILABLE); + expect(err.name).toBe('ConnectorUpstreamUnavailableError'); + expect(err.message).toBe('mcp server unreachable'); + expect(err.cause).toBe(cause); + }); + + it('the guard is structural: any error-like object carrying the code matches', () => { + expect(isConnectorUpstreamUnavailable(new ConnectorUpstreamUnavailableError('x'))).toBe(true); + // A duplicated module instance (package-manager double-install) produces a + // different class identity but the same code — must still classify. + expect(isConnectorUpstreamUnavailable({ code: CONNECTOR_UPSTREAM_UNAVAILABLE, message: 'x' })).toBe(true); + }); + + it('everything else stays on the fail-loud path', () => { + expect(isConnectorUpstreamUnavailable(new Error('providerConfig.spec is required'))).toBe(false); + expect(isConnectorUpstreamUnavailable({ code: 'SOMETHING_ELSE' })).toBe(false); + expect(isConnectorUpstreamUnavailable(undefined)).toBe(false); + expect(isConnectorUpstreamUnavailable(null)).toBe(false); + expect(isConnectorUpstreamUnavailable('CONNECTOR_UPSTREAM_UNAVAILABLE')).toBe(false); + }); +}); diff --git a/packages/spec/src/integration/connector-provider-errors.ts b/packages/spec/src/integration/connector-provider-errors.ts new file mode 100644 index 0000000000..611af81a29 --- /dev/null +++ b/packages/spec/src/integration/connector-provider-errors.ts @@ -0,0 +1,64 @@ +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Upstream-availability classification for connector provider factories + * (ADR-0097 follow-up, #3017). + * + * A {@link ConnectorProviderFactory} can fail for two very different reasons: + * + * 1. **Configuration faults** — invalid `providerConfig`, an unresolvable + * `credentialRef`, a name conflict. These are authoring mistakes; the + * materializer keeps them **fatal at boot** (ADR-0097 "fail loudly"). + * 2. **Operational faults** — the upstream the factory must contact to + * materialize (an MCP server's `tools/list`, a remote spec endpoint) is + * *temporarily unreachable*. Aborting the whole app boot for a transient + * network blip turns one degraded integration into a total outage. + * + * A factory signals case 2 by throwing an error whose `code` is + * {@link CONNECTOR_UPSTREAM_UNAVAILABLE} — most conveniently a + * {@link ConnectorUpstreamUnavailableError}. The materializer then **degrades** + * that one instance instead of failing boot: it is registered in a `degraded` + * state (visible via `GET /connectors`, dispatch fails with a clear error) and + * re-materialization is retried with backoff and on every reconcile. Errors + * without the marker keep the fail-loud contract unchanged. + * + * The check is **structural** ({@link isConnectorUpstreamUnavailable} reads + * `code`, never `instanceof`), so classification survives package-manager + * module duplication across the plugin/host boundary. + */ + +/** Marker `code` for an operational (retryable) provider-factory failure. */ +export const CONNECTOR_UPSTREAM_UNAVAILABLE = 'CONNECTOR_UPSTREAM_UNAVAILABLE' as const; + +/** + * Thrown by a connector provider factory when the upstream it must contact to + * materialize an instance is temporarily unreachable (connect refused/timeout, + * discovery call failed). Carries {@link CONNECTOR_UPSTREAM_UNAVAILABLE} as its + * `code`; keep the underlying failure in `cause` for diagnostics. + */ +export class ConnectorUpstreamUnavailableError extends Error { + readonly code = CONNECTOR_UPSTREAM_UNAVAILABLE; + /** The underlying failure (connect error, timeout), kept for diagnostics. */ + readonly cause?: unknown; + constructor(message: string, options?: { cause?: unknown }) { + // `cause` is assigned manually — the ES2022 ErrorOptions constructor + // overload is unavailable at this package's compile target. + super(message); + this.name = 'ConnectorUpstreamUnavailableError'; + if (options && 'cause' in options) this.cause = options.cause; + } +} + +/** + * Structural check for the {@link CONNECTOR_UPSTREAM_UNAVAILABLE} marker — used + * by the materializer to route a factory failure to the degrade path instead of + * the fatal path. Matches any error-like object carrying the `code`, not just + * `instanceof ConnectorUpstreamUnavailableError`. + */ +export function isConnectorUpstreamUnavailable(err: unknown): boolean { + return ( + typeof err === 'object' && + err !== null && + (err as { code?: unknown }).code === CONNECTOR_UPSTREAM_UNAVAILABLE + ); +} diff --git a/packages/spec/src/integration/connector-provider.ts b/packages/spec/src/integration/connector-provider.ts index 52befbcca9..65b58524fa 100644 --- a/packages/spec/src/integration/connector-provider.ts +++ b/packages/spec/src/integration/connector-provider.ts @@ -81,8 +81,12 @@ export interface ConnectorProviderContext { * A provider factory contributed by a connector plugin under a provider key. * Invoked once per declarative instance at boot; may be async (loading a spec * document, opening a connection). Throwing is a **hard boot error** — invalid - * `providerConfig`, an unreachable upstream, etc. surface loudly rather than - * yielding a silently-dead connector (ADR-0097 §Decision). + * `providerConfig`, an unresolvable credential, etc. surface loudly rather than + * yielding a silently-dead connector (ADR-0097 §Decision). One exception + * (#3017): a throw carrying the `CONNECTOR_UPSTREAM_UNAVAILABLE` code (see + * `connector-provider-errors.ts`) marks an *operational* fault — the upstream + * the factory must contact is temporarily unreachable — which the materializer + * degrades and retries instead of failing boot. */ export type ConnectorProviderFactory = ( ctx: ConnectorProviderContext, diff --git a/packages/spec/src/integration/index.ts b/packages/spec/src/integration/index.ts index 824ad0ca24..e0a83a0efe 100644 --- a/packages/spec/src/integration/index.ts +++ b/packages/spec/src/integration/index.ts @@ -17,6 +17,7 @@ export * from './connector.zod'; // Connector provider contract (ADR-0097) — declarative instances → live connectors export * from './connector-provider'; +export * from './connector-provider-errors'; // Connector Templates export * from './connector/saas.zod'; From 976a46d748647cac9324b7442df24719c8677094 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 12:40:12 +0000 Subject: [PATCH 2/4] =?UTF-8?q?feat(connector-mcp):=20policy-gate=20declar?= =?UTF-8?q?ative=20stdio=20transports=20=E2=80=94=20default-deny=20+=20hos?= =?UTF-8?q?t=20allowlist=20(#3055)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A declarative provider: 'mcp' entry with a stdio transport spawns a local child process from METADATA — which a runtime Studio publish can introduce (the reload reconcile materializes new instances; soft mode skips failures, not successes). Anyone who can publish metadata could otherwise execute arbitrary commands on the server. Declarative stdio is now denied by default and opt-in per host: new ConnectorMcpPlugin({ declarativeStdio: ['my-mcp-server'] }) // allowlist new ConnectorMcpPlugin({ declarativeStdio: true }) // full trust - createMcpProviderFactory gains a declarativeStdio policy (undefined/false = deny, string[] = strict-equality command allowlist, true = allow any); ConnectorMcpPlugin plumbs it through. New export McpDeclarativeStdioPolicy. - A violation is a CONFIGURATION fault: plain throw with an actionable opt-in message (fatal at boot, skipped+logged on reload) — never classified CONNECTOR_UPSTREAM_UNAVAILABLE, so a security rejection cannot be retried into existence (#3049 seam). - http transports are unaffected (same trust class as the http node), and so are hand-wired connectors (plugin instance options / createMcpConnector) — their command lives in host code, a different trust anchor than metadata. - ADR-0097 gains a 'Declarative stdio policy' section (cross-ref ADR-0024 §4, honest about the allowlist being a coarse boundary); this gate is the precondition for shipping connector-mcp in default presets (#3056). Tests: default-deny (plain, non-retryable, rejected before any connection attempt), allowlist hit/miss, true passthrough, http unaffected, plugin option plumb-through, hand-wired stdio path explicitly un-gated (e2e). Refs #3055. Stacked on #3049 (claude/adr-0097-mcp-nonfatal-boot). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pbmw3pMqNJfPbkvwh9Fkjs --- .changeset/adr-0097-stdio-policy-gate.md | 23 +++++++ .../0097-declarative-connector-instances.md | 25 +++++++ .../src/connector-mcp-plugin.test.ts | 31 +++++++++ .../connector-mcp/src/connector-mcp-plugin.ts | 23 ++++++- .../connectors/connector-mcp/src/index.ts | 1 + .../connector-mcp/src/mcp-provider.test.ts | 69 +++++++++++++++++-- .../connector-mcp/src/mcp-provider.ts | 59 ++++++++++++++++ 7 files changed, 224 insertions(+), 7 deletions(-) create mode 100644 .changeset/adr-0097-stdio-policy-gate.md diff --git a/.changeset/adr-0097-stdio-policy-gate.md b/.changeset/adr-0097-stdio-policy-gate.md new file mode 100644 index 0000000000..25e54ec108 --- /dev/null +++ b/.changeset/adr-0097-stdio-policy-gate.md @@ -0,0 +1,23 @@ +--- +'@objectstack/connector-mcp': minor +--- + +feat(connector-mcp)!: policy-gate declarative stdio transports — default-deny + host allowlist (#3055) + +A declarative `provider: 'mcp'` entry with a stdio transport spawns a local +child process **from metadata** — which a runtime Studio publish can introduce. +Declarative stdio is now **denied by default**; hosts opt in deliberately: + +```ts +new ConnectorMcpPlugin({ declarativeStdio: ['my-mcp-server'] }) // command allowlist +new ConnectorMcpPlugin({ declarativeStdio: true }) // allow any (full trust) +``` + +Behavior change: a declarative stdio instance that materialized before now +fails as a **configuration fault** (fatal at boot / skipped on reload) with an +actionable opt-in message, and is never classified upstream-unavailable — a +security rejection must not be retried into existence. `http` transports and +**hand-wired** connectors (plugin instance options / `createMcpConnector`) are +unaffected. This is the security precondition for shipping `connector-mcp` in +default presets (#3056); see ADR-0097 §"Declarative stdio policy" and +ADR-0024 §4. diff --git a/docs/adr/0097-declarative-connector-instances.md b/docs/adr/0097-declarative-connector-instances.md index 89a7039289..e44926051e 100644 --- a/docs/adr/0097-declarative-connector-instances.md +++ b/docs/adr/0097-declarative-connector-instances.md @@ -167,6 +167,31 @@ dispatch was rejected: MCP actions ARE the server's `tools/list`, so a connector materialized without connecting would register a zero-action def — exactly the plausible-but-dead shape this ADR exists to kill. +### Declarative stdio policy: default-deny (follow-up, landed — #3055) + +A declarative `provider: 'mcp'` entry may name a **stdio transport**, and +materializing one **spawns a local child process** — from *metadata*, which a +runtime Studio publish can introduce (the reload reconcile materializes new +instances; soft mode skips failures, not successes). Anyone who can publish +metadata could otherwise execute commands on the server. Stdio on declarative +instances is therefore **denied by default** and opt-in per host: + +```ts +new ConnectorMcpPlugin({ declarativeStdio: ['my-mcp-server'] }) // allowlist +new ConnectorMcpPlugin({ declarativeStdio: true }) // full trust +``` + +A policy violation is a **configuration fault** — plain throw (fatal at boot, +skipped on reload), never `CONNECTOR_UPSTREAM_UNAVAILABLE`: a security +rejection must not be retried into existence. `http` transports are unaffected +(same trust class as the `http` node), and so are **hand-wired** connectors +(plugin instance options / `createMcpConnector`) — their command lives in host +code, a different trust anchor than metadata. The allowlist is deliberately +honest about being a *coarse* boundary (allowlisting `npx` ≈ allowing any +package it can run); sandboxed execution remains the enterprise tier +(ADR-0024 §4). This gate is the precondition for shipping `connector-mcp` in +any default preset (#3056). + ### Deliberate scope boundaries - **`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. diff --git a/packages/connectors/connector-mcp/src/connector-mcp-plugin.test.ts b/packages/connectors/connector-mcp/src/connector-mcp-plugin.test.ts index f8a1029375..3102163601 100644 --- a/packages/connectors/connector-mcp/src/connector-mcp-plugin.test.ts +++ b/packages/connectors/connector-mcp/src/connector-mcp-plugin.test.ts @@ -41,6 +41,9 @@ describe('ConnectorMcpPlugin — end to end with the automation engine', () => { kernel.use(new AutomationServicePlugin()); kernel.use( new ConnectorMcpPlugin({ + // NOTE deliberately no `declarativeStdio`: the #3055 default-deny + // policy gates DECLARATIVE instances only — this hand-wired stdio + // transport (host code, not metadata) must keep working un-gated. name: 'github_mcp', label: 'GitHub MCP', transport: { kind: 'stdio', command: 'noop' }, @@ -93,4 +96,32 @@ describe('ConnectorMcpPlugin — end to end with the automation engine', () => { await kernel.shutdown(); expect(isClosed()).toBe(true); }); + + it('plumbs declarativeStdio through to the registered provider factory (#3055)', async () => { + const { client } = fakeClient(); + let registered: ((ctx: unknown) => Promise) | undefined; + const automationStub = { + registerConnector: () => {}, + unregisterConnector: () => {}, + registerConnectorProvider: (_key: string, factory: never) => { registered = factory; }, + }; + const plugin = new ConnectorMcpPlugin({ + clientFactory: async () => client, + declarativeStdio: ['trusted-mcp'], + }); + await plugin.init({ + getService: () => automationStub, + logger: { info: () => {}, warn: () => {} }, + } as never); + expect(registered).toBeDefined(); + + const provider = registered!; + const declarativeCtx = (command: string) => ({ + name: 'x', label: 'X', type: 'api', + providerConfig: { transport: { kind: 'stdio', command } }, + }); + // Allowlisted command materializes; anything else is denied by policy. + await expect(provider(declarativeCtx('trusted-mcp'))).resolves.toBeDefined(); + await expect(provider(declarativeCtx('bash'))).rejects.toThrow(/declarativeStdio allowlist/); + }); }); diff --git a/packages/connectors/connector-mcp/src/connector-mcp-plugin.ts b/packages/connectors/connector-mcp/src/connector-mcp-plugin.ts index a2b31d1cbd..a4cced126c 100644 --- a/packages/connectors/connector-mcp/src/connector-mcp-plugin.ts +++ b/packages/connectors/connector-mcp/src/connector-mcp-plugin.ts @@ -3,7 +3,11 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import type { Connector, ConnectorProviderFactory } from '@objectstack/spec/integration'; import { createMcpConnector, type McpConnectorOptions } from './mcp-connector.js'; -import { createMcpProviderFactory, MCP_PROVIDER_KEY } from './mcp-provider.js'; +import { + createMcpProviderFactory, + MCP_PROVIDER_KEY, + type McpDeclarativeStdioPolicy, +} from './mcp-provider.js'; /** * Minimal surface of the automation engine this plugin depends on — the @@ -29,7 +33,17 @@ export interface ConnectorRegistrySurface { * stack can declare `provider: 'mcp'` instances as pure metadata. Supply a * `transport` to ALSO connect one hand-wired MCP server at `start()`. */ -export interface ConnectorMcpPluginOptions extends Partial {} +export interface ConnectorMcpPluginOptions extends Partial { + /** + * Policy for stdio transports on **declarative** `provider: 'mcp'` + * instances (#3055). Default **deny**: metadata (including a runtime Studio + * publish) must not spawn local processes unless the host opts in. + * `string[]` allowlists specific commands; `true` allows any. Hand-wired + * connectors configured via these plugin options are not subject to it — + * their command lives in host code, not metadata. + */ + declarativeStdio?: McpDeclarativeStdioPolicy; +} /** * ConnectorMcpPlugin — contributes the generic MCP adapter (ADR-0024) in two forms: @@ -71,7 +85,10 @@ export class ConnectorMcpPlugin implements Plugin { if (automation && typeof automation.registerConnectorProvider === 'function') { automation.registerConnectorProvider( MCP_PROVIDER_KEY, - createMcpProviderFactory({ clientFactory: this.options.clientFactory }), + createMcpProviderFactory({ + clientFactory: this.options.clientFactory, + declarativeStdio: this.options.declarativeStdio, + }), ); ctx.logger.info("ConnectorMcpPlugin: registered 'mcp' connector provider"); } diff --git a/packages/connectors/connector-mcp/src/index.ts b/packages/connectors/connector-mcp/src/index.ts index d9919e4f2e..2999138e24 100644 --- a/packages/connectors/connector-mcp/src/index.ts +++ b/packages/connectors/connector-mcp/src/index.ts @@ -35,4 +35,5 @@ export { createMcpProviderFactory, MCP_PROVIDER_KEY, type McpProviderDeps, + type McpDeclarativeStdioPolicy, } from './mcp-provider.js'; diff --git a/packages/connectors/connector-mcp/src/mcp-provider.test.ts b/packages/connectors/connector-mcp/src/mcp-provider.test.ts index 639ebc7c7d..ccb27bca92 100644 --- a/packages/connectors/connector-mcp/src/mcp-provider.test.ts +++ b/packages/connectors/connector-mcp/src/mcp-provider.test.ts @@ -41,7 +41,8 @@ describe('mcp provider factory (ADR-0097)', () => { it('connects, lists tools, and maps them to actions', async () => { const { factory: clientFactory } = fakeClientFactory(); - const factory = createMcpProviderFactory({ clientFactory }); + // stdio on a declarative instance requires the host opt-in (#3055). + const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['my-mcp'] }); const mat = await factory(ctx({ providerConfig: { transport: { kind: 'stdio', command: 'my-mcp' } } })); expect(mat.def.name).toBe('github'); expect(Object.keys(mat.handlers).sort()).toEqual(['create_issue', 'list_issues']); @@ -50,7 +51,7 @@ describe('mcp provider factory (ADR-0097)', () => { it('applies the tool allowlist from providerConfig.include', async () => { const { factory: clientFactory } = fakeClientFactory(); - const factory = createMcpProviderFactory({ clientFactory }); + const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['my-mcp'] }); const mat = await factory( ctx({ providerConfig: { transport: { kind: 'stdio', command: 'my-mcp' }, include: ['create_issue'] } }), ); @@ -88,11 +89,12 @@ describe('mcp provider factory (ADR-0097)', () => { describe('mcp provider fault classification (#3017)', () => { const stdio = { transport: { kind: 'stdio', command: 'my-mcp' } }; + const allowMyMcp = { declarativeStdio: ['my-mcp'] }; it('classifies a connect failure as upstream-unavailable (retryable), keeping the cause', async () => { const boom = new Error('connect ECONNREFUSED 127.0.0.1:9999'); const clientFactory = async (): Promise => { throw boom; }; - const factory = createMcpProviderFactory({ clientFactory }); + const factory = createMcpProviderFactory({ clientFactory, ...allowMyMcp }); const err = await factory(ctx({ providerConfig: stdio })).then( () => { throw new Error('expected rejection'); }, @@ -111,7 +113,7 @@ describe('mcp provider fault classification (#3017)', () => { callTool: async () => ({}), close: async () => { closed = true; }, }); - const factory = createMcpProviderFactory({ clientFactory }); + const factory = createMcpProviderFactory({ clientFactory, ...allowMyMcp }); const err = await factory(ctx({ providerConfig: stdio })).then( () => { throw new Error('expected rejection'); }, @@ -130,3 +132,62 @@ describe('mcp provider fault classification (#3017)', () => { expect(isConnectorUpstreamUnavailable(err)).toBe(false); }); }); + +// ── #3055 — declarative stdio policy: default-deny + host allowlist ───────── +// +// A declarative stdio transport spawns a local process from metadata (a Studio +// publish reaches materialization at runtime), so it is gated OFF unless the +// host opts in. Violations are CONFIGURATION faults: plain throw (fatal at +// boot, skipped on reload) — never upstream-unavailable, which would retry a +// security rejection into existence. + +describe('mcp provider declarative stdio policy (#3055)', () => { + const stdioCfg = { transport: { kind: 'stdio', command: 'my-mcp' } }; + + it('DENIES a declarative stdio transport by default, as a plain (non-retryable) fault', async () => { + const { factory: clientFactory, seen } = fakeClientFactory(); + const factory = createMcpProviderFactory({ clientFactory }); // no policy + const err = await factory(ctx({ providerConfig: stdioCfg })).then( + () => { throw new Error('expected rejection'); }, + (e: unknown) => e, + ); + expect((err as Error).message).toMatch(/stdio transports are disabled by default/); + expect((err as Error).message).toContain("declarativeStdio: ['my-mcp']"); // actionable opt-in hint + expect(isConnectorUpstreamUnavailable(err)).toBe(false); + expect(seen.transport).toBeUndefined(); // rejected before any connection attempt + }); + + it('allowlist admits exactly the listed command and rejects others', async () => { + const { factory: clientFactory } = fakeClientFactory(); + const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['npx', 'my-mcp'] }); + const mat = await factory(ctx({ providerConfig: stdioCfg })); + expect(mat.def.name).toBe('github'); + + const err = await factory( + ctx({ providerConfig: { transport: { kind: 'stdio', command: 'bash' } } }), + ).then( + () => { throw new Error('expected rejection'); }, + (e: unknown) => e, + ); + expect((err as Error).message).toMatch(/not in the host's declarativeStdio allowlist \[npx, my-mcp\]/); + expect(isConnectorUpstreamUnavailable(err)).toBe(false); + }); + + it('declarativeStdio: true allows any command (explicit full trust)', async () => { + const { factory: clientFactory } = fakeClientFactory(); + const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: true }); + const mat = await factory( + ctx({ providerConfig: { transport: { kind: 'stdio', command: 'anything' } } }), + ); + expect(Object.keys(mat.handlers).length).toBeGreaterThan(0); + }); + + it('http transports are NOT subject to the policy', async () => { + const { factory: clientFactory } = fakeClientFactory(); + const factory = createMcpProviderFactory({ clientFactory }); // default-deny policy in force + const mat = await factory( + ctx({ providerConfig: { transport: { kind: 'http', url: 'https://mcp.example.com' } } }), + ); + expect(mat.def.name).toBe('github'); + }); +}); diff --git a/packages/connectors/connector-mcp/src/mcp-provider.ts b/packages/connectors/connector-mcp/src/mcp-provider.ts index 04924e02b9..0f8484abe8 100644 --- a/packages/connectors/connector-mcp/src/mcp-provider.ts +++ b/packages/connectors/connector-mcp/src/mcp-provider.ts @@ -10,10 +10,34 @@ import { createMcpConnector, type McpConnectorOptions, type McpTransport } from */ export const MCP_PROVIDER_KEY = 'mcp'; +/** + * Host policy for **declarative** stdio transports (#3055). A stdio transport + * launches a local child process, and declarative entries arrive through + * metadata — including a runtime Studio publish — so spawning from them is + * gated OFF by default: + * + * - `undefined` / `false` — deny (default): a `provider: 'mcp'` entry with a + * stdio transport is rejected as a configuration fault. + * - `string[]` — allowlist: the transport's `command` must strictly equal one + * of the listed commands. NOTE this is a coarse trust boundary — listing a + * launcher like `npx` effectively allows any package it can run; list the + * specific server binaries you trust. Sandboxed execution is the enterprise + * tier (ADR-0024 §4). + * - `true` — allow any command (explicit full trust; hosts that treat every + * metadata author as an operator). + * + * Hand-wired connectors (plugin instance options / `createMcpConnector`) are + * NOT subject to this policy: their command was written in host code, a + * different trust anchor than metadata. + */ +export type McpDeclarativeStdioPolicy = boolean | string[]; + /** Injectable dependencies for {@link createMcpProviderFactory} (tests). */ export interface McpProviderDeps { /** Injected MCP client factory; defaults to the SDK-backed client. */ clientFactory?: McpConnectorOptions['clientFactory']; + /** Policy for declarative stdio transports (#3055). Default: deny. */ + declarativeStdio?: McpDeclarativeStdioPolicy; } /** Shape of `providerConfig` for a `provider: 'mcp'` declarative instance. */ @@ -96,6 +120,35 @@ function normalizeTransport( ); } +/** + * Enforce the {@link McpDeclarativeStdioPolicy} for one declarative instance + * (#3055). Throws a **plain** Error on violation: a security-policy rejection + * is a configuration fault — fatal at boot, skipped+logged on reload — and must + * never be classified upstream-unavailable (it cannot be retried into + * existence). + */ +function assertDeclarativeStdioAllowed( + policy: McpDeclarativeStdioPolicy | undefined, + command: string, + connectorName: string, +): void { + if (policy === true) return; + if (Array.isArray(policy)) { + if (policy.includes(command)) return; + throw new Error( + `connector-mcp provider: connector '${connectorName}' declares a stdio transport with command '${command}', ` + + `which is not in the host's declarativeStdio allowlist [${policy.join(', ')}]. ` + + `Add the command to new ConnectorMcpPlugin({ declarativeStdio: [...] }) if this server is trusted (#3055).`, + ); + } + throw new Error( + `connector-mcp provider: connector '${connectorName}' declares a stdio transport (command '${command}'), ` + + `but declarative stdio transports are disabled by default — a stdio transport launches a local process ` + + `from stack metadata (including runtime Studio publishes). If this server is trusted, opt in deliberately: ` + + `new ConnectorMcpPlugin({ declarativeStdio: ['${command}'] }) — or use an http transport (#3055, ADR-0024 §4).`, + ); +} + /** * Build the `mcp` {@link ConnectorProviderFactory} (ADR-0097 / ADR-0024). At boot * the automation service invokes it for each `provider: 'mcp'` declarative @@ -104,6 +157,9 @@ function normalizeTransport( * {@link createMcpConnector} builds for a hand-wired MCP connector — one action * per tool, dispatched to the server's `tools/call`. * + * Stdio transports on declarative instances are policy-gated (default deny) — + * see {@link McpDeclarativeStdioPolicy} (#3055). + * * The connection is opened at materialization. Faults are classified (#3017): * an invalid transport shape is a *configuration* fault and throws plain — * fatal at boot per the ADR-0097 fail-loud contract — while a connect / @@ -116,6 +172,9 @@ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorP return async (ctx) => { const cfg = (ctx.providerConfig ?? {}) as McpProviderConfig; const transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth); + if (transport.kind === 'stdio') { + assertDeclarativeStdioAllowed(deps.declarativeStdio, transport.command, ctx.name); + } const includeList = Array.isArray(cfg.include) ? cfg.include.filter((x): x is string => typeof x === 'string') : undefined; From 48e8fa30fdcc98477a11e15a0e10292e0375ac13 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 13:54:44 +0000 Subject: [PATCH 3/4] feat: default preset ships the three generic connector executors + live provider:'mcp' showcase demo (#3056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the last authoring gap in ADR-0097's 'expressible AND executable as pure metadata' promise, and lands the provider:'mcp' acceptance demo the ADR originally envisioned (deferred at #3017). create-objectstack (blank template): - plugins: gains new ConnectorRestPlugin() / ConnectorOpenApiPlugin() / ConnectorMcpPlugin() (zero-arg = provider factory only), so a scaffolded app materializes declarative provider-bound connectors with no plugin-wiring step. README documents the default providers, credentialRef rule, and the #3055 declarativeStdio opt-in. Deps added at ^15.0.0. examples/app-showcase (live mcp demo): - scripts/mcp-fixture.mjs — tiny in-repo stdio MCP server (one deterministic echo_upper tool): no network, no ports, no boot-ordering coupling, so the demo is CI-deterministic. - DevToolsMcpConnector (provider: 'mcp', stdio transport 'node ./scripts/mcp-fixture.mjs') + ShowcaseMcpConnectorEchoFlow dispatching echo_upper end-to-end; coverage notes updated. - objectstack.config.ts wires new ConnectorMcpPlugin({ declarativeStdio: ['node'] }) — dogfooding the #3055 default-deny opt-in, with the coarse-boundary caveat spelled out. dogfood: - showcase-declarative-mcp.dogfood.test.ts boots the REAL showcase (chdir to the app root, exactly how os dev runs), injects the three executors, and pins: GET /connectors lists showcase_mcp_tools origin=declarative state=ready with echo_upper (rest + openapi siblings ready too; catalog descriptor absent), and the flow trigger round-trips OBJECTSTACK from the fixture's tools/call into the run output. docs: flows.mdx explains where connector_action connectors come from, the default preset, and the stdio opt-in. Verified: dogfood proof 2/2; turbo test dogfood+showcase+create-objectstack 63 tasks green; showcase validate (23 flows / 8 plugins) + typecheck clean; eslint clean; lockfile updated for the new deps. Refs #3056 (template + showcase + demo + docs; issue also tracks nothing further after this — see closing comment) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pbmw3pMqNJfPbkvwh9Fkjs --- .changeset/adr-0097-default-preset.md | 15 +++ content/docs/automation/flows.mdx | 2 + examples/app-showcase/objectstack.config.ts | 12 +++ examples/app-showcase/package.json | 2 + examples/app-showcase/scripts/mcp-fixture.mjs | 54 +++++++++++ .../src/automation/flows/index.ts | 50 ++++++++++ examples/app-showcase/src/coverage.ts | 2 +- .../src/system/connectors/index.ts | 46 ++++++++- .../src/templates/blank/README.md | 21 ++++ .../src/templates/blank/objectstack.config.ts | 18 ++++ .../src/templates/blank/package.json | 3 + packages/dogfood/package.json | 3 + .../showcase-declarative-mcp.dogfood.test.ts | 97 +++++++++++++++++++ pnpm-lock.yaml | 15 +++ 14 files changed, 338 insertions(+), 2 deletions(-) create mode 100644 .changeset/adr-0097-default-preset.md create mode 100644 examples/app-showcase/scripts/mcp-fixture.mjs create mode 100644 packages/dogfood/test/showcase-declarative-mcp.dogfood.test.ts diff --git a/.changeset/adr-0097-default-preset.md b/.changeset/adr-0097-default-preset.md new file mode 100644 index 0000000000..2b99ae1cc3 --- /dev/null +++ b/.changeset/adr-0097-default-preset.md @@ -0,0 +1,15 @@ +--- +'create-objectstack': minor +--- + +feat(create-objectstack): scaffold ships the three generic connector executors (#3056) + +The blank template's `plugins:` now includes `new ConnectorRestPlugin()`, +`new ConnectorOpenApiPlugin()`, and `new ConnectorMcpPlugin()` (zero-arg = +provider factory only, no hand-wired connector), so a scaffolded app can +declare provider-bound `connectors:` entries (ADR-0097) — `provider: 'rest' | +'openapi' | 'mcp'` — as pure metadata and have them materialize into live, +dispatchable connectors at boot, with no plugin-wiring step. The template +README documents the default providers, the `credentialRef` rule, and the +#3055 `declarativeStdio` opt-in (declarative stdio transports stay denied by +default). Remove unwanted executors by deleting a line from `plugins:`. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 42aa7280e5..375ac2b795 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -117,6 +117,8 @@ Each node performs a specific action in the flow. | `map` | Sequential multi-instance — run a per-item subflow for each element, one at a time (each may pause); batch approval (ADR-0039) | | `connector_action` | Execute an external connector action | +`connector_action` dispatches against the runtime **connector registry**, which connector plugins populate. Scaffolded apps ship the three **generic executors** — `rest`, `openapi`, and `mcp` — in their default `plugins:` (remove any you don't want), so a declarative `connectors:` entry that names a `provider` is materialized into a live, dispatchable connector at boot with no plugin code (ADR-0097). Brand connectors (Slack, …) are installed separately. One security note: a declarative `mcp` instance using a **stdio** transport spawns a local process from metadata and is therefore denied by default — the host opts in with `new ConnectorMcpPlugin({ declarativeStdio: [''] })`; `http` transports need no opt-in. + ### Node Structure ```typescript diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index 0d9b1f6eb2..efe0095d70 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { defineStack } from '@objectstack/spec'; +import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import { ConnectorSlackPlugin } from '@objectstack/connector-slack'; @@ -112,8 +113,19 @@ export default defineStack({ // (ADR-0097), which materializes the StatusOpenApiConnector // declarative instance below — its OpenAPI document is a // package-relative FILE PATH read at boot (#3016). + // • mcp → contributes the `mcp` provider factory, which materializes + // the DevToolsMcpConnector declarative instance from the + // in-repo stdio fixture (scripts/mcp-fixture.mjs). The + // `declarativeStdio` allowlist is the #3055 security opt-in: + // declarative stdio transports spawn a local process from + // METADATA, so they are denied by default; this host + // deliberately trusts `node` to run the in-repo fixture. + // (A coarse boundary by design — trusting `node` trusts what + // it is asked to run; real deployments should list specific + // server binaries.) plugins: [ new ConnectorOpenApiPlugin(), + new ConnectorMcpPlugin({ declarativeStdio: ['node'] }), new ConnectorRestPlugin({ name: 'rest', baseUrl: process.env.SHOWCASE_SELF_URL ?? 'http://127.0.0.1:3000', diff --git a/examples/app-showcase/package.json b/examples/app-showcase/package.json index e9abd25251..e92765c1ac 100644 --- a/examples/app-showcase/package.json +++ b/examples/app-showcase/package.json @@ -22,7 +22,9 @@ "test:smoke": "playwright test --config=playwright.config.ts" }, "dependencies": { + "@modelcontextprotocol/sdk": "^1.29.0", "@objectstack/cloud-connection": "workspace:*", + "@objectstack/connector-mcp": "workspace:*", "@objectstack/connector-openapi": "workspace:*", "@objectstack/connector-rest": "workspace:*", "@objectstack/connector-slack": "workspace:*", diff --git a/examples/app-showcase/scripts/mcp-fixture.mjs b/examples/app-showcase/scripts/mcp-fixture.mjs new file mode 100644 index 0000000000..cd9fd58555 --- /dev/null +++ b/examples/app-showcase/scripts/mcp-fixture.mjs @@ -0,0 +1,54 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// Tiny in-repo MCP server (stdio) — the deterministic target for the +// showcase's declarative `provider: 'mcp'` connector instance (#3056, +// completing the live demo deferred from #3017 / ADR-0097 §6). +// +// Why a fixture instead of a real server: the demo must materialize during +// BOOT in CI (the Dogfood Regression Gate), so the target has to exist with +// no network, no ports, and no boot-ordering coupling. A stdio child process +// spawned at materialization is exactly that. The spawn is allowlisted by the +// host via `new ConnectorMcpPlugin({ declarativeStdio: ['node'] })` in +// objectstack.config.ts — dogfooding the #3055 opt-in. +// +// One deliberately boring, deterministic tool: `echo_upper` upper-cases the +// input text, so the flow run's captured output proves the whole chain +// (metadata entry → mcp provider factory → tools/list → connector_action +// dispatch → tools/call) with zero flakiness. + +import { Server } from '@modelcontextprotocol/sdk/server/index.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js'; + +const server = new Server( + { name: 'showcase-mcp-fixture', version: '1.0.0' }, + { capabilities: { tools: {} } }, +); + +server.setRequestHandler(ListToolsRequestSchema, async () => ({ + tools: [ + { + name: 'echo_upper', + description: 'Upper-case the input text (deterministic showcase demo tool).', + inputSchema: { + type: 'object', + properties: { text: { type: 'string', description: 'Text to upper-case' } }, + required: ['text'], + }, + }, + ], +})); + +server.setRequestHandler(CallToolRequestSchema, async (req) => { + if (req.params.name !== 'echo_upper') { + return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true }; + } + const text = String(req.params.arguments?.text ?? ''); + const upper = text.toUpperCase(); + return { + content: [{ type: 'text', text: upper }], + structuredContent: { upper }, + }; +}); + +await server.connect(new StdioServerTransport()); diff --git a/examples/app-showcase/src/automation/flows/index.ts b/examples/app-showcase/src/automation/flows/index.ts index 08cc03ebcd..4e72355c9b 100644 --- a/examples/app-showcase/src/automation/flows/index.ts +++ b/examples/app-showcase/src/automation/flows/index.ts @@ -445,6 +445,55 @@ export const ShowcaseDeclarativeConnectorPingFlow = defineFlow({ ], }); +/** + * MCP Connector Echo (ADR-0097 / #3056) — dispatches through the DECLARATIVE + * MCP instance `showcase_mcp_tools` (src/system/connectors/index.ts), which the + * `mcp` provider materialized at boot from the in-repo stdio fixture server: + * its `tools/list` became the action list, and this `connector_action` invokes + * the `echo_upper` tool via `tools/call`. The run's captured output + * (`structuredContent.upper === 'OBJECTSTACK'`) proves the full chain — + * metadata entry → provider factory → MCP handshake → flow dispatch — with no + * external dependency. Completes the `provider: 'mcp'` acceptance demo from + * ADR-0097 §6, deferred at #3017. + */ +export const ShowcaseMcpConnectorEchoFlow = defineFlow({ + name: 'showcase_mcp_connector_echo', + label: 'MCP Connector Echo (ADR-0097)', + description: + 'Dispatches the echo_upper tool of showcase_mcp_tools — a declarative MCP connector instance materialized ' + + 'at boot from the in-repo stdio fixture (scripts/mcp-fixture.mjs).', + type: 'autolaunched', + // Surface the tool result on the run output, so the flow run view (and the + // dogfood proof) can assert the MCP round-trip observably. + variables: [{ name: 'echo.structuredContent', type: 'json', isOutput: true }], + nodes: [ + { + id: 'start', + type: 'start', + label: 'On Task Created', + config: { + objectName: 'showcase_task', + triggerType: 'record-after-create', + }, + }, + { + id: 'echo', + type: 'connector_action', + label: 'echo_upper via MCP (declarative)', + connectorConfig: { + connectorId: 'showcase_mcp_tools', + actionId: 'echo_upper', + input: { text: 'objectstack' }, + }, + }, + { id: 'end', type: 'end', label: 'End' }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'echo' }, + { id: 'e2', source: 'echo', target: 'end' }, + ], +}); + /** * Task Follow-up Reminder — the worked `wait` (durable timer) example. * @@ -1327,6 +1376,7 @@ export const allFlows = [ ScheduledDigestFlow, TaskCompletedRestPingFlow, ShowcaseDeclarativeConnectorPingFlow, + ShowcaseMcpConnectorEchoFlow, TaskFollowUpFlow, NotifyOwnerSubflow, TaskDoneNotifyOwnerFlow, diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 570157b61c..19bfc7e01e 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -184,7 +184,7 @@ export const STACK_COLLECTION_COVERAGE: Record = { status: 'demonstrated', files: ['src/system/connectors/index.ts', 'src/automation/flows/index.ts'], notes: - 'Both connector kinds are demonstrated. (1) Provider-bound INSTANCES (ADR-0097 / #2977): StatusApiConnector declares `provider: rest` (inline config) and StatusOpenApiConnector declares `provider: openapi` with its OpenAPI document referenced as a package-relative FILE PATH (#3016, read at boot and confined to the package root) — both are materialized into live, dispatchable connectors at boot; ShowcaseDeclarativeConnectorPingFlow calls the rest instance via connector_action and both appear in GET /connectors. (2) Catalog DESCRIPTOR (#2612): ErpCatalogConnector has no provider, so it stays inert metadata; enabled:false marks the deliberate catalog entry and silences the boot audit. Plugin-registered connectors (ConnectorRestPlugin/ConnectorSlackPlugin in objectstack.config.ts) are also exercised by the connector flows.', + 'Both connector kinds are demonstrated. (1) Provider-bound INSTANCES (ADR-0097 / #2977) cover all three generic executors: StatusApiConnector declares `provider: rest` (inline config), StatusOpenApiConnector declares `provider: openapi` with its OpenAPI document referenced as a package-relative FILE PATH (#3016, read at boot and confined to the package root), and DevToolsMcpConnector declares `provider: mcp` against the in-repo stdio fixture (scripts/mcp-fixture.mjs, #3056) — spawned under the host declarativeStdio allowlist (#3055) and mapped tools/list → actions. All three are materialized into live, dispatchable connectors at boot; ShowcaseDeclarativeConnectorPingFlow calls the rest instance and ShowcaseMcpConnectorEchoFlow calls the mcp instance via connector_action, and all appear in GET /connectors. (2) Catalog DESCRIPTOR (#2612): ErpCatalogConnector has no provider, so it stays inert metadata; enabled:false marks the deliberate catalog entry and silences the boot audit. Plugin-registered connectors (ConnectorRestPlugin/ConnectorSlackPlugin in objectstack.config.ts) are also exercised by the connector flows.', }, }; diff --git a/examples/app-showcase/src/system/connectors/index.ts b/examples/app-showcase/src/system/connectors/index.ts index 7e9a88c485..f97ccc20d5 100644 --- a/examples/app-showcase/src/system/connectors/index.ts +++ b/examples/app-showcase/src/system/connectors/index.ts @@ -91,6 +91,45 @@ export const StatusOpenApiConnector = defineConnector({ auth: { type: 'none' }, }); +/** + * ADR-0097 provider-bound instance, **mcp** form (#3056, completing the live + * demo deferred from #3017): the entry points at the tiny in-repo MCP fixture + * server (`scripts/mcp-fixture.mjs`) over a **stdio** transport. At boot the + * `mcp` provider factory (ConnectorMcpPlugin in objectstack.config.ts) spawns + * the fixture, calls `tools/list`, and maps its one deterministic tool + * (`echo_upper`) to a connector action — no network, no ports, no boot-ordering + * coupling, so the demo is CI-deterministic. + * + * Two platform behaviors are dogfooded here: + * - **#3055 stdio policy**: a declarative stdio transport spawns a local + * process from metadata, so it is denied by default; the host opts in via + * `new ConnectorMcpPlugin({ declarativeStdio: ['node'] })` in + * objectstack.config.ts. Remove that option and boot fails loudly with the + * opt-in instructions — try it. + * - **#3049 degrade + retry**: if the fixture were unreachable (e.g. the + * script path wrong), boot would NOT crash — the instance registers + * `state: 'degraded'` on GET /connectors and the platform retries with + * backoff until it heals. + */ +export const DevToolsMcpConnector = defineConnector({ + name: 'showcase_mcp_tools', + label: 'Dev Tools (Declarative MCP Instance)', + type: 'api', + description: + 'Provider-bound declarative connector instance (ADR-0097) backed by a Model Context Protocol server: the ' + + 'in-repo stdio fixture (scripts/mcp-fixture.mjs). Its tools/list becomes the action list — echo_upper is ' + + 'dispatched end-to-end by ShowcaseMcpConnectorEchoFlow.', + provider: 'mcp', + providerConfig: { + // Spawned at materialization; the path is relative to the app's working + // directory (`os dev`/`serve` run from the app root). The command must be + // allowlisted by the host's declarativeStdio policy (#3055) — see + // objectstack.config.ts. + transport: { kind: 'stdio', command: 'node', args: ['./scripts/mcp-fixture.mjs'] }, + }, + auth: { type: 'none' }, +}); + export const ErpCatalogConnector = defineConnector({ name: 'showcase_erp_catalog', label: 'ERP Integration (Catalog Descriptor)', @@ -143,4 +182,9 @@ export const ErpCatalogConnector = defineConnector({ enabled: false, }); -export const allConnectors: Connector[] = [StatusApiConnector, StatusOpenApiConnector, ErpCatalogConnector]; +export const allConnectors: Connector[] = [ + StatusApiConnector, + StatusOpenApiConnector, + DevToolsMcpConnector, + ErpCatalogConnector, +]; diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index 0b7746a6d1..06c9999e6d 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -26,6 +26,27 @@ curl -b cookies.txt "http://localhost:3000/api/v1/data/" - `objectstack.config.ts` — environment manifest (objects, API, plugins) - `src/objects/` — object definitions (one file per object) +## Connectors (default providers) + +The template ships the three generic connector executors — `rest`, `openapi`, +and `mcp` — in `plugins:`. With them installed, a declarative `connectors:` +entry that names a `provider` becomes a live, dispatchable connector at boot +(pure metadata, no plugin code): + +```ts +connectors: [{ + name: 'billing', + provider: 'openapi', + providerConfig: { spec: './specs/billing-openapi.json', baseUrl: 'https://billing.example.com' }, + auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' }, // env var name — never an inline secret +}] +``` + +Remove executors you don't want from `plugins:`. Note: a declarative `mcp` +instance over a **stdio** transport spawns a local process from metadata and is +denied by default — opt in with +`new ConnectorMcpPlugin({ declarativeStdio: [''] })`. + ## Verify your changes After editing any metadata, run: diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index 2927273a32..e0d9102c34 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -1,4 +1,7 @@ import { defineStack } from '@objectstack/spec'; +import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; +import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import * as objects from './src/objects/index.js'; export default defineStack({ @@ -16,4 +19,19 @@ export default defineStack({ engines: { protocol: '^15' }, }, objects: Object.values(objects), + + // The three GENERIC connector executors (rest / openapi / mcp) ship in the + // default preset (#3056, ADR-0097): with them installed, a declarative + // `connectors:` entry that names a `provider` materializes into a live, + // dispatchable connector at boot — integrations stay pure metadata, no + // plugin code required. Zero-arg = each contributes only its provider + // factory (an unused factory costs nothing at runtime). Remove any you + // don't want; brand connectors (Slack, …) are installed separately. + // + // Security note (#3055): a declarative `mcp` instance with a STDIO + // transport spawns a local process from metadata, so it is denied by + // default — opt in per host with + // `new ConnectorMcpPlugin({ declarativeStdio: [''] })`. + // http transports need no opt-in. + plugins: [new ConnectorRestPlugin(), new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin()], }); diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 564b9efae0..719a540786 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -12,6 +12,9 @@ }, "dependencies": { "@objectstack/spec": "^15.0.0", + "@objectstack/connector-mcp": "^15.0.0", + "@objectstack/connector-openapi": "^15.0.0", + "@objectstack/connector-rest": "^15.0.0", "@objectstack/runtime": "^15.0.0", "@objectstack/driver-memory": "^15.0.0", "@objectstack/plugin-hono-server": "^15.0.0" diff --git a/packages/dogfood/package.json b/packages/dogfood/package.json index 8ed3d48897..9ba3b743e1 100644 --- a/packages/dogfood/package.json +++ b/packages/dogfood/package.json @@ -9,6 +9,9 @@ "test": "vitest run" }, "dependencies": { + "@objectstack/connector-mcp": "workspace:*", + "@objectstack/connector-openapi": "workspace:*", + "@objectstack/connector-rest": "workspace:*", "@objectstack/example-crm": "workspace:*", "@objectstack/example-showcase": "workspace:*", "@objectstack/objectql": "workspace:*", diff --git a/packages/dogfood/test/showcase-declarative-mcp.dogfood.test.ts b/packages/dogfood/test/showcase-declarative-mcp.dogfood.test.ts new file mode 100644 index 0000000000..b7e6edc632 --- /dev/null +++ b/packages/dogfood/test/showcase-declarative-mcp.dogfood.test.ts @@ -0,0 +1,97 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// ADR-0097 §6 acceptance, `provider: 'mcp'` form (#3056; deferred from #3017): +// a declarative `connectors:` entry pointing at an MCP server — here the +// in-repo stdio fixture (examples/app-showcase/scripts/mcp-fixture.mjs) — is +// materialized at boot into a live connector (spawn → tools/list → actions) +// and dispatched end-to-end by a flow `connector_action`. Also pins: +// - #3055: the spawn only happens because the host allowlists `node` via +// `declarativeStdio` (remove it and boot fails loudly); +// - the GET /connectors surface: origin 'declarative' + state 'ready' for +// all three generic-executor instances (rest / openapi / mcp). +// +// cwd note: the fixture command (`node ./scripts/mcp-fixture.mjs`) and the +// openapi instance's file-path spec both resolve relative to the app root — +// exactly how `os dev`/`serve` run — so the boot chdirs there for its +// duration (vitest gives each test FILE its own process, so this is isolated). + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { fileURLToPath } from 'node:url'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; +import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; +import { ConnectorRestPlugin } from '@objectstack/connector-rest'; + +const SHOWCASE_DIR = fileURLToPath(new URL('../../../examples/app-showcase/', import.meta.url)); + +interface ConnectorDescriptor { + name: string; + origin?: string; + state?: string; + degradedReason?: string; + actions?: Array<{ key: string }>; +} + +describe('showcase declarative MCP connector — ADR-0097 §6 acceptance (#3056)', () => { + let stack: VerifyStack; + let token: string; + let prevCwd: string; + + beforeAll(async () => { + prevCwd = process.cwd(); + process.chdir(SHOWCASE_DIR); + // The three generic executors, exactly as objectstack.config.ts wires + // them (bootStack does not register a stack's `plugins:` — it mirrors + // the service pairs only — so the harness injects them here). + stack = await bootStack(showcaseStack, { + automation: true, + extraPlugins: [ + new ConnectorRestPlugin(), + new ConnectorOpenApiPlugin(), + new ConnectorMcpPlugin({ declarativeStdio: ['node'] }), + ], + }); + token = await stack.signIn(); + }, 120_000); + + afterAll(async () => { + await stack?.stop(); + process.chdir(prevCwd); + }); + + it('materializes all three generic-executor instances at boot — mcp included, state ready', async () => { + const res = await stack.apiAs(token, 'GET', '/automation/connectors'); + expect(res.status).toBeLessThan(300); + const body = (await res.json()) as { data?: { connectors?: ConnectorDescriptor[] } }; + const connectors = body.data?.connectors ?? []; + const byName = Object.fromEntries(connectors.map((c) => [c.name, c])); + + // The MCP instance: fixture spawned, tools/list mapped to actions. + const mcp = byName['showcase_mcp_tools']; + expect(mcp, `showcase_mcp_tools missing from registry: ${JSON.stringify(Object.keys(byName))}`).toBeDefined(); + expect(mcp.origin).toBe('declarative'); + expect(mcp.state, `mcp instance degraded: ${mcp.degradedReason ?? ''}`).toBe('ready'); + expect(mcp.actions?.map((a) => a.key)).toContain('echo_upper'); + + // Its rest / openapi siblings (ADR-0097 + #3016) on the same surface. + expect(byName['showcase_status_api']?.state).toBe('ready'); + expect(byName['showcase_status_openapi']?.state).toBe('ready'); + + // The catalog descriptor stays inert (never reaches this registry). + expect(byName['showcase_erp_catalog']).toBeUndefined(); + }); + + it('dispatches the MCP tool end-to-end through the flow connector_action', async () => { + const res = await stack.apiAs(token, 'POST', '/automation/showcase_mcp_connector_echo/trigger', {}); + expect(res.status, `trigger failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300); + const body = (await res.json()) as { + success?: boolean; + data?: { success?: boolean; error?: string; output?: Record }; + }; + expect(body.success).toBe(true); + expect(body.data?.success, `flow run failed: ${JSON.stringify(body.data)}`).toBe(true); + // The fixture's tools/call result round-tripped into the run output. + expect(JSON.stringify(body.data?.output ?? body.data)).toContain('OBJECTSTACK'); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3491255c0a..93a8b31490 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -153,9 +153,15 @@ importers: examples/app-showcase: dependencies: + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@4.4.3) '@objectstack/cloud-connection': specifier: workspace:* version: link:../../packages/cloud-connection + '@objectstack/connector-mcp': + specifier: workspace:* + version: link:../../packages/connectors/connector-mcp '@objectstack/connector-openapi': specifier: workspace:* version: link:../../packages/connectors/connector-openapi @@ -763,6 +769,15 @@ importers: packages/dogfood: dependencies: + '@objectstack/connector-mcp': + specifier: workspace:* + version: link:../connectors/connector-mcp + '@objectstack/connector-openapi': + specifier: workspace:* + version: link:../connectors/connector-openapi + '@objectstack/connector-rest': + specifier: workspace:* + version: link:../connectors/connector-rest '@objectstack/example-crm': specifier: workspace:* version: link:../../examples/app-crm From 7d92fd040da34e74b63a148b4beb02781db13ae5 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 13:59:49 +0000 Subject: [PATCH 4/4] =?UTF-8?q?narrow=20to=20showcase=20demo=20+=20dogfood?= =?UTF-8?q?=20proof=20=E2=80=94=20template=20preset=20waits=20for=20the=20?= =?UTF-8?q?15.1.0=20publish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scaffold-e2e job installs the generated app from the npm REGISTRY, and ConnectorOpenApiPlugin (and the #3055 declarativeStdio option) only exist in the unpublished 15.1.0 (queued in the Changesets release PR). A template must not reference unpublished APIs — the same failure would hit real 'npm create objectstack' users. Reverted the template files + changeset; once 15.1.0 publishes, the template's ^15.0.0 pins resolve to it and the preset piece lands as a small follow-up. flows.mdx reworded to describe the mechanism without the scaffold claim. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Pbmw3pMqNJfPbkvwh9Fkjs --- .changeset/adr-0097-default-preset.md | 15 ------------- content/docs/automation/flows.mdx | 2 +- .../src/templates/blank/README.md | 21 ------------------- .../src/templates/blank/objectstack.config.ts | 18 ---------------- .../src/templates/blank/package.json | 3 --- 5 files changed, 1 insertion(+), 58 deletions(-) delete mode 100644 .changeset/adr-0097-default-preset.md diff --git a/.changeset/adr-0097-default-preset.md b/.changeset/adr-0097-default-preset.md deleted file mode 100644 index 2b99ae1cc3..0000000000 --- a/.changeset/adr-0097-default-preset.md +++ /dev/null @@ -1,15 +0,0 @@ ---- -'create-objectstack': minor ---- - -feat(create-objectstack): scaffold ships the three generic connector executors (#3056) - -The blank template's `plugins:` now includes `new ConnectorRestPlugin()`, -`new ConnectorOpenApiPlugin()`, and `new ConnectorMcpPlugin()` (zero-arg = -provider factory only, no hand-wired connector), so a scaffolded app can -declare provider-bound `connectors:` entries (ADR-0097) — `provider: 'rest' | -'openapi' | 'mcp'` — as pure metadata and have them materialize into live, -dispatchable connectors at boot, with no plugin-wiring step. The template -README documents the default providers, the `credentialRef` rule, and the -#3055 `declarativeStdio` opt-in (declarative stdio transports stay denied by -default). Remove unwanted executors by deleting a line from `plugins:`. diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 375ac2b795..d8e5d986cf 100644 --- a/content/docs/automation/flows.mdx +++ b/content/docs/automation/flows.mdx @@ -117,7 +117,7 @@ Each node performs a specific action in the flow. | `map` | Sequential multi-instance — run a per-item subflow for each element, one at a time (each may pause); batch approval (ADR-0039) | | `connector_action` | Execute an external connector action | -`connector_action` dispatches against the runtime **connector registry**, which connector plugins populate. Scaffolded apps ship the three **generic executors** — `rest`, `openapi`, and `mcp` — in their default `plugins:` (remove any you don't want), so a declarative `connectors:` entry that names a `provider` is materialized into a live, dispatchable connector at boot with no plugin code (ADR-0097). Brand connectors (Slack, …) are installed separately. One security note: a declarative `mcp` instance using a **stdio** transport spawns a local process from metadata and is therefore denied by default — the host opts in with `new ConnectorMcpPlugin({ declarativeStdio: [''] })`; `http` transports need no opt-in. +`connector_action` dispatches against the runtime **connector registry**, which connector plugins populate. The three **generic executors** — `rest`, `openapi`, and `mcp` — double as provider factories: with them in your app's `plugins:`, a declarative `connectors:` entry that names a `provider` is materialized into a live, dispatchable connector at boot with no plugin code (ADR-0097; the showcase example wires all three). Brand connectors (Slack, …) are installed separately. One security note: a declarative `mcp` instance using a **stdio** transport spawns a local process from metadata and is therefore denied by default — the host opts in with `new ConnectorMcpPlugin({ declarativeStdio: [''] })`; `http` transports need no opt-in. ### Node Structure diff --git a/packages/create-objectstack/src/templates/blank/README.md b/packages/create-objectstack/src/templates/blank/README.md index 06c9999e6d..0b7746a6d1 100644 --- a/packages/create-objectstack/src/templates/blank/README.md +++ b/packages/create-objectstack/src/templates/blank/README.md @@ -26,27 +26,6 @@ curl -b cookies.txt "http://localhost:3000/api/v1/data/" - `objectstack.config.ts` — environment manifest (objects, API, plugins) - `src/objects/` — object definitions (one file per object) -## Connectors (default providers) - -The template ships the three generic connector executors — `rest`, `openapi`, -and `mcp` — in `plugins:`. With them installed, a declarative `connectors:` -entry that names a `provider` becomes a live, dispatchable connector at boot -(pure metadata, no plugin code): - -```ts -connectors: [{ - name: 'billing', - provider: 'openapi', - providerConfig: { spec: './specs/billing-openapi.json', baseUrl: 'https://billing.example.com' }, - auth: { type: 'bearer', credentialRef: 'BILLING_API_TOKEN' }, // env var name — never an inline secret -}] -``` - -Remove executors you don't want from `plugins:`. Note: a declarative `mcp` -instance over a **stdio** transport spawns a local process from metadata and is -denied by default — opt in with -`new ConnectorMcpPlugin({ declarativeStdio: [''] })`. - ## Verify your changes After editing any metadata, run: diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index e0d9102c34..2927273a32 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -1,7 +1,4 @@ import { defineStack } from '@objectstack/spec'; -import { ConnectorMcpPlugin } from '@objectstack/connector-mcp'; -import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi'; -import { ConnectorRestPlugin } from '@objectstack/connector-rest'; import * as objects from './src/objects/index.js'; export default defineStack({ @@ -19,19 +16,4 @@ export default defineStack({ engines: { protocol: '^15' }, }, objects: Object.values(objects), - - // The three GENERIC connector executors (rest / openapi / mcp) ship in the - // default preset (#3056, ADR-0097): with them installed, a declarative - // `connectors:` entry that names a `provider` materializes into a live, - // dispatchable connector at boot — integrations stay pure metadata, no - // plugin code required. Zero-arg = each contributes only its provider - // factory (an unused factory costs nothing at runtime). Remove any you - // don't want; brand connectors (Slack, …) are installed separately. - // - // Security note (#3055): a declarative `mcp` instance with a STDIO - // transport spawns a local process from metadata, so it is denied by - // default — opt in per host with - // `new ConnectorMcpPlugin({ declarativeStdio: [''] })`. - // http transports need no opt-in. - plugins: [new ConnectorRestPlugin(), new ConnectorOpenApiPlugin(), new ConnectorMcpPlugin()], }); diff --git a/packages/create-objectstack/src/templates/blank/package.json b/packages/create-objectstack/src/templates/blank/package.json index 719a540786..564b9efae0 100644 --- a/packages/create-objectstack/src/templates/blank/package.json +++ b/packages/create-objectstack/src/templates/blank/package.json @@ -12,9 +12,6 @@ }, "dependencies": { "@objectstack/spec": "^15.0.0", - "@objectstack/connector-mcp": "^15.0.0", - "@objectstack/connector-openapi": "^15.0.0", - "@objectstack/connector-rest": "^15.0.0", "@objectstack/runtime": "^15.0.0", "@objectstack/driver-memory": "^15.0.0", "@objectstack/plugin-hono-server": "^15.0.0"