@danieltmn/tamnora 0.6.2 → 0.7.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 (32) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/SECURITY.md +6 -0
  3. package/harness/packages/cli/src/commands/repl.js +2 -0
  4. package/harness/packages/cli/src/commands/run.js +2 -0
  5. package/harness/packages/cli/src/commands/tui.js +2 -0
  6. package/harness/packages/cli/src/lib/runtime.js +4 -0
  7. package/harness/packages/cli/src/tui/app.js +4 -0
  8. package/harness/packages/core/src/agent/runAgent.js +131 -47
  9. package/harness/packages/core/src/config/index.js +3 -1
  10. package/harness/packages/core/src/config/secrets.js +36 -0
  11. package/harness/packages/core/src/eval/runTask.js +2 -11
  12. package/harness/packages/core/src/index.js +13 -1
  13. package/harness/packages/core/src/mcp/common.js +15 -11
  14. package/harness/packages/core/src/mcp/index.js +1 -1
  15. package/harness/packages/core/src/mcp/registry.js +1 -0
  16. package/harness/packages/core/src/mcp/stdio.js +41 -2
  17. package/harness/packages/core/src/redact.js +104 -0
  18. package/harness/packages/core/test/agent.test.js +93 -0
  19. package/harness/packages/core/test/mcp.test.js +67 -0
  20. package/harness/packages/core/test/redact.test.js +45 -0
  21. package/harness/packages/server/public/app.js +217 -18
  22. package/harness/packages/server/public/index.html +89 -26
  23. package/harness/packages/server/public/styles.css +61 -1
  24. package/harness/packages/server/src/lib/agentTools.js +9 -3
  25. package/harness/packages/server/src/lib/channels/telegram.js +11 -5
  26. package/harness/packages/server/src/routes/chat.js +8 -1
  27. package/harness/packages/server/src/routes/mcp.js +29 -5
  28. package/harness/packages/server/src/routes/providers.js +16 -15
  29. package/harness/packages/server/src/server.js +13 -0
  30. package/harness/packages/server/test/server.test.js +76 -1
  31. package/harness/tamnora.example.json +17 -1
  32. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -5,6 +5,29 @@ Todos los cambios relevantes de este proyecto se documentan en este archivo.
5
5
  El formato sigue [Keep a Changelog](https://keepachangelog.com/es-ES/1.1.0/)
6
6
  y el proyecto usa [Versionado Semantico](https://semver.org/lang/es/).
7
7
 
8
+ ## [0.7.0] - 2026-10-05
9
+
10
+ ### Seguridad
11
+
12
+ - **MCP sin filtracion de entorno**: los servidores stdio ya no heredan
13
+ `process.env`; reciben un baseline minimo mas el `env` declarado (`env:VAR`
14
+ soportado). `inheritEnv: true` es opt-in para servidores de confianza.
15
+ - **Redaccion de secretos**: los resultados MCP pasan por `redactSecrets` y
16
+ `/api/mcp` devuelve el `env` enmascarado.
17
+
18
+ ### Cambiado
19
+
20
+ - **Agente autonomo**: `runAgent` corre hasta `end_turn` (sin tope fijo de
21
+ rondas). Frena por **no-progreso** (llamadas repetidas) o por presupuestos
22
+ opcionales `maxTokens`/`maxDurationMs`, y cierra con un resumen
23
+ (`wrappedUp`). La consola agrega el boton **Continuar**.
24
+ - **Ajustes de la consola**: Agente, Sesiones, MCP, Skills, Telegram y
25
+ **Límites** pasan a **pestañas** con ancho acotado (ya no ocupan todo el
26
+ ancho). El formulario MCP suma `inheritEnv` y `env` con `env:VAR`.
27
+ - **Consola**: el footer (modo · proveedor · modelo · estado) ahora es
28
+ clickeable y abre el modal de ajustes; el input muestra un **menú de
29
+ comandos `/`** al escribir la barra.
30
+
8
31
  ## [0.6.2] - 2026-10-05
9
32
 
10
33
  ### Cambios
package/SECURITY.md CHANGED
@@ -21,6 +21,12 @@ asi que se lo debe tratar como codigo con acceso a tu usuario.
21
21
  - **Credenciales**: las API keys de proveedores se guardan en la casa local del
22
22
  usuario (fuera del repo) y se cifran con una clave derivada de la passphrase.
23
23
  Nunca se commitean y se devuelven enmascaradas al cliente.
24
+ - **Servidores MCP**: tratalos como codigo de terceros. Los MCP stdio **no heredan
25
+ `process.env`** por defecto: solo reciben un baseline minimo mas el `env` que
26
+ declares (`"env": { "TOKEN": "env:TOKEN" }`); `inheritEnv: true` es solo para
27
+ servidores de confianza. Un tool tipo `get-env` no puede leer tus secretos del
28
+ shell, y los resultados MCP se **redactan** (`redactSecrets`) por si devuelven un
29
+ token. No pases credenciales en los argumentos de las tools: van por `env`.
24
30
 
25
31
  ## Buenas practicas
26
32
 
@@ -146,6 +146,8 @@ export async function replCommand(argv) {
146
146
  history,
147
147
  input: text,
148
148
  maxRounds,
149
+ maxTokens: config.maxTokens,
150
+ maxDurationMs: config.maxDurationMs,
149
151
  tools: toolDefs,
150
152
  execute,
151
153
  onEvent: printer.onEvent,
@@ -106,6 +106,8 @@ export async function runCommand(argv) {
106
106
  history,
107
107
  input: prompt,
108
108
  maxRounds: num(values['max-steps'], config.maxRounds),
109
+ maxTokens: config.maxTokens,
110
+ maxDurationMs: config.maxDurationMs,
109
111
  signal: controller.signal,
110
112
  tools: toolDefs,
111
113
  execute,
@@ -85,6 +85,8 @@ export async function tuiCommand(argv) {
85
85
  onModeChange: (id) => store.setSetting('activeMode', id),
86
86
  systemPrompt: promptFor(mode),
87
87
  maxRounds: num(values['max-steps'], config.maxRounds),
88
+ maxTokens: config.maxTokens,
89
+ maxDurationMs: config.maxDurationMs,
88
90
  toolOptions: {
89
91
  root: cwd,
90
92
  enabled: enabledFor(mode),
@@ -52,6 +52,8 @@ export async function runTurn({
52
52
  history = [],
53
53
  input,
54
54
  maxRounds,
55
+ maxTokens,
56
+ maxDurationMs,
55
57
  stallWarnMs,
56
58
  signal,
57
59
  execute,
@@ -76,6 +78,8 @@ export async function runTurn({
76
78
  execute,
77
79
  toolContext,
78
80
  maxRounds,
81
+ maxTokens,
82
+ maxDurationMs,
79
83
  stallWarnMs,
80
84
  signal,
81
85
  onEvent,
@@ -18,6 +18,8 @@ export function runTui({
18
18
  onModeChange,
19
19
  systemPrompt,
20
20
  maxRounds,
21
+ maxTokens,
22
+ maxDurationMs,
21
23
  toolOptions,
22
24
  store,
23
25
  sessionId,
@@ -170,6 +172,8 @@ export function runTui({
170
172
  history: conv,
171
173
  input: text,
172
174
  maxRounds,
175
+ maxTokens,
176
+ maxDurationMs,
173
177
  signal: controller.signal,
174
178
  tools: toolset.definitions,
175
179
  execute: toolset.execute,
@@ -1,12 +1,24 @@
1
1
  import { withRetry, isRetryableError } from '../providers/resilience.js';
2
2
  import { trimContext, estimateTokens } from './context.js';
3
3
 
4
- export const MAX_TOOL_ROUNDS = 8;
4
+ // 0 = sin limite de rondas: el loop sigue hasta que el modelo deja de pedir
5
+ // herramientas. Los frenos reales son el guard de no-progreso y, opcionalmente,
6
+ // los presupuestos `maxTokens` / `maxDurationMs`.
7
+ export const MAX_TOOL_ROUNDS = 0;
8
+
9
+ /** Rondas seguidas sin ninguna llamada nueva antes de cortar (guard de loop). */
10
+ const NO_PROGRESS_LIMIT = 2;
11
+
12
+ export const WRAP_UP_PROMPT =
13
+ 'Se agotó el presupuesto de esta ejecución. No pidas más herramientas: resumí brevemente lo que hiciste, lo que quedó pendiente y respondé al usuario con lo que tengas.';
14
+
15
+ const NO_PROGRESS_MESSAGE =
16
+ 'No repitas la misma herramienta con los mismos argumentos: no produce cambios. Usá el resultado anterior o probá otra estrategia.';
5
17
 
6
18
  /**
7
19
  * Provider-agnostic agent loop. Runs the model, executes any tool calls it
8
20
  * requests, feeds the results back, and repeats until the model answers
9
- * without tools (or `maxRounds` is reached).
21
+ * without tools (or a limit/budget stops it).
10
22
  *
11
23
  * Free of React and browser APIs so it can run headless in Node.
12
24
  *
@@ -20,13 +32,17 @@ export const MAX_TOOL_ROUNDS = 8;
20
32
  * @param {object} [opts.toolContext] - passed through to `execute`
21
33
  * @param {AbortSignal} [opts.signal]
22
34
  * @param {Function} [opts.onEvent] - streaming/lifecycle callback
23
- * @param {number} [opts.maxRounds]
35
+ * @param {number} [opts.maxRounds] - techo de seguridad de rondas (0 = sin limite)
36
+ * @param {number} [opts.maxTokens] - presupuesto de tokens totales (0 = sin limite)
37
+ * @param {number} [opts.maxDurationMs] - presupuesto de tiempo (0 = sin limite)
38
+ * @param {number} [opts.repeatLimit] - veces que una misma llamada puede repetirse (0 = desactivado)
39
+ * @param {boolean} [opts.wrapUp] - ronda final sin tools para resumir si se cortó
24
40
  * @param {number} [opts.stallWarnMs] - warn once if the model emits nothing for
25
41
  * this long (0 disables; default 90000)
26
42
  * @param {number} [opts.retries] - reintentos por ronda ante error transitorio
27
43
  * (default 2; solo si la ronda no emitio contenido todavia)
28
44
  * @param {number} [opts.retryDelayMs] - base del backoff (default 500)
29
- * @returns {Promise<{content, reasoning, toolCalls, messages, rounds, stopReason, usage}>}
45
+ * @returns {Promise<{content, reasoning, toolCalls, messages, rounds, stopReason, usage, wrappedUp}>}
30
46
  */
31
47
  export async function runAgent({
32
48
  provider,
@@ -39,6 +55,10 @@ export async function runAgent({
39
55
  signal,
40
56
  onEvent,
41
57
  maxRounds = MAX_TOOL_ROUNDS,
58
+ maxTokens = 0,
59
+ maxDurationMs = 0,
60
+ repeatLimit = 3,
61
+ wrapUp = true,
42
62
  stallWarnMs = 90000,
43
63
  retries = 2,
44
64
  retryDelayMs = 500,
@@ -46,18 +66,38 @@ export async function runAgent({
46
66
  }) {
47
67
  const working = [...messages];
48
68
  let usageTotal = null;
69
+ const startedAt = Date.now();
70
+ const fingerprints = new Map();
71
+ const unlimited = !(maxRounds > 0);
72
+ let noProgressStreak = 0;
73
+ let roundsRun = 0;
74
+ let stopReason = 'max_rounds';
75
+ let content = '';
76
+
77
+ const accumulateUsage = (roundUsage) => {
78
+ if (!roundUsage) return {};
79
+ usageTotal = {
80
+ prompt_tokens: (usageTotal?.prompt_tokens ?? 0) + roundUsage.prompt_tokens,
81
+ completion_tokens: (usageTotal?.completion_tokens ?? 0) + roundUsage.completion_tokens,
82
+ total_tokens: (usageTotal?.total_tokens ?? 0) + roundUsage.total_tokens,
83
+ // last round = current context footprint (for context meters)
84
+ last_prompt_tokens: roundUsage.prompt_tokens,
85
+ last_completion_tokens: roundUsage.completion_tokens,
86
+ };
87
+ return { tokens_in: roundUsage.prompt_tokens, tokens_out: roundUsage.completion_tokens };
88
+ };
89
+
90
+ const budgetExceeded = () => {
91
+ if (maxDurationMs > 0 && Date.now() - startedAt >= maxDurationMs) return 'duration';
92
+ if (maxTokens > 0 && (usageTotal?.total_tokens ?? 0) >= maxTokens) return 'tokens';
93
+ return null;
94
+ };
49
95
 
50
- for (let round = 1; round <= maxRounds; round++) {
96
+ while (unlimited || roundsRun < maxRounds) {
97
+ roundsRun++;
51
98
  if (signal?.aborted) {
52
- return {
53
- content: '',
54
- reasoning: '',
55
- toolCalls: [],
56
- messages: working,
57
- rounds: Math.max(0, round - 1),
58
- stopReason: 'aborted',
59
- usage: usageTotal,
60
- };
99
+ stopReason = 'aborted';
100
+ break;
61
101
  }
62
102
 
63
103
  const stall = createStallGuard(onEvent, stallWarnMs);
@@ -109,38 +149,20 @@ export async function runAgent({
109
149
  stall.dispose();
110
150
  }
111
151
 
112
- const roundUsage = result.usage ?? null;
113
- if (roundUsage) {
114
- usageTotal = {
115
- prompt_tokens: (usageTotal?.prompt_tokens ?? 0) + roundUsage.prompt_tokens,
116
- completion_tokens: (usageTotal?.completion_tokens ?? 0) + roundUsage.completion_tokens,
117
- total_tokens: (usageTotal?.total_tokens ?? 0) + roundUsage.total_tokens,
118
- // last round = current context footprint (for context meters)
119
- last_prompt_tokens: roundUsage.prompt_tokens,
120
- last_completion_tokens: roundUsage.completion_tokens,
121
- };
122
- }
123
- const tokens = roundUsage
124
- ? { tokens_in: roundUsage.prompt_tokens, tokens_out: roundUsage.completion_tokens }
125
- : {};
152
+ const tokens = accumulateUsage(result.usage ?? null);
126
153
 
127
154
  const calls = result.toolCalls ?? [];
128
155
  if (calls.length === 0) {
129
- working.push({ role: 'assistant', content: result.content ?? '', ...tokens });
130
- return {
131
- content: result.content,
132
- reasoning: result.reasoning ?? '',
133
- toolCalls: [],
134
- messages: working,
135
- rounds: round,
136
- stopReason: 'end_turn',
137
- usage: usageTotal,
138
- };
156
+ content = result.content ?? '';
157
+ working.push({ role: 'assistant', content, ...tokens });
158
+ stopReason = 'end_turn';
159
+ break;
139
160
  }
140
161
 
141
162
  working.push({ role: 'assistant', content: result.content || null, tool_calls: calls, ...tokens });
142
- onEvent?.({ type: 'tool_round', round, toolCalls: calls, content: result.content });
163
+ onEvent?.({ type: 'tool_round', round: roundsRun, toolCalls: calls, content: result.content });
143
164
 
165
+ let newCalls = 0;
144
166
  for (let index = 0; index < calls.length; index++) {
145
167
  const call = calls[index];
146
168
  const name = call.function?.name ?? call.name;
@@ -162,20 +184,32 @@ export async function runAgent({
162
184
  reasoning: result.reasoning ?? '',
163
185
  toolCalls: calls,
164
186
  messages: working,
165
- rounds: round,
187
+ rounds: roundsRun,
166
188
  stopReason: 'aborted',
167
189
  usage: usageTotal,
190
+ wrappedUp: false,
168
191
  };
169
192
  }
170
193
 
171
194
  const args = parseToolArguments(call);
172
195
  onEvent?.({ type: 'tool_call', id: call.id, name, args });
173
196
 
197
+ const fingerprint = `${name}\u0000${stableStringify(args)}`;
198
+ const seen = (fingerprints.get(fingerprint) ?? 0) + 1;
199
+ fingerprints.set(fingerprint, seen);
200
+
174
201
  let output;
175
- try {
176
- output = await execute(name, args, toolContext);
177
- } catch (err) {
178
- output = `Error ejecutando ${name}: ${err?.message ?? err}`;
202
+ if (repeatLimit > 0 && seen > repeatLimit) {
203
+ // Llamada identica repetida: no re-ejecutar, avisar al modelo.
204
+ onEvent?.({ type: 'repeat', name, count: seen });
205
+ output = NO_PROGRESS_MESSAGE;
206
+ } else {
207
+ newCalls++;
208
+ try {
209
+ output = await execute(name, args, toolContext);
210
+ } catch (err) {
211
+ output = `Error ejecutando ${name}: ${err?.message ?? err}`;
212
+ }
179
213
  }
180
214
 
181
215
  onEvent?.({ type: 'tool_result', id: call.id, name, result: output });
@@ -186,19 +220,69 @@ export async function runAgent({
186
220
  content: typeof output === 'string' ? output : JSON.stringify(output),
187
221
  });
188
222
  }
223
+
224
+ noProgressStreak = newCalls === 0 ? noProgressStreak + 1 : 0;
225
+ if (noProgressStreak >= NO_PROGRESS_LIMIT) {
226
+ stopReason = 'no_progress';
227
+ break;
228
+ }
229
+ if (budgetExceeded()) {
230
+ stopReason = 'budget';
231
+ break;
232
+ }
233
+ }
234
+
235
+ let wrappedUp = false;
236
+ if (wrapUp && (stopReason === 'max_rounds' || stopReason === 'no_progress' || stopReason === 'budget')) {
237
+ const wrap = await runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries: retries, retryDelayMs });
238
+ if (wrap.usage) accumulateUsage(wrap.usage);
239
+ if (wrap.content) {
240
+ working.push({ role: 'assistant', content: wrap.content, tokens_in: wrap.usage?.prompt_tokens, tokens_out: wrap.usage?.completion_tokens });
241
+ content = wrap.content;
242
+ wrappedUp = true;
243
+ }
189
244
  }
190
245
 
191
246
  return {
192
- content: '',
247
+ content,
193
248
  reasoning: '',
194
249
  toolCalls: [],
195
250
  messages: working,
196
- rounds: maxRounds,
197
- stopReason: 'max_rounds',
251
+ rounds: roundsRun,
252
+ stopReason,
198
253
  usage: usageTotal,
254
+ wrappedUp,
199
255
  };
200
256
  }
201
257
 
258
+ /** Ronda final con tools deshabilitadas para forzar un cierre con texto. */
259
+ async function runFinalAnswer({ provider, config, model, working, signal, onEvent, maxRetries, retryDelayMs }) {
260
+ try {
261
+ const result = await withRetry(
262
+ () =>
263
+ provider.streamChat(config, {
264
+ model,
265
+ messages: [...working, { role: 'system', content: WRAP_UP_PROMPT }],
266
+ tools: [],
267
+ signal,
268
+ onEvent,
269
+ }),
270
+ { retries: maxRetries, baseDelayMs: retryDelayMs, signal }
271
+ );
272
+ return { content: result?.content ?? '', usage: result?.usage ?? null };
273
+ } catch {
274
+ return { content: '', usage: null };
275
+ }
276
+ }
277
+
278
+ /** JSON estable (claves ordenadas) para huellar argumentos. */
279
+ function stableStringify(value) {
280
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
281
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
282
+ const keys = Object.keys(value).sort();
283
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
284
+ }
285
+
202
286
  /**
203
287
  * Emit a single warning event when the model emits nothing for `stallWarnMs`
204
288
  * during one stream (openbridge's stall watchdog port). Any provider event
@@ -7,7 +7,9 @@ export const DEFAULT_CONFIG = {
7
7
  provider: 'openrouter',
8
8
  model: '',
9
9
  instructions: ['AGENTS.md'],
10
- maxRounds: 8,
10
+ maxRounds: 0,
11
+ maxTokens: 0,
12
+ maxDurationMs: 0,
11
13
  tools: [],
12
14
  permissions: {},
13
15
  mcp: [],
@@ -0,0 +1,36 @@
1
+ // Secret indirection helpers. Values may reference environment variables as
2
+ // `env:VAR` so credentials never live in `tamnora.json` or in tool arguments.
3
+
4
+ import { getEnv } from '../env.js';
5
+
6
+ /**
7
+ * Resolve a single value that may be an `env:VAR` reference.
8
+ * @param {string} value
9
+ * @param {Record<string,string>} [env]
10
+ */
11
+ export function resolveApiKey(value, env = getEnv()) {
12
+ if (typeof value === 'string' && value.startsWith('env:')) {
13
+ return env[value.slice(4)] ?? '';
14
+ }
15
+ return value ?? '';
16
+ }
17
+
18
+ /**
19
+ * Resolve the values of an env map, expanding `env:VAR` references.
20
+ * String values only; anything else is stringified.
21
+ * @param {Record<string, unknown>} map
22
+ * @param {Record<string,string>} [env]
23
+ */
24
+ export function resolveEnvMap(map = {}, env = getEnv()) {
25
+ const result = {};
26
+ for (const [key, value] of Object.entries(map ?? {})) {
27
+ if (value == null) continue;
28
+ result[key] = typeof value === 'string' ? resolveApiKey(value, env) : String(value);
29
+ }
30
+ return result;
31
+ }
32
+
33
+ /** True when at least one value in the map is an unresolved `env:VAR` reference. */
34
+ export function hasEnvRefs(map = {}) {
35
+ return Object.values(map ?? {}).some((value) => typeof value === 'string' && value.startsWith('env:'));
36
+ }
@@ -1,18 +1,9 @@
1
1
  import { resolveProviderRuntime } from '../providers/index.js';
2
2
  import { runAgent } from '../agent/runAgent.js';
3
3
  import { buildMcpRegistry } from '../mcp/index.js';
4
+ import { resolveApiKey } from '../config/secrets.js';
4
5
 
5
- /**
6
- * Resolve `env:VAR` references in a task's apiKey field.
7
- * @param {string} value
8
- * @param {Record<string,string>} env
9
- */
10
- export function resolveApiKey(value, env = {}) {
11
- if (typeof value === 'string' && value.startsWith('env:')) {
12
- return env[value.slice(4)] ?? '';
13
- }
14
- return value ?? '';
15
- }
6
+ export { resolveApiKey } from '../config/secrets.js';
16
7
 
17
8
  /**
18
9
  * Run a single evaluation task headlessly.
@@ -1,4 +1,14 @@
1
1
  export { getEnv, envValue } from './env.js';
2
+ export {
3
+ redactSecrets,
4
+ redactDeep,
5
+ secretValues,
6
+ isSecretName,
7
+ maskSecret,
8
+ maskEnv,
9
+ REDACTED,
10
+ } from './redact.js';
11
+ export { resolveApiKey, resolveEnvMap } from './config/secrets.js';
2
12
  export { ensureUtf8Console } from './console.js';
3
13
  export { MAX_TOOL_ROUNDS, runAgent, parseToolArguments } from './agent/runAgent.js';
4
14
  export { estimateTokens, trimContext } from './agent/context.js';
@@ -20,10 +30,12 @@ export {
20
30
  connectMcpServer,
21
31
  connectMcpHttpServer,
22
32
  connectMcpStdioServer,
33
+ childEnv,
23
34
  buildMcpRegistry,
24
35
  mcpTransport,
25
36
  qualifiedToolName,
26
37
  mcpToolToDefinition,
38
+ mcpResultToText,
27
39
  } from './mcp/index.js';
28
40
  export {
29
41
  loadConfig,
@@ -65,7 +77,7 @@ export {
65
77
  toRel,
66
78
  } from './tools/index.js';
67
79
  export { Store, openDatabase } from './storage/sqlite.js';
68
- export { runTask, resolveApiKey } from './eval/runTask.js';
80
+ export { runTask } from './eval/runTask.js';
69
81
  export { loadDataset } from './eval/dataset.js';
70
82
  export { runDataset, extractToolCalls } from './eval/runner.js';
71
83
  export { summarize } from './eval/report.js';
@@ -1,5 +1,7 @@
1
1
  // Shared MCP helpers used by both transports (HTTP and stdio).
2
2
 
3
+ import { redactSecrets } from '../redact.js';
4
+
3
5
  export const PROTOCOL_VERSION = '2025-06-18';
4
6
  export const CLIENT_INFO = { name: 'tamnora', version: '1.0.0' };
5
7
 
@@ -24,27 +26,29 @@ export function mcpToolToDefinition(tool, server, qualifiedName) {
24
26
  /** Flatten an MCP `tools/call` result into plain text for the model. */
25
27
  export function mcpResultToText(result) {
26
28
  if (!result) return '';
27
- if (typeof result.content === 'string') return result.content;
29
+ if (typeof result.content === 'string') return redactSecrets(result.content);
28
30
  if (Array.isArray(result.content)) {
29
31
  const text = result.content
30
32
  .map((c) => (c.type === 'text' ? c.text : `[${c.type}]`))
31
33
  .join('\n');
32
- return result.isError ? `Error: ${text}` : text;
34
+ return redactSecrets(result.isError ? `Error: ${text}` : text);
33
35
  }
34
- return JSON.stringify(result);
36
+ return redactSecrets(JSON.stringify(result));
35
37
  }
36
38
 
37
39
  /** Flatten a `resources/read` result into plain text. */
38
40
  export function mcpResourceToText(result) {
39
41
  const contents = result?.contents ?? [];
40
42
  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');
43
+ return redactSecrets(
44
+ contents
45
+ .map((c) => {
46
+ if (typeof c.text === 'string') return c.text;
47
+ if (c.blob) return `[binario ${c.mimeType ?? ''} ${c.blob.length}b64]`;
48
+ return JSON.stringify(c);
49
+ })
50
+ .join('\n')
51
+ );
48
52
  }
49
53
 
50
54
  /** Flatten a `prompts/get` result into plain text. */
@@ -59,7 +63,7 @@ export function mcpPromptToText(result) {
59
63
  : (message.content?.text ?? JSON.stringify(message.content));
60
64
  parts.push(`[${message.role}] ${content}`);
61
65
  }
62
- return parts.join('\n') || '(prompt vacío)';
66
+ return redactSecrets(parts.join('\n')) || '(prompt vacío)';
63
67
  }
64
68
 
65
69
  /** Text summary of a `resources/list` result. */
@@ -11,7 +11,7 @@ import {
11
11
  } from './common.js';
12
12
 
13
13
  export { connectMcpHttpServer } from './http.js';
14
- export { connectMcpStdioServer } from './stdio.js';
14
+ export { connectMcpStdioServer, childEnv } from './stdio.js';
15
15
  export {
16
16
  PROTOCOL_VERSION,
17
17
  CLIENT_INFO,
@@ -112,6 +112,7 @@ export function normalizeMcpConfig(input, fallbackId = 'mcp') {
112
112
  args: url ? [] : args,
113
113
  url: url ? String(url) : undefined,
114
114
  env: def.env && typeof def.env === 'object' ? def.env : undefined,
115
+ inheritEnv: def.inheritEnv === true,
115
116
  enabled: def.enabled !== false,
116
117
  };
117
118
  }
@@ -11,6 +11,7 @@ import {
11
11
  mcpResourceToText,
12
12
  mcpPromptToText,
13
13
  } from './common.js';
14
+ import { resolveEnvMap } from '../config/secrets.js';
14
15
 
15
16
  /**
16
17
  * On Windows, bare commands like `npx`/`uvx` are `.cmd` shims that require a
@@ -25,10 +26,48 @@ function shouldUseShell(server, command) {
25
26
  return true;
26
27
  }
27
28
 
29
+ // Variables minimas para que un proceso hijo comun (node, npx, uvx) arranque.
30
+ const BASE_ENV_KEYS = [
31
+ 'PATH',
32
+ 'PATHEXT',
33
+ 'SystemRoot',
34
+ 'windir',
35
+ 'ComSpec',
36
+ 'TEMP',
37
+ 'TMP',
38
+ 'TMPDIR',
39
+ 'USERPROFILE',
40
+ 'HOME',
41
+ 'APPDATA',
42
+ 'LOCALAPPDATA',
43
+ 'LANG',
44
+ 'LC_ALL',
45
+ 'TZ',
46
+ 'NUMBER_OF_PROCESSORS',
47
+ 'PROCESSOR_ARCHITECTURE',
48
+ ];
49
+
50
+ /**
51
+ * Construye el entorno del hijo MCP. Por defecto **no** hereda `process.env`
52
+ * (asi un MCP de terceros no puede leer tokens del shell); solo pasa un
53
+ * baseline seguro mas el `env` declarado. `inheritEnv: true` restaura la
54
+ * herencia completa para servidores de confianza.
55
+ */
56
+ export function childEnv(server = {}) {
57
+ const declared = resolveEnvMap(server.env ?? {});
58
+ if (server.inheritEnv === true) return { ...process.env, ...declared };
59
+
60
+ const base = {};
61
+ for (const key of BASE_ENV_KEYS) {
62
+ if (process.env[key] !== undefined) base[key] = process.env[key];
63
+ }
64
+ return { ...base, ...declared };
65
+ }
66
+
28
67
  /**
29
68
  * Launch an MCP stdio server, initialize it and list its tools.
30
69
  *
31
- * @param {{id:string, name?:string, command:string, args?:string[], env?:object, cwd?:string, shell?:boolean}} server
70
+ * @param {{id:string, name?:string, command:string, args?:string[], env?:object, inheritEnv?:boolean, cwd?:string, shell?:boolean}} server
32
71
  * @param {{signal?:AbortSignal, timeout?:number}} [opts]
33
72
  */
34
73
  export async function connectMcpStdioServer(server, { signal, timeout = 30000 } = {}) {
@@ -37,7 +76,7 @@ export async function connectMcpStdioServer(server, { signal, timeout = 30000 }
37
76
 
38
77
  const child = spawn(command, server.args ?? [], {
39
78
  cwd: server.cwd,
40
- env: { ...process.env, ...(server.env ?? {}) },
79
+ env: childEnv(server),
41
80
  stdio: ['pipe', 'pipe', 'pipe'],
42
81
  windowsHide: true,
43
82
  shell: shouldUseShell(server, command),