@zincapp/mcp-server 1.5.0 → 1.8.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.
@@ -0,0 +1,89 @@
1
+ export interface NimaCenterRef {
2
+ nima: number;
3
+ idCentro: number;
4
+ tipoCentro: string;
5
+ direccion: string;
6
+ }
7
+ /**
8
+ * Parse the ConsultaNimaAccion.icm search response into center refs.
9
+ *
10
+ * Each result row in the CM search table follows this fixed document order
11
+ * (verified against test/fixtures/cm-search-consenur.html):
12
+ * 1. NIMA cell: a `<script>` literal `var nima='2800006779';` followed by a
13
+ * UI-only filter `nima.substring(2,5) != '913'` that gates a `document.write`
14
+ * call — we read the JS literal directly and ignore that filter entirely.
15
+ * 2. Province/municipality cell (`<p>…</p>`) — not needed here.
16
+ * 3. Address cell (`<p>…</p>`) — captured as `direccion`.
17
+ * 4. A "Consultar" button `onclick="consultar('form',<idCentro>,'<tipoCentro>')"`.
18
+ *
19
+ * NIMA and idCentro are different identifiers for the same center (e.g. nima
20
+ * 2800082834 has idCentro 82834; nima 2800006779 has idCentro 90181) — they are
21
+ * NOT derivable from one another, so each row's nima/address must be aligned
22
+ * with that same row's idCentro/tipoCentro by document order, not by value.
23
+ *
24
+ * We match one row at a time with a single regex spanning nima → address →
25
+ * consultar(), rather than two separate passes, since the fixture shows rows
26
+ * never interleave (each row's four fields appear back-to-back before the next
27
+ * row's nima literal starts).
28
+ */
29
+ export declare function parseCenterList(html: string): {
30
+ razonSocial: string | null;
31
+ centros: NimaCenterRef[];
32
+ };
33
+ export interface NimaAuthorization {
34
+ authId: string;
35
+ autorizacion: string;
36
+ estado: boolean;
37
+ }
38
+ /**
39
+ * Parse the FichaNimaAccion.icm detail into the razón social, the center NIMA, and its
40
+ * authorizations (verified against test/fixtures/cm-ficha-consenur-90181.html).
41
+ *
42
+ * Razón Social and NIMA come from the "Sede"/"Centro" fieldsets:
43
+ * - `<b>Razón Social:</b> CONSENUR SANITARIOS, S.L.</td>` — plain text between the label and the cell close.
44
+ * - `var nima = '2800006779';` — a JS literal inside the NIMA cell (the surrounding
45
+ * `document.write(nima)` is a UI-only rendering detail we ignore, same as parseCenterList).
46
+ *
47
+ * The "Autorizaciones" table rows are `Tipo | Nº | Estado | …4 date cells… | Consultar button`,
48
+ * one row per authorization type held by the center. The real markup has malformed nested
49
+ * `<tr>`s (`<tr>\n<tr bgcolor="...">`) which we don't need to care about since we match on
50
+ * `<td>`/`<p>` boundaries rather than row boundaries. We map:
51
+ * authId = code before ' - ' in the Tipo cell, uppercased ('G01 - Centro Gestor…' -> 'G01')
52
+ * autorizacion = the Nº Autorización cell's text
53
+ * estado = true iff the Estado cell's text is exactly 'Autorizado/Registrado' (else e.g. 'Baja' -> false)
54
+ * We scope the match to the text after the "Autorizaciones" heading so unrelated <p>s
55
+ * elsewhere in the page can't be mistaken for a row.
56
+ */
57
+ export declare function parseAuthorizations(html: string): {
58
+ razonSocial: string | null;
59
+ nima: number | null;
60
+ autorizaciones: NimaAuthorization[];
61
+ };
62
+ export interface NimaCenter {
63
+ nima: number;
64
+ direccion: string;
65
+ autorizaciones: NimaAuthorization[];
66
+ }
67
+ export interface NimaEntity {
68
+ cif: string;
69
+ nombre: string | null;
70
+ centros: NimaCenter[];
71
+ }
72
+ export interface NimaLookupResult {
73
+ entidad: NimaEntity;
74
+ }
75
+ /**
76
+ * Orchestrate a NIMA registry lookup: search the CM by NIF (paginating through the result
77
+ * pages), then fetch the ficha for each target center and assemble the nested
78
+ * `Entidad -> centros -> autorizaciones` graph.
79
+ *
80
+ * When `args.nima` is given, we page only until that NIMA is found among the search results
81
+ * (or we run out of pages), then fetch just that one center's ficha. Without `args.nima`, every
82
+ * center found across all pages is fetched.
83
+ *
84
+ * `fetchImpl` is injectable so tests never hit the network; it defaults to the global `fetch`.
85
+ */
86
+ export declare function lookupNimaAuthorizations(args: {
87
+ nif: string;
88
+ nima?: number;
89
+ }, fetchImpl?: typeof fetch): Promise<NimaLookupResult>;
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Parse the ConsultaNimaAccion.icm search response into center refs.
3
+ *
4
+ * Each result row in the CM search table follows this fixed document order
5
+ * (verified against test/fixtures/cm-search-consenur.html):
6
+ * 1. NIMA cell: a `<script>` literal `var nima='2800006779';` followed by a
7
+ * UI-only filter `nima.substring(2,5) != '913'` that gates a `document.write`
8
+ * call — we read the JS literal directly and ignore that filter entirely.
9
+ * 2. Province/municipality cell (`<p>…</p>`) — not needed here.
10
+ * 3. Address cell (`<p>…</p>`) — captured as `direccion`.
11
+ * 4. A "Consultar" button `onclick="consultar('form',<idCentro>,'<tipoCentro>')"`.
12
+ *
13
+ * NIMA and idCentro are different identifiers for the same center (e.g. nima
14
+ * 2800082834 has idCentro 82834; nima 2800006779 has idCentro 90181) — they are
15
+ * NOT derivable from one another, so each row's nima/address must be aligned
16
+ * with that same row's idCentro/tipoCentro by document order, not by value.
17
+ *
18
+ * We match one row at a time with a single regex spanning nima → address →
19
+ * consultar(), rather than two separate passes, since the fixture shows rows
20
+ * never interleave (each row's four fields appear back-to-back before the next
21
+ * row's nima literal starts).
22
+ */
23
+ export function parseCenterList(html) {
24
+ const centros = [];
25
+ const rowRe = /var nima\s*=\s*'(\d+)'[\s\S]*?<\/script>[\s\S]*?<p>([^<]*)<\/p>\s*<\/td>\s*<td[^>]*>\s*<p>([^<]*)<\/p>[\s\S]*?consultar\('form',\s*(\d+)\s*,\s*'([^']+)'\)/g;
26
+ let m;
27
+ while ((m = rowRe.exec(html)) !== null) {
28
+ const nima = Number(m[1]);
29
+ // m[2] is the province/municipality cell (unused); m[3] is the address cell.
30
+ const direccion = m[3].trim();
31
+ const idCentro = Number(m[4]);
32
+ const tipoCentro = m[5].trim();
33
+ centros.push({ nima, idCentro, tipoCentro, direccion });
34
+ }
35
+ return { razonSocial: null, centros };
36
+ }
37
+ /** Collapse an HTML fragment to plain text (strip tags, decode &nbsp;, squash whitespace). */
38
+ function pText(s) {
39
+ return s.replace(/<[^>]+>/g, ' ').replace(/&nbsp;/g, ' ').replace(/\s+/g, ' ').trim();
40
+ }
41
+ /**
42
+ * Parse the FichaNimaAccion.icm detail into the razón social, the center NIMA, and its
43
+ * authorizations (verified against test/fixtures/cm-ficha-consenur-90181.html).
44
+ *
45
+ * Razón Social and NIMA come from the "Sede"/"Centro" fieldsets:
46
+ * - `<b>Razón Social:</b> CONSENUR SANITARIOS, S.L.</td>` — plain text between the label and the cell close.
47
+ * - `var nima = '2800006779';` — a JS literal inside the NIMA cell (the surrounding
48
+ * `document.write(nima)` is a UI-only rendering detail we ignore, same as parseCenterList).
49
+ *
50
+ * The "Autorizaciones" table rows are `Tipo | Nº | Estado | …4 date cells… | Consultar button`,
51
+ * one row per authorization type held by the center. The real markup has malformed nested
52
+ * `<tr>`s (`<tr>\n<tr bgcolor="...">`) which we don't need to care about since we match on
53
+ * `<td>`/`<p>` boundaries rather than row boundaries. We map:
54
+ * authId = code before ' - ' in the Tipo cell, uppercased ('G01 - Centro Gestor…' -> 'G01')
55
+ * autorizacion = the Nº Autorización cell's text
56
+ * estado = true iff the Estado cell's text is exactly 'Autorizado/Registrado' (else e.g. 'Baja' -> false)
57
+ * We scope the match to the text after the "Autorizaciones" heading so unrelated <p>s
58
+ * elsewhere in the page can't be mistaken for a row.
59
+ */
60
+ export function parseAuthorizations(html) {
61
+ const rs = html.match(/Razón Social:<\/b>\s*([^<]+?)\s*<\/td>/);
62
+ const razonSocial = rs ? rs[1].trim() : null;
63
+ const nm = html.match(/var nima\s*=\s*'(\d+)'/);
64
+ const nima = nm ? Number(nm[1]) : null;
65
+ const fsStart = html.indexOf('Autorizaciones');
66
+ const scope = fsStart >= 0 ? html.slice(fsStart) : html;
67
+ const autorizaciones = [];
68
+ // Tipo cell's "<code> - <desc>" <p>, then the next two <td> cells (Nº, Estado) in document order.
69
+ const rowRe = /<p>\s*([A-Z]\d{2})\s*-[^<]*<\/p>[\s\S]*?<td[^>]*>\s*([\s\S]*?)<\/td>\s*<td[^>]*>\s*([\s\S]*?)<\/td>/g;
70
+ let m;
71
+ while ((m = rowRe.exec(scope)) !== null) {
72
+ const authId = m[1].toUpperCase();
73
+ const numero = pText(m[2]);
74
+ const estadoText = pText(m[3]);
75
+ if (!numero)
76
+ continue;
77
+ autorizaciones.push({
78
+ authId,
79
+ autorizacion: numero,
80
+ estado: estadoText === 'Autorizado/Registrado',
81
+ });
82
+ }
83
+ return { razonSocial, nima, autorizaciones };
84
+ }
85
+ const CM_BASE = 'https://gestiona.comunidad.madrid/pcea_nima_web/html/web';
86
+ function form(fields) {
87
+ return new URLSearchParams({
88
+ posicionActual: '0', idCentro: '', tipoCentro: '', nif: '', nima: '', denominacion: '',
89
+ autorizacion: '', cdProvincia: '', dsProvincia: '', cdMunicipio: '', dsMunicipio: '',
90
+ ...fields,
91
+ }).toString();
92
+ }
93
+ /**
94
+ * The CM serves `Content-Type: text/html; charset=ISO-8859-1` (Latin-1) — the `<meta>` tag
95
+ * inside the HTML claims UTF-8, but the HTTP header is authoritative and the actual bytes are
96
+ * Latin-1 (e.g. `Razón` is `52 61 7a f3 6e`, where `0xf3` is `ó` in Latin-1, not UTF-8). Decoding
97
+ * with `res.text()` (UTF-8) mangles accented characters (`RÍO` -> `R�O`) and breaks the
98
+ * razonSocial regex entirely (`Razón Social` -> `Raz�n Social`, never matches -> null nombre).
99
+ * We always decode as Latin-1 regardless of what the response claims.
100
+ */
101
+ async function cmPost(fetchImpl, action, fields) {
102
+ const res = await fetchImpl(`${CM_BASE}/${action}`, {
103
+ method: 'POST',
104
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
105
+ body: form(fields),
106
+ });
107
+ if (!res.ok)
108
+ throw new Error(`CM service error ${res.status} on ${action}`);
109
+ const buf = await res.arrayBuffer();
110
+ return new TextDecoder('iso-8859-1').decode(buf);
111
+ }
112
+ /**
113
+ * Orchestrate a NIMA registry lookup: search the CM by NIF (paginating through the result
114
+ * pages), then fetch the ficha for each target center and assemble the nested
115
+ * `Entidad -> centros -> autorizaciones` graph.
116
+ *
117
+ * When `args.nima` is given, we page only until that NIMA is found among the search results
118
+ * (or we run out of pages), then fetch just that one center's ficha. Without `args.nima`, every
119
+ * center found across all pages is fetched.
120
+ *
121
+ * `fetchImpl` is injectable so tests never hit the network; it defaults to the global `fetch`.
122
+ */
123
+ export async function lookupNimaAuthorizations(args, fetchImpl = fetch) {
124
+ const nif = args.nif.trim().toUpperCase();
125
+ if (!nif)
126
+ throw new Error('nif is required');
127
+ // 1. Search by NIF, paginating through result pages. The CM shows "Número total de
128
+ // registros: N" in the response; we page while more rows remain and (if a target NIMA was
129
+ // requested) we haven't found it yet. Capped defensively at 10 pages.
130
+ //
131
+ // Pagination mechanism: live testing against the real CM proved `accion_paginacion` (page
132
+ // number / 'Siguiente') does NOTHING — it returns page 1's same rows regardless of value.
133
+ // The real mechanism is the `posicionActual` OFFSET: posicionActual=0 -> rows 1..10,
134
+ // posicionActual=10 -> row 11, etc. So we advance posicionActual by the page size (10)
135
+ // each iteration and drop accion_paginacion entirely.
136
+ const PAGE_SIZE = 10;
137
+ const centers = [];
138
+ const seenIdCentro = new Set();
139
+ let page = 0;
140
+ for (; page < 10; page++) {
141
+ const html = await cmPost(fetchImpl, 'ConsultaNimaAccion.icm', { nif, posicionActual: String(page * PAGE_SIZE) });
142
+ const parsed = parseCenterList(html);
143
+ // De-dup guard: a bad/repeated offset should never duplicate a center already collected.
144
+ const beforeCount = centers.length;
145
+ for (const c of parsed.centros) {
146
+ if (seenIdCentro.has(c.idCentro))
147
+ continue;
148
+ seenIdCentro.add(c.idCentro);
149
+ centers.push(c);
150
+ }
151
+ const total = Number(html.match(/Número total de registros:\s*(\d+)/)?.[1] ?? centers.length);
152
+ const nimaFound = args.nima ? centers.some(c => c.nima === args.nima) : false;
153
+ // Finding 2: a page that adds zero new centers means we're no longer making progress
154
+ // (e.g. the server ignores posicionActual and re-serves the same rows) — stop instead of
155
+ // burning the remaining page budget on repeats.
156
+ if (centers.length === beforeCount)
157
+ break;
158
+ if (args.nima && nimaFound)
159
+ break;
160
+ // Finding 1: a small/wrong `total` must never cut off a search that is still looking for
161
+ // an unfound target nima — only let `>= total` end the loop when we're gathering ALL
162
+ // centers (no nima filter) or the requested nima has already been found.
163
+ if (!args.nima && centers.length >= total)
164
+ break;
165
+ }
166
+ if (centers.length === 0)
167
+ throw new Error(`Sin centros en el registro NIMA para NIF ${nif}`);
168
+ const targets = args.nima
169
+ ? centers.filter(c => c.nima === args.nima)
170
+ : centers;
171
+ if (args.nima && targets.length === 0) {
172
+ throw new Error(`NIMA ${args.nima} no está entre los centros del NIF ${nif}`);
173
+ }
174
+ // 2. Fetch each target center's ficha and assemble the nested graph.
175
+ const out = [];
176
+ let razonSocial = null;
177
+ for (const c of targets) {
178
+ const ficha = await cmPost(fetchImpl, 'FichaNimaAccion.icm', { idCentro: String(c.idCentro), tipoCentro: c.tipoCentro, nif });
179
+ const parsed = parseAuthorizations(ficha);
180
+ if (parsed.razonSocial)
181
+ razonSocial = parsed.razonSocial;
182
+ out.push({ nima: c.nima, direccion: c.direccion, autorizaciones: parsed.autorizaciones });
183
+ }
184
+ return { entidad: { cif: nif, nombre: razonSocial, centros: out } };
185
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ZincAppClient } from '../client.js';
3
+ export declare function registerApplyAuthorization(server: McpServer, client: ZincAppClient): void;
@@ -0,0 +1,41 @@
1
+ import { z } from 'zod';
2
+ import { resolveElevation } from './elevation.js';
3
+ // One authorization to upsert on an entity center. authId = static TYPE (T01/T02/G0x from the
4
+ // catalogue); autorizacion = the official registry NUMBER the human supplies (the agent never invents it).
5
+ const AuthSchema = z.object({
6
+ clientId: z.number().describe('The entity (client) id — from audit_entities / propose_authorizations'),
7
+ centerId: z.number().describe('The entity center id'),
8
+ authId: z.string().describe('The static authorization TYPE code, e.g. "T01" (hazardous transport), "G01" (gestor)'),
9
+ autorizacion: z.string().describe('The official registry NUMBER for this authorization — YOU (the human) provide it; the agent never invents it'),
10
+ });
11
+ export function registerApplyAuthorization(server, client) {
12
+ server.tool('apply_authorization', 'Write (upsert) entity-center authorizations — the master-data WRITE complement to ' +
13
+ 'propose_authorizations. propose_authorizations tells you WHICH authorization TYPE a ' +
14
+ 'transporter/gestor is missing (e.g. T01); this writes it, WITH the official registry number ' +
15
+ 'THE HUMAN SUPPLIES in each item\'s `autorizacion`. The agent must NEVER invent that number — ' +
16
+ 'it is administrative master data (the company\'s inscription code in the waste registry), not ' +
17
+ 'something in the DIs. Each item: {clientId, centerId, authId (the type), autorizacion (the ' +
18
+ 'number)}. Upsert: updates the number if the authorization already exists, else creates it. ' +
19
+ 'Runs UNDER YOUR ACTIVE OPERATOR ELEVATION (company derived from it, no companyId); writes ' +
20
+ 'through the framework (hooks + audit) as the elevated _sysadmin, recording you in the audit ' +
21
+ 'trail. Only apply numbers you (the human) have verified.', {
22
+ authorizations: z.array(AuthSchema)
23
+ .describe('The authorizations to write, each with the registry number you provide'),
24
+ }, async ({ authorizations }) => {
25
+ const elev = await resolveElevation(client);
26
+ if ('content' in elev)
27
+ return elev;
28
+ const companyId = elev.companyId;
29
+ const result = await client.post('/mwm/retirada/apply-authorization', { confirm: true, authorizations });
30
+ const ok = result.applied;
31
+ const bad = result.failed.length;
32
+ const failLines = bad
33
+ ? '\n\n### Fallidas\n' + result.failed.map(f => `- entidad ${f.clientId}/${f.centerId} · ${f.authId}: ${f.error}`).join('\n')
34
+ : '';
35
+ const text = `# Autorizaciones aplicadas · company ${companyId}\n` +
36
+ `${ok} autorización(es) escrita(s)${bad ? `, ${bad} con error` : ''}, bajo tu elevación de operador.` +
37
+ failLines +
38
+ `\n\n_Recuerda des-elevarte desde el portal web con un motivo cuando termines._`;
39
+ return { content: [{ type: 'text', text }] };
40
+ });
41
+ }
@@ -12,7 +12,10 @@ import { registerProposeCompletion } from './propose-completion.js';
12
12
  import { registerAuditEntities } from './audit-entities.js';
13
13
  import { registerApplyCompletion } from './apply-completion.js';
14
14
  import { registerProposeAuthorizations } from './propose-authorizations.js';
15
+ import { registerApplyAuthorization } from './apply-authorization.js';
16
+ import { registerLinkGestorAuthorizations } from './link-gestor-authorizations.js';
15
17
  import { registerProposeNt } from './propose-nt.js';
18
+ import { registerLookupAuthorization } from './lookup-authorization.js';
16
19
  export function registerAllTools(server, client) {
17
20
  registerSearchDocs(server, client);
18
21
  registerReadDoc(server, client);
@@ -28,5 +31,8 @@ export function registerAllTools(server, client) {
28
31
  registerAuditEntities(server, client);
29
32
  registerApplyCompletion(server, client);
30
33
  registerProposeAuthorizations(server, client);
34
+ registerApplyAuthorization(server, client);
35
+ registerLinkGestorAuthorizations(server, client);
31
36
  registerProposeNt(server, client);
37
+ registerLookupAuthorization(server, client);
32
38
  }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ZincAppClient } from '../client.js';
3
+ export declare function registerLinkGestorAuthorizations(server: McpServer, client: ZincAppClient): void;
@@ -0,0 +1,42 @@
1
+ import { z } from 'zod';
2
+ import { resolveElevation } from './elevation.js';
3
+ export function registerLinkGestorAuthorizations(server, client) {
4
+ server.tool('link_gestor_authorizations', 'Backfill the missing gestor authorization LINK on a year\'s waste removals — the fix for the ' +
5
+ 'register\'s `autorizacion-gestor` gap. The importer created the gestor EntidadCentroLer rows ' +
6
+ 'with an empty auth_id; this links each to the center\'s already-loaded gestor authorization ' +
7
+ '(G0x) using the SAME rule the register enforces (destino + hazard class). Without confirm it ' +
8
+ 'is a DRY-RUN (shows what would be linked and what can\'t resolve); with confirm=true it writes ' +
9
+ 'under your active operator elevation (company derived from it). Rows whose center has no ' +
10
+ 'matching destino authorization are reported, never forced — load the G0x with ' +
11
+ 'apply_authorization first. Read the dry-run before confirming.', {
12
+ year: z.number().describe('Calendar year, e.g. 2025'),
13
+ confirm: z.boolean().optional().describe('true to write; omit/false for a dry-run'),
14
+ }, async ({ year, confirm }) => {
15
+ const elev = await resolveElevation(client);
16
+ if ('content' in elev)
17
+ return elev;
18
+ const body = { year, confirm: confirm === true };
19
+ const res = confirm === true
20
+ ? await client.post('/mwm/retirada/link-gestor-authorizations', body)
21
+ : await client.post('/mwm/retirada/link-gestor-authorizations', body);
22
+ if (confirm !== true) {
23
+ const d = res;
24
+ const sampleR = d.resoluble.slice(0, 15).map(r => ` - ${r.clientId}/${r.centerId} · LER ${r.codigoLer} → \`${r.authId}\``).join('\n');
25
+ const sampleU = d.unresoluble.slice(0, 15).map(u => ` - ${u.clientId}/${u.centerId} · LER ${u.codigoLer}: ${u.reason}`).join('\n');
26
+ const text = `# Dry-run · vincular autorización de gestor · ${d.year}\n` +
27
+ `${d.total} fila(s) EntidadCentroLer sin auth_id · ${d.resoluble.length} resolubles · ${d.unresoluble.length} sin resolver.\n\n` +
28
+ (d.resoluble.length ? `## Se vincularían (authId)\n${sampleR}${d.resoluble.length > 15 ? `\n … +${d.resoluble.length - 15} más` : ''}\n\n` : '') +
29
+ (d.unresoluble.length ? `## No resolubles (falta cargar la G0x)\n${sampleU}${d.unresoluble.length > 15 ? `\n … +${d.unresoluble.length - 15} más` : ''}\n\n` : '') +
30
+ `_Ejecuta con confirm=true para aplicar las ${d.resoluble.length} resolubles._`;
31
+ return { content: [{ type: 'text', text }] };
32
+ }
33
+ const r = res;
34
+ const failLines = r.failed.length ? '\n\n### Fallidas\n' + r.failed.map(f => `- ${f.clientId}/${f.centerId} LER ${f.codigoLer}: ${f.error}`).join('\n') : '';
35
+ const text = `# Aplicado · vincular autorización de gestor · ${r.year}\n` +
36
+ `${r.applied} vinculada(s)${r.skipped ? `, ${r.skipped} ya poblada(s)` : ''}${r.failed.length ? `, ${r.failed.length} con error` : ''}, bajo tu elevación.` +
37
+ (r.unresoluble.length ? `\n${r.unresoluble.length} sin resolver (falta cargar su G0x — usa apply_authorization).` : '') +
38
+ failLines +
39
+ `\n\n_Recuerda des-elevarte desde el portal web con un motivo cuando termines._`;
40
+ return { content: [{ type: 'text', text }] };
41
+ });
42
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { ZincAppClient } from '../client.js';
3
+ export declare function registerLookupAuthorization(server: McpServer, _client: ZincAppClient): void;
@@ -0,0 +1,38 @@
1
+ import { z } from 'zod';
2
+ import { lookupNimaAuthorizations } from '../madrid-nima.js';
3
+ export function registerLookupAuthorization(server, _client) {
4
+ server.tool('lookup_authorization', 'Look up an entity\'s waste authorizations in the OFFICIAL Comunidad de Madrid NIMA registry ' +
5
+ '(gestiona.comunidad.madrid) by NIF — the authoritative source for the registry NUMBER that ' +
6
+ 'the agent must never invent. Returns the entity with its centers (one per NIMA) and each ' +
7
+ 'center\'s authorizations, mapped to the system\'s authId codes (T01/G01/…). IMPORTANT: in ' +
8
+ 'the CM registry a company has DIFFERENT NIMAs per role — the transport authorization (T01/T02) ' +
9
+ 'lives on a different NIMA/center than the gestor authorization (G0x) — so this reveals which ' +
10
+ 'NIMA holds which. Read-only, proposes only: pass a found number to apply_authorization to write ' +
11
+ 'it. If a `nima` is given, only that center is returned.', {
12
+ nif: z.string().describe('The entity NIF/CIF, e.g. "B86208824" (from audit_entities / propose_authorizations)'),
13
+ nima: z.number().optional().describe('Restrict to the center with this exact NIMA (else all centers are returned)'),
14
+ }, async ({ nif, nima }) => {
15
+ let result;
16
+ try {
17
+ result = await lookupNimaAuthorizations({ nif, nima });
18
+ }
19
+ catch (e) {
20
+ return { content: [{ type: 'text', text: `No se pudo consultar el registro NIMA: ${e?.message ?? e}` }] };
21
+ }
22
+ const e = result.entidad;
23
+ if (e.centros.length === 0) {
24
+ return { content: [{ type: 'text', text: `Sin centros en el registro NIMA para NIF ${e.cif}.` }] };
25
+ }
26
+ const blocks = e.centros.map(c => {
27
+ const auths = c.autorizaciones.length
28
+ ? c.autorizaciones.map(a => ` - \`${a.authId}\` · ${a.autorizacion} · ${a.estado ? 'ACTIVA' : 'baja'}`).join('\n')
29
+ : ' _(sin autorizaciones)_';
30
+ return `### NIMA ${c.nima}\n${c.direccion}\n${auths}`;
31
+ }).join('\n\n');
32
+ const text = `# Registro NIMA (Comunidad de Madrid) · ${e.nombre ?? e.cif}\n` +
33
+ `NIF ${e.cif} · ${e.centros.length} centro(s)\n\n` +
34
+ blocks +
35
+ `\n\n_Fuente oficial. Para escribir un número: apply_authorization con el authId y el número ACTIVO correcto._`;
36
+ return { content: [{ type: 'text', text }] };
37
+ });
38
+ }
@@ -23,11 +23,14 @@ export function registerProposeAuthorizations(server, client) {
23
23
  }
24
24
  const blocks = r.proposals.map(p => {
25
25
  const name = p.nombre ?? p.cif ?? '(sin nombre)';
26
+ const coords = (p.clientId != null && p.centerId != null)
27
+ ? ` · entidad **${p.clientId}/${p.centerId}** (para apply_authorization)`
28
+ : ' · _(sin centro resoluble — no aplicable directamente)_';
26
29
  const cands = p.candidates.length
27
30
  ? p.candidates.map(c => ` - \`${c.authId}\`${c.name ? ` — ${c.name}` : ''}`).join('\n')
28
31
  : ' _(sin candidatos en el catálogo)_';
29
32
  const warns = p.warnings.map(w => ` ⚠️ ${w}`).join('\n');
30
- return `### ${name} — ${p.usedByRemovals} retirada(s)${p.peligroso ? ' · peligroso' : ''}\nCandidatos (authId):\n${cands}\n${warns}`;
33
+ return `### ${name} — ${p.usedByRemovals} retirada(s)${p.peligroso ? ' · peligroso' : ''}${coords}\nCandidatos (authId):\n${cands}\n${warns}`;
31
34
  }).join('\n\n');
32
35
  const text = `# Propuesta de autorizaciones · ${r.role}s · company ${companyId}\n${r.proposals.length} sin autorización\n\n${blocks}`;
33
36
  return { content: [{ type: 'text', text }] };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zincapp/mcp-server",
3
- "version": "1.5.0",
3
+ "version": "1.8.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": {
@@ -10,7 +10,9 @@
10
10
  "scripts": {
11
11
  "build": "tsc",
12
12
  "dev": "tsc --watch",
13
- "start": "node dist/index.js"
13
+ "start": "node dist/index.js",
14
+ "build:test": "tsc -p tsconfig.test.json",
15
+ "test": "npm run build:test && node --test \"dist-test/test/**/*.test.js\""
14
16
  },
15
17
  "keywords": [
16
18
  "mcp",