@objectstack/connector-mcp 14.8.0 → 15.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,13 +1,19 @@
1
1
  // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
2
 
3
3
  import type { Plugin, PluginContext } from '@objectstack/core';
4
- import type { Connector } from '@objectstack/spec/integration';
4
+ import type { Connector, ConnectorProviderFactory } from '@objectstack/spec/integration';
5
5
  import { createMcpConnector, type McpConnectorOptions } from './mcp-connector.js';
6
+ import {
7
+ createMcpProviderFactory,
8
+ MCP_PROVIDER_KEY,
9
+ type McpDeclarativeStdioPolicy,
10
+ } from './mcp-provider.js';
6
11
 
7
12
  /**
8
13
  * Minimal surface of the automation engine this plugin depends on — the
9
- * connector registry from ADR-0018 §Addendum. Kept structural so the plugin
10
- * needs no runtime dependency on `@objectstack/service-automation`.
14
+ * connector registry (ADR-0018 §Addendum) plus the provider registry (ADR-0097).
15
+ * Kept structural so the plugin needs no runtime dependency on
16
+ * `@objectstack/service-automation`.
11
17
  */
12
18
  export interface ConnectorRegistrySurface {
13
19
  registerConnector(
@@ -18,26 +24,49 @@ export interface ConnectorRegistrySurface {
18
24
  >,
19
25
  ): void;
20
26
  unregisterConnector(name: string): void;
27
+ registerConnectorProvider(providerKey: string, factory: ConnectorProviderFactory): void;
21
28
  }
22
29
 
23
- export interface ConnectorMcpPluginOptions extends McpConnectorOptions {}
30
+ /**
31
+ * Options for {@link ConnectorMcpPlugin}. All optional (ADR-0097): with no
32
+ * `transport` the plugin contributes only the `mcp` provider factory — so a
33
+ * stack can declare `provider: 'mcp'` instances as pure metadata. Supply a
34
+ * `transport` to ALSO connect one hand-wired MCP server at `start()`.
35
+ */
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
+ }
24
47
 
25
48
  /**
26
- * ConnectorMcpPlugin — connects to an MCP server, discovers its tools, and
27
- * registers them as a single connector on the automation engine (ADR-0024).
28
- * One generic adapter, configured per server (transport + `include`), never
29
- * per-server code.
49
+ * ConnectorMcpPlugin — contributes the generic MCP adapter (ADR-0024) in two forms:
50
+ *
51
+ * 1. **Provider factory** (`mcp`, ADR-0097): registered at `init()` so the
52
+ * automation service can materialize declarative `provider: 'mcp'`
53
+ * `connectors:` entries — connecting to the server and mapping its tools to
54
+ * connector actions — at boot.
55
+ * 2. **Hand-wired instance** (optional, back-compat): when constructed with a
56
+ * `transport`, it also connects that one server at `start()` and registers
57
+ * the resulting connector.
30
58
  *
31
- * Lifecycle: on `start()` it connects and builds the connector once; on
32
- * `stop()` it tears the MCP connection down. If no automation engine is present
33
- * — or the server is unreachable at boot — the plugin logs and skips: a missing
34
- * optional connector is not a fatal error (same posture as `ConnectorRestPlugin`).
59
+ * Lifecycle: on `start()` a configured instance connects and builds the
60
+ * connector once; on `destroy()` it tears the MCP connection down. If no
61
+ * automation engine is present — or the server is unreachable at boot — the
62
+ * hand-wired path logs and skips: a missing optional connector is not fatal
63
+ * (unlike a *declarative* provider-bound instance, which fails boot loudly).
35
64
  */
36
65
  export class ConnectorMcpPlugin implements Plugin {
37
66
  name = 'com.objectstack.connector.mcp';
38
67
  version = '1.0.0';
39
68
  type = 'standard' as const;
40
- // Ensure the automation engine (and its connector registry) is started first.
69
+ // Ensure the automation engine (and its connector/provider registries) exist first.
41
70
  dependencies = ['com.objectstack.service-automation'];
42
71
 
43
72
  private readonly options: ConnectorMcpPluginOptions;
@@ -45,23 +74,31 @@ export class ConnectorMcpPlugin implements Plugin {
45
74
  private automation?: ConnectorRegistrySurface;
46
75
  private close?: () => Promise<void>;
47
76
 
48
- constructor(options: ConnectorMcpPluginOptions) {
77
+ constructor(options: ConnectorMcpPluginOptions = {}) {
49
78
  this.options = options;
50
79
  }
51
80
 
52
- async init(_ctx: PluginContext): Promise<void> {
53
- // No services to register; the connector is registered in start() once
54
- // the automation engine is available and the MCP server has been queried.
81
+ async init(ctx: PluginContext): Promise<void> {
82
+ // Contribute the `mcp` provider factory (ADR-0097) before the automation
83
+ // service materializes declarative instances during its start().
84
+ const automation = this.tryGetAutomation(ctx);
85
+ if (automation && typeof automation.registerConnectorProvider === 'function') {
86
+ automation.registerConnectorProvider(
87
+ MCP_PROVIDER_KEY,
88
+ createMcpProviderFactory({
89
+ clientFactory: this.options.clientFactory,
90
+ declarativeStdio: this.options.declarativeStdio,
91
+ }),
92
+ );
93
+ ctx.logger.info("ConnectorMcpPlugin: registered 'mcp' connector provider");
94
+ }
55
95
  }
56
96
 
57
97
  async start(ctx: PluginContext): Promise<void> {
58
- let automation: ConnectorRegistrySurface | undefined;
59
- try {
60
- automation = ctx.getService<ConnectorRegistrySurface>('automation');
61
- } catch {
62
- automation = undefined;
63
- }
98
+ // Provider-only usage (no transport) contributes just the factory in init().
99
+ if (!this.options.transport) return;
64
100
 
101
+ const automation = this.tryGetAutomation(ctx);
65
102
  if (!automation || typeof automation.registerConnector !== 'function') {
66
103
  ctx.logger.info('ConnectorMcpPlugin: no automation engine — MCP connector not registered');
67
104
  return;
@@ -69,7 +106,7 @@ export class ConnectorMcpPlugin implements Plugin {
69
106
 
70
107
  let bundle;
71
108
  try {
72
- bundle = await createMcpConnector(this.options);
109
+ bundle = await createMcpConnector(this.options as McpConnectorOptions);
73
110
  } catch (err) {
74
111
  // The MCP server is unreachable / failed discovery at boot. Skip the
75
112
  // optional connector rather than failing the whole bootstrap.
@@ -101,4 +138,12 @@ export class ConnectorMcpPlugin implements Plugin {
101
138
  try { await this.close(); } catch { /* ignore */ }
102
139
  }
103
140
  }
141
+
142
+ private tryGetAutomation(ctx: PluginContext): ConnectorRegistrySurface | undefined {
143
+ try {
144
+ return ctx.getService<ConnectorRegistrySurface>('automation');
145
+ } catch {
146
+ return undefined;
147
+ }
148
+ }
104
149
  }
package/src/index.ts CHANGED
@@ -31,3 +31,9 @@ export {
31
31
  type ConnectorMcpPluginOptions,
32
32
  type ConnectorRegistrySurface,
33
33
  } from './connector-mcp-plugin.js';
34
+ export {
35
+ createMcpProviderFactory,
36
+ MCP_PROVIDER_KEY,
37
+ type McpProviderDeps,
38
+ type McpDeclarativeStdioPolicy,
39
+ } from './mcp-provider.js';
@@ -0,0 +1,193 @@
1
+ // Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2
+ //
3
+ // ADR-0097 — the `mcp` provider factory: materialize a declarative
4
+ // `provider: 'mcp'` connector instance by connecting to the server (an injected
5
+ // fake client here), listing its tools, and mapping them to actions.
6
+
7
+ import { describe, it, expect } from 'vitest';
8
+ import type { ConnectorProviderContext } from '@objectstack/spec/integration';
9
+ import { isConnectorUpstreamUnavailable } from '@objectstack/spec/integration';
10
+ import type { McpClientLike, McpToolDescriptor, McpTransport } from './mcp-connector.js';
11
+ import { createMcpProviderFactory, MCP_PROVIDER_KEY } from './mcp-provider.js';
12
+
13
+ const TOOLS: McpToolDescriptor[] = [
14
+ { name: 'create_issue', description: 'Create an issue', inputSchema: { type: 'object' } },
15
+ { name: 'list_issues', description: 'List issues' },
16
+ ];
17
+
18
+ /** Capture the transport the factory built, and serve fixed tools. */
19
+ function fakeClientFactory() {
20
+ const seen: { transport?: McpTransport } = {};
21
+ let closed = false;
22
+ const factory = async (transport: McpTransport): Promise<McpClientLike> => {
23
+ seen.transport = transport;
24
+ return {
25
+ listTools: async () => TOOLS,
26
+ callTool: async () => ({ content: [{ type: 'text', text: 'ok' }] }),
27
+ close: async () => { closed = true; },
28
+ };
29
+ };
30
+ return { factory, seen, isClosed: () => closed };
31
+ }
32
+
33
+ function ctx(partial: Partial<ConnectorProviderContext> & Pick<ConnectorProviderContext, 'providerConfig'>): ConnectorProviderContext {
34
+ return { name: 'github', label: 'GitHub', type: 'api', ...partial };
35
+ }
36
+
37
+ describe('mcp provider factory (ADR-0097)', () => {
38
+ it('advertises the mcp provider key', () => {
39
+ expect(MCP_PROVIDER_KEY).toBe('mcp');
40
+ });
41
+
42
+ it('connects, lists tools, and maps them to actions', async () => {
43
+ const { factory: clientFactory } = fakeClientFactory();
44
+ // stdio on a declarative instance requires the host opt-in (#3055).
45
+ const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['my-mcp'] });
46
+ const mat = await factory(ctx({ providerConfig: { transport: { kind: 'stdio', command: 'my-mcp' } } }));
47
+ expect(mat.def.name).toBe('github');
48
+ expect(Object.keys(mat.handlers).sort()).toEqual(['create_issue', 'list_issues']);
49
+ expect(typeof mat.close).toBe('function');
50
+ });
51
+
52
+ it('applies the tool allowlist from providerConfig.include', async () => {
53
+ const { factory: clientFactory } = fakeClientFactory();
54
+ const factory = createMcpProviderFactory({ clientFactory, declarativeStdio: ['my-mcp'] });
55
+ const mat = await factory(
56
+ ctx({ providerConfig: { transport: { kind: 'stdio', command: 'my-mcp' }, include: ['create_issue'] } }),
57
+ );
58
+ expect(Object.keys(mat.handlers)).toEqual(['create_issue']);
59
+ });
60
+
61
+ it('folds resolved bearer auth into an http transport header', async () => {
62
+ const captured = fakeClientFactory();
63
+ const factory = createMcpProviderFactory({ clientFactory: captured.factory });
64
+ await factory(
65
+ ctx({
66
+ providerConfig: { transport: { kind: 'http', url: 'https://mcp.example.com' } },
67
+ auth: { type: 'bearer', token: 'tok' },
68
+ }),
69
+ );
70
+ const t = captured.seen.transport;
71
+ expect(t?.kind).toBe('http');
72
+ expect(t?.kind === 'http' && t.headers?.Authorization).toBe('Bearer tok');
73
+ });
74
+
75
+ it('throws when the transport is missing', async () => {
76
+ const factory = createMcpProviderFactory();
77
+ await expect(factory(ctx({ providerConfig: {} }))).rejects.toThrow(/providerConfig\.transport/);
78
+ });
79
+
80
+ it('throws for an unknown transport kind', async () => {
81
+ const factory = createMcpProviderFactory();
82
+ await expect(
83
+ factory(ctx({ providerConfig: { transport: { kind: 'carrier-pigeon' } } })),
84
+ ).rejects.toThrow(/kind must be 'stdio' or 'http'/);
85
+ });
86
+ });
87
+
88
+ // ── #3017 — fault classification: config stays fatal, upstream degrades ─────
89
+
90
+ describe('mcp provider fault classification (#3017)', () => {
91
+ const stdio = { transport: { kind: 'stdio', command: 'my-mcp' } };
92
+ const allowMyMcp = { declarativeStdio: ['my-mcp'] };
93
+
94
+ it('classifies a connect failure as upstream-unavailable (retryable), keeping the cause', async () => {
95
+ const boom = new Error('connect ECONNREFUSED 127.0.0.1:9999');
96
+ const clientFactory = async (): Promise<McpClientLike> => { throw boom; };
97
+ const factory = createMcpProviderFactory({ clientFactory, ...allowMyMcp });
98
+
99
+ const err = await factory(ctx({ providerConfig: stdio })).then(
100
+ () => { throw new Error('expected rejection'); },
101
+ (e: unknown) => e,
102
+ );
103
+ expect(isConnectorUpstreamUnavailable(err)).toBe(true);
104
+ expect((err as Error).message).toMatch(/'github' could not reach its MCP server/);
105
+ expect((err as Error).message).toContain('ECONNREFUSED');
106
+ expect((err as { cause?: unknown }).cause).toBe(boom);
107
+ });
108
+
109
+ it('classifies a tools/list failure as upstream-unavailable and closes the client', async () => {
110
+ let closed = false;
111
+ const clientFactory = async (): Promise<McpClientLike> => ({
112
+ listTools: async () => { throw new Error('request timed out'); },
113
+ callTool: async () => ({}),
114
+ close: async () => { closed = true; },
115
+ });
116
+ const factory = createMcpProviderFactory({ clientFactory, ...allowMyMcp });
117
+
118
+ const err = await factory(ctx({ providerConfig: stdio })).then(
119
+ () => { throw new Error('expected rejection'); },
120
+ (e: unknown) => e,
121
+ );
122
+ expect(isConnectorUpstreamUnavailable(err)).toBe(true);
123
+ expect(closed).toBe(true); // discovery failure must not leak the connection
124
+ });
125
+
126
+ it('keeps transport-shape faults plain — configuration errors stay fatal at boot', async () => {
127
+ const factory = createMcpProviderFactory();
128
+ const err = await factory(ctx({ providerConfig: {} })).then(
129
+ () => { throw new Error('expected rejection'); },
130
+ (e: unknown) => e,
131
+ );
132
+ expect(isConnectorUpstreamUnavailable(err)).toBe(false);
133
+ });
134
+ });
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
+ });
@@ -0,0 +1,205 @@
1
+ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2
+
3
+ import type { ConnectorProviderFactory, ResolvedConnectorAuth } from '@objectstack/spec/integration';
4
+ import { ConnectorUpstreamUnavailableError } from '@objectstack/spec/integration';
5
+ import { createMcpConnector, type McpConnectorOptions, type McpTransport } from './mcp-connector.js';
6
+
7
+ /**
8
+ * The provider key this package contributes (ADR-0097). A declarative
9
+ * `connectors:` entry with `provider: 'mcp'` is materialized by this factory.
10
+ */
11
+ export const MCP_PROVIDER_KEY = 'mcp';
12
+
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
+
35
+ /** Injectable dependencies for {@link createMcpProviderFactory} (tests). */
36
+ export interface McpProviderDeps {
37
+ /** Injected MCP client factory; defaults to the SDK-backed client. */
38
+ clientFactory?: McpConnectorOptions['clientFactory'];
39
+ /** Policy for declarative stdio transports (#3055). Default: deny. */
40
+ declarativeStdio?: McpDeclarativeStdioPolicy;
41
+ }
42
+
43
+ /** Shape of `providerConfig` for a `provider: 'mcp'` declarative instance. */
44
+ interface McpProviderConfig {
45
+ /** How to reach the MCP server (stdio or streamable-http). */
46
+ transport?: unknown;
47
+ /** Optional tool-name allowlist — only these tools become actions. */
48
+ include?: unknown;
49
+ }
50
+
51
+ function isStringRecord(v: unknown): v is Record<string, string> {
52
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return false;
53
+ return Object.values(v as Record<string, unknown>).every((x) => typeof x === 'string');
54
+ }
55
+
56
+ /**
57
+ * Fold the resolved instance `auth` into an MCP **http** transport's headers
58
+ * (ADR-0024 keeps MCP credentials with the transport). `credentialRef` has
59
+ * already been resolved upstream, so this only maps the static credential to the
60
+ * right header. Not applied to stdio transports — a stdio server receives its
61
+ * credentials through `transport.env`.
62
+ */
63
+ function applyAuthToHeaders(
64
+ auth: ResolvedConnectorAuth | undefined,
65
+ headers: Record<string, string>,
66
+ ): void {
67
+ if (!auth || auth.type === 'none') return;
68
+ switch (auth.type) {
69
+ case 'bearer':
70
+ headers['Authorization'] = `Bearer ${auth.token}`;
71
+ return;
72
+ case 'basic':
73
+ headers['Authorization'] = `Basic ${Buffer.from(`${auth.username}:${auth.password}`).toString('base64')}`;
74
+ return;
75
+ case 'api-key':
76
+ // Header-based only for MCP http (query-param keys are not part of the transport).
77
+ if (!auth.paramName) headers[auth.headerName ?? 'X-API-Key'] = auth.key;
78
+ return;
79
+ }
80
+ }
81
+
82
+ /** Validate + normalize `providerConfig.transport`, injecting resolved auth for http. */
83
+ function normalizeTransport(
84
+ raw: unknown,
85
+ connectorName: string,
86
+ auth: ResolvedConnectorAuth | undefined,
87
+ ): McpTransport {
88
+ if (!raw || typeof raw !== 'object') {
89
+ throw new Error(
90
+ `connector-mcp provider: connector '${connectorName}' requires providerConfig.transport ` +
91
+ `({ kind: 'stdio', command, ... } or { kind: 'http', url, ... }).`,
92
+ );
93
+ }
94
+ const t = raw as Record<string, unknown>;
95
+ if (t.kind === 'stdio') {
96
+ if (typeof t.command !== 'string' || t.command.length === 0) {
97
+ throw new Error(
98
+ `connector-mcp provider: connector '${connectorName}' stdio transport requires a 'command' string.`,
99
+ );
100
+ }
101
+ return {
102
+ kind: 'stdio',
103
+ command: t.command,
104
+ args: Array.isArray(t.args) ? t.args.map((a) => String(a)) : undefined,
105
+ env: isStringRecord(t.env) ? t.env : undefined,
106
+ };
107
+ }
108
+ if (t.kind === 'http') {
109
+ if (typeof t.url !== 'string' || t.url.length === 0) {
110
+ throw new Error(
111
+ `connector-mcp provider: connector '${connectorName}' http transport requires a 'url' string.`,
112
+ );
113
+ }
114
+ const headers: Record<string, string> = { ...(isStringRecord(t.headers) ? t.headers : {}) };
115
+ applyAuthToHeaders(auth, headers);
116
+ return { kind: 'http', url: t.url, headers: Object.keys(headers).length > 0 ? headers : undefined };
117
+ }
118
+ throw new Error(
119
+ `connector-mcp provider: connector '${connectorName}' providerConfig.transport.kind must be 'stdio' or 'http'.`,
120
+ );
121
+ }
122
+
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
+
152
+ /**
153
+ * Build the `mcp` {@link ConnectorProviderFactory} (ADR-0097 / ADR-0024). At boot
154
+ * the automation service invokes it for each `provider: 'mcp'` declarative
155
+ * instance: it connects to the MCP server named by `providerConfig.transport`,
156
+ * lists its tools, and produces the same `{ def, handlers, close }` bundle
157
+ * {@link createMcpConnector} builds for a hand-wired MCP connector — one action
158
+ * per tool, dispatched to the server's `tools/call`.
159
+ *
160
+ * Stdio transports on declarative instances are policy-gated (default deny) —
161
+ * see {@link McpDeclarativeStdioPolicy} (#3055).
162
+ *
163
+ * The connection is opened at materialization. Faults are classified (#3017):
164
+ * an invalid transport shape is a *configuration* fault and throws plain —
165
+ * fatal at boot per the ADR-0097 fail-loud contract — while a connect /
166
+ * `tools/list` failure (server down, refused, timed out) is an *operational*
167
+ * fault and throws {@link ConnectorUpstreamUnavailableError}, which the
168
+ * materializer turns into a degraded instance that is retried with backoff
169
+ * instead of aborting the whole app boot.
170
+ */
171
+ export function createMcpProviderFactory(deps: McpProviderDeps = {}): ConnectorProviderFactory {
172
+ return async (ctx) => {
173
+ const cfg = (ctx.providerConfig ?? {}) as McpProviderConfig;
174
+ const transport = normalizeTransport(cfg.transport, ctx.name, ctx.auth);
175
+ if (transport.kind === 'stdio') {
176
+ assertDeclarativeStdioAllowed(deps.declarativeStdio, transport.command, ctx.name);
177
+ }
178
+ const includeList = Array.isArray(cfg.include)
179
+ ? cfg.include.filter((x): x is string => typeof x === 'string')
180
+ : undefined;
181
+ const include = includeList ? (toolName: string) => includeList.includes(toolName) : undefined;
182
+
183
+ let bundle;
184
+ try {
185
+ bundle = await createMcpConnector({
186
+ name: ctx.name,
187
+ label: ctx.label,
188
+ description: ctx.description,
189
+ transport,
190
+ include,
191
+ clientFactory: deps.clientFactory,
192
+ });
193
+ } catch (err) {
194
+ // Everything past transport validation is talking to the server (connect,
195
+ // handshake, tools/list) — operational, hence retryable. A credential the
196
+ // server rejects also lands here: indistinguishable from the outside, and
197
+ // retrying it is loud (logged per attempt), never silent.
198
+ throw new ConnectorUpstreamUnavailableError(
199
+ `connector-mcp provider: connector '${ctx.name}' could not reach its MCP server: ${(err as Error).message}`,
200
+ { cause: err },
201
+ );
202
+ }
203
+ return { def: bundle.def, handlers: bundle.handlers, close: bundle.close };
204
+ };
205
+ }