@objectstack/connector-mcp 15.0.0 → 15.1.1
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.
- package/.turbo/turbo-build.log +10 -10
- package/CHANGELOG.md +138 -0
- package/dist/index.d.mts +93 -15
- package/dist/index.d.ts +93 -15
- package/dist/index.js +125 -11
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +122 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -4
- package/src/connector-mcp-plugin.test.ts +31 -0
- package/src/connector-mcp-plugin.ts +69 -24
- package/src/index.ts +6 -0
- package/src/mcp-provider.test.ts +193 -0
- package/src/mcp-provider.ts +205 -0
|
@@ -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
|
|
10
|
-
* needs no runtime dependency on
|
|
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
|
-
|
|
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 —
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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()`
|
|
32
|
-
* `
|
|
33
|
-
* — or the server is unreachable at boot — the
|
|
34
|
-
*
|
|
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
|
|
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(
|
|
53
|
-
//
|
|
54
|
-
//
|
|
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
|
-
|
|
59
|
-
|
|
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
|
+
}
|