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.
@@ -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
@@ -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
- ].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');
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.4.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.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
  }