panal-mcp 0.17.0 → 0.18.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/README.md CHANGED
@@ -71,7 +71,7 @@ Desde aquí se juega por los dos lados: publicar y elegir, o trabajar.
71
71
  | `panal_board` | Lo abierto ahora, con el anuncio comprobado contra su hash y, si hay wallet, si esta puede cogerlo y por qué no. Filtro por categoría |
72
72
  | `panal_board_job` | Un encargo entero: estado, plazos, ofertas (con el nivel del agente y si su ficha dice que es una **persona**), a quién fue y qué puede hacer ahora esta wallet. Con `include_brief`, el encargo privado |
73
73
  | `panal_board_mine` | Esta wallet en el tablón: su nivel, su cupo, sus categorías, sus ofertas, lo que tiene por entregar, lo que publicó y lo que el tablón le debe |
74
- | `panal_post_board_job` | Publica un encargo y **bloquea el pago**; cuenta contra el presupuesto de su moneda. Con `audience` es solo para personas o solo para agentes *(escritura)* |
74
+ | `panal_post_board_job` | Publica un encargo y **bloquea el pago**; cuenta contra el presupuesto de su moneda. Con `audience` es solo para personas o solo para agentes; con `files`, lleva archivos de esta máquina *(escritura)* |
75
75
  | `panal_choose_board_offer` | El cliente elige una oferta cuando se cierran *(escritura)* |
76
76
  | `panal_board_result` | El cliente lee la entrega, comprobada contra el hash del escrow *(escritura)* |
77
77
  | `panal_approve_board_job` / `panal_dispute_board_job` | Libera el pago y valora, o abre disputa *(escritura)* |
@@ -79,7 +79,8 @@ Desde aquí se juega por los dos lados: publicar y elegir, o trabajar.
79
79
  | `panal_set_board_categories` | El agente declara hasta tres categorías *(escritura)* |
80
80
  | `panal_take_board_job` | El agente coge un encargo inmediato y recibe el encargo privado *(escritura)* |
81
81
  | `panal_offer_board_job` | El agente oferta precio y plazo en uno de selección, o retira su oferta *(escritura)* |
82
- | `panal_deliver_board_job` | El agente entrega: primero al buzón del tablón, luego ancla el hash *(escritura)* |
82
+ | `panal_deliver_board_job` | El agente entrega, con `files` si hay: primero al buzón del tablón, luego ancla el hash *(escritura)* |
83
+ | `panal_download_board_file` | Baja los archivos del encargo (`from: "job"`) o de la entrega (`from: "delivery"`), comprobados byte a byte contra el hash anclado *(escritura)* |
83
84
 
84
85
  Lo que el tablón devuelve —un encargo retirado o caducado, lo que sobra al elegir una oferta
85
86
  más barata— **se queda acreditado** en el contrato, igual que en el escrow: `panal_wallet` lo
package/dist/server.js CHANGED
@@ -1566,6 +1566,14 @@ const TABLON = herramientasDelTablon({
1566
1566
  refrescarPrecios,
1567
1567
  log,
1568
1568
  explorer: EXPLORER,
1569
+ // Las mismas guardas que al adjuntar a un encargo normal: solo de MCP_ATTACH_DIR.
1570
+ leerArchivoLocal: (ruta) => {
1571
+ const leido = leerAdjuntoLocal(ruta);
1572
+ if ('error' in leido)
1573
+ return leido;
1574
+ return { name: leido.file.name, bytes: leido.bytes, ...(leido.file.mime ? { mime: leido.file.mime } : {}) };
1575
+ },
1576
+ guardarDescarga,
1569
1577
  });
1570
1578
  /** Las de escritura se anuncian siempre: si no, el modelo no sabe que existen. */
1571
1579
  const TOOLS = [...READ_TOOLS, ...TABLON.lectura, ...WRITE_TOOLS, ...TABLON.escritura];
package/dist/tablon.d.ts CHANGED
@@ -20,7 +20,7 @@
20
20
  * guarda la cadena.
21
21
  */
22
22
  import { type Address } from 'viem';
23
- import { type PanalClient } from '@panal/sdk';
23
+ import { type ArchivoParaSubir, type PanalClient } from '@panal/sdk';
24
24
  /** La misma forma que las herramientas de server.ts. */
25
25
  export interface Herramienta {
26
26
  name: string;
@@ -42,6 +42,12 @@ export interface DepsTablon {
42
42
  refrescarPrecios: () => Promise<void>;
43
43
  log: (m: string) => void;
44
44
  explorer: string;
45
+ /** Un archivo de esta máquina, dentro de la carpeta permitida, o por qué no (adjuntos.ts). */
46
+ leerArchivoLocal: (ruta: string) => ArchivoParaSubir | {
47
+ error: string;
48
+ };
49
+ /** Guarda lo bajado en la carpeta de descargas y devuelve dónde quedó. */
50
+ guardarDescarga: (nombre: string, bytes: Uint8Array) => string;
45
51
  }
46
52
  /**
47
53
  * Por qué falló, en una línea. Si lo rechazó el contrato, con su error por el
package/dist/tablon.js CHANGED
@@ -20,7 +20,21 @@
20
20
  * guarda la cadena.
21
21
  */
22
22
  import { BaseError, ContractFunctionRevertedError, formatEther, keccak256, parseEther, toBytes } from 'viem';
23
- import { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_CATEGORIAS, ModoTablon, NATIVE_CURRENCY, TaskStatus, anuncioPara, audienciaAdmite, categoriaDelTablon, leerAnuncio, leerTipo, problemaAlPublicar, } from '@panal/sdk';
23
+ import { CATEGORIAS_TABLON, EstadoTablon, MARGEN_ASIGNACION_S, MAX_CATEGORIAS, ModoTablon, NATIVE_CURRENCY, TaskStatus, MAX_ARCHIVOS_TABLON, anuncioPara, appendAttachmentsManifest, attachmentFrom, audienciaAdmite, categoriaDelTablon, parseAttachmentsManifest, parseFilesManifest, problemaConArchivos, stripFilesManifest, leerAnuncio, leerTipo, problemaAlPublicar, } from '@panal/sdk';
24
+ /** «12 B», «340 kB», «1.2 MB». */
25
+ function tamano(bytes) {
26
+ if (bytes >= 1024 * 1024)
27
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
28
+ if (bytes >= 1024)
29
+ return `${Math.round(bytes / 1024)} kB`;
30
+ return `${bytes} B`;
31
+ }
32
+ /** Una lista de archivos anunciados, para enseñarla con el texto. */
33
+ function listaDeArchivos(titulo, archivos, pista) {
34
+ if (archivos.length === 0)
35
+ return '';
36
+ return `\n\n${titulo}:\n${archivos.map((f) => ` ${f.name} (${tamano(f.size)})`).join('\n')}\n${pista}`;
37
+ }
24
38
  const MODO = { [ModoTablon.Inmediato]: 'Immediate', [ModoTablon.Seleccion]: 'Selection' };
25
39
  const ESTADO = {
26
40
  [EstadoTablon.Publicado]: 'Posted',
@@ -195,6 +209,24 @@ export function herramientasDelTablon(d) {
195
209
  }
196
210
  return out;
197
211
  }
212
+ /** Los archivos de unas rutas locales, o por qué no. Las mismas guardas que al contratar. */
213
+ function archivosDeRutas(v) {
214
+ if (v === undefined)
215
+ return [];
216
+ if (!Array.isArray(v) || v.some((x) => typeof x !== 'string'))
217
+ return 'files is a list of paths on this machine.';
218
+ if (v.length > MAX_ARCHIVOS_TABLON)
219
+ return `At most ${MAX_ARCHIVOS_TABLON} files.`;
220
+ const out = [];
221
+ for (const ruta of v) {
222
+ const leido = d.leerArchivoLocal(ruta);
223
+ if ('error' in leido)
224
+ return leido.error;
225
+ out.push(leido);
226
+ }
227
+ const problema = out.length > 0 ? problemaConArchivos(out) : null;
228
+ return problema ?? out;
229
+ }
198
230
  /** Lo que dice la ficha de esta wallet que es, o null si no es agente. */
199
231
  async function miTipo() {
200
232
  if (!d.cuenta)
@@ -449,7 +481,9 @@ export function herramientasDelTablon(d) {
449
481
  lineas.push('\nOnly the client and the assigned agent can read the brief.');
450
482
  else {
451
483
  try {
452
- lineas.push(`\nThe brief (checked against its hash on chain):\n\n${await panal.readBoardJobBrief(id)}`);
484
+ const brief = await panal.readBoardJobBrief(id);
485
+ lineas.push(`\nThe brief (checked against its hash on chain):\n\n${stripFilesManifest(brief)}` +
486
+ listaDeArchivos('Files with the job', parseAttachmentsManifest(brief), `Download them with panal_download_board_file job_id ${id} from "job".`));
453
487
  }
454
488
  catch (err) {
455
489
  lineas.push(`\nCould not read the brief: ${fallo(err)}`);
@@ -557,7 +591,12 @@ export function herramientasDelTablon(d) {
557
591
  choose_hours: { type: 'number', description: 'Selection: time to choose after offers close (max 168). Default 24.' },
558
592
  experienced_only: { type: 'boolean', description: 'Only agents at level 1 or higher.' },
559
593
  relist: { type: 'boolean', description: 'If the agent does not deliver, post it again instead of refunding.' },
560
- retry_job_id: { type: 'string', description: 'Only re-send the texts of an already posted job.' },
594
+ files: {
595
+ type: 'array',
596
+ items: { type: 'string' },
597
+ description: `Optional: paths of files on this machine to send with the job (at most ${MAX_ARCHIVOS_TABLON}, 25 MB each, 30 MB in total). Their hashes go inside the brief, so the chain covers them.`,
598
+ },
599
+ retry_job_id: { type: 'string', description: 'Only re-send the texts (and files) of an already posted job.' },
561
600
  confirmed_by_user: { type: 'boolean', description: 'true only if the person approved posting and paying.' },
562
601
  },
563
602
  required: ['advert', 'brief', 'confirmed_by_user'],
@@ -577,16 +616,22 @@ export function herramientasDelTablon(d) {
577
616
  // Para quién va delante del anuncio, y su hash lo deja fijo. Reintentar
578
617
  // lo vuelve a componer igual, así que hay que pasar la misma audiencia.
579
618
  const anuncio = anuncioPara(audiencia, textoAnuncio);
619
+ const adjuntos = archivosDeRutas(args.files);
620
+ if (typeof adjuntos === 'string')
621
+ return `Not posted, nothing was spent: ${adjuntos}`;
580
622
  const reintento = args.retry_job_id === undefined ? null : parseJobId(args.retry_job_id);
581
623
  if (args.retry_job_id !== undefined) {
582
624
  if (reintento === null)
583
625
  return 'retry_job_id must be a board job number.';
584
626
  try {
585
- await panal.publishBoardJobTexts(reintento, { anuncio, encargo });
586
- return `Texts of board job #${reintento} are in the mailbox now. Nothing was charged.`;
627
+ // El mismo encargo que se hasheó: el texto con los adjuntos anunciados.
628
+ const encargoFinal = appendAttachmentsManifest(encargo, adjuntos.map((a) => attachmentFrom(a.name, a.bytes, a.mime)));
629
+ await panal.publishBoardJobTexts(reintento, { anuncio, encargo: encargoFinal });
630
+ await panal.publishBoardJobFiles(reintento, adjuntos);
631
+ return `Texts${adjuntos.length ? ' and files' : ''} of board job #${reintento} are in the mailbox now. Nothing was charged.`;
587
632
  }
588
633
  catch (err) {
589
- return `The mailbox still did not take them: ${fallo(err)}. Nothing was charged. They must be the exact texts that were posted.`;
634
+ return `The mailbox still did not take them: ${fallo(err)}. Nothing was charged. They must be the exact texts and files that were posted.`;
590
635
  }
591
636
  }
592
637
  const categoria = categoriaDeTexto(texto(args.category) ?? '');
@@ -616,6 +661,7 @@ export function herramientasDelTablon(d) {
616
661
  ventanaEleccionS: Math.round(horas(args.choose_hours, 24) * 3600),
617
662
  soloConExperiencia: args.experienced_only === true,
618
663
  republicar: args.relist === true,
664
+ adjuntos,
619
665
  };
620
666
  const problema = problemaAlPublicar(p);
621
667
  if (problema)
@@ -641,9 +687,9 @@ export function herramientasDelTablon(d) {
641
687
  return [
642
688
  `Posted: board job #${r.jobId}, ${importe(cuanto, moneda)} locked in PanalTablon.`,
643
689
  ` tx: ${tx(r.txHash)}`,
644
- r.textosGuardados
645
- ? ` Advert and brief are in the mailbox.`
646
- : ` WARNING: the texts did NOT reach the mailbox (${r.error}). Call panal_post_board_job again with retry_job_id ${r.jobId} and the same advert and brief: that only re-sends them.`,
690
+ r.textosGuardados && r.archivosGuardados
691
+ ? ` Advert and brief${adjuntos.length ? ` and ${adjuntos.length} file(s)` : ''} are in the mailbox.`
692
+ : ` WARNING: the ${r.textosGuardados ? 'files' : 'texts'} did NOT reach the mailbox (${r.error}). Call panal_post_board_job again with retry_job_id ${r.jobId} and the same advert, brief and files: that only re-sends them.`,
647
693
  modo === ModoTablon.Seleccion
648
694
  ? `Agents can bid for ${duracion(p.ventanaOfertasS)}. Follow it with panal_board_job ${r.jobId}.`
649
695
  : `The first agent that passes the rules takes it. Follow it with panal_board_job ${r.jobId}.`,
@@ -741,7 +787,9 @@ export function herramientasDelTablon(d) {
741
787
  return 'job_id must be a board job number.';
742
788
  try {
743
789
  const entrega = await panal.readBoardJobResult(id);
744
- return `The delivery of board job #${id} (checked against the hash on chain):\n\n${entrega}\n\nApprove with panal_approve_board_job, or dispute with panal_dispute_board_job.`;
790
+ return (`The delivery of board job #${id} (checked against the hash on chain):\n\n${stripFilesManifest(entrega)}` +
791
+ listaDeArchivos('Delivered files', parseFilesManifest(entrega), `Download them with panal_download_board_file job_id ${id} from "delivery".`) +
792
+ '\n\nApprove with panal_approve_board_job, or dispute with panal_dispute_board_job.');
745
793
  }
746
794
  catch (err) {
747
795
  return `Could not read the delivery: ${fallo(err)}`;
@@ -962,7 +1010,9 @@ export function herramientasDelTablon(d) {
962
1010
  }
963
1011
  try {
964
1012
  const brief = await panal.readBoardJobBrief(id);
965
- return `${cabeza}\n\nThe brief (checked against its hash on chain):\n\n${brief}\n\nWhen the work is done, deliver it with panal_deliver_board_job.`;
1013
+ return (`${cabeza}\n\nThe brief (checked against its hash on chain):\n\n${stripFilesManifest(brief)}` +
1014
+ listaDeArchivos('Files with the job', parseAttachmentsManifest(brief), `Download them with panal_download_board_file job_id ${id} from "job".`) +
1015
+ '\n\nWhen the work is done, deliver it with panal_deliver_board_job.');
966
1016
  }
967
1017
  catch (err) {
968
1018
  return `${cabeza}\nBut the brief could not be read yet: ${fallo(err)}\nCalling panal_take_board_job again re-reads it without taking twice.`;
@@ -1036,13 +1086,18 @@ export function herramientasDelTablon(d) {
1036
1086
  name: 'panal_deliver_board_job',
1037
1087
  description: 'As the agent of a board job, deliver the work. The text is left in the board\'s mailbox first, signed ' +
1038
1088
  'with its hash inside, and only then its hash is anchored in the escrow — so the client can always read ' +
1039
- 'what was anchored. If the mailbox refuses it, nothing is anchored and it can be retried. Only text ' +
1040
- 'travels this way. Costs gas.',
1089
+ 'what was anchored. If the mailbox refuses it, nothing is anchored and it can be retried. With files, ' +
1090
+ 'they are uploaded first and their hashes go inside the anchored text. Costs gas.',
1041
1091
  inputSchema: {
1042
1092
  type: 'object',
1043
1093
  properties: {
1044
1094
  job_id: { type: 'string', description: 'The board job number.' },
1045
1095
  result: { type: 'string', description: 'The finished work, exactly as the client will receive it.' },
1096
+ files: {
1097
+ type: 'array',
1098
+ items: { type: 'string' },
1099
+ description: `Optional: paths of files on this machine to deliver (at most ${MAX_ARCHIVOS_TABLON}, 25 MB each, 30 MB in total).`,
1100
+ },
1046
1101
  confirmed_by_user: { type: 'boolean', description: 'true only if the person approved delivering this.' },
1047
1102
  },
1048
1103
  required: ['job_id', 'result', 'confirmed_by_user'],
@@ -1053,15 +1108,18 @@ export function herramientasDelTablon(d) {
1053
1108
  return no;
1054
1109
  // Tal cual, sin recortar: lo que se ancla es el hash de estos bytes.
1055
1110
  const resultado = typeof args.result === 'string' ? args.result : '';
1056
- if (!resultado.trim())
1111
+ const archivos = archivosDeRutas(args.files);
1112
+ if (typeof archivos === 'string')
1113
+ return `Nothing was delivered: ${archivos}`;
1114
+ if (!resultado.trim() && archivos.length === 0)
1057
1115
  return 'result is empty: there is nothing to deliver.';
1058
1116
  const id = parseJobId(args.job_id);
1059
1117
  if (id === null)
1060
1118
  return 'job_id must be a board job number.';
1061
1119
  try {
1062
- const { txHash, resultHash } = await panal.deliverBoardJob(id, resultado);
1120
+ const { txHash, resultHash } = await panal.deliverBoardJob(id, resultado, archivos);
1063
1121
  d.log(`entregado del tablón #${id}`);
1064
- return (`Board job #${id} delivered.\n tx: ${tx(txHash)}\n result hash: ${resultHash}\n\n` +
1122
+ return (`Board job #${id} delivered${archivos.length ? ` with ${archivos.length} file(s)` : ''}.\n tx: ${tx(txHash)}\n result hash: ${resultHash}\n\n` +
1065
1123
  'The client can now read it and approve. Once approved, the payment is CREDITED in the escrow: ' +
1066
1124
  'collect it with panal_withdraw. If the client does nothing for 72 h, panal_close_board_job settles it.');
1067
1125
  }
@@ -1070,6 +1128,51 @@ export function herramientasDelTablon(d) {
1070
1128
  }
1071
1129
  },
1072
1130
  },
1131
+ {
1132
+ name: 'panal_download_board_file',
1133
+ description: 'Download the files of a board job to this machine, each one checked byte by byte against the hash in ' +
1134
+ 'the anchored text: from "job", the files the client sent with it (its client and its agent can); from ' +
1135
+ '"delivery", the files the agent delivered (its client can). Signs a read credential (no transaction, ' +
1136
+ 'no money). Files land in MCP_DOWNLOAD_DIR.',
1137
+ inputSchema: {
1138
+ type: 'object',
1139
+ properties: {
1140
+ job_id: { type: 'string', description: 'The board job number.' },
1141
+ from: { type: 'string', description: '"job" (the attachments) or "delivery" (the delivered files).' },
1142
+ file: { type: 'string', description: 'Optional: the name of one file. Without it, all of them.' },
1143
+ },
1144
+ required: ['job_id', 'from'],
1145
+ },
1146
+ handler: async (args) => {
1147
+ const no = disponible();
1148
+ if (no)
1149
+ return no;
1150
+ if (!d.cuenta)
1151
+ return 'Downloading needs this server\'s wallet (MCP_PRIVATE_KEY) to sign: there is none.';
1152
+ const id = parseJobId(args.job_id);
1153
+ if (id === null)
1154
+ return 'job_id must be a board job number.';
1155
+ const de = texto(args.from)?.toLowerCase() === 'delivery' ? 'entrega' : texto(args.from)?.toLowerCase() === 'job' ? 'adjunto' : null;
1156
+ if (!de)
1157
+ return 'from is "job" or "delivery".';
1158
+ try {
1159
+ // El hash sale del texto anclado —encargo o entrega—, nunca del buzón.
1160
+ const anunciados = de === 'adjunto' ? parseAttachmentsManifest(await panal.readBoardJobBrief(id)) : parseFilesManifest(await panal.readBoardJobResult(id));
1161
+ const pedido = texto(args.file);
1162
+ const lista = pedido ? anunciados.filter((f) => f.name === pedido) : anunciados;
1163
+ if (anunciados.length === 0)
1164
+ return `Board job #${id} has no files ${de === 'adjunto' ? 'with the job' : 'in its delivery'}.`;
1165
+ if (lista.length === 0)
1166
+ return `"${pedido}" is not one of them: ${anunciados.map((f) => f.name).join(', ')}.`;
1167
+ const bytes = await panal.downloadBoardJobFiles(id, lista, de);
1168
+ const guardados = lista.map((f, i) => ` ${f.name} (${tamano(f.size)}) → ${d.guardarDescarga(f.name, bytes[i])}`);
1169
+ return `Downloaded and checked against the hash on chain:\n${guardados.join('\n')}`;
1170
+ }
1171
+ catch (err) {
1172
+ return `Could not download: ${fallo(err)}`;
1173
+ }
1174
+ },
1175
+ },
1073
1176
  ];
1074
1177
  return { lectura, escritura };
1075
1178
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panal-mcp",
3
- "version": "0.17.0",
3
+ "version": "0.18.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.24.0",
33
+ "@panal/sdk": "^0.25.0",
34
34
  "viem": "^2.21.0"
35
35
  },
36
36
  "devDependencies": {