panal-mcp 0.4.0 → 0.5.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 +18 -0
- package/dist/fetch-result.js +49 -0
- package/dist/server.js +144 -3
- package/package.json +4 -3
package/dist/fetch-result.d.ts
CHANGED
|
@@ -37,5 +37,23 @@ export declare function briefSignMessage(taskId: bigint): string;
|
|
|
37
37
|
* pago YA está bloqueado, así que lo útil es contarlo, no romper.
|
|
38
38
|
*/
|
|
39
39
|
export declare function pushBrief(botUrl: string, taskId: bigint, brief: string, client: string, signature: string): Promise<string | null>;
|
|
40
|
+
/**
|
|
41
|
+
* Los límites que el agente publica en su tarjeta (GET /agent.json).
|
|
42
|
+
*
|
|
43
|
+
* Existe para no descubrir un límite pagando. El encargo se entrega DESPUÉS de
|
|
44
|
+
* crear la tarea en la cadena —la firma va atada al taskId, que hasta entonces
|
|
45
|
+
* no existe—, así que un brief que el agente rechaza deja el pago bloqueado por
|
|
46
|
+
* una tarea que no puede empezar. Preguntando antes, eso se convierte en un
|
|
47
|
+
* presupuesto que no se emite y no cuesta nada.
|
|
48
|
+
*
|
|
49
|
+
* `maxBriefChars: null` significa NO LO SÉ, y no «no hay límite»: los agentes
|
|
50
|
+
* de la plantilla publican una tarjeta distinta que no declara ninguno. Quien
|
|
51
|
+
* llame tiene que poder distinguir las dos cosas, porque tratar «no lo sé» como
|
|
52
|
+
* «no hay tope» es volver a averiguarlo pagando.
|
|
53
|
+
*/
|
|
54
|
+
export declare function parseMaxBriefChars(card: unknown): number | null;
|
|
55
|
+
export declare function fetchAgentLimits(botUrl: string): Promise<{
|
|
56
|
+
maxBriefChars: number | null;
|
|
57
|
+
}>;
|
|
40
58
|
/** Descarga acotada: se corta en cuanto se pasa del tope. */
|
|
41
59
|
export declare function fetchResultText(botUrl: string, taskId: bigint, client: string, signature: string, expira: number): Promise<string>;
|
package/dist/fetch-result.js
CHANGED
|
@@ -87,6 +87,55 @@ export async function pushBrief(botUrl, taskId, brief, client, signature) {
|
|
|
87
87
|
clearTimeout(timer);
|
|
88
88
|
}
|
|
89
89
|
}
|
|
90
|
+
/**
|
|
91
|
+
* Los límites que el agente publica en su tarjeta (GET /agent.json).
|
|
92
|
+
*
|
|
93
|
+
* Existe para no descubrir un límite pagando. El encargo se entrega DESPUÉS de
|
|
94
|
+
* crear la tarea en la cadena —la firma va atada al taskId, que hasta entonces
|
|
95
|
+
* no existe—, así que un brief que el agente rechaza deja el pago bloqueado por
|
|
96
|
+
* una tarea que no puede empezar. Preguntando antes, eso se convierte en un
|
|
97
|
+
* presupuesto que no se emite y no cuesta nada.
|
|
98
|
+
*
|
|
99
|
+
* `maxBriefChars: null` significa NO LO SÉ, y no «no hay límite»: los agentes
|
|
100
|
+
* de la plantilla publican una tarjeta distinta que no declara ninguno. Quien
|
|
101
|
+
* llame tiene que poder distinguir las dos cosas, porque tratar «no lo sé» como
|
|
102
|
+
* «no hay tope» es volver a averiguarlo pagando.
|
|
103
|
+
*/
|
|
104
|
+
export function parseMaxBriefChars(card) {
|
|
105
|
+
const max = card?.endpoints?.postBrief
|
|
106
|
+
?.maxBriefChars;
|
|
107
|
+
// La tarjeta la sirve un desconocido: solo un entero positivo cuenta. Un 0 o
|
|
108
|
+
// un negativo harían imposible cualquier encargo, y eso lo decide el agente
|
|
109
|
+
// cambiando su tope, no mandando basura en un campo.
|
|
110
|
+
return typeof max === 'number' && Number.isInteger(max) && max > 0 ? max : null;
|
|
111
|
+
}
|
|
112
|
+
export async function fetchAgentLimits(botUrl) {
|
|
113
|
+
const desconocido = { maxBriefChars: null };
|
|
114
|
+
let url;
|
|
115
|
+
try {
|
|
116
|
+
const base = await assertPublicUrl(botUrl);
|
|
117
|
+
url = new URL('/agent.json', base);
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
return desconocido;
|
|
121
|
+
}
|
|
122
|
+
const controller = new AbortController();
|
|
123
|
+
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
|
|
124
|
+
try {
|
|
125
|
+
const res = await fetch(url, { signal: controller.signal, redirect: 'error' });
|
|
126
|
+
if (!res.ok)
|
|
127
|
+
return desconocido;
|
|
128
|
+
return { maxBriefChars: parseMaxBriefChars(await res.json()) };
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
// Un agente sin tarjeta, o que tarda, no impide contratarlo: solo deja el
|
|
132
|
+
// límite sin comprobar. Fallar aquí seria peor que el problema.
|
|
133
|
+
return desconocido;
|
|
134
|
+
}
|
|
135
|
+
finally {
|
|
136
|
+
clearTimeout(timer);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
90
139
|
/** Descarga acotada: se corta en cuanto se pasa del tope. */
|
|
91
140
|
export async function fetchResultText(botUrl, taskId, client, signature, expira) {
|
|
92
141
|
const base = await assertPublicUrl(botUrl);
|
package/dist/server.js
CHANGED
|
@@ -30,7 +30,7 @@ import { formatEther, isAddress, keccak256, parseEther, toBytes } from 'viem';
|
|
|
30
30
|
import { privateKeyToAccount } from 'viem/accounts';
|
|
31
31
|
import { NATIVE_CURRENCY, TaskStatus, createPanalClient, } from '@panal/sdk';
|
|
32
32
|
import { QuoteBook, SpendLedger, limitsFromEnv } from './limits.js';
|
|
33
|
-
import { briefSignMessage, expiraEn, fetchResultText, pushBrief, resultSignMessage } from './fetch-result.js';
|
|
33
|
+
import { briefSignMessage, expiraEn, fetchAgentLimits, fetchResultText, pushBrief, resultSignMessage, } from './fetch-result.js';
|
|
34
34
|
const SERVER_NAME = 'panal';
|
|
35
35
|
/**
|
|
36
36
|
* Sale del package.json, no de una constante a mano: la copiada se quedó en
|
|
@@ -106,13 +106,25 @@ function daysSince(ts) {
|
|
|
106
106
|
*
|
|
107
107
|
* Va en la ficha y no en un campo aparte porque quien lee esto elige por su
|
|
108
108
|
* cuenta, y lo que no está delante no se mira.
|
|
109
|
+
*
|
|
110
|
+
* `verificado` tiene TRES estados y aquí se dicen los tres. `undefined` no es
|
|
111
|
+
* un «no»: es que por esta ruta no se ha mirado —la ficha viene de la cadena,
|
|
112
|
+
* que no guarda verificaciones de dominio—, o el agente cambió de endpoint y
|
|
113
|
+
* lo de antes ya no dice nada de la URL nueva. Contarlo como «no verificado»
|
|
114
|
+
* acusaba en falso a agentes que sí lo están, y hacía que dos herramientas del
|
|
115
|
+
* mismo servidor afirmaran lo contrario la una de la otra sobre el mismo
|
|
116
|
+
* agente: marketplace_stats lee la cadena y search_agents el indexador. Una
|
|
117
|
+
* insignia que se contradice consigo misma no sirve para lo único que sirve
|
|
118
|
+
* una insignia, que es decidir.
|
|
109
119
|
*/
|
|
110
120
|
function trustLine(agent) {
|
|
111
121
|
const partes = [];
|
|
112
122
|
if (agent.verificado === true)
|
|
113
123
|
partes.push('domain verified (its endpoint declares this same address)');
|
|
114
|
-
else
|
|
124
|
+
else if (agent.verificado === false)
|
|
115
125
|
partes.push('DOMAIN NOT VERIFIED — the name alone proves nothing about who this is');
|
|
126
|
+
else
|
|
127
|
+
partes.push('domain NOT CHECKED on this listing — it was read from the chain, which does not record verification; the name alone proves nothing about who this is');
|
|
116
128
|
if (agent.nombre && agent.nombre.origen !== 'reclamado') {
|
|
117
129
|
const dias = Math.round(daysSince(agent.nombre.desdeTs));
|
|
118
130
|
if (dias <= 30) {
|
|
@@ -323,12 +335,21 @@ const WRITE_TOOLS = [
|
|
|
323
335
|
const address = account.address;
|
|
324
336
|
const [balance, spent] = [await panal.publicClient.getBalance({ address }), ledger.spentToday()];
|
|
325
337
|
const left = limits.dailyBudgetWei > spent ? limits.dailyBudgetWei - spent : 0n;
|
|
338
|
+
// Lo acreditado en el escrow no aparece en el balance de la wallet: es
|
|
339
|
+
// pull payment, así que un reembolso se queda ahí quieto hasta que se
|
|
340
|
+
// reclama. Sin decirlo, el dinero devuelto es indistinguible del perdido.
|
|
341
|
+
const pending = await panal.getPendingWithdrawal(address).catch(() => 0n);
|
|
326
342
|
return [
|
|
327
343
|
`Wallet: ${address}`,
|
|
328
344
|
`Balance: ${formatEther(balance)} MON`,
|
|
329
345
|
`Per-job cap: ${formatEther(limits.maxPerTaskWei)}`,
|
|
330
346
|
`Today's budget: spent ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} · ${formatEther(left)} left`,
|
|
331
|
-
|
|
347
|
+
pending > 0n
|
|
348
|
+
? `Waiting in the escrow: ${formatEther(pending)} MON — collect it with panal_withdraw`
|
|
349
|
+
: null,
|
|
350
|
+
]
|
|
351
|
+
.filter(Boolean)
|
|
352
|
+
.join('\n');
|
|
332
353
|
},
|
|
333
354
|
},
|
|
334
355
|
{
|
|
@@ -367,6 +388,19 @@ const WRITE_TOOLS = [
|
|
|
367
388
|
return (`That would blow today's budget: ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} spent ` +
|
|
368
389
|
`and this costs ${formatEther(amount)} ${symbol}.`);
|
|
369
390
|
}
|
|
391
|
+
// El tope del brief se pregunta ANTES de presupuestar. Si no cabe, el
|
|
392
|
+
// agente lo rechazaría con un 400 al recibirlo — pero eso ocurre ya con
|
|
393
|
+
// el pago bloqueado, porque el encargo se entrega después de crear la
|
|
394
|
+
// tarea. Aquí no cuesta nada y evita dejar dinero atrapado hasta que
|
|
395
|
+
// vence el plazo.
|
|
396
|
+
if (agent.metadata.botUrl) {
|
|
397
|
+
const { maxBriefChars } = await fetchAgentLimits(agent.metadata.botUrl);
|
|
398
|
+
if (maxBriefChars !== null && brief.length > maxBriefChars) {
|
|
399
|
+
return (`That job does not fit: ${agent.metadata.name || address} accepts ${maxBriefChars} characters ` +
|
|
400
|
+
`and this brief is ${brief.length}. Nothing was quoted and nothing was spent. ` +
|
|
401
|
+
`Shorten it by ${brief.length - maxBriefChars} characters and ask again.`);
|
|
402
|
+
}
|
|
403
|
+
}
|
|
370
404
|
const quote = quotes.issue({
|
|
371
405
|
kind: 'hire',
|
|
372
406
|
worker: agent.address,
|
|
@@ -676,6 +710,113 @@ const WRITE_TOOLS = [
|
|
|
676
710
|
}
|
|
677
711
|
},
|
|
678
712
|
},
|
|
713
|
+
{
|
|
714
|
+
name: 'panal_cancel_task',
|
|
715
|
+
description: 'Cancel a job that never started and get the locked payment back. Only works on a task that was ' +
|
|
716
|
+
'never delivered, and only once its deadline has passed (or no agent was ever assigned). ' +
|
|
717
|
+
'The refund is CREDITED, not sent: call panal_withdraw afterwards to actually receive it.',
|
|
718
|
+
inputSchema: {
|
|
719
|
+
type: 'object',
|
|
720
|
+
properties: {
|
|
721
|
+
task_id: { type: 'number', description: 'Id of the task to cancel.' },
|
|
722
|
+
confirmed_by_user: { type: 'boolean', description: 'true only if the person asked to cancel it.' },
|
|
723
|
+
},
|
|
724
|
+
required: ['task_id', 'confirmed_by_user'],
|
|
725
|
+
},
|
|
726
|
+
handler: async (args) => {
|
|
727
|
+
const blocked = writesBlockedReason();
|
|
728
|
+
if (blocked)
|
|
729
|
+
return blocked;
|
|
730
|
+
if (args.confirmed_by_user !== true) {
|
|
731
|
+
return 'I will not cancel a job without confirmed_by_user: true.';
|
|
732
|
+
}
|
|
733
|
+
const id = Number(args.task_id);
|
|
734
|
+
if (!Number.isInteger(id) || id < 0)
|
|
735
|
+
return 'The task id is an integer.';
|
|
736
|
+
try {
|
|
737
|
+
const hash = await panal.cancelTask(BigInt(id));
|
|
738
|
+
return [
|
|
739
|
+
`Task #${id} cancelled and the payment refunded.`,
|
|
740
|
+
` tx: ${EXPLORER}/tx/${hash}`,
|
|
741
|
+
'',
|
|
742
|
+
'The refund is credited in the escrow, not in the wallet yet: call panal_withdraw to collect it.',
|
|
743
|
+
].join('\n');
|
|
744
|
+
}
|
|
745
|
+
catch (err) {
|
|
746
|
+
return `Could not cancel: ${err instanceof Error ? err.message : err}`;
|
|
747
|
+
}
|
|
748
|
+
},
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
name: 'panal_open_dispute',
|
|
752
|
+
description: 'Dispute a delivered result and STOP the clock. If nobody approves or disputes, the escrow pays ' +
|
|
753
|
+
'the agent on its own 3 days after delivery, with an implicit 5/5 — so doing nothing is not ' +
|
|
754
|
+
'neutral, it pays. An arbitrator then splits the amount; if they do not act in 14 days, the ' +
|
|
755
|
+
'client can be refunded in full. Use it when the delivery is wrong or never arrived.',
|
|
756
|
+
inputSchema: {
|
|
757
|
+
type: 'object',
|
|
758
|
+
properties: {
|
|
759
|
+
task_id: { type: 'number', description: 'Id of the delivered task.' },
|
|
760
|
+
confirmed_by_user: { type: 'boolean', description: 'true only if the person asked to dispute it.' },
|
|
761
|
+
},
|
|
762
|
+
required: ['task_id', 'confirmed_by_user'],
|
|
763
|
+
},
|
|
764
|
+
handler: async (args) => {
|
|
765
|
+
const blocked = writesBlockedReason();
|
|
766
|
+
if (blocked)
|
|
767
|
+
return blocked;
|
|
768
|
+
if (args.confirmed_by_user !== true) {
|
|
769
|
+
return 'I will not open a dispute without confirmed_by_user: true. Show the person the result first.';
|
|
770
|
+
}
|
|
771
|
+
const id = Number(args.task_id);
|
|
772
|
+
if (!Number.isInteger(id) || id < 0)
|
|
773
|
+
return 'The task id is an integer.';
|
|
774
|
+
try {
|
|
775
|
+
const hash = await panal.openDispute(BigInt(id));
|
|
776
|
+
return [
|
|
777
|
+
`Dispute opened on task #${id}. The automatic release is stopped.`,
|
|
778
|
+
` tx: ${EXPLORER}/tx/${hash}`,
|
|
779
|
+
'',
|
|
780
|
+
'An arbitrator decides how to split it. If nobody resolves it within 14 days, the whole amount',
|
|
781
|
+
'can be refunded to the client.',
|
|
782
|
+
].join('\n');
|
|
783
|
+
}
|
|
784
|
+
catch (err) {
|
|
785
|
+
return `Could not open the dispute: ${err instanceof Error ? err.message : err}`;
|
|
786
|
+
}
|
|
787
|
+
},
|
|
788
|
+
},
|
|
789
|
+
{
|
|
790
|
+
name: 'panal_withdraw',
|
|
791
|
+
description: 'Collect what the escrow owes this wallet — refunds from cancelled jobs. The escrow is pull ' +
|
|
792
|
+
'payment: it credits balances and never pushes them, so money sits there until this is called. ' +
|
|
793
|
+
'Call panal_wallet first to see whether there is anything to collect.',
|
|
794
|
+
inputSchema: {
|
|
795
|
+
type: 'object',
|
|
796
|
+
properties: {
|
|
797
|
+
confirmed_by_user: { type: 'boolean', description: 'true only if the person asked to collect it.' },
|
|
798
|
+
},
|
|
799
|
+
required: ['confirmed_by_user'],
|
|
800
|
+
},
|
|
801
|
+
handler: async (args) => {
|
|
802
|
+
const blocked = writesBlockedReason();
|
|
803
|
+
if (blocked)
|
|
804
|
+
return blocked;
|
|
805
|
+
if (args.confirmed_by_user !== true) {
|
|
806
|
+
return 'I will not move funds without confirmed_by_user: true.';
|
|
807
|
+
}
|
|
808
|
+
try {
|
|
809
|
+
const pending = await panal.getPendingWithdrawal(account.address);
|
|
810
|
+
if (pending === 0n)
|
|
811
|
+
return 'The escrow owes this wallet nothing: there is nothing to collect.';
|
|
812
|
+
const hash = await panal.withdraw();
|
|
813
|
+
return `Collected ${formatEther(pending)} MON from the escrow.\n tx: ${EXPLORER}/tx/${hash}`;
|
|
814
|
+
}
|
|
815
|
+
catch (err) {
|
|
816
|
+
return `Could not withdraw: ${err instanceof Error ? err.message : err}`;
|
|
817
|
+
}
|
|
818
|
+
},
|
|
819
|
+
},
|
|
679
820
|
];
|
|
680
821
|
/** Las de escritura se anuncian siempre: si no, el modelo no sabe que existen. */
|
|
681
822
|
const TOOLS = [...READ_TOOLS, ...WRITE_TOOLS];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "panal-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.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.9.0",
|
|
34
34
|
"viem": "^2.21.0"
|
|
35
35
|
},
|
|
36
36
|
"devDependencies": {
|
|
@@ -41,6 +41,7 @@
|
|
|
41
41
|
"build": "tsc -p tsconfig.json",
|
|
42
42
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
43
43
|
"start": "tsx src/server.ts",
|
|
44
|
-
"test": "tsx test/mcp.test.ts"
|
|
44
|
+
"test": "tsx test/mcp.test.ts",
|
|
45
|
+
"test:limites": "tsx test/limites.test.ts"
|
|
45
46
|
}
|
|
46
47
|
}
|