panal-mcp 0.4.1 → 0.5.1

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
@@ -125,10 +125,21 @@ function trustLine(agent) {
125
125
  partes.push('DOMAIN NOT VERIFIED — the name alone proves nothing about who this is');
126
126
  else
127
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');
128
- if (agent.nombre && agent.nombre.origen !== 'reclamado') {
129
- const dias = Math.round(daysSince(agent.nombre.desdeTs));
130
- if (dias <= 30) {
131
- partes.push(`its name "${agent.nombre.nombre}" changed hands ${dias}d ago — reputation stays with the previous owner`);
128
+ // El nombre unico se dice SIEMPRE que lo haya. Es la unica prueba de
129
+ // identidad que vive en la cadena: el nombre del perfil es texto libre y se
130
+ // repite —hay tres direcciones anunciandose como "LexPanal"— mientras que
131
+ // este lo tiene una sola. Y a diferencia del dominio, no depende de que el
132
+ // indexador este vivo para poder afirmarlo.
133
+ if (agent.nombre) {
134
+ partes.push(`holds the unique name "${agent.nombre.nombre}" in PanalNames`);
135
+ // La advertencia solo con origen CONOCIDO y distinto de reclamado. Sin
136
+ // dato no se avisa: `undefined` seria «no lo se», y convertirlo en una
137
+ // acusacion de compra reciente es inventarse la mitad que importa.
138
+ if (agent.nombre.origen && agent.nombre.origen !== 'reclamado') {
139
+ const dias = Math.round(daysSince(agent.nombre.desdeTs));
140
+ if (dias <= 30) {
141
+ partes.push(`its name "${agent.nombre.nombre}" changed hands ${dias}d ago — reputation stays with the previous owner`);
142
+ }
132
143
  }
133
144
  }
134
145
  return ` Trust: ${partes.join('; ')}`;
@@ -335,12 +346,21 @@ const WRITE_TOOLS = [
335
346
  const address = account.address;
336
347
  const [balance, spent] = [await panal.publicClient.getBalance({ address }), ledger.spentToday()];
337
348
  const left = limits.dailyBudgetWei > spent ? limits.dailyBudgetWei - spent : 0n;
349
+ // Lo acreditado en el escrow no aparece en el balance de la wallet: es
350
+ // pull payment, así que un reembolso se queda ahí quieto hasta que se
351
+ // reclama. Sin decirlo, el dinero devuelto es indistinguible del perdido.
352
+ const pending = await panal.getPendingWithdrawal(address).catch(() => 0n);
338
353
  return [
339
354
  `Wallet: ${address}`,
340
355
  `Balance: ${formatEther(balance)} MON`,
341
356
  `Per-job cap: ${formatEther(limits.maxPerTaskWei)}`,
342
357
  `Today's budget: spent ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} · ${formatEther(left)} left`,
343
- ].join('\n');
358
+ pending > 0n
359
+ ? `Waiting in the escrow: ${formatEther(pending)} MON — collect it with panal_withdraw`
360
+ : null,
361
+ ]
362
+ .filter(Boolean)
363
+ .join('\n');
344
364
  },
345
365
  },
346
366
  {
@@ -379,6 +399,19 @@ const WRITE_TOOLS = [
379
399
  return (`That would blow today's budget: ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} spent ` +
380
400
  `and this costs ${formatEther(amount)} ${symbol}.`);
381
401
  }
402
+ // El tope del brief se pregunta ANTES de presupuestar. Si no cabe, el
403
+ // agente lo rechazaría con un 400 al recibirlo — pero eso ocurre ya con
404
+ // el pago bloqueado, porque el encargo se entrega después de crear la
405
+ // tarea. Aquí no cuesta nada y evita dejar dinero atrapado hasta que
406
+ // vence el plazo.
407
+ if (agent.metadata.botUrl) {
408
+ const { maxBriefChars } = await fetchAgentLimits(agent.metadata.botUrl);
409
+ if (maxBriefChars !== null && brief.length > maxBriefChars) {
410
+ return (`That job does not fit: ${agent.metadata.name || address} accepts ${maxBriefChars} characters ` +
411
+ `and this brief is ${brief.length}. Nothing was quoted and nothing was spent. ` +
412
+ `Shorten it by ${brief.length - maxBriefChars} characters and ask again.`);
413
+ }
414
+ }
382
415
  const quote = quotes.issue({
383
416
  kind: 'hire',
384
417
  worker: agent.address,
@@ -688,6 +721,113 @@ const WRITE_TOOLS = [
688
721
  }
689
722
  },
690
723
  },
724
+ {
725
+ name: 'panal_cancel_task',
726
+ description: 'Cancel a job that never started and get the locked payment back. Only works on a task that was ' +
727
+ 'never delivered, and only once its deadline has passed (or no agent was ever assigned). ' +
728
+ 'The refund is CREDITED, not sent: call panal_withdraw afterwards to actually receive it.',
729
+ inputSchema: {
730
+ type: 'object',
731
+ properties: {
732
+ task_id: { type: 'number', description: 'Id of the task to cancel.' },
733
+ confirmed_by_user: { type: 'boolean', description: 'true only if the person asked to cancel it.' },
734
+ },
735
+ required: ['task_id', 'confirmed_by_user'],
736
+ },
737
+ handler: async (args) => {
738
+ const blocked = writesBlockedReason();
739
+ if (blocked)
740
+ return blocked;
741
+ if (args.confirmed_by_user !== true) {
742
+ return 'I will not cancel a job without confirmed_by_user: true.';
743
+ }
744
+ const id = Number(args.task_id);
745
+ if (!Number.isInteger(id) || id < 0)
746
+ return 'The task id is an integer.';
747
+ try {
748
+ const hash = await panal.cancelTask(BigInt(id));
749
+ return [
750
+ `Task #${id} cancelled and the payment refunded.`,
751
+ ` tx: ${EXPLORER}/tx/${hash}`,
752
+ '',
753
+ 'The refund is credited in the escrow, not in the wallet yet: call panal_withdraw to collect it.',
754
+ ].join('\n');
755
+ }
756
+ catch (err) {
757
+ return `Could not cancel: ${err instanceof Error ? err.message : err}`;
758
+ }
759
+ },
760
+ },
761
+ {
762
+ name: 'panal_open_dispute',
763
+ description: 'Dispute a delivered result and STOP the clock. If nobody approves or disputes, the escrow pays ' +
764
+ 'the agent on its own 3 days after delivery, with an implicit 5/5 — so doing nothing is not ' +
765
+ 'neutral, it pays. An arbitrator then splits the amount; if they do not act in 14 days, the ' +
766
+ 'client can be refunded in full. Use it when the delivery is wrong or never arrived.',
767
+ inputSchema: {
768
+ type: 'object',
769
+ properties: {
770
+ task_id: { type: 'number', description: 'Id of the delivered task.' },
771
+ confirmed_by_user: { type: 'boolean', description: 'true only if the person asked to dispute it.' },
772
+ },
773
+ required: ['task_id', 'confirmed_by_user'],
774
+ },
775
+ handler: async (args) => {
776
+ const blocked = writesBlockedReason();
777
+ if (blocked)
778
+ return blocked;
779
+ if (args.confirmed_by_user !== true) {
780
+ return 'I will not open a dispute without confirmed_by_user: true. Show the person the result first.';
781
+ }
782
+ const id = Number(args.task_id);
783
+ if (!Number.isInteger(id) || id < 0)
784
+ return 'The task id is an integer.';
785
+ try {
786
+ const hash = await panal.openDispute(BigInt(id));
787
+ return [
788
+ `Dispute opened on task #${id}. The automatic release is stopped.`,
789
+ ` tx: ${EXPLORER}/tx/${hash}`,
790
+ '',
791
+ 'An arbitrator decides how to split it. If nobody resolves it within 14 days, the whole amount',
792
+ 'can be refunded to the client.',
793
+ ].join('\n');
794
+ }
795
+ catch (err) {
796
+ return `Could not open the dispute: ${err instanceof Error ? err.message : err}`;
797
+ }
798
+ },
799
+ },
800
+ {
801
+ name: 'panal_withdraw',
802
+ description: 'Collect what the escrow owes this wallet — refunds from cancelled jobs. The escrow is pull ' +
803
+ 'payment: it credits balances and never pushes them, so money sits there until this is called. ' +
804
+ 'Call panal_wallet first to see whether there is anything to collect.',
805
+ inputSchema: {
806
+ type: 'object',
807
+ properties: {
808
+ confirmed_by_user: { type: 'boolean', description: 'true only if the person asked to collect it.' },
809
+ },
810
+ required: ['confirmed_by_user'],
811
+ },
812
+ handler: async (args) => {
813
+ const blocked = writesBlockedReason();
814
+ if (blocked)
815
+ return blocked;
816
+ if (args.confirmed_by_user !== true) {
817
+ return 'I will not move funds without confirmed_by_user: true.';
818
+ }
819
+ try {
820
+ const pending = await panal.getPendingWithdrawal(account.address);
821
+ if (pending === 0n)
822
+ return 'The escrow owes this wallet nothing: there is nothing to collect.';
823
+ const hash = await panal.withdraw();
824
+ return `Collected ${formatEther(pending)} MON from the escrow.\n tx: ${EXPLORER}/tx/${hash}`;
825
+ }
826
+ catch (err) {
827
+ return `Could not withdraw: ${err instanceof Error ? err.message : err}`;
828
+ }
829
+ },
830
+ },
691
831
  ];
692
832
  /** Las de escritura se anuncian siempre: si no, el modelo no sabe que existen. */
693
833
  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.1",
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.10.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
  }