Skip to content

Commit e97885b

Browse files
authored
feat(connector-mcp): policy-gate declarative stdio transports — default-deny + host allowlist (#3055) (#3059)
A declarative provider:'mcp' stdio transport spawns a local child process from metadata (a runtime Studio publish reaches materialization), so it is now denied by default; hosts opt in via new ConnectorMcpPlugin({ declarativeStdio: ['cmd'] }) (allowlist) or true. Violations are plain configuration faults (fatal at boot, skipped on reload), never upstream-unavailable. http transports and hand-wired connectors are unaffected. Precondition for default-preset adoption (#3056).
1 parent 4f8c2d1 commit e97885b

7 files changed

Lines changed: 224 additions & 7 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/connector-mcp': minor
3+
---
4+
5+
feat(connector-mcp)!: policy-gate declarative stdio transports — default-deny + host allowlist (#3055)
6+
7+
A declarative `provider: 'mcp'` entry with a stdio transport spawns a local
8+
child process **from metadata** — which a runtime Studio publish can introduce.
9+
Declarative stdio is now **denied by default**; hosts opt in deliberately:
10+
11+
```ts
12+
new ConnectorMcpPlugin({ declarativeStdio: ['my-mcp-server'] }) // command allowlist
13+
new ConnectorMcpPlugin({ declarativeStdio: true }) // allow any (full trust)
14+
```
15+
16+
Behavior change: a declarative stdio instance that materialized before now
17+
fails as a **configuration fault** (fatal at boot / skipped on reload) with an
18+
actionable opt-in message, and is never classified upstream-unavailable — a
19+
security rejection must not be retried into existence. `http` transports and
20+
**hand-wired** connectors (plugin instance options / `createMcpConnector`) are
21+
unaffected. This is the security precondition for shipping `connector-mcp` in
22+
default presets (#3056); see ADR-0097 §"Declarative stdio policy" and
23+
ADR-0024 §4.

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

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,31 @@ dispatch was rejected: MCP actions ARE the server's `tools/list`, so a
167167
connector materialized without connecting would register a zero-action def —
168168
exactly the plausible-but-dead shape this ADR exists to kill.
169169

170+
### Declarative stdio policy: default-deny (follow-up, landed — #3055)
171+
172+
A declarative `provider: 'mcp'` entry may name a **stdio transport**, and
173+
materializing one **spawns a local child process** — from *metadata*, which a
174+
runtime Studio publish can introduce (the reload reconcile materializes new
175+
instances; soft mode skips failures, not successes). Anyone who can publish
176+
metadata could otherwise execute commands on the server. Stdio on declarative
177+
instances is therefore **denied by default** and opt-in per host:
178+
179+
```ts
180+
new ConnectorMcpPlugin({ declarativeStdio: ['my-mcp-server'] }) // allowlist
181+
new ConnectorMcpPlugin({ declarativeStdio: true }) // full trust
182+
```
183+
184+
A policy violation is a **configuration fault** — plain throw (fatal at boot,
185+
skipped on reload), never `CONNECTOR_UPSTREAM_UNAVAILABLE`: a security
186+
rejection must not be retried into existence. `http` transports are unaffected
187+
(same trust class as the `http` node), and so are **hand-wired** connectors
188+
(plugin instance options / `createMcpConnector`) — their command lives in host
189+
code, a different trust anchor than metadata. The allowlist is deliberately
190+
honest about being a *coarse* boundary (allowlisting `npx` ≈ allowing any
191+
package it can run); sandboxed execution remains the enterprise tier
192+
(ADR-0024 §4). This gate is the precondition for shipping `connector-mcp` in
193+
any default preset (#3056).
194+
170195
### Deliberate scope boundaries
171196

172197
- **`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.

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

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ describe('ConnectorMcpPlugin — end to end with the automation engine', () => {
4141
kernel.use(new AutomationServicePlugin());
4242
kernel.use(
4343
new ConnectorMcpPlugin({
44+
// NOTE deliberately no `declarativeStdio`: the #3055 default-deny
45+
// policy gates DECLARATIVE instances only — this hand-wired stdio
46+
// transport (host code, not metadata) must keep working un-gated.
4447
name: 'github_mcp',
4548
label: 'GitHub MCP',
4649
transport: { kind: 'stdio', command: 'noop' },
@@ -93,4 +96,32 @@ describe('ConnectorMcpPlugin — end to end with the automation engine', () => {
9396
await kernel.shutdown();
9497
expect(isClosed()).toBe(true);
9598
});
99+
100+
it('plumbs declarativeStdio through to the registered provider factory (#3055)', async () => {
101+
const { client } = fakeClient();
102+
let registered: ((ctx: unknown) => Promise<unknown>) | undefined;
103+
const automationStub = {
104+
registerConnector: () => {},
105+
unregisterConnector: () => {},
106+
registerConnectorProvider: (_key: string, factory: never) => { registered = factory; },
107+
};
108+
const plugin = new ConnectorMcpPlugin({
109+
clientFactory: async () => client,
110+
declarativeStdio: ['trusted-mcp'],
111+
});
112+
await plugin.init({
113+
getService: () => automationStub,
114+
logger: { info: () => {}, warn: () => {} },
115+
} as never);
116+
expect(registered).toBeDefined();
117+
118+
const provider = registered!;
119+
const declarativeCtx = (command: string) => ({
120+
name: 'x', label: 'X', type: 'api',
121+
providerConfig: { transport: { kind: 'stdio', command } },
122+
});
123+
// Allowlisted command materializes; anything else is denied by policy.
124+
await expect(provider(declarativeCtx('trusted-mcp'))).resolves.toBeDefined();
125+
await expect(provider(declarativeCtx('bash'))).rejects.toThrow(/declarativeStdio allowlist/);
126+
});
96127
});

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

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import type { Plugin, PluginContext } from '@objectstack/core';
44
import type { Connector, ConnectorProviderFactory } from '@objectstack/spec/integration';
55
import { createMcpConnector, type McpConnectorOptions } from './mcp-connector.js';
6-
import { createMcpProviderFactory, MCP_PROVIDER_KEY } from './mcp-provider.js';
6+
import {
7+
createMcpProviderFactory,
8+
MCP_PROVIDER_KEY,
9+
type McpDeclarativeStdioPolicy,
10+
} from './mcp-provider.js';
711

812
/**
913
* Minimal surface of the automation engine this plugin depends on — the
@@ -29,7 +33,17 @@ export interface ConnectorRegistrySurface {
2933
* stack can declare `provider: 'mcp'` instances as pure metadata. Supply a
3034
* `transport` to ALSO connect one hand-wired MCP server at `start()`.
3135
*/
32-
export interface ConnectorMcpPluginOptions extends Partial<McpConnectorOptions> {}
36+
export interface ConnectorMcpPluginOptions extends Partial<McpConnectorOptions> {
37+
/**
38+
* Policy for stdio transports on **declarative** `provider: 'mcp'`
39+
* instances (#3055). Default **deny**: metadata (including a runtime Studio
40+
* publish) must not spawn local processes unless the host opts in.
41+
* `string[]` allowlists specific commands; `true` allows any. Hand-wired
42+
* connectors configured via these plugin options are not subject to it —
43+
* their command lives in host code, not metadata.
44+
*/
45+
declarativeStdio?: McpDeclarativeStdioPolicy;
46+
}
3347

3448
/**
3549
* ConnectorMcpPlugin — contributes the generic MCP adapter (ADR-0024) in two forms:
@@ -71,7 +85,10 @@ export class ConnectorMcpPlugin implements Plugin {
7185
if (automation && typeof automation.registerConnectorProvider === 'function') {
7286
automation.registerConnectorProvider(
7387
MCP_PROVIDER_KEY,
74-
createMcpProviderFactory({ clientFactory: this.options.clientFactory }),
88+
createMcpProviderFactory({
89+
clientFactory: this.options.clientFactory,
90+
declarativeStdio: this.options.declarativeStdio,
91+
}),
7592
);
7693
ctx.logger.info("ConnectorMcpPlugin: registered 'mcp' connector provider");
7794
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ export {
3535
createMcpProviderFactory,
3636
MCP_PROVIDER_KEY,
3737
type McpProviderDeps,
38+
type McpDeclarativeStdioPolicy,
3839
} from './mcp-provider.js';

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

Lines changed: 65 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ describe('mcp provider factory (ADR-0097)', () => {
4141

4242
it('connects, lists tools, and maps them to actions', async () => {
4343
const { factory: clientFactory } = fakeClientFactory();
44-
const factory = createMcpProviderFactory({ clientFactory });
44+
// stdio on a declarative instance requires the host opt-in (#3055).
45+
const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['my-mcp'] });
4546
const mat = await factory(ctx({ providerConfig: { transport: { kind: 'stdio', command: 'my-mcp' } } }));
4647
expect(mat.def.name).toBe('github');
4748
expect(Object.keys(mat.handlers).sort()).toEqual(['create_issue', 'list_issues']);
@@ -50,7 +51,7 @@ describe('mcp provider factory (ADR-0097)', () => {
5051

5152
it('applies the tool allowlist from providerConfig.include', async () => {
5253
const { factory: clientFactory } = fakeClientFactory();
53-
const factory = createMcpProviderFactory({ clientFactory });
54+
const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['my-mcp'] });
5455
const mat = await factory(
5556
ctx({ providerConfig: { transport: { kind: 'stdio', command: 'my-mcp' }, include: ['create_issue'] } }),
5657
);
@@ -88,11 +89,12 @@ describe('mcp provider factory (ADR-0097)', () => {
8889

8990
describe('mcp provider fault classification (#3017)', () => {
9091
const stdio = { transport: { kind: 'stdio', command: 'my-mcp' } };
92+
const allowMyMcp = { declarativeStdio: ['my-mcp'] };
9193

9294
it('classifies a connect failure as upstream-unavailable (retryable), keeping the cause', async () => {
9395
const boom = new Error('connect ECONNREFUSED 127.0.0.1:9999');
9496
const clientFactory = async (): Promise<McpClientLike> => { throw boom; };
95-
const factory = createMcpProviderFactory({ clientFactory });
97+
const factory = createMcpProviderFactory({ clientFactory, ...allowMyMcp });
9698

9799
const err = await factory(ctx({ providerConfig: stdio })).then(
98100
() => { throw new Error('expected rejection'); },
@@ -111,7 +113,7 @@ describe('mcp provider fault classification (#3017)', () => {
111113
callTool: async () => ({}),
112114
close: async () => { closed = true; },
113115
});
114-
const factory = createMcpProviderFactory({ clientFactory });
116+
const factory = createMcpProviderFactory({ clientFactory, ...allowMyMcp });
115117

116118
const err = await factory(ctx({ providerConfig: stdio })).then(
117119
() => { throw new Error('expected rejection'); },
@@ -130,3 +132,62 @@ describe('mcp provider fault classification (#3017)', () => {
130132
expect(isConnectorUpstreamUnavailable(err)).toBe(false);
131133
});
132134
});
135+
136+
// ── #3055 — declarative stdio policy: default-deny + host allowlist ─────────
137+
//
138+
// A declarative stdio transport spawns a local process from metadata (a Studio
139+
// publish reaches materialization at runtime), so it is gated OFF unless the
140+
// host opts in. Violations are CONFIGURATION faults: plain throw (fatal at
141+
// boot, skipped on reload) — never upstream-unavailable, which would retry a
142+
// security rejection into existence.
143+
144+
describe('mcp provider declarative stdio policy (#3055)', () => {
145+
const stdioCfg = { transport: { kind: 'stdio', command: 'my-mcp' } };
146+
147+
it('DENIES a declarative stdio transport by default, as a plain (non-retryable) fault', async () => {
148+
const { factory: clientFactory, seen } = fakeClientFactory();
149+
const factory = createMcpProviderFactory({ clientFactory }); // no policy
150+
const err = await factory(ctx({ providerConfig: stdioCfg })).then(
151+
() => { throw new Error('expected rejection'); },
152+
(e: unknown) => e,
153+
);
154+
expect((err as Error).message).toMatch(/stdio transports are disabled by default/);
155+
expect((err as Error).message).toContain("declarativeStdio: ['my-mcp']"); // actionable opt-in hint
156+
expect(isConnectorUpstreamUnavailable(err)).toBe(false);
157+
expect(seen.transport).toBeUndefined(); // rejected before any connection attempt
158+
});
159+
160+
it('allowlist admits exactly the listed command and rejects others', async () => {
161+
const { factory: clientFactory } = fakeClientFactory();
162+
const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['npx', 'my-mcp'] });
163+
const mat = await factory(ctx({ providerConfig: stdioCfg }));
164+
expect(mat.def.name).toBe('github');
165+
166+
const err = await factory(
167+
ctx({ providerConfig: { transport: { kind: 'stdio', command: 'bash' } } }),
168+
).then(
169+
() => { throw new Error('expected rejection'); },
170+
(e: unknown) => e,
171+
);
172+
expect((err as Error).message).toMatch(/not in the host's declarativeStdio allowlist \[npx, my-mcp\]/);
173+
expect(isConnectorUpstreamUnavailable(err)).toBe(false);
174+
});
175+
176+
it('declarativeStdio: true allows any command (explicit full trust)', async () => {
177+
const { factory: clientFactory } = fakeClientFactory();
178+
const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: true });
179+
const mat = await factory(
180+
ctx({ providerConfig: { transport: { kind: 'stdio', command: 'anything' } } }),
181+
);
182+
expect(Object.keys(mat.handlers).length).toBeGreaterThan(0);
183+
});
184+
185+
it('http transports are NOT subject to the policy', async () => {
186+
const { factory: clientFactory } = fakeClientFactory();
187+
const factory = createMcpProviderFactory({ clientFactory }); // default-deny policy in force
188+
const mat = await factory(
189+
ctx({ providerConfig: { transport: { kind: 'http', url: 'https://mcp.example.com' } } }),
190+
);
191+
expect(mat.def.name).toBe('github');
192+
});
193+
});

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

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,34 @@ import { createMcpConnector, type McpConnectorOptions, type McpTransport } from
1010
*/
1111
export const MCP_PROVIDER_KEY = 'mcp';
1212

13+
/**
14+
* Host policy for **declarative** stdio transports (#3055). A stdio transport
15+
* launches a local child process, and declarative entries arrive through
16+
* metadata — including a runtime Studio publish — so spawning from them is
17+
* gated OFF by default:
18+
*
19+
* - `undefined` / `false` — deny (default): a `provider: 'mcp'` entry with a
20+
* stdio transport is rejected as a configuration fault.
21+
* - `string[]` — allowlist: the transport's `command` must strictly equal one
22+
* of the listed commands. NOTE this is a coarse trust boundary — listing a
23+
* launcher like `npx` effectively allows any package it can run; list the
24+
* specific server binaries you trust. Sandboxed execution is the enterprise
25+
* tier (ADR-0024 §4).
26+
* - `true` — allow any command (explicit full trust; hosts that treat every
27+
* metadata author as an operator).
28+
*
29+
* Hand-wired connectors (plugin instance options / `createMcpConnector`) are
30+
* NOT subject to this policy: their command was written in host code, a
31+
* different trust anchor than metadata.
32+
*/
33+
export type McpDeclarativeStdioPolicy = boolean | string[];
34+
1335
/** Injectable dependencies for {@link createMcpProviderFactory} (tests). */
1436
export interface McpProviderDeps {
1537
/** Injected MCP client factory; defaults to the SDK-backed client. */
1638
clientFactory?: McpConnectorOptions['clientFactory'];
39+
/** Policy for declarative stdio transports (#3055). Default: deny. */
40+
declarativeStdio?: McpDeclarativeStdioPolicy;
1741
}
1842

1943
/** Shape of `providerConfig` for a `provider: 'mcp'` declarative instance. */
@@ -96,6 +120,35 @@ function normalizeTransport(
96120
);
97121
}
98122

123+
/**
124+
* Enforce the {@link McpDeclarativeStdioPolicy} for one declarative instance
125+
* (#3055). Throws a **plain** Error on violation: a security-policy rejection
126+
* is a configuration fault — fatal at boot, skipped+logged on reload — and must
127+
* never be classified upstream-unavailable (it cannot be retried into
128+
* existence).
129+
*/
130+
function assertDeclarativeStdioAllowed(
131+
policy: McpDeclarativeStdioPolicy | undefined,
132+
command: string,
133+
connectorName: string,
134+
): void {
135+
if (policy === true) return;
136+
if (Array.isArray(policy)) {
137+
if (policy.includes(command)) return;
138+
throw new Error(
139+
`connector-mcp provider: connector '${connectorName}' declares a stdio transport with command '${command}', ` +
140+
`which is not in the host's declarativeStdio allowlist [${policy.join(', ')}]. ` +
141+
`Add the command to new ConnectorMcpPlugin({ declarativeStdio: [...] }) if this server is trusted (#3055).`,
142+
);
143+
}
144+
throw new Error(
145+
`connector-mcp provider: connector '${connectorName}' declares a stdio transport (command '${command}'), ` +
146+
`but declarative stdio transports are disabled by default — a stdio transport launches a local process ` +
147+
`from stack metadata (including runtime Studio publishes). If this server is trusted, opt in deliberately: ` +
148+
`new ConnectorMcpPlugin({ declarativeStdio: ['${command}'] }) — or use an http transport (#3055, ADR-0024 §4).`,
149+
);
150+
}
151+
99152
/**
100153
* Build the `mcp` {@link ConnectorProviderFactory} (ADR-0097 / ADR-0024). At boot
101154
* the automation service invokes it for each `provider: 'mcp'` declarative
@@ -104,6 +157,9 @@ function normalizeTransport(
104157
* {@link createMcpConnector} builds for a hand-wired MCP connector — one action
105158
* per tool, dispatched to the server's `tools/call`.
106159
*
160+
* Stdio transports on declarative instances are policy-gated (default deny) —
161+
* see {@link McpDeclarativeStdioPolicy} (#3055).
162+
*
107163
* The connection is opened at materialization. Faults are classified (#3017):
108164
* an invalid transport shape is a *configuration* fault and throws plain —
109165
* fatal at boot per the ADR-0097 fail-loud contract — while a connect /
@@ -116,6 +172,9 @@ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorP
116172
return async (ctx) => {
117173
const cfg = (ctx.providerConfig ?? {}) as McpProviderConfig;
118174
const transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth);
175+
if (transport.kind === 'stdio') {
176+
assertDeclarativeStdioAllowed(deps.declarativeStdio, transport.command, ctx.name);
177+
}
119178
const includeList = Array.isArray(cfg.include)
120179
? cfg.include.filter((x): x is string => typeof x === 'string')
121180
: undefined;

0 commit comments

Comments
 (0)