Skip to content

Commit f8663b8

Browse files
authored
feat(showcase): live provider:'mcp' declarative connector demo + CI-pinned dogfood proof (#3056) (#3062)
In-repo stdio MCP fixture (echo_upper) + DevToolsMcpConnector (provider:'mcp') + end-to-end connector_action flow; ConnectorMcpPlugin({declarativeStdio:['node']}) dogfoods the #3055 opt-in. New dogfood test boots the real showcase and pins materialization (origin declarative, state ready, echo_upper listed) and dispatch (OBJECTSTACK round-trip). Completes the ADR-0097 §6 mcp acceptance demo deferred from #3017. Template-preset half of #3056 waits for the 15.1.0 publish (scaffold-e2e installs from the registry).
1 parent 90f0a56 commit f8663b8

10 files changed

Lines changed: 281 additions & 2 deletions

File tree

content/docs/automation/flows.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@ Each node performs a specific action in the flow.
117117
| `map` | Sequential multi-instance — run a per-item subflow for each element, one at a time (each may pause); batch approval (ADR-0039) |
118118
| `connector_action` | Execute an external connector action |
119119

120+
`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: ['<trusted-command>'] })`; `http` transports need no opt-in.
121+
120122
### Node Structure
121123

122124
```typescript

examples/app-showcase/objectstack.config.ts

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

33
import { defineStack } from '@objectstack/spec';
4+
import { ConnectorMcpPlugin } from '@objectstack/connector-mcp';
45
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
56
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
67
import { ConnectorSlackPlugin } from '@objectstack/connector-slack';
@@ -112,8 +113,19 @@ export default defineStack({
112113
// (ADR-0097), which materializes the StatusOpenApiConnector
113114
// declarative instance below — its OpenAPI document is a
114115
// package-relative FILE PATH read at boot (#3016).
116+
// • mcp → contributes the `mcp` provider factory, which materializes
117+
// the DevToolsMcpConnector declarative instance from the
118+
// in-repo stdio fixture (scripts/mcp-fixture.mjs). The
119+
// `declarativeStdio` allowlist is the #3055 security opt-in:
120+
// declarative stdio transports spawn a local process from
121+
// METADATA, so they are denied by default; this host
122+
// deliberately trusts `node` to run the in-repo fixture.
123+
// (A coarse boundary by design — trusting `node` trusts what
124+
// it is asked to run; real deployments should list specific
125+
// server binaries.)
115126
plugins: [
116127
new ConnectorOpenApiPlugin(),
128+
new ConnectorMcpPlugin({ declarativeStdio: ['node'] }),
117129
new ConnectorRestPlugin({
118130
name: 'rest',
119131
baseUrl: process.env.SHOWCASE_SELF_URL ?? 'http://127.0.0.1:3000',

examples/app-showcase/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,9 @@
2222
"test:smoke": "playwright test --config=playwright.config.ts"
2323
},
2424
"dependencies": {
25+
"@modelcontextprotocol/sdk": "^1.29.0",
2526
"@objectstack/cloud-connection": "workspace:*",
27+
"@objectstack/connector-mcp": "workspace:*",
2628
"@objectstack/connector-openapi": "workspace:*",
2729
"@objectstack/connector-rest": "workspace:*",
2830
"@objectstack/connector-slack": "workspace:*",
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// Tiny in-repo MCP server (stdio) — the deterministic target for the
4+
// showcase's declarative `provider: 'mcp'` connector instance (#3056,
5+
// completing the live demo deferred from #3017 / ADR-0097 §6).
6+
//
7+
// Why a fixture instead of a real server: the demo must materialize during
8+
// BOOT in CI (the Dogfood Regression Gate), so the target has to exist with
9+
// no network, no ports, and no boot-ordering coupling. A stdio child process
10+
// spawned at materialization is exactly that. The spawn is allowlisted by the
11+
// host via `new ConnectorMcpPlugin({ declarativeStdio: ['node'] })` in
12+
// objectstack.config.ts — dogfooding the #3055 opt-in.
13+
//
14+
// One deliberately boring, deterministic tool: `echo_upper` upper-cases the
15+
// input text, so the flow run's captured output proves the whole chain
16+
// (metadata entry → mcp provider factory → tools/list → connector_action
17+
// dispatch → tools/call) with zero flakiness.
18+
19+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
20+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
21+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
22+
23+
const server = new Server(
24+
{ name: 'showcase-mcp-fixture', version: '1.0.0' },
25+
{ capabilities: { tools: {} } },
26+
);
27+
28+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
29+
tools: [
30+
{
31+
name: 'echo_upper',
32+
description: 'Upper-case the input text (deterministic showcase demo tool).',
33+
inputSchema: {
34+
type: 'object',
35+
properties: { text: { type: 'string', description: 'Text to upper-case' } },
36+
required: ['text'],
37+
},
38+
},
39+
],
40+
}));
41+
42+
server.setRequestHandler(CallToolRequestSchema, async (req) => {
43+
if (req.params.name !== 'echo_upper') {
44+
return { content: [{ type: 'text', text: `unknown tool: ${req.params.name}` }], isError: true };
45+
}
46+
const text = String(req.params.arguments?.text ?? '');
47+
const upper = text.toUpperCase();
48+
return {
49+
content: [{ type: 'text', text: upper }],
50+
structuredContent: { upper },
51+
};
52+
});
53+
54+
await server.connect(new StdioServerTransport());

examples/app-showcase/src/automation/flows/index.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -445,6 +445,55 @@ export const ShowcaseDeclarativeConnectorPingFlow = defineFlow({
445445
],
446446
});
447447

448+
/**
449+
* MCP Connector Echo (ADR-0097 / #3056) — dispatches through the DECLARATIVE
450+
* MCP instance `showcase_mcp_tools` (src/system/connectors/index.ts), which the
451+
* `mcp` provider materialized at boot from the in-repo stdio fixture server:
452+
* its `tools/list` became the action list, and this `connector_action` invokes
453+
* the `echo_upper` tool via `tools/call`. The run's captured output
454+
* (`structuredContent.upper === 'OBJECTSTACK'`) proves the full chain —
455+
* metadata entry → provider factory → MCP handshake → flow dispatch — with no
456+
* external dependency. Completes the `provider: 'mcp'` acceptance demo from
457+
* ADR-0097 §6, deferred at #3017.
458+
*/
459+
export const ShowcaseMcpConnectorEchoFlow = defineFlow({
460+
name: 'showcase_mcp_connector_echo',
461+
label: 'MCP Connector Echo (ADR-0097)',
462+
description:
463+
'Dispatches the echo_upper tool of showcase_mcp_tools — a declarative MCP connector instance materialized ' +
464+
'at boot from the in-repo stdio fixture (scripts/mcp-fixture.mjs).',
465+
type: 'autolaunched',
466+
// Surface the tool result on the run output, so the flow run view (and the
467+
// dogfood proof) can assert the MCP round-trip observably.
468+
variables: [{ name: 'echo.structuredContent', type: 'json', isOutput: true }],
469+
nodes: [
470+
{
471+
id: 'start',
472+
type: 'start',
473+
label: 'On Task Created',
474+
config: {
475+
objectName: 'showcase_task',
476+
triggerType: 'record-after-create',
477+
},
478+
},
479+
{
480+
id: 'echo',
481+
type: 'connector_action',
482+
label: 'echo_upper via MCP (declarative)',
483+
connectorConfig: {
484+
connectorId: 'showcase_mcp_tools',
485+
actionId: 'echo_upper',
486+
input: { text: 'objectstack' },
487+
},
488+
},
489+
{ id: 'end', type: 'end', label: 'End' },
490+
],
491+
edges: [
492+
{ id: 'e1', source: 'start', target: 'echo' },
493+
{ id: 'e2', source: 'echo', target: 'end' },
494+
],
495+
});
496+
448497
/**
449498
* Task Follow-up Reminder — the worked `wait` (durable timer) example.
450499
*
@@ -1327,6 +1376,7 @@ export const allFlows = [
13271376
ScheduledDigestFlow,
13281377
TaskCompletedRestPingFlow,
13291378
ShowcaseDeclarativeConnectorPingFlow,
1379+
ShowcaseMcpConnectorEchoFlow,
13301380
TaskFollowUpFlow,
13311381
NotifyOwnerSubflow,
13321382
TaskDoneNotifyOwnerFlow,

examples/app-showcase/src/coverage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -184,7 +184,7 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
184184
status: 'demonstrated',
185185
files: ['src/system/connectors/index.ts', 'src/automation/flows/index.ts'],
186186
notes:
187-
'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.',
187+
'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.',
188188
},
189189
};
190190

examples/app-showcase/src/system/connectors/index.ts

Lines changed: 45 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,45 @@ export const StatusOpenApiConnector = defineConnector({
9191
auth: { type: 'none' },
9292
});
9393

94+
/**
95+
* ADR-0097 provider-bound instance, **mcp** form (#3056, completing the live
96+
* demo deferred from #3017): the entry points at the tiny in-repo MCP fixture
97+
* server (`scripts/mcp-fixture.mjs`) over a **stdio** transport. At boot the
98+
* `mcp` provider factory (ConnectorMcpPlugin in objectstack.config.ts) spawns
99+
* the fixture, calls `tools/list`, and maps its one deterministic tool
100+
* (`echo_upper`) to a connector action — no network, no ports, no boot-ordering
101+
* coupling, so the demo is CI-deterministic.
102+
*
103+
* Two platform behaviors are dogfooded here:
104+
* - **#3055 stdio policy**: a declarative stdio transport spawns a local
105+
* process from metadata, so it is denied by default; the host opts in via
106+
* `new ConnectorMcpPlugin({ declarativeStdio: ['node'] })` in
107+
* objectstack.config.ts. Remove that option and boot fails loudly with the
108+
* opt-in instructions — try it.
109+
* - **#3049 degrade + retry**: if the fixture were unreachable (e.g. the
110+
* script path wrong), boot would NOT crash — the instance registers
111+
* `state: 'degraded'` on GET /connectors and the platform retries with
112+
* backoff until it heals.
113+
*/
114+
export const DevToolsMcpConnector = defineConnector({
115+
name: 'showcase_mcp_tools',
116+
label: 'Dev Tools (Declarative MCP Instance)',
117+
type: 'api',
118+
description:
119+
'Provider-bound declarative connector instance (ADR-0097) backed by a Model Context Protocol server: the ' +
120+
'in-repo stdio fixture (scripts/mcp-fixture.mjs). Its tools/list becomes the action list — echo_upper is ' +
121+
'dispatched end-to-end by ShowcaseMcpConnectorEchoFlow.',
122+
provider: 'mcp',
123+
providerConfig: {
124+
// Spawned at materialization; the path is relative to the app's working
125+
// directory (`os dev`/`serve` run from the app root). The command must be
126+
// allowlisted by the host's declarativeStdio policy (#3055) — see
127+
// objectstack.config.ts.
128+
transport: { kind: 'stdio', command: 'node', args: ['./scripts/mcp-fixture.mjs'] },
129+
},
130+
auth: { type: 'none' },
131+
});
132+
94133
export const ErpCatalogConnector = defineConnector({
95134
name: 'showcase_erp_catalog',
96135
label: 'ERP Integration (Catalog Descriptor)',
@@ -143,4 +182,9 @@ export const ErpCatalogConnector = defineConnector({
143182
enabled: false,
144183
});
145184

146-
export const allConnectors: Connector[] = [StatusApiConnector, StatusOpenApiConnector, ErpCatalogConnector];
185+
export const allConnectors: Connector[] = [
186+
StatusApiConnector,
187+
StatusOpenApiConnector,
188+
DevToolsMcpConnector,
189+
ErpCatalogConnector,
190+
];

packages/dogfood/package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
"test": "vitest run"
1010
},
1111
"dependencies": {
12+
"@objectstack/connector-mcp": "workspace:*",
13+
"@objectstack/connector-openapi": "workspace:*",
14+
"@objectstack/connector-rest": "workspace:*",
1215
"@objectstack/example-crm": "workspace:*",
1316
"@objectstack/example-showcase": "workspace:*",
1417
"@objectstack/objectql": "workspace:*",
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0097 §6 acceptance, `provider: 'mcp'` form (#3056; deferred from #3017):
4+
// a declarative `connectors:` entry pointing at an MCP server — here the
5+
// in-repo stdio fixture (examples/app-showcase/scripts/mcp-fixture.mjs) — is
6+
// materialized at boot into a live connector (spawn → tools/list → actions)
7+
// and dispatched end-to-end by a flow `connector_action`. Also pins:
8+
// - #3055: the spawn only happens because the host allowlists `node` via
9+
// `declarativeStdio` (remove it and boot fails loudly);
10+
// - the GET /connectors surface: origin 'declarative' + state 'ready' for
11+
// all three generic-executor instances (rest / openapi / mcp).
12+
//
13+
// cwd note: the fixture command (`node ./scripts/mcp-fixture.mjs`) and the
14+
// openapi instance's file-path spec both resolve relative to the app root —
15+
// exactly how `os dev`/`serve` run — so the boot chdirs there for its
16+
// duration (vitest gives each test FILE its own process, so this is isolated).
17+
18+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
19+
import { fileURLToPath } from 'node:url';
20+
import showcaseStack from '@objectstack/example-showcase';
21+
import { bootStack, type VerifyStack } from '@objectstack/verify';
22+
import { ConnectorMcpPlugin } from '@objectstack/connector-mcp';
23+
import { ConnectorOpenApiPlugin } from '@objectstack/connector-openapi';
24+
import { ConnectorRestPlugin } from '@objectstack/connector-rest';
25+
26+
const SHOWCASE_DIR = fileURLToPath(new URL('../../../examples/app-showcase/', import.meta.url));
27+
28+
interface ConnectorDescriptor {
29+
name: string;
30+
origin?: string;
31+
state?: string;
32+
degradedReason?: string;
33+
actions?: Array<{ key: string }>;
34+
}
35+
36+
describe('showcase declarative MCP connector — ADR-0097 §6 acceptance (#3056)', () => {
37+
let stack: VerifyStack;
38+
let token: string;
39+
let prevCwd: string;
40+
41+
beforeAll(async () => {
42+
prevCwd = process.cwd();
43+
process.chdir(SHOWCASE_DIR);
44+
// The three generic executors, exactly as objectstack.config.ts wires
45+
// them (bootStack does not register a stack's `plugins:` — it mirrors
46+
// the service pairs only — so the harness injects them here).
47+
stack = await bootStack(showcaseStack, {
48+
automation: true,
49+
extraPlugins: [
50+
new ConnectorRestPlugin(),
51+
new ConnectorOpenApiPlugin(),
52+
new ConnectorMcpPlugin({ declarativeStdio: ['node'] }),
53+
],
54+
});
55+
token = await stack.signIn();
56+
}, 120_000);
57+
58+
afterAll(async () => {
59+
await stack?.stop();
60+
process.chdir(prevCwd);
61+
});
62+
63+
it('materializes all three generic-executor instances at boot — mcp included, state ready', async () => {
64+
const res = await stack.apiAs(token, 'GET', '/automation/connectors');
65+
expect(res.status).toBeLessThan(300);
66+
const body = (await res.json()) as { data?: { connectors?: ConnectorDescriptor[] } };
67+
const connectors = body.data?.connectors ?? [];
68+
const byName = Object.fromEntries(connectors.map((c) => [c.name, c]));
69+
70+
// The MCP instance: fixture spawned, tools/list mapped to actions.
71+
const mcp = byName['showcase_mcp_tools'];
72+
expect(mcp, `showcase_mcp_tools missing from registry: ${JSON.stringify(Object.keys(byName))}`).toBeDefined();
73+
expect(mcp.origin).toBe('declarative');
74+
expect(mcp.state, `mcp instance degraded: ${mcp.degradedReason ?? ''}`).toBe('ready');
75+
expect(mcp.actions?.map((a) => a.key)).toContain('echo_upper');
76+
77+
// Its rest / openapi siblings (ADR-0097 + #3016) on the same surface.
78+
expect(byName['showcase_status_api']?.state).toBe('ready');
79+
expect(byName['showcase_status_openapi']?.state).toBe('ready');
80+
81+
// The catalog descriptor stays inert (never reaches this registry).
82+
expect(byName['showcase_erp_catalog']).toBeUndefined();
83+
});
84+
85+
it('dispatches the MCP tool end-to-end through the flow connector_action', async () => {
86+
const res = await stack.apiAs(token, 'POST', '/automation/showcase_mcp_connector_echo/trigger', {});
87+
expect(res.status, `trigger failed: ${res.status} ${await res.clone().text()}`).toBeLessThan(300);
88+
const body = (await res.json()) as {
89+
success?: boolean;
90+
data?: { success?: boolean; error?: string; output?: Record<string, unknown> };
91+
};
92+
expect(body.success).toBe(true);
93+
expect(body.data?.success, `flow run failed: ${JSON.stringify(body.data)}`).toBe(true);
94+
// The fixture's tools/call result round-tripped into the run output.
95+
expect(JSON.stringify(body.data?.output ?? body.data)).toContain('OBJECTSTACK');
96+
});
97+
});

pnpm-lock.yaml

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)