diff --git a/content/docs/automation/flows.mdx b/content/docs/automation/flows.mdx index 42aa7280e5..d8e5d986cf 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. 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 ```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/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