@noodleseed/one 0.138.0 → 0.139.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/dist/cli.js +1 -1
- package/dist/cli.js.map +1 -1
- package/dist/commands/import.d.ts +4 -0
- package/dist/commands/import.d.ts.map +1 -0
- package/dist/commands/import.js +198 -0
- package/dist/commands/import.js.map +1 -0
- package/dist/commands/project-setup.d.ts +1 -1
- package/dist/commands/project-setup.d.ts.map +1 -1
- package/dist/commands/project-setup.js +2 -48
- package/dist/commands/project-setup.js.map +1 -1
- package/dist/commands/service-ops.d.ts.map +1 -1
- package/dist/commands/service-ops.js +44 -51
- package/dist/commands/service-ops.js.map +1 -1
- package/dist/mcp-import.d.ts +22 -0
- package/dist/mcp-import.d.ts.map +1 -0
- package/dist/mcp-import.js +91 -0
- package/dist/mcp-import.js.map +1 -0
- package/dist/self-host-init.d.ts +23 -0
- package/dist/self-host-init.d.ts.map +1 -0
- package/dist/self-host-init.js +315 -0
- package/dist/self-host-init.js.map +1 -0
- package/node_modules/@noodle-borg/agent-kit/dist/generated/example-files.js +3 -3
- package/node_modules/@noodle-borg/agent-kit/dist/skill-authoring-refs.js +11 -2
- package/node_modules/@noodle-borg/agent-kit/dist/skill-connect-refs.js +31 -1
- package/node_modules/@noodle-borg/agent-kit/dist/skill-content.js +3 -2
- package/node_modules/@noodle-borg/agent-kit/package.json +1 -1
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.d.ts +1 -0
- package/node_modules/@noodle-borg/assistant-gateway/dist/assistant-guide.js +35 -1
- package/node_modules/@noodle-borg/assistant-gateway/package.json +1 -1
- package/node_modules/@noodle-borg/authoring/dist/connections.js +1 -0
- package/node_modules/@noodle-borg/authoring/dist/connectors.d.ts +25 -3
- package/node_modules/@noodle-borg/authoring/dist/connectors.js +82 -7
- package/node_modules/@noodle-borg/authoring/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/authoring/dist/model-tool-visibility.js +33 -0
- package/node_modules/@noodle-borg/authoring/dist/server.d.ts +5 -0
- package/node_modules/@noodle-borg/authoring/dist/server.js +7 -2
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-account.js +13 -1
- package/node_modules/@noodle-borg/cli-catalog/dist/catalog-data-bootstrap-authoring.js +30 -1
- package/node_modules/@noodle-borg/connector-defs/dist/call-graph.js +1 -1
- package/node_modules/@noodle-borg/connector-defs/dist/compile-mcp.js +95 -0
- package/node_modules/@noodle-borg/connector-defs/dist/compile.js +4 -0
- package/node_modules/@noodle-borg/connector-defs/dist/customer-routing.js +1 -1
- package/node_modules/@noodle-borg/connector-defs/dist/index.d.ts +1 -1
- package/node_modules/@noodle-borg/connector-defs/dist/schema.d.ts +642 -3
- package/node_modules/@noodle-borg/connector-defs/dist/schema.js +143 -1
- package/node_modules/@noodle-borg/connector-http/dist/index.d.ts +1 -0
- package/node_modules/@noodle-borg/connector-http/dist/index.js +1 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/config.js +76 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/egress.js +125 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/failure.js +48 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/index.d.ts +3 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/index.js +2 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/mcp-connector.d.ts +12 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/mcp-connector.js +125 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/result.js +55 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/types.d.ts +52 -0
- package/node_modules/@noodle-borg/connector-http/dist/mcp/types.js +10 -0
- package/node_modules/@noodle-borg/connector-http/package.json +4 -1
- package/node_modules/@noodle-borg/openapi-import/dist/index.js +1 -0
- package/node_modules/@noodle-borg/openapi-import/dist/mcp/index.js +4 -0
- package/node_modules/@noodle-borg/openapi-import/dist/mcp/probe.js +165 -0
- package/node_modules/@noodle-borg/openapi-import/dist/mcp/render.js +99 -0
- package/node_modules/@noodle-borg/openapi-import/dist/mcp/snapshot.js +290 -0
- package/node_modules/@noodle-borg/openapi-import/package.json +6 -2
- package/node_modules/@noodle-borg/runtime/dist/connector/types.d.ts +2 -0
- package/node_modules/@noodle-borg/runtime/dist/operation-execution.js +1 -0
- package/node_modules/@noodle-borg/service/dist/routes/assistant-agent.js +8 -5
- package/node_modules/@noodle-borg/service/package.json +1 -1
- package/node_modules/@noodleseed/assistant/package.json +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { FetchLike, Tool } from '@modelcontextprotocol/client';
|
|
2
|
+
import type { CredentialProfile, OperationSignature } from '@noodle-borg/compiler';
|
|
3
|
+
import type { DnsLookup } from '../ssrf.js';
|
|
4
|
+
/** How a broker-minted credential is presented to an upstream MCP server. */
|
|
5
|
+
export type McpAuthScheme = CredentialProfile | {
|
|
6
|
+
readonly kind: 'cookie';
|
|
7
|
+
};
|
|
8
|
+
/** One curated Noodle operation mapped to one frozen upstream MCP tool. */
|
|
9
|
+
export interface McpOperation {
|
|
10
|
+
readonly upstreamTool: string;
|
|
11
|
+
readonly signature: OperationSignature;
|
|
12
|
+
/** Frozen upstream output schema; omitted when the imported tool had no output schema. */
|
|
13
|
+
readonly upstreamOutputSchema?: OperationSignature['output'];
|
|
14
|
+
readonly auth?: McpAuthScheme;
|
|
15
|
+
/** Inclusive decoded-result limit. Defaults to the connector-wide limit. */
|
|
16
|
+
readonly maxResponseBytes?: number;
|
|
17
|
+
readonly fake?: McpOperationFake;
|
|
18
|
+
}
|
|
19
|
+
export type McpOperationFake = {
|
|
20
|
+
readonly structuredContent: Readonly<Record<string, unknown>>;
|
|
21
|
+
} | {
|
|
22
|
+
readonly text: string;
|
|
23
|
+
};
|
|
24
|
+
/** Runtime configuration for one remote Streamable HTTP MCP server. */
|
|
25
|
+
export interface McpConnectorConfig {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly version: string;
|
|
28
|
+
/** Literal endpoint or one exact `${env.NAME}` managed-variable reference. */
|
|
29
|
+
readonly endpoint: string;
|
|
30
|
+
/** Exact egress origins. Required when `endpoint` is managed. */
|
|
31
|
+
readonly allowedOrigins?: readonly string[];
|
|
32
|
+
readonly operations: Readonly<Record<string, McpOperation>>;
|
|
33
|
+
readonly auth?: McpAuthScheme;
|
|
34
|
+
/** Total connection + call timeout. Default 10 seconds. */
|
|
35
|
+
readonly timeoutMs?: number;
|
|
36
|
+
/** Inclusive decoded MCP response limit. Default 1 MiB, maximum 6 MiB. */
|
|
37
|
+
readonly maxResponseBytes?: number;
|
|
38
|
+
/** Protocol-era policy. `auto` safely negotiates modern or legacy and is the default. */
|
|
39
|
+
readonly protocol?: 'auto' | 'legacy' | 'modern';
|
|
40
|
+
/** Compiler-owned deterministic mode; bypasses transport and credentials. */
|
|
41
|
+
readonly fakeMode?: boolean;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Hermetic-test seams. Production compilation never accepts or emits these values; outbound traffic uses
|
|
45
|
+
* the guarded transport and system DNS resolver.
|
|
46
|
+
*/
|
|
47
|
+
export interface McpConnectorDependencies {
|
|
48
|
+
readonly fetch?: FetchLike;
|
|
49
|
+
readonly lookup?: DnsLookup;
|
|
50
|
+
}
|
|
51
|
+
export declare function toolDefinition(operation: McpOperation): Tool;
|
|
52
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function toolDefinition(operation) {
|
|
2
|
+
return {
|
|
3
|
+
name: operation.upstreamTool,
|
|
4
|
+
inputSchema: operation.signature.input,
|
|
5
|
+
...(operation.upstreamOutputSchema === undefined
|
|
6
|
+
? {}
|
|
7
|
+
: { outputSchema: operation.upstreamOutputSchema }),
|
|
8
|
+
};
|
|
9
|
+
}
|
|
10
|
+
//# sourceMappingURL=types.js.map
|
|
@@ -22,12 +22,15 @@
|
|
|
22
22
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
25
26
|
"@noodle-borg/compiler": "0.0.0",
|
|
26
27
|
"@noodle-borg/runtime": "0.0.0",
|
|
27
28
|
"ipaddr.js": "^2.4.0",
|
|
28
29
|
"undici": "^8.7.0"
|
|
29
30
|
},
|
|
30
31
|
"devDependencies": {
|
|
31
|
-
"@
|
|
32
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
33
|
+
"@types/node": "^24.13.1",
|
|
34
|
+
"zod": "^4.4.3"
|
|
32
35
|
}
|
|
33
36
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import YAML from 'yaml';
|
|
2
2
|
import { importResponseSchema, toOutputJsonSchema, } from './response-schema.js';
|
|
3
|
+
export * from './mcp/index.js';
|
|
3
4
|
export { importResponseSchema, MAX_RESPONSE_SCHEMA_DEPTH, MAX_RESPONSE_SCHEMA_NODES, toOutputJsonSchema, } from './response-schema.js';
|
|
4
5
|
const METHODS = new Set(['get', 'post', 'put', 'patch', 'delete']);
|
|
5
6
|
export function parseOpenApiToIr(source, options) {
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { Client, SdkHttpError, StreamableHTTPClientTransport, } from '@modelcontextprotocol/client';
|
|
2
|
+
import { guardedFetch } from '@noodle-borg/connector-http';
|
|
3
|
+
import { snapshotFromTools } from './snapshot.js';
|
|
4
|
+
const DEFAULT_TIMEOUT_MS = 15_000;
|
|
5
|
+
const DEFAULT_MAX_RESPONSE_BYTES = 6 * 1024 * 1024;
|
|
6
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
7
|
+
export async function probeMcpServer(options) {
|
|
8
|
+
const endpoint = safeEndpoint(options.endpoint);
|
|
9
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
10
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
|
|
11
|
+
throw new Error(`MCP import timeout must be between 1 and ${MAX_TIMEOUT_MS} milliseconds`);
|
|
12
|
+
}
|
|
13
|
+
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
14
|
+
if (!Number.isSafeInteger(maxResponseBytes) ||
|
|
15
|
+
maxResponseBytes < 1 ||
|
|
16
|
+
maxResponseBytes > DEFAULT_MAX_RESPONSE_BYTES) {
|
|
17
|
+
throw new Error(`MCP import response limit must be between 1 and ${DEFAULT_MAX_RESPONSE_BYTES} bytes`);
|
|
18
|
+
}
|
|
19
|
+
const signal = AbortSignal.timeout(timeoutMs);
|
|
20
|
+
const fetch = importFetch(endpoint.origin, options.headers ?? {}, maxResponseBytes, options.fetch);
|
|
21
|
+
const client = new Client({ name: 'noodle-mcp-import', version: '1.0.0' }, {
|
|
22
|
+
capabilities: {},
|
|
23
|
+
versionNegotiation: { mode: protocolMode(options.protocol) },
|
|
24
|
+
inputRequired: { autoFulfill: false },
|
|
25
|
+
listMaxPages: 32,
|
|
26
|
+
});
|
|
27
|
+
const transport = new StreamableHTTPClientTransport(endpoint, {
|
|
28
|
+
fetch,
|
|
29
|
+
onInsufficientScope: 'throw',
|
|
30
|
+
});
|
|
31
|
+
const closeTransportOnAbort = () => {
|
|
32
|
+
void transport.close().catch(() => undefined);
|
|
33
|
+
};
|
|
34
|
+
signal.addEventListener('abort', closeTransportOnAbort, { once: true });
|
|
35
|
+
if (signal.aborted)
|
|
36
|
+
closeTransportOnAbort();
|
|
37
|
+
let tools;
|
|
38
|
+
try {
|
|
39
|
+
await client.connect(transport, { signal, timeout: timeoutMs });
|
|
40
|
+
const result = await client.listTools(undefined, {
|
|
41
|
+
signal,
|
|
42
|
+
timeout: timeoutMs,
|
|
43
|
+
cacheMode: 'bypass',
|
|
44
|
+
});
|
|
45
|
+
tools = result.tools.map((tool) => ({
|
|
46
|
+
name: tool.name,
|
|
47
|
+
...(tool.description === undefined ? {} : { description: tool.description }),
|
|
48
|
+
inputSchema: tool.inputSchema,
|
|
49
|
+
...(tool.outputSchema === undefined ? {} : { outputSchema: tool.outputSchema }),
|
|
50
|
+
...(tool.annotations === undefined ? {} : { annotations: tool.annotations }),
|
|
51
|
+
}));
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (signal.aborted)
|
|
55
|
+
throw new Error('MCP import probe timed out');
|
|
56
|
+
if (SdkHttpError.isInstance(error)) {
|
|
57
|
+
throw new Error(`MCP import probe rejected with HTTP ${error.status}`);
|
|
58
|
+
}
|
|
59
|
+
throw new Error('MCP import probe failed');
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
signal.removeEventListener('abort', closeTransportOnAbort);
|
|
63
|
+
await client.close().catch(() => undefined);
|
|
64
|
+
}
|
|
65
|
+
return snapshotFromTools({
|
|
66
|
+
endpoint: options.endpoint,
|
|
67
|
+
name: options.name,
|
|
68
|
+
...(options.prefix === undefined ? {} : { prefix: options.prefix }),
|
|
69
|
+
...(options.auth === undefined ? {} : { auth: options.auth }),
|
|
70
|
+
tools,
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function importFetch(allowedOrigin, configuredHeaders, maxBytes, injected) {
|
|
74
|
+
const safeHeaders = validatedHeaders(configuredHeaders);
|
|
75
|
+
return async (input, init = {}) => {
|
|
76
|
+
const url = new URL(input);
|
|
77
|
+
if (url.origin !== allowedOrigin)
|
|
78
|
+
throw new Error('MCP import egress origin rejected');
|
|
79
|
+
const headers = new Headers(init.headers);
|
|
80
|
+
for (const [name, value] of Object.entries(safeHeaders))
|
|
81
|
+
headers.set(name, value);
|
|
82
|
+
const response = injected === undefined
|
|
83
|
+
? await guardedFetch(url, { ...init, headers, redirect: 'manual' })
|
|
84
|
+
: await injected(url, { ...init, headers, redirect: 'manual' });
|
|
85
|
+
if (response.status >= 300 && response.status <= 399) {
|
|
86
|
+
throw new Error('MCP import redirect rejected');
|
|
87
|
+
}
|
|
88
|
+
return boundedResponse(response, maxBytes);
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function boundedResponse(response, maxBytes) {
|
|
92
|
+
const length = Number(response.headers.get('content-length') ?? '0');
|
|
93
|
+
if (length > maxBytes)
|
|
94
|
+
throw new Error('MCP import response exceeds the size limit');
|
|
95
|
+
if (response.body === null)
|
|
96
|
+
return response;
|
|
97
|
+
const reader = response.body.getReader();
|
|
98
|
+
let size = 0;
|
|
99
|
+
return new Response(new ReadableStream({
|
|
100
|
+
async pull(controller) {
|
|
101
|
+
const chunk = await reader.read();
|
|
102
|
+
if (chunk.done) {
|
|
103
|
+
controller.close();
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
size += chunk.value.byteLength;
|
|
107
|
+
if (size > maxBytes) {
|
|
108
|
+
await reader.cancel().catch(() => undefined);
|
|
109
|
+
controller.error(new Error('MCP import response exceeds the size limit'));
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
controller.enqueue(chunk.value);
|
|
113
|
+
},
|
|
114
|
+
async cancel(reason) {
|
|
115
|
+
await reader.cancel(reason).catch(() => undefined);
|
|
116
|
+
},
|
|
117
|
+
}), { status: response.status, statusText: response.statusText, headers: response.headers });
|
|
118
|
+
}
|
|
119
|
+
function safeEndpoint(value) {
|
|
120
|
+
const url = new URL(value);
|
|
121
|
+
if (url.username !== '' || url.password !== '' || url.hash !== '') {
|
|
122
|
+
throw new Error('MCP endpoint contains forbidden URL components');
|
|
123
|
+
}
|
|
124
|
+
const loopback = url.protocol === 'http:' &&
|
|
125
|
+
(url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]');
|
|
126
|
+
if (url.protocol !== 'https:' && !loopback)
|
|
127
|
+
throw new Error('MCP endpoint must use HTTPS');
|
|
128
|
+
return url;
|
|
129
|
+
}
|
|
130
|
+
function validatedHeaders(input) {
|
|
131
|
+
if (Object.keys(input).length > 32)
|
|
132
|
+
throw new Error('MCP import accepts at most 32 headers');
|
|
133
|
+
const headers = Object.create(null);
|
|
134
|
+
const seen = new Set();
|
|
135
|
+
for (const [name, value] of Object.entries(input)) {
|
|
136
|
+
const lower = name.toLowerCase();
|
|
137
|
+
if (seen.has(lower))
|
|
138
|
+
throw new Error(`MCP import header "${name}" is duplicated`);
|
|
139
|
+
seen.add(lower);
|
|
140
|
+
if (lower === 'host' ||
|
|
141
|
+
lower === 'cookie' ||
|
|
142
|
+
lower === 'content-type' ||
|
|
143
|
+
lower === 'content-length' ||
|
|
144
|
+
lower.startsWith('mcp-')) {
|
|
145
|
+
throw new Error(`MCP import header "${name}" is transport-owned`);
|
|
146
|
+
}
|
|
147
|
+
if (value.length > 8_192 || /[\r\n]/.test(value)) {
|
|
148
|
+
throw new Error(`MCP import header "${name}" has an invalid value`);
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
new Headers({ [name]: value });
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
throw new Error(`MCP import header "${name}" is invalid`);
|
|
155
|
+
}
|
|
156
|
+
headers[lower] = value;
|
|
157
|
+
}
|
|
158
|
+
return headers;
|
|
159
|
+
}
|
|
160
|
+
function protocolMode(value) {
|
|
161
|
+
if (value === 'modern')
|
|
162
|
+
return { pin: '2026-07-28' };
|
|
163
|
+
return value ?? 'auto';
|
|
164
|
+
}
|
|
165
|
+
//# sourceMappingURL=probe.js.map
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export function renderMcpServerSource(snapshot) {
|
|
2
|
+
const needsSecret = snapshot.auth !== undefined;
|
|
3
|
+
const imports = ['annotations', 'connector', 'server', 'tool', 'variable'];
|
|
4
|
+
if (needsSecret)
|
|
5
|
+
imports.push('secret');
|
|
6
|
+
const operations = snapshot.tools.map(renderOperation).join(',\n ');
|
|
7
|
+
const tools = snapshot.tools.map(renderTool).join(',\n ');
|
|
8
|
+
const auth = snapshot.auth === undefined ? '' : `\n auth: ${renderAuth(snapshot.auth)},`;
|
|
9
|
+
return `import { ${imports.sort().join(', ')} } from '@noodleseed/one';
|
|
10
|
+
|
|
11
|
+
const upstream = connector(${quote(snapshot.connectorId)})
|
|
12
|
+
.version('1.0.0')
|
|
13
|
+
.mcp({
|
|
14
|
+
endpoint: variable(${quote(snapshot.endpointVariable)}),
|
|
15
|
+
allowedOrigins: [variable(${quote(snapshot.originVariable)})],${auth}
|
|
16
|
+
operations: {
|
|
17
|
+
${operations}
|
|
18
|
+
},
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export default server(${quote(snapshot.connectorId)}, {
|
|
22
|
+
title: ${quote(title(snapshot.connectorId))},
|
|
23
|
+
version: '1.0.0',
|
|
24
|
+
use: { upstream },
|
|
25
|
+
}, [
|
|
26
|
+
${tools}
|
|
27
|
+
]);
|
|
28
|
+
`;
|
|
29
|
+
}
|
|
30
|
+
function renderOperation(tool) {
|
|
31
|
+
return `${tool.operationName}: {
|
|
32
|
+
type: ${quote(tool.operationType)},
|
|
33
|
+
tool: ${quote(tool.upstreamName)},
|
|
34
|
+
result: ${quote(tool.result)},
|
|
35
|
+
input: ${json(tool.inputSchema, 8)},
|
|
36
|
+
output: ${json(tool.outputSchema, 8)},
|
|
37
|
+
}`;
|
|
38
|
+
}
|
|
39
|
+
function renderTool(item) {
|
|
40
|
+
const annotations = item.operationType === 'read'
|
|
41
|
+
? 'annotations.readOnly({ openWorld: true })'
|
|
42
|
+
: `annotations.openAction({ destructive: ${String(item.destructive)}, confirm: true })`;
|
|
43
|
+
const properties = schemaProperties(item.outputSchema);
|
|
44
|
+
const input = schemaProperties(item.inputSchema)
|
|
45
|
+
.map((property) => `${quote(property)}: input[${quote(property)}]`)
|
|
46
|
+
.join(', ');
|
|
47
|
+
const operationCall = `connectors.upstream.${item.operationName}({ ${input} })`;
|
|
48
|
+
const outputSchema = properties.length === 0
|
|
49
|
+
? {
|
|
50
|
+
type: 'object',
|
|
51
|
+
properties: { result: item.outputSchema },
|
|
52
|
+
required: ['result'],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
}
|
|
55
|
+
: item.outputSchema;
|
|
56
|
+
const fulfil = properties.length === 0
|
|
57
|
+
? `({ input, connectors }) => ({ result: ${operationCall}.raw })`
|
|
58
|
+
: `({ input, connectors }) => {
|
|
59
|
+
const result = ${operationCall};
|
|
60
|
+
return {
|
|
61
|
+
${properties.map((property) => ` ${quote(property)}: result[${quote(property)}],`).join('\n')}
|
|
62
|
+
};
|
|
63
|
+
}`;
|
|
64
|
+
return `tool(${quote(item.operationName)}, {
|
|
65
|
+
title: ${quote(title(item.operationName))},
|
|
66
|
+
description: ${quote(item.description)},
|
|
67
|
+
annotations: ${annotations},
|
|
68
|
+
input: ${json(item.inputSchema, 4)},
|
|
69
|
+
output: ${json(outputSchema, 4)},
|
|
70
|
+
fulfil: ${fulfil},
|
|
71
|
+
})`;
|
|
72
|
+
}
|
|
73
|
+
function schemaProperties(schema) {
|
|
74
|
+
const properties = schema.properties;
|
|
75
|
+
if (typeof properties !== 'object' || properties === null || Array.isArray(properties))
|
|
76
|
+
return [];
|
|
77
|
+
return Object.keys(properties).sort();
|
|
78
|
+
}
|
|
79
|
+
function renderAuth(auth) {
|
|
80
|
+
if (auth.kind === 'bearer') {
|
|
81
|
+
return `{ kind: 'bearer', secret: secret(${quote(auth.secretRef)}) }`;
|
|
82
|
+
}
|
|
83
|
+
return `{ kind: 'apiKey', header: ${quote(auth.header ?? 'x-api-key')}, secret: secret(${quote(auth.secretRef)}) }`;
|
|
84
|
+
}
|
|
85
|
+
function json(value, indentation) {
|
|
86
|
+
const pad = ' '.repeat(indentation);
|
|
87
|
+
return JSON.stringify(value, null, 2).replace(/\n/g, `\n${pad}`);
|
|
88
|
+
}
|
|
89
|
+
function quote(value) {
|
|
90
|
+
return JSON.stringify(value);
|
|
91
|
+
}
|
|
92
|
+
function title(value) {
|
|
93
|
+
return value
|
|
94
|
+
.split('_')
|
|
95
|
+
.filter(Boolean)
|
|
96
|
+
.map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`)
|
|
97
|
+
.join(' ');
|
|
98
|
+
}
|
|
99
|
+
//# sourceMappingURL=render.js.map
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import { normalizeOperationIoSchema } from '@noodle-borg/compiler';
|
|
2
|
+
const NAME = /^[a-z0-9_]+$/;
|
|
3
|
+
const MAX_TOOLS = 256;
|
|
4
|
+
const MAX_UPSTREAM_NAME_LENGTH = 256;
|
|
5
|
+
const MAX_SCHEMA_DEPTH = 64;
|
|
6
|
+
const MAX_SCHEMA_NODES = 50_000;
|
|
7
|
+
export function snapshotFromTools(input) {
|
|
8
|
+
if (input.tools.length > MAX_TOOLS) {
|
|
9
|
+
throw new Error(`upstream MCP server exposes more than ${MAX_TOOLS} tools`);
|
|
10
|
+
}
|
|
11
|
+
const connectorId = safeBaseName(input.name);
|
|
12
|
+
const safePrefix = input.prefix === undefined ? undefined : safeBaseName(input.prefix);
|
|
13
|
+
const prefix = safePrefix === undefined ? '' : `${safePrefix}_`;
|
|
14
|
+
const warnings = [];
|
|
15
|
+
const counts = new Map();
|
|
16
|
+
const tools = [];
|
|
17
|
+
const discovered = input.tools.map((tool) => ({
|
|
18
|
+
...tool,
|
|
19
|
+
name: upstreamName(tool.name),
|
|
20
|
+
}));
|
|
21
|
+
for (const tool of discovered.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
22
|
+
const base = `${prefix}${safeBaseName(tool.name)}`;
|
|
23
|
+
const count = (counts.get(base) ?? 0) + 1;
|
|
24
|
+
counts.set(base, count);
|
|
25
|
+
const operationName = count === 1 ? base : `${base}_${count}`;
|
|
26
|
+
if (operationName !== tool.name) {
|
|
27
|
+
warnings.push(`mapped upstream tool "${tool.name}" to operation "${operationName}"`);
|
|
28
|
+
}
|
|
29
|
+
const inputSchema = objectSchema(tool.inputSchema, `${tool.name}.inputSchema`);
|
|
30
|
+
const result = tool.outputSchema === undefined ? 'text' : 'structured';
|
|
31
|
+
const outputSchema = tool.outputSchema === undefined
|
|
32
|
+
? textOutputSchema()
|
|
33
|
+
: objectSchema(tool.outputSchema, `${tool.name}.outputSchema`);
|
|
34
|
+
const operationType = 'action';
|
|
35
|
+
const destructive = true;
|
|
36
|
+
if (tool.annotations?.readOnlyHint === true) {
|
|
37
|
+
warnings.push(`tool "${tool.name}" claims readOnlyHint; imported as a confirmed action until the author verifies its behavior`);
|
|
38
|
+
}
|
|
39
|
+
else if (tool.annotations?.readOnlyHint === undefined) {
|
|
40
|
+
warnings.push(`tool "${tool.name}" has no readOnlyHint; imported conservatively as a confirmed action`);
|
|
41
|
+
}
|
|
42
|
+
tools.push({
|
|
43
|
+
upstreamName: tool.name,
|
|
44
|
+
operationName,
|
|
45
|
+
description: cleanDescription(tool.description, tool.name),
|
|
46
|
+
operationType,
|
|
47
|
+
destructive,
|
|
48
|
+
inputSchema,
|
|
49
|
+
outputSchema,
|
|
50
|
+
result,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
return {
|
|
54
|
+
formatVersion: 1,
|
|
55
|
+
connectorId,
|
|
56
|
+
...(safePrefix === undefined ? {} : { prefix: safePrefix }),
|
|
57
|
+
endpoint: canonicalEndpoint(input.endpoint),
|
|
58
|
+
endpointVariable: `${constantName(connectorId)}_MCP_ENDPOINT`,
|
|
59
|
+
originVariable: `${constantName(connectorId)}_MCP_ORIGIN`,
|
|
60
|
+
...(input.auth === undefined ? {} : { auth: input.auth }),
|
|
61
|
+
tools,
|
|
62
|
+
warnings: [...warnings].sort(),
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function upstreamName(value) {
|
|
66
|
+
if (value.length === 0 || value.length > MAX_UPSTREAM_NAME_LENGTH || /\p{Cc}/u.test(value)) {
|
|
67
|
+
throw new Error(`upstream MCP tool name must contain 1-${MAX_UPSTREAM_NAME_LENGTH} non-control characters`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
export function diffSnapshots(before, after) {
|
|
72
|
+
const lines = [];
|
|
73
|
+
const prior = new Map(before.tools.map((tool) => [tool.upstreamName, tool]));
|
|
74
|
+
const next = new Map(after.tools.map((tool) => [tool.upstreamName, tool]));
|
|
75
|
+
for (const name of [...new Set([...prior.keys(), ...next.keys()])].sort()) {
|
|
76
|
+
const left = prior.get(name);
|
|
77
|
+
const right = next.get(name);
|
|
78
|
+
if (left === undefined)
|
|
79
|
+
lines.push(`additive: added tool: ${name}`);
|
|
80
|
+
else if (right === undefined)
|
|
81
|
+
lines.push(`breaking: removed tool: ${name}`);
|
|
82
|
+
else if (stableJson(left) !== stableJson(right)) {
|
|
83
|
+
const metadataOnly = stableJson({ ...left, description: '' }) === stableJson({ ...right, description: '' });
|
|
84
|
+
lines.push(`${metadataOnly ? 'metadata-only' : 'breaking'}: changed tool: ${name}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
if (before.endpoint !== after.endpoint)
|
|
88
|
+
lines.push('breaking: changed endpoint');
|
|
89
|
+
if (stableJson(before.auth) !== stableJson(after.auth))
|
|
90
|
+
lines.push('breaking: changed auth');
|
|
91
|
+
return { changed: lines.length > 0, lines };
|
|
92
|
+
}
|
|
93
|
+
/** Parse the system-owned drift snapshot without trusting edited local JSON. */
|
|
94
|
+
export function parseMcpImportSnapshot(value) {
|
|
95
|
+
if (!isRecord(value) || value.formatVersion !== 1) {
|
|
96
|
+
throw new Error('MCP import snapshot has an unsupported format');
|
|
97
|
+
}
|
|
98
|
+
const connectorId = requiredString(value.connectorId, 'connectorId');
|
|
99
|
+
if (safeBaseName(connectorId) !== connectorId) {
|
|
100
|
+
throw new Error('MCP import snapshot connectorId is invalid');
|
|
101
|
+
}
|
|
102
|
+
const prefix = optionalString(value.prefix, 'prefix');
|
|
103
|
+
if (prefix !== undefined && safeBaseName(prefix) !== prefix) {
|
|
104
|
+
throw new Error('MCP import snapshot prefix is invalid');
|
|
105
|
+
}
|
|
106
|
+
if (!Array.isArray(value.tools) || value.tools.length > MAX_TOOLS) {
|
|
107
|
+
throw new Error(`MCP import snapshot tools must contain at most ${MAX_TOOLS} entries`);
|
|
108
|
+
}
|
|
109
|
+
const tools = value.tools.map((candidate, index) => {
|
|
110
|
+
if (!isRecord(candidate))
|
|
111
|
+
throw new Error(`MCP import snapshot tools[${index}] is invalid`);
|
|
112
|
+
const upstream = upstreamName(requiredString(candidate.upstreamName, `tools[${index}].upstreamName`));
|
|
113
|
+
const operationName = requiredString(candidate.operationName, `tools[${index}].operationName`);
|
|
114
|
+
if (!NAME.test(operationName) || operationName.length > 128) {
|
|
115
|
+
throw new Error(`MCP import snapshot tools[${index}].operationName is invalid`);
|
|
116
|
+
}
|
|
117
|
+
const description = requiredString(candidate.description, `tools[${index}].description`);
|
|
118
|
+
if (description.length > 2_000) {
|
|
119
|
+
throw new Error(`MCP import snapshot tools[${index}].description is too long`);
|
|
120
|
+
}
|
|
121
|
+
if (candidate.operationType !== 'read' && candidate.operationType !== 'action') {
|
|
122
|
+
throw new Error(`MCP import snapshot tools[${index}].operationType is invalid`);
|
|
123
|
+
}
|
|
124
|
+
if (typeof candidate.destructive !== 'boolean') {
|
|
125
|
+
throw new Error(`MCP import snapshot tools[${index}].destructive is invalid`);
|
|
126
|
+
}
|
|
127
|
+
if (candidate.result !== 'structured' && candidate.result !== 'text') {
|
|
128
|
+
throw new Error(`MCP import snapshot tools[${index}].result is invalid`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
upstreamName: upstream,
|
|
132
|
+
operationName,
|
|
133
|
+
description,
|
|
134
|
+
operationType: candidate.operationType,
|
|
135
|
+
destructive: candidate.destructive,
|
|
136
|
+
inputSchema: objectSchema(candidate.inputSchema, `tools[${index}].inputSchema`),
|
|
137
|
+
outputSchema: objectSchema(candidate.outputSchema, `tools[${index}].outputSchema`),
|
|
138
|
+
result: candidate.result,
|
|
139
|
+
};
|
|
140
|
+
});
|
|
141
|
+
if (!Array.isArray(value.warnings) || value.warnings.length > MAX_TOOLS * 2) {
|
|
142
|
+
throw new Error('MCP import snapshot warnings are invalid');
|
|
143
|
+
}
|
|
144
|
+
const warnings = value.warnings.map((warning, index) => {
|
|
145
|
+
const parsed = requiredString(warning, `warnings[${index}]`);
|
|
146
|
+
if (parsed.length > 2_000)
|
|
147
|
+
throw new Error(`MCP import snapshot warnings[${index}] is too long`);
|
|
148
|
+
return parsed;
|
|
149
|
+
});
|
|
150
|
+
const endpoint = canonicalEndpoint(requiredString(value.endpoint, 'endpoint'));
|
|
151
|
+
const endpointVariable = `${constantName(connectorId)}_MCP_ENDPOINT`;
|
|
152
|
+
const originVariable = `${constantName(connectorId)}_MCP_ORIGIN`;
|
|
153
|
+
if (value.endpointVariable !== endpointVariable || value.originVariable !== originVariable) {
|
|
154
|
+
throw new Error('MCP import snapshot managed binding names are invalid');
|
|
155
|
+
}
|
|
156
|
+
const auth = parseAuth(value.auth);
|
|
157
|
+
return {
|
|
158
|
+
formatVersion: 1,
|
|
159
|
+
connectorId,
|
|
160
|
+
...(prefix === undefined ? {} : { prefix }),
|
|
161
|
+
endpoint,
|
|
162
|
+
endpointVariable,
|
|
163
|
+
originVariable,
|
|
164
|
+
...(auth === undefined ? {} : { auth }),
|
|
165
|
+
tools,
|
|
166
|
+
warnings,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
function parseAuth(value) {
|
|
170
|
+
if (value === undefined)
|
|
171
|
+
return undefined;
|
|
172
|
+
if (!isRecord(value) || (value.kind !== 'bearer' && value.kind !== 'apiKey')) {
|
|
173
|
+
throw new Error('MCP import snapshot auth is invalid');
|
|
174
|
+
}
|
|
175
|
+
const secretRef = requiredString(value.secretRef, 'auth.secretRef');
|
|
176
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]{0,127}$/.test(secretRef)) {
|
|
177
|
+
throw new Error('MCP import snapshot auth.secretRef is invalid');
|
|
178
|
+
}
|
|
179
|
+
if (value.kind === 'bearer')
|
|
180
|
+
return { kind: 'bearer', secretRef };
|
|
181
|
+
const header = requiredString(value.header, 'auth.header');
|
|
182
|
+
if (!/^[A-Za-z0-9_-]{1,128}$/.test(header)) {
|
|
183
|
+
throw new Error('MCP import snapshot auth.header is invalid');
|
|
184
|
+
}
|
|
185
|
+
return { kind: 'apiKey', header, secretRef };
|
|
186
|
+
}
|
|
187
|
+
function requiredString(value, path) {
|
|
188
|
+
if (typeof value !== 'string' || value.length === 0) {
|
|
189
|
+
throw new Error(`MCP import snapshot ${path} is invalid`);
|
|
190
|
+
}
|
|
191
|
+
return value;
|
|
192
|
+
}
|
|
193
|
+
function optionalString(value, path) {
|
|
194
|
+
if (value === undefined)
|
|
195
|
+
return undefined;
|
|
196
|
+
return requiredString(value, path);
|
|
197
|
+
}
|
|
198
|
+
function objectSchema(value, path) {
|
|
199
|
+
inspectSchema(value, path);
|
|
200
|
+
if (!isRecord(value))
|
|
201
|
+
throw new Error(`${path} must be a JSON Schema object`);
|
|
202
|
+
const type = value.type;
|
|
203
|
+
if (type !== 'object' && !(Array.isArray(type) && type.includes('object'))) {
|
|
204
|
+
throw new Error(`${path} must declare an object top-level type`);
|
|
205
|
+
}
|
|
206
|
+
if (value.additionalProperties !== undefined && typeof value.additionalProperties !== 'boolean') {
|
|
207
|
+
throw new Error(`${path}.additionalProperties must be boolean at the top level`);
|
|
208
|
+
}
|
|
209
|
+
return normalizeOperationIoSchema(value);
|
|
210
|
+
}
|
|
211
|
+
function inspectSchema(value, path) {
|
|
212
|
+
let nodes = 0;
|
|
213
|
+
const visit = (node, depth) => {
|
|
214
|
+
nodes += 1;
|
|
215
|
+
if (nodes > MAX_SCHEMA_NODES)
|
|
216
|
+
throw new Error(`${path} exceeds the schema node limit`);
|
|
217
|
+
if (depth > MAX_SCHEMA_DEPTH)
|
|
218
|
+
throw new Error(`${path} exceeds the schema depth limit`);
|
|
219
|
+
if (Array.isArray(node)) {
|
|
220
|
+
for (const item of node)
|
|
221
|
+
visit(item, depth + 1);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!isRecord(node))
|
|
225
|
+
return;
|
|
226
|
+
if (typeof node.$ref === 'string' && !node.$ref.startsWith('#')) {
|
|
227
|
+
throw new Error(`${path} contains an external $ref, which is never fetched`);
|
|
228
|
+
}
|
|
229
|
+
for (const child of Object.values(node))
|
|
230
|
+
visit(child, depth + 1);
|
|
231
|
+
};
|
|
232
|
+
visit(value, 0);
|
|
233
|
+
}
|
|
234
|
+
function safeBaseName(value) {
|
|
235
|
+
const separated = value
|
|
236
|
+
.normalize('NFKD')
|
|
237
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
238
|
+
.toLowerCase()
|
|
239
|
+
.replace(/[^a-z0-9]+/g, '_')
|
|
240
|
+
.replace(/^_+|_+$/g, '')
|
|
241
|
+
.replace(/_+/g, '_');
|
|
242
|
+
const safe = /^[a-z]/.test(separated) ? separated : `tool_${separated}`;
|
|
243
|
+
if (!NAME.test(safe) || safe.length === 0)
|
|
244
|
+
throw new Error(`cannot derive a safe name from "${value}"`);
|
|
245
|
+
return safe.slice(0, 96).replace(/_+$/g, '');
|
|
246
|
+
}
|
|
247
|
+
function constantName(value) {
|
|
248
|
+
return value.toUpperCase();
|
|
249
|
+
}
|
|
250
|
+
function cleanDescription(value, name) {
|
|
251
|
+
const cleaned = value
|
|
252
|
+
?.replace(/\p{Cc}/gu, (character) => character === '\n' || character === '\t' ? character : '')
|
|
253
|
+
.trim()
|
|
254
|
+
.slice(0, 2_000);
|
|
255
|
+
return cleaned && cleaned.length > 0 ? cleaned : `Call upstream MCP tool "${name}".`;
|
|
256
|
+
}
|
|
257
|
+
function textOutputSchema() {
|
|
258
|
+
return {
|
|
259
|
+
type: 'object',
|
|
260
|
+
properties: { text: { type: 'string' } },
|
|
261
|
+
required: ['text'],
|
|
262
|
+
additionalProperties: false,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
function canonicalEndpoint(value) {
|
|
266
|
+
const url = new URL(value);
|
|
267
|
+
if (url.username !== '' || url.password !== '' || url.hash !== '') {
|
|
268
|
+
throw new Error('MCP endpoint contains forbidden URL components');
|
|
269
|
+
}
|
|
270
|
+
if (url.protocol !== 'https:' &&
|
|
271
|
+
!(url.protocol === 'http:' &&
|
|
272
|
+
(url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '[::1]'))) {
|
|
273
|
+
throw new Error('MCP endpoint must use HTTPS');
|
|
274
|
+
}
|
|
275
|
+
return url.toString();
|
|
276
|
+
}
|
|
277
|
+
function stableJson(value) {
|
|
278
|
+
if (Array.isArray(value))
|
|
279
|
+
return `[${value.map(stableJson).join(',')}]`;
|
|
280
|
+
if (!isRecord(value))
|
|
281
|
+
return JSON.stringify(value);
|
|
282
|
+
return `{${Object.keys(value)
|
|
283
|
+
.sort()
|
|
284
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
|
285
|
+
.join(',')}}`;
|
|
286
|
+
}
|
|
287
|
+
function isRecord(value) {
|
|
288
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
289
|
+
}
|
|
290
|
+
//# sourceMappingURL=snapshot.js.map
|
|
@@ -22,11 +22,15 @@
|
|
|
22
22
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
+
"@modelcontextprotocol/client": "2.0.0",
|
|
26
|
+
"@noodle-borg/compiler": "0.0.0",
|
|
27
|
+
"@noodle-borg/connector-http": "0.0.0",
|
|
25
28
|
"yaml": "^2.9.0"
|
|
26
29
|
},
|
|
27
30
|
"devDependencies": {
|
|
28
|
-
"@
|
|
31
|
+
"@modelcontextprotocol/server": "2.0.0",
|
|
29
32
|
"@noodle-borg/connector-defs": "0.0.0",
|
|
30
|
-
"@types/node": "^24.13.1"
|
|
33
|
+
"@types/node": "^24.13.1",
|
|
34
|
+
"zod": "^4.4.3"
|
|
31
35
|
}
|
|
32
36
|
}
|
|
@@ -74,6 +74,8 @@ export interface ConnectorCall {
|
|
|
74
74
|
readonly args: Readonly<Record<string, unknown>>;
|
|
75
75
|
/** Managed variables resolved for the deployment scope. */
|
|
76
76
|
readonly env?: Readonly<Record<string, string>>;
|
|
77
|
+
/** Request-scoped cancellation; connectors should propagate it to outbound work. */
|
|
78
|
+
readonly signal?: AbortSignal;
|
|
77
79
|
/**
|
|
78
80
|
* The downstream-scoped credential minted by the broker — never an inbound bearer token
|
|
79
81
|
* (docs/SPEC.md "Auth And Identity").
|