@ankhorage/flow 0.0.0-stage → 0.4.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/CHANGELOG.md +33 -0
- package/LICENSE +21 -0
- package/README.md +17 -2
- package/dist/ai.d.ts +7 -0
- package/dist/ai.d.ts.map +1 -0
- package/dist/ai.js +7 -0
- package/dist/ai.js.map +1 -0
- package/dist/features/execution/application/executeFlowAsync.d.ts +4 -0
- package/dist/features/execution/application/executeFlowAsync.d.ts.map +1 -0
- package/dist/features/execution/application/executeFlowAsync.js +211 -0
- package/dist/features/execution/application/executeFlowAsync.js.map +1 -0
- package/dist/features/execution/domain/assertFlowGraphSchemasSupported.d.ts +5 -0
- package/dist/features/execution/domain/assertFlowGraphSchemasSupported.d.ts.map +1 -0
- package/dist/features/execution/domain/assertFlowGraphSchemasSupported.js +11 -0
- package/dist/features/execution/domain/assertFlowGraphSchemasSupported.js.map +1 -0
- package/dist/features/execution/domain/compileFlowGraph.d.ts +5 -0
- package/dist/features/execution/domain/compileFlowGraph.d.ts.map +1 -0
- package/dist/features/execution/domain/compileFlowGraph.js +76 -0
- package/dist/features/execution/domain/compileFlowGraph.js.map +1 -0
- package/dist/features/execution/domain/createDefaultNodeRegistry.d.ts +4 -0
- package/dist/features/execution/domain/createDefaultNodeRegistry.d.ts.map +1 -0
- package/dist/features/execution/domain/createDefaultNodeRegistry.js +15 -0
- package/dist/features/execution/domain/createDefaultNodeRegistry.js.map +1 -0
- package/dist/features/execution/domain/validateFlowDataSchema.d.ts +6 -0
- package/dist/features/execution/domain/validateFlowDataSchema.d.ts.map +1 -0
- package/dist/features/execution/domain/validateFlowDataSchema.js +144 -0
- package/dist/features/execution/domain/validateFlowDataSchema.js.map +1 -0
- package/dist/features/mcp/McpToolError.d.ts +6 -0
- package/dist/features/mcp/McpToolError.d.ts.map +1 -0
- package/dist/features/mcp/McpToolError.js +10 -0
- package/dist/features/mcp/McpToolError.js.map +1 -0
- package/dist/features/mcp/createMcpRemoteTransport.d.ts +8 -0
- package/dist/features/mcp/createMcpRemoteTransport.d.ts.map +1 -0
- package/dist/features/mcp/createMcpRemoteTransport.js +23 -0
- package/dist/features/mcp/createMcpRemoteTransport.js.map +1 -0
- package/dist/features/mcp/createMcpToolRegistry.d.ts +10 -0
- package/dist/features/mcp/createMcpToolRegistry.d.ts.map +1 -0
- package/dist/features/mcp/createMcpToolRegistry.js +185 -0
- package/dist/features/mcp/createMcpToolRegistry.js.map +1 -0
- package/dist/features/mcp/loadMcpSdkAsync.d.ts +12 -0
- package/dist/features/mcp/loadMcpSdkAsync.d.ts.map +1 -0
- package/dist/features/mcp/loadMcpSdkAsync.js +20 -0
- package/dist/features/mcp/loadMcpSdkAsync.js.map +1 -0
- package/dist/features/mcp/prepareMcpProviders.d.ts +4 -0
- package/dist/features/mcp/prepareMcpProviders.d.ts.map +1 -0
- package/dist/features/mcp/prepareMcpProviders.js +97 -0
- package/dist/features/mcp/prepareMcpProviders.js.map +1 -0
- package/dist/features/mcp/validateMcpSchema.d.ts +6 -0
- package/dist/features/mcp/validateMcpSchema.d.ts.map +1 -0
- package/dist/features/mcp/validateMcpSchema.js +220 -0
- package/dist/features/mcp/validateMcpSchema.js.map +1 -0
- package/dist/features/security/FlowSecurityError.d.ts +6 -0
- package/dist/features/security/FlowSecurityError.d.ts.map +1 -0
- package/dist/features/security/FlowSecurityError.js +10 -0
- package/dist/features/security/FlowSecurityError.js.map +1 -0
- package/dist/features/security/application/createFlowSecurityPort.d.ts +12 -0
- package/dist/features/security/application/createFlowSecurityPort.d.ts.map +1 -0
- package/dist/features/security/application/createFlowSecurityPort.js +208 -0
- package/dist/features/security/application/createFlowSecurityPort.js.map +1 -0
- package/dist/features/security/domain/validateFlowSecurityTarget.d.ts +4 -0
- package/dist/features/security/domain/validateFlowSecurityTarget.d.ts.map +1 -0
- package/dist/features/security/domain/validateFlowSecurityTarget.js +57 -0
- package/dist/features/security/domain/validateFlowSecurityTarget.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp.d.ts +4 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +3 -0
- package/dist/mcp.js.map +1 -0
- package/dist/social.d.ts +7 -0
- package/dist/social.d.ts.map +1 -0
- package/dist/social.js +7 -0
- package/dist/social.js.map +1 -0
- package/dist/types/execution.d.ts +33 -0
- package/dist/types/execution.d.ts.map +1 -0
- package/dist/types/execution.js +2 -0
- package/dist/types/execution.js.map +1 -0
- package/dist/types/mcp.d.ts +61 -0
- package/dist/types/mcp.d.ts.map +1 -0
- package/dist/types/mcp.js +2 -0
- package/dist/types/mcp.js.map +1 -0
- package/dist/types/security.d.ts +123 -0
- package/dist/types/security.d.ts.map +1 -0
- package/dist/types/security.js +2 -0
- package/dist/types/security.js.map +1 -0
- package/package.json +86 -4
- package/src/ai.ts +6 -0
- package/src/features/execution/application/executeFlowAsync.ts +338 -0
- package/src/features/execution/domain/assertFlowGraphSchemasSupported.ts +17 -0
- package/src/features/execution/domain/compileFlowGraph.ts +97 -0
- package/src/features/execution/domain/createDefaultNodeRegistry.ts +26 -0
- package/src/features/execution/domain/validateFlowDataSchema.ts +218 -0
- package/src/features/mcp/McpToolError.ts +10 -0
- package/src/features/mcp/createMcpRemoteTransport.ts +34 -0
- package/src/features/mcp/createMcpToolRegistry.ts +324 -0
- package/src/features/mcp/loadMcpSdkAsync.ts +32 -0
- package/src/features/mcp/prepareMcpProviders.ts +135 -0
- package/src/features/mcp/validateMcpSchema.ts +269 -0
- package/src/features/security/FlowSecurityError.ts +17 -0
- package/src/features/security/application/createFlowSecurityPort.ts +339 -0
- package/src/features/security/domain/validateFlowSecurityTarget.ts +73 -0
- package/src/index.ts +35 -0
- package/src/mcp.ts +11 -0
- package/src/social.ts +6 -0
- package/src/types/execution.ts +41 -0
- package/src/types/mcp.ts +70 -0
- package/src/types/security.ts +148 -0
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
import type { Client as McpClient } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import type { StdioClientTransport as McpStdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
3
|
+
|
|
4
|
+
import type {
|
|
5
|
+
McpProviderDefinition,
|
|
6
|
+
McpToolInvocation,
|
|
7
|
+
McpToolMetadata,
|
|
8
|
+
McpToolPermission,
|
|
9
|
+
McpToolRegistry,
|
|
10
|
+
McpToolResult,
|
|
11
|
+
McpTrustedStdioProvider,
|
|
12
|
+
} from '../../types/mcp.js';
|
|
13
|
+
import type { FlowOutboundTransportPort, FlowSecurityPort } from '../../types/security.js';
|
|
14
|
+
import { createMcpRemoteTransportAsync } from './createMcpRemoteTransport.js';
|
|
15
|
+
import { loadMcpSdkAsync } from './loadMcpSdkAsync.js';
|
|
16
|
+
import { McpToolError } from './McpToolError.js';
|
|
17
|
+
import { prepareMcpProviders } from './prepareMcpProviders.js';
|
|
18
|
+
import { validateMcpSchema } from './validateMcpSchema.js';
|
|
19
|
+
|
|
20
|
+
const MAX_PAGES = 20;
|
|
21
|
+
const MAX_TOOLS = 100;
|
|
22
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
23
|
+
|
|
24
|
+
/*** Creates a snapshotting, allowlist-only MCP registry. Real invocation fails closed until Issue #4 releases its approval boundary. */
|
|
25
|
+
export const createMcpToolRegistry = (input: {
|
|
26
|
+
readonly providers: readonly McpProviderDefinition[];
|
|
27
|
+
readonly outboundTransport?: FlowOutboundTransportPort;
|
|
28
|
+
readonly security?: FlowSecurityPort;
|
|
29
|
+
readonly trustedStdioProviders?: Readonly<Record<string, McpTrustedStdioProvider>>;
|
|
30
|
+
}): McpToolRegistry => {
|
|
31
|
+
const providers = prepareMcpProviders(input.providers);
|
|
32
|
+
const trusted = new Map(
|
|
33
|
+
Object.entries(input.trustedStdioProviders ?? {}).map(([id, value]) => [
|
|
34
|
+
id,
|
|
35
|
+
{
|
|
36
|
+
command: value.command,
|
|
37
|
+
...(value.args ? { args: [...value.args] } : {}),
|
|
38
|
+
},
|
|
39
|
+
]),
|
|
40
|
+
);
|
|
41
|
+
const snapshot = new Map<string, McpToolMetadata>();
|
|
42
|
+
return {
|
|
43
|
+
discoverAsync: async (signal) => {
|
|
44
|
+
const groups = await Promise.all(
|
|
45
|
+
[...providers.values()].map((provider) =>
|
|
46
|
+
discoverAsync(provider, trusted, input.outboundTransport, signal),
|
|
47
|
+
),
|
|
48
|
+
);
|
|
49
|
+
const tools = groups.flat();
|
|
50
|
+
const entries = tools.map((tool) => [key(tool.providerId, tool.name), tool] as const);
|
|
51
|
+
if (new Set(entries.map(([identifier]) => identifier)).size !== entries.length)
|
|
52
|
+
throw new McpToolError(
|
|
53
|
+
'duplicate_tool',
|
|
54
|
+
'MCP discovery returned duplicate provider tool identifiers.',
|
|
55
|
+
);
|
|
56
|
+
snapshot.clear();
|
|
57
|
+
entries.forEach(([identifier, tool]) => snapshot.set(identifier, tool));
|
|
58
|
+
return tools;
|
|
59
|
+
},
|
|
60
|
+
invokeAsync: (invocation) =>
|
|
61
|
+
invokeAsync(
|
|
62
|
+
providers,
|
|
63
|
+
trusted,
|
|
64
|
+
snapshot,
|
|
65
|
+
input.security,
|
|
66
|
+
input.outboundTransport,
|
|
67
|
+
invocation,
|
|
68
|
+
),
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/*** Discovers bounded tool metadata from one trusted provider connection. */
|
|
73
|
+
const discoverAsync = async (
|
|
74
|
+
provider: McpProviderDefinition,
|
|
75
|
+
trusted: ReadonlyMap<string, McpTrustedStdioProvider>,
|
|
76
|
+
outboundTransport: FlowOutboundTransportPort | undefined,
|
|
77
|
+
signal?: AbortSignal,
|
|
78
|
+
): Promise<readonly McpToolMetadata[]> => {
|
|
79
|
+
const connection = await connectAsync(provider, trusted, outboundTransport, signal);
|
|
80
|
+
try {
|
|
81
|
+
const tools = await listToolsAsync(connection.client, timeoutOf(provider), signal);
|
|
82
|
+
return tools
|
|
83
|
+
.filter((tool) => permissionFor(provider, tool.name))
|
|
84
|
+
.map((tool) => toMetadata(provider, tool));
|
|
85
|
+
} finally {
|
|
86
|
+
await connection.closeAsync().catch(() => undefined);
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/*** Validates, approves, and invokes one discovered tool through the server-only security port. */
|
|
91
|
+
const invokeAsync = async (
|
|
92
|
+
providers: ReadonlyMap<string, McpProviderDefinition>,
|
|
93
|
+
trusted: ReadonlyMap<string, McpTrustedStdioProvider>,
|
|
94
|
+
snapshot: ReadonlyMap<string, McpToolMetadata>,
|
|
95
|
+
security: FlowSecurityPort | undefined,
|
|
96
|
+
outboundTransport: FlowOutboundTransportPort | undefined,
|
|
97
|
+
invocation: McpToolInvocation,
|
|
98
|
+
): Promise<McpToolResult> => {
|
|
99
|
+
const provider = providers.get(invocation.providerId);
|
|
100
|
+
const tool = snapshot.get(key(invocation.providerId, invocation.toolName));
|
|
101
|
+
if (!provider || !tool || !permissionFor(provider, invocation.toolName))
|
|
102
|
+
throw new McpToolError('tool_not_discovered', 'The requested MCP tool is not allowlisted.');
|
|
103
|
+
if (tool.schemaFingerprint !== invocation.schemaFingerprint)
|
|
104
|
+
throw new McpToolError(
|
|
105
|
+
'schema_changed',
|
|
106
|
+
'The MCP tool schema changed; rediscover it before invoking.',
|
|
107
|
+
);
|
|
108
|
+
const permission = permissionFor(provider, invocation.toolName);
|
|
109
|
+
if (!permission || !sameScopes(permission.grantedScopes, invocation.scopes))
|
|
110
|
+
throw new McpToolError(
|
|
111
|
+
'scope_denied',
|
|
112
|
+
'MCP tool invocation scopes are not explicitly granted.',
|
|
113
|
+
);
|
|
114
|
+
if (!validateMcpSchema(tool.inputSchema).matches(invocation.arguments))
|
|
115
|
+
throw new McpToolError(
|
|
116
|
+
'invalid_arguments',
|
|
117
|
+
'MCP tool arguments do not satisfy the discovered schema.',
|
|
118
|
+
);
|
|
119
|
+
if (!security)
|
|
120
|
+
throw new McpToolError(
|
|
121
|
+
'authorization_boundary_unavailable',
|
|
122
|
+
'MCP tool execution is unavailable until Flow security approvals are installed.',
|
|
123
|
+
);
|
|
124
|
+
const permit = await security.authorizeAsync({
|
|
125
|
+
...invocation.security,
|
|
126
|
+
capability: `mcp:${provider.id}:${tool.name}`,
|
|
127
|
+
...(provider.transport === 'remote'
|
|
128
|
+
? { target: { allowedHosts: provider.allowedHosts, url: provider.endpoint } }
|
|
129
|
+
: {}),
|
|
130
|
+
});
|
|
131
|
+
return permit.executeAsync(async () =>
|
|
132
|
+
callToolAsync(provider, trusted, outboundTransport, invocation),
|
|
133
|
+
);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/*** Opens a bounded provider connection only after the approval permit was issued. */
|
|
137
|
+
const callToolAsync = async (
|
|
138
|
+
provider: McpProviderDefinition,
|
|
139
|
+
trusted: ReadonlyMap<string, McpTrustedStdioProvider>,
|
|
140
|
+
outboundTransport: FlowOutboundTransportPort | undefined,
|
|
141
|
+
invocation: McpToolInvocation,
|
|
142
|
+
): Promise<McpToolResult> => {
|
|
143
|
+
const connection = await connectAsync(provider, trusted, outboundTransport, invocation.signal);
|
|
144
|
+
try {
|
|
145
|
+
const result = await boundedAsync(
|
|
146
|
+
(signal) =>
|
|
147
|
+
connection.client.callTool(
|
|
148
|
+
{ name: invocation.toolName, arguments: invocation.arguments },
|
|
149
|
+
undefined,
|
|
150
|
+
{ signal },
|
|
151
|
+
),
|
|
152
|
+
timeoutOf(provider),
|
|
153
|
+
invocation.signal,
|
|
154
|
+
);
|
|
155
|
+
if (!Array.isArray(result.content))
|
|
156
|
+
throw new McpToolError(
|
|
157
|
+
'invalid_execution_response',
|
|
158
|
+
'The MCP provider returned an invalid tool result.',
|
|
159
|
+
);
|
|
160
|
+
return {
|
|
161
|
+
content: result.content,
|
|
162
|
+
...(isRecord(result.structuredContent)
|
|
163
|
+
? { structuredContent: result.structuredContent }
|
|
164
|
+
: {}),
|
|
165
|
+
isError: result.isError === true,
|
|
166
|
+
};
|
|
167
|
+
} finally {
|
|
168
|
+
await connection.closeAsync().catch(() => undefined);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
interface ListedTool {
|
|
173
|
+
readonly name: string;
|
|
174
|
+
readonly title?: string;
|
|
175
|
+
readonly description?: string;
|
|
176
|
+
readonly inputSchema: Readonly<Record<string, unknown>>;
|
|
177
|
+
readonly annotations?: { readonly readOnlyHint?: boolean };
|
|
178
|
+
}
|
|
179
|
+
interface Connection {
|
|
180
|
+
readonly client: McpClient;
|
|
181
|
+
closeAsync(): Promise<void>;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/*** Connects with a bounded timeout and an abort signal propagated to the SDK transport. */
|
|
185
|
+
const connectAsync = async (
|
|
186
|
+
provider: McpProviderDefinition,
|
|
187
|
+
trusted: ReadonlyMap<string, McpTrustedStdioProvider>,
|
|
188
|
+
outboundTransport: FlowOutboundTransportPort | undefined,
|
|
189
|
+
signal?: AbortSignal,
|
|
190
|
+
): Promise<Connection> => {
|
|
191
|
+
const { Client, StdioClientTransport } = await loadMcpSdkAsync();
|
|
192
|
+
const client = new Client({ name: '@ankhorage/flow', version: '0.1.0' });
|
|
193
|
+
const transport =
|
|
194
|
+
provider.transport === 'stdio'
|
|
195
|
+
? stdioTransport(provider, trusted, StdioClientTransport)
|
|
196
|
+
: await createMcpRemoteTransportAsync(provider, outboundTransport);
|
|
197
|
+
await boundedAsync(
|
|
198
|
+
(timeoutSignal) =>
|
|
199
|
+
client.connect(transport, { signal: timeoutSignal, timeout: timeoutOf(provider) }),
|
|
200
|
+
timeoutOf(provider),
|
|
201
|
+
signal,
|
|
202
|
+
);
|
|
203
|
+
return { client, closeAsync: () => transport.close() };
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/*** Resolves executable details exclusively from trusted host configuration. */
|
|
207
|
+
const stdioTransport = (
|
|
208
|
+
provider: Extract<McpProviderDefinition, { readonly transport: 'stdio' }>,
|
|
209
|
+
trusted: ReadonlyMap<string, McpTrustedStdioProvider>,
|
|
210
|
+
StdioClientTransport: typeof McpStdioClientTransport,
|
|
211
|
+
): McpStdioClientTransport => {
|
|
212
|
+
const host = trusted.get(provider.id);
|
|
213
|
+
if (!host)
|
|
214
|
+
throw new McpToolError(
|
|
215
|
+
'untrusted_stdio_provider',
|
|
216
|
+
'STDIO MCP providers require trusted host command configuration.',
|
|
217
|
+
);
|
|
218
|
+
return new StdioClientTransport({
|
|
219
|
+
command: host.command,
|
|
220
|
+
args: host.args ? [...host.args] : undefined,
|
|
221
|
+
stderr: 'pipe',
|
|
222
|
+
});
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/*** Lists a bounded, non-cyclic sequence of paginated tools. */
|
|
226
|
+
const listToolsAsync = async (
|
|
227
|
+
client: McpClient,
|
|
228
|
+
timeoutMs: number,
|
|
229
|
+
signal?: AbortSignal,
|
|
230
|
+
): Promise<readonly ListedTool[]> => {
|
|
231
|
+
const tools: ListedTool[] = [];
|
|
232
|
+
const cursors = new Set<string>();
|
|
233
|
+
let cursor: string | undefined;
|
|
234
|
+
do {
|
|
235
|
+
if (cursors.size >= MAX_PAGES)
|
|
236
|
+
throw new McpToolError('pagination_limit', 'MCP tool discovery exceeded the page limit.');
|
|
237
|
+
if (cursor) {
|
|
238
|
+
if (cursors.has(cursor))
|
|
239
|
+
throw new McpToolError(
|
|
240
|
+
'pagination_cycle',
|
|
241
|
+
'MCP tool discovery returned a repeated cursor.',
|
|
242
|
+
);
|
|
243
|
+
cursors.add(cursor);
|
|
244
|
+
}
|
|
245
|
+
const page = await boundedAsync(
|
|
246
|
+
(timeoutSignal) =>
|
|
247
|
+
client.listTools(cursor ? { cursor } : undefined, { signal: timeoutSignal }),
|
|
248
|
+
timeoutMs,
|
|
249
|
+
signal,
|
|
250
|
+
);
|
|
251
|
+
if (!Array.isArray(page.tools))
|
|
252
|
+
throw new McpToolError(
|
|
253
|
+
'invalid_discovery_response',
|
|
254
|
+
'The MCP provider returned invalid tool metadata.',
|
|
255
|
+
);
|
|
256
|
+
tools.push(...page.tools);
|
|
257
|
+
if (tools.length > MAX_TOOLS)
|
|
258
|
+
throw new McpToolError('tool_limit', 'MCP tool discovery exceeded the tool limit.');
|
|
259
|
+
cursor = page.nextCursor;
|
|
260
|
+
} while (cursor);
|
|
261
|
+
return tools;
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
/*** Converts valid protocol metadata into a stable Flow descriptor. */
|
|
265
|
+
const toMetadata = (provider: McpProviderDefinition, tool: ListedTool): McpToolMetadata => {
|
|
266
|
+
if (!tool.name || !isRecord(tool.inputSchema))
|
|
267
|
+
throw new McpToolError('invalid_schema', 'The MCP tool has no valid input schema.');
|
|
268
|
+
const permission = permissionFor(provider, tool.name);
|
|
269
|
+
if (!permission)
|
|
270
|
+
throw new McpToolError('tool_not_discovered', 'The requested MCP tool is not allowlisted.');
|
|
271
|
+
const schema = validateMcpSchema(tool.inputSchema);
|
|
272
|
+
return {
|
|
273
|
+
providerId: provider.id,
|
|
274
|
+
name: tool.name,
|
|
275
|
+
...(tool.title ? { title: tool.title } : {}),
|
|
276
|
+
...(tool.description ? { description: tool.description } : {}),
|
|
277
|
+
inputSchema: tool.inputSchema,
|
|
278
|
+
schemaFingerprint: schema.fingerprint,
|
|
279
|
+
scopes: permission.grantedScopes,
|
|
280
|
+
readOnly: tool.annotations?.readOnlyHint === true,
|
|
281
|
+
};
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
/*** Propagates cancellation and a fixed timeout into an asynchronous SDK operation. */
|
|
285
|
+
const boundedAsync = async <T>(
|
|
286
|
+
operation: (signal: AbortSignal) => Promise<T>,
|
|
287
|
+
timeoutMs: number,
|
|
288
|
+
signal?: AbortSignal,
|
|
289
|
+
): Promise<T> => {
|
|
290
|
+
const controller = new AbortController();
|
|
291
|
+
const timer = setTimeout(
|
|
292
|
+
() => controller.abort(new DOMException('Timeout', 'TimeoutError')),
|
|
293
|
+
timeoutMs,
|
|
294
|
+
);
|
|
295
|
+
const combined = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
|
|
296
|
+
try {
|
|
297
|
+
return await operation(combined);
|
|
298
|
+
} catch (error) {
|
|
299
|
+
if (controller.signal.aborted) throw new McpToolError('timed_out', 'MCP operation timed out.');
|
|
300
|
+
if (combined.aborted) throw new McpToolError('cancelled', 'MCP operation was cancelled.');
|
|
301
|
+
throw error;
|
|
302
|
+
} finally {
|
|
303
|
+
clearTimeout(timer);
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
/*** Finds one explicit permission for a protocol tool. */
|
|
307
|
+
const permissionFor = (
|
|
308
|
+
provider: McpProviderDefinition,
|
|
309
|
+
name: string,
|
|
310
|
+
): McpToolPermission | undefined => provider.allowedTools.find((item) => item.name === name);
|
|
311
|
+
/*** Requires every and only the configured granted scope. */
|
|
312
|
+
const sameScopes = (granted: readonly string[], requested: readonly string[]): boolean =>
|
|
313
|
+
requested.length > 0 &&
|
|
314
|
+
requested.length === granted.length &&
|
|
315
|
+
new Set(requested).size === requested.length &&
|
|
316
|
+
requested.every((scope) => granted.includes(scope));
|
|
317
|
+
/*** Builds a provider-scoped tool identifier. */
|
|
318
|
+
const key = (providerId: string, toolName: string): string => `${providerId}:${toolName}`;
|
|
319
|
+
/*** Narrows unknown data to a non-array object. */
|
|
320
|
+
const isRecord = (value: unknown): value is Readonly<Record<string, unknown>> =>
|
|
321
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
322
|
+
/*** Resolves a configured timeout to its bounded default. */
|
|
323
|
+
const timeoutOf = (provider: McpProviderDefinition): number =>
|
|
324
|
+
provider.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
2
|
+
import type { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
3
|
+
import type { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';
|
|
4
|
+
|
|
5
|
+
import { McpToolError } from './McpToolError.js';
|
|
6
|
+
|
|
7
|
+
interface McpSdk {
|
|
8
|
+
readonly Client: typeof Client;
|
|
9
|
+
readonly StdioClientTransport: typeof StdioClientTransport;
|
|
10
|
+
readonly StreamableHTTPClientTransport: typeof StreamableHTTPClientTransport;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/*** Loads the optional MCP SDK only when an approved MCP connection needs to be opened. */
|
|
14
|
+
export const loadMcpSdkAsync = async (): Promise<McpSdk> => {
|
|
15
|
+
try {
|
|
16
|
+
const [client, stdio, streamableHttp] = await Promise.all([
|
|
17
|
+
import('@modelcontextprotocol/sdk/client/index.js'),
|
|
18
|
+
import('@modelcontextprotocol/sdk/client/stdio.js'),
|
|
19
|
+
import('@modelcontextprotocol/sdk/client/streamableHttp.js'),
|
|
20
|
+
]);
|
|
21
|
+
return {
|
|
22
|
+
Client: client.Client,
|
|
23
|
+
StdioClientTransport: stdio.StdioClientTransport,
|
|
24
|
+
StreamableHTTPClientTransport: streamableHttp.StreamableHTTPClientTransport,
|
|
25
|
+
};
|
|
26
|
+
} catch {
|
|
27
|
+
throw new McpToolError(
|
|
28
|
+
'optional_sdk_unavailable',
|
|
29
|
+
'MCP execution requires the optional @modelcontextprotocol/sdk peer dependency.',
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { McpProviderDefinition } from '../../types/mcp.js';
|
|
2
|
+
import { McpToolError } from './McpToolError.js';
|
|
3
|
+
|
|
4
|
+
const MAX_TIMEOUT_MS = 30_000;
|
|
5
|
+
|
|
6
|
+
/*** Snapshots and validates explicit MCP provider authority before any connection is opened. */
|
|
7
|
+
export const prepareMcpProviders = (
|
|
8
|
+
configured: readonly McpProviderDefinition[],
|
|
9
|
+
): ReadonlyMap<string, McpProviderDefinition> => {
|
|
10
|
+
const providers = new Map(
|
|
11
|
+
configured.map(cloneProvider).map((provider) => [provider.id, provider]),
|
|
12
|
+
);
|
|
13
|
+
if (!providers.size || providers.size !== configured.length)
|
|
14
|
+
throw new McpToolError('invalid_provider', 'MCP provider identifiers must be unique.');
|
|
15
|
+
providers.forEach(validateProvider);
|
|
16
|
+
return providers;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/*** Clones provider data so later workflow-object mutation cannot expand authority. */
|
|
20
|
+
const cloneProvider = (provider: McpProviderDefinition): McpProviderDefinition => {
|
|
21
|
+
const common = {
|
|
22
|
+
id: provider.id,
|
|
23
|
+
allowedTools: provider.allowedTools.map((item) => ({
|
|
24
|
+
name: item.name,
|
|
25
|
+
grantedScopes: [...item.grantedScopes],
|
|
26
|
+
})),
|
|
27
|
+
grantedScopes: [...provider.grantedScopes],
|
|
28
|
+
...(provider.timeoutMs === undefined ? {} : { timeoutMs: provider.timeoutMs }),
|
|
29
|
+
};
|
|
30
|
+
return provider.transport === 'stdio'
|
|
31
|
+
? { ...common, transport: 'stdio' }
|
|
32
|
+
: {
|
|
33
|
+
...common,
|
|
34
|
+
transport: 'remote',
|
|
35
|
+
endpoint: provider.endpoint,
|
|
36
|
+
allowedHosts: [...provider.allowedHosts],
|
|
37
|
+
};
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/*** Requires non-empty scopes, unique tool permissions, and safe transport configuration. */
|
|
41
|
+
const validateProvider = (provider: McpProviderDefinition): void => {
|
|
42
|
+
if (!provider.id || !uniqueStrings(provider.grantedScopes) || !provider.allowedTools.length)
|
|
43
|
+
throw new McpToolError(
|
|
44
|
+
'invalid_provider',
|
|
45
|
+
'MCP providers need an id, explicit granted scopes, and tool permissions.',
|
|
46
|
+
);
|
|
47
|
+
if (!uniqueStrings(provider.allowedTools.map((item) => item.name)))
|
|
48
|
+
throw new McpToolError('invalid_provider', 'MCP tool permission names must be unique.');
|
|
49
|
+
if (
|
|
50
|
+
provider.allowedTools.some(
|
|
51
|
+
(item) =>
|
|
52
|
+
!uniqueStrings(item.grantedScopes) ||
|
|
53
|
+
!item.grantedScopes.every((scope) => provider.grantedScopes.includes(scope)),
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
throw new McpToolError(
|
|
57
|
+
'invalid_provider',
|
|
58
|
+
'MCP tools require explicitly granted provider scopes.',
|
|
59
|
+
);
|
|
60
|
+
if (
|
|
61
|
+
provider.timeoutMs !== undefined &&
|
|
62
|
+
(!Number.isSafeInteger(provider.timeoutMs) ||
|
|
63
|
+
provider.timeoutMs < 100 ||
|
|
64
|
+
provider.timeoutMs > MAX_TIMEOUT_MS)
|
|
65
|
+
)
|
|
66
|
+
throw new McpToolError('invalid_provider', 'MCP timeout must be bounded.');
|
|
67
|
+
if (provider.transport === 'remote') validateRemote(provider);
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/*** Rejects credentials, private literals, and endpoints outside their explicit host allowlist. */
|
|
71
|
+
const validateRemote = (
|
|
72
|
+
provider: Extract<McpProviderDefinition, { readonly transport: 'remote' }>,
|
|
73
|
+
): void => {
|
|
74
|
+
let url: URL;
|
|
75
|
+
try {
|
|
76
|
+
url = new URL(provider.endpoint);
|
|
77
|
+
} catch {
|
|
78
|
+
throw new McpToolError('unsafe_endpoint', 'Remote MCP endpoint must be an absolute URL.');
|
|
79
|
+
}
|
|
80
|
+
const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
|
81
|
+
if (url.username || url.password || url.hash)
|
|
82
|
+
throw new McpToolError(
|
|
83
|
+
'unsafe_endpoint',
|
|
84
|
+
'Remote MCP endpoints cannot embed credentials or fragments.',
|
|
85
|
+
);
|
|
86
|
+
if (
|
|
87
|
+
url.protocol !== 'https:' ||
|
|
88
|
+
privateIpv4(host) ||
|
|
89
|
+
privateIpv6(host) ||
|
|
90
|
+
!uniqueStrings(provider.allowedHosts) ||
|
|
91
|
+
!provider.allowedHosts.includes(host)
|
|
92
|
+
)
|
|
93
|
+
throw new McpToolError(
|
|
94
|
+
'unsafe_endpoint',
|
|
95
|
+
'Remote MCP endpoints require HTTPS, a public host allowlist, and no private address.',
|
|
96
|
+
);
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
/*** Detects loopback, link-local, unique-local, and unspecified IPv6 literals. */
|
|
100
|
+
const privateIpv6 = (host: string): boolean => {
|
|
101
|
+
const normalized = host.replace(/^\[|\]$/g, '').toLowerCase();
|
|
102
|
+
return (
|
|
103
|
+
normalized === '::' ||
|
|
104
|
+
normalized === '::1' ||
|
|
105
|
+
normalized.startsWith('fc') ||
|
|
106
|
+
normalized.startsWith('fd') ||
|
|
107
|
+
normalized.startsWith('fe8') ||
|
|
108
|
+
normalized.startsWith('fe9') ||
|
|
109
|
+
normalized.startsWith('fea') ||
|
|
110
|
+
normalized.startsWith('feb') ||
|
|
111
|
+
normalized.startsWith('::ffff:')
|
|
112
|
+
);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/*** Detects private, reserved, and local IPv4 literals. */
|
|
116
|
+
const privateIpv4 = (host: string): boolean => {
|
|
117
|
+
const parts = host.split('.').map(Number);
|
|
118
|
+
if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255))
|
|
119
|
+
return false;
|
|
120
|
+
const [first, second] = parts as [number, number, number, number];
|
|
121
|
+
return (
|
|
122
|
+
first === 0 ||
|
|
123
|
+
first === 10 ||
|
|
124
|
+
first === 127 ||
|
|
125
|
+
first >= 224 ||
|
|
126
|
+
(first === 169 && second === 254) ||
|
|
127
|
+
(first === 172 && second >= 16 && second <= 31) ||
|
|
128
|
+
(first === 192 && second === 168)
|
|
129
|
+
);
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/*** Checks unique non-empty strings. */
|
|
133
|
+
const uniqueStrings = (values: readonly unknown[]): values is readonly string[] =>
|
|
134
|
+
values.every((value) => typeof value === 'string' && value.length > 0) &&
|
|
135
|
+
new Set(values).size === values.length;
|