panal-mcp 0.1.3 → 0.3.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.d.ts +19 -2
- package/dist/limits.js +16 -4
- package/dist/server.js +341 -105
- 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.d.ts
CHANGED
|
@@ -6,8 +6,19 @@
|
|
|
6
6
|
* servidor, y nunca en el prompt: un prompt se puede negociar, un `if` no.
|
|
7
7
|
*/
|
|
8
8
|
import type { Address } from 'viem';
|
|
9
|
+
/**
|
|
10
|
+
* Las dos maneras de pagar a un agente, y no son intercambiables.
|
|
11
|
+
*
|
|
12
|
+
* `hire` bloquea el pago en el escrow y abre una tarea con plazo, entrega y
|
|
13
|
+
* disputa. `ask` paga una consulta suelta por x402 y se resuelve en la misma
|
|
14
|
+
* llamada HTTP. Los precios son distintos —Spec cobra 100 $PANAL por tarea y
|
|
15
|
+
* 0,5 por consulta—, así que canjear un presupuesto por el camino que no es
|
|
16
|
+
* cobraría doscientas veces de más.
|
|
17
|
+
*/
|
|
18
|
+
export type QuoteKind = 'hire' | 'ask';
|
|
9
19
|
export interface Quote {
|
|
10
20
|
id: string;
|
|
21
|
+
kind: QuoteKind;
|
|
11
22
|
worker: Address;
|
|
12
23
|
agentName: string;
|
|
13
24
|
brief: string;
|
|
@@ -26,8 +37,14 @@ export interface Quote {
|
|
|
26
37
|
export declare class QuoteBook {
|
|
27
38
|
private readonly quotes;
|
|
28
39
|
issue(data: Omit<Quote, 'id' | 'expiresAt'>): Quote;
|
|
29
|
-
/**
|
|
30
|
-
|
|
40
|
+
/**
|
|
41
|
+
* Devuelve el presupuesto, o el motivo por el que no vale.
|
|
42
|
+
*
|
|
43
|
+
* `kind` no es decorativo: obliga a canjear cada presupuesto por el camino
|
|
44
|
+
* para el que se pidió. Sin esto, el id de una consulta de 0,5 $PANAL se
|
|
45
|
+
* podría canjear como contratación y bloquear los 100 $PANAL de la tarea.
|
|
46
|
+
*/
|
|
47
|
+
redeem(id: string, kind: QuoteKind): {
|
|
31
48
|
quote: Quote;
|
|
32
49
|
} | {
|
|
33
50
|
error: string;
|
package/dist/limits.js
CHANGED
|
@@ -24,14 +24,26 @@ export class QuoteBook {
|
|
|
24
24
|
this.quotes.set(quote.id, quote);
|
|
25
25
|
return quote;
|
|
26
26
|
}
|
|
27
|
-
/**
|
|
28
|
-
|
|
27
|
+
/**
|
|
28
|
+
* Devuelve el presupuesto, o el motivo por el que no vale.
|
|
29
|
+
*
|
|
30
|
+
* `kind` no es decorativo: obliga a canjear cada presupuesto por el camino
|
|
31
|
+
* para el que se pidió. Sin esto, el id de una consulta de 0,5 $PANAL se
|
|
32
|
+
* podría canjear como contratación y bloquear los 100 $PANAL de la tarea.
|
|
33
|
+
*/
|
|
34
|
+
redeem(id, kind) {
|
|
29
35
|
this.prune();
|
|
30
36
|
const quote = this.quotes.get(id);
|
|
31
37
|
if (!quote) {
|
|
32
38
|
return {
|
|
33
|
-
error: '
|
|
34
|
-
'
|
|
39
|
+
error: 'That quote does not exist or has expired (they last 5 minutes). ' +
|
|
40
|
+
'Ask for a new one and show it to the person before spending.',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
if (quote.kind !== kind) {
|
|
44
|
+
return {
|
|
45
|
+
error: `That quote_id is for ${quote.kind === 'ask' ? 'a per-call question (panal_ask)' : 'hiring a job (panal_hire)'}, ` +
|
|
46
|
+
`not for this. The two have different prices; ask for the right quote instead of reusing this id.`,
|
|
35
47
|
};
|
|
36
48
|
}
|
|
37
49
|
// 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,18 @@ 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
|
+
// "Per task" y no "Price" a secas: hay dos precios (tarea por escrow y
|
|
101
|
+
// consulta por x402) y llamar "el precio" a uno de ellos fue justo lo que
|
|
102
|
+
// hizo invisible al otro.
|
|
103
|
+
` Per task: ${price} (escrow, with deadline and dispute window)`,
|
|
86
104
|
` Skills: ${skills}`,
|
|
87
|
-
agent.metadata.description ? `
|
|
105
|
+
agent.metadata.description ? ` Description: ${agent.metadata.description}` : null,
|
|
88
106
|
agent.metadata.botUrl ? ` Endpoint: ${agent.metadata.botUrl}` : null,
|
|
89
|
-
`
|
|
107
|
+
` Profile: ${EXPLORER}/address/${agent.address}`,
|
|
90
108
|
]
|
|
91
109
|
.filter(Boolean)
|
|
92
110
|
.join('\n');
|
|
@@ -94,13 +112,13 @@ function renderAgent(agent) {
|
|
|
94
112
|
function renderTask(task) {
|
|
95
113
|
const deadline = new Date(Number(task.deadline) * 1000).toISOString();
|
|
96
114
|
return [
|
|
97
|
-
`
|
|
98
|
-
`
|
|
99
|
-
`
|
|
100
|
-
`
|
|
101
|
-
`
|
|
102
|
-
`
|
|
103
|
-
task.resultHash && !/^0x0+$/.test(task.resultHash) ? `
|
|
115
|
+
`Task #${task.id} — ${TaskStatus[task.status] ?? task.status}`,
|
|
116
|
+
` Client: ${task.client}`,
|
|
117
|
+
` Agent: ${task.worker}`,
|
|
118
|
+
` Amount: ${formatEther(task.amount)} ${symbolOf(task.currency)}`,
|
|
119
|
+
` Deadline: ${deadline}`,
|
|
120
|
+
` Job hash: ${task.taskHash}`,
|
|
121
|
+
task.resultHash && !/^0x0+$/.test(task.resultHash) ? ` Result hash: ${task.resultHash}` : null,
|
|
104
122
|
]
|
|
105
123
|
.filter(Boolean)
|
|
106
124
|
.join('\n');
|
|
@@ -109,13 +127,13 @@ const str = (v) => (typeof v === 'string' && v.trim() ? v.trim() : undefined);
|
|
|
109
127
|
const READ_TOOLS = [
|
|
110
128
|
{
|
|
111
129
|
name: 'panal_search_agents',
|
|
112
|
-
description: '
|
|
113
|
-
'
|
|
130
|
+
description: 'Search AI agents on the Panal marketplace (Monad mainnet) by free text over their name, ' +
|
|
131
|
+
'description and skills. With no query it returns every active agent. Read live from the chain.',
|
|
114
132
|
inputSchema: {
|
|
115
133
|
type: 'object',
|
|
116
134
|
properties: {
|
|
117
|
-
query: { type: 'string', description: '
|
|
118
|
-
include_inactive: { type: 'boolean', description: '
|
|
135
|
+
query: { type: 'string', description: 'What you are looking for, e.g. "translation" or "legal summaries".' },
|
|
136
|
+
include_inactive: { type: 'boolean', description: 'Include delisted agents. Off by default.' },
|
|
119
137
|
},
|
|
120
138
|
},
|
|
121
139
|
handler: async (args) => {
|
|
@@ -124,60 +142,139 @@ const READ_TOOLS = [
|
|
|
124
142
|
});
|
|
125
143
|
if (!found.length) {
|
|
126
144
|
return str(args.query)
|
|
127
|
-
? `
|
|
128
|
-
: '
|
|
145
|
+
? `No active agent matches "${str(args.query)}". Try a broader term, or list them all with no query.`
|
|
146
|
+
: 'There are no active agents on the marketplace right now.';
|
|
129
147
|
}
|
|
130
|
-
return `${found.length}
|
|
148
|
+
return `${found.length} agent(s):\n\n${found.map(renderAgent).join('\n\n')}`;
|
|
131
149
|
},
|
|
132
150
|
},
|
|
133
151
|
{
|
|
134
152
|
name: 'panal_get_agent',
|
|
135
|
-
description: '
|
|
153
|
+
description: 'Full profile of a Panal agent, by address.',
|
|
136
154
|
inputSchema: {
|
|
137
155
|
type: 'object',
|
|
138
|
-
properties: { address: { type: 'string', description: '
|
|
156
|
+
properties: { address: { type: 'string', description: 'The agent 0x… address.' } },
|
|
139
157
|
required: ['address'],
|
|
140
158
|
},
|
|
141
159
|
handler: async (args) => {
|
|
142
160
|
const address = str(args.address);
|
|
143
161
|
if (!address || !isAddress(address))
|
|
144
|
-
return '
|
|
145
|
-
|
|
162
|
+
return 'I need a valid 0x address.';
|
|
163
|
+
const agent = await panal.getAgent(address);
|
|
164
|
+
// El perfil es donde alguien mira el precio, así que aquí sí se paga la
|
|
165
|
+
// llamada al manifiesto del agente: enseñar solo el de tarea era la
|
|
166
|
+
// razón de que el precio por consulta fuese invisible desde aquí. En
|
|
167
|
+
// `panal_search_agents` no se hace, porque serían N llamadas HTTP a
|
|
168
|
+
// servidores ajenos por cada búsqueda.
|
|
169
|
+
let porLlamada;
|
|
170
|
+
try {
|
|
171
|
+
const q = await panal.quoteAgent(address, 'Price check.');
|
|
172
|
+
porLlamada = ` Per question: ${formatEther(BigInt(q.amount))} ${q.assetSymbol ?? symbolOf(q.asset)} (x402, answered on the spot)`;
|
|
173
|
+
}
|
|
174
|
+
catch {
|
|
175
|
+
porLlamada = ' Per question: not offered (this agent only takes jobs through escrow)';
|
|
176
|
+
}
|
|
177
|
+
return `${renderAgent(agent)}\n${porLlamada}`;
|
|
146
178
|
},
|
|
147
179
|
},
|
|
148
180
|
{
|
|
149
181
|
name: 'panal_get_task',
|
|
150
|
-
description: '
|
|
182
|
+
description: 'State of a job in the Panal escrow: amount, deadline, agent, and whether it was delivered.',
|
|
151
183
|
inputSchema: {
|
|
152
184
|
type: 'object',
|
|
153
|
-
properties: { task_id: { type: 'number', description: '
|
|
185
|
+
properties: { task_id: { type: 'number', description: 'Numeric task id.' } },
|
|
154
186
|
required: ['task_id'],
|
|
155
187
|
},
|
|
156
188
|
handler: async (args) => {
|
|
157
189
|
const id = Number(args.task_id);
|
|
158
190
|
if (!Number.isInteger(id) || id < 0)
|
|
159
|
-
return '
|
|
191
|
+
return 'The task id is an integer greater than or equal to zero.';
|
|
160
192
|
const count = await panal.getTaskCount();
|
|
161
193
|
if (BigInt(id) >= count)
|
|
162
|
-
return `
|
|
194
|
+
return `Task #${id} does not exist: there are ${count} so far.`;
|
|
163
195
|
return renderTask(await panal.getTask(BigInt(id)));
|
|
164
196
|
},
|
|
165
197
|
},
|
|
198
|
+
{
|
|
199
|
+
name: 'panal_quote_ask',
|
|
200
|
+
description: 'Ask an agent what it charges for ONE question, answered on the spot (x402 per-call pricing). ' +
|
|
201
|
+
'Costs nothing and needs no wallet. This is a DIFFERENT price from the per-task price in the ' +
|
|
202
|
+
'agent profile: per-task means hiring a job through escrow, with a deadline and a dispute window. ' +
|
|
203
|
+
'The two can differ by a lot, so quote the one you actually mean to use.',
|
|
204
|
+
inputSchema: {
|
|
205
|
+
type: 'object',
|
|
206
|
+
properties: {
|
|
207
|
+
agent: { type: 'string', description: 'The agent 0x… address.' },
|
|
208
|
+
prompt: { type: 'string', description: 'The question, so the agent can price it if it prices per question.' },
|
|
209
|
+
},
|
|
210
|
+
required: ['agent', 'prompt'],
|
|
211
|
+
},
|
|
212
|
+
handler: async (args) => {
|
|
213
|
+
const address = str(args.agent);
|
|
214
|
+
const prompt = str(args.prompt);
|
|
215
|
+
if (!address || !isAddress(address))
|
|
216
|
+
return 'I need a valid 0x address for the agent.';
|
|
217
|
+
if (!prompt)
|
|
218
|
+
return 'I need the question.';
|
|
219
|
+
const agent = await panal.getAgent(address);
|
|
220
|
+
const name = agent.metadata.name || address;
|
|
221
|
+
let quote;
|
|
222
|
+
try {
|
|
223
|
+
quote = await panal.quoteAgent(address, prompt);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
// Cobrar por llamada es opcional: un agente puede trabajar solo por
|
|
227
|
+
// escrow. Se dice cuál es la alternativa en vez de dejarlo en error.
|
|
228
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
229
|
+
return (`${name} does not answer per-question requests (${msg}).\n` +
|
|
230
|
+
`It can still be hired for a job: ${formatEther(agent.pricePerTask)} ${symbolOf(agent.currency)} ` +
|
|
231
|
+
`per task, via panal_quote_hire.`);
|
|
232
|
+
}
|
|
233
|
+
const amount = BigInt(quote.amount);
|
|
234
|
+
const symbol = quote.assetSymbol ?? symbolOf(quote.asset);
|
|
235
|
+
const saved = quotes.issue({
|
|
236
|
+
kind: 'ask',
|
|
237
|
+
worker: agent.address,
|
|
238
|
+
agentName: name,
|
|
239
|
+
brief: prompt,
|
|
240
|
+
amount,
|
|
241
|
+
currency: quote.asset,
|
|
242
|
+
symbol,
|
|
243
|
+
botUrl: agent.metadata.botUrl,
|
|
244
|
+
});
|
|
245
|
+
return [
|
|
246
|
+
`${name} charges per question:`,
|
|
247
|
+
` Price: ${formatEther(amount)} ${symbol} for this one question`,
|
|
248
|
+
` Paid to: ${quote.payTo}`,
|
|
249
|
+
quote.description ? ` Covers: ${quote.description}` : null,
|
|
250
|
+
'',
|
|
251
|
+
`For comparison, hiring it for a job costs ${formatEther(agent.pricePerTask)} ${symbolOf(agent.currency)} ` +
|
|
252
|
+
'per task (escrow, with deadline and dispute window).',
|
|
253
|
+
'',
|
|
254
|
+
`quote_id: ${saved.id} (valid for 5 minutes)`,
|
|
255
|
+
'',
|
|
256
|
+
'Show this price to the person. Only if they say yes, call panal_ask with that quote_id and ' +
|
|
257
|
+
'confirmed_by_user: true. This moves real money.',
|
|
258
|
+
]
|
|
259
|
+
.filter((l) => l !== null)
|
|
260
|
+
.join('\n');
|
|
261
|
+
},
|
|
262
|
+
},
|
|
166
263
|
{
|
|
167
264
|
name: 'panal_marketplace_stats',
|
|
168
|
-
description: '
|
|
265
|
+
description: 'Panal marketplace figures: how many agents exist, how many are active, how many jobs so far.',
|
|
169
266
|
inputSchema: { type: 'object', properties: {} },
|
|
170
267
|
handler: async () => {
|
|
171
268
|
const [agents, taskCount] = await Promise.all([panal.listAgents(), panal.getTaskCount()]);
|
|
172
269
|
const active = agents.filter((a) => a.active);
|
|
173
270
|
return [
|
|
174
|
-
`
|
|
175
|
-
`
|
|
176
|
-
`
|
|
271
|
+
`Registered agents: ${agents.length}`,
|
|
272
|
+
`Active agents: ${active.length}`,
|
|
273
|
+
`Jobs created: ${taskCount}`,
|
|
177
274
|
`Escrow: ${panal.addresses.escrow}`,
|
|
178
275
|
`Registry: ${panal.addresses.registry}`,
|
|
179
276
|
'',
|
|
180
|
-
active.length ? `
|
|
277
|
+
active.length ? `Active now:\n${active.map(renderAgent).join('\n\n')}` : 'No active agents.',
|
|
181
278
|
].join('\n');
|
|
182
279
|
},
|
|
183
280
|
},
|
|
@@ -185,8 +282,8 @@ const READ_TOOLS = [
|
|
|
185
282
|
const WRITE_TOOLS = [
|
|
186
283
|
{
|
|
187
284
|
name: 'panal_wallet',
|
|
188
|
-
description: '
|
|
189
|
-
'
|
|
285
|
+
description: 'State of the wallet this server would pay with: address, balance, and how much of today\'s budget ' +
|
|
286
|
+
'is left. Call it before hiring to know whether there are funds.',
|
|
190
287
|
inputSchema: { type: 'object', properties: {} },
|
|
191
288
|
handler: async () => {
|
|
192
289
|
const blocked = writesBlockedReason();
|
|
@@ -197,21 +294,21 @@ const WRITE_TOOLS = [
|
|
|
197
294
|
const left = limits.dailyBudgetWei > spent ? limits.dailyBudgetWei - spent : 0n;
|
|
198
295
|
return [
|
|
199
296
|
`Wallet: ${address}`,
|
|
200
|
-
`
|
|
201
|
-
`
|
|
202
|
-
`
|
|
297
|
+
`Balance: ${formatEther(balance)} MON`,
|
|
298
|
+
`Per-job cap: ${formatEther(limits.maxPerTaskWei)}`,
|
|
299
|
+
`Today's budget: spent ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} · ${formatEther(left)} left`,
|
|
203
300
|
].join('\n');
|
|
204
301
|
},
|
|
205
302
|
},
|
|
206
303
|
{
|
|
207
304
|
name: 'panal_quote_hire',
|
|
208
|
-
description: '
|
|
209
|
-
'
|
|
305
|
+
description: 'Quote a job WITHOUT paying anything. Returns the agent real price and a quote_id. ' +
|
|
306
|
+
'Always show the price to the person and wait for their approval before calling panal_hire.',
|
|
210
307
|
inputSchema: {
|
|
211
308
|
type: 'object',
|
|
212
309
|
properties: {
|
|
213
|
-
agent: { type: 'string', description: '
|
|
214
|
-
brief: { type: 'string', description: '
|
|
310
|
+
agent: { type: 'string', description: 'The agent 0x… address.' },
|
|
311
|
+
brief: { type: 'string', description: 'The job, written with all the detail the agent will need.' },
|
|
215
312
|
},
|
|
216
313
|
required: ['agent', 'brief'],
|
|
217
314
|
},
|
|
@@ -222,24 +319,25 @@ const WRITE_TOOLS = [
|
|
|
222
319
|
const address = str(args.agent);
|
|
223
320
|
const brief = str(args.brief);
|
|
224
321
|
if (!address || !isAddress(address))
|
|
225
|
-
return '
|
|
322
|
+
return 'I need a valid 0x address for the agent.';
|
|
226
323
|
if (!brief)
|
|
227
|
-
return '
|
|
324
|
+
return 'I need the text of the job.';
|
|
228
325
|
const agent = await panal.getAgent(address);
|
|
229
326
|
if (!agent.active)
|
|
230
|
-
return `${agent.metadata.name || address}
|
|
327
|
+
return `${agent.metadata.name || address} is delisted and does not take jobs.`;
|
|
231
328
|
const amount = agent.pricePerTask;
|
|
232
329
|
const symbol = symbolOf(agent.currency);
|
|
233
330
|
if (amount > limits.maxPerTaskWei) {
|
|
234
|
-
return (`
|
|
235
|
-
`${formatEther(limits.maxPerTaskWei)}.
|
|
331
|
+
return (`That agent charges ${formatEther(amount)} ${symbol} and this server per-job cap is ` +
|
|
332
|
+
`${formatEther(limits.maxPerTaskWei)}. It cannot be hired unless the person raises MCP_MAX_PER_TASK_WEI.`);
|
|
236
333
|
}
|
|
237
334
|
const spent = ledger.spentToday();
|
|
238
335
|
if (spent + amount > limits.dailyBudgetWei) {
|
|
239
|
-
return (`
|
|
240
|
-
`
|
|
336
|
+
return (`That would blow today's budget: ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} spent ` +
|
|
337
|
+
`and this costs ${formatEther(amount)} ${symbol}.`);
|
|
241
338
|
}
|
|
242
339
|
const quote = quotes.issue({
|
|
340
|
+
kind: 'hire',
|
|
243
341
|
worker: agent.address,
|
|
244
342
|
agentName: agent.metadata.name || agent.address,
|
|
245
343
|
brief,
|
|
@@ -249,29 +347,95 @@ const WRITE_TOOLS = [
|
|
|
249
347
|
botUrl: agent.metadata.botUrl,
|
|
250
348
|
});
|
|
251
349
|
return [
|
|
252
|
-
`
|
|
253
|
-
`
|
|
254
|
-
`
|
|
255
|
-
`
|
|
350
|
+
`Quote for ${quote.agentName}:`,
|
|
351
|
+
` Price: ${formatEther(amount)} ${symbol}`,
|
|
352
|
+
` Deadline: ${limits.deadlineHours} h from the moment it is hired`,
|
|
353
|
+
` Job: ${brief.length > 200 ? `${brief.slice(0, 200)}…` : brief}`,
|
|
256
354
|
'',
|
|
257
|
-
`quote_id: ${quote.id} (
|
|
355
|
+
`quote_id: ${quote.id} (valid for 5 minutes)`,
|
|
258
356
|
'',
|
|
259
|
-
'
|
|
260
|
-
'
|
|
357
|
+
'Show this price to the person. Only if they say yes, call panal_hire with that quote_id ' +
|
|
358
|
+
'and confirmed_by_user: true. This moves real money.',
|
|
261
359
|
].join('\n');
|
|
262
360
|
},
|
|
263
361
|
},
|
|
362
|
+
{
|
|
363
|
+
name: 'panal_ask',
|
|
364
|
+
description: 'Pay an agent for ONE question and get its answer in the same call (x402). Requires a quote_id from ' +
|
|
365
|
+
'panal_quote_ask and an explicit yes from the person. This spends real money. Unlike panal_hire ' +
|
|
366
|
+
'there is no escrow, no deadline and no dispute: once paid, it is paid.',
|
|
367
|
+
inputSchema: {
|
|
368
|
+
type: 'object',
|
|
369
|
+
properties: {
|
|
370
|
+
quote_id: { type: 'string', description: 'The id returned by panal_quote_ask.' },
|
|
371
|
+
confirmed_by_user: {
|
|
372
|
+
type: 'boolean',
|
|
373
|
+
description: 'true only if the person has seen the price and approved it.',
|
|
374
|
+
},
|
|
375
|
+
},
|
|
376
|
+
required: ['quote_id', 'confirmed_by_user'],
|
|
377
|
+
},
|
|
378
|
+
handler: async (args) => {
|
|
379
|
+
const blocked = writesBlockedReason();
|
|
380
|
+
if (blocked)
|
|
381
|
+
return blocked;
|
|
382
|
+
if (args.confirmed_by_user !== true) {
|
|
383
|
+
return 'I will not pay without confirmed_by_user: true. Show the quote to the person and ask first.';
|
|
384
|
+
}
|
|
385
|
+
const id = str(args.quote_id);
|
|
386
|
+
if (!id)
|
|
387
|
+
return 'The quote_id is missing. Ask for a quote with panal_quote_ask first.';
|
|
388
|
+
const redeemed = quotes.redeem(id, 'ask');
|
|
389
|
+
if ('error' in redeemed)
|
|
390
|
+
return redeemed.error;
|
|
391
|
+
const quote = redeemed.quote;
|
|
392
|
+
// Los mismos topes que contratar. Una consulta es barata, pero nada
|
|
393
|
+
// impide encadenar mil: el presupuesto del día es lo que lo impide.
|
|
394
|
+
if (quote.amount > limits.maxPerTaskWei) {
|
|
395
|
+
return (`That question costs ${formatEther(quote.amount)} ${quote.symbol} and this server per-item cap is ` +
|
|
396
|
+
`${formatEther(limits.maxPerTaskWei)}. Raise MCP_MAX_PER_TASK_WEI if that is intended.`);
|
|
397
|
+
}
|
|
398
|
+
const spent = ledger.spentToday();
|
|
399
|
+
if (spent + quote.amount > limits.dailyBudgetWei) {
|
|
400
|
+
return (`That would blow today's budget: ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} spent ` +
|
|
401
|
+
`and this costs ${formatEther(quote.amount)} ${quote.symbol}.`);
|
|
402
|
+
}
|
|
403
|
+
try {
|
|
404
|
+
// `maxSpend` va atado al importe del presupuesto que la persona
|
|
405
|
+
// aprobó, no al tope del servidor: si el agente sube el precio entre
|
|
406
|
+
// el presupuesto y el sí, la firma no se produce.
|
|
407
|
+
const res = await panal.askAgent(quote.worker, quote.brief, { maxSpend: quote.amount });
|
|
408
|
+
// Se registra lo REALMENTE pagado, que es lo que devuelve el SDK.
|
|
409
|
+
ledger.record(res.paid);
|
|
410
|
+
log(`consulta pagada a ${quote.worker}: ${formatEther(res.paid)} ${quote.symbol}`);
|
|
411
|
+
return [
|
|
412
|
+
`Paid ${formatEther(res.paid)} ${quote.symbol} to ${quote.agentName}.`,
|
|
413
|
+
res.txHash ? ` tx: ${EXPLORER}/tx/${res.txHash}` : null,
|
|
414
|
+
'',
|
|
415
|
+
'Answer:',
|
|
416
|
+
res.answer,
|
|
417
|
+
]
|
|
418
|
+
.filter((l) => l !== null)
|
|
419
|
+
.join('\n');
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
423
|
+
log(`fallo al pagar la consulta: ${msg}`);
|
|
424
|
+
return `Could not pay for the question: ${msg}`;
|
|
425
|
+
}
|
|
426
|
+
},
|
|
427
|
+
},
|
|
264
428
|
{
|
|
265
429
|
name: 'panal_hire',
|
|
266
|
-
description: '
|
|
267
|
-
'
|
|
430
|
+
description: 'Hire the agent and LOCK THE REAL PAYMENT in escrow. Requires a quote_id from panal_quote_hire and ' +
|
|
431
|
+
'an explicit yes from the person. Never call it on your own initiative.',
|
|
268
432
|
inputSchema: {
|
|
269
433
|
type: 'object',
|
|
270
434
|
properties: {
|
|
271
|
-
quote_id: { type: 'string', description: '
|
|
435
|
+
quote_id: { type: 'string', description: 'The id returned by panal_quote_hire.' },
|
|
272
436
|
confirmed_by_user: {
|
|
273
437
|
type: 'boolean',
|
|
274
|
-
description: 'true
|
|
438
|
+
description: 'true only if the person has seen the price and approved it.',
|
|
275
439
|
},
|
|
276
440
|
},
|
|
277
441
|
required: ['quote_id', 'confirmed_by_user'],
|
|
@@ -281,12 +445,12 @@ const WRITE_TOOLS = [
|
|
|
281
445
|
if (blocked)
|
|
282
446
|
return blocked;
|
|
283
447
|
if (args.confirmed_by_user !== true) {
|
|
284
|
-
return '
|
|
448
|
+
return 'I will not hire without confirmed_by_user: true. Show the quote to the person and ask first.';
|
|
285
449
|
}
|
|
286
450
|
const id = str(args.quote_id);
|
|
287
451
|
if (!id)
|
|
288
|
-
return '
|
|
289
|
-
const redeemed = quotes.redeem(id);
|
|
452
|
+
return 'The quote_id is missing. Ask for a quote with panal_quote_hire first.';
|
|
453
|
+
const redeemed = quotes.redeem(id, 'hire');
|
|
290
454
|
if ('error' in redeemed)
|
|
291
455
|
return redeemed.error;
|
|
292
456
|
const quote = redeemed.quote;
|
|
@@ -294,63 +458,83 @@ const WRITE_TOOLS = [
|
|
|
294
458
|
// pueden haber pasado minutos y otros encargos.
|
|
295
459
|
const spent = ledger.spentToday();
|
|
296
460
|
if (spent + quote.amount > limits.dailyBudgetWei) {
|
|
297
|
-
return `
|
|
461
|
+
return `Today's budget ran out in the meantime (${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)}).`;
|
|
298
462
|
}
|
|
299
463
|
const deadline = BigInt(Math.floor(Date.now() / 1000) + limits.deadlineHours * 3600);
|
|
300
464
|
try {
|
|
301
465
|
const result = await panal.hire({ agent: quote.worker, brief: quote.brief, deadline });
|
|
302
466
|
ledger.record(result.amount);
|
|
303
467
|
log(`contratada #${result.taskId} a ${quote.worker} por ${formatEther(result.amount)} ${quote.symbol}`);
|
|
468
|
+
// Contratar sin entregar el encargo deja la tarea a medias: el pago
|
|
469
|
+
// bloqueado y el agente esperando un texto que nadie le manda. Se hace
|
|
470
|
+
// aquí, no se le pide al usuario, porque quien está leyendo esto es un
|
|
471
|
+
// modelo en mitad de una conversación y no va a abrir una terminal.
|
|
472
|
+
let entregaBrief;
|
|
473
|
+
if (!quote.botUrl) {
|
|
474
|
+
entregaBrief = 'That agent publishes no endpoint: get the job to them through whatever channel you use.';
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
const firma = await account.signMessage({ message: briefSignMessage(result.taskId) });
|
|
478
|
+
const fallo = await pushBrief(quote.botUrl, result.taskId, quote.brief, account.address, firma);
|
|
479
|
+
if (fallo === null) {
|
|
480
|
+
entregaBrief = `Job delivered to ${quote.botUrl}: the agent is already working on it.`;
|
|
481
|
+
log(`brief #${result.taskId} entregado en ${quote.botUrl}`);
|
|
482
|
+
}
|
|
483
|
+
else {
|
|
484
|
+
entregaBrief =
|
|
485
|
+
`WARNING: the payment is locked but the job did NOT arrive (${fallo}).\n` +
|
|
486
|
+
`The agent cannot start. Retry with panal_send_brief ${result.taskId}, ` +
|
|
487
|
+
`or the payment returns on its own when the deadline passes.`;
|
|
488
|
+
log(`brief #${result.taskId} NO entregado: ${fallo}`);
|
|
489
|
+
}
|
|
490
|
+
}
|
|
304
491
|
return [
|
|
305
|
-
`
|
|
306
|
-
`
|
|
307
|
-
`
|
|
492
|
+
`Hired. Task #${result.taskId} created and payment locked in escrow.`,
|
|
493
|
+
` Agent: ${quote.agentName}`,
|
|
494
|
+
` Amount: ${formatEther(result.amount)} ${quote.symbol}`,
|
|
308
495
|
` tx: ${EXPLORER}/tx/${result.txHash}`,
|
|
309
496
|
'',
|
|
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.',
|
|
497
|
+
entregaBrief,
|
|
313
498
|
'',
|
|
314
|
-
|
|
315
|
-
`Cuando entregue, revisa con panal_get_task ${result.taskId} y aprueba con panal_approve_task.`,
|
|
499
|
+
`When it delivers, check with panal_get_task ${result.taskId} and approve with panal_approve_task.`,
|
|
316
500
|
].join('\n');
|
|
317
501
|
}
|
|
318
502
|
catch (err) {
|
|
319
503
|
const msg = err instanceof Error ? err.message : String(err);
|
|
320
504
|
log(`fallo al contratar: ${msg}`);
|
|
321
|
-
return `
|
|
505
|
+
return `Could not hire: ${msg}`;
|
|
322
506
|
}
|
|
323
507
|
},
|
|
324
508
|
},
|
|
325
509
|
{
|
|
326
510
|
name: 'panal_get_result',
|
|
327
|
-
description: '
|
|
328
|
-
'endpoint
|
|
329
|
-
'
|
|
511
|
+
description: 'Collect the result of a delivered task. The text lives off-chain: it is requested from the agent ' +
|
|
512
|
+
'endpoint by signing as the client, and its hash is checked against the one anchored on-chain. ' +
|
|
513
|
+
'Only works for tasks hired by this server wallet.',
|
|
330
514
|
inputSchema: {
|
|
331
515
|
type: 'object',
|
|
332
|
-
properties: { task_id: { type: 'number', description: 'Id
|
|
516
|
+
properties: { task_id: { type: 'number', description: 'Id of the delivered task.' } },
|
|
333
517
|
required: ['task_id'],
|
|
334
518
|
},
|
|
335
519
|
handler: async (args) => {
|
|
336
520
|
if (!account)
|
|
337
|
-
return '
|
|
521
|
+
return 'Without MCP_PRIVATE_KEY the request for the result cannot be signed.';
|
|
338
522
|
const id = Number(args.task_id);
|
|
339
523
|
if (!Number.isInteger(id) || id < 0)
|
|
340
524
|
return 'El id de la tarea es un entero mayor o igual que cero.';
|
|
341
525
|
const taskId = BigInt(id);
|
|
342
526
|
const task = await panal.getTask(taskId);
|
|
343
527
|
if (task.client.toLowerCase() !== account.address.toLowerCase()) {
|
|
344
|
-
return `
|
|
528
|
+
return `Task #${id} was hired by ${task.client}, not this wallet. Only the client can collect the result.`;
|
|
345
529
|
}
|
|
346
530
|
if (/^0x0+$/.test(task.resultHash)) {
|
|
347
|
-
return `
|
|
531
|
+
return `Task #${id} has no delivered result yet (status: ${TaskStatus[task.status] ?? task.status}).`;
|
|
348
532
|
}
|
|
349
533
|
const agent = await panal.getAgent(task.worker);
|
|
350
534
|
if (!agent.metadata.botUrl) {
|
|
351
|
-
return (`
|
|
352
|
-
`
|
|
353
|
-
'
|
|
535
|
+
return (`Agent ${agent.metadata.name || task.worker} publishes no endpoint, so the result cannot be downloaded ` +
|
|
536
|
+
`here. Its hash is on-chain (${task.resultHash}): ask them through your channel and check that the ` +
|
|
537
|
+
'hash matches.');
|
|
354
538
|
}
|
|
355
539
|
try {
|
|
356
540
|
const signature = await account.signMessage({ message: resultSignMessage(taskId) });
|
|
@@ -360,28 +544,80 @@ const WRITE_TOOLS = [
|
|
|
360
544
|
// on-chain y enseñarte otra distinta.
|
|
361
545
|
const actual = keccak256(toBytes(text));
|
|
362
546
|
if (actual.toLowerCase() !== task.resultHash.toLowerCase()) {
|
|
363
|
-
return (`⚠️
|
|
364
|
-
`
|
|
365
|
-
'
|
|
366
|
-
'
|
|
547
|
+
return (`⚠️ The result the agent serves does NOT match the one anchored on-chain.\n` +
|
|
548
|
+
` expected: ${task.resultHash}\n received: ${actual}\n\n` +
|
|
549
|
+
'Do not approve it: either the agent changed the text after delivering, or someone altered the ' +
|
|
550
|
+
'response. You can open a dispute at https://panal.lat/dashboard.');
|
|
367
551
|
}
|
|
368
|
-
return `
|
|
552
|
+
return `Result of task #${id} (hash verified against the chain):\n\n${text}`;
|
|
369
553
|
}
|
|
370
554
|
catch (err) {
|
|
371
|
-
return `
|
|
555
|
+
return `Could not collect the result: ${err instanceof Error ? err.message : err}`;
|
|
556
|
+
}
|
|
557
|
+
},
|
|
558
|
+
},
|
|
559
|
+
{
|
|
560
|
+
name: 'panal_send_brief',
|
|
561
|
+
description: 'Deliver the job again to an already-hired agent, when the automatic send from panal_hire did not ' +
|
|
562
|
+
'arrive. It costs NO money: the payment is already locked, this only repeats the HTTP delivery. ' +
|
|
563
|
+
'The text must be EXACTLY the one hired, because its hash is on the chain.',
|
|
564
|
+
inputSchema: {
|
|
565
|
+
type: 'object',
|
|
566
|
+
properties: {
|
|
567
|
+
task_id: { type: 'number', description: 'Id of the already-created task.' },
|
|
568
|
+
brief: { type: 'string', description: 'The job, word for word as it was when hired.' },
|
|
569
|
+
},
|
|
570
|
+
required: ['task_id', 'brief'],
|
|
571
|
+
},
|
|
572
|
+
handler: async (args) => {
|
|
573
|
+
const blocked = writesBlockedReason();
|
|
574
|
+
if (blocked)
|
|
575
|
+
return blocked;
|
|
576
|
+
const id = Number(args.task_id);
|
|
577
|
+
if (!Number.isInteger(id) || id < 0)
|
|
578
|
+
return 'I need a valid task id.';
|
|
579
|
+
const brief = str(args.brief);
|
|
580
|
+
if (!brief)
|
|
581
|
+
return 'I need the text of the job.';
|
|
582
|
+
const task = await panal.getTask(BigInt(id));
|
|
583
|
+
if (task.client.toLowerCase() !== account.address.toLowerCase()) {
|
|
584
|
+
return `Task #${id} was not hired by this wallet, so its signature is not valid for delivering the job.`;
|
|
585
|
+
}
|
|
586
|
+
if (task.status !== TaskStatus.Open) {
|
|
587
|
+
return `Task #${id} is ${TaskStatus[task.status]}: it no longer accepts the job.`;
|
|
588
|
+
}
|
|
589
|
+
// Se comprueba aquí antes de molestar al agente: así el error dice que el
|
|
590
|
+
// texto no es el mismo, en vez de un 409 sin contexto desde el endpoint.
|
|
591
|
+
if (keccak256(toBytes(brief)) !== task.taskHash) {
|
|
592
|
+
return [
|
|
593
|
+
'That text is NOT the one hired: its hash does not match the one on the chain.',
|
|
594
|
+
` hired hash: ${task.taskHash}`,
|
|
595
|
+
` your text hash: ${keccak256(toBytes(brief))}`,
|
|
596
|
+
'It must be identical, character for character. One extra space already changes it.',
|
|
597
|
+
].join('\n');
|
|
598
|
+
}
|
|
599
|
+
const agent = await panal.getAgent(task.worker);
|
|
600
|
+
if (!agent.metadata.botUrl)
|
|
601
|
+
return 'That agent publishes no endpoint: there is nowhere to send the job.';
|
|
602
|
+
const firma = await account.signMessage({ message: briefSignMessage(BigInt(id)) });
|
|
603
|
+
const fallo = await pushBrief(agent.metadata.botUrl, BigInt(id), brief, account.address, firma);
|
|
604
|
+
if (fallo === null) {
|
|
605
|
+
log(`brief #${id} entregado (reintento) en ${agent.metadata.botUrl}`);
|
|
606
|
+
return `Job delivered. The agent is already working on task #${id}.`;
|
|
372
607
|
}
|
|
608
|
+
return `It still does not arrive (${fallo}). The payment stays locked and returns on its own when the deadline passes.`;
|
|
373
609
|
},
|
|
374
610
|
},
|
|
375
611
|
{
|
|
376
612
|
name: 'panal_approve_task',
|
|
377
|
-
description: '
|
|
378
|
-
'
|
|
613
|
+
description: 'Approve the delivered result, RELEASE THE PAYMENT to the agent and leave a 1-5 rating. ' +
|
|
614
|
+
'Only after showing the result to the person and letting them decide the score.',
|
|
379
615
|
inputSchema: {
|
|
380
616
|
type: 'object',
|
|
381
617
|
properties: {
|
|
382
618
|
task_id: { type: 'number', description: 'Id de la tarea entregada.' },
|
|
383
|
-
rating: { type: 'number', description: '
|
|
384
|
-
confirmed_by_user: { type: 'boolean', description: 'true
|
|
619
|
+
rating: { type: 'number', description: 'The 1-5 rating the person decided.' },
|
|
620
|
+
confirmed_by_user: { type: 'boolean', description: 'true only if the person approved it.' },
|
|
385
621
|
},
|
|
386
622
|
required: ['task_id', 'rating', 'confirmed_by_user'],
|
|
387
623
|
},
|
|
@@ -390,20 +626,20 @@ const WRITE_TOOLS = [
|
|
|
390
626
|
if (blocked)
|
|
391
627
|
return blocked;
|
|
392
628
|
if (args.confirmed_by_user !== true) {
|
|
393
|
-
return '
|
|
629
|
+
return 'I will not release the payment without confirmed_by_user: true. Show the result to the person first.';
|
|
394
630
|
}
|
|
395
631
|
const id = Number(args.task_id);
|
|
396
632
|
const rating = Number(args.rating);
|
|
397
633
|
if (!Number.isInteger(id) || id < 0)
|
|
398
|
-
return '
|
|
634
|
+
return 'The task id is an integer.';
|
|
399
635
|
if (!Number.isInteger(rating) || rating < 1 || rating > 5)
|
|
400
|
-
return '
|
|
636
|
+
return 'The rating is an integer from 1 to 5.';
|
|
401
637
|
try {
|
|
402
638
|
const hash = await panal.approveTask(BigInt(id), rating);
|
|
403
|
-
return `
|
|
639
|
+
return `Payment released for task #${id} with a ${rating}/5 rating.\n tx: ${EXPLORER}/tx/${hash}`;
|
|
404
640
|
}
|
|
405
641
|
catch (err) {
|
|
406
|
-
return `
|
|
642
|
+
return `Could not approve: ${err instanceof Error ? err.message : err}`;
|
|
407
643
|
}
|
|
408
644
|
},
|
|
409
645
|
},
|
|
@@ -450,7 +686,7 @@ async function handle(req) {
|
|
|
450
686
|
const name = typeof req.params?.name === 'string' ? req.params.name : '';
|
|
451
687
|
const tool = TOOLS.find((t) => t.name === name);
|
|
452
688
|
if (!tool) {
|
|
453
|
-
replyError(req.id, -32602, `
|
|
689
|
+
replyError(req.id, -32602, `There is no tool named "${name}".`);
|
|
454
690
|
return;
|
|
455
691
|
}
|
|
456
692
|
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.3.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.6.1",
|
|
34
34
|
"viem": "^2.21.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|