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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions content/docs/automation/flows.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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: ['<trusted-command>'] })`; `http` transports need no opt-in.

### Node Structure

```typescript
Expand Down
12 changes: 12 additions & 0 deletions examples/app-showcase/objectstack.config.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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',
Expand Down
2 changes: 2 additions & 0 deletions examples/app-showcase/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
54 changes: 54 additions & 0 deletions examples/app-showcase/scripts/mcp-fixture.mjs
Original file line number Diff line number Diff line change
@@ -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());
50 changes: 50 additions & 0 deletions examples/app-showcase/src/automation/flows/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -1327,6 +1376,7 @@ export const allFlows = [
ScheduledDigestFlow,
TaskCompletedRestPingFlow,
ShowcaseDeclarativeConnectorPingFlow,
ShowcaseMcpConnectorEchoFlow,
TaskFollowUpFlow,
NotifyOwnerSubflow,
TaskDoneNotifyOwnerFlow,
Expand Down
2 changes: 1 addition & 1 deletion examples/app-showcase/src/coverage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,7 @@ export const STACK_COLLECTION_COVERAGE: Record<string, KindCoverage> = {
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.',
},
};

Expand Down
46 changes: 45 additions & 1 deletion examples/app-showcase/src/system/connectors/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -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,
];
3 changes: 3 additions & 0 deletions packages/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:*",
Expand Down
97 changes: 97 additions & 0 deletions packages/dogfood/test/showcase-declarative-mcp.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> };
};
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');
});
});
15 changes: 15 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.