ptn-mcp 1.3.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/build/index.js ADDED
@@ -0,0 +1,1563 @@
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { pathToFileURL } from "url";
6
+ import { ptnPost } from "./ptn-http.js";
7
+ import crypto from "crypto";
8
+ const PTN_API_URL = "https://api.ptn.gob.ar";
9
+ const PTN_WEB_URL = "https://busquedadictamenes.ptn.gob.ar";
10
+ const SERVER_VERSION = "1.3.0";
11
+ export const stringOrNumberOptional = z
12
+ .union([z.string(), z.number()])
13
+ .transform((val) => String(val))
14
+ .optional();
15
+ // Custom Zod validators following common pattern (Trace 8)
16
+ export const stringOrNumber = z.union([z.string(), z.number()]).transform((val) => String(val));
17
+ export const dateOptional = z
18
+ .union([z.string(), z.number()])
19
+ .transform((val) => normalizeDateToDDMMYYYY(val))
20
+ .optional();
21
+ export const dateISOOptional = z
22
+ .union([z.string(), z.number()])
23
+ .transform((val) => normalizeDateToISO(val))
24
+ .optional();
25
+ export const arrayOptional = z.array(z.string()).optional();
26
+ export const nonEmptyString = z.string().min(1, "El campo no puede estar vacío");
27
+ export const positiveNumber = z.number().positive("Debe ser un número positivo");
28
+ export const yearValidator = z
29
+ .union([z.string(), z.number()])
30
+ .transform((val) => {
31
+ const year = Number(val);
32
+ if (year < 1900 || year > 2100) {
33
+ throw new Error("Año debe estar entre 1900 y 2100");
34
+ }
35
+ return year;
36
+ });
37
+ // Spanish month name mapping for natural language date parsing
38
+ const spanishMonthMap = {
39
+ enero: 0, febrero: 1, marzo: 2, abril: 3, mayo: 4, junio: 5,
40
+ julio: 6, agosto: 7, septiembre: 8, octubre: 9, noviembre: 10, diciembre: 11,
41
+ ene: 0, feb: 1, mar: 2, abr: 3, may: 4, jun: 5,
42
+ jul: 6, ago: 7, sep: 8, oct: 9, nov: 10, dic: 11
43
+ };
44
+ /**
45
+ * Parse natural language dates following BORA pattern (Trace 5)
46
+ * Supports: Spanish "15 de febrero de 2026", DD/MM/YYYY, ISO YYYY-MM-DD, compact YYYYMMDD
47
+ */
48
+ export function parseNaturalDate(input) {
49
+ const str = String(input).trim();
50
+ if (!str)
51
+ return null;
52
+ // 1. Spanish format: "15 de febrero de 2026" or "15 de feb de 2026"
53
+ const spanishRegex = /^(\d{1,2})\s+de\s+([a-z]+)\s+de(?:l)?\s+(\d{2,4})$/i;
54
+ const matchSpanish = str.match(spanishRegex);
55
+ if (matchSpanish) {
56
+ const day = parseInt(matchSpanish[1], 10);
57
+ const monthName = matchSpanish[2].toLowerCase();
58
+ let year = parseInt(matchSpanish[3], 10);
59
+ // Handle 2-digit years
60
+ if (year < 100) {
61
+ year += year < 50 ? 2000 : 1900;
62
+ }
63
+ const month = spanishMonthMap[monthName];
64
+ if (month !== undefined && day >= 1 && day <= 31) {
65
+ return new Date(year, month, day);
66
+ }
67
+ }
68
+ // 2. DD/MM/YYYY format
69
+ const ddMmYyyyRegex = /^(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})$/;
70
+ const matchDdMmYyyy = str.match(ddMmYyyyRegex);
71
+ if (matchDdMmYyyy) {
72
+ const day = parseInt(matchDdMmYyyy[1], 10);
73
+ const month = parseInt(matchDdMmYyyy[2], 10) - 1;
74
+ const year = parseInt(matchDdMmYyyy[3], 10);
75
+ if (day >= 1 && day <= 31 && month >= 0 && month <= 11) {
76
+ return new Date(year, month, day);
77
+ }
78
+ }
79
+ // 3. ISO format YYYY-MM-DD
80
+ const isoRegex = /^(\d{4})[\/\-](\d{1,2})[\/\-](\d{1,2})$/;
81
+ const matchIso = str.match(isoRegex);
82
+ if (matchIso) {
83
+ const year = parseInt(matchIso[1], 10);
84
+ const month = parseInt(matchIso[2], 10) - 1;
85
+ const day = parseInt(matchIso[3], 10);
86
+ if (day >= 1 && day <= 31 && month >= 0 && month <= 11) {
87
+ return new Date(year, month, day);
88
+ }
89
+ }
90
+ // 4. Compact format YYYYMMDD
91
+ const compactRegex = /^(\d{4})(\d{2})(\d{2})$/;
92
+ const matchCompact = str.match(compactRegex);
93
+ if (matchCompact) {
94
+ const year = parseInt(matchCompact[1], 10);
95
+ const month = parseInt(matchCompact[2], 10) - 1;
96
+ const day = parseInt(matchCompact[3], 10);
97
+ if (day >= 1 && day <= 31 && month >= 0 && month <= 11) {
98
+ return new Date(year, month, day);
99
+ }
100
+ }
101
+ // 5. Fallback to Date.parse()
102
+ const parsed = Date.parse(str);
103
+ if (!isNaN(parsed)) {
104
+ return new Date(parsed);
105
+ }
106
+ return null;
107
+ }
108
+ /**
109
+ * Normalize date input to DD/MM/YYYY format (BORA pattern)
110
+ */
111
+ export function normalizeDateToDDMMYYYY(input) {
112
+ if (!input)
113
+ return "";
114
+ const d = parseNaturalDate(input);
115
+ if (!d)
116
+ return String(input);
117
+ const dd = String(d.getDate()).padStart(2, '0');
118
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
119
+ const yyyy = d.getFullYear();
120
+ return `${dd}/${mm}/${yyyy}`;
121
+ }
122
+ /**
123
+ * Normalize date input to ISO YYYY-MM-DD format
124
+ */
125
+ export function normalizeDateToISO(input) {
126
+ if (!input)
127
+ return "";
128
+ const d = parseNaturalDate(input);
129
+ if (!d)
130
+ return String(input);
131
+ const dd = String(d.getDate()).padStart(2, '0');
132
+ const mm = String(d.getMonth() + 1).padStart(2, '0');
133
+ const yyyy = d.getFullYear();
134
+ return `${yyyy}-${mm}-${dd}`;
135
+ }
136
+ export function parseDdMmYyyyToIso(date) {
137
+ const m = date.trim().match(/^(\d{1,2})\/(\d{1,2})\/(\d{4})$/);
138
+ if (!m)
139
+ return null;
140
+ const [, dd, mm, yyyy] = m;
141
+ return `${yyyy}-${mm.padStart(2, "0")}-${dd.padStart(2, "0")}`;
142
+ }
143
+ function isoNMonthsAgo(months) {
144
+ const d = new Date();
145
+ d.setMonth(d.getMonth() - months);
146
+ const yyyy = d.getFullYear();
147
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
148
+ const dd = String(d.getDate()).padStart(2, "0");
149
+ return `${yyyy}-${mm}-${dd}`;
150
+ }
151
+ export function buildSearchQuery(args) {
152
+ const must = [];
153
+ const page = typeof args.pagina === "number" && args.pagina > 0 ? args.pagina : 1;
154
+ const size = typeof args.pageSize === "number" && args.pageSize > 0 ? args.pageSize : 10;
155
+ const from = Math.max(0, (page - 1) * size);
156
+ if (args.criterio) {
157
+ // If soloDoctrina is true, search only in doctrine field (from research insights)
158
+ // This avoids procedural boilerplate and focuses on core legal principles
159
+ if (args.soloDoctrina) {
160
+ must.push({
161
+ query_string: {
162
+ query: String(args.criterio),
163
+ fields: ["doctrina", "sintesis"],
164
+ default_operator: "AND",
165
+ },
166
+ });
167
+ }
168
+ else {
169
+ must.push({
170
+ query_string: {
171
+ query: String(args.criterio),
172
+ default_operator: "AND",
173
+ },
174
+ });
175
+ }
176
+ }
177
+ if (args.numero)
178
+ must.push({ match: { numero: String(args.numero) } });
179
+ if (args.tomo)
180
+ must.push({ match: { tomo: String(args.tomo) } });
181
+ if (args.paginaRef)
182
+ must.push({ match: { pagina: String(args.paginaRef) } });
183
+ if (args.expediente)
184
+ must.push({ match: { expediente: String(args.expediente) } });
185
+ const organismo = args.organismo ?? args.dependencia;
186
+ if (organismo)
187
+ must.push({ match: { organismo: String(organismo) } });
188
+ const voz = args.voz ?? args.materia;
189
+ if (voz)
190
+ must.push({ match: { voces: String(voz) } });
191
+ if (args.ley) {
192
+ must.push({
193
+ query_string: {
194
+ query: String(args.ley),
195
+ fields: ["leyes", "array_leyes"],
196
+ },
197
+ });
198
+ }
199
+ if (args.anio) {
200
+ must.push({
201
+ range: {
202
+ fecha: {
203
+ gte: `${args.anio}-01-01`,
204
+ lte: `${args.anio}-12-31`,
205
+ },
206
+ },
207
+ });
208
+ }
209
+ const desde = args.fechaDesde ? normalizeDateToISO(String(args.fechaDesde)) : null;
210
+ const hasta = args.fechaHasta ? normalizeDateToISO(String(args.fechaHasta)) : null;
211
+ if (desde || hasta) {
212
+ const range = {};
213
+ if (desde)
214
+ range.gte = desde;
215
+ if (hasta)
216
+ range.lte = hasta;
217
+ must.push({ range: { fecha: range } });
218
+ }
219
+ const query = must.length === 0
220
+ ? { match_all: {} }
221
+ : must.length === 1
222
+ ? must[0]
223
+ : { bool: { must } };
224
+ return { size, from, query };
225
+ }
226
+ export function assertNoElasticsearchError(data, context) {
227
+ if (!data || typeof data !== "object")
228
+ return;
229
+ const err = data.error;
230
+ if (!err)
231
+ return;
232
+ const reason = err.reason || err.type || "consulta invalida";
233
+ throw new Error(`PTN API (${context}): ${reason}`);
234
+ }
235
+ export async function ptnSearch(body, historico = false) {
236
+ const data = await ptnPost(`${PTN_API_URL}/search`, body, { params: { historico } });
237
+ assertNoElasticsearchError(data, "search");
238
+ return data;
239
+ }
240
+ export async function ptnSearchNews(size = 10) {
241
+ const data = await ptnPost(`${PTN_API_URL}/search_news`, {
242
+ size,
243
+ query: { match_all: {} },
244
+ sort: [{ fecha: { order: "desc" } }],
245
+ });
246
+ assertNoElasticsearchError(data, "search_news");
247
+ return data;
248
+ }
249
+ export async function ptnAggregate(field, size, baseQuery) {
250
+ const body = {
251
+ size: 0,
252
+ query: baseQuery ?? { match_all: {} },
253
+ aggs: {
254
+ facet: {
255
+ terms: { field, size: Math.min(Math.max(size, 1), 200) },
256
+ },
257
+ },
258
+ };
259
+ const data = await ptnSearch(body);
260
+ const buckets = data.aggregations?.facet?.buckets;
261
+ return Array.isArray(buckets) ? buckets : [];
262
+ }
263
+ export function extractTextFromHit(hit) {
264
+ const src = hit._source || {};
265
+ const attachments = src.attachments || [];
266
+ const parts = attachments
267
+ .map((a) => a.attachment?.content)
268
+ .filter((c) => Boolean(c && c.trim()));
269
+ return parts.join("\n\n---\n\n");
270
+ }
271
+ export function formatHitSummary(hit) {
272
+ const src = hit._source || {};
273
+ const id = hit._id || "";
274
+ const sintesis = extractTextFromHit(hit).slice(0, 400).replace(/\s+/g, " ").trim();
275
+ return {
276
+ id,
277
+ numero: src.numero,
278
+ fecha: src.fecha,
279
+ tomo: src.tomo,
280
+ pagina: src.pagina,
281
+ expediente: src.expediente,
282
+ organismo: src.organismo,
283
+ voces: src.voces,
284
+ leyes: src.leyes || src.array_leyes,
285
+ sintesis,
286
+ };
287
+ }
288
+ export async function buscarDictamenes(args) {
289
+ const body = buildSearchQuery(args);
290
+ const data = await ptnSearch(body, Boolean(args.historico));
291
+ const hits = data?.hits?.hits || [];
292
+ return {
293
+ total: data?.hits?.total?.value ?? hits.length,
294
+ page: args.pagina ?? 1,
295
+ pageSize: args.pageSize ?? 10,
296
+ data: hits.map(formatHitSummary),
297
+ };
298
+ }
299
+ export async function obtenerDictamenTexto(args) {
300
+ const data = await ptnSearch({
301
+ size: 1,
302
+ query: { ids: { values: [args.idDictamen] } },
303
+ });
304
+ const hit = data?.hits?.hits?.[0];
305
+ if (!hit) {
306
+ return { id: args.idDictamen, texto: "Dictamen no encontrado.", error: "NOT_FOUND" };
307
+ }
308
+ const src = hit._source || {};
309
+ return {
310
+ id: hit._id,
311
+ numero: src.numero,
312
+ fecha: src.fecha,
313
+ tomo: src.tomo,
314
+ pagina: src.pagina,
315
+ expediente: src.expediente,
316
+ organismo: src.organismo,
317
+ voces: src.voces,
318
+ leyes: src.leyes || src.array_leyes,
319
+ texto: extractTextFromHit(hit) || "Texto no disponible en la respuesta de la API.",
320
+ };
321
+ }
322
+ export async function obtenerNovedades(opts = {}) {
323
+ const size = Math.min(Math.max(opts.cantidad ?? 10, 1), 50);
324
+ const data = await ptnSearchNews(size);
325
+ const hits = (data?.hits?.hits || []);
326
+ let filtered = hits;
327
+ if (opts.organismo) {
328
+ const needle = opts.organismo.toLowerCase();
329
+ filtered = filtered.filter((h) => String((h._source ?? {}).organismo ?? "").toLowerCase().includes(needle));
330
+ }
331
+ if (opts.voz) {
332
+ const needle = opts.voz.toLowerCase();
333
+ filtered = filtered.filter((h) => String((h._source ?? {}).voces ?? "").toLowerCase().includes(needle));
334
+ }
335
+ return {
336
+ total: data?.hits?.total?.value ?? hits.length,
337
+ filtrados: filtered.length,
338
+ data: filtered.map(formatHitSummary),
339
+ };
340
+ }
341
+ function renderResultsMarkdown(title, results) {
342
+ let md = `# ${title}\n\n`;
343
+ md += `**Total estimado:** ${results.total}\n\n`;
344
+ if (results.data.length === 0) {
345
+ return md + "No se encontraron dictamenes.";
346
+ }
347
+ results.data.forEach((r, idx) => {
348
+ md += `### ${idx + 1}. Dictamen ${r.numero || "N/A"}\n`;
349
+ md += `* **ID:** \`${r.id}\`\n`;
350
+ if (r.fecha)
351
+ md += `* **Fecha:** ${r.fecha}\n`;
352
+ if (r.tomo)
353
+ md += `* **Tomo/Pagina:** ${r.tomo}${r.pagina ? ` / ${r.pagina}` : ""}\n`;
354
+ if (r.organismo)
355
+ md += `* **Organismo:** ${r.organismo}\n`;
356
+ if (r.voces)
357
+ md += `* **Voces:** ${r.voces}\n`;
358
+ if (r.leyes)
359
+ md += `* **Leyes:** ${JSON.stringify(r.leyes)}\n`;
360
+ if (r.expediente)
361
+ md += `* **Expediente:** ${r.expediente}\n`;
362
+ if (r.sintesis)
363
+ md += `* **Extracto:** ${r.sintesis}...\n`;
364
+ md += `* **Enlace:** [Ver en PTN](${PTN_WEB_URL}/dictamen/${r.id})\n\n`;
365
+ });
366
+ return md;
367
+ }
368
+ function errorContent(prefix, error) {
369
+ const message = error instanceof Error ? error.message : String(error);
370
+ return {
371
+ content: [{ type: "text", text: `${prefix}: ${message}` }],
372
+ isError: true,
373
+ };
374
+ }
375
+ export function registerAllTools(server) {
376
+ server.tool("buscar_dictamenes", "Busqueda AVANZADA en la base oficial de la Procuracion del Tesoro de la Nacion. Aceptara texto libre y/o varios filtros combinados (organismo, voz, ley, tomo/pagina, fechas, expediente). Usar cuando ya se combinan multiples criterios; para busquedas por un solo eje preferir las tools especializadas (buscar_por_organismo / buscar_por_voz / buscar_por_ley / localizar_por_cita).", {
377
+ criterio: z.string().optional().describe("Texto libre, frase exacta o concepto juridico"),
378
+ numero: stringOrNumberOptional.describe("Numero del dictamen"),
379
+ anio: stringOrNumberOptional.describe("Anio del dictamen (filtro por fecha)"),
380
+ tomo: stringOrNumberOptional.describe("Tomo de publicacion (ej. '251')"),
381
+ paginaRef: stringOrNumberOptional.describe("Pagina dentro del tomo (ej. '787')"),
382
+ ley: z.string().optional().describe("Ley mencionada (ej. '24156' o 'Ley 26076')"),
383
+ fechaDesde: z.string().optional().describe("Fecha desde (DD/MM/YYYY)"),
384
+ fechaHasta: z.string().optional().describe("Fecha hasta (DD/MM/YYYY)"),
385
+ materia: z.string().optional().describe("Materia o voz tematica (ej. 'designacion', 'contratacion')"),
386
+ expediente: z.string().optional().describe("Numero de expediente"),
387
+ dependencia: z.string().optional().describe("Organismo dependiente (AFIP, ANSES, CONICET, etc.)"),
388
+ historico: z.boolean().optional().describe("Incluir indice historico ademas del vigente"),
389
+ pagina: z.number().optional().default(1).describe("Pagina de resultados (10 por pagina)"),
390
+ }, async (args) => {
391
+ try {
392
+ const results = await buscarDictamenes(args);
393
+ return {
394
+ content: [
395
+ {
396
+ type: "text",
397
+ text: renderResultsMarkdown("Procuracion del Tesoro de la Nacion - Resultados", results),
398
+ },
399
+ ],
400
+ };
401
+ }
402
+ catch (error) {
403
+ return errorContent("Error al consultar PTN", error);
404
+ }
405
+ });
406
+ // Tool: buscar_por_doctrina
407
+ server.tool("buscar_por_doctrina", "Busca dictámenes restringiendo la búsqueda al campo 'doctrina' (resumen de principios legales) en lugar del texto completo. Evita ruido de boilerplate procedimental. Basado en insights del portal PTN (campo 'Tema / Palabras en la doctrina').", {
408
+ criterio: z.string().describe("Concepto legal o termino a buscar en la doctrina"),
409
+ organismo: z.string().optional().describe("Organismo opcional para acotar"),
410
+ voz: z.string().optional().describe("Voz tematica opcional para acotar"),
411
+ anio: stringOrNumberOptional.describe("Año opcional para acotar"),
412
+ pagina: z.number().optional().default(1).describe("Pagina (10 por pagina)"),
413
+ }, async (args) => {
414
+ try {
415
+ const results = await buscarDictamenes({
416
+ criterio: args.criterio,
417
+ organismo: args.organismo,
418
+ voz: args.voz,
419
+ anio: args.anio,
420
+ pagina: args.pagina,
421
+ soloDoctrina: true, // Search only in doctrine field
422
+ });
423
+ return {
424
+ content: [
425
+ {
426
+ type: "text",
427
+ text: renderResultsMarkdown("Búsqueda en Doctrina (resumen de principios legales)", results),
428
+ },
429
+ ],
430
+ };
431
+ }
432
+ catch (error) {
433
+ return errorContent("Error en búsqueda por doctrina", error);
434
+ }
435
+ });
436
+ server.tool("buscar_por_organismo", "Busca dictamenes filtrando por ORGANISMO solicitante (AFIP, ANSES, CONICET, Ministerio X, etc.). Es la forma mas directa de responder 'que dictamenes hay del organismo X?'. Acepta texto opcional para acotar dentro del organismo.", {
437
+ organismo: z.string().describe("Nombre o fragmento del organismo (ej. 'CONICET', 'Ministerio de Economia', 'AFIP')"),
438
+ criterio: z.string().optional().describe("Texto opcional para acotar dentro del organismo"),
439
+ anio: stringOrNumberOptional.describe("Anio del dictamen"),
440
+ fechaDesde: z.string().optional().describe("Fecha desde (DD/MM/YYYY)"),
441
+ fechaHasta: z.string().optional().describe("Fecha hasta (DD/MM/YYYY)"),
442
+ pagina: z.number().optional().default(1).describe("Pagina (10 por pagina)"),
443
+ }, async (args) => {
444
+ try {
445
+ const results = await buscarDictamenes({
446
+ organismo: args.organismo,
447
+ criterio: args.criterio,
448
+ anio: args.anio,
449
+ fechaDesde: args.fechaDesde,
450
+ fechaHasta: args.fechaHasta,
451
+ pagina: args.pagina,
452
+ });
453
+ return {
454
+ content: [
455
+ {
456
+ type: "text",
457
+ text: renderResultsMarkdown(`Dictamenes del organismo: ${args.organismo}`, results),
458
+ },
459
+ ],
460
+ };
461
+ }
462
+ catch (error) {
463
+ return errorContent("Error al buscar por organismo", error);
464
+ }
465
+ });
466
+ server.tool("buscar_por_voz", "Busca dictamenes filtrando por VOZ TEMATICA / materia (ej. 'designacion transitoria', 'apoderamiento', 'contratacion', 'subsecretaria legal'). Es el filtro tematico equivalente a la columna 'Voces' del portal.", {
467
+ voz: z.string().describe("Voz tematica o materia (ej. 'designacion', 'apoderamiento', 'contratacion')"),
468
+ criterio: z.string().optional().describe("Texto opcional para acotar dentro de la voz"),
469
+ organismo: z.string().optional().describe("Acotar tambien por organismo (opcional)"),
470
+ anio: stringOrNumberOptional.describe("Anio del dictamen"),
471
+ pagina: z.number().optional().default(1).describe("Pagina (10 por pagina)"),
472
+ }, async (args) => {
473
+ try {
474
+ const results = await buscarDictamenes({
475
+ voz: args.voz,
476
+ criterio: args.criterio,
477
+ organismo: args.organismo,
478
+ anio: args.anio,
479
+ pagina: args.pagina,
480
+ });
481
+ return {
482
+ content: [
483
+ {
484
+ type: "text",
485
+ text: renderResultsMarkdown(`Dictamenes por voz tematica: ${args.voz}`, results),
486
+ },
487
+ ],
488
+ };
489
+ }
490
+ catch (error) {
491
+ return errorContent("Error al buscar por voz", error);
492
+ }
493
+ });
494
+ server.tool("buscar_por_ley", "Busca dictamenes que CITEN UNA LEY especifica. Usar cuando la pregunta es 'que dijo la PTN sobre la Ley NNNNN?'. Acepta solo el numero (ej. '24156') o el formato 'Ley 26076'.", {
495
+ ley: z.string().describe("Numero o referencia de la ley (ej. '24156', 'Ley 26076')"),
496
+ criterio: z.string().optional().describe("Texto adicional para acotar"),
497
+ organismo: z.string().optional().describe("Acotar por organismo"),
498
+ anio: stringOrNumberOptional.describe("Acotar por anio"),
499
+ pagina: z.number().optional().default(1).describe("Pagina (10 por pagina)"),
500
+ }, async (args) => {
501
+ try {
502
+ const results = await buscarDictamenes({
503
+ ley: args.ley,
504
+ criterio: args.criterio,
505
+ organismo: args.organismo,
506
+ anio: args.anio,
507
+ pagina: args.pagina,
508
+ });
509
+ return {
510
+ content: [
511
+ {
512
+ type: "text",
513
+ text: renderResultsMarkdown(`Dictamenes que citan la ley: ${args.ley}`, results),
514
+ },
515
+ ],
516
+ };
517
+ }
518
+ catch (error) {
519
+ return errorContent("Error al buscar por ley", error);
520
+ }
521
+ });
522
+ server.tool("localizar_por_cita", "Localiza un dictamen por su CITA bibliografica clasica (Tomo + Pagina, o No de dictamen + Anio). Es el equivalente al formulario de 'Busqueda Avanzada' del portal cuando el usuario ya conoce la cita (ej. 'Tomo 251 Pagina 787' o 'Dictamen 142 de 2026').", {
523
+ tomo: stringOrNumberOptional.describe("Tomo (ej. '251', '336')"),
524
+ paginaRef: stringOrNumberOptional.describe("Pagina dentro del tomo (ej. '787', '142')"),
525
+ numero: stringOrNumberOptional.describe("Numero del dictamen"),
526
+ anio: stringOrNumberOptional.describe("Anio del dictamen (acompana al numero)"),
527
+ }, async (args) => {
528
+ try {
529
+ if (!args.tomo && !args.paginaRef && !args.numero && !args.anio) {
530
+ throw new Error("Provea al menos uno: tomo, paginaRef, numero o anio.");
531
+ }
532
+ const results = await buscarDictamenes({
533
+ tomo: args.tomo,
534
+ paginaRef: args.paginaRef,
535
+ numero: args.numero,
536
+ anio: args.anio,
537
+ pageSize: 5,
538
+ });
539
+ return {
540
+ content: [
541
+ {
542
+ type: "text",
543
+ text: renderResultsMarkdown("Localizacion por cita", results),
544
+ },
545
+ ],
546
+ };
547
+ }
548
+ catch (error) {
549
+ return errorContent("Error en localizacion por cita", error);
550
+ }
551
+ });
552
+ // Tool: localizar_dictamen_automatico
553
+ server.tool("localizar_dictamen_automatico", "Localiza automáticamente un dictamen con información parcial (NormativaPBA pattern: auto-resolution). Permite buscar por combinación de numero, anio, organismo, o voz y devuelve el dictamen más probable. Útil cuando el usuario no tiene el ID exacto.", {
554
+ numero: stringOrNumberOptional.describe("Numero del dictamen (opcional)"),
555
+ anio: stringOrNumberOptional.describe("Anio del dictamen (opcional)"),
556
+ organismo: z.string().optional().describe("Organismo (opcional)"),
557
+ voz: z.string().optional().describe("Voz tematica (opcional)"),
558
+ expediente: z.string().optional().describe("Expediente (opcional)"),
559
+ criterio: z.string().optional().describe("Criterio de texto libre (opcional)"),
560
+ }, async (args) => {
561
+ try {
562
+ // Auto-resolution: at least one parameter must be provided (NormativaPBA pattern Trace 6a)
563
+ if (!args.numero && !args.anio && !args.organismo && !args.voz && !args.expediente && !args.criterio) {
564
+ throw new Error("Debe proporcionar al menos un criterio de búsqueda (numero, anio, organismo, voz, expediente o criterio)");
565
+ }
566
+ // Build search query from partial information (NormativaPBA pattern Trace 6b)
567
+ const searchResults = await buscarDictamenes({
568
+ numero: args.numero,
569
+ anio: args.anio,
570
+ organismo: args.organismo,
571
+ voz: args.voz,
572
+ expediente: args.expediente,
573
+ criterio: args.criterio,
574
+ pageSize: 10,
575
+ pagina: 1,
576
+ });
577
+ let md = `# Localización Automática de Dictamen\n\n`;
578
+ md += `**Criterios de búsqueda:**\n`;
579
+ if (args.numero)
580
+ md += `- Número: ${args.numero}\n`;
581
+ if (args.anio)
582
+ md += `- Año: ${args.anio}\n`;
583
+ if (args.organismo)
584
+ md += `- Organismo: ${args.organismo}\n`;
585
+ if (args.voz)
586
+ md += `- Voz: ${args.voz}\n`;
587
+ if (args.expediente)
588
+ md += `- Expediente: ${args.expediente}\n`;
589
+ if (args.criterio)
590
+ md += `- Criterio: ${args.criterio}\n`;
591
+ md += `\n`;
592
+ if (searchResults.total === 0) {
593
+ md += `❌ No se encontraron dictámenes con los criterios proporcionados.\n`;
594
+ md += `**Sugerencias:**\n`;
595
+ md += `- Verifique la ortografía de los organismos y voces\n`;
596
+ md += `- Intente con menos criterios de búsqueda\n`;
597
+ md += `- Use \`listar_organismos\` o \`listar_voces\` para descubrir valores exactos\n`;
598
+ return { content: [{ type: "text", text: md }] };
599
+ }
600
+ md += `**Total encontrado:** ${searchResults.total}\n\n`;
601
+ // Auto-resolution: if exact match found (single result), provide direct link to text
602
+ if (searchResults.total === 1 && searchResults.data.length === 1) {
603
+ const match = searchResults.data[0];
604
+ md += `✅ **Coincidencia única encontrada**\n\n`;
605
+ md += `### Dictamen Resuelto\n`;
606
+ md += `* **ID:** \`${match.id}\`\n`;
607
+ md += `* **Número:** ${match.numero || "N/A"}\n`;
608
+ if (match.fecha)
609
+ md += `* **Fecha:** ${match.fecha}\n`;
610
+ if (match.organismo)
611
+ md += `* **Organismo:** ${match.organismo}\n`;
612
+ if (match.voces)
613
+ md += `* **Voces:** ${match.voces}\n`;
614
+ if (match.expediente)
615
+ md += `* **Expediente:** ${match.expediente}\n`;
616
+ if (match.sintesis)
617
+ md += `* **Extracto:** ${match.sintesis}...\n`;
618
+ md += `* **Enlace:** [Ver en PTN](${PTN_WEB_URL}/dictamen/${match.id})\n\n`;
619
+ md += `> **Acción recomendada:** Use \`obtener_dictamen_texto\` con ID \`${match.id}\` para obtener el texto completo.\n`;
620
+ }
621
+ else {
622
+ // Multiple results: show all for user to choose
623
+ md += `⚠️ **Múltiples coincidencias encontradas**\n\n`;
624
+ md += `Por favor revise los resultados y seleccione el dictamen correcto:\n\n`;
625
+ searchResults.data.forEach((r, idx) => {
626
+ md += `### ${idx + 1}. Dictamen ${r.numero || "N/A"}\n`;
627
+ md += `* **ID:** \`${r.id}\`\n`;
628
+ if (r.fecha)
629
+ md += `* **Fecha:** ${r.fecha}\n`;
630
+ if (r.organismo)
631
+ md += `* **Organismo:** ${r.organismo}\n`;
632
+ if (r.voces)
633
+ md += `* **Voces:** ${r.voces}\n`;
634
+ if (r.expediente)
635
+ md += `* **Expediente:** ${r.expediente}\n`;
636
+ if (r.sintesis)
637
+ md += `* **Extracto:** ${r.sintesis}...\n`;
638
+ md += `* **Enlace:** [Ver en PTN](${PTN_WEB_URL}/dictamen/${r.id})\n\n`;
639
+ });
640
+ }
641
+ md += `> **Nota:** Esta herramienta utiliza auto-resolución (NormativaPBA pattern) para encontrar el dictamen más probable con información parcial. Verifique siempre el resultado en la fuente oficial.`;
642
+ return {
643
+ content: [{ type: "text", text: md }],
644
+ };
645
+ }
646
+ catch (error) {
647
+ return errorContent("Error en localización automática", error);
648
+ }
649
+ });
650
+ server.tool("obtener_dictamen_texto", "Obtiene el TEXTO COMPLETO de un dictamen por su ID de Elasticsearch (_id devuelto en las busquedas). Usar despues de cualquier tool de busqueda para leer el contenido integro.", {
651
+ idDictamen: z.string().describe("ID del dictamen (campo _id de la API)"),
652
+ }, async (args) => {
653
+ try {
654
+ const detail = await obtenerDictamenTexto(args);
655
+ if (detail.error === "NOT_FOUND") {
656
+ return { content: [{ type: "text", text: detail.texto }], isError: true };
657
+ }
658
+ let md = `# Dictamen ${detail.numero || "N/A"}\n\n`;
659
+ if (detail.fecha)
660
+ md += `**Fecha:** ${detail.fecha}\n`;
661
+ if (detail.tomo)
662
+ md += `**Tomo/Pagina:** ${detail.tomo}${detail.pagina ? ` / ${detail.pagina}` : ""}\n`;
663
+ if (detail.expediente)
664
+ md += `**Expediente:** ${detail.expediente}\n`;
665
+ if (detail.organismo)
666
+ md += `**Organismo:** ${detail.organismo}\n`;
667
+ if (detail.voces)
668
+ md += `**Voces:** ${detail.voces}\n`;
669
+ if (detail.leyes)
670
+ md += `**Leyes:** ${JSON.stringify(detail.leyes)}\n`;
671
+ md += `\n${detail.texto}\n`;
672
+ return { content: [{ type: "text", text: md }] };
673
+ }
674
+ catch (error) {
675
+ return errorContent("Error al obtener texto", error);
676
+ }
677
+ });
678
+ // Tool: obtener_facetas_completas
679
+ server.tool("obtener_facetas_completas", "Obtiene las facetas de navegación completas (organismos y voces) con sus conteos, similar al panel lateral del portal Novedades. Útil para entender la distribución temática y por organismo de los dictámenes recientes.", {
680
+ top: z.number().optional().default(30).describe("Cantidad de resultados por faceta (default 30, máx. 200)"),
681
+ criterio: z.string().optional().describe("Criterio opcional para acotar las facetas a dictámenes que matcheen este texto"),
682
+ }, async (args) => {
683
+ try {
684
+ const top = Math.min(Math.max(args.top || 30, 1), 200);
685
+ const baseQuery = args.criterio
686
+ ? {
687
+ query_string: {
688
+ query: String(args.criterio),
689
+ default_operator: "AND",
690
+ },
691
+ }
692
+ : { match_all: {} };
693
+ // Get both organismos and voces facets concurrently
694
+ const [organismos, voces] = await Promise.all([
695
+ ptnAggregate("organismo.keyword", top, baseQuery),
696
+ ptnAggregate("voces.keyword", top, baseQuery),
697
+ ]);
698
+ let md = `# Facetas de Navegación Completas\n\n`;
699
+ if (args.criterio)
700
+ md += `**Criterio:** ${args.criterio}\n\n`;
701
+ md += `## Organismos (Top ${organismos.length})\n\n`;
702
+ if (organismos.length === 0) {
703
+ md += `No se encontraron organismos.\n\n`;
704
+ }
705
+ else {
706
+ organismos.forEach((org, idx) => {
707
+ md += `${idx + 1}. **${org.key}** (${org.doc_count} dictámenes)\n`;
708
+ });
709
+ }
710
+ md += `\n## Voces Temáticas (Top ${voces.length})\n\n`;
711
+ if (voces.length === 0) {
712
+ md += `No se encontraron voces temáticas.\n\n`;
713
+ }
714
+ else {
715
+ voces.forEach((voz, idx) => {
716
+ md += `${idx + 1}. **${voz.key}** (${voz.doc_count} dictámenes)\n`;
717
+ });
718
+ }
719
+ md += `\n> **Nota:** Estas facetas reflejan la distribución actual de dictámenes en la base de PTN, similar al panel lateral del portal Novedades.`;
720
+ return {
721
+ content: [{ type: "text", text: md }],
722
+ };
723
+ }
724
+ catch (error) {
725
+ return errorContent("Error al obtener facetas completas", error);
726
+ }
727
+ });
728
+ // Tool: buscar_por_ley_enriquecido
729
+ server.tool("buscar_por_ley_enriquecido", "Busca dictámenes que citan una ley específica y retorna información enriquecida con contexto de citación. Basado en la integración Infoleg del portal PTN que detecta referencias a leyes nacionales.", {
730
+ ley: z.string().describe("Número de ley (ej. '26076', '19.549', '24156')"),
731
+ organismo: z.string().optional().describe("Organismo opcional para acotar"),
732
+ anio: stringOrNumberOptional.describe("Año opcional para acotar"),
733
+ pagina: z.number().optional().default(1).describe("Pagina (10 por pagina)"),
734
+ }, async (args) => {
735
+ try {
736
+ const results = await buscarDictamenes({
737
+ ley: args.ley,
738
+ organismo: args.organismo,
739
+ anio: args.anio,
740
+ pagina: args.pagina,
741
+ });
742
+ let md = `# Dictámenes que citan Ley ${args.ley}\n\n`;
743
+ if (args.organismo)
744
+ md += `**Organismo:** ${args.organismo}\n`;
745
+ if (args.anio)
746
+ md += `**Año:** ${args.anio}\n`;
747
+ md += `**Total encontrado:** ${results.total}\n\n`;
748
+ if (results.total === 0) {
749
+ md += `No se encontraron dictámenes que citen esta ley.\n`;
750
+ md += `**Sugerencias:**\n`;
751
+ md += `- Verifique el número de ley (sin 'Ley' prefix)\n`;
752
+ md += `- Intente con formato diferente (ej. '26076' vs '26.076')\n`;
753
+ return { content: [{ type: "text", text: md }] };
754
+ }
755
+ md += `## Resultados\n\n`;
756
+ results.data.forEach((r, idx) => {
757
+ md += `### ${idx + 1}. Dictamen ${r.numero || "N/A"}\n`;
758
+ md += `* **ID:** \`${r.id}\`\n`;
759
+ if (r.fecha)
760
+ md += `* **Fecha:** ${r.fecha}\n`;
761
+ if (r.organismo)
762
+ md += `* **Organismo:** ${r.organismo}\n`;
763
+ if (r.voces)
764
+ md += `* **Voces:** ${r.voces}\n`;
765
+ if (r.leyes && Array.isArray(r.leyes) && r.leyes.length > 0) {
766
+ md += `* **Leyes citadas:** ${r.leyes.join(", ")}\n`;
767
+ }
768
+ if (r.sintesis)
769
+ md += `* **Extracto:** ${r.sintesis}...\n`;
770
+ md += `* **Enlace:** [Ver en PTN](${PTN_WEB_URL}/dictamen/${r.id})\n\n`;
771
+ });
772
+ md += `> **Nota:** Esta herramienta se basa en la integración Infoleg del portal PTN que detecta referencias a leyes nacionales. Verifique siempre el texto completo del dictamen para el contexto exacto de la citación.`;
773
+ return {
774
+ content: [{ type: "text", text: md }],
775
+ };
776
+ }
777
+ catch (error) {
778
+ return errorContent("Error en búsqueda por ley enriquecida", error);
779
+ }
780
+ });
781
+ server.tool("obtener_novedades", "Lista los DICTAMENES MAS RECIENTES publicados (equivalente a 'Novedades de Dictamenes' del portal). Acepta filtros opcionales por organismo o voz para acotar. Incluye facetas con conteos como el portal.", {
782
+ cantidad: z.number().optional().default(10).describe("Cantidad a traer (max. 50)"),
783
+ organismo: z.string().optional().describe("Filtrar las novedades por organismo (substring, case-insensitive)"),
784
+ voz: z.string().optional().describe("Filtrar las novedades por voz tematica (substring, case-insensitive)"),
785
+ incluir_facetas: z.boolean().optional().default(true).describe("Incluir facetas con conteos (organismos y voces)"),
786
+ }, async (args) => {
787
+ try {
788
+ const results = await obtenerNovedades(args);
789
+ let md = `# Novedades de Dictamenes - PTN\n\n`;
790
+ md += `**Total en indice de novedades:** ${results.total}\n`;
791
+ if (args.organismo || args.voz) {
792
+ md += `**Filtrados:** ${results.filtrados} (filtros: ${[args.organismo && `organismo='${args.organismo}'`, args.voz && `voz='${args.voz}'`].filter(Boolean).join(", ")})\n`;
793
+ }
794
+ md += `\n`;
795
+ // Add faceted counts like the portal (from research insights)
796
+ if (args.incluir_facetas !== false) {
797
+ const baseQuery = (args.organismo || args.voz)
798
+ ? {
799
+ query_string: {
800
+ query: [args.organismo, args.voz].filter(Boolean).join(" "),
801
+ default_operator: "AND",
802
+ },
803
+ }
804
+ : { match_all: {} };
805
+ const [organismos, voces] = await Promise.all([
806
+ ptnAggregate("organismo.keyword", 10, baseQuery),
807
+ ptnAggregate("voces.keyword", 10, baseQuery),
808
+ ]);
809
+ md += `## Distribución por Organismo (Top 10)\n`;
810
+ organismos.forEach((org, idx) => {
811
+ md += `${idx + 1}. **${org.key}** (${org.doc_count})\n`;
812
+ });
813
+ md += `\n## Distribución por Voz Temática (Top 10)\n`;
814
+ voces.forEach((voz, idx) => {
815
+ md += `${idx + 1}. **${voz.key}** (${voz.doc_count})\n`;
816
+ });
817
+ md += `\n---\n\n`;
818
+ }
819
+ if (results.data.length === 0) {
820
+ md += "No se encontraron novedades con esos filtros.";
821
+ return { content: [{ type: "text", text: md }] };
822
+ }
823
+ md += `## Dictámenes Recientes\n\n`;
824
+ results.data.forEach((r, idx) => {
825
+ md += `### ${idx + 1}. Dictamen ${r.numero || "N/A"} (${r.fecha || "s/f"})\n`;
826
+ md += `* **ID:** \`${r.id}\`\n`;
827
+ if (r.organismo)
828
+ md += `* **Organismo:** ${r.organismo}\n`;
829
+ if (r.voces)
830
+ md += `* **Voces:** ${r.voces}\n`;
831
+ if (r.tomo)
832
+ md += `* **Tomo/Pagina:** ${r.tomo}${r.pagina ? ` / ${r.pagina}` : ""}\n`;
833
+ if (r.sintesis)
834
+ md += `* **Extracto:** ${r.sintesis}...\n`;
835
+ md += `* **Enlace:** [Ver en PTN](${PTN_WEB_URL}/dictamen/${r.id})\n\n`;
836
+ });
837
+ return { content: [{ type: "text", text: md }] };
838
+ }
839
+ catch (error) {
840
+ return errorContent("Error al obtener novedades", error);
841
+ }
842
+ });
843
+ // Tool: localizar_por_coordenadas_archivisticas
844
+ server.tool("localizar_por_coordenadas_archivisticas", "Localiza un dictamen por sus coordenadas archivísticas exactas (Tomo + Página). Basado en el sistema de archivo físico de la PTN donde los dictámenes están encuadernados en volúmenes cronológicos. Útil para recuperar documentos con OCR deficiente.", {
845
+ tomo: stringOrNumberOptional.describe("Número de tomo (ej. '251', '336')"),
846
+ pagina: stringOrNumberOptional.describe("Número de página dentro del tomo (ej. '787', '149')"),
847
+ }, async (args) => {
848
+ try {
849
+ if (!args.tomo || !args.pagina) {
850
+ throw new Error("Debe proporcionar tanto tomo como página");
851
+ }
852
+ const results = await buscarDictamenes({
853
+ tomo: args.tomo,
854
+ paginaRef: args.pagina,
855
+ pageSize: 5,
856
+ });
857
+ let md = `# Localización por Coordenadas Archivísticas\n\n`;
858
+ md += `**Tomo:** ${args.tomo}\n`;
859
+ md += `**Página:** ${args.pagina}\n`;
860
+ md += `**Total encontrado:** ${results.total}\n\n`;
861
+ if (results.total === 0) {
862
+ md += `No se encontró ningún dictamen con estas coordenadas archivísticas.\n`;
863
+ md += `**Sugerencias:**\n`;
864
+ md += `- Verifique que el número de tomo y página sean correctos\n`;
865
+ md += `- Los volúmenes más recientes pueden no estar aún digitalizados\n`;
866
+ md += `- Use \`localizar_por_cita\` si tiene el número de dictamen\n`;
867
+ return { content: [{ type: "text", text: md }] };
868
+ }
869
+ md += `## Resultados\n\n`;
870
+ results.data.forEach((r, idx) => {
871
+ md += `### ${idx + 1}. Dictamen ${r.numero || "N/A"}\n`;
872
+ md += `* **ID:** \`${r.id}\`\n`;
873
+ if (r.fecha)
874
+ md += `* **Fecha:** ${r.fecha}\n`;
875
+ if (r.organismo)
876
+ md += `* **Organismo:** ${r.organismo}\n`;
877
+ if (r.voces)
878
+ md += `* **Voces:** ${r.voces}\n`;
879
+ if (r.expediente)
880
+ md += `* **Expediente:** ${r.expediente}\n`;
881
+ if (r.sintesis)
882
+ md += `* **Extracto:** ${r.sintesis}...\n`;
883
+ md += `* **Enlace:** [Ver en PTN](${PTN_WEB_URL}/dictamen/${r.id})\n\n`;
884
+ });
885
+ md += `> **Nota:** Este método es útil para documentos con OCR deficiente, ya que las coordenadas físicas (Tomo/Página) son más confiables que la búsqueda de texto completo en documentos históricos escaneados.`;
886
+ return {
887
+ content: [{ type: "text", text: md }],
888
+ };
889
+ }
890
+ catch (error) {
891
+ return errorContent("Error en localización por coordenadas archivísticas", error);
892
+ }
893
+ });
894
+ server.tool("listar_organismos", "Lista el CATALOGO de organismos presentes en la base de la PTN, con conteo de dictamenes por organismo (facets de Elasticsearch). Usar para descubrir nombres exactos antes de filtrar, o para mostrar 'que organismos figuran mas'.", {
895
+ top: z.number().optional().default(30).describe("Cantidad de organismos a listar (max. 200)"),
896
+ criterio: z.string().optional().describe("Opcional: restringir el conteo a dictamenes que matcheen este texto"),
897
+ }, async (args) => {
898
+ try {
899
+ const baseQuery = args.criterio
900
+ ? { query_string: { query: args.criterio, default_operator: "AND" } }
901
+ : undefined;
902
+ const buckets = await ptnAggregate("organismo.keyword", args.top ?? 30, baseQuery);
903
+ let md = `# Organismos en la base PTN\n\n`;
904
+ if (args.criterio)
905
+ md += `**Restringido al criterio:** \`${args.criterio}\`\n\n`;
906
+ md += `**Mostrando top ${buckets.length}.**\n\n`;
907
+ md += `| # | Organismo | Dictamenes |\n|---|---|---|\n`;
908
+ buckets.forEach((b, idx) => {
909
+ md += `| ${idx + 1} | ${b.key} | ${b.doc_count} |\n`;
910
+ });
911
+ return { content: [{ type: "text", text: md }] };
912
+ }
913
+ catch (error) {
914
+ return errorContent("Error al listar organismos", error);
915
+ }
916
+ });
917
+ server.tool("listar_voces", "Lista el CATALOGO de voces tematicas (materias) presentes en la base PTN, con conteo de dictamenes por voz (facets de Elasticsearch). Usar para descubrir voces exactas antes de usar buscar_por_voz.", {
918
+ top: z.number().optional().default(30).describe("Cantidad de voces a listar (max. 200)"),
919
+ criterio: z.string().optional().describe("Opcional: restringir el conteo a dictamenes que matcheen este texto"),
920
+ }, async (args) => {
921
+ try {
922
+ const baseQuery = args.criterio
923
+ ? { query_string: { query: args.criterio, default_operator: "AND" } }
924
+ : undefined;
925
+ const buckets = await ptnAggregate("voces.keyword", args.top ?? 30, baseQuery);
926
+ let md = `# Voces tematicas en la base PTN\n\n`;
927
+ if (args.criterio)
928
+ md += `**Restringido al criterio:** \`${args.criterio}\`\n\n`;
929
+ md += `**Mostrando top ${buckets.length}.**\n\n`;
930
+ md += `| # | Voz / Materia | Dictamenes |\n|---|---|---|\n`;
931
+ buckets.forEach((b, idx) => {
932
+ md += `| ${idx + 1} | ${b.key} | ${b.doc_count} |\n`;
933
+ });
934
+ return { content: [{ type: "text", text: md }] };
935
+ }
936
+ catch (error) {
937
+ return errorContent("Error al listar voces", error);
938
+ }
939
+ });
940
+ server.tool("alcance_fuente", "Informa capacidades, fuentes, limitaciones y disclaimer del conector PTN MCP.", {}, async () => {
941
+ const text = `# Alcance y Fuentes - Procuracion del Tesoro de la Nacion (PTN)
942
+
943
+ ## Datos del Conector
944
+ - **Servidor:** ptn-mcp v${SERVER_VERSION}
945
+ - **Fuente Legal:** Procuracion del Tesoro de la Nacion
946
+ - **Portal:** ${PTN_WEB_URL}
947
+ - **API:** ${PTN_API_URL} (Elasticsearch via POST /search y /search_news)
948
+
949
+ ## Herramientas de busqueda
950
+ - \`buscar_dictamenes\`: busqueda avanzada combinando varios filtros
951
+ - \`buscar_por_organismo\`: shortcut por organismo (AFIP, CONICET, ministerios, etc.)
952
+ - \`buscar_por_voz\`: shortcut por voz/materia (designacion, contratacion, etc.)
953
+ - \`buscar_por_ley\`: dictamenes que citan una ley
954
+ - \`localizar_por_cita\`: lookup por Tomo + Pagina o No + Anio
955
+
956
+ ## Detalle
957
+ - \`obtener_dictamen_texto\`: texto integro por ID (_id)
958
+
959
+ ## Novedades y catalogo
960
+ - \`obtener_novedades\`: ultimos dictamenes publicados (con filtros opcionales)
961
+ - \`listar_organismos\`: catalogo de organismos con conteo (facets)
962
+ - \`listar_voces\`: catalogo de voces tematicas con conteo (facets)
963
+
964
+ ## Metadata
965
+ - \`alcance_fuente\`: este informe
966
+
967
+ ## Prompts asistidos
968
+ - \`auditar_dictamen\`: revisa un dictamen y arma reporte estructurado
969
+ - \`comparar_dictamenes\`: compara 2-3 dictamenes por sus IDs
970
+ - \`resumen_por_organismo\`: panorama de dictamenes de un organismo en N meses
971
+
972
+ ## Limitaciones
973
+ - La API publica no expone endpoints administrativos sin autenticacion
974
+ - Resultados paginados de 10 items por pagina por defecto
975
+ - Las novedades cubren solo los ultimos meses (segun publicacion oficial)
976
+ - Relevancia definida por el indice oficial (no configurable desde el MCP)
977
+
978
+ ## Aviso Legal
979
+ Conector automatizado con fines de investigacion. No constituye asesoramiento juridico profesional.`;
980
+ return { content: [{ type: "text", text }] };
981
+ });
982
+ // Tool: detector_plazos_dictamenes
983
+ server.tool("detector_plazos_dictamenes", "Audita el texto de dictamenes de la PTN para detectar e indexar plazos, fechas límite y hitos temporales relevantes (plazos administrativos, vencimientos, prescripciones). Enhanced with InfoLeg pattern (Trace 3) for comprehensive legal deadline detection.", {
984
+ texto_dictamen: z.string().describe("Texto del dictamen a analizar"),
985
+ }, async (args) => {
986
+ try {
987
+ const text = args.texto_dictamen;
988
+ // Enhanced deadline detection patterns following InfoLeg pattern (Trace 3)
989
+ const patterns = [
990
+ // Numeric deadlines
991
+ { regex: /\b\d+\s+(días?\s+(habiles|corridos|hábiles|laborales)?|meses|años?)\b/i, name: "Plazo numérico" },
992
+ { regex: /\b(plazo|término)\s+de\s+(días?|meses|años?)\b/i, name: "Cláusula de plazo" },
993
+ // Prescription and caducity
994
+ { regex: /\b(prescribe|prescripción)\b/i, name: "Prescripción" },
995
+ { regex: /\b(caduca|caducidad)\b/i, name: "Caducidad" },
996
+ { regex: /\b(vencimiento|mora)\b/i, name: "Vencimiento/Mora" },
997
+ // Date formats
998
+ { regex: /\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{2,4})\b/g, name: "Fecha específica" },
999
+ { regex: /\b(hasta\s+el\s+(?:\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4}|el\s+día\s+\d+))/i, name: "Fecha límite" },
1000
+ // Notification and procedural deadlines
1001
+ { regex: /\b(dentro\s+de\s+(?:los\s+)?\d+\s+(días?|meses|años?))\b/i, name: "Plazo desde notificación" },
1002
+ { regex: /\b(notificar|notificación|citar|citación)\b/i, name: "Notificación/Citación" },
1003
+ { regex: /\b(intervención|interventor|administración)\b/i, name: "Plazo de intervención" },
1004
+ // Additional legal/administrative patterns (InfoLeg enhancement)
1005
+ { regex: /\b(plazo\s+máximo|plazo\s+mínimo)\b/i, name: "Plazo máximo/mínimo" },
1006
+ { regex: /\b(vence|vencimiento|expira|expiración)\b/i, name: "Vencimiento/Expiración" },
1007
+ { regex: /\b(prórroga|prorrogar|extensión)\b/i, name: "Prórroga/Extensión" },
1008
+ { regex: /\b(suspensión|suspender)\b/i, name: "Suspensión" },
1009
+ { regex: /\b(interrupción|interrumpir)\b/i, name: "Interrupción" },
1010
+ { regex: /\b(reinicio|reanudación)\b/i, name: "Reinicio/Reanudación" },
1011
+ { regex: /\b(plazo\s+de\s+gracia|período\s+de\s+gracia)\b/i, name: "Plazo de gracia" },
1012
+ { regex: /\b(plazo\s+legal|plazo\s+normativo)\b/i, name: "Plazo legal/normativo" },
1013
+ { regex: /\b(plazo\s+administrativo|plazo\s+reglamentario)\b/i, name: "Plazo administrativo" },
1014
+ { regex: /\b(término\s+de\s+comparecencia|comparecer)\b/i, name: "Comparecencia" },
1015
+ { regex: /\b(plazo\s+de\s+apelación|apelar)\b/i, name: "Plazo de apelación" },
1016
+ { regex: /\b(plazo\s+de\s+recurso|recurso)\b/i, name: "Plazo de recurso" },
1017
+ { regex: /\b(plazo\s+de\s+impugnación|impugnar)\b/i, name: "Plazo de impugnación" },
1018
+ { regex: /\b(plazo\s+de\s+oposición|oponer)\b/i, name: "Plazo de oposición" },
1019
+ { regex: /\b(plazo\s+de\s+contestación|contestar)\b/i, name: "Plazo de contestación" },
1020
+ { regex: /\b(plazo\s+de\s+presentación|presentar)\b/i, name: "Plazo de presentación" },
1021
+ { regex: /\b(plazo\s+de\s+inscripción|inscribir)\b/i, name: "Plazo de inscripción" },
1022
+ { regex: /\b(plazo\s+de\s+renuncia|renunciar)\b/i, name: "Plazo de renuncia" },
1023
+ { regex: /\b(plazo\s+de\s+aceptación|aceptar)\b/i, name: "Plazo de aceptación" },
1024
+ { regex: /\b(plazo\s+de\s+ejecución|ejecutar)\b/i, name: "Plazo de ejecución" },
1025
+ { regex: /\b(plazo\s+de\s+cumplimiento|cumplir)\b/i, name: "Plazo de cumplimiento" },
1026
+ { regex: /\b(plazo\s+de\s+vigencia|vigencia)\b/i, name: "Plazo de vigencia" },
1027
+ { regex: /\b(plazo\s+de\s+validez|validez)\b/i, name: "Plazo de validez" },
1028
+ { regex: /\b(plazo\s+de\s+duración|duración)\b/i, name: "Plazo de duración" },
1029
+ { regex: /\b(plazo\s+de\s+permanencia|permanencia)\b/i, name: "Plazo de permanencia" },
1030
+ { regex: /\b(plazo\s+de\s+estadía|estadía)\b/i, name: "Plazo de estadía" },
1031
+ { regex: /\b(plazo\s+de\s+residencia|residencia)\b/i, name: "Plazo de residencia" },
1032
+ { regex: /\b(plazo\s+de\s+domicilio|domicilio)\b/i, name: "Plazo de domicilio" },
1033
+ { regex: /\b(plazo\s+de\s+notificación|notificar)\b/i, name: "Plazo de notificación" },
1034
+ { regex: /\b(plazo\s+de\s+emisión|emitir)\b/i, name: "Plazo de emisión" },
1035
+ { regex: /\b(plazo\s+de\s+entrega|entregar)\b/i, name: "Plazo de entrega" },
1036
+ { regex: /\b(plazo\s+de\s+devolución|devolver)\b/i, name: "Plazo de devolución" },
1037
+ { regex: /\b(plazo\s+de\s+reintegro|reintegrar)\b/i, name: "Plazo de reintegro" },
1038
+ { regex: /\b(plazo\s+de\s+reembolso|reembolsar)\b/i, name: "Plazo de reembolso" },
1039
+ { regex: /\b(plazo\s+de\s+reparación|reparar)\b/i, name: "Plazo de reparación" },
1040
+ { regex: /\b(plazo\s+de\s+subsistencia|subsistir)\b/i, name: "Plazo de subsistencia" },
1041
+ { regex: /\b(plazo\s+de\s+conservación|conservar)\b/i, name: "Plazo de conservación" },
1042
+ { regex: /\b(plazo\s+de\s+custodia|custodiar)\b/i, name: "Plazo de custodia" },
1043
+ { regex: /\b(plazo\s+de\s+guarda|guardar)\b/i, name: "Plazo de guarda" },
1044
+ { regex: /\b(plazo\s+de\s+depósito|depositar)\b/i, name: "Plazo de depósito" },
1045
+ { regex: /\b(plazo\s+de\s+retención|retener)\b/i, name: "Plazo de retención" },
1046
+ { regex: /\b(plazo\s+de\s+consignación|consignar)\b/i, name: "Plazo de consignación" },
1047
+ { regex: /\b(plazo\s+de\s+liberación|liberar)\b/i, name: "Plazo de liberación" },
1048
+ { regex: /\b(plazo\s+de\s+libertad|libertad)\b/i, name: "Plazo de libertad" },
1049
+ { regex: /\b(plazo\s+de\s+detención|detener)\b/i, name: "Plazo de detención" },
1050
+ { regex: /\b(plazo\s+de\s+prisión|prisión)\b/i, name: "Plazo de prisión" },
1051
+ { regex: /\b(plazo\s+de\s+condena|condena)\b/i, name: "Plazo de condena" },
1052
+ { regex: /\b(plazo\s+de\s+sanción|sancionar)\b/i, name: "Plazo de sanción" },
1053
+ { regex: /\b(plazo\s+de\s+multa|multar)\b/i, name: "Plazo de multa" },
1054
+ { regex: /\b(plazo\s+de\s+pena|pena)\b/i, name: "Plazo de pena" },
1055
+ { regex: /\b(plazo\s+de\s+castigo|castigar)\b/i, name: "Plazo de castigo" },
1056
+ { regex: /\b(plazo\s+de\s+suspensión|suspender)\b/i, name: "Plazo de suspensión" },
1057
+ { regex: /\b(plazo\s+de\s+inhabilitación|inhabilitar)\b/i, name: "Plazo de inhabilitación" },
1058
+ { regex: /\b(plazo\s+de\s+destitución|destituir)\b/i, name: "Plazo de destitución" },
1059
+ { regex: /\b(plazo\s+de\s+separación|separar)\b/i, name: "Plazo de separación" },
1060
+ { regex: /\b(plazo\s+de\s+remoción|remover)\b/i, name: "Plazo de remoción" },
1061
+ { regex: /\b(plazo\s+de\s+cese|cesar)\b/i, name: "Plazo de cese" },
1062
+ { regex: /\b(plazo\s+de\s+terminación|terminar)\b/i, name: "Plazo de terminación" },
1063
+ { regex: /\b(plazo\s+de\s+finalización|finalizar)\b/i, name: "Plazo de finalización" },
1064
+ { regex: /\b(plazo\s+de\s+conclusión|concluir)\b/i, name: "Plazo de conclusión" },
1065
+ { regex: /\b(plazo\s+de\s+cierre|cerrar)\b/i, name: "Plazo de cierre" },
1066
+ { regex: /\b(plazo\s+de\s+clausura|clausurar)\b/i, name: "Plazo de clausura" },
1067
+ { regex: /\b(plazo\s+de\s+extinción|extinguir)\b/i, name: "Plazo de extinción" },
1068
+ { regex: /\b(plazo\s+de\s+anulación|anular)\b/i, name: "Plazo de anulación" },
1069
+ { regex: /\b(plazo\s+de\s+revocación|revocar)\b/i, name: "Plazo de revocación" },
1070
+ { regex: /\b(plazo\s+de\s+rescisión|rescindir)\b/i, name: "Plazo de rescisión" },
1071
+ { regex: /\b(plazo\s+de\s+resolución|resolver)\b/i, name: "Plazo de resolución" },
1072
+ { regex: /\b(plazo\s+de\s+decisión|decidir)\b/i, name: "Plazo de decisión" },
1073
+ { regex: /\b(plazo\s+de\s+jurisdicción|jurisdicción)\b/i, name: "Plazo de jurisdicción" },
1074
+ { regex: /\b(plazo\s+de\s+competencia|competencia)\b/i, name: "Plazo de competencia" },
1075
+ { regex: /\b(plazo\s+de\s+atribución|atribución)\b/i, name: "Plazo de atribución" },
1076
+ { regex: /\b(plazo\s+de\s+facultad|facultad)\b/i, name: "Plazo de facultad" },
1077
+ { regex: /\b(plazo\s+de\s+poder|poder)\b/i, name: "Plazo de poder" },
1078
+ { regex: /\b(plazo\s+de\s+autoridad|autoridad)\b/i, name: "Plazo de autoridad" },
1079
+ { regex: /\b(plazo\s+de\s+jurisdicción|jurisdicción)\b/i, name: "Plazo de jurisdicción" },
1080
+ ];
1081
+ // Split text into paragraphs for analysis (InfoLeg pattern Trace 3c)
1082
+ const paragraphs = text.split(/\n\n+/);
1083
+ const results = [];
1084
+ for (const paragraph of paragraphs) {
1085
+ const trimmed = paragraph.trim();
1086
+ if (!trimmed || trimmed.length < 10)
1087
+ continue;
1088
+ const foundMatches = [];
1089
+ for (const pattern of patterns) {
1090
+ if (pattern.regex.test(trimmed)) {
1091
+ foundMatches.push(pattern.name);
1092
+ }
1093
+ }
1094
+ if (foundMatches.length > 0) {
1095
+ results.push({
1096
+ paragraph: trimmed.substring(0, 500) + (trimmed.length > 500 ? '...' : ''),
1097
+ matches: foundMatches
1098
+ });
1099
+ }
1100
+ }
1101
+ let content = `# Auditoría de Plazos y Hitos Temporales en Dictámenes PTN\n\n`;
1102
+ content += `## Resumen\n`;
1103
+ content += `Se identificaron **${results.length}** cláusulas con indicadores temporales relevantes.\n\n`;
1104
+ if (results.length === 0) {
1105
+ content += `No se detectaron plazos, fechas límite o hitos temporales en el texto analizado.\n`;
1106
+ content += `Esto puede indicar:\n`;
1107
+ content += `- El dictamen no contiene plazos temporales\n`;
1108
+ content += `- Los plazos están expresados en formato no detectado por los patrones actuales\n`;
1109
+ content += `- El texto es muy breve o no es legible\n\n`;
1110
+ }
1111
+ else {
1112
+ content += `## Cláusulas Temporales Detectadas\n\n`;
1113
+ results.forEach((r, idx) => {
1114
+ content += `### ${idx + 1}. Cláusula Temporal (Indicador: ${r.matches.join(', ')})\n`;
1115
+ content += `> ${r.paragraph}\n\n`;
1116
+ });
1117
+ }
1118
+ content += `## Patrones de Búsqueda Utilizados\n`;
1119
+ patterns.forEach((p, idx) => {
1120
+ content += `${idx + 1}. **${p.name}**: ${p.regex.source}\n`;
1121
+ });
1122
+ content += `\n> **Nota:** Esta herramienta detecta patrones de texto comunes en dictámenes de la PTN (InfoLeg pattern enhanced). No constituye asesoramiento legal. Verificar siempre los plazos directamente en el documento original de la PTN.`;
1123
+ return {
1124
+ content: [{ type: "text", text: content }],
1125
+ };
1126
+ }
1127
+ catch (error) {
1128
+ return {
1129
+ isError: true,
1130
+ content: [{ type: "text", text: `Error al detectar plazos en dictamen: ${error.message}` }],
1131
+ };
1132
+ }
1133
+ });
1134
+ // Tool: generar_certificacion_forense
1135
+ server.tool("generar_certificacion_forense", "Genera una certificación forense de autenticidad para un dictamen de la PTN con hash SHA-256, timestamp y metadatos de integridad", {
1136
+ idDictamen: z.string().describe("ID del dictamen a certificar"),
1137
+ }, async (args) => {
1138
+ try {
1139
+ const dictamenId = String(args.idDictamen);
1140
+ const timestamp = new Date().toISOString();
1141
+ // Get dictamen data
1142
+ const detail = await obtenerDictamenTexto({ idDictamen: dictamenId });
1143
+ if (detail.error === "NOT_FOUND") {
1144
+ return { content: [{ type: "text", text: detail.texto }], isError: true };
1145
+ }
1146
+ const textContent = detail.texto || "";
1147
+ const docBuffer = Buffer.from(textContent, 'utf8');
1148
+ const sizeBytes = Buffer.byteLength(docBuffer, 'utf8');
1149
+ const hash = crypto.createHash('sha256').update(docBuffer).digest('hex');
1150
+ let content = `::: ACTA DE CERTIFICACIÓN FORENSE DE AUTENTICIDAD Y TRAZABILIDAD\n`;
1151
+ content += `::: Procuración del Tesoro de la Nación (PTN)\n\n`;
1152
+ content += `## DOCUMENTO CERTIFICADO\n`;
1153
+ content += `- **ID de Dictamen:** \`${dictamenId}\`\n`;
1154
+ content += `- **Número:** ${detail.numero || "N/A"}\n`;
1155
+ content += `- **Fuente:** Procuración del Tesoro de la Nación (PTN)\n\n`;
1156
+ content += `## METADATOS FORENSES\n`;
1157
+ content += `| Metadato Forense | Detalle Registrado |\n`;
1158
+ content += `| :--- | :--- |\n`;
1159
+ content += `| **Timestamp UTC** | \`${timestamp}\` |\n`;
1160
+ content += `| **URL de Origen** | ${PTN_WEB_URL}/dictamen/${dictamenId} |\n`;
1161
+ content += `| **Peso del Documento** | \`${sizeBytes} bytes\` |\n`;
1162
+ content += `| **Hash SHA-256 de Control** | \`${hash}\` |\n\n`;
1163
+ content += `## GARANTÍA DE INTEGRIDAD\n`;
1164
+ content += `> **[!] GARANTÍA DE NO ALTERACIÓN:** Este certificado garantiza que el dictamen fue recuperado íntegramente desde la fuente oficial de la PTN en el timestamp indicado. El hash SHA-256 permite verificar cualquier modificación posterior del contenido.\n\n`;
1165
+ content += `## MÉTODO DE VERIFICACIÓN\n`;
1166
+ content += `Para verificar la integridad de este documento en el futuro:\n`;
1167
+ content += `1. Recupere nuevamente el dictamen desde la PTN usando el ID ${dictamenId}\n`;
1168
+ content += `2. Calcule el hash SHA-256 del contenido recuperado\n`;
1169
+ content += `3. Compare con el hash certificado: \`${hash}\`\n`;
1170
+ content += `4. Si los hashes coinciden, el documento no ha sido alterado\n\n`;
1171
+ content += `---\n`;
1172
+ content += `*Este documento constituye un instrumento técnico de trazabilidad y autenticidad. No constituye certificación legal oficial de la Procuración del Tesoro de la Nación. Para fines legales, consulte las autoridades competentes.*\n`;
1173
+ content += `*Certificado generado automáticamente por Argentina-PTN-MCP v${SERVER_VERSION}*`;
1174
+ return {
1175
+ content: [{ type: "text", text: content }],
1176
+ };
1177
+ }
1178
+ catch (error) {
1179
+ return {
1180
+ isError: true,
1181
+ content: [{ type: "text", text: `Error al generar certificación forense: ${error.message}` }],
1182
+ };
1183
+ }
1184
+ });
1185
+ // Tool: buscar_por_semantica
1186
+ server.tool("buscar_por_semantica", "Busca dictamenes en la PTN utilizando expansión semántica de términos. El LLM debe generar sinónimos y términos equivalentes antes de llamar esta herramienta.", {
1187
+ concepto: z.string().describe("Concepto central a buscar (ej. 'designación', 'apoderamiento', 'contratación')"),
1188
+ terminos_equivalentes: z.array(z.string()).describe("Lista de sinónimos o términos relacionados generados por el LLM (ej. ['nombramiento', 'designación', 'cargo'])"),
1189
+ organismo: z.string().optional().describe("Acotar por organismo (opcional)"),
1190
+ anio: stringOrNumberOptional.describe("Acotar por año (opcional)"),
1191
+ pagina: z.number().optional().default(1).describe("Página (10 por página)"),
1192
+ }, async (args) => {
1193
+ try {
1194
+ const concepto = args.concepto;
1195
+ const terminos = args.terminos_equivalentes || [];
1196
+ // Combine concept with equivalent terms for broader search
1197
+ const allTerms = [concepto, ...terminos].join(' ');
1198
+ const results = await buscarDictamenes({
1199
+ criterio: allTerms,
1200
+ organismo: args.organismo,
1201
+ anio: args.anio,
1202
+ pagina: args.pagina,
1203
+ });
1204
+ let md = `# Búsqueda Semántica de Dictámenes - "${concepto}"\n\n`;
1205
+ md += `## Términos de Búsqueda Utilizados\n`;
1206
+ md += `- **Concepto principal:** ${concepto}\n`;
1207
+ md += `- **Términos equivalentes:** ${terminos.join(', ') || 'Ninguno'}\n`;
1208
+ md += `- **Query completa:** "${allTerms}"\n`;
1209
+ if (args.organismo)
1210
+ md += `- **Organismo:** ${args.organismo}\n`;
1211
+ md += `\n`;
1212
+ md += renderResultsMarkdown("Resultados de Búsqueda Semántica", results);
1213
+ md += `\n> **Nota:** Esta herramienta utiliza expansión semántica para capturar dictámenes que pueden no usar la terminología exacta del concepto buscado.`;
1214
+ return {
1215
+ content: [{ type: "text", text: md }],
1216
+ };
1217
+ }
1218
+ catch (error) {
1219
+ return errorContent("Error en búsqueda semántica", error);
1220
+ }
1221
+ });
1222
+ // Tool: relacionar_dictamenes
1223
+ server.tool("relacionar_dictamenes", "Busca dictámenes relacionados con un dictamen específico (mismo organismo, temas similares, misma voz temática)", {
1224
+ criterio_base: z.string().describe("Criterio base del dictamen de referencia (organismo, voz o tema)"),
1225
+ terminos_relacionados: z.array(z.string()).optional().describe("Términos relacionados para buscar dictámenes conexos"),
1226
+ organismo: z.string().optional().describe("Acotar por organismo (opcional)"),
1227
+ voz: z.string().optional().describe("Acotar por voz temática (opcional)"),
1228
+ pagina: z.number().optional().default(1).describe("Página (10 por página)"),
1229
+ }, async (args) => {
1230
+ try {
1231
+ const criterioBase = args.criterio_base;
1232
+ const terminosRelacionados = args.terminos_relacionados || [];
1233
+ // Combine base criteria with related terms
1234
+ const searchQuery = [criterioBase, ...terminosRelacionados].join(' ');
1235
+ const results = await buscarDictamenes({
1236
+ criterio: searchQuery,
1237
+ organismo: args.organismo,
1238
+ voz: args.voz,
1239
+ pagina: args.pagina,
1240
+ });
1241
+ let md = `# Dictámenes Relacionados - "${criterioBase}"\n\n`;
1242
+ md += `## Dictamen de Referencia\n`;
1243
+ md += `- **Criterio base:** ${criterioBase}\n`;
1244
+ if (args.organismo)
1245
+ md += `- **Organismo:** ${args.organismo}\n`;
1246
+ if (args.voz)
1247
+ md += `- **Voz temática:** ${args.voz}\n`;
1248
+ md += `\n`;
1249
+ md += `## Criterio de Búsqueda\n`;
1250
+ md += `**Query:** "${searchQuery}"\n`;
1251
+ md += `**Términos relacionados:** ${terminosRelacionados.join(', ') || 'Ninguno'}\n\n`;
1252
+ md += renderResultsMarkdown("Dictámenes Relacionados Encontrados", results);
1253
+ md += `\n> **Nota:** Esta herramienta busca por similitud temática y contextual. Las relaciones no son oficiales de la PTN.`;
1254
+ return {
1255
+ content: [{ type: "text", text: md }],
1256
+ };
1257
+ }
1258
+ catch (error) {
1259
+ return errorContent("Error al relacionar dictámenes", error);
1260
+ }
1261
+ });
1262
+ // Tool: exportar_dictamen
1263
+ server.tool("exportar_dictamen", "Exporta la información de un dictamen a formato Markdown estructurado con frontmatter YAML para sistemas de gestión del conocimiento (Notion, Obsidian, etc.)", {
1264
+ idDictamen: z.string().describe("ID del dictamen a exportar"),
1265
+ incluir_texto: z.boolean().optional().describe("Incluir texto completo del dictamen (por defecto: true)"),
1266
+ }, async (args) => {
1267
+ try {
1268
+ const dictamenId = args.idDictamen;
1269
+ const incluirTexto = args.incluir_texto !== false;
1270
+ const exportDate = new Date().toISOString();
1271
+ // Get dictamen data
1272
+ const detail = await obtenerDictamenTexto({ idDictamen: dictamenId });
1273
+ if (detail.error === "NOT_FOUND") {
1274
+ return { content: [{ type: "text", text: detail.texto }], isError: true };
1275
+ }
1276
+ // Build YAML frontmatter
1277
+ let content = `---\n`;
1278
+ content += `title: "Dictamen ${detail.numero || 'N/A'}"\n`;
1279
+ content += `dictamen_id: "${dictamenId}"\n`;
1280
+ content += `numero: "${detail.numero || 'N/A'}"\n`;
1281
+ content += `fecha: "${detail.fecha || 'N/A'}"\n`;
1282
+ content += `organismo: "${detail.organismo || 'N/A'}"\n`;
1283
+ content += `voces: "${detail.voces || 'N/A'}"\n`;
1284
+ content += `source: "Procuración del Tesoro de la Nación (PTN)"\n`;
1285
+ content += `source_url: "${PTN_WEB_URL}/dictamen/${dictamenId}"\n`;
1286
+ content += `export_date: "${exportDate}"\n`;
1287
+ content += `exported_by: "Argentina-PTN-MCP v${SERVER_VERSION}"\n`;
1288
+ content += `tags:\n`;
1289
+ content += ` - PTN\n`;
1290
+ content += ` - dictamen\n`;
1291
+ content += ` - procuracion-tesoro-nacion\n`;
1292
+ content += ` - dictamen-${dictamenId}\n`;
1293
+ if (detail.organismo)
1294
+ content += ` - organismo-${String(detail.organismo).toLowerCase().replace(/\s+/g, '-')}\n`;
1295
+ content += `---\n\n`;
1296
+ // Add document content
1297
+ content += `# Dictamen ${detail.numero || 'N/A'}\n\n`;
1298
+ content += `> **Fuente:** [PTN](${PTN_WEB_URL}/dictamen/${dictamenId})\n`;
1299
+ content += `> **ID de Dictamen:** ${dictamenId}\n`;
1300
+ if (detail.fecha)
1301
+ content += `> **Fecha:** ${detail.fecha}\n`;
1302
+ if (detail.organismo)
1303
+ content += `> **Organismo:** ${detail.organismo}\n`;
1304
+ if (detail.voces)
1305
+ content += `> **Voces:** ${detail.voces}\n`;
1306
+ if (detail.expediente)
1307
+ content += `> **Expediente:** ${detail.expediente}\n`;
1308
+ content += `\n`;
1309
+ if (incluirTexto) {
1310
+ content += `## Texto Completo\n\n`;
1311
+ content += `${detail.texto}\n\n`;
1312
+ }
1313
+ content += `---\n\n`;
1314
+ content += `*Documento exportado automáticamente desde la Procuración del Tesoro de la Nación. Verificar siempre la información en la fuente oficial.*`;
1315
+ return {
1316
+ content: [{ type: "text", text: content }],
1317
+ };
1318
+ }
1319
+ catch (error) {
1320
+ return {
1321
+ isError: true,
1322
+ content: [{ type: "text", text: `Error al exportar dictamen: ${error.message}` }],
1323
+ };
1324
+ }
1325
+ });
1326
+ // Tool: obtener_resumen_multiple_organismos
1327
+ server.tool("obtener_resumen_multiple_organismos", "Obtiene un resumen concurrente de dictámenes de múltiples organismos en paralelo (BORA pattern: Promise.all aggregation). Útil para análisis comparativos entre organismos.", {
1328
+ organismos: z.array(z.string()).describe("Lista de organismos a consultar (ej. ['CONICET', 'AFIP', 'ANSES'])"),
1329
+ criterio: z.string().optional().describe("Criterio opcional para acotar la búsqueda en cada organismo"),
1330
+ anio: stringOrNumberOptional.describe("Año opcional para filtrar"),
1331
+ max_por_organismo: z.number().optional().default(5).describe("Máximo de resultados por organismo (default 5)"),
1332
+ }, async (args) => {
1333
+ try {
1334
+ const organismos = args.organismos || [];
1335
+ if (organismos.length === 0) {
1336
+ throw new Error("Debe proporcionar al menos un organismo");
1337
+ }
1338
+ // Concurrently fetch all organisms using Promise.all (BORA pattern Trace 7)
1339
+ const results = await Promise.all(organismos.map(async (org) => {
1340
+ try {
1341
+ const searchResults = await buscarDictamenes({
1342
+ organismo: org,
1343
+ criterio: args.criterio,
1344
+ anio: args.anio,
1345
+ pageSize: args.max_por_organismo,
1346
+ pagina: 1,
1347
+ });
1348
+ return {
1349
+ organismo: org,
1350
+ total: searchResults.total,
1351
+ data: searchResults.data,
1352
+ error: null,
1353
+ };
1354
+ }
1355
+ catch (error) {
1356
+ return {
1357
+ organismo: org,
1358
+ total: 0,
1359
+ data: [],
1360
+ error: error.message,
1361
+ };
1362
+ }
1363
+ }));
1364
+ let md = `# Resumen Concurrente de Múltiples Organismos\n\n`;
1365
+ md += `**Organismos consultados:** ${organismos.join(', ')}\n`;
1366
+ if (args.criterio)
1367
+ md += `**Criterio:** ${args.criterio}\n`;
1368
+ if (args.anio)
1369
+ md += `**Año:** ${args.anio}\n`;
1370
+ md += `**Máximo por organismo:** ${args.max_por_organismo}\n\n`;
1371
+ const totalGlobal = results.reduce((sum, r) => sum + r.total, 0);
1372
+ md += `**Total global de dictámenes:** ${totalGlobal}\n\n`;
1373
+ results.forEach((r) => {
1374
+ md += `## ${r.organismo}\n`;
1375
+ if (r.error) {
1376
+ md += `❌ Error: ${r.error}\n\n`;
1377
+ }
1378
+ else {
1379
+ md += `**Total encontrado:** ${r.total}\n\n`;
1380
+ if (r.data.length > 0) {
1381
+ r.data.forEach((d, idx) => {
1382
+ md += `### ${idx + 1}. Dictamen ${d.numero || "N/A"}\n`;
1383
+ md += `* **ID:** \`${d.id}\`\n`;
1384
+ if (d.fecha)
1385
+ md += `* **Fecha:** ${d.fecha}\n`;
1386
+ if (d.voces)
1387
+ md += `* **Voces:** ${d.voces}\n`;
1388
+ if (d.sintesis)
1389
+ md += `* **Extracto:** ${d.sintesis}...\n`;
1390
+ md += `* **Enlace:** [Ver en PTN](${PTN_WEB_URL}/dictamen/${d.id})\n\n`;
1391
+ });
1392
+ }
1393
+ else {
1394
+ md += `No se encontraron dictámenes para este organismo.\n\n`;
1395
+ }
1396
+ }
1397
+ });
1398
+ md += `> **Nota:** Esta herramienta utiliza ejecución concurrente (Promise.all) para optimizar el tiempo de respuesta al consultar múltiples organismos en paralelo.`;
1399
+ return {
1400
+ content: [{ type: "text", text: md }],
1401
+ };
1402
+ }
1403
+ catch (error) {
1404
+ return errorContent("Error en resumen múltiple organismos", error);
1405
+ }
1406
+ });
1407
+ // Tool: obtener_resumen_multiple_voces
1408
+ server.tool("obtener_resumen_multiple_voces", "Obtiene un resumen concurrente de dictámenes de múltiples voces temáticas en paralelo (BORA pattern: Promise.all aggregation). Útil para análisis comparativos entre materias.", {
1409
+ voces: z.array(z.string()).describe("Lista de voces temáticas a consultar (ej. ['designación', 'apoderamiento', 'contratación'])"),
1410
+ criterio: z.string().optional().describe("Criterio opcional para acotar la búsqueda en cada voz"),
1411
+ organismo: z.string().optional().describe("Organismo opcional para filtrar"),
1412
+ anio: stringOrNumberOptional.describe("Año opcional para filtrar"),
1413
+ max_por_voz: z.number().optional().default(5).describe("Máximo de resultados por voz (default 5)"),
1414
+ }, async (args) => {
1415
+ try {
1416
+ const voces = args.voces || [];
1417
+ if (voces.length === 0) {
1418
+ throw new Error("Debe proporcionar al menos una voz temática");
1419
+ }
1420
+ // Concurrently fetch all voices using Promise.all (BORA pattern Trace 7)
1421
+ const results = await Promise.all(voces.map(async (voz) => {
1422
+ try {
1423
+ const searchResults = await buscarDictamenes({
1424
+ voz: voz,
1425
+ criterio: args.criterio,
1426
+ organismo: args.organismo,
1427
+ anio: args.anio,
1428
+ pageSize: args.max_por_voz,
1429
+ pagina: 1,
1430
+ });
1431
+ return {
1432
+ voz: voz,
1433
+ total: searchResults.total,
1434
+ data: searchResults.data,
1435
+ error: null,
1436
+ };
1437
+ }
1438
+ catch (error) {
1439
+ return {
1440
+ voz: voz,
1441
+ total: 0,
1442
+ data: [],
1443
+ error: error.message,
1444
+ };
1445
+ }
1446
+ }));
1447
+ let md = `# Resumen Concurrente de Múltiples Voces Temáticas\n\n`;
1448
+ md += `**Voces consultadas:** ${voces.join(', ')}\n`;
1449
+ if (args.criterio)
1450
+ md += `**Criterio:** ${args.criterio}\n`;
1451
+ if (args.organismo)
1452
+ md += `**Organismo:** ${args.organismo}\n`;
1453
+ if (args.anio)
1454
+ md += `**Año:** ${args.anio}\n`;
1455
+ md += `**Máximo por voz:** ${args.max_por_voz}\n\n`;
1456
+ const totalGlobal = results.reduce((sum, r) => sum + r.total, 0);
1457
+ md += `**Total global de dictámenes:** ${totalGlobal}\n\n`;
1458
+ results.forEach((r) => {
1459
+ md += `## ${r.voz}\n`;
1460
+ if (r.error) {
1461
+ md += `❌ Error: ${r.error}\n\n`;
1462
+ }
1463
+ else {
1464
+ md += `**Total encontrado:** ${r.total}\n\n`;
1465
+ if (r.data.length > 0) {
1466
+ r.data.forEach((d, idx) => {
1467
+ md += `### ${idx + 1}. Dictamen ${d.numero || "N/A"}\n`;
1468
+ md += `* **ID:** \`${d.id}\`\n`;
1469
+ if (d.fecha)
1470
+ md += `* **Fecha:** ${d.fecha}\n`;
1471
+ if (d.organismo)
1472
+ md += `* **Organismo:** ${d.organismo}\n`;
1473
+ if (d.sintesis)
1474
+ md += `* **Extracto:** ${d.sintesis}...\n`;
1475
+ md += `* **Enlace:** [Ver en PTN](${PTN_WEB_URL}/dictamen/${d.id})\n\n`;
1476
+ });
1477
+ }
1478
+ else {
1479
+ md += `No se encontraron dictámenes para esta voz temática.\n\n`;
1480
+ }
1481
+ }
1482
+ });
1483
+ md += `> **Nota:** Esta herramienta utiliza ejecución concurrente (Promise.all) para optimizar el tiempo de respuesta al consultar múltiples voces en paralelo.`;
1484
+ return {
1485
+ content: [{ type: "text", text: md }],
1486
+ };
1487
+ }
1488
+ catch (error) {
1489
+ return errorContent("Error en resumen múltiple voces", error);
1490
+ }
1491
+ });
1492
+ server.prompt("auditar_dictamen", "Recupera un dictamen y genera un reporte legal estructurado.", {
1493
+ idDictamen: z.string().describe("ID oficial del dictamen (_id)"),
1494
+ objetivo: z.string().optional().describe("Objetivo de auditoria"),
1495
+ }, (args) => ({
1496
+ messages: [
1497
+ {
1498
+ role: "user",
1499
+ content: {
1500
+ type: "text",
1501
+ text: `Usa la tool 'obtener_dictamen_texto' del MCP ptn-mcp para recuperar el dictamen con id ${args.idDictamen}. Luego elabora un reporte estructurado con: (1) Caratula (numero, fecha, organismo, expediente), (2) Hechos relevantes, (3) Marco normativo citado, (4) Razonamiento juridico de la PTN, (5) Conclusion / parte dispositiva, (6) Voces / temas. Objetivo del analisis: ${args.objetivo ?? "auditoria general"}.`,
1502
+ },
1503
+ },
1504
+ ],
1505
+ }));
1506
+ server.prompt("comparar_dictamenes", "Compara 2 o 3 dictamenes por sus IDs y arma cuadro comparativo de criterios y resoluciones.", {
1507
+ ids: z.string().describe("IDs separados por coma (ej. 'abc,def,ghi'). Hasta 3."),
1508
+ eje: z.string().optional().describe("Eje de comparacion (ej. 'designaciones transitorias', 'apoderamiento')"),
1509
+ }, (args) => {
1510
+ const ids = args.ids
1511
+ .split(",")
1512
+ .map((s) => s.trim())
1513
+ .filter(Boolean)
1514
+ .slice(0, 3);
1515
+ return {
1516
+ messages: [
1517
+ {
1518
+ role: "user",
1519
+ content: {
1520
+ type: "text",
1521
+ text: `Usa la tool 'obtener_dictamen_texto' del MCP ptn-mcp para recuperar el texto de cada uno de estos dictamenes: ${ids.join(", ")}. Luego construi un cuadro comparativo (tabla markdown) con columnas: Dictamen, Fecha, Organismo, Hechos, Normativa, Conclusion. Despues, agrega una seccion final 'Coincidencias y divergencias' enfocada en el eje: ${args.eje ?? "criterios y conclusiones"}.`,
1522
+ },
1523
+ },
1524
+ ],
1525
+ };
1526
+ });
1527
+ server.prompt("resumen_por_organismo", "Panorama de los dictamenes de un organismo en los ultimos N meses.", {
1528
+ organismo: z.string().describe("Nombre o fragmento del organismo (ej. 'CONICET')"),
1529
+ mesesAtras: z.string().optional().describe("Cantidad de meses hacia atras (default 12)"),
1530
+ }, (args) => {
1531
+ const months = Number(args.mesesAtras ?? 12) || 12;
1532
+ const desdeIso = isoNMonthsAgo(months);
1533
+ const [yyyy, mm, dd] = desdeIso.split("-");
1534
+ const desde = `${dd}/${mm}/${yyyy}`;
1535
+ return {
1536
+ messages: [
1537
+ {
1538
+ role: "user",
1539
+ content: {
1540
+ type: "text",
1541
+ text: `Usa la tool 'buscar_por_organismo' del MCP ptn-mcp con organismo='${args.organismo}' y fechaDesde='${desde}', recorriendo paginas hasta cubrir todos los resultados (max 5 paginas). Luego elabora un resumen ejecutivo con: (1) Volumen total y por mes, (2) Voces tematicas mas frecuentes, (3) Tendencia / patrones observados, (4) 3-5 dictamenes destacados con su ID y motivo del destaque. Periodo: ultimos ${months} meses (desde ${desde}).`,
1542
+ },
1543
+ },
1544
+ ],
1545
+ };
1546
+ });
1547
+ }
1548
+ export const server = new McpServer({
1549
+ name: "ptn-mcp",
1550
+ version: SERVER_VERSION,
1551
+ });
1552
+ registerAllTools(server);
1553
+ const isDirectCliRun = typeof process !== "undefined" &&
1554
+ Boolean(process.argv[1]) &&
1555
+ import.meta.url === pathToFileURL(process.argv[1]).href;
1556
+ if (isDirectCliRun && !process.env.VERCEL && !process.env.NEXT_RUNTIME) {
1557
+ const transport = new StdioServerTransport();
1558
+ server.connect(transport).catch((error) => {
1559
+ console.error("Fatal error running PTN MCP server:", error);
1560
+ process.exit(1);
1561
+ });
1562
+ console.error(`PTN MCP Server v${SERVER_VERSION} is running via stdio`);
1563
+ }