@objectstack/connector-mcp 7.4.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.
- package/.turbo/turbo-build.log +22 -0
- package/CHANGELOG.md +44 -0
- package/LICENSE +93 -0
- package/dist/index.d.mts +155 -0
- package/dist/index.d.ts +155 -0
- package/dist/index.js +212 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +174 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +39 -0
- package/src/connector-mcp-plugin.test.ts +96 -0
- package/src/connector-mcp-plugin.ts +104 -0
- package/src/index.ts +33 -0
- package/src/mcp-connector.test.ts +194 -0
- package/src/mcp-connector.ts +265 -0
- package/tsconfig.json +10 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
4
|
+
import {
|
|
5
|
+
createMcpConnector,
|
|
6
|
+
type McpClientLike,
|
|
7
|
+
type McpToolDescriptor,
|
|
8
|
+
} from './mcp-connector.js';
|
|
9
|
+
|
|
10
|
+
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
11
|
+
|
|
12
|
+
interface CapturedCall {
|
|
13
|
+
name: string;
|
|
14
|
+
args: Record<string, unknown>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A fake MCP client recording `tools/call` invocations and returning a fixed result. */
|
|
18
|
+
function fakeClient(
|
|
19
|
+
tools: McpToolDescriptor[],
|
|
20
|
+
result: unknown = { content: [{ type: 'text', text: 'ok' }] },
|
|
21
|
+
) {
|
|
22
|
+
const calls: CapturedCall[] = [];
|
|
23
|
+
let closed = false;
|
|
24
|
+
const client: McpClientLike = {
|
|
25
|
+
listTools: async () => tools,
|
|
26
|
+
callTool: async (name, args) => {
|
|
27
|
+
calls.push({ name, args });
|
|
28
|
+
return result;
|
|
29
|
+
},
|
|
30
|
+
close: async () => {
|
|
31
|
+
closed = true;
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
return { client, calls, isClosed: () => closed };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const SAMPLE_TOOLS: McpToolDescriptor[] = [
|
|
38
|
+
{
|
|
39
|
+
name: 'create_issue',
|
|
40
|
+
description: 'Create a GitHub issue',
|
|
41
|
+
inputSchema: {
|
|
42
|
+
type: 'object',
|
|
43
|
+
properties: { repo: { type: 'string' }, title: { type: 'string' } },
|
|
44
|
+
required: ['repo', 'title'],
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
name: 'list_issues',
|
|
49
|
+
description: 'List issues in a repo',
|
|
50
|
+
inputSchema: { type: 'object', properties: { repo: { type: 'string' } } },
|
|
51
|
+
outputSchema: { type: 'object', properties: { issues: { type: 'array' } } },
|
|
52
|
+
},
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
// ─── tools/list → connector actions ───────────────────────────────────
|
|
56
|
+
|
|
57
|
+
describe('createMcpConnector — discovery maps tools to actions', () => {
|
|
58
|
+
it('builds a type:api connector with one action per tool, mapping name/description/schemas', async () => {
|
|
59
|
+
const { client } = fakeClient(SAMPLE_TOOLS);
|
|
60
|
+
const { def } = await createMcpConnector({
|
|
61
|
+
name: 'github_mcp',
|
|
62
|
+
label: 'GitHub MCP',
|
|
63
|
+
transport: { kind: 'stdio', command: 'noop' },
|
|
64
|
+
clientFactory: async () => client,
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
expect(def.name).toBe('github_mcp');
|
|
68
|
+
expect(def.label).toBe('GitHub MCP');
|
|
69
|
+
expect(def.type).toBe('api');
|
|
70
|
+
// Credentials live with the server — the def carries no upstream auth.
|
|
71
|
+
expect(def.authentication).toEqual({ type: 'none' });
|
|
72
|
+
|
|
73
|
+
expect(def.actions).toHaveLength(2);
|
|
74
|
+
const create = def.actions?.find((a) => a.key === 'create_issue');
|
|
75
|
+
expect(create?.description).toBe('Create a GitHub issue');
|
|
76
|
+
expect(create?.label).toBe('Create Issue');
|
|
77
|
+
expect(create?.inputSchema).toEqual(SAMPLE_TOOLS[0].inputSchema);
|
|
78
|
+
// Tools without an outputSchema leave it unset.
|
|
79
|
+
expect(create?.outputSchema).toBeUndefined();
|
|
80
|
+
|
|
81
|
+
const list = def.actions?.find((a) => a.key === 'list_issues');
|
|
82
|
+
expect(list?.outputSchema).toEqual(SAMPLE_TOOLS[1].outputSchema);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('derives a snake_case name from the label when name is omitted', async () => {
|
|
86
|
+
const { client } = fakeClient(SAMPLE_TOOLS);
|
|
87
|
+
const { def } = await createMcpConnector({
|
|
88
|
+
label: 'My Cool Server!',
|
|
89
|
+
transport: { kind: 'http', url: 'https://mcp.example.com' },
|
|
90
|
+
clientFactory: async () => client,
|
|
91
|
+
});
|
|
92
|
+
expect(def.name).toBe('my_cool_server');
|
|
93
|
+
expect(def.name).toMatch(/^[a-z_][a-z0-9_]*$/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('applies the include allowlist to keep the palette lean', async () => {
|
|
97
|
+
const { client } = fakeClient(SAMPLE_TOOLS);
|
|
98
|
+
const { def, handlers } = await createMcpConnector({
|
|
99
|
+
name: 'gh',
|
|
100
|
+
transport: { kind: 'stdio', command: 'noop' },
|
|
101
|
+
include: (tool) => tool === 'create_issue',
|
|
102
|
+
clientFactory: async () => client,
|
|
103
|
+
});
|
|
104
|
+
expect(def.actions).toHaveLength(1);
|
|
105
|
+
expect(def.actions?.[0].key).toBe('create_issue');
|
|
106
|
+
expect(Object.keys(handlers)).toEqual(['create_issue']);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('passes the configured transport and client info to the factory', async () => {
|
|
110
|
+
const { client } = fakeClient(SAMPLE_TOOLS);
|
|
111
|
+
const factory = vi.fn(async () => client);
|
|
112
|
+
const transport = { kind: 'http' as const, url: 'https://mcp.example.com', headers: { Authorization: 'Bearer t' } };
|
|
113
|
+
await createMcpConnector({
|
|
114
|
+
name: 'gh',
|
|
115
|
+
transport,
|
|
116
|
+
clientInfo: { name: 'tester', version: '9.9.9' },
|
|
117
|
+
clientFactory: factory,
|
|
118
|
+
});
|
|
119
|
+
expect(factory).toHaveBeenCalledWith(transport, { name: 'tester', version: '9.9.9' });
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ─── tools/call dispatch + result normalisation ───────────────────────
|
|
124
|
+
|
|
125
|
+
describe('createMcpConnector — handlers dispatch to tools/call', () => {
|
|
126
|
+
it('forwards input to callTool and normalises the result to an { ok, content } envelope', async () => {
|
|
127
|
+
const { client, calls } = fakeClient(SAMPLE_TOOLS, {
|
|
128
|
+
content: [{ type: 'text', text: 'created #1' }],
|
|
129
|
+
structuredContent: { number: 1 },
|
|
130
|
+
});
|
|
131
|
+
const { handlers } = await createMcpConnector({
|
|
132
|
+
name: 'gh',
|
|
133
|
+
transport: { kind: 'stdio', command: 'noop' },
|
|
134
|
+
clientFactory: async () => client,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const out = await handlers.create_issue({ repo: 'acme/app', title: 'Bug' }, {});
|
|
138
|
+
|
|
139
|
+
expect(calls).toEqual([{ name: 'create_issue', args: { repo: 'acme/app', title: 'Bug' } }]);
|
|
140
|
+
expect(out).toEqual({
|
|
141
|
+
ok: true,
|
|
142
|
+
content: [{ type: 'text', text: 'created #1' }],
|
|
143
|
+
structuredContent: { number: 1 },
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('surfaces a tool error as ok:false without throwing', async () => {
|
|
148
|
+
const { client } = fakeClient(SAMPLE_TOOLS, {
|
|
149
|
+
isError: true,
|
|
150
|
+
content: [{ type: 'text', text: 'boom' }],
|
|
151
|
+
});
|
|
152
|
+
const { handlers } = await createMcpConnector({
|
|
153
|
+
name: 'gh',
|
|
154
|
+
transport: { kind: 'stdio', command: 'noop' },
|
|
155
|
+
clientFactory: async () => client,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
const out = await handlers.list_issues({ repo: 'acme/app' }, {});
|
|
159
|
+
expect(out).toEqual({ ok: false, content: [{ type: 'text', text: 'boom' }], isError: true });
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
// ─── lifecycle ────────────────────────────────────────────────────────
|
|
164
|
+
|
|
165
|
+
describe('createMcpConnector — lifecycle', () => {
|
|
166
|
+
it('exposes close() that tears the client down', async () => {
|
|
167
|
+
const { client, isClosed } = fakeClient(SAMPLE_TOOLS);
|
|
168
|
+
const { close } = await createMcpConnector({
|
|
169
|
+
name: 'gh',
|
|
170
|
+
transport: { kind: 'stdio', command: 'noop' },
|
|
171
|
+
clientFactory: async () => client,
|
|
172
|
+
});
|
|
173
|
+
expect(isClosed()).toBe(false);
|
|
174
|
+
await close();
|
|
175
|
+
expect(isClosed()).toBe(true);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('closes the client when discovery (tools/list) fails', async () => {
|
|
179
|
+
let closed = false;
|
|
180
|
+
const client: McpClientLike = {
|
|
181
|
+
listTools: async () => { throw new Error('list boom'); },
|
|
182
|
+
callTool: async () => ({}),
|
|
183
|
+
close: async () => { closed = true; },
|
|
184
|
+
};
|
|
185
|
+
await expect(
|
|
186
|
+
createMcpConnector({
|
|
187
|
+
name: 'gh',
|
|
188
|
+
transport: { kind: 'stdio', command: 'noop' },
|
|
189
|
+
clientFactory: async () => client,
|
|
190
|
+
}),
|
|
191
|
+
).rejects.toThrow('list boom');
|
|
192
|
+
expect(closed).toBe(true);
|
|
193
|
+
});
|
|
194
|
+
});
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
|
|
2
|
+
|
|
3
|
+
import type { Connector } from '@objectstack/spec/integration';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* MCP connector — a *generic* adapter that turns any Model Context Protocol
|
|
7
|
+
* server into a {@link Connector} (ADR-0024). Where `connector-rest` and
|
|
8
|
+
* `connector-slack` are concrete, per-service connectors, this one is a single
|
|
9
|
+
* adapter that adopts the entire MCP ecosystem with **no per-server code**:
|
|
10
|
+
*
|
|
11
|
+
* 1. connect to the MCP server over the configured transport,
|
|
12
|
+
* 2. call `tools/list` and map each tool to a connector action
|
|
13
|
+
* (`name → key`, `description → label/description`, `inputSchema → inputSchema`),
|
|
14
|
+
* 3. build an ordinary `type: 'api'` {@link Connector} once, and
|
|
15
|
+
* 4. dispatch each `connector_action` call to the server's `tools/call`.
|
|
16
|
+
*
|
|
17
|
+
* After construction the registry, the `connector_action` node, the discovery
|
|
18
|
+
* route, and the Studio palette all see a plain connector — they never know it
|
|
19
|
+
* is backed by MCP (ADR-0024 §2).
|
|
20
|
+
*
|
|
21
|
+
* **Credentials live with the MCP server, not in `ConnectorSchema`** (ADR-0024
|
|
22
|
+
* §3). The operator supplies `env` (stdio) / `headers` (http) which we pass
|
|
23
|
+
* straight to the transport; they are never copied into the serialized `def`
|
|
24
|
+
* (which is exposed via discovery) and must never be logged.
|
|
25
|
+
*
|
|
26
|
+
* **Trust:** launching a stdio server runs a local process. Sandboxed,
|
|
27
|
+
* multi-tenant execution and managed secrets are the enterprise tier (ADR-0024
|
|
28
|
+
* §4); the open adapter runs an operator-provided server with operator-provided
|
|
29
|
+
* credentials and documents that trust assumption.
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
/** How to reach the MCP server. */
|
|
33
|
+
export type McpTransport =
|
|
34
|
+
| {
|
|
35
|
+
kind: 'stdio';
|
|
36
|
+
/** Executable to launch (e.g. `npx`). */
|
|
37
|
+
command: string;
|
|
38
|
+
/** Arguments passed to the command. */
|
|
39
|
+
args?: string[];
|
|
40
|
+
/** Environment variables for the child process — carries credentials. */
|
|
41
|
+
env?: Record<string, string>;
|
|
42
|
+
}
|
|
43
|
+
| {
|
|
44
|
+
kind: 'http';
|
|
45
|
+
/** Streamable-HTTP endpoint of the MCP server. */
|
|
46
|
+
url: string;
|
|
47
|
+
/** Headers sent on every request — carries credentials (e.g. a bearer token). */
|
|
48
|
+
headers?: Record<string, string>;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** A tool as advertised by an MCP server's `tools/list`. */
|
|
52
|
+
export interface McpToolDescriptor {
|
|
53
|
+
name: string;
|
|
54
|
+
description?: string;
|
|
55
|
+
/** JSON Schema for the tool's arguments. */
|
|
56
|
+
inputSchema?: Record<string, unknown>;
|
|
57
|
+
/** JSON Schema for the tool's result (optional — many servers omit it). */
|
|
58
|
+
outputSchema?: Record<string, unknown>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The minimal slice of an MCP client the adapter needs. Kept structural so
|
|
63
|
+
* tests can inject a fake and the real SDK stays an implementation detail
|
|
64
|
+
* (mirrors `fetchImpl` injection in `connector-rest`).
|
|
65
|
+
*/
|
|
66
|
+
export interface McpClientLike {
|
|
67
|
+
/** List the server's tools (`tools/list`). */
|
|
68
|
+
listTools(): Promise<McpToolDescriptor[]>;
|
|
69
|
+
/** Invoke a tool (`tools/call`); returns the raw MCP result. */
|
|
70
|
+
callTool(name: string, args: Record<string, unknown>): Promise<unknown>;
|
|
71
|
+
/** Close the connection / tear down the transport. */
|
|
72
|
+
close(): Promise<void>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface McpConnectorOptions {
|
|
76
|
+
/** Connector machine name (snake_case). Defaults to a slug of `label`, else `mcp`. */
|
|
77
|
+
name?: string;
|
|
78
|
+
/** Human-readable label. Defaults to a title derived from `name`. */
|
|
79
|
+
label?: string;
|
|
80
|
+
/** Connector description for the palette. */
|
|
81
|
+
description?: string;
|
|
82
|
+
/** Icon identifier. Defaults to `plug`. */
|
|
83
|
+
icon?: string;
|
|
84
|
+
/** How to reach the MCP server. */
|
|
85
|
+
transport: McpTransport;
|
|
86
|
+
/** Only expose tools whose name matches (allowlist) — keeps the palette lean. */
|
|
87
|
+
include?: (toolName: string) => boolean;
|
|
88
|
+
/** Identifies this client to the MCP server during the handshake. */
|
|
89
|
+
clientInfo?: { name: string; version: string };
|
|
90
|
+
/**
|
|
91
|
+
* Injected for tests; defaults to the real SDK-backed client. Receives the
|
|
92
|
+
* configured transport and returns a connected {@link McpClientLike}.
|
|
93
|
+
*/
|
|
94
|
+
clientFactory?: (transport: McpTransport, clientInfo: { name: string; version: string }) => Promise<McpClientLike>;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A connector definition + handlers, ready for `engine.registerConnector()`,
|
|
99
|
+
* plus a `close()` for the connection lifecycle (called by the plugin's stop()).
|
|
100
|
+
*/
|
|
101
|
+
export interface McpConnectorBundle {
|
|
102
|
+
def: Connector;
|
|
103
|
+
handlers: Record<
|
|
104
|
+
string,
|
|
105
|
+
(input: Record<string, unknown>, ctx: unknown) => Promise<Record<string, unknown>>
|
|
106
|
+
>;
|
|
107
|
+
/** Tear down the MCP client/connection. */
|
|
108
|
+
close(): Promise<void>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const DEFAULT_CLIENT_INFO = { name: 'objectstack-connector-mcp', version: '1.0.0' } as const;
|
|
112
|
+
|
|
113
|
+
/** Slugify a label into a valid connector `name` (`/^[a-z_][a-z0-9_]*$/`). */
|
|
114
|
+
function slugify(input: string): string {
|
|
115
|
+
const slug = input
|
|
116
|
+
.toLowerCase()
|
|
117
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
118
|
+
.replace(/^_+|_+$/g, '');
|
|
119
|
+
if (!slug) return 'mcp';
|
|
120
|
+
// The name must start with a letter or underscore.
|
|
121
|
+
return /^[a-z_]/.test(slug) ? slug : `mcp_${slug}`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Title-case a snake_case name for a default label (`github_issues` → `Github Issues`). */
|
|
125
|
+
function titleize(name: string): string {
|
|
126
|
+
return name
|
|
127
|
+
.split('_')
|
|
128
|
+
.filter(Boolean)
|
|
129
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
130
|
+
.join(' ');
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Normalise an MCP `tools/call` result into the connector handler's return
|
|
135
|
+
* shape, mirroring the `{ ok, … }` envelope the other connectors expose. An MCP
|
|
136
|
+
* result carries `content` blocks and an optional `isError` flag /
|
|
137
|
+
* `structuredContent`; we surface `ok` from `isError` (never throwing on a
|
|
138
|
+
* logical tool error so the flow author can branch on `${node.ok}`).
|
|
139
|
+
*/
|
|
140
|
+
function normalizeResult(raw: unknown): Record<string, unknown> {
|
|
141
|
+
const result = (raw ?? {}) as Record<string, unknown>;
|
|
142
|
+
const isError = result.isError === true;
|
|
143
|
+
const out: Record<string, unknown> = {
|
|
144
|
+
ok: !isError,
|
|
145
|
+
content: result.content ?? [],
|
|
146
|
+
};
|
|
147
|
+
if (result.structuredContent !== undefined) out.structuredContent = result.structuredContent;
|
|
148
|
+
if (isError) out.isError = true;
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The default {@link McpClientLike} — lazily imports the official MCP SDK so it
|
|
154
|
+
* is only loaded when a real connection is made (tests inject their own client).
|
|
155
|
+
*/
|
|
156
|
+
async function defaultClientFactory(
|
|
157
|
+
transport: McpTransport,
|
|
158
|
+
clientInfo: { name: string; version: string },
|
|
159
|
+
): Promise<McpClientLike> {
|
|
160
|
+
const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
|
|
161
|
+
const client = new Client(clientInfo, { capabilities: {} });
|
|
162
|
+
|
|
163
|
+
if (transport.kind === 'stdio') {
|
|
164
|
+
const { StdioClientTransport } = await import('@modelcontextprotocol/sdk/client/stdio.js');
|
|
165
|
+
await client.connect(
|
|
166
|
+
new StdioClientTransport({
|
|
167
|
+
command: transport.command,
|
|
168
|
+
args: transport.args,
|
|
169
|
+
env: transport.env,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
} else {
|
|
173
|
+
const { StreamableHTTPClientTransport } = await import(
|
|
174
|
+
'@modelcontextprotocol/sdk/client/streamableHttp.js'
|
|
175
|
+
);
|
|
176
|
+
await client.connect(
|
|
177
|
+
new StreamableHTTPClientTransport(new URL(transport.url), {
|
|
178
|
+
requestInit: transport.headers ? { headers: transport.headers } : undefined,
|
|
179
|
+
}),
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
async listTools() {
|
|
185
|
+
const res = await client.listTools();
|
|
186
|
+
return (res.tools ?? []) as McpToolDescriptor[];
|
|
187
|
+
},
|
|
188
|
+
async callTool(name, args) {
|
|
189
|
+
return client.callTool({ name, arguments: args });
|
|
190
|
+
},
|
|
191
|
+
async close() {
|
|
192
|
+
await client.close();
|
|
193
|
+
},
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Connect to an MCP server, discover its tools, and build a {@link Connector}
|
|
199
|
+
* whose actions dispatch to the server's `tools/call`. The connection is held
|
|
200
|
+
* open for the lifetime of the bundle; call {@link McpConnectorBundle.close} to
|
|
201
|
+
* tear it down.
|
|
202
|
+
*/
|
|
203
|
+
export async function createMcpConnector(opts: McpConnectorOptions): Promise<McpConnectorBundle> {
|
|
204
|
+
const clientInfo = opts.clientInfo ?? DEFAULT_CLIENT_INFO;
|
|
205
|
+
const factory = opts.clientFactory ?? defaultClientFactory;
|
|
206
|
+
|
|
207
|
+
const client = await factory(opts.transport, clientInfo);
|
|
208
|
+
|
|
209
|
+
let tools: McpToolDescriptor[];
|
|
210
|
+
try {
|
|
211
|
+
tools = await client.listTools();
|
|
212
|
+
} catch (err) {
|
|
213
|
+
// Discovery failed after connecting — release the connection rather than
|
|
214
|
+
// leaking it, then surface the error to the caller (the plugin fail-soft).
|
|
215
|
+
await client.close().catch(() => {});
|
|
216
|
+
throw err;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const include = opts.include ?? (() => true);
|
|
220
|
+
const selected = tools.filter((t) => include(t.name));
|
|
221
|
+
|
|
222
|
+
const name = opts.name ?? slugify(opts.label ?? 'mcp');
|
|
223
|
+
const label = opts.label ?? titleize(name);
|
|
224
|
+
|
|
225
|
+
const handlers: McpConnectorBundle['handlers'] = {};
|
|
226
|
+
const def: Connector = {
|
|
227
|
+
name,
|
|
228
|
+
label,
|
|
229
|
+
type: 'api',
|
|
230
|
+
description:
|
|
231
|
+
opts.description ?? `MCP connector exposing ${selected.length} tool(s) from a Model Context Protocol server.`,
|
|
232
|
+
icon: opts.icon ?? 'plug',
|
|
233
|
+
// MCP servers own their own auth (passed via transport env/headers); we
|
|
234
|
+
// do not model the upstream's credentials in ConnectorSchema (ADR-0024 §3).
|
|
235
|
+
authentication: { type: 'none' },
|
|
236
|
+
// Defaulted by ConnectorSchema; set explicitly so the literal satisfies
|
|
237
|
+
// the (post-parse) Connector output type.
|
|
238
|
+
status: 'active',
|
|
239
|
+
enabled: true,
|
|
240
|
+
connectionTimeoutMs: 30000,
|
|
241
|
+
requestTimeoutMs: 30000,
|
|
242
|
+
actions: selected.map((tool) => ({
|
|
243
|
+
key: tool.name,
|
|
244
|
+
// MCP tool names are machine names; derive a readable label and keep
|
|
245
|
+
// the server's description verbatim (ADR-0024 `description → label/description`).
|
|
246
|
+
label: titleize(slugify(tool.name)),
|
|
247
|
+
description: tool.description,
|
|
248
|
+
// The MCP inputSchema is already JSON Schema — pass it straight through.
|
|
249
|
+
inputSchema: tool.inputSchema,
|
|
250
|
+
// Many servers omit outputSchema; leave it unset when absent (as the
|
|
251
|
+
// REST connector does for untyped responses).
|
|
252
|
+
outputSchema: tool.outputSchema,
|
|
253
|
+
})),
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
for (const tool of selected) {
|
|
257
|
+
handlers[tool.name] = async (input) => normalizeResult(await client.callTool(tool.name, input));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
return {
|
|
261
|
+
def,
|
|
262
|
+
handlers,
|
|
263
|
+
close: () => client.close(),
|
|
264
|
+
};
|
|
265
|
+
}
|