@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.
- package/dist/madrid-nima.d.ts +89 -0
- package/dist/madrid-nima.js +185 -0
- package/dist/tools/apply-authorization.d.ts +3 -0
- package/dist/tools/apply-authorization.js +41 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/link-gestor-authorizations.d.ts +3 -0
- package/dist/tools/link-gestor-authorizations.js +42 -0
- package/dist/tools/lookup-authorization.d.ts +3 -0
- package/dist/tools/lookup-authorization.js +38 -0
- package/dist/tools/propose-authorizations.js +4 -1
- package/package.json +4 -2
|
@@ -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 , squash whitespace). */
|
|
38
|
+
function pText(s) {
|
|
39
|
+
return s.replace(/<[^>]+>/g, ' ').replace(/ /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,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
|
+
}
|
package/dist/tools/index.js
CHANGED
|
@@ -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,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,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.
|
|
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",
|