factoryhub 0.1.0 → 0.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.
Files changed (87) hide show
  1. package/README.md +9 -3
  2. package/factories/index.json +231 -0
  3. package/factories/newsletter-note/README.md +11 -0
  4. package/factories/newsletter-note/agents/reviewer/agent.md +10 -0
  5. package/factories/newsletter-note/agents/writer/agent.md +11 -0
  6. package/factories/newsletter-note/factory.yaml +12 -0
  7. package/factories/newsletter-note/flow.yaml +45 -0
  8. package/factories/newsletter-note/mindburn.tests.yaml +80 -0
  9. package/factories/release-desk/README.md +13 -0
  10. package/factories/release-desk/agents/editor/agent.md +10 -0
  11. package/factories/release-desk/agents/reviewer/agent.md +10 -0
  12. package/factories/release-desk/factory.yaml +13 -0
  13. package/factories/release-desk/flow.yaml +67 -0
  14. package/factories/release-desk/mindburn.tests.yaml +163 -0
  15. package/factories/short-video-cut/README.md +11 -0
  16. package/factories/short-video-cut/agents/reviewer/agent.md +10 -0
  17. package/factories/short-video-cut/agents/writer/agent.md +11 -0
  18. package/factories/short-video-cut/factory.yaml +13 -0
  19. package/factories/short-video-cut/flow.yaml +46 -0
  20. package/factories/short-video-cut/mindburn.tests.yaml +80 -0
  21. package/factories/video-facts/README.md +11 -0
  22. package/factories/video-facts/agents/analyst/agent.md +10 -0
  23. package/factories/video-facts/agents/reviewer/agent.md +10 -0
  24. package/factories/video-facts/factory.yaml +13 -0
  25. package/factories/video-facts/flow.yaml +39 -0
  26. package/factories/video-facts/mindburn.tests.yaml +34 -0
  27. package/factories/video-to-posts/README.md +35 -0
  28. package/factories/video-to-posts/agents/analyst/agent.md +10 -0
  29. package/factories/video-to-posts/agents/reviewer/agent.md +10 -0
  30. package/factories/video-to-posts/agents/writer/agent.md +11 -0
  31. package/factories/video-to-posts/factory.yaml +12 -0
  32. package/factories/video-to-posts/flow.yaml +62 -0
  33. package/factories/video-to-posts/mindburn.tests.yaml +34 -0
  34. package/factories/week-of-posts/README.md +11 -0
  35. package/factories/week-of-posts/agents/reviewer/agent.md +10 -0
  36. package/factories/week-of-posts/agents/writer/agent.md +11 -0
  37. package/factories/week-of-posts/factory.yaml +12 -0
  38. package/factories/week-of-posts/flow.yaml +46 -0
  39. package/factories/week-of-posts/mindburn.tests.yaml +80 -0
  40. package/package.json +15 -2
  41. package/packages/cli/package.json +1 -1
  42. package/packages/cli/src/commands/export.mjs +0 -1
  43. package/packages/cli/src/commands/get.mjs +0 -1
  44. package/packages/cli/src/commands/inspect.mjs +0 -1
  45. package/packages/cli/src/commands/list.mjs +1 -6
  46. package/packages/cli/src/commands/login.mjs +161 -0
  47. package/packages/cli/src/commands/logout.mjs +43 -0
  48. package/packages/cli/src/commands/mcp.mjs +0 -1
  49. package/packages/cli/src/commands/new.mjs +0 -1
  50. package/packages/cli/src/commands/pack.mjs +0 -1
  51. package/packages/cli/src/commands/publish.mjs +62 -0
  52. package/packages/cli/src/commands/pull.mjs +59 -0
  53. package/packages/cli/src/commands/push.mjs +115 -0
  54. package/packages/cli/src/commands/resume.mjs +0 -1
  55. package/packages/cli/src/commands/run.mjs +182 -20
  56. package/packages/cli/src/commands/runs.mjs +52 -0
  57. package/packages/cli/src/commands/search.mjs +0 -1
  58. package/packages/cli/src/commands/validate.mjs +1 -3
  59. package/packages/cli/src/commands/whoami.mjs +34 -0
  60. package/packages/cli/src/hosted.mjs +159 -0
  61. package/packages/cli/src/main.mjs +80 -14
  62. package/packages/cli/src/tool-sources.mjs +272 -0
  63. package/packages/cli/src/util.mjs +37 -0
  64. package/packages/engine/lib/credentials.mjs +121 -0
  65. package/packages/engine/lib/hosted.mjs +119 -0
  66. package/packages/engine/lib/hub.mjs +376 -24
  67. package/packages/engine/lib/nango.mjs +157 -0
  68. package/packages/engine/lib/node.mjs +161 -33
  69. package/packages/engine/lib/providers.mjs +8 -3
  70. package/packages/engine/lib/run.mjs +70 -9
  71. package/packages/engine/lib/text.mjs +5 -0
  72. package/packages/engine/package.json +8 -2
  73. package/packages/engine/src/factory-engine.mjs +415 -68
  74. package/packages/engine/src/flow-sources.mjs +135 -0
  75. package/packages/engine/src/mcp-client.mjs +429 -0
  76. package/packages/engine/src/run.mjs +81 -5
  77. package/packages/engine/src/tool-sources.mjs +566 -0
  78. package/packages/mcp/package.json +1 -1
  79. package/packages/mcp/src/server.mjs +47 -17
  80. package/packages/sdk/package.json +28 -0
  81. package/packages/sdk/src/index.d.ts +163 -0
  82. package/packages/sdk/src/index.mjs +266 -0
  83. package/spec/README.md +7 -1
  84. package/spec/factory-format.md +264 -19
  85. package/spec/schema/factory.yaml.json +815 -0
  86. package/spec/schema/flow.schema.json +24 -2
  87. package/spec/skill-export.md +3 -0
@@ -0,0 +1,135 @@
1
+ // Experimental: tool sources a flow declares, in the shape RFC 0017 (a
2
+ // draft) proposes. Read only when a runner opts in to experimental tool
3
+ // sources; the default parser still refuses a flow that requires
4
+ // `tool-sources`, as it refuses any feature it does not know.
5
+ //
6
+ // What this reads: `requires: [tool-sources]` and a top-level `sources` map
7
+ // in flow.yaml, and capability names `<source>/<tool>` in steps' `tools`.
8
+ // What it does not read yet: the fields of other harnesses as references to
9
+ // sources (RFC 0017 section 1.5), and the no-way-out rules beyond the two
10
+ // simplest (section 3.7).
11
+ // SPDX-License-Identifier: Apache-2.0
12
+ import { parseYaml } from './factory-engine.mjs';
13
+
14
+ export const SOURCES_FEATURE = 'tool-sources';
15
+ export const EFFECT_ORDER = ['read', 'write', 'external', 'irreversible'];
16
+ const SOURCE_KEY = /^[a-z0-9][a-z0-9_-]{0,40}$/;
17
+ const TOOL_NAME = /^[A-Za-z0-9_.-]{1,128}$/;
18
+ const ARGUMENT = /^[A-Za-z_$][A-Za-z0-9_$-]{0,63}$/;
19
+ const REGISTRY = /^[A-Za-z0-9.-]{1,100}\/[A-Za-z0-9._-]{1,100}$/;
20
+ const SOURCE_FIELDS = ['title', 'mcp', 'target', 'tools'];
21
+ const TOOL_FIELDS = ['effect', 'title', 'target', 'idempotency'];
22
+ const MAX_SOURCES = 8;
23
+ const MAX_TOOLS = 24;
24
+ const isMap = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
25
+ // Text a person reads at consent: one line, with no control, format or private-use characters.
26
+ const PLAIN_LINE = /^[^\p{Cc}\p{Cf}\p{Co}\p{Cs}\u2028\u2029]*$/u;
27
+ const plainLine = (value) => typeof value === 'string' && PLAIN_LINE.test(value);
28
+ // The factory's own words, when a message repeats them: one line, short, no control characters.
29
+ const quote = (value, limit = 60) => String(value).normalize('NFKC').replace(/[\p{Cc}\p{Cf}\p{Co}\p{Cs}\u2028\u2029]+/gu, ' ').replace(/\s+/g, ' ').trim().slice(0, limit);
30
+ // An own property, also for a name such as __proto__.
31
+ const put = (map, key, value) => Object.defineProperty(map, key, { value, enumerable: true, writable: true, configurable: true });
32
+ const idOf = (text) => String(text || '').toLowerCase().normalize('NFKD').replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 64);
33
+
34
+ /** The stronger of two effect classes. */
35
+ export const strongerEffect = (a, b) => (EFFECT_ORDER.indexOf(a) >= EFFECT_ORDER.indexOf(b) ? a : b);
36
+
37
+ // An https address with a host name, no user name, query or fragment.
38
+ function addressProblem(url) {
39
+ let parsed;
40
+ try { parsed = new URL(url); } catch { return true; }
41
+ const host = parsed.hostname;
42
+ return parsed.protocol !== 'https:' || parsed.username || parsed.password || parsed.search || parsed.hash || url.includes('#') || url.includes('?')
43
+ || !host.includes('.') || /^[\d.]+$/.test(host) || host.startsWith('[');
44
+ }
45
+
46
+ /**
47
+ * The sources a flow declares. Returns `{ declared: false }` for a flow
48
+ * that does not require `tool-sources`; otherwise `{ declared: true,
49
+ * sources, uses, problems }`, where `sources` maps each key to its
50
+ * declaration, `uses` lists for each capability `<source>/<tool>` the steps
51
+ * that name it, and `problems` are the errors of RFC 0017 section 1.6 this
52
+ * runner checks, in its words.
53
+ */
54
+ export function declaredSources(files) {
55
+ let flow;
56
+ try { flow = parseYaml(files?.['flow.yaml'] ?? ''); } catch (error) {
57
+ // A flow that cannot be read says so, rather than seeming to declare nothing.
58
+ if (/^requires:.*\btool-sources\b/m.test(String(files?.['flow.yaml'] ?? ''))) return { declared: true, sources: {}, uses: {}, problems: [`flow.yaml: ${quote(error.message, 200)}`] };
59
+ return { declared: false };
60
+ }
61
+ if (!isMap(flow) || !Array.isArray(flow.requires) || !flow.requires.includes(SOURCES_FEATURE)) return { declared: false };
62
+ const problems = [];
63
+ const sources = {};
64
+ const raw = flow.sources;
65
+ if (raw !== undefined && !isMap(raw)) problems.push('The flow\'s sources must be a map from source name to source.');
66
+ const entries = isMap(raw) ? Object.entries(raw) : [];
67
+ if (entries.length > MAX_SOURCES) problems.push(`A flow declares at most ${MAX_SOURCES} sources, and a source at most ${MAX_TOOLS} tools.`);
68
+ for (const [key, spec] of entries) {
69
+ if (!SOURCE_KEY.test(key)) { problems.push(`Source "${quote(key)}" needs a name of lowercase letters, digits, _ and -, starting with a letter or digit, at most 41 characters.`); continue; }
70
+ if (!isMap(spec)) { problems.push(`Source "${key}" needs title, tools and one protocol field.`); continue; }
71
+ const unknown = Object.keys(spec).filter((field) => !SOURCE_FIELDS.includes(field));
72
+ if (unknown.length) problems.push(`Source "${key}" has a field the format does not define: ${unknown.map((field) => quote(field, 40)).join(', ')}. A source has title, target, tools and one protocol field.`);
73
+ if (typeof spec.title !== 'string' || !spec.title.trim() || spec.title.length > 100) problems.push(`Source "${key}" needs a title of 1 to 100 characters.`);
74
+ else if (!plainLine(spec.title)) problems.push(`Source "${key}" has a title that must be one line of plain text, without line breaks, tabs or control characters.`);
75
+ const mcp = spec.mcp;
76
+ if (!isMap(mcp)) problems.push(`Source "${key}" needs exactly one protocol field: mcp.`);
77
+ else {
78
+ const extra = Object.keys(mcp).filter((field) => !['url', 'registry'].includes(field));
79
+ if (extra.length) problems.push(`Source "${key}" has a field the format does not define: mcp.${extra.map((field) => quote(field, 40)).join(', mcp.')}. A source has title, target, tools and one protocol field.`);
80
+ if (mcp.url === undefined && mcp.registry === undefined) problems.push(`Source "${key}" needs an mcp url or registry name.`);
81
+ if (mcp.url !== undefined && (typeof mcp.url !== 'string' || addressProblem(mcp.url))) problems.push(`Source "${key}" needs an https address with a host name and no user name, query or fragment.`);
82
+ if (mcp.registry !== undefined && (typeof mcp.registry !== 'string' || !REGISTRY.test(mcp.registry))) problems.push(`Source "${key}" has a registry name this runner cannot read: use a name such as com.example/tracker.`);
83
+ }
84
+ let target = null;
85
+ if (spec.target !== undefined) {
86
+ const fieldsOk = isMap(spec.target) && Object.keys(spec.target).every((field) => ['label', 'help'].includes(field));
87
+ if (!fieldsOk || typeof spec.target.label !== 'string' || !spec.target.label.trim() || spec.target.label.length > 40 || (spec.target.help !== undefined && (typeof spec.target.help !== 'string' || spec.target.help.length > 200))) problems.push(`Source "${key}" has a target that needs a label of 1 to 40 characters and, optionally, help of up to 200.`);
88
+ else if (!plainLine(spec.target.label) || (spec.target.help !== undefined && !plainLine(spec.target.help))) problems.push(`Source "${key}" has a target label or help that must be one line of plain text, without line breaks, tabs or control characters.`);
89
+ else target = { label: spec.target.label, help: spec.target.help || '' };
90
+ }
91
+ const tools = {};
92
+ const toolEntries = isMap(spec.tools) ? Object.entries(spec.tools) : [];
93
+ if (!toolEntries.length || toolEntries.length > MAX_TOOLS) problems.push(`Source "${key}" needs tools: 1 to ${MAX_TOOLS} of them.`);
94
+ for (const [name, declaration] of toolEntries) {
95
+ if (!TOOL_NAME.test(name)) { problems.push(`Source "${key}" names a tool "${quote(name)}" that is not a tool name: letters, digits, _, - and ., at most 128.`); continue; }
96
+ if (!isMap(declaration) || !EFFECT_ORDER.includes(declaration.effect)) { problems.push(`The tool "${name}" of source "${key}" needs an effect: read, write, external or irreversible.`); continue; }
97
+ const unknownTool = Object.keys(declaration).filter((field) => !TOOL_FIELDS.includes(field));
98
+ if (unknownTool.length) problems.push(`The tool "${name}" of source "${key}" has a field the format does not define: ${unknownTool.map((field) => quote(field, 40)).join(', ')}.`);
99
+ if (declaration.title !== undefined && (typeof declaration.title !== 'string' || !declaration.title.trim() || declaration.title.length > 100)) problems.push(`The tool "${name}" of source "${key}" needs a title of 1 to 100 characters.`);
100
+ else if (declaration.title !== undefined && !plainLine(declaration.title)) problems.push(`The tool "${name}" of source "${key}" has a title that must be one line of plain text, without line breaks, tabs or control characters.`);
101
+ if (declaration.target !== undefined) {
102
+ if (!target) problems.push(`The tool "${name}" of source "${key}" names a target argument, but the source declares no target.`);
103
+ else if (typeof declaration.target !== 'string' || !ARGUMENT.test(declaration.target)) problems.push(`The tool "${name}" of source "${key}" names a target argument this runner cannot use: one argument name of letters, digits, _, $ and -.`);
104
+ }
105
+ if (declaration.idempotency !== undefined) {
106
+ if (declaration.effect === 'read') problems.push(`The tool "${name}" of source "${key}" only reads, so it takes no idempotency key.`);
107
+ else if (typeof declaration.idempotency !== 'string' || !ARGUMENT.test(declaration.idempotency)) problems.push(`The tool "${name}" of source "${key}" names an idempotency argument this runner cannot use.`);
108
+ }
109
+ put(tools, name, { effect: declaration.effect, title: typeof declaration.title === 'string' ? declaration.title : name, target: typeof declaration.target === 'string' ? declaration.target : null, idempotency: typeof declaration.idempotency === 'string' ? declaration.idempotency : null });
110
+ }
111
+ put(sources, key, { key, title: typeof spec.title === 'string' ? spec.title : key, url: isMap(mcp) && typeof mcp.url === 'string' ? mcp.url : null, registry: isMap(mcp) && typeof mcp.registry === 'string' ? mcp.registry : null, target, tools });
112
+ }
113
+ // Capability names in steps, and the two simplest no-way-out rules.
114
+ const uses = {};
115
+ for (const [stepKey, step] of Object.entries(isMap(flow.steps) ? flow.steps : {})) {
116
+ if (!isMap(step)) continue;
117
+ const title = quote(String(step.title || stepKey).replace(/_/g, ' '), 100);
118
+ const kind = String(step.kind || 'work').toLowerCase();
119
+ const asked = [].concat(step.tools ?? []).map((tool) => String(tool).trim());
120
+ const capabilities = asked.filter((tool) => tool.includes('/'));
121
+ if (!capabilities.length) continue;
122
+ if (!['work', 'deliver'].includes(kind)) { problems.push(`The step "${title}" is a ${quote(kind)} step. Only work and deliver steps use tools from sources.`); continue; }
123
+ const used = new Set();
124
+ for (const name of capabilities) {
125
+ const [source, tool, ...rest] = name.split('/');
126
+ if (rest.length || !Object.hasOwn(sources, source) || !Object.hasOwn(sources[source].tools, tool)) { problems.push(`The step "${title}" names ${quote(name, 120)}, which no source declares.`); continue; }
127
+ used.add(source);
128
+ if (!Object.hasOwn(uses, name)) put(uses, name, []);
129
+ uses[name].push(idOf(stepKey));
130
+ }
131
+ if (asked.some((tool) => tool === 'web_search' || tool === 'web_fetch')) problems.push(`The step "${title}" can reach the web and also read from source "${quote([...used][0] || capabilities[0].split('/')[0])}". Read in one step and use the web in another.`);
132
+ if (used.size > 1) problems.push(`The step "${title}" can read from source "${[...used][0]}" and source "${[...used][1]}". Give each source its own step.`);
133
+ }
134
+ return { declared: true, sources, uses, problems };
135
+ }
@@ -0,0 +1,429 @@
1
+ // Experimental: a client for remote MCP servers over Streamable HTTP.
2
+ // Only runs that opt in to experimental tool sources use it (see
3
+ // tool-sources.mjs); a default run never reaches this code. It will change
4
+ // with the RFC on tool sources.
5
+ //
6
+ // No dependencies and no Node.js-only APIs: fetch, TextDecoder and
7
+ // AbortSignal work in Node.js, browsers and workers alike.
8
+ // SPDX-License-Identifier: Apache-2.0
9
+
10
+ /** The protocol revision this client asks for. It accepts the one the server answers with. */
11
+ export const MCP_PROTOCOL_VERSION = '2025-06-18';
12
+ /** How long one request may take. Remote tools often call other services. */
13
+ export const MCP_TIMEOUT_MS = 90000;
14
+ const MAX_PAGES = 20;
15
+ const MAX_RESPONSE_BYTES = 8 * 1024 * 1024;
16
+ const MAX_ERROR_BYTES = 64 * 1024;
17
+ const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]']);
18
+ /** A shorter token could not be found again reliably in what a server sends back. */
19
+ export const MIN_TOKEN_LENGTH = 16;
20
+ const PROTOCOL_VERSION = /^\d{4}-\d{2}-\d{2}$/;
21
+ const SERVER_NAME = /^[A-Za-z0-9][A-Za-z0-9 ._@/+-]{0,99}$/;
22
+ const SERVER_VERSION = /^[A-Za-z0-9][A-Za-z0-9._+-]{0,39}$/;
23
+ const DELETE_TIMEOUT_MS = 3000;
24
+
25
+ /**
26
+ * A failed request. `retryable` says whether trying again later may work;
27
+ * `kind` names the class of failure: timeout, network, auth, http, rpc,
28
+ * redirect, too_large, input_required, closed or protocol. A message is
29
+ * always this client's own sentence: no text a server sent is in it.
30
+ */
31
+ export class McpError extends Error {
32
+ constructor(message, { retryable = false, status, rpcCode, kind = 'protocol' } = {}) {
33
+ super(message);
34
+ this.name = 'McpError';
35
+ this.kind = kind;
36
+ this.retryable = retryable;
37
+ if (status) this.status = status;
38
+ if (rpcCode !== undefined) this.rpcCode = rpcCode;
39
+ if (kind === 'input_required') this.inputRequired = true;
40
+ }
41
+ }
42
+
43
+ const tooLarge = () => new McpError('The server sent more than 8 MB for one request.', { kind: 'too_large' });
44
+ const isTimeout = (error) => error?.name === 'TimeoutError' || error?.name === 'AbortError';
45
+
46
+ // The body, chunk by chunk, so nothing has to be read whole.
47
+ async function* chunks(response) {
48
+ const reader = response.body?.getReader?.();
49
+ if (!reader) {
50
+ const text = await response.text();
51
+ if (text) yield new TextEncoder().encode(text);
52
+ return;
53
+ }
54
+ try {
55
+ for (;;) {
56
+ const { value, done } = await reader.read();
57
+ if (done) return;
58
+ if (value?.byteLength) yield value;
59
+ }
60
+ } finally {
61
+ reader.cancel().catch(() => {});
62
+ }
63
+ }
64
+
65
+ // At most `limit` bytes of text: past it, an error, or with `cut` the text so far.
66
+ async function readText(response, limit, { cut = false } = {}) {
67
+ const decoder = new TextDecoder();
68
+ let text = '';
69
+ let total = 0;
70
+ for await (const chunk of chunks(response)) {
71
+ total += chunk.byteLength;
72
+ if (total > limit) {
73
+ if (cut) return text;
74
+ throw tooLarge();
75
+ }
76
+ text += decoder.decode(chunk, { stream: true });
77
+ }
78
+ return text + decoder.decode();
79
+ }
80
+
81
+ // Server-sent events, one `data` payload at a time, read as they arrive, so
82
+ // a server that keeps its stream open after answering is fine.
83
+ async function* sseData(response) {
84
+ const decoder = new TextDecoder();
85
+ let buffer = '';
86
+ let data = [];
87
+ let total = 0;
88
+ const lines = function* (final) {
89
+ for (;;) {
90
+ const at = buffer.search(/\r|\n/);
91
+ if (at < 0 || (buffer[at] === '\r' && at === buffer.length - 1 && !final)) return;
92
+ const line = buffer.slice(0, at);
93
+ buffer = buffer.slice(at + (buffer[at] === '\r' && buffer[at + 1] === '\n' ? 2 : 1));
94
+ yield line;
95
+ }
96
+ };
97
+ const event = function* (line) {
98
+ if (line === '') { if (data.length) yield data.join('\n'); data = []; return; }
99
+ if (line.startsWith(':')) return;
100
+ const colon = line.indexOf(':');
101
+ const field = colon < 0 ? line : line.slice(0, colon);
102
+ const value = colon < 0 ? '' : line.slice(colon + 1).replace(/^ /, '');
103
+ if (field === 'data') data.push(value);
104
+ };
105
+ for await (const chunk of chunks(response)) {
106
+ total += chunk.byteLength;
107
+ if (total > MAX_RESPONSE_BYTES) throw tooLarge();
108
+ buffer += decoder.decode(chunk, { stream: true });
109
+ for (const line of lines(false)) yield* event(line);
110
+ }
111
+ buffer += decoder.decode();
112
+ for (const line of lines(true)) yield* event(line);
113
+ if (buffer) yield* event(buffer);
114
+ if (data.length) yield data.join('\n');
115
+ }
116
+
117
+ // A value that reads like a credential: long, letters and digits, varied,
118
+ // and not a date. Words, tool names and dates never are.
119
+ function credentialLike(value) {
120
+ if (value.length < 16 || !/[A-Za-z]/.test(value) || !/\d/.test(value) || /^\d{4}-\d{2}-\d{2}/.test(value)) return false;
121
+ const counts = new Map();
122
+ for (const char of value) counts.set(char, (counts.get(char) || 0) + 1);
123
+ let entropy = 0;
124
+ for (const count of counts.values()) { const share = count / value.length; entropy -= share * Math.log2(share); }
125
+ return entropy >= 3;
126
+ }
127
+ const CREDENTIAL_NAME = /key|token|secret|passw|auth|sig|credential|session|code/i;
128
+
129
+ // The secrets an address carries: query values named like a credential (8
130
+ // characters or more) or reading like one, and path segments reading like one.
131
+ function addressSecrets(endpoint) {
132
+ const found = [];
133
+ for (const [name, value] of endpoint.searchParams) if (value.length >= 8 && (CREDENTIAL_NAME.test(name) || credentialLike(value))) found.push(value);
134
+ for (const segment of endpoint.pathname.split('/')) {
135
+ let value = segment;
136
+ try { value = decodeURIComponent(segment); } catch { /* kept as written */ }
137
+ if (credentialLike(value)) found.push(value);
138
+ }
139
+ return found;
140
+ }
141
+
142
+ const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\/]/g, '\\$&');
143
+ const hexOf = (bytes) => [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('');
144
+
145
+ // The forms of a token this client removes from what a server sends: as
146
+ // written and percent-encoded (any case), hex (any case), and base64 and
147
+ // base64url with or without padding, also where the token starts at any
148
+ // byte offset inside a longer encoded text.
149
+ function secretPatterns(secrets) {
150
+ const anyCase = new Set();
151
+ const exact = new Set();
152
+ for (const token of secrets) addPatterns(token, anyCase, exact);
153
+ const alternation = (set) => [...set].filter(Boolean).sort((a, b) => b.length - a.length).map(escapeRegExp).join('|');
154
+ return [new RegExp(alternation(anyCase), 'gi'), new RegExp(alternation(exact), 'g')];
155
+ }
156
+
157
+ function addPatterns(token, anyCase, exact) {
158
+ const raw = new TextEncoder().encode(token);
159
+ for (const form of [token, encodeURIComponent(token), [...raw].map((byte) => `%${byte.toString(16).padStart(2, '0')}`).join(''), hexOf(raw)]) anyCase.add(form);
160
+ const encode = (bytes) => btoa(String.fromCharCode(...bytes));
161
+ const url = (text) => text.replace(/\+/g, '-').replace(/\//g, '_');
162
+ const full = encode(raw);
163
+ for (const form of [full, full.replace(/=+$/, '')]) { exact.add(form); exact.add(url(form)); }
164
+ for (let offset = 0; offset < 3; offset += 1) {
165
+ const shifted = new Uint8Array(offset + raw.length);
166
+ shifted.set(raw, offset);
167
+ // The characters that depend on the token's bytes alone.
168
+ const middle = encode(shifted).slice(Math.ceil((8 * offset) / 6), Math.floor((8 * (offset + raw.length)) / 6));
169
+ exact.add(middle);
170
+ exact.add(url(middle));
171
+ }
172
+ }
173
+
174
+ /**
175
+ * A client for one remote MCP server over Streamable HTTP.
176
+ *
177
+ * - `url`: the server's MCP endpoint: https, or http on this machine only.
178
+ * - `token`: sent as `Authorization: Bearer <token>` when given, at least
179
+ * 16 characters. It never appears in an error message. It is removed,
180
+ * with the encodings secretPatterns lists, from everything the server
181
+ * sends back; `mentionsSecret` finds a piece of it in text. A hostile
182
+ * server holding the token can still pass it on by a channel this client
183
+ * cannot see.
184
+ * - `fetchImpl`, `timeoutMs`, `clientInfo`: as named.
185
+ *
186
+ * Works with servers that keep a session (`Mcp-Session-Id`) and with
187
+ * stateless ones that need none. Answers may come as plain JSON or as
188
+ * server-sent events.
189
+ */
190
+ export function createMcpClient({ url, token = '', fetchImpl = fetch, timeoutMs = MCP_TIMEOUT_MS, clientInfo = { name: 'factoryhub', version: 'experimental' } }) {
191
+ let endpoint;
192
+ try { endpoint = new URL(url); } catch { throw new McpError('The MCP server address is not a valid URL.'); }
193
+ if (endpoint.protocol !== 'https:' && !(endpoint.protocol === 'http:' && LOOPBACK.has(endpoint.hostname))) throw new McpError('The MCP server address must start with https:// (http:// only for this machine).');
194
+ if (endpoint.username || endpoint.password) throw new McpError('The MCP server address must not hold credentials: pass a token instead.');
195
+ if (token && String(token).length < MIN_TOKEN_LENGTH) throw new McpError(`A token must be at least ${MIN_TOKEN_LENGTH} characters long, so it can be found again in what a server sends back.`);
196
+ // Named in messages without its path or query, which can carry identifiers.
197
+ const origin = endpoint.origin;
198
+ // Secrets to remove: the token, and what in the address reads as a
199
+ // credential (a key put in the query or the path instead of in a token).
200
+ const secrets = [...new Set([...(token ? [String(token)] : []), ...addressSecrets(endpoint)])];
201
+ const patterns = secrets.length ? secretPatterns(secrets) : [];
202
+ const redact = (text) => patterns.reduce((out, pattern) => out.replace(pattern, '[redacted]'), String(text));
203
+ // Any piece of the token at least half its length, in text with spaces,
204
+ // invisible and control characters and case taken out, numeric HTML
205
+ // entities read, forwards or backwards: a split, spaced or reversed echo.
206
+ const span = Math.max(8, Math.ceil(String(token).length / 2));
207
+ const pieces = token ? Array.from({ length: String(token).length - span + 1 }, (_, at) => String(token).slice(at, at + span).toLowerCase()) : [];
208
+ const mentionsSecret = (text) => {
209
+ if (!pieces.length) return false;
210
+ const squashed = String(text)
211
+ .replace(/&#x([0-9a-f]{1,6});/gi, (_, code) => String.fromCodePoint(Math.min(parseInt(code, 16), 0x10ffff)))
212
+ .replace(/&#(\d{1,7});/g, (_, code) => String.fromCodePoint(Math.min(Number(code), 0x10ffff)))
213
+ .normalize('NFKC').replace(/[\s\p{Cf}\p{Cc}]/gu, '').toLowerCase();
214
+ const backwards = [...squashed].reverse().join('');
215
+ return pieces.some((piece) => squashed.includes(piece) || backwards.includes(piece));
216
+ };
217
+ const scrub = (value) => {
218
+ if (typeof value === 'string') return redact(value);
219
+ if (Array.isArray(value)) return value.map(scrub);
220
+ if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value).map(([key, item]) => [redact(key), scrub(item)]));
221
+ return value;
222
+ };
223
+ let nextId = 1;
224
+ let sessionId = null;
225
+ let protocolVersion = null;
226
+ let server = null;
227
+ let initialized = false;
228
+ let initializing = null;
229
+ let closed = false;
230
+ // Aborts every request in flight when the client is closed.
231
+ const closing = new AbortController();
232
+ const signalFor = () => (AbortSignal.any ? AbortSignal.any([AbortSignal.timeout(timeoutMs), closing.signal]) : AbortSignal.timeout(timeoutMs));
233
+ const closedError = () => new McpError('The connection to the tool source was closed.', { kind: 'closed' });
234
+
235
+ const headersFor = (method) => {
236
+ const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream' };
237
+ if (token) headers.authorization = `Bearer ${token}`;
238
+ if (protocolVersion && method !== 'initialize') headers['mcp-protocol-version'] = protocolVersion;
239
+ if (sessionId) headers['mcp-session-id'] = sessionId;
240
+ return headers;
241
+ };
242
+ const timedOut = () => new McpError(`${origin} did not answer within ${Math.round(timeoutMs / 1000)} seconds.`, { retryable: true, kind: 'timeout' });
243
+
244
+ async function post(message) {
245
+ if (closed) throw closedError();
246
+ let response;
247
+ try {
248
+ // Redirects are not followed, so the token only ever goes to this address.
249
+ response = await fetchImpl(endpoint.href, { method: 'POST', headers: headersFor(message.method), body: JSON.stringify(message), redirect: 'manual', signal: signalFor() });
250
+ } catch (error) {
251
+ if (closed) throw closedError();
252
+ if (isTimeout(error)) throw timedOut();
253
+ throw new McpError(`The connection to ${origin} failed.`, { retryable: true, kind: 'network' });
254
+ }
255
+ if (response.type === 'opaqueredirect' || (response.status >= 300 && response.status < 400)) {
256
+ await response.body?.cancel?.().catch?.(() => {});
257
+ throw new McpError(`${origin} answered with a redirect, which this client does not follow. Give the server's final address.`, { kind: 'redirect' });
258
+ }
259
+ return response;
260
+ }
261
+
262
+ // The class and status of a failed answer. What the server wrote is read
263
+ // (at most 64 KB, to free the connection) and never put in the message.
264
+ async function failureOf(response, method) {
265
+ try { await readText(response, MAX_ERROR_BYTES, { cut: true }); } catch { /* nothing to keep */ }
266
+ const status = response.status;
267
+ if (status === 401 || status === 403) return new McpError(`${origin} refused the credentials for ${method} (HTTP ${status}).`, { status, kind: 'auth' });
268
+ const retryable = status === 408 || status === 429 || status >= 500;
269
+ return new McpError(`${origin} answered ${method} with HTTP ${status}.`, { status, retryable, kind: 'http' });
270
+ }
271
+
272
+ // Answers a ping the server sends while it works on a request. Never throws.
273
+ async function pong(requestId) {
274
+ try {
275
+ const response = await post({ jsonrpc: '2.0', id: requestId, result: {} });
276
+ await response.body?.cancel?.().catch?.(() => {});
277
+ } catch { /* the server goes on or gives up */ }
278
+ }
279
+
280
+ // Sends one request and returns its result, or throws an McpError.
281
+ async function request(method, params, retried = false) {
282
+ const id = nextId;
283
+ nextId += 1;
284
+ const used = sessionId;
285
+ const response = await post({ jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) });
286
+ // An expired session: one new session, shared by every request that saw it expire.
287
+ if (response.status === 404 && used && method !== 'initialize' && !retried) {
288
+ await response.body?.cancel?.().catch?.(() => {});
289
+ if (sessionId === used) { sessionId = null; initialized = false; }
290
+ await ready();
291
+ return request(method, params, true);
292
+ }
293
+ if (!response.ok) throw await failureOf(response, method);
294
+ if (method === 'initialize') {
295
+ const given = response.headers.get('mcp-session-id');
296
+ if (given && /^[\x21-\x7e]{1,512}$/.test(given)) sessionId = given;
297
+ }
298
+ const type = (response.headers.get('content-type') || '').toLowerCase();
299
+ const answer = async (messages) => {
300
+ for (const raw of [].concat(messages)) {
301
+ const message = scrub(raw);
302
+ if (!message || typeof message !== 'object') continue;
303
+ if (message.id === id && (Object.hasOwn(message, 'result') || Object.hasOwn(message, 'error'))) return { done: true, message };
304
+ if (typeof message.method === 'string' && message.id !== undefined && message.id !== null) {
305
+ if (message.method === 'ping') { await pong(message.id); continue; }
306
+ // The server asks the client something (elicitation, sampling,
307
+ // roots) before it answers: this client cannot take part yet.
308
+ return { done: true, asks: message.method };
309
+ }
310
+ }
311
+ return { done: false };
312
+ };
313
+ let found = { done: false };
314
+ try {
315
+ if (type.includes('text/event-stream')) {
316
+ for await (const data of sseData(response)) {
317
+ let parsed;
318
+ try { parsed = JSON.parse(data); } catch { throw new McpError('The server sent an event that is not JSON.'); }
319
+ found = await answer(parsed);
320
+ if (found.done) break;
321
+ }
322
+ } else if (type.includes('application/json')) {
323
+ let parsed;
324
+ const text = await readText(response, MAX_RESPONSE_BYTES);
325
+ try { parsed = JSON.parse(text); } catch { throw new McpError('The server answered with invalid JSON.'); }
326
+ found = await answer(parsed);
327
+ } else {
328
+ await response.body?.cancel?.().catch?.(() => {});
329
+ throw new McpError(`${origin} answered ${method} with an unexpected content type.`);
330
+ }
331
+ } catch (error) {
332
+ if (closed) throw closedError();
333
+ if (error instanceof McpError) throw error;
334
+ if (isTimeout(error)) throw timedOut();
335
+ throw new McpError(`The connection to ${origin} broke while the answer was read.`, { retryable: true, kind: 'network' });
336
+ }
337
+ if (found.asks) throw new McpError('The server asked for input before answering, which this experimental mode does not support yet.', { kind: 'input_required' });
338
+ if (!found.done) throw new McpError(`${origin} closed the answer to ${method} without a result.`, { retryable: true });
339
+ const { message } = found;
340
+ if (message.error) {
341
+ const code = Number.isSafeInteger(message.error.code) ? message.error.code : undefined;
342
+ throw new McpError(`${origin} answered ${method} with JSON-RPC error ${code ?? 'without a code'}.`, { rpcCode: code, retryable: code === -32603, kind: 'rpc' });
343
+ }
344
+ return message.result;
345
+ }
346
+
347
+ async function notify(method, params) {
348
+ const response = await post({ jsonrpc: '2.0', method, ...(params !== undefined ? { params } : {}) });
349
+ await response.body?.cancel?.().catch?.(() => {});
350
+ if (response.status === 401 || response.status === 403) throw new McpError(`${origin} refused the credentials (HTTP ${response.status}).`, { status: response.status, kind: 'auth' });
351
+ }
352
+
353
+ // One handshake at a time: requests that need one while it runs wait for it.
354
+ function initialize() {
355
+ initializing ||= (async () => {
356
+ const result = await request('initialize', { protocolVersion: MCP_PROTOCOL_VERSION, capabilities: {}, clientInfo });
357
+ // A revision is a date. Anything else is refused: this value goes back
358
+ // to the server in a header and into every receipt entry.
359
+ if (!result || typeof result.protocolVersion !== 'string' || !PROTOCOL_VERSION.test(result.protocolVersion)) throw new McpError(`${origin} answered initialize with a protocol version that is not a date.`, { kind: 'protocol' });
360
+ protocolVersion = result.protocolVersion;
361
+ // The server's name and version, only when they read as a plain name and version.
362
+ const plain = (value, pattern) => (typeof value === 'string' && pattern.test(value) && !mentionsSecret(value) ? value : null);
363
+ server = { name: plain(result.serverInfo?.name, SERVER_NAME), version: plain(result.serverInfo?.version, SERVER_VERSION) };
364
+ await notify('notifications/initialized');
365
+ initialized = true;
366
+ return { protocolVersion, server };
367
+ })().finally(() => { initializing = null; });
368
+ return initializing;
369
+ }
370
+
371
+ async function ready() { if (!initialized) await initialize(); }
372
+
373
+ return {
374
+ origin,
375
+ get protocolVersion() { return protocolVersion; },
376
+ get server() { return server; },
377
+ get sessionId() { return sessionId; },
378
+ /** Removes the token, and the encodings of it this client knows, from text. */
379
+ redact,
380
+ /** True when text holds a piece of the token, however spaced, split or cased. */
381
+ mentionsSecret,
382
+ initialize,
383
+ /** Every tool the server lists, following `nextCursor` page by page. */
384
+ async listTools() {
385
+ await ready();
386
+ const tools = [];
387
+ const seen = new Set();
388
+ let cursor;
389
+ for (let page = 0; page < MAX_PAGES; page += 1) {
390
+ const result = await request('tools/list', cursor === undefined ? {} : { cursor });
391
+ if (!result || !Array.isArray(result.tools)) throw new McpError('The server answered tools/list without a list of tools.');
392
+ tools.push(...result.tools);
393
+ cursor = result.nextCursor;
394
+ if (cursor === undefined || cursor === null || cursor === '') return tools;
395
+ if (typeof cursor !== 'string' || seen.has(cursor)) throw new McpError('The server sent a tools/list page cursor that repeats or is not text.');
396
+ seen.add(cursor);
397
+ }
398
+ throw new McpError(`The server listed more than ${MAX_PAGES} pages of tools.`);
399
+ },
400
+ /** The raw `tools/call` result. Errors are thrown as McpError. */
401
+ async callTool(name, args) {
402
+ await ready();
403
+ return request('tools/call', { name, arguments: args });
404
+ },
405
+ /**
406
+ * Ends the server's session, when it gave one, and refuses new requests.
407
+ * During a handshake, the session it opens is ended once it settles;
408
+ * this waits for that at most a few seconds. Never throws.
409
+ */
410
+ async close() {
411
+ closed = true;
412
+ closing.abort();
413
+ const end = async () => {
414
+ if (!sessionId) return;
415
+ const ending = sessionId;
416
+ sessionId = null;
417
+ try {
418
+ const headers = { ...headersFor('close'), 'mcp-session-id': ending };
419
+ const response = await fetchImpl(endpoint.href, { method: 'DELETE', headers, redirect: 'manual', signal: AbortSignal.timeout(DELETE_TIMEOUT_MS) });
420
+ await response.body?.cancel?.().catch?.(() => {});
421
+ } catch { /* the session expires on its own */ }
422
+ };
423
+ const settled = initializing ? initializing.catch(() => {}).then(end) : end();
424
+ let timer;
425
+ await Promise.race([settled, new Promise((resolve) => { timer = setTimeout(resolve, DELETE_TIMEOUT_MS + 1000); })]);
426
+ clearTimeout(timer);
427
+ },
428
+ };
429
+ }