panal-mcp 0.2.0 → 0.3.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.
@@ -10,8 +10,18 @@
10
10
  * La URL sale del metadata on-chain, o sea que la escribe un desconocido: se
11
11
  * valida antes de pedirle nada, y la respuesta se lee con un tope de tamaño.
12
12
  */
13
- /** El mensaje que firma el cliente. Debe coincidir con el bot y el frontend. */
14
- export declare function resultSignMessage(taskId: bigint): string;
13
+ /** Cuánto vale una firma de descarga: abre toda la entrega, así que poco. */
14
+ export declare const VENTANA_FIRMA_S: number;
15
+ /**
16
+ * El mensaje que firma el cliente. Debe coincidir con el bot y el frontend.
17
+ *
18
+ * Lleva la caducidad dentro: cambiarla invalida la firma. Antes no caducaba, y
19
+ * como además viajaba en la query acababa en el log del proxy — un pase de
20
+ * acceso permanente escrito en un archivo de texto.
21
+ */
22
+ export declare function resultSignMessage(taskId: bigint, expira: number): string;
23
+ /** El segundo en el que caduca una firma emitida ahora. */
24
+ export declare function expiraEn(ventanaS?: number): number;
15
25
  /** El mensaje que firma el cliente para MANDAR el encargo. Mismo pacto. */
16
26
  export declare function briefSignMessage(taskId: bigint): string;
17
27
  /**
@@ -27,18 +37,5 @@ export declare function briefSignMessage(taskId: bigint): string;
27
37
  * pago YA está bloqueado, así que lo útil es contarlo, no romper.
28
38
  */
29
39
  export declare function pushBrief(botUrl: string, taskId: bigint, brief: string, client: string, signature: string): Promise<string | null>;
30
- /**
31
- * Rechaza una URL que apunte a algo que no sea internet pública.
32
- *
33
- * Sin esto, cualquiera puede registrar un agente cuyo endpoint sea
34
- * `http://169.254.169.254/latest/meta-data/` y usar este servidor para leer las
35
- * credenciales de la máquina donde corre.
36
- *
37
- * Queda una ventana de DNS rebinding: se resuelve aquí y `fetch` vuelve a
38
- * resolver por su cuenta. Cerrarla del todo exige un agente HTTP a medida; el
39
- * riesgo residual es aceptable porque la respuesta solo se compara contra un
40
- * hash y nunca se ejecuta.
41
- */
42
- export declare function assertPublicUrl(raw: string): Promise<URL>;
43
40
  /** Descarga acotada: se corta en cuanto se pasa del tope. */
44
- export declare function fetchResultText(botUrl: string, taskId: bigint, client: string, signature: string): Promise<string>;
41
+ export declare function fetchResultText(botUrl: string, taskId: bigint, client: string, signature: string, expira: number): Promise<string>;
@@ -10,14 +10,29 @@
10
10
  * La URL sale del metadata on-chain, o sea que la escribe un desconocido: se
11
11
  * valida antes de pedirle nada, y la respuesta se lee con un tope de tamaño.
12
12
  */
13
- import { lookup } from 'node:dns/promises';
14
- import { isIP } from 'node:net';
13
+ // El guard de SSRF viene del SDK, no de una copia local. Había dos
14
+ // implementaciones equivalentes de lo mismo —la del SDK y una aquí—, y dos
15
+ // copias de un control de seguridad son una que se queda atrás sin que nadie
16
+ // se entere el día que la otra mejora.
17
+ import { assertPublicUrl } from '@panal/sdk';
15
18
  /** Tope de la respuesta. Sin esto, un endpoint hostil tumba el proceso. */
16
19
  const MAX_BYTES = 512 * 1024;
17
20
  const TIMEOUT_MS = 20_000;
18
- /** El mensaje que firma el cliente. Debe coincidir con el bot y el frontend. */
19
- export function resultSignMessage(taskId) {
20
- return `Panal resultado #${taskId.toString()}`;
21
+ /** Cuánto vale una firma de descarga: abre toda la entrega, así que poco. */
22
+ export const VENTANA_FIRMA_S = 10 * 60;
23
+ /**
24
+ * El mensaje que firma el cliente. Debe coincidir con el bot y el frontend.
25
+ *
26
+ * Lleva la caducidad dentro: cambiarla invalida la firma. Antes no caducaba, y
27
+ * como además viajaba en la query acababa en el log del proxy — un pase de
28
+ * acceso permanente escrito en un archivo de texto.
29
+ */
30
+ export function resultSignMessage(taskId, expira) {
31
+ return `Panal resultado #${taskId.toString()} · ${expira}`;
32
+ }
33
+ /** El segundo en el que caduca una firma emitida ahora. */
34
+ export function expiraEn(ventanaS = VENTANA_FIRMA_S) {
35
+ return Math.floor(Date.now() / 1000) + ventanaS;
21
36
  }
22
37
  /** El mensaje que firma el cliente para MANDAR el encargo. Mismo pacto. */
23
38
  export function briefSignMessage(taskId) {
@@ -72,77 +87,23 @@ export async function pushBrief(botUrl, taskId, brief, client, signature) {
72
87
  clearTimeout(timer);
73
88
  }
74
89
  }
75
- /** ¿Esta IP apunta dentro de nuestra propia red? */
76
- function isPrivateIp(ip) {
77
- if (isIP(ip) === 6) {
78
- const v6 = ip.toLowerCase();
79
- if (v6 === '::1' || v6 === '::')
80
- return true;
81
- if (/^f[cd]/.test(v6) || v6.startsWith('fe80'))
82
- return true;
83
- // IPv4 mapeada: ::ffff:10.0.0.1
84
- const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/.exec(v6);
85
- return mapped ? isPrivateIp(mapped[1]) : false;
86
- }
87
- const [a = 0, b = 0] = ip.split('.').map(Number);
88
- return (a === 0 ||
89
- a === 10 ||
90
- a === 127 ||
91
- (a === 169 && b === 254) || // metadatos de la nube: credenciales
92
- (a === 172 && b >= 16 && b <= 31) ||
93
- (a === 192 && b === 168) ||
94
- (a === 100 && b >= 64 && b <= 127) || // CGNAT
95
- a >= 224);
96
- }
97
- /**
98
- * Rechaza una URL que apunte a algo que no sea internet pública.
99
- *
100
- * Sin esto, cualquiera puede registrar un agente cuyo endpoint sea
101
- * `http://169.254.169.254/latest/meta-data/` y usar este servidor para leer las
102
- * credenciales de la máquina donde corre.
103
- *
104
- * Queda una ventana de DNS rebinding: se resuelve aquí y `fetch` vuelve a
105
- * resolver por su cuenta. Cerrarla del todo exige un agente HTTP a medida; el
106
- * riesgo residual es aceptable porque la respuesta solo se compara contra un
107
- * hash y nunca se ejecuta.
108
- */
109
- export async function assertPublicUrl(raw) {
110
- let url;
111
- try {
112
- url = new URL(raw);
113
- }
114
- catch {
115
- throw new Error(`The agent endpoint is not a valid URL: ${raw}`);
116
- }
117
- if (url.protocol !== 'https:') {
118
- // La petición lleva una firma del cliente en la query: en claro la lee cualquiera.
119
- throw new Error(`The agent endpoint must be https, and it is ${url.protocol}//`);
120
- }
121
- if (url.username || url.password)
122
- throw new Error('The endpoint carries embedded credentials: rejected.');
123
- const host = url.hostname.replace(/^\[|\]$/g, '');
124
- if (/^(localhost|.*\.local|.*\.internal)$/i.test(host)) {
125
- throw new Error(`The endpoint points at a local name (${host}): rejected.`);
126
- }
127
- const ips = isIP(host) ? [host] : (await lookup(host, { all: true })).map((r) => r.address);
128
- if (!ips.length)
129
- throw new Error(`Could not resolve ${host}.`);
130
- for (const ip of ips) {
131
- if (isPrivateIp(ip))
132
- throw new Error(`The endpoint points at an internal address (${ip}): rejected.`);
133
- }
134
- return url;
135
- }
136
90
  /** Descarga acotada: se corta en cuanto se pasa del tope. */
137
- export async function fetchResultText(botUrl, taskId, client, signature) {
91
+ export async function fetchResultText(botUrl, taskId, client, signature, expira) {
138
92
  const base = await assertPublicUrl(botUrl);
139
93
  const url = new URL(`/result/${taskId}`, base);
140
- url.searchParams.set('address', client);
141
- url.searchParams.set('signature', signature);
142
94
  const controller = new AbortController();
143
95
  const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
144
96
  try {
145
- const res = await fetch(url, { signal: controller.signal, headers: { accept: 'application/json' } });
97
+ // En cabeceras, no en la query: por ahí acababan en el log del proxy.
98
+ const res = await fetch(url, {
99
+ signal: controller.signal,
100
+ headers: {
101
+ accept: 'application/json',
102
+ 'x-panal-address': client,
103
+ 'x-panal-signature': signature,
104
+ 'x-panal-expira': String(expira),
105
+ },
106
+ });
146
107
  if (!res.ok) {
147
108
  throw new Error(`The agent endpoint replied ${res.status}. Is it still up, and is the signature the client's?`);
148
109
  }
package/dist/limits.d.ts CHANGED
@@ -6,8 +6,19 @@
6
6
  * servidor, y nunca en el prompt: un prompt se puede negociar, un `if` no.
7
7
  */
8
8
  import type { Address } from 'viem';
9
+ /**
10
+ * Las dos maneras de pagar a un agente, y no son intercambiables.
11
+ *
12
+ * `hire` bloquea el pago en el escrow y abre una tarea con plazo, entrega y
13
+ * disputa. `ask` paga una consulta suelta por x402 y se resuelve en la misma
14
+ * llamada HTTP. Los precios son distintos —Spec cobra 100 $PANAL por tarea y
15
+ * 0,5 por consulta—, así que canjear un presupuesto por el camino que no es
16
+ * cobraría doscientas veces de más.
17
+ */
18
+ export type QuoteKind = 'hire' | 'ask';
9
19
  export interface Quote {
10
20
  id: string;
21
+ kind: QuoteKind;
11
22
  worker: Address;
12
23
  agentName: string;
13
24
  brief: string;
@@ -26,8 +37,14 @@ export interface Quote {
26
37
  export declare class QuoteBook {
27
38
  private readonly quotes;
28
39
  issue(data: Omit<Quote, 'id' | 'expiresAt'>): Quote;
29
- /** Devuelve el presupuesto, o el motivo por el que no vale. */
30
- redeem(id: string): {
40
+ /**
41
+ * Devuelve el presupuesto, o el motivo por el que no vale.
42
+ *
43
+ * `kind` no es decorativo: obliga a canjear cada presupuesto por el camino
44
+ * para el que se pidió. Sin esto, el id de una consulta de 0,5 $PANAL se
45
+ * podría canjear como contratación y bloquear los 100 $PANAL de la tarea.
46
+ */
47
+ redeem(id: string, kind: QuoteKind): {
31
48
  quote: Quote;
32
49
  } | {
33
50
  error: string;
package/dist/limits.js CHANGED
@@ -24,14 +24,26 @@ export class QuoteBook {
24
24
  this.quotes.set(quote.id, quote);
25
25
  return quote;
26
26
  }
27
- /** Devuelve el presupuesto, o el motivo por el que no vale. */
28
- redeem(id) {
27
+ /**
28
+ * Devuelve el presupuesto, o el motivo por el que no vale.
29
+ *
30
+ * `kind` no es decorativo: obliga a canjear cada presupuesto por el camino
31
+ * para el que se pidió. Sin esto, el id de una consulta de 0,5 $PANAL se
32
+ * podría canjear como contratación y bloquear los 100 $PANAL de la tarea.
33
+ */
34
+ redeem(id, kind) {
29
35
  this.prune();
30
36
  const quote = this.quotes.get(id);
31
37
  if (!quote) {
32
38
  return {
33
39
  error: 'That quote does not exist or has expired (they last 5 minutes). ' +
34
- 'Ask for a new one with panal_quote_hire and show it to the person before hiring.',
40
+ 'Ask for a new one and show it to the person before spending.',
41
+ };
42
+ }
43
+ if (quote.kind !== kind) {
44
+ return {
45
+ error: `That quote_id is for ${quote.kind === 'ask' ? 'a per-call question (panal_ask)' : 'hiring a job (panal_hire)'}, ` +
46
+ `not for this. The two have different prices; ask for the right quote instead of reusing this id.`,
35
47
  };
36
48
  }
37
49
  // De un solo uso: sin esto, un reintento del modelo contrataría dos veces.
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, fetchResultText, pushBrief, resultSignMessage } from './fetch-result.js';
33
+ import { briefSignMessage, expiraEn, 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
@@ -97,7 +97,10 @@ function renderAgent(agent) {
97
97
  return [
98
98
  `${agent.metadata.name || '(no name)'} — ${agent.address}`,
99
99
  ` Status: ${agent.active ? 'active' : 'DELISTED (does not take jobs)'}`,
100
- ` Price: ${price} per task`,
100
+ // "Per task" y no "Price" a secas: hay dos precios (tarea por escrow y
101
+ // consulta por x402) y llamar "el precio" a uno de ellos fue justo lo que
102
+ // hizo invisible al otro.
103
+ ` Per task: ${price} (escrow, with deadline and dispute window)`,
101
104
  ` Skills: ${skills}`,
102
105
  agent.metadata.description ? ` Description: ${agent.metadata.description}` : null,
103
106
  agent.metadata.botUrl ? ` Endpoint: ${agent.metadata.botUrl}` : null,
@@ -157,7 +160,21 @@ const READ_TOOLS = [
157
160
  const address = str(args.address);
158
161
  if (!address || !isAddress(address))
159
162
  return 'I need a valid 0x address.';
160
- return renderAgent(await panal.getAgent(address));
163
+ const agent = await panal.getAgent(address);
164
+ // El perfil es donde alguien mira el precio, así que aquí sí se paga la
165
+ // llamada al manifiesto del agente: enseñar solo el de tarea era la
166
+ // razón de que el precio por consulta fuese invisible desde aquí. En
167
+ // `panal_search_agents` no se hace, porque serían N llamadas HTTP a
168
+ // servidores ajenos por cada búsqueda.
169
+ let porLlamada;
170
+ try {
171
+ const q = await panal.quoteAgent(address, 'Price check.');
172
+ porLlamada = ` Per question: ${formatEther(BigInt(q.amount))} ${q.assetSymbol ?? symbolOf(q.asset)} (x402, answered on the spot)`;
173
+ }
174
+ catch {
175
+ porLlamada = ' Per question: not offered (this agent only takes jobs through escrow)';
176
+ }
177
+ return `${renderAgent(agent)}\n${porLlamada}`;
161
178
  },
162
179
  },
163
180
  {
@@ -178,6 +195,71 @@ const READ_TOOLS = [
178
195
  return renderTask(await panal.getTask(BigInt(id)));
179
196
  },
180
197
  },
198
+ {
199
+ name: 'panal_quote_ask',
200
+ description: 'Ask an agent what it charges for ONE question, answered on the spot (x402 per-call pricing). ' +
201
+ 'Costs nothing and needs no wallet. This is a DIFFERENT price from the per-task price in the ' +
202
+ 'agent profile: per-task means hiring a job through escrow, with a deadline and a dispute window. ' +
203
+ 'The two can differ by a lot, so quote the one you actually mean to use.',
204
+ inputSchema: {
205
+ type: 'object',
206
+ properties: {
207
+ agent: { type: 'string', description: 'The agent 0x… address.' },
208
+ prompt: { type: 'string', description: 'The question, so the agent can price it if it prices per question.' },
209
+ },
210
+ required: ['agent', 'prompt'],
211
+ },
212
+ handler: async (args) => {
213
+ const address = str(args.agent);
214
+ const prompt = str(args.prompt);
215
+ if (!address || !isAddress(address))
216
+ return 'I need a valid 0x address for the agent.';
217
+ if (!prompt)
218
+ return 'I need the question.';
219
+ const agent = await panal.getAgent(address);
220
+ const name = agent.metadata.name || address;
221
+ let quote;
222
+ try {
223
+ quote = await panal.quoteAgent(address, prompt);
224
+ }
225
+ catch (err) {
226
+ // Cobrar por llamada es opcional: un agente puede trabajar solo por
227
+ // escrow. Se dice cuál es la alternativa en vez de dejarlo en error.
228
+ const msg = err instanceof Error ? err.message : String(err);
229
+ return (`${name} does not answer per-question requests (${msg}).\n` +
230
+ `It can still be hired for a job: ${formatEther(agent.pricePerTask)} ${symbolOf(agent.currency)} ` +
231
+ `per task, via panal_quote_hire.`);
232
+ }
233
+ const amount = BigInt(quote.amount);
234
+ const symbol = quote.assetSymbol ?? symbolOf(quote.asset);
235
+ const saved = quotes.issue({
236
+ kind: 'ask',
237
+ worker: agent.address,
238
+ agentName: name,
239
+ brief: prompt,
240
+ amount,
241
+ currency: quote.asset,
242
+ symbol,
243
+ botUrl: agent.metadata.botUrl,
244
+ });
245
+ return [
246
+ `${name} charges per question:`,
247
+ ` Price: ${formatEther(amount)} ${symbol} for this one question`,
248
+ ` Paid to: ${quote.payTo}`,
249
+ quote.description ? ` Covers: ${quote.description}` : null,
250
+ '',
251
+ `For comparison, hiring it for a job costs ${formatEther(agent.pricePerTask)} ${symbolOf(agent.currency)} ` +
252
+ 'per task (escrow, with deadline and dispute window).',
253
+ '',
254
+ `quote_id: ${saved.id} (valid for 5 minutes)`,
255
+ '',
256
+ 'Show this price to the person. Only if they say yes, call panal_ask with that quote_id and ' +
257
+ 'confirmed_by_user: true. This moves real money.',
258
+ ]
259
+ .filter((l) => l !== null)
260
+ .join('\n');
261
+ },
262
+ },
181
263
  {
182
264
  name: 'panal_marketplace_stats',
183
265
  description: 'Panal marketplace figures: how many agents exist, how many are active, how many jobs so far.',
@@ -255,6 +337,7 @@ const WRITE_TOOLS = [
255
337
  `and this costs ${formatEther(amount)} ${symbol}.`);
256
338
  }
257
339
  const quote = quotes.issue({
340
+ kind: 'hire',
258
341
  worker: agent.address,
259
342
  agentName: agent.metadata.name || agent.address,
260
343
  brief,
@@ -276,6 +359,72 @@ const WRITE_TOOLS = [
276
359
  ].join('\n');
277
360
  },
278
361
  },
362
+ {
363
+ name: 'panal_ask',
364
+ description: 'Pay an agent for ONE question and get its answer in the same call (x402). Requires a quote_id from ' +
365
+ 'panal_quote_ask and an explicit yes from the person. This spends real money. Unlike panal_hire ' +
366
+ 'there is no escrow, no deadline and no dispute: once paid, it is paid.',
367
+ inputSchema: {
368
+ type: 'object',
369
+ properties: {
370
+ quote_id: { type: 'string', description: 'The id returned by panal_quote_ask.' },
371
+ confirmed_by_user: {
372
+ type: 'boolean',
373
+ description: 'true only if the person has seen the price and approved it.',
374
+ },
375
+ },
376
+ required: ['quote_id', 'confirmed_by_user'],
377
+ },
378
+ handler: async (args) => {
379
+ const blocked = writesBlockedReason();
380
+ if (blocked)
381
+ return blocked;
382
+ if (args.confirmed_by_user !== true) {
383
+ return 'I will not pay without confirmed_by_user: true. Show the quote to the person and ask first.';
384
+ }
385
+ const id = str(args.quote_id);
386
+ if (!id)
387
+ return 'The quote_id is missing. Ask for a quote with panal_quote_ask first.';
388
+ const redeemed = quotes.redeem(id, 'ask');
389
+ if ('error' in redeemed)
390
+ return redeemed.error;
391
+ const quote = redeemed.quote;
392
+ // Los mismos topes que contratar. Una consulta es barata, pero nada
393
+ // impide encadenar mil: el presupuesto del día es lo que lo impide.
394
+ if (quote.amount > limits.maxPerTaskWei) {
395
+ return (`That question costs ${formatEther(quote.amount)} ${quote.symbol} and this server per-item cap is ` +
396
+ `${formatEther(limits.maxPerTaskWei)}. Raise MCP_MAX_PER_TASK_WEI if that is intended.`);
397
+ }
398
+ const spent = ledger.spentToday();
399
+ if (spent + quote.amount > limits.dailyBudgetWei) {
400
+ return (`That would blow today's budget: ${formatEther(spent)} of ${formatEther(limits.dailyBudgetWei)} spent ` +
401
+ `and this costs ${formatEther(quote.amount)} ${quote.symbol}.`);
402
+ }
403
+ try {
404
+ // `maxSpend` va atado al importe del presupuesto que la persona
405
+ // aprobó, no al tope del servidor: si el agente sube el precio entre
406
+ // el presupuesto y el sí, la firma no se produce.
407
+ const res = await panal.askAgent(quote.worker, quote.brief, { maxSpend: quote.amount });
408
+ // Se registra lo REALMENTE pagado, que es lo que devuelve el SDK.
409
+ ledger.record(res.paid);
410
+ log(`consulta pagada a ${quote.worker}: ${formatEther(res.paid)} ${quote.symbol}`);
411
+ return [
412
+ `Paid ${formatEther(res.paid)} ${quote.symbol} to ${quote.agentName}.`,
413
+ res.txHash ? ` tx: ${EXPLORER}/tx/${res.txHash}` : null,
414
+ '',
415
+ 'Answer:',
416
+ res.answer,
417
+ ]
418
+ .filter((l) => l !== null)
419
+ .join('\n');
420
+ }
421
+ catch (err) {
422
+ const msg = err instanceof Error ? err.message : String(err);
423
+ log(`fallo al pagar la consulta: ${msg}`);
424
+ return `Could not pay for the question: ${msg}`;
425
+ }
426
+ },
427
+ },
279
428
  {
280
429
  name: 'panal_hire',
281
430
  description: 'Hire the agent and LOCK THE REAL PAYMENT in escrow. Requires a quote_id from panal_quote_hire and ' +
@@ -301,7 +450,7 @@ const WRITE_TOOLS = [
301
450
  const id = str(args.quote_id);
302
451
  if (!id)
303
452
  return 'The quote_id is missing. Ask for a quote with panal_quote_hire first.';
304
- const redeemed = quotes.redeem(id);
453
+ const redeemed = quotes.redeem(id, 'hire');
305
454
  if ('error' in redeemed)
306
455
  return redeemed.error;
307
456
  const quote = redeemed.quote;
@@ -388,8 +537,10 @@ const WRITE_TOOLS = [
388
537
  'hash matches.');
389
538
  }
390
539
  try {
391
- const signature = await account.signMessage({ message: resultSignMessage(taskId) });
392
- const text = await fetchResultText(agent.metadata.botUrl, taskId, account.address, signature);
540
+ // La firma caduca: abre el resultado y todos los archivos de la tarea.
541
+ const expira = expiraEn();
542
+ const signature = await account.signMessage({ message: resultSignMessage(taskId, expira) });
543
+ const text = await fetchResultText(agent.metadata.botUrl, taskId, account.address, signature, expira);
393
544
  // Lo que importa de todo esto: que el texto sea EXACTAMENTE el que se
394
545
  // ancló. Sin esta comprobación, el agente podría entregar una cosa
395
546
  // on-chain y enseñarte otra distinta.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "panal-mcp",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",
@@ -29,18 +29,19 @@
29
29
  "engines": {
30
30
  "node": ">=20"
31
31
  },
32
+ "scripts": {
33
+ "build": "tsc -p tsconfig.json",
34
+ "typecheck": "tsc -p tsconfig.json --noEmit",
35
+ "start": "tsx src/server.ts",
36
+ "test": "tsx test/mcp.test.ts",
37
+ "prepublishOnly": "npm run build"
38
+ },
32
39
  "dependencies": {
33
- "@panal/sdk": "^0.4.0",
40
+ "@panal/sdk": "workspace:^0.7.0",
34
41
  "viem": "^2.21.0"
35
42
  },
36
43
  "devDependencies": {
37
44
  "typescript": "^5.6.0",
38
45
  "tsx": "^4.19.0"
39
- },
40
- "scripts": {
41
- "build": "tsc -p tsconfig.json",
42
- "typecheck": "tsc -p tsconfig.json --noEmit",
43
- "start": "tsx src/server.ts",
44
- "test": "tsx test/mcp.test.ts"
45
46
  }
46
- }
47
+ }