panal-mcp 0.1.1 → 0.2.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.
@@ -12,6 +12,21 @@
12
12
  */
13
13
  /** El mensaje que firma el cliente. Debe coincidir con el bot y el frontend. */
14
14
  export declare function resultSignMessage(taskId: bigint): string;
15
+ /** El mensaje que firma el cliente para MANDAR el encargo. Mismo pacto. */
16
+ export declare function briefSignMessage(taskId: bigint): string;
17
+ /**
18
+ * Le entrega el encargo al agente: POST /brief/<taskId>, firmado.
19
+ *
20
+ * Existe porque contratar sin esto deja la tarea a medias. On-chain solo queda
21
+ * el hash del encargo; el texto tiene que llegarle al agente por su endpoint o
22
+ * el agente se queda mirando una tarea pagada sin saber qué se le pide. Antes
23
+ * el MCP contrataba y le decía al usuario "ahora hazle llegar el texto tú",
24
+ * que en una conversación con un modelo no lo hace nadie.
25
+ *
26
+ * Devuelve null si fue bien, o el motivo si no. No lanza: cuando esto falla el
27
+ * pago YA está bloqueado, así que lo útil es contarlo, no romper.
28
+ */
29
+ export declare function pushBrief(botUrl: string, taskId: bigint, brief: string, client: string, signature: string): Promise<string | null>;
15
30
  /**
16
31
  * Rechaza una URL que apunte a algo que no sea internet pública.
17
32
  *
@@ -19,6 +19,59 @@ const TIMEOUT_MS = 20_000;
19
19
  export function resultSignMessage(taskId) {
20
20
  return `Panal resultado #${taskId.toString()}`;
21
21
  }
22
+ /** El mensaje que firma el cliente para MANDAR el encargo. Mismo pacto. */
23
+ export function briefSignMessage(taskId) {
24
+ return `Panal brief #${taskId.toString()}`;
25
+ }
26
+ /**
27
+ * Le entrega el encargo al agente: POST /brief/<taskId>, firmado.
28
+ *
29
+ * Existe porque contratar sin esto deja la tarea a medias. On-chain solo queda
30
+ * el hash del encargo; el texto tiene que llegarle al agente por su endpoint o
31
+ * el agente se queda mirando una tarea pagada sin saber qué se le pide. Antes
32
+ * el MCP contrataba y le decía al usuario "ahora hazle llegar el texto tú",
33
+ * que en una conversación con un modelo no lo hace nadie.
34
+ *
35
+ * Devuelve null si fue bien, o el motivo si no. No lanza: cuando esto falla el
36
+ * pago YA está bloqueado, así que lo útil es contarlo, no romper.
37
+ */
38
+ export async function pushBrief(botUrl, taskId, brief, client, signature) {
39
+ let url;
40
+ try {
41
+ const base = await assertPublicUrl(botUrl);
42
+ url = new URL(`/brief/${taskId}`, base);
43
+ }
44
+ catch (err) {
45
+ return err instanceof Error ? err.message : String(err);
46
+ }
47
+ const controller = new AbortController();
48
+ const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
49
+ try {
50
+ const res = await fetch(url, {
51
+ method: 'POST',
52
+ headers: { 'content-type': 'application/json' },
53
+ body: JSON.stringify({ brief, address: client, signature }),
54
+ signal: controller.signal,
55
+ redirect: 'error',
56
+ });
57
+ if (res.ok)
58
+ return null;
59
+ // El cuerpo del error dice cuál de las comprobaciones del agente falló
60
+ // —firma, estado, o que el texto no cuadra con el hash—, y eso es
61
+ // exactamente lo que hay que enseñar para poder arreglarlo.
62
+ const detalle = (await res.text().catch(() => '')).slice(0, 300);
63
+ return `the agent replied ${res.status}${detalle ? `: ${detalle}` : ''}`;
64
+ }
65
+ catch (err) {
66
+ if (err instanceof Error && err.name === 'AbortError') {
67
+ return `the endpoint did not answer within ${TIMEOUT_MS / 1000} s`;
68
+ }
69
+ return err instanceof Error ? err.message : String(err);
70
+ }
71
+ finally {
72
+ clearTimeout(timer);
73
+ }
74
+ }
22
75
  /** ¿Esta IP apunta dentro de nuestra propia red? */
23
76
  function isPrivateIp(ip) {
24
77
  if (isIP(ip) === 6) {
@@ -59,24 +112,24 @@ export async function assertPublicUrl(raw) {
59
112
  url = new URL(raw);
60
113
  }
61
114
  catch {
62
- throw new Error(`El endpoint del agente no es una URL válida: ${raw}`);
115
+ throw new Error(`The agent endpoint is not a valid URL: ${raw}`);
63
116
  }
64
117
  if (url.protocol !== 'https:') {
65
118
  // La petición lleva una firma del cliente en la query: en claro la lee cualquiera.
66
- throw new Error(`El endpoint del agente tiene que ser https, y es ${url.protocol}//`);
119
+ throw new Error(`The agent endpoint must be https, and it is ${url.protocol}//`);
67
120
  }
68
121
  if (url.username || url.password)
69
- throw new Error('El endpoint lleva credenciales embebidas: se rechaza.');
122
+ throw new Error('The endpoint carries embedded credentials: rejected.');
70
123
  const host = url.hostname.replace(/^\[|\]$/g, '');
71
124
  if (/^(localhost|.*\.local|.*\.internal)$/i.test(host)) {
72
- throw new Error(`El endpoint apunta a un nombre local (${host}): se rechaza.`);
125
+ throw new Error(`The endpoint points at a local name (${host}): rejected.`);
73
126
  }
74
127
  const ips = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address);
75
128
  if (!ips.length)
76
- throw new Error(`No se pudo resolver ${host}.`);
129
+ throw new Error(`Could not resolve ${host}.`);
77
130
  for (const ip of ips) {
78
131
  if (isPrivateIp(ip))
79
- throw new Error(`El endpoint apunta a una dirección interna (${ip}): se rechaza.`);
132
+ throw new Error(`The endpoint points at an internal address (${ip}): rejected.`);
80
133
  }
81
134
  return url;
82
135
  }
@@ -91,11 +144,11 @@ export async function fetchResultText(botUrl, taskId, client, signature) {
91
144
  try {
92
145
  const res = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
93
146
  if (!res.ok) {
94
- throw new Error(`El endpoint del agente respondió ${res.status}. ¿Sigue en pie y la firma es la del cliente?`);
147
+ throw new Error(`The agent endpoint replied ${res.status}. Is it still up, and is the signature the client's?`);
95
148
  }
96
149
  const reader = res.body?.getReader();
97
150
  if (!reader)
98
- throw new Error('El endpoint del agente respondió sin cuerpo.');
151
+ throw new Error('The agent endpoint replied with no body.');
99
152
  const chunks = [];
100
153
  let total = 0;
101
154
  for (;;) {
@@ -105,14 +158,14 @@ export async function fetchResultText(botUrl, taskId, client, signature) {
105
158
  total += value.byteLength;
106
159
  if (total > MAX_BYTES) {
107
160
  await reader.cancel();
108
- throw new Error(`El resultado pasa de ${MAX_BYTES / 1024} KB: se corta por seguridad.`);
161
+ throw new Error(`The result exceeds ${MAX_BYTES / 1024} KB: cut off for safety.`);
109
162
  }
110
163
  chunks.push(value);
111
164
  }
112
165
  const body = Buffer.concat(chunks).toString('utf8');
113
166
  const parsed = JSON.parse(body);
114
167
  if (typeof parsed.resultText !== 'string')
115
- throw new Error('La respuesta del agente no trae `resultText`.');
168
+ throw new Error('The agent response has no `resultText`.');
116
169
  return parsed.resultText;
117
170
  }
118
171
  finally {
package/dist/limits.js CHANGED
@@ -30,8 +30,8 @@ export class QuoteBook {
30
30
  const quote = this.quotes.get(id);
31
31
  if (!quote) {
32
32
  return {
33
- error: 'Ese presupuesto no existe o ya caducó (duran 5 minutos). ' +
34
- 'Pide uno nuevo con panal_quote_hire y enséñaselo a la persona antes de contratar.',
33
+ error: 'That quote does not exist or has expired (they last 5 minutes). ' +
34
+ 'Ask for a new one with panal_quote_hire and show it to the person before hiring.',
35
35
  };
36
36
  }
37
37
  // De un solo uso: sin esto, un reintento del modelo contrataría dos veces.
package/dist/server.js CHANGED
@@ -22,14 +22,30 @@
22
22
  * RESERVADO para los mensajes del protocolo —un `console.log` suelto ahí
23
23
  * corrompe la sesión entera—, así que todo el registro va por stderr.
24
24
  */
25
+ import { readFileSync } from 'node:fs';
26
+ import { dirname, resolve } from 'node:path';
25
27
  import { createInterface } from 'node:readline';
28
+ import { fileURLToPath } from 'node:url';
26
29
  import { formatEther, isAddress, keccak256, parseEther, toBytes } from 'viem';
27
30
  import { privateKeyToAccount } from 'viem/accounts';
28
31
  import { NATIVE_CURRENCY, TaskStatus, createPanalClient, } from '@panal/sdk';
29
32
  import { QuoteBook, SpendLedger, limitsFromEnv } from './limits.js';
30
- import { fetchResultText, resultSignMessage } from './fetch-result.js';
33
+ import { briefSignMessage, fetchResultText, pushBrief, resultSignMessage } from './fetch-result.js';
31
34
  const SERVER_NAME = 'panal';
32
- const SERVER_VERSION = '0.1.0';
35
+ /**
36
+ * Sale del package.json, no de una constante a mano: la copiada se quedó en
37
+ * 0.1.0 mientras el paquete iba por 0.1.3, y un servidor que se anuncia con
38
+ * una versión falsa hace imposible saber qué está corriendo el usuario.
39
+ */
40
+ const SERVER_VERSION = (() => {
41
+ try {
42
+ const aqui = dirname(fileURLToPath(import.meta.url));
43
+ return JSON.parse(readFileSync(resolve(aqui, '..', 'package.json'), 'utf8')).version;
44
+ }
45
+ catch {
46
+ return '0.0.0';
47
+ }
48
+ })();
33
49
  /** Versiones del protocolo que sabemos hablar; la primera es la preferida. */
34
50
  const SUPPORTED_PROTOCOLS = ['2025-06-18', '2025-03-26', '2024-11-05'];
35
51
  const EXPLORER = 'https://monadvision.com';
@@ -61,12 +77,11 @@ const panal = createPanalClient({
61
77
  /** Motivo por el que no se puede escribir, o null si sí se puede. */
62
78
  function writesBlockedReason() {
63
79
  if (!writesRequested) {
64
- return ('Este servidor está en SOLO LECTURA. Para contratar hay que arrancarlo con ' +
65
- 'MCP_ENABLE_WRITES=true y una MCP_PRIVATE_KEY con fondos. Dile a la persona que lo configure ella: ' +
66
- 'no intentes rodearlo.');
80
+ return ('This server is READ-ONLY. To hire, it must be started with MCP_ENABLE_WRITES=true and an ' +
81
+ 'MCP_PRIVATE_KEY with funds. Tell the person to configure it themselves: do not try to work around it.');
67
82
  }
68
83
  if (!account) {
69
- return 'MCP_ENABLE_WRITES está activo pero no hay una MCP_PRIVATE_KEY válida, así que no hay wallet con la que pagar.';
84
+ return 'MCP_ENABLE_WRITES is on but there is no valid MCP_PRIVATE_KEY, so there is no wallet to pay with.';
70
85
  }
71
86
  return null;
72
87
  }
@@ -78,15 +93,15 @@ function symbolOf(currency) {
78
93
  }
79
94
  function renderAgent(agent) {
80
95
  const price = `${formatEther(agent.pricePerTask)} ${symbolOf(agent.currency)}`;
81
- const skills = agent.metadata.skills.length ? agent.metadata.skills.join(', ') : '(sin skills declaradas)';
96
+ const skills = agent.metadata.skills.length ? agent.metadata.skills.join(', ') : '(no skills declared)';
82
97
  return [
83
- `${agent.metadata.name || '(sin nombre)'} — ${agent.address}`,
84
- ` Estado: ${agent.active ? 'activo' : 'DADO DE BAJA (no acepta encargos)'}`,
85
- ` Precio: ${price} por tarea`,
98
+ `${agent.metadata.name || '(no name)'} — ${agent.address}`,
99
+ ` Status: ${agent.active ? 'active' : 'DELISTED (does not take jobs)'}`,
100
+ ` Price: ${price} per task`,
86
101
  ` Skills: ${skills}`,
87
- agent.metadata.description ? ` Descripción: ${agent.metadata.description}` : null,
102
+ agent.metadata.description ? ` Description: ${agent.metadata.description}` : null,
88
103
  agent.metadata.botUrl ? ` Endpoint: ${agent.metadata.botUrl}` : null,
89
- ` Ficha: ${EXPLORER}/address/${agent.address}`,
104
+ ` Profile: ${EXPLORER}/address/${agent.address}`,
90
105
  ]
91
106
  .filter(Boolean)
92
107
  .join('\n');
@@ -94,13 +109,13 @@ function renderAgent(agent) {
94
109
  function renderTask(task) {
95
110
  const deadline = new Date(Number(task.deadline) * 1000).toISOString();
96
111
  return [
97
- `Tarea #${task.id} — ${TaskStatus[task.status] ?? task.status}`,
98
- ` Cliente: ${task.client}`,
99
- ` Agente: ${task.worker}`,
100
- ` Importe: ${formatEther(task.amount)} ${symbolOf(task.currency)}`,
101
- ` Plazo: ${deadline}`,
102
- ` Hash del encargo: ${task.taskHash}`,
103
- task.resultHash && !/^0x0+$/.test(task.resultHash) ? ` Hash del resultado: ${task.resultHash}` : null,
112
+ `Task #${task.id} — ${TaskStatus[task.status] ?? task.status}`,
113
+ ` Client: ${task.client}`,
114
+ ` Agent: ${task.worker}`,
115
+ ` Amount: ${formatEther(task.amount)} ${symbolOf(task.currency)}`,
116
+ ` Deadline: ${deadline}`,
117
+ ` Job hash: ${task.taskHash}`,
118
+ task.resultHash && !/^0x0+$/.test(task.resultHash) ? ` Result hash: ${task.resultHash}` : null,
104
119
  ]
105
120
  .filter(Boolean)
106
121
  .join('\n');
@@ -109,13 +124,13 @@ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
109
124
  const READ_TOOLS = [
110
125
  {
111
126
  name: 'panal_search_agents',
112
- description: 'Busca agentes de IA en el marketplace Panal (Monad mainnet) por texto libre sobre su nombre, ' +
113
- 'descripción y skills. Sin búsqueda devuelve todos los activos. Datos leídos de la cadena en vivo.',
127
+ description: 'Search AI agents on the Panal marketplace (Monad mainnet) by free text over their name, ' +
128
+ 'description and skills. With no query it returns every active agent. Read live from the chain.',
114
129
  inputSchema: {
115
130
  type: 'object',
116
131
  properties: {
117
- query: { type: 'string', description: 'Qué buscas, p. ej. "traducción" o "resúmenes legales".' },
118
- include_inactive: { type: 'boolean', description: 'Incluir agentes dados de baja. Por defecto no.' },
132
+ query: { type: 'string', description: 'What you are looking for, e.g. "translation" or "legal summaries".' },
133
+ include_inactive: { type: 'boolean', description: 'Include delisted agents. Off by default.' },
119
134
  },
120
135
  },
121
136
  handler: async (args) => {
@@ -124,60 +139,60 @@ const READ_TOOLS = [
124
139
  });
125
140
  if (!found.length) {
126
141
  return str(args.query)
127
- ? `Ningún agente activo encaja con "${str(args.query)}". Prueba con un término más general o mira todos sin búsqueda.`
128
- : 'Ahora mismo no hay agentes activos en el marketplace.';
142
+ ? `No active agent matches "${str(args.query)}". Try a broader term, or list them all with no query.`
143
+ : 'There are no active agents on the marketplace right now.';
129
144
  }
130
- return `${found.length} agente(s):\n\n${found.map(renderAgent).join('\n\n')}`;
145
+ return `${found.length} agent(s):\n\n${found.map(renderAgent).join('\n\n')}`;
131
146
  },
132
147
  },
133
148
  {
134
149
  name: 'panal_get_agent',
135
- description: 'Ficha completa de un agente de Panal por su dirección.',
150
+ description: 'Full profile of a Panal agent, by address.',
136
151
  inputSchema: {
137
152
  type: 'object',
138
- properties: { address: { type: 'string', description: 'Dirección 0x… del agente.' } },
153
+ properties: { address: { type: 'string', description: 'The agent 0x… address.' } },
139
154
  required: ['address'],
140
155
  },
141
156
  handler: async (args) => {
142
157
  const address = str(args.address);
143
158
  if (!address || !isAddress(address))
144
- return 'Necesito una dirección 0x válida.';
159
+ return 'I need a valid 0x address.';
145
160
  return renderAgent(await panal.getAgent(address));
146
161
  },
147
162
  },
148
163
  {
149
164
  name: 'panal_get_task',
150
- description: 'Estado de un encargo en el escrow de Panal: importe, plazo, agente y si ya se entregó.',
165
+ description: 'State of a job in the Panal escrow: amount, deadline, agent, and whether it was delivered.',
151
166
  inputSchema: {
152
167
  type: 'object',
153
- properties: { task_id: { type: 'number', description: 'Id numérico de la tarea.' } },
168
+ properties: { task_id: { type: 'number', description: 'Numeric task id.' } },
154
169
  required: ['task_id'],
155
170
  },
156
171
  handler: async (args) => {
157
172
  const id = Number(args.task_id);
158
173
  if (!Number.isInteger(id) || id < 0)
159
- return 'El id de la tarea es un entero mayor o igual que cero.';
174
+ return 'The task id is an integer greater than or equal to zero.';
160
175
  const count = await panal.getTaskCount();
161
176
  if (BigInt(id) >= count)
162
- return `La tarea #${id} no existe: ahora mismo hay ${count}.`;
177
+ return `Task #${id} does not exist: there are ${count} so far.`;
163
178
  return renderTask(await panal.getTask(BigInt(id)));
164
179
  },
165
180
  },
166
181
  {
167
182
  name: 'panal_marketplace_stats',
168
- description: 'Cifras del marketplace Panal: cuántos agentes hay, cuántos activos y cuántos encargos van.',
183
+ description: 'Panal marketplace figures: how many agents exist, how many are active, how many jobs so far.',
169
184
  inputSchema: { type: 'object', properties: {} },
170
185
  handler: async () => {
171
186
  const [agents, taskCount] = await Promise.all([panal.listAgents(), panal.getTaskCount()]);
172
187
  const active = agents.filter((a) => a.active);
173
188
  return [
174
- `Agentes registrados: ${agents.length}`,
175
- `Agentes activos: ${active.length}`,
176
- `Encargos creados: ${taskCount}`,
189
+ `Registered agents: ${agents.length}`,
190
+ `Active agents: ${active.length}`,
191
+ `Jobs created: ${taskCount}`,
177
192
  `Escrow: ${panal.addresses.escrow}`,
178
193
  `Registry: ${panal.addresses.registry}`,
179
194
  '',
180
- active.length ? `Activos ahora:\n${active.map(renderAgent).join('\n\n')}` : 'No hay agentes activos.',
195
+ active.length ? `Active now:\n${active.map(renderAgent).join('\n\n')}` : 'No active agents.',
181
196
  ].join('\n');
182
197
  },
183
198
  },
@@ -185,8 +200,8 @@ const READ_TOOLS = [
185
200
  const WRITE_TOOLS = [
186
201
  {
187
202
  name: 'panal_wallet',
188
- description: 'Estado de la wallet con la que este servidor pagaría: dirección, saldo y cuánto queda del ' +
189
- 'presupuesto de hoy. Úsala antes de contratar para saber si hay fondos.',
203
+ description: 'State of the wallet this server would pay with: address, balance, and how much of today\'s budget ' +
204
+ 'is left. Call it before hiring to know whether there are funds.',
190
205
  inputSchema: { type: 'object', properties: {} },
191
206
  handler: async () => {
192
207
  const blocked = writesBlockedReason();
@@ -197,21 +212,21 @@ const WRITE_TOOLS = [
197
212
  const left = limits.dailyBudgetWei > spent ? limits.dailyBudgetWei - spent : 0n;
198
213
  return [
199
214
  `Wallet: ${address}`,
200
- `Saldo: ${formatEther(balance)} MON`,
201
- `Tope por encargo: ${formatEther(limits.maxPerTaskWei)}`,
202
- `Presupuesto de hoy: gastado ${formatEther(spent)} de ${formatEther(limits.dailyBudgetWei)} · queda ${formatEther(left)}`,
215
+ `Balance: ${formatEther(balance)} MON`,
216
+ `Per-job cap: ${formatEther(limits.maxPerTaskWei)}`,
217
+ `Today's budget: spent ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} · ${formatEther(left)} left`,
203
218
  ].join('\n');
204
219
  },
205
220
  },
206
221
  {
207
222
  name: 'panal_quote_hire',
208
- description: 'Presupuesta un encargo SIN pagar nada. Devuelve el precio real del agente y un quote_id. ' +
209
- 'Enseña siempre el precio a la persona y espera su visto bueno antes de llamar a panal_hire.',
223
+ description: 'Quote a job WITHOUT paying anything. Returns the agent real price and a quote_id. ' +
224
+ 'Always show the price to the person and wait for their approval before calling panal_hire.',
210
225
  inputSchema: {
211
226
  type: 'object',
212
227
  properties: {
213
- agent: { type: 'string', description: 'Dirección 0x… del agente.' },
214
- brief: { type: 'string', description: 'El encargo, redactado con todo el detalle necesario.' },
228
+ agent: { type: 'string', description: 'The agent 0x… address.' },
229
+ brief: { type: 'string', description: 'The job, written with all the detail the agent will need.' },
215
230
  },
216
231
  required: ['agent', 'brief'],
217
232
  },
@@ -222,22 +237,22 @@ const WRITE_TOOLS = [
222
237
  const address = str(args.agent);
223
238
  const brief = str(args.brief);
224
239
  if (!address || !isAddress(address))
225
- return 'Necesito una dirección 0x válida del agente.';
240
+ return 'I need a valid 0x address for the agent.';
226
241
  if (!brief)
227
- return 'Necesito el texto del encargo.';
242
+ return 'I need the text of the job.';
228
243
  const agent = await panal.getAgent(address);
229
244
  if (!agent.active)
230
- return `${agent.metadata.name || address} está dado de baja y no acepta encargos.`;
245
+ return `${agent.metadata.name || address} is delisted and does not take jobs.`;
231
246
  const amount = agent.pricePerTask;
232
247
  const symbol = symbolOf(agent.currency);
233
248
  if (amount > limits.maxPerTaskWei) {
234
- return (`Ese agente cobra ${formatEther(amount)} ${symbol} y el tope por encargo de este servidor es ` +
235
- `${formatEther(limits.maxPerTaskWei)}. No se puede contratar sin que la persona suba MCP_MAX_PER_TASK_WEI.`);
249
+ return (`That agent charges ${formatEther(amount)} ${symbol} and this server per-job cap is ` +
250
+ `${formatEther(limits.maxPerTaskWei)}. It cannot be hired unless the person raises MCP_MAX_PER_TASK_WEI.`);
236
251
  }
237
252
  const spent = ledger.spentToday();
238
253
  if (spent + amount > limits.dailyBudgetWei) {
239
- return (`Se pasaría del presupuesto de hoy: llevas ${formatEther(spent)} de ${formatEther(limits.dailyBudgetWei)} ` +
240
- `y esto cuesta ${formatEther(amount)} ${symbol}.`);
254
+ return (`That would blow today's budget: ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} spent ` +
255
+ `and this costs ${formatEther(amount)} ${symbol}.`);
241
256
  }
242
257
  const quote = quotes.issue({
243
258
  worker: agent.address,
@@ -249,29 +264,29 @@ const WRITE_TOOLS = [
249
264
  botUrl: agent.metadata.botUrl,
250
265
  });
251
266
  return [
252
- `Presupuesto para ${quote.agentName}:`,
253
- ` Precio: ${formatEther(amount)} ${symbol}`,
254
- ` Plazo: ${limits.deadlineHours} h desde que se contrate`,
255
- ` Encargo: ${brief.length > 200 ? `${brief.slice(0, 200)}…` : brief}`,
267
+ `Quote for ${quote.agentName}:`,
268
+ ` Price: ${formatEther(amount)} ${symbol}`,
269
+ ` Deadline: ${limits.deadlineHours} h from the moment it is hired`,
270
+ ` Job: ${brief.length > 200 ? `${brief.slice(0, 200)}…` : brief}`,
256
271
  '',
257
- `quote_id: ${quote.id} (vale 5 minutos)`,
272
+ `quote_id: ${quote.id} (valid for 5 minutes)`,
258
273
  '',
259
- 'Enséñale este precio a la persona. Solo si dice que sí, llama a panal_hire con ese quote_id ' +
260
- 'y confirmed_by_user: true. Esto mueve dinero real.',
274
+ 'Show this price to the person. Only if they say yes, call panal_hire with that quote_id ' +
275
+ 'and confirmed_by_user: true. This moves real money.',
261
276
  ].join('\n');
262
277
  },
263
278
  },
264
279
  {
265
280
  name: 'panal_hire',
266
- description: 'Contrata al agente y BLOQUEA EL PAGO REAL en el escrow. Exige un quote_id de panal_quote_hire y ' +
267
- 'que la persona haya dicho que sí de forma explícita. No lo llames por iniciativa propia.',
281
+ description: 'Hire the agent and LOCK THE REAL PAYMENT in escrow. Requires a quote_id from panal_quote_hire and ' +
282
+ 'an explicit yes from the person. Never call it on your own initiative.',
268
283
  inputSchema: {
269
284
  type: 'object',
270
285
  properties: {
271
- quote_id: { type: 'string', description: 'El id devuelto por panal_quote_hire.' },
286
+ quote_id: { type: 'string', description: 'The id returned by panal_quote_hire.' },
272
287
  confirmed_by_user: {
273
288
  type: 'boolean',
274
- description: 'true solo si la persona ha visto el precio y ha dado su visto bueno.',
289
+ description: 'true only if the person has seen the price and approved it.',
275
290
  },
276
291
  },
277
292
  required: ['quote_id', 'confirmed_by_user'],
@@ -281,11 +296,11 @@ const WRITE_TOOLS = [
281
296
  if (blocked)
282
297
  return blocked;
283
298
  if (args.confirmed_by_user !== true) {
284
- return 'No contrato sin confirmed_by_user: true. Enséñale el presupuesto a la persona y pregúntale primero.';
299
+ return 'I will not hire without confirmed_by_user: true. Show the quote to the person and ask first.';
285
300
  }
286
301
  const id = str(args.quote_id);
287
302
  if (!id)
288
- return 'Falta el quote_id. Pide antes un presupuesto con panal_quote_hire.';
303
+ return 'The quote_id is missing. Ask for a quote with panal_quote_hire first.';
289
304
  const redeemed = quotes.redeem(id);
290
305
  if ('error' in redeemed)
291
306
  return redeemed.error;
@@ -294,63 +309,83 @@ const WRITE_TOOLS = [
294
309
  // pueden haber pasado minutos y otros encargos.
295
310
  const spent = ledger.spentToday();
296
311
  if (spent + quote.amount > limits.dailyBudgetWei) {
297
- return `Mientras tanto se agotó el presupuesto de hoy (${formatEther(spent)} de ${formatEther(limits.dailyBudgetWei)}).`;
312
+ return `Today's budget ran out in the meantime (${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)}).`;
298
313
  }
299
314
  const deadline = BigInt(Math.floor(Date.now() / 1000) + limits.deadlineHours * 3600);
300
315
  try {
301
316
  const result = await panal.hire({ agent: quote.worker, brief: quote.brief, deadline });
302
317
  ledger.record(result.amount);
303
318
  log(`contratada #${result.taskId} a ${quote.worker} por ${formatEther(result.amount)} ${quote.symbol}`);
319
+ // Contratar sin entregar el encargo deja la tarea a medias: el pago
320
+ // bloqueado y el agente esperando un texto que nadie le manda. Se hace
321
+ // aquí, no se le pide al usuario, porque quien está leyendo esto es un
322
+ // modelo en mitad de una conversación y no va a abrir una terminal.
323
+ let entregaBrief;
324
+ if (!quote.botUrl) {
325
+ entregaBrief = 'That agent publishes no endpoint: get the job to them through whatever channel you use.';
326
+ }
327
+ else {
328
+ const firma = await account.signMessage({ message: briefSignMessage(result.taskId) });
329
+ const fallo = await pushBrief(quote.botUrl, result.taskId, quote.brief, account.address, firma);
330
+ if (fallo === null) {
331
+ entregaBrief = `Job delivered to ${quote.botUrl}: the agent is already working on it.`;
332
+ log(`brief #${result.taskId} entregado en ${quote.botUrl}`);
333
+ }
334
+ else {
335
+ entregaBrief =
336
+ `WARNING: the payment is locked but the job did NOT arrive (${fallo}).\n` +
337
+ `The agent cannot start. Retry with panal_send_brief ${result.taskId}, ` +
338
+ `or the payment returns on its own when the deadline passes.`;
339
+ log(`brief #${result.taskId} NO entregado: ${fallo}`);
340
+ }
341
+ }
304
342
  return [
305
- `Contratado. Tarea #${result.taskId} creada y pago bloqueado en el escrow.`,
306
- ` Agente: ${quote.agentName}`,
307
- ` Importe: ${formatEther(result.amount)} ${quote.symbol}`,
343
+ `Hired. Task #${result.taskId} created and payment locked in escrow.`,
344
+ ` Agent: ${quote.agentName}`,
345
+ ` Amount: ${formatEther(result.amount)} ${quote.symbol}`,
308
346
  ` tx: ${EXPLORER}/tx/${result.txHash}`,
309
347
  '',
310
- quote.botUrl
311
- ? `El agente publica endpoint (${quote.botUrl}): el resultado se podrá descargar de ahí.`
312
- : 'Ese agente no publica endpoint: pídele el resultado por el canal que uses con él.',
348
+ entregaBrief,
313
349
  '',
314
- 'IMPORTANTE: el encargo NO viaja on-chain, solo su hash. Hazle llegar el texto al agente.',
315
- `Cuando entregue, revisa con panal_get_task ${result.taskId} y aprueba con panal_approve_task.`,
350
+ `When it delivers, check with panal_get_task ${result.taskId} and approve with panal_approve_task.`,
316
351
  ].join('\n');
317
352
  }
318
353
  catch (err) {
319
354
  const msg = err instanceof Error ? err.message : String(err);
320
355
  log(`fallo al contratar: ${msg}`);
321
- return `No se pudo contratar: ${msg}`;
356
+ return `Could not hire: ${msg}`;
322
357
  }
323
358
  },
324
359
  },
325
360
  {
326
361
  name: 'panal_get_result',
327
- description: 'Recoge el resultado de una tarea ya entregada. El texto vive fuera de la cadena: se le pide al ' +
328
- 'endpoint del agente firmando como cliente, y se comprueba que su hash coincide con el anclado ' +
329
- 'on-chain. Solo funciona para tareas que contrató la wallet de este servidor.',
362
+ description: 'Collect the result of a delivered task. The text lives off-chain: it is requested from the agent ' +
363
+ 'endpoint by signing as the client, and its hash is checked against the one anchored on-chain. ' +
364
+ 'Only works for tasks hired by this server wallet.',
330
365
  inputSchema: {
331
366
  type: 'object',
332
- properties: { task_id: { type: 'number', description: 'Id de la tarea entregada.' } },
367
+ properties: { task_id: { type: 'number', description: 'Id of the delivered task.' } },
333
368
  required: ['task_id'],
334
369
  },
335
370
  handler: async (args) => {
336
371
  if (!account)
337
- return 'Sin MCP_PRIVATE_KEY no se puede firmar la petición del resultado.';
372
+ return 'Without MCP_PRIVATE_KEY the request for the result cannot be signed.';
338
373
  const id = Number(args.task_id);
339
374
  if (!Number.isInteger(id) || id < 0)
340
375
  return 'El id de la tarea es un entero mayor o igual que cero.';
341
376
  const taskId = BigInt(id);
342
377
  const task = await panal.getTask(taskId);
343
378
  if (task.client.toLowerCase() !== account.address.toLowerCase()) {
344
- return `La tarea #${id} la contrató ${task.client}, no esta wallet. Solo el cliente puede recoger el resultado.`;
379
+ return `Task #${id} was hired by ${task.client}, not this wallet. Only the client can collect the result.`;
345
380
  }
346
381
  if (/^0x0+$/.test(task.resultHash)) {
347
- return `La tarea #${id} todavía no tiene resultado entregado (estado: ${TaskStatus[task.status] ?? task.status}).`;
382
+ return `Task #${id} has no delivered result yet (status: ${TaskStatus[task.status] ?? task.status}).`;
348
383
  }
349
384
  const agent = await panal.getAgent(task.worker);
350
385
  if (!agent.metadata.botUrl) {
351
- return (`El agente ${agent.metadata.name || task.worker} no publica endpoint, así que el resultado no se puede ` +
352
- `descargar por aquí. En la cadena consta su hash (${task.resultHash}): pídeselo por vuestro canal y ` +
353
- 'comprueba que el hash coincide.');
386
+ return (`Agent ${agent.metadata.name || task.worker} publishes no endpoint, so the result cannot be downloaded ` +
387
+ `here. Its hash is on-chain (${task.resultHash}): ask them through your channel and check that the ` +
388
+ 'hash matches.');
354
389
  }
355
390
  try {
356
391
  const signature = await account.signMessage({ message: resultSignMessage(taskId) });
@@ -360,28 +395,80 @@ const WRITE_TOOLS = [
360
395
  // on-chain y enseñarte otra distinta.
361
396
  const actual = keccak256(toBytes(text));
362
397
  if (actual.toLowerCase() !== task.resultHash.toLowerCase()) {
363
- return (`⚠️ El resultado que devuelve el agente NO coincide con el anclado on-chain.\n` +
364
- ` esperado: ${task.resultHash}\n recibido: ${actual}\n\n` +
365
- 'No lo apruebes: o el agente cambió el texto después de entregar, o alguien alteró la respuesta. ' +
366
- 'Puedes abrir una disputa desde https://panal.lat/dashboard.');
398
+ return (`⚠️ The result the agent serves does NOT match the one anchored on-chain.\n` +
399
+ ` expected: ${task.resultHash}\n received: ${actual}\n\n` +
400
+ 'Do not approve it: either the agent changed the text after delivering, or someone altered the ' +
401
+ 'response. You can open a dispute at https://panal.lat/dashboard.');
367
402
  }
368
- return `Resultado de la tarea #${id} (hash verificado contra la cadena):\n\n${text}`;
403
+ return `Result of task #${id} (hash verified against the chain):\n\n${text}`;
369
404
  }
370
405
  catch (err) {
371
- return `No se pudo recoger el resultado: ${err instanceof Error ? err.message : err}`;
406
+ return `Could not collect the result: ${err instanceof Error ? err.message : err}`;
407
+ }
408
+ },
409
+ },
410
+ {
411
+ name: 'panal_send_brief',
412
+ description: 'Deliver the job again to an already-hired agent, when the automatic send from panal_hire did not ' +
413
+ 'arrive. It costs NO money: the payment is already locked, this only repeats the HTTP delivery. ' +
414
+ 'The text must be EXACTLY the one hired, because its hash is on the chain.',
415
+ inputSchema: {
416
+ type: 'object',
417
+ properties: {
418
+ task_id: { type: 'number', description: 'Id of the already-created task.' },
419
+ brief: { type: 'string', description: 'The job, word for word as it was when hired.' },
420
+ },
421
+ required: ['task_id', 'brief'],
422
+ },
423
+ handler: async (args) => {
424
+ const blocked = writesBlockedReason();
425
+ if (blocked)
426
+ return blocked;
427
+ const id = Number(args.task_id);
428
+ if (!Number.isInteger(id) || id < 0)
429
+ return 'I need a valid task id.';
430
+ const brief = str(args.brief);
431
+ if (!brief)
432
+ return 'I need the text of the job.';
433
+ const task = await panal.getTask(BigInt(id));
434
+ if (task.client.toLowerCase() !== account.address.toLowerCase()) {
435
+ return `Task #${id} was not hired by this wallet, so its signature is not valid for delivering the job.`;
436
+ }
437
+ if (task.status !== TaskStatus.Open) {
438
+ return `Task #${id} is ${TaskStatus[task.status]}: it no longer accepts the job.`;
439
+ }
440
+ // Se comprueba aquí antes de molestar al agente: así el error dice que el
441
+ // texto no es el mismo, en vez de un 409 sin contexto desde el endpoint.
442
+ if (keccak256(toBytes(brief)) !== task.taskHash) {
443
+ return [
444
+ 'That text is NOT the one hired: its hash does not match the one on the chain.',
445
+ ` hired hash: ${task.taskHash}`,
446
+ ` your text hash: ${keccak256(toBytes(brief))}`,
447
+ 'It must be identical, character for character. One extra space already changes it.',
448
+ ].join('\n');
449
+ }
450
+ const agent = await panal.getAgent(task.worker);
451
+ if (!agent.metadata.botUrl)
452
+ return 'That agent publishes no endpoint: there is nowhere to send the job.';
453
+ const firma = await account.signMessage({ message: briefSignMessage(BigInt(id)) });
454
+ const fallo = await pushBrief(agent.metadata.botUrl, BigInt(id), brief, account.address, firma);
455
+ if (fallo === null) {
456
+ log(`brief #${id} entregado (reintento) en ${agent.metadata.botUrl}`);
457
+ return `Job delivered. The agent is already working on task #${id}.`;
372
458
  }
459
+ return `It still does not arrive (${fallo}). The payment stays locked and returns on its own when the deadline passes.`;
373
460
  },
374
461
  },
375
462
  {
376
463
  name: 'panal_approve_task',
377
- description: 'Aprueba el resultado entregado, LIBERA EL PAGO al agente y le deja una valoración de 1 a 5. ' +
378
- 'Solo tras enseñarle el resultado a la persona y que ella decida la nota.',
464
+ description: 'Approve the delivered result, RELEASE THE PAYMENT to the agent and leave a 1-5 rating. ' +
465
+ 'Only after showing the result to the person and letting them decide the score.',
379
466
  inputSchema: {
380
467
  type: 'object',
381
468
  properties: {
382
469
  task_id: { type: 'number', description: 'Id de la tarea entregada.' },
383
- rating: { type: 'number', description: 'Valoración de 1 a 5 que ha decidido la persona.' },
384
- confirmed_by_user: { type: 'boolean', description: 'true solo si la persona lo ha aprobado.' },
470
+ rating: { type: 'number', description: 'The 1-5 rating the person decided.' },
471
+ confirmed_by_user: { type: 'boolean', description: 'true only if the person approved it.' },
385
472
  },
386
473
  required: ['task_id', 'rating', 'confirmed_by_user'],
387
474
  },
@@ -390,20 +477,20 @@ const WRITE_TOOLS = [
390
477
  if (blocked)
391
478
  return blocked;
392
479
  if (args.confirmed_by_user !== true) {
393
- return 'No libero el pago sin confirmed_by_user: true. Enséñale el resultado a la persona primero.';
480
+ return 'I will not release the payment without confirmed_by_user: true. Show the result to the person first.';
394
481
  }
395
482
  const id = Number(args.task_id);
396
483
  const rating = Number(args.rating);
397
484
  if (!Number.isInteger(id) || id < 0)
398
- return 'El id de la tarea es un entero.';
485
+ return 'The task id is an integer.';
399
486
  if (!Number.isInteger(rating) || rating < 1 || rating > 5)
400
- return 'La valoración es un entero de 1 a 5.';
487
+ return 'The rating is an integer from 1 to 5.';
401
488
  try {
402
489
  const hash = await panal.approveTask(BigInt(id), rating);
403
- return `Pago liberado para la tarea #${id} con valoración ${rating}/5.\n tx: ${EXPLORER}/tx/${hash}`;
490
+ return `Payment released for task #${id} with a ${rating}/5 rating.\n tx: ${EXPLORER}/tx/${hash}`;
404
491
  }
405
492
  catch (err) {
406
- return `No se pudo aprobar: ${err instanceof Error ? err.message : err}`;
493
+ return `Could not approve: ${err instanceof Error ? err.message : err}`;
407
494
  }
408
495
  },
409
496
  },
@@ -450,7 +537,7 @@ async function handle(req) {
450
537
  const name = typeof req.params?.name === 'string' ? req.params.name : '';
451
538
  const tool = TOOLS.find((t) => t.name === name);
452
539
  if (!tool) {
453
- replyError(req.id, -32602, `No existe la herramienta "${name}".`);
540
+ replyError(req.id, -32602, `There is no tool named "${name}".`);
454
541
  return;
455
542
  }
456
543
  const args = req.params?.arguments ?? {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panal-mcp",
3
- "version": "0.1.1",
3
+ "version": "0.2.0",
4
4
  "description": "Servidor MCP de Panal: busca y contrata agentes de IA on-chain desde Claude",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -30,7 +30,7 @@
30
30
  "node": ">=20"
31
31
  },
32
32
  "dependencies": {
33
- "@panal/sdk": "^0.2.0",
33
+ "@panal/sdk": "^0.4.0",
34
34
  "viem": "^2.21.0"
35
35
  },
36
36
  "devDependencies": {