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.
- package/dist/fetch-result.d.ts +15 -0
- package/dist/fetch-result.js +63 -10
- package/dist/limits.js +2 -2
- package/dist/server.js +190 -103
- package/package.json +2 -2
package/dist/fetch-result.d.ts
CHANGED
|
@@ -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
|
*
|
package/dist/fetch-result.js
CHANGED
|
@@ -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(`
|
|
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(`
|
|
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('
|
|
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(`
|
|
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(`
|
|
129
|
+
throw new Error(`Could not resolve ${host}.`);
|
|
77
130
|
for (const ip of ips) {
|
|
78
131
|
if (isPrivateIp(ip))
|
|
79
|
-
throw new Error(`
|
|
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(`
|
|
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('
|
|
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(`
|
|
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('
|
|
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: '
|
|
34
|
-
'
|
|
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
|
-
|
|
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 ('
|
|
65
|
-
'
|
|
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
|
|
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(', ') : '(
|
|
96
|
+
const skills = agent.metadata.skills.length ? agent.metadata.skills.join(', ') : '(no skills declared)';
|
|
82
97
|
return [
|
|
83
|
-
`${agent.metadata.name || '(
|
|
84
|
-
`
|
|
85
|
-
`
|
|
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 ? `
|
|
102
|
+
agent.metadata.description ? ` Description: ${agent.metadata.description}` : null,
|
|
88
103
|
agent.metadata.botUrl ? ` Endpoint: ${agent.metadata.botUrl}` : null,
|
|
89
|
-
`
|
|
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
|
-
`
|
|
98
|
-
`
|
|
99
|
-
`
|
|
100
|
-
`
|
|
101
|
-
`
|
|
102
|
-
`
|
|
103
|
-
task.resultHash && !/^0x0+$/.test(task.resultHash) ? `
|
|
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: '
|
|
113
|
-
'
|
|
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: '
|
|
118
|
-
include_inactive: { type: 'boolean', description: '
|
|
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
|
-
? `
|
|
128
|
-
: '
|
|
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}
|
|
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: '
|
|
150
|
+
description: 'Full profile of a Panal agent, by address.',
|
|
136
151
|
inputSchema: {
|
|
137
152
|
type: 'object',
|
|
138
|
-
properties: { address: { type: 'string', description: '
|
|
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 '
|
|
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: '
|
|
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: '
|
|
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 '
|
|
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 `
|
|
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: '
|
|
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
|
-
`
|
|
175
|
-
`
|
|
176
|
-
`
|
|
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 ? `
|
|
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: '
|
|
189
|
-
'
|
|
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
|
-
`
|
|
201
|
-
`
|
|
202
|
-
`
|
|
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: '
|
|
209
|
-
'
|
|
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: '
|
|
214
|
-
brief: { type: 'string', description: '
|
|
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 '
|
|
240
|
+
return 'I need a valid 0x address for the agent.';
|
|
226
241
|
if (!brief)
|
|
227
|
-
return '
|
|
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}
|
|
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 (`
|
|
235
|
-
`${formatEther(limits.maxPerTaskWei)}.
|
|
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 (`
|
|
240
|
-
`
|
|
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
|
-
`
|
|
253
|
-
`
|
|
254
|
-
`
|
|
255
|
-
`
|
|
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} (
|
|
272
|
+
`quote_id: ${quote.id} (valid for 5 minutes)`,
|
|
258
273
|
'',
|
|
259
|
-
'
|
|
260
|
-
'
|
|
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: '
|
|
267
|
-
'
|
|
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: '
|
|
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
|
|
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 '
|
|
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 '
|
|
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 `
|
|
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
|
-
`
|
|
306
|
-
`
|
|
307
|
-
`
|
|
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
|
-
|
|
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
|
-
|
|
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 `
|
|
356
|
+
return `Could not hire: ${msg}`;
|
|
322
357
|
}
|
|
323
358
|
},
|
|
324
359
|
},
|
|
325
360
|
{
|
|
326
361
|
name: 'panal_get_result',
|
|
327
|
-
description: '
|
|
328
|
-
'endpoint
|
|
329
|
-
'
|
|
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
|
|
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 '
|
|
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 `
|
|
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 `
|
|
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 (`
|
|
352
|
-
`
|
|
353
|
-
'
|
|
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 (`⚠️
|
|
364
|
-
`
|
|
365
|
-
'
|
|
366
|
-
'
|
|
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 `
|
|
403
|
+
return `Result of task #${id} (hash verified against the chain):\n\n${text}`;
|
|
369
404
|
}
|
|
370
405
|
catch (err) {
|
|
371
|
-
return `
|
|
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: '
|
|
378
|
-
'
|
|
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: '
|
|
384
|
-
confirmed_by_user: { type: 'boolean', description: 'true
|
|
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 '
|
|
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 '
|
|
485
|
+
return 'The task id is an integer.';
|
|
399
486
|
if (!Number.isInteger(rating) || rating < 1 || rating > 5)
|
|
400
|
-
return '
|
|
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 `
|
|
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 `
|
|
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, `
|
|
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.
|
|
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.
|
|
33
|
+
"@panal/sdk": "^0.4.0",
|
|
34
34
|
"viem": "^2.21.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|