@danieltmn/tamnora 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (123) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/LICENSE +21 -0
  3. package/README.md +130 -0
  4. package/SECURITY.md +31 -0
  5. package/harness/packages/cli/bin/tamnora.mjs +11 -0
  6. package/harness/packages/cli/package.json +10 -0
  7. package/harness/packages/cli/src/cli.js +90 -0
  8. package/harness/packages/cli/src/commands/autostart.js +121 -0
  9. package/harness/packages/cli/src/commands/bridge.js +274 -0
  10. package/harness/packages/cli/src/commands/doctor.js +73 -0
  11. package/harness/packages/cli/src/commands/eval.js +183 -0
  12. package/harness/packages/cli/src/commands/init.js +31 -0
  13. package/harness/packages/cli/src/commands/mcp.js +101 -0
  14. package/harness/packages/cli/src/commands/repl.js +172 -0
  15. package/harness/packages/cli/src/commands/run.js +165 -0
  16. package/harness/packages/cli/src/commands/serve.js +1 -0
  17. package/harness/packages/cli/src/commands/server.js +345 -0
  18. package/harness/packages/cli/src/commands/tui.js +110 -0
  19. package/harness/packages/cli/src/commands/update.js +76 -0
  20. package/harness/packages/cli/src/commands/user.js +135 -0
  21. package/harness/packages/cli/src/lib/core.js +2 -0
  22. package/harness/packages/cli/src/lib/daemon.js +153 -0
  23. package/harness/packages/cli/src/lib/qr.js +203 -0
  24. package/harness/packages/cli/src/lib/runtime.js +110 -0
  25. package/harness/packages/cli/src/lib/tunnel.js +113 -0
  26. package/harness/packages/cli/src/lib/util.js +24 -0
  27. package/harness/packages/cli/src/tui/app.js +302 -0
  28. package/harness/packages/cli/src/tui/keys.js +78 -0
  29. package/harness/packages/cli/src/tui/layout.js +130 -0
  30. package/harness/packages/cli/src/tui/screen.js +13 -0
  31. package/harness/packages/cli/src/ui/color.js +15 -0
  32. package/harness/packages/cli/test/release.test.js +34 -0
  33. package/harness/packages/cli/test/tui.test.js +99 -0
  34. package/harness/packages/cli/test/tunnel.test.js +20 -0
  35. package/harness/packages/core/package.json +14 -0
  36. package/harness/packages/core/src/agent/context.js +66 -0
  37. package/harness/packages/core/src/agent/modes.js +26 -0
  38. package/harness/packages/core/src/agent/runAgent.js +257 -0
  39. package/harness/packages/core/src/config/app.js +49 -0
  40. package/harness/packages/core/src/config/home.js +98 -0
  41. package/harness/packages/core/src/config/identity.js +41 -0
  42. package/harness/packages/core/src/config/index.js +69 -0
  43. package/harness/packages/core/src/config/providers.js +24 -0
  44. package/harness/packages/core/src/env.js +19 -0
  45. package/harness/packages/core/src/eval/dataset.js +47 -0
  46. package/harness/packages/core/src/eval/judge.js +39 -0
  47. package/harness/packages/core/src/eval/report.js +48 -0
  48. package/harness/packages/core/src/eval/runTask.js +71 -0
  49. package/harness/packages/core/src/eval/runner.js +150 -0
  50. package/harness/packages/core/src/eval/scorers.js +217 -0
  51. package/harness/packages/core/src/eval/task.js +59 -0
  52. package/harness/packages/core/src/index.js +72 -0
  53. package/harness/packages/core/src/mcp/common.js +82 -0
  54. package/harness/packages/core/src/mcp/http.js +149 -0
  55. package/harness/packages/core/src/mcp/index.js +167 -0
  56. package/harness/packages/core/src/mcp/stdio.js +218 -0
  57. package/harness/packages/core/src/providers/index.js +119 -0
  58. package/harness/packages/core/src/providers/ollama.js +76 -0
  59. package/harness/packages/core/src/providers/openaiCompatible.js +57 -0
  60. package/harness/packages/core/src/providers/openrouter.js +57 -0
  61. package/harness/packages/core/src/providers/presets.js +19 -0
  62. package/harness/packages/core/src/providers/resilience.js +74 -0
  63. package/harness/packages/core/src/providers/stream.js +200 -0
  64. package/harness/packages/core/src/providers/utils.js +66 -0
  65. package/harness/packages/core/src/storage/sqlite.js +453 -0
  66. package/harness/packages/core/src/tools/diff.js +90 -0
  67. package/harness/packages/core/src/tools/executors.js +277 -0
  68. package/harness/packages/core/src/tools/glob.js +67 -0
  69. package/harness/packages/core/src/tools/index.js +28 -0
  70. package/harness/packages/core/src/tools/paths.js +29 -0
  71. package/harness/packages/core/src/tools/permissions.js +69 -0
  72. package/harness/packages/core/src/tools/registry.js +44 -0
  73. package/harness/packages/core/src/tools/schema.js +177 -0
  74. package/harness/packages/core/test/agent.test.js +237 -0
  75. package/harness/packages/core/test/config.test.js +33 -0
  76. package/harness/packages/core/test/context.test.js +44 -0
  77. package/harness/packages/core/test/diff.test.js +20 -0
  78. package/harness/packages/core/test/eval.test.js +144 -0
  79. package/harness/packages/core/test/git-tools.test.js +44 -0
  80. package/harness/packages/core/test/home.test.js +62 -0
  81. package/harness/packages/core/test/mcp.test.js +158 -0
  82. package/harness/packages/core/test/providers.test.js +80 -0
  83. package/harness/packages/core/test/resilience.test.js +61 -0
  84. package/harness/packages/core/test/storage.test.js +55 -0
  85. package/harness/packages/core/test/stream.test.js +91 -0
  86. package/harness/packages/core/test/tools.test.js +96 -0
  87. package/harness/packages/server/bin/tamnora-server.mjs +11 -0
  88. package/harness/packages/server/package.json +10 -0
  89. package/harness/packages/server/public/app.js +2188 -0
  90. package/harness/packages/server/public/img/fondo-agentes.webp +0 -0
  91. package/harness/packages/server/public/index.html +368 -0
  92. package/harness/packages/server/public/landing/index.html +523 -0
  93. package/harness/packages/server/public/landing/landing.js +89 -0
  94. package/harness/packages/server/public/markdown.js +174 -0
  95. package/harness/packages/server/public/styles.css +923 -0
  96. package/harness/packages/server/src/auth.js +146 -0
  97. package/harness/packages/server/src/cli.js +61 -0
  98. package/harness/packages/server/src/index.js +1 -0
  99. package/harness/packages/server/src/lib/approvals.js +51 -0
  100. package/harness/packages/server/src/lib/core.js +2 -0
  101. package/harness/packages/server/src/lib/http.js +74 -0
  102. package/harness/packages/server/src/lib/modelCache.js +32 -0
  103. package/harness/packages/server/src/lib/prompt.js +48 -0
  104. package/harness/packages/server/src/lib/workspace.js +74 -0
  105. package/harness/packages/server/src/routes/auth.js +56 -0
  106. package/harness/packages/server/src/routes/chat.js +222 -0
  107. package/harness/packages/server/src/routes/eval.js +100 -0
  108. package/harness/packages/server/src/routes/files.js +117 -0
  109. package/harness/packages/server/src/routes/folders.js +80 -0
  110. package/harness/packages/server/src/routes/mcp.js +50 -0
  111. package/harness/packages/server/src/routes/models.js +26 -0
  112. package/harness/packages/server/src/routes/providers.js +135 -0
  113. package/harness/packages/server/src/routes/sessions.js +157 -0
  114. package/harness/packages/server/src/server.js +249 -0
  115. package/harness/packages/server/src/static.js +67 -0
  116. package/harness/packages/server/test/markdown.test.js +36 -0
  117. package/harness/packages/server/test/modelCache.test.js +72 -0
  118. package/harness/packages/server/test/server.test.js +809 -0
  119. package/harness/scripts/example-dataset.jsonl +4 -0
  120. package/harness/scripts/mcp-echo-server.mjs +108 -0
  121. package/harness/scripts/mock-provider.mjs +134 -0
  122. package/harness/tamnora.example.json +18 -0
  123. package/package.json +70 -0
@@ -0,0 +1,217 @@
1
+ import { readFile, access } from 'node:fs/promises';
2
+ import { spawn } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { normalizeExpect } from './task.js';
5
+
6
+ const DEFAULT_SCRIPT_TIMEOUT = 60;
7
+
8
+ function outputText(output) {
9
+ return typeof output === 'string' ? output : output == null ? '' : JSON.stringify(output);
10
+ }
11
+
12
+ /** Extract a JSON value from text, tolerating ```json fenced blocks. */
13
+ export function extractJson(text) {
14
+ const trimmed = String(text ?? '').trim();
15
+ if (!trimmed) return { error: 'vacío' };
16
+
17
+ try {
18
+ return { value: JSON.parse(trimmed) };
19
+ } catch {
20
+ // try fenced block
21
+ }
22
+
23
+ const match = trimmed.match(/```(?:json)?\s*([\s\S]*?)```/i);
24
+ if (match) {
25
+ try {
26
+ return { value: JSON.parse(match[1].trim()) };
27
+ } catch (err) {
28
+ return { error: err.message };
29
+ }
30
+ }
31
+
32
+ // inline object/array inside surrounding text
33
+ for (const [open, close] of [
34
+ ['{', '}'],
35
+ ['[', ']'],
36
+ ]) {
37
+ const start = trimmed.indexOf(open);
38
+ const end = trimmed.lastIndexOf(close);
39
+ if (start !== -1 && end > start) {
40
+ try {
41
+ return { value: JSON.parse(trimmed.slice(start, end + 1)) };
42
+ } catch {
43
+ // keep looking
44
+ }
45
+ }
46
+ }
47
+
48
+ return { error: 'no se pudo parsear JSON' };
49
+ }
50
+
51
+ /** Minimal JSON Schema validator (type, required, properties, items, enum). */
52
+ export function validateSchema(value, schema) {
53
+ const errors = [];
54
+ const check = (val, sch, pointer) => {
55
+ if (!sch || typeof sch !== 'object') return;
56
+ if (sch.type) {
57
+ const types = Array.isArray(sch.type) ? sch.type : [sch.type];
58
+ const actual =
59
+ val === null ? 'null' : Array.isArray(val) ? 'array' : typeof val === 'number' && Number.isInteger(val) ? 'integer' : typeof val;
60
+ const ok = types.some((t) => t === actual || (t === 'number' && actual === 'integer'));
61
+ if (!ok) errors.push(`${pointer}: se esperaba ${types.join('|')}, llegó ${actual}`);
62
+ }
63
+ if (Array.isArray(sch.enum) && !sch.enum.includes(val)) {
64
+ errors.push(`${pointer}: valor no está en enum`);
65
+ }
66
+ if (sch.type === 'object' || sch.properties) {
67
+ for (const req of sch.required ?? []) {
68
+ if (val == null || val[req] === undefined) errors.push(`${pointer}: falta "${req}"`);
69
+ }
70
+ for (const [key, sub] of Object.entries(sch.properties ?? {})) {
71
+ if (val && val[key] !== undefined) check(val[key], sub, `${pointer}.${key}`);
72
+ }
73
+ }
74
+ if (sch.type === 'array' && sch.items && Array.isArray(val)) {
75
+ val.forEach((item, i) => check(item, sch.items, `${pointer}[${i}]`));
76
+ }
77
+ };
78
+ check(value, schema, '$');
79
+ return { valid: errors.length === 0, errors };
80
+ }
81
+
82
+ function runCommand(command, cwd, timeoutSec = DEFAULT_SCRIPT_TIMEOUT) {
83
+ return new Promise((resolve) => {
84
+ const child = spawn(command, { cwd, shell: true, windowsHide: true });
85
+ let output = '';
86
+ const append = (d) => {
87
+ if (output.length < 20_000) output += d.toString();
88
+ };
89
+ child.stdout?.on('data', append);
90
+ child.stderr?.on('data', append);
91
+ const timer = setTimeout(() => child.kill('SIGKILL'), timeoutSec * 1000);
92
+ child.on('error', (err) => {
93
+ clearTimeout(timer);
94
+ resolve({ code: -1, output: err.message });
95
+ });
96
+ child.on('close', (code) => {
97
+ clearTimeout(timer);
98
+ resolve({ code, output });
99
+ });
100
+ });
101
+ }
102
+
103
+ const SCORERS = {
104
+ exact(spec, ctx) {
105
+ const a = outputText(ctx.output).trim();
106
+ const b = String(spec.value ?? '').trim();
107
+ const match = spec.caseSensitive ? a === b : a.toLowerCase() === b.toLowerCase();
108
+ return { passed: match, detail: match ? 'coincide' : `esperaba "${b}"` };
109
+ },
110
+
111
+ contains(spec, ctx) {
112
+ const haystack = spec.caseSensitive ? outputText(ctx.output) : outputText(ctx.output).toLowerCase();
113
+ const needle = spec.caseSensitive ? String(spec.value ?? '') : String(spec.value ?? '').toLowerCase();
114
+ const match = needle ? haystack.includes(needle) : true;
115
+ return { passed: match, detail: match ? `contiene "${spec.value}"` : `no contiene "${spec.value}"` };
116
+ },
117
+
118
+ regex(spec, ctx) {
119
+ try {
120
+ const re = new RegExp(spec.pattern, spec.flags ?? '');
121
+ const match = re.test(outputText(ctx.output));
122
+ return { passed: match, detail: match ? 'regex ok' : `no matchea /${spec.pattern}/` };
123
+ } catch (err) {
124
+ return { passed: false, detail: `regex inválida: ${err.message}` };
125
+ }
126
+ },
127
+
128
+ jsonSchema(spec, ctx) {
129
+ const { value, error } = extractJson(outputText(ctx.output));
130
+ if (error) return { passed: false, detail: `JSON inválido: ${error}` };
131
+ const { valid, errors } = validateSchema(value, spec.schema ?? {});
132
+ return { passed: valid, detail: valid ? 'schema ok' : errors.join('; ') };
133
+ },
134
+
135
+ toolCalled(spec, ctx) {
136
+ const calls = ctx.toolCalls ?? [];
137
+ const match = calls.some((c) => {
138
+ if (c.name !== spec.name) return false;
139
+ if (!spec.argsContains) return true;
140
+ return JSON.stringify(c.args ?? {}).includes(spec.argsContains);
141
+ });
142
+ return {
143
+ passed: match,
144
+ detail: match ? `llamó ${spec.name}` : `no llamó ${spec.name}`,
145
+ };
146
+ },
147
+
148
+ async fileExists(spec, ctx) {
149
+ try {
150
+ await access(path.join(ctx.workspaceDir, spec.path));
151
+ return { passed: true, detail: `${spec.path} existe` };
152
+ } catch {
153
+ return { passed: false, detail: `${spec.path} no existe` };
154
+ }
155
+ },
156
+
157
+ async fileContains(spec, ctx) {
158
+ try {
159
+ const content = await readFile(path.join(ctx.workspaceDir, spec.path), 'utf8');
160
+ const match = content.includes(spec.value);
161
+ return { passed: match, detail: match ? `${spec.path} contiene "${spec.value}"` : `no contiene` };
162
+ } catch (err) {
163
+ return { passed: false, detail: `no se pudo leer ${spec.path}: ${err.message}` };
164
+ }
165
+ },
166
+
167
+ async script(spec, ctx) {
168
+ const { code, output } = await runCommand(spec.command, ctx.workspaceDir, spec.timeout);
169
+ const expected = spec.exit ?? 0;
170
+ return {
171
+ passed: code === expected,
172
+ detail: `exit ${code} (esperado ${expected})${output ? `: ${output.slice(0, 200)}` : ''}`,
173
+ };
174
+ },
175
+
176
+ async llmJudge(spec, ctx) {
177
+ if (!ctx.judge) return { passed: false, detail: 'no hay modelo juez configurado' };
178
+ return ctx.judge({ criteria: spec.criteria, output: outputText(ctx.output), input: ctx.input });
179
+ },
180
+ };
181
+
182
+ const DEFAULT_SCORER_NAMES = Object.keys(SCORERS);
183
+
184
+ /**
185
+ * Evaluate a task result against its scorers.
186
+ * @param {object} task
187
+ * @param {{output:any, toolCalls?:object[], workspaceDir:string, input?:string, judge?:Function}} ctx
188
+ * @param {string[]} [scorerNames] - subset of scorers to use
189
+ */
190
+ export async function runScorers(task, ctx, scorerNames = DEFAULT_SCORER_NAMES) {
191
+ const specs = normalizeExpect(task.expect);
192
+ if (!specs.length) return { passed: true, score: 1, details: [] };
193
+
194
+ const details = [];
195
+ for (const spec of specs) {
196
+ if (!scorerNames.includes(spec.type) || !SCORERS[spec.type]) {
197
+ details.push({ scorer: spec.type, passed: false, detail: 'scorer desconocido' });
198
+ continue;
199
+ }
200
+ let result;
201
+ try {
202
+ result = await SCORERS[spec.type](spec, ctx);
203
+ } catch (err) {
204
+ result = { passed: false, detail: `error: ${err.message}` };
205
+ }
206
+ details.push({ scorer: spec.type, ...result });
207
+ }
208
+
209
+ const passedCount = details.filter((d) => d.passed).length;
210
+ return {
211
+ passed: passedCount === details.length,
212
+ score: details.length ? passedCount / details.length : 1,
213
+ details,
214
+ };
215
+ }
216
+
217
+ export { SCORERS, DEFAULT_SCORER_NAMES };
@@ -0,0 +1,59 @@
1
+ import { mkdtemp, cp, mkdir, rm } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * Create an isolated workspace for a task. If the task has a `fixture`
7
+ * (a directory, relative to `rootDir`), it is copied into the workspace.
8
+ *
9
+ * @param {object} task
10
+ * @param {{rootDir?: string, parentDir?: string}} [opts]
11
+ * @returns {Promise<{dir:string, parent:string, cleanup:()=>Promise<void>}>}
12
+ */
13
+ export async function createTaskWorkspace(task, { rootDir = process.cwd(), parentDir } = {}) {
14
+ const parent = parentDir ?? (await mkdtemp(path.join(tmpdir(), 'tamnora-task-')));
15
+ const dir = path.join(parent, 'workspace');
16
+ await mkdir(dir, { recursive: true });
17
+
18
+ if (task.fixture) {
19
+ const source = path.isAbsolute(task.fixture)
20
+ ? task.fixture
21
+ : path.resolve(rootDir, task.fixture);
22
+ await cp(source, dir, { recursive: true });
23
+ }
24
+
25
+ const cleanup = async () => {
26
+ if (task.keepWorkspace) return;
27
+ await rm(parent, { recursive: true, force: true }).catch(() => {});
28
+ };
29
+
30
+ return { dir, parent, cleanup };
31
+ }
32
+
33
+ /**
34
+ * Normalize a task's `expect` field into a list of scorer specs.
35
+ * Accepts a string, a single object, or an array; also supports shorthand keys.
36
+ */
37
+ export function normalizeExpect(expect) {
38
+ if (expect === undefined || expect === null) return [];
39
+ if (typeof expect === 'string') return [{ type: 'contains', value: expect }];
40
+ if (Array.isArray(expect)) return expect.map(normalizeSpec);
41
+ return [normalizeSpec(expect)];
42
+ }
43
+
44
+ function normalizeSpec(spec) {
45
+ if (typeof spec === 'string') return { type: 'contains', value: spec };
46
+ if (spec.type) return spec;
47
+ if ('contains' in spec) return { type: 'contains', value: spec.contains };
48
+ if ('equals' in spec) return { type: 'exact', value: spec.equals };
49
+ if ('regex' in spec) return { type: 'regex', pattern: spec.regex };
50
+ if ('jsonSchema' in spec) return { type: 'jsonSchema', schema: spec.jsonSchema };
51
+ if ('toolCalled' in spec) return { type: 'toolCalled', name: spec.toolCalled };
52
+ if ('fileExists' in spec) return { type: 'fileExists', path: spec.fileExists };
53
+ if ('fileContains' in spec) {
54
+ return { type: 'fileContains', path: spec.fileContains.path, value: spec.fileContains.value };
55
+ }
56
+ if ('script' in spec) return { type: 'script', command: spec.script };
57
+ if ('llmJudge' in spec) return { type: 'llmJudge', criteria: spec.llmJudge };
58
+ return { type: 'unknown', raw: spec };
59
+ }
@@ -0,0 +1,72 @@
1
+ export { getEnv, envValue } from './env.js';
2
+ export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
3
+ export { estimateTokens, trimContext } from './agent/context.js';
4
+ export { diffLines } from './tools/diff.js';
5
+ export { MODES, MODE_IDS, resolveMode } from './agent/modes.js';
6
+ export { withRetry, isRetryableError, isAbortError } from './providers/resilience.js';
7
+ export {
8
+ PROVIDERS,
9
+ PROVIDER_META,
10
+ getProvider,
11
+ createCustomProvider,
12
+ resolveProviderRuntime,
13
+ listProviderModels,
14
+ testProviderConnection,
15
+ streamChat,
16
+ } from './providers/index.js';
17
+ export {
18
+ connectMcpServer,
19
+ connectMcpHttpServer,
20
+ connectMcpStdioServer,
21
+ buildMcpRegistry,
22
+ mcpTransport,
23
+ qualifiedToolName,
24
+ mcpToolToDefinition,
25
+ } from './mcp/index.js';
26
+ export {
27
+ loadConfig,
28
+ loadInstructions,
29
+ resolveModel,
30
+ resolveProvider,
31
+ databasePath,
32
+ STATE_DIR,
33
+ DEFAULT_CONFIG,
34
+ } from './config/index.js';
35
+ export {
36
+ DIR_NAME,
37
+ setHome,
38
+ homeDir,
39
+ homePath,
40
+ appConfigPath,
41
+ databaseFile,
42
+ runtimePath,
43
+ pidPath,
44
+ logsDir,
45
+ serverLogPath,
46
+ bridgeLogPath,
47
+ bridgeStatePath,
48
+ ensureHome,
49
+ homeExists,
50
+ migrateLegacyDb,
51
+ } from './config/home.js';
52
+ export { DEFAULT_APP, readApp, writeApp, ensureApp, randomToken } from './config/app.js';
53
+ export { hashPassword, verifyPassword, userValidName, makeUser } from './config/identity.js';
54
+ export { composeProviderSettings } from './config/providers.js';
55
+ export {
56
+ createTools,
57
+ createToolRegistry,
58
+ TOOL_DEFINITIONS,
59
+ TOOL_NAMES,
60
+ DEFAULT_PERMISSIONS,
61
+ evaluatePermission,
62
+ resolveInRoot,
63
+ toRel,
64
+ } from './tools/index.js';
65
+ export { Store, openDatabase } from './storage/sqlite.js';
66
+ export { runTask, resolveApiKey } from './eval/runTask.js';
67
+ export { loadDataset } from './eval/dataset.js';
68
+ export { runDataset, extractToolCalls } from './eval/runner.js';
69
+ export { summarize } from './eval/report.js';
70
+ export { runScorers, validateSchema, extractJson, SCORERS } from './eval/scorers.js';
71
+ export { createJudge } from './eval/judge.js';
72
+ export { createTaskWorkspace, normalizeExpect } from './eval/task.js';
@@ -0,0 +1,82 @@
1
+ // Shared MCP helpers used by both transports (HTTP and stdio).
2
+
3
+ export const PROTOCOL_VERSION = '2025-06-18';
4
+ export const CLIENT_INFO = { name: 'tamnora', version: '1.0.0' };
5
+
6
+ /** Namespace an MCP tool as `mcp__<server>__<tool>`, sanitized and capped. */
7
+ export function qualifiedToolName(serverId, toolName) {
8
+ const prefix = String(serverId).replace(/[^a-zA-Z0-9_-]/g, '_').slice(0, 20);
9
+ return `mcp__${prefix}__${toolName}`.slice(0, 64);
10
+ }
11
+
12
+ export function mcpToolToDefinition(tool, server, qualifiedName) {
13
+ return {
14
+ type: 'function',
15
+ function: {
16
+ name: qualifiedName,
17
+ description:
18
+ tool.description || `MCP tool "${tool.name}" del servidor ${server.name ?? server.id}`,
19
+ parameters: tool.inputSchema ?? { type: 'object', properties: {} },
20
+ },
21
+ };
22
+ }
23
+
24
+ /** Flatten an MCP `tools/call` result into plain text for the model. */
25
+ export function mcpResultToText(result) {
26
+ if (!result) return '';
27
+ if (typeof result.content === 'string') return result.content;
28
+ if (Array.isArray(result.content)) {
29
+ const text = result.content
30
+ .map((c) => (c.type === 'text' ? c.text : `[${c.type}]`))
31
+ .join('\n');
32
+ return result.isError ? `Error: ${text}` : text;
33
+ }
34
+ return JSON.stringify(result);
35
+ }
36
+
37
+ /** Flatten a `resources/read` result into plain text. */
38
+ export function mcpResourceToText(result) {
39
+ const contents = result?.contents ?? [];
40
+ if (!contents.length) return '(recurso vacío)';
41
+ return contents
42
+ .map((c) => {
43
+ if (typeof c.text === 'string') return c.text;
44
+ if (c.blob) return `[binario ${c.mimeType ?? ''} ${c.blob.length}b64]`;
45
+ return JSON.stringify(c);
46
+ })
47
+ .join('\n');
48
+ }
49
+
50
+ /** Flatten a `prompts/get` result into plain text. */
51
+ export function mcpPromptToText(result) {
52
+ if (!result) return '';
53
+ const parts = [];
54
+ if (result.description) parts.push(result.description);
55
+ for (const message of result.messages ?? []) {
56
+ const content =
57
+ typeof message.content === 'string'
58
+ ? message.content
59
+ : (message.content?.text ?? JSON.stringify(message.content));
60
+ parts.push(`[${message.role}] ${content}`);
61
+ }
62
+ return parts.join('\n') || '(prompt vacío)';
63
+ }
64
+
65
+ /** Text summary of a `resources/list` result. */
66
+ export function formatResources(resources = []) {
67
+ if (!resources.length) return '(sin recursos)';
68
+ return resources
69
+ .map((r) => `${r.uri} ${r.name ?? ''}${r.mimeType ? ` [${r.mimeType}]` : ''}`.trim())
70
+ .join('\n');
71
+ }
72
+
73
+ /** Text summary of a `prompts/list` result. */
74
+ export function formatPrompts(prompts = []) {
75
+ if (!prompts.length) return '(sin prompts)';
76
+ return prompts
77
+ .map((p) => {
78
+ const args = (p.arguments ?? []).map((a) => a.name).join(', ');
79
+ return `${p.name}${args ? ` (${args})` : ''} ${p.description ?? ''}`.trim();
80
+ })
81
+ .join('\n');
82
+ }
@@ -0,0 +1,149 @@
1
+ // MCP (Model Context Protocol) client over the Streamable HTTP transport.
2
+ // Works in Node and the browser. The stdio transport lives in `./stdio.js`.
3
+
4
+ import {
5
+ PROTOCOL_VERSION,
6
+ CLIENT_INFO,
7
+ mcpResultToText,
8
+ mcpResourceToText,
9
+ mcpPromptToText,
10
+ } from './common.js';
11
+
12
+ let rpcCounter = 0;
13
+ const nextId = () => ++rpcCounter;
14
+
15
+ async function rpc(url, { sessionId, method, params, id, notification, signal }) {
16
+ const body = notification
17
+ ? { jsonrpc: '2.0', method, params }
18
+ : { jsonrpc: '2.0', id: id ?? nextId(), method, params };
19
+
20
+ const headers = {
21
+ 'Content-Type': 'application/json',
22
+ Accept: 'application/json, text/event-stream',
23
+ };
24
+ if (sessionId) headers['Mcp-Session-Id'] = sessionId;
25
+
26
+ const res = await fetch(url, {
27
+ method: 'POST',
28
+ headers,
29
+ body: JSON.stringify(body),
30
+ signal,
31
+ });
32
+ const nextSessionId = res.headers.get('Mcp-Session-Id') ?? sessionId;
33
+
34
+ if (notification) return { sessionId: nextSessionId };
35
+ if (!res.ok) throw new Error(`MCP HTTP ${res.status} ${res.statusText}`);
36
+
37
+ const contentType = res.headers.get('content-type') ?? '';
38
+ const payload = contentType.includes('text/event-stream')
39
+ ? parseSsePayload(await res.text())
40
+ : await res.json();
41
+
42
+ if (payload?.error) throw new Error(payload.error.message ?? 'MCP error');
43
+ return { sessionId: nextSessionId, result: payload?.result };
44
+ }
45
+
46
+ function parseSsePayload(text) {
47
+ for (const event of text.split(/\r?\n\r?\n/)) {
48
+ for (const line of event.split(/\r?\n/)) {
49
+ if (!line.startsWith('data:')) continue;
50
+ const data = line.slice(5).trim();
51
+ if (!data) continue;
52
+ try {
53
+ const parsed = JSON.parse(data);
54
+ if (parsed.result !== undefined || parsed.error !== undefined) return parsed;
55
+ } catch {
56
+ // ignore non-JSON data lines
57
+ }
58
+ }
59
+ }
60
+ throw new Error('MCP: la respuesta SSE no contiene un resultado');
61
+ }
62
+
63
+ /**
64
+ * Open a session against an MCP HTTP server and list its tools.
65
+ * @param {{id:string, name?:string, url:string}} server
66
+ */
67
+ export async function connectMcpHttpServer(server, { signal } = {}) {
68
+ const { url } = server;
69
+
70
+ const init = await rpc(url, {
71
+ method: 'initialize',
72
+ id: 1,
73
+ signal,
74
+ params: { protocolVersion: PROTOCOL_VERSION, capabilities: {}, clientInfo: CLIENT_INFO },
75
+ });
76
+
77
+ const sessionId = init.sessionId;
78
+ await rpc(url, { sessionId, method: 'notifications/initialized', notification: true, signal });
79
+
80
+ const capabilities = init.result?.capabilities ?? {};
81
+ const listed = await rpc(url, { sessionId, method: 'tools/list', params: {}, id: 2, signal });
82
+ const tools = listed.result?.tools ?? [];
83
+
84
+ let resources = [];
85
+ if (capabilities.resources) {
86
+ try {
87
+ const res = await rpc(url, { sessionId, method: 'resources/list', params: {}, id: 3, signal });
88
+ resources = res.result?.resources ?? [];
89
+ } catch {
90
+ resources = [];
91
+ }
92
+ }
93
+ let prompts = [];
94
+ if (capabilities.prompts) {
95
+ try {
96
+ const res = await rpc(url, { sessionId, method: 'prompts/list', params: {}, id: 4, signal });
97
+ prompts = res.result?.prompts ?? [];
98
+ } catch {
99
+ prompts = [];
100
+ }
101
+ }
102
+
103
+ return {
104
+ server,
105
+ transport: 'http',
106
+ sessionId,
107
+ capabilities,
108
+ tools,
109
+ resources,
110
+ prompts,
111
+ alive: () => true,
112
+ async callTool(name, args) {
113
+ const res = await rpc(url, {
114
+ sessionId,
115
+ method: 'tools/call',
116
+ id: 5,
117
+ params: { name, arguments: args ?? {} },
118
+ signal,
119
+ });
120
+ return mcpResultToText(res.result);
121
+ },
122
+ async refreshTools() {
123
+ const res = await rpc(url, { sessionId, method: 'tools/list', params: {}, signal });
124
+ return res.result?.tools ?? tools;
125
+ },
126
+ async listResources() {
127
+ const res = await rpc(url, { sessionId, method: 'resources/list', params: {}, signal });
128
+ return res.result?.resources ?? resources;
129
+ },
130
+ async readResource(uri) {
131
+ const res = await rpc(url, { sessionId, method: 'resources/read', params: { uri }, signal });
132
+ return mcpResourceToText(res.result);
133
+ },
134
+ async listPrompts() {
135
+ const res = await rpc(url, { sessionId, method: 'prompts/list', params: {}, signal });
136
+ return res.result?.prompts ?? prompts;
137
+ },
138
+ async getPrompt(name, args) {
139
+ const res = await rpc(url, {
140
+ sessionId,
141
+ method: 'prompts/get',
142
+ params: { name, arguments: args ?? {} },
143
+ signal,
144
+ });
145
+ return mcpPromptToText(res.result);
146
+ },
147
+ close() {},
148
+ };
149
+ }