@zincapp/mcp-server 1.11.0 → 1.12.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/dist/client.d.ts CHANGED
@@ -6,4 +6,6 @@ export declare class ZincAppClient {
6
6
  constructor(config: McpConfig);
7
7
  get<T>(path: string, params?: Record<string, string>): Promise<T>;
8
8
  post<T>(path: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<T>;
9
+ put<T>(path: string, body?: unknown, extraHeaders?: Record<string, string>): Promise<T>;
10
+ private send;
9
11
  }
package/dist/client.js CHANGED
@@ -32,9 +32,15 @@ export class ZincAppClient {
32
32
  return res.json();
33
33
  }
34
34
  async post(path, body, extraHeaders) {
35
+ return this.send('POST', path, body, extraHeaders);
36
+ }
37
+ async put(path, body, extraHeaders) {
38
+ return this.send('PUT', path, body, extraHeaders);
39
+ }
40
+ async send(method, path, body, extraHeaders) {
35
41
  const url = `${this.baseUrl}/agent/v1${path}`;
36
42
  const res = await fetch(url, {
37
- method: 'POST',
43
+ method,
38
44
  headers: {
39
45
  'Authorization': `Bearer ${this.token}`,
40
46
  'Accept': 'application/json',
@@ -18,6 +18,7 @@ import { registerProposeNt } from './propose-nt.js';
18
18
  import { registerLookupAuthorization } from './lookup-authorization.js';
19
19
  import { registerNtFinalGestor } from './nt-final-gestor.js';
20
20
  import { registerUpsertGestor } from './upsert-gestor.js';
21
+ import { registerPrintables } from './printables.js';
21
22
  export function registerAllTools(server, client) {
22
23
  registerSearchDocs(server, client);
23
24
  registerReadDoc(server, client);
@@ -39,4 +40,5 @@ export function registerAllTools(server, client) {
39
40
  registerLookupAuthorization(server, client);
40
41
  registerNtFinalGestor(server, client);
41
42
  registerUpsertGestor(server, client);
43
+ registerPrintables(server, client);
42
44
  }
@@ -0,0 +1,38 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ZincAppClient } from '../client.js';
3
+ interface SeriesSummary {
4
+ seriesUuid: string;
5
+ module: string | null;
6
+ document: string | null;
7
+ title: string | null;
8
+ style: string | null;
9
+ }
10
+ /** Page raster width in px — same as the portal's PAGE_WIDTH so the agent sees what the studio sees. */
11
+ export declare const PAGE_WIDTH = 800;
12
+ /** Default pages rasterised per render — the portal's MAX_PAGES. */
13
+ export declare const DEFAULT_RENDER_PAGES = 3;
14
+ /** Hard cap on pages per render (image content is expensive for the model). */
15
+ export declare const MAX_RENDER_PAGES = 8;
16
+ /**
17
+ * Rasterise a PDF to page PNGs — the Node twin of the portal's `renderPdfPages`
18
+ * (zn-web-app-v1 `printables/utils/pdf-images.ts`): pdfjs render at a fixed page width, one
19
+ * image per page, capped. Browser canvas → `@napi-rs/canvas`; JPEG → PNG (lossless proves
20
+ * layout: hairlines, kerning and 1px borders survive). Exported for tests.
21
+ */
22
+ export declare function rasterizePdfToPngs(pdf: Buffer, maxPages: number, pageWidth?: number): Promise<{
23
+ pageCount: number;
24
+ pngs: Buffer[];
25
+ }>;
26
+ /** Una línea por serie, con el uuid en código para copiar/pegar en las demás herramientas. */
27
+ export declare function formatSeriesList(series: SeriesSummary[]): string;
28
+ /** JSON pretty en bloque de código — el modelo/estilo se lee y se re-manda ENTERO al actualizar. */
29
+ export declare function formatJsonBlock(obj: unknown): string;
30
+ /**
31
+ * Traduce el error del POST de render a una acción para el operador. El 409 característico es
32
+ * "No render session is stored for this elevation" — la elevación está viva pero el backend no
33
+ * capturó el sessid al elevarte (elevate lo captura best-effort): la salida es re-elevarte en la
34
+ * web. Cualquier otro error se muestra tal cual (el backend ya habla claro).
35
+ */
36
+ export declare function renderErrorHint(message: string): string;
37
+ export declare function registerPrintables(server: McpServer, client: ZincAppClient): void;
38
+ export {};
@@ -0,0 +1,194 @@
1
+ import { z } from 'zod';
2
+ import { resolveElevation } from './elevation.js';
3
+ // ── Rasterisation constants (mirrors the portal's renderPdfPages, pdf-images.ts) ──────────────
4
+ /** Page raster width in px — same as the portal's PAGE_WIDTH so the agent sees what the studio sees. */
5
+ export const PAGE_WIDTH = 800;
6
+ /** Default pages rasterised per render — the portal's MAX_PAGES. */
7
+ export const DEFAULT_RENDER_PAGES = 3;
8
+ /** Hard cap on pages per render (image content is expensive for the model). */
9
+ export const MAX_RENDER_PAGES = 8;
10
+ /**
11
+ * Rasterise a PDF to page PNGs — the Node twin of the portal's `renderPdfPages`
12
+ * (zn-web-app-v1 `printables/utils/pdf-images.ts`): pdfjs render at a fixed page width, one
13
+ * image per page, capped. Browser canvas → `@napi-rs/canvas`; JPEG → PNG (lossless proves
14
+ * layout: hairlines, kerning and 1px borders survive). Exported for tests.
15
+ */
16
+ export async function rasterizePdfToPngs(pdf, maxPages, pageWidth = PAGE_WIDTH) {
17
+ // Dynamic imports: pdfjs is a multi-MB parse and the canvas binding is native — only render pays.
18
+ const { getDocument } = await import('pdfjs-dist/legacy/build/pdf.mjs');
19
+ const { createCanvas } = await import('@napi-rs/canvas');
20
+ const doc = await getDocument({
21
+ data: new Uint8Array(pdf),
22
+ isEvalSupported: false,
23
+ }).promise;
24
+ try {
25
+ const pageCount = doc.numPages;
26
+ const toRender = Math.min(pageCount, maxPages);
27
+ const pngs = [];
28
+ for (let i = 1; i <= toRender; i++) {
29
+ const page = await doc.getPage(i);
30
+ const base = page.getViewport({ scale: 1 });
31
+ const viewport = page.getViewport({ scale: pageWidth / base.width });
32
+ const canvas = createCanvas(Math.ceil(viewport.width), Math.ceil(viewport.height));
33
+ const ctx = canvas.getContext('2d');
34
+ await page.render({
35
+ canvasContext: ctx,
36
+ viewport,
37
+ }).promise;
38
+ pngs.push(canvas.toBuffer('image/png'));
39
+ }
40
+ return { pageCount, pngs };
41
+ }
42
+ finally {
43
+ await doc.destroy();
44
+ }
45
+ }
46
+ // ── Pure formatting helpers (exported for tests) ───────────────────────────────────────────────
47
+ /** Una línea por serie, con el uuid en código para copiar/pegar en las demás herramientas. */
48
+ export function formatSeriesList(series) {
49
+ if (series.length === 0)
50
+ return 'No hay series de printables.';
51
+ return series
52
+ .map((s) => {
53
+ const title = s.title ?? '(sin título)';
54
+ const doc = [s.module, s.document].filter(Boolean).join(' / ') || '—';
55
+ return `- **${title}** · \`${s.seriesUuid}\` · ${doc} · estilo: ${s.style ?? '—'}`;
56
+ })
57
+ .join('\n');
58
+ }
59
+ /** JSON pretty en bloque de código — el modelo/estilo se lee y se re-manda ENTERO al actualizar. */
60
+ export function formatJsonBlock(obj) {
61
+ return '```json\n' + JSON.stringify(obj ?? null, null, 2) + '\n```';
62
+ }
63
+ /**
64
+ * Traduce el error del POST de render a una acción para el operador. El 409 característico es
65
+ * "No render session is stored for this elevation" — la elevación está viva pero el backend no
66
+ * capturó el sessid al elevarte (elevate lo captura best-effort): la salida es re-elevarte en la
67
+ * web. Cualquier otro error se muestra tal cual (el backend ya habla claro).
68
+ */
69
+ export function renderErrorHint(message) {
70
+ if (/render session/i.test(message)) {
71
+ return ('# Render no disponible\n\n' +
72
+ 'Tu elevación está abierta pero no tiene sesión de render capturada (se captura al ' +
73
+ 'elevar, best-effort). Cierra la elevación y **vuelve a elevarte desde el portal web** ' +
74
+ 'para capturar una nueva, y relanza la herramienta.\n\n' +
75
+ `_Detalle del backend:_ ${message}`);
76
+ }
77
+ return `# Error del render\n\n${message}`;
78
+ }
79
+ const text = (t) => ({ content: [{ type: 'text', text: t }] });
80
+ // ── Tools ──────────────────────────────────────────────────────────────────────────────────────
81
+ export function registerPrintables(server, client) {
82
+ server.tool('list_printable_series', 'Lista el catálogo de series de printables de plataforma (documentos imprimibles: facturas, ' +
83
+ 'albaranes, tickets…): uuid, módulo/documento, título y estilo asignado. Sólo resúmenes — el ' +
84
+ 'modelo se lee con read_printable_model. Requiere el permiso de operador `printables`; NO ' +
85
+ 'requiere elevación (son datos globales de plataforma, sin tenant). Ningún parámetro de ' +
86
+ 'company, host ni identidad.', {}, async () => {
87
+ const series = await client.get('/printable/series');
88
+ return text(`# Series de printables (${series.length})\n\n${formatSeriesList(series)}`);
89
+ });
90
+ server.tool('read_printable_model', 'Lee el MODELO completo (JSON) de una serie de printable — la definición del documento que ' +
91
+ 'edita el estudio de printables del portal. Léelo SIEMPRE antes de update_printable_model: ' +
92
+ 'la actualización reemplaza el modelo entero, así que tu edición parte de este JSON. ' +
93
+ 'Permiso `printables`; sin elevación.', {
94
+ seriesUuid: z.string().describe('El uuid de la serie (de list_printable_series)'),
95
+ }, async ({ seriesUuid }) => {
96
+ const m = await client.get(`/printable/series/${encodeURIComponent(seriesUuid)}/model`);
97
+ return text(`# Modelo · serie ${m.seriesUuid}\n\n${formatJsonBlock(m.model)}`);
98
+ });
99
+ server.tool('update_printable_model', 'REEMPLAZA el modelo (JSON completo) de una serie de printable. Flujo lectura→edición: lee el ' +
100
+ 'modelo actual con read_printable_model, aplica tu cambio sobre ese JSON y manda aquí el ' +
101
+ 'MODELO ENTERO resultante (no un parche — lo que mandes es lo que queda). El backend guarda ' +
102
+ 'un snapshot de versión antes de sobrescribir (mismo camino guardado que usa el portal). ' +
103
+ 'Sólo aplica cambios que hayas revisado. Permiso `printables`; sin elevación. Verifica el ' +
104
+ 'resultado visualmente con render_printable.', {
105
+ seriesUuid: z.string().describe('El uuid de la serie (de list_printable_series)'),
106
+ model: z
107
+ .record(z.string(), z.unknown())
108
+ .describe('El modelo COMPLETO resultante (JSON de read_printable_model con tu edición)'),
109
+ }, async ({ seriesUuid, model }) => {
110
+ const updated = await client.put(`/printable/series/${encodeURIComponent(seriesUuid)}/model`, { confirm: true, model });
111
+ return text(`# Modelo actualizado · serie ${updated.seriesUuid}\n\n` +
112
+ 'El backend guardó un snapshot de la versión anterior antes de sobrescribir.\n\n' +
113
+ `Modelo vigente:\n${formatJsonBlock(updated.model)}\n\n` +
114
+ '_Comprueba el resultado con `render_printable` (requiere elevación web activa)._');
115
+ });
116
+ server.tool('read_printable_style', 'Lee la DEFINICIÓN DE ESTILO (JSON) asignada a una serie de printable (colores, fuentes, ' +
117
+ 'espaciados…). Ojo: el estilo es COMPARTIDO — otras series con el mismo estilo también lo ' +
118
+ 'usan. Léelo siempre antes de update_printable_style. Permiso `printables`; sin elevación.', {
119
+ seriesUuid: z.string().describe('El uuid de la serie cuyo estilo quieres leer'),
120
+ }, async ({ seriesUuid }) => {
121
+ const s = await client.get(`/printable/series/${encodeURIComponent(seriesUuid)}/style`);
122
+ return text(`# Estilo \`${s.style}\`\n\n${formatJsonBlock(s.definition)}`);
123
+ });
124
+ server.tool('update_printable_style', 'REEMPLAZA la definición del estilo asignado a una serie. Mismo flujo lectura→edición que el ' +
125
+ 'modelo: lee con read_printable_style, edita ese JSON y manda la DEFINICIÓN ENTERA. ' +
126
+ 'ATENCIÓN: el estilo es compartido entre series — tu cambio afecta a TODAS las series que ' +
127
+ 'lo usen, no sólo a ésta. El backend guarda snapshot de versión antes de sobrescribir. ' +
128
+ 'Permiso `printables`; sin elevación. Verifica con render_printable.', {
129
+ seriesUuid: z.string().describe('El uuid de la serie cuyo estilo quieres actualizar'),
130
+ definition: z
131
+ .record(z.string(), z.unknown())
132
+ .describe('La definición COMPLETA resultante (JSON de read_printable_style con tu edición)'),
133
+ }, async ({ seriesUuid, definition }) => {
134
+ const updated = await client.put(`/printable/series/${encodeURIComponent(seriesUuid)}/style`, { confirm: true, definition });
135
+ return text(`# Estilo \`${updated.style}\` actualizado\n\n` +
136
+ 'El backend guardó un snapshot de la versión anterior. Recuerda: el estilo es ' +
137
+ 'compartido — afecta a todas las series que lo usan.\n\n' +
138
+ `Definición vigente:\n${formatJsonBlock(updated.definition)}\n\n` +
139
+ '_Comprueba el resultado con `render_printable` (requiere elevación web activa)._');
140
+ });
141
+ server.tool('render_printable', 'Renderiza una serie de printable a PDF en el servidor y te devuelve las páginas como ' +
142
+ 'IMÁGENES PNG para que las VEAS y juzgues la maquetación (tras un update_printable_model / ' +
143
+ '_style). REQUIERE tu elevación de operador ABIERTA EN LA WEB: el backend renderiza contra ' +
144
+ 'el portal del tenant elevado reutilizando la sesión capturada al elevarte — esta ' +
145
+ 'herramienta nunca abre una elevación ni acepta company, host o cookie. El cuerpo lleva ' +
146
+ 'sólo presentación: theme, lang y testData (ids de documentos de prueba). Salida PNG ' +
147
+ `a ${PAGE_WIDTH}px de ancho (el formato HTML no está expuesto por el canal agente).`, {
148
+ seriesUuid: z.string().describe('El uuid de la serie a renderizar'),
149
+ theme: z.string().optional().describe('Tema de render (por defecto el test-theme del modelo, o "black")'),
150
+ lang: z.string().optional().describe('Idioma del documento (por defecto el test-lang del modelo, o "es")'),
151
+ testData: z
152
+ .string()
153
+ .optional()
154
+ .describe('Ids de documentos de prueba, separados por comas (p.ej. "15,16"); por defecto el test-data del modelo'),
155
+ maxPages: z
156
+ .number()
157
+ .int()
158
+ .min(1)
159
+ .max(MAX_RENDER_PAGES)
160
+ .optional()
161
+ .describe(`Páginas a devolver como imagen (1-${MAX_RENDER_PAGES}; por defecto ${DEFAULT_RENDER_PAGES})`),
162
+ }, async ({ seriesUuid, theme, lang, testData, maxPages }) => {
163
+ // La elevación se abre en la web — si no hay, guía al operador en vez de un HTTP opaco.
164
+ const elev = await resolveElevation(client);
165
+ if ('content' in elev)
166
+ return elev;
167
+ let r;
168
+ try {
169
+ r = await client.post(`/printable/series/${encodeURIComponent(seriesUuid)}/render`, { theme, lang, testData });
170
+ }
171
+ catch (e) {
172
+ return text(renderErrorHint(e.message));
173
+ }
174
+ const pdf = Buffer.from(r.pdfBase64, 'base64');
175
+ const { pageCount, pngs } = await rasterizePdfToPngs(pdf, maxPages ?? DEFAULT_RENDER_PAGES);
176
+ const truncated = pngs.length < pageCount
177
+ ? ` (mostrando ${pngs.length}; sube maxPages para ver más)`
178
+ : '';
179
+ return {
180
+ content: [
181
+ {
182
+ type: 'text',
183
+ text: `# Render · serie ${seriesUuid} · company ${elev.companyId}\n` +
184
+ `${pageCount} página(s)${truncated} · PNG a ${PAGE_WIDTH}px`,
185
+ },
186
+ ...pngs.map((p) => ({
187
+ type: 'image',
188
+ data: p.toString('base64'),
189
+ mimeType: 'image/png',
190
+ })),
191
+ ],
192
+ };
193
+ });
194
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zincapp/mcp-server",
3
- "version": "1.11.0",
3
+ "version": "1.12.0",
4
4
  "description": "MCP server for ZincApp — search docs, read API reference, execute sandbox calls, and audit/complete waste removals (retiradas)",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,11 +28,13 @@
28
28
  ],
29
29
  "dependencies": {
30
30
  "@modelcontextprotocol/sdk": "^1.12.0",
31
+ "@napi-rs/canvas": "^0.1.100",
32
+ "pdfjs-dist": "^4.10.38",
31
33
  "zod": "^3.23.0"
32
34
  },
33
35
  "devDependencies": {
34
- "typescript": "^5.6.0",
35
- "@types/node": "^22.0.0"
36
+ "@types/node": "^22.0.0",
37
+ "typescript": "^5.6.0"
36
38
  },
37
39
  "repository": {
38
40
  "type": "git",
@@ -43,6 +45,6 @@
43
45
  "provenance": true
44
46
  },
45
47
  "engines": {
46
- "node": ">=18"
48
+ "node": ">=20"
47
49
  }
48
50
  }