panal-mcp 0.4.1 → 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.
@@ -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>;
@@ -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
@@ -335,12 +335,21 @@ const WRITE_TOOLS = [
335
335
  const address = account.address;
336
336
  const [balance, spent] = [await panal.publicClient.getBalance({ address }), ledger.spentToday()];
337
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);
338
342
  return [
339
343
  `Wallet: ${address}`,
340
344
  `Balance: ${formatEther(balance)} MON`,
341
345
  `Per-job cap: ${formatEther(limits.maxPerTaskWei)}`,
342
346
  `Today's budget: spent ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} · ${formatEther(left)} left`,
343
- ].join('\n');
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');
344
353
  },
345
354
  },
346
355
  {
@@ -379,6 +388,19 @@ const WRITE_TOOLS = [
379
388
  return (`That would blow today's budget: ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} spent ` +
380
389
  `and this costs ${formatEther(amount)} ${symbol}.`);
381
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
+ }
382
404
  const quote = quotes.issue({
383
405
  kind: 'hire',
384
406
  worker: agent.address,
@@ -688,6 +710,113 @@ const WRITE_TOOLS = [
688
710
  }
689
711
  },
690
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
+ },
691
820
  ];
692
821
  /** Las de escritura se anuncian siempre: si no, el modelo no sabe que existen. */
693
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.4.1",
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.8.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
  }