@pharmadate/mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/dist/auth/crypto.d.ts +24 -0
- package/dist/auth/crypto.js +110 -0
- package/dist/auth/login-page.d.ts +32 -0
- package/dist/auth/login-page.js +98 -0
- package/dist/auth/oauth-provider.d.ts +92 -0
- package/dist/auth/oauth-provider.js +208 -0
- package/dist/auth/redirect-trust.d.ts +26 -0
- package/dist/auth/redirect-trust.js +94 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.js +131 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +28 -0
- package/dist/http.d.ts +79 -0
- package/dist/http.js +272 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +114 -0
- package/dist/limits.d.ts +74 -0
- package/dist/limits.js +152 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.js +34 -0
- package/dist/operation-mode.d.ts +14 -0
- package/dist/operation-mode.js +10 -0
- package/dist/pharmacy-resolver.d.ts +54 -0
- package/dist/pharmacy-resolver.js +92 -0
- package/dist/principal.d.ts +30 -0
- package/dist/principal.js +85 -0
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.js +42 -0
- package/dist/resources.d.ts +15 -0
- package/dist/resources.js +58 -0
- package/dist/results.d.ts +42 -0
- package/dist/results.js +121 -0
- package/dist/scrub.d.ts +18 -0
- package/dist/scrub.js +49 -0
- package/dist/server-instructions.d.ts +8 -0
- package/dist/server-instructions.js +39 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +59 -0
- package/dist/tools/common.d.ts +9 -0
- package/dist/tools/common.js +225 -0
- package/dist/tools/index.d.ts +15 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/operate.d.ts +15 -0
- package/dist/tools/operate.js +24 -0
- package/dist/tools/partner.d.ts +2 -0
- package/dist/tools/partner.js +176 -0
- package/dist/tools/types.d.ts +39 -0
- package/dist/tools/types.js +3 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +15 -0
- package/package.json +75 -4
- package/server.json +56 -0
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clasificacion de cada herramienta por tipo de operacion y modo de operacion.
|
|
3
|
+
*
|
|
4
|
+
* READ_ONLY solo herramientas de lectura (incluye `createQuote` y `lookupConditions`, que son POST pero no escriben)
|
|
5
|
+
* STANDARD lectura y escritura (las destructivas llevan `destructiveHint` para que el cliente pida confirmacion)
|
|
6
|
+
*
|
|
7
|
+
* Una herramienta que no esta en la tabla se DENIEGA (fail-closed).
|
|
8
|
+
*
|
|
9
|
+
* @module operation-mode
|
|
10
|
+
*/
|
|
11
|
+
import type { OperationMode } from './config.js';
|
|
12
|
+
export type OperationType = 'READ' | 'WRITE' | 'DELETE';
|
|
13
|
+
export declare function isAllowed(mode: OperationMode, op: OperationType | undefined): boolean;
|
|
14
|
+
export declare function deniedMessage(toolName: string, mode: OperationMode): string;
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export function isAllowed(mode, op) {
|
|
2
|
+
if (!op)
|
|
3
|
+
return false;
|
|
4
|
+
if (mode === 'STANDARD')
|
|
5
|
+
return true;
|
|
6
|
+
return op === 'READ';
|
|
7
|
+
}
|
|
8
|
+
export function deniedMessage(toolName, mode) {
|
|
9
|
+
return `La herramienta ${toolName} escribe datos y no esta permitida en modo ${mode}. Arranca el MCP con PHARMADATE_MCP_MODE=STANDARD para permitir escrituras.`;
|
|
10
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolucion de la farmacia sobre la que opera un partner.
|
|
3
|
+
*
|
|
4
|
+
* PUNTO CRITICO DE SEGURIDAD. Con una credencial de partner, cualquier ruta con `apiKeyAuth` (`/quote`, `/orders`,
|
|
5
|
+
* `/conditions`...) DA DE ALTA AUTOMATICAMENTE una farmacia cuyo CIF no existe (`apiV1.js`, `apiKeyAuth`). Un CIF mal
|
|
6
|
+
* escrito por el modelo crearia, por tanto, una farmacia fantasma. Para que eso no pueda pasar:
|
|
7
|
+
*
|
|
8
|
+
* 1. La herramienta recibe `pharmacy: { pharmacy_id }` (preferido) o `pharmacy: { cif }`.
|
|
9
|
+
* 2. Se RESUELVE ANTES con `GET /pharmacies/{id}` o `GET /pharmacies?cif=` (ambas solo leen y solo ven las farmacias
|
|
10
|
+
* del propio partner).
|
|
11
|
+
* 3. Si no aparece, o esta de baja, se responde con un error y NO se llama a la API operativa.
|
|
12
|
+
* 4. Solo entonces se envia `X-Pharmacy-Ref` con el CIF ya verificado.
|
|
13
|
+
* 5. NUNCA se envia `pharmacy` ni `pharmacy_ref` en el cuerpo (ademas lo impide `PharmadateClient`).
|
|
14
|
+
*
|
|
15
|
+
* Con una API key de farmacia no hay nada que resolver: la propia credencial identifica a la farmacia y la herramienta
|
|
16
|
+
* ni siquiera expone el argumento `pharmacy`.
|
|
17
|
+
*
|
|
18
|
+
* @module pharmacy-resolver
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
22
|
+
import type { PharmadateClient } from './client.js';
|
|
23
|
+
import type { Principal } from './principal.js';
|
|
24
|
+
/** Argumento `pharmacy` de las herramientas operativas, solo para partners. */
|
|
25
|
+
export declare const pharmacyRefShape: z.ZodObject<{
|
|
26
|
+
pharmacy_id: z.ZodOptional<z.ZodNumber>;
|
|
27
|
+
cif: z.ZodOptional<z.ZodString>;
|
|
28
|
+
}, "strip", z.ZodTypeAny, {
|
|
29
|
+
pharmacy_id?: number | undefined;
|
|
30
|
+
cif?: string | undefined;
|
|
31
|
+
}, {
|
|
32
|
+
pharmacy_id?: number | undefined;
|
|
33
|
+
cif?: string | undefined;
|
|
34
|
+
}>;
|
|
35
|
+
export type PharmacyRefArg = z.infer<typeof pharmacyRefShape>;
|
|
36
|
+
export interface ResolvedPharmacy {
|
|
37
|
+
id: number;
|
|
38
|
+
cif: string;
|
|
39
|
+
nombre: string;
|
|
40
|
+
}
|
|
41
|
+
export type Resolution = {
|
|
42
|
+
ok: true;
|
|
43
|
+
cif: string | undefined;
|
|
44
|
+
pharmacy?: ResolvedPharmacy;
|
|
45
|
+
} | {
|
|
46
|
+
ok: false;
|
|
47
|
+
result: CallToolResult;
|
|
48
|
+
};
|
|
49
|
+
export declare const normalizeCif: (cif: string) => string;
|
|
50
|
+
/**
|
|
51
|
+
* Devuelve el CIF que debe ir en `X-Pharmacy-Ref`, o el resultado de error que la herramienta debe devolver tal cual.
|
|
52
|
+
* Con una key de farmacia devuelve `{ok:true, cif: undefined}` y no llama a nada.
|
|
53
|
+
*/
|
|
54
|
+
export declare function resolvePharmacy(client: PharmadateClient, principal: Principal, ref: PharmacyRefArg | undefined): Promise<Resolution>;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolucion de la farmacia sobre la que opera un partner.
|
|
3
|
+
*
|
|
4
|
+
* PUNTO CRITICO DE SEGURIDAD. Con una credencial de partner, cualquier ruta con `apiKeyAuth` (`/quote`, `/orders`,
|
|
5
|
+
* `/conditions`...) DA DE ALTA AUTOMATICAMENTE una farmacia cuyo CIF no existe (`apiV1.js`, `apiKeyAuth`). Un CIF mal
|
|
6
|
+
* escrito por el modelo crearia, por tanto, una farmacia fantasma. Para que eso no pueda pasar:
|
|
7
|
+
*
|
|
8
|
+
* 1. La herramienta recibe `pharmacy: { pharmacy_id }` (preferido) o `pharmacy: { cif }`.
|
|
9
|
+
* 2. Se RESUELVE ANTES con `GET /pharmacies/{id}` o `GET /pharmacies?cif=` (ambas solo leen y solo ven las farmacias
|
|
10
|
+
* del propio partner).
|
|
11
|
+
* 3. Si no aparece, o esta de baja, se responde con un error y NO se llama a la API operativa.
|
|
12
|
+
* 4. Solo entonces se envia `X-Pharmacy-Ref` con el CIF ya verificado.
|
|
13
|
+
* 5. NUNCA se envia `pharmacy` ni `pharmacy_ref` en el cuerpo (ademas lo impide `PharmadateClient`).
|
|
14
|
+
*
|
|
15
|
+
* Con una API key de farmacia no hay nada que resolver: la propia credencial identifica a la farmacia y la herramienta
|
|
16
|
+
* ni siquiera expone el argumento `pharmacy`.
|
|
17
|
+
*
|
|
18
|
+
* @module pharmacy-resolver
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
import { ApiNetworkError } from './client.js';
|
|
22
|
+
import { apiErrorResult, errResult, networkErrorResult } from './results.js';
|
|
23
|
+
const CIF_VALIDO = /^[A-Z0-9]{8,12}$/;
|
|
24
|
+
/** Argumento `pharmacy` de las herramientas operativas, solo para partners. */
|
|
25
|
+
export const pharmacyRefShape = z
|
|
26
|
+
.object({
|
|
27
|
+
pharmacy_id: z.number().int().positive().optional().describe('Id de la farmacia en Pharmadate (el `id` que devuelven listPharmacies / createPharmacy). PREFERIDO.'),
|
|
28
|
+
cif: z.string().min(8).max(12).optional().describe('CIF de la farmacia, solo si no tienes su id. Debe ser una farmacia YA dada de alta entre las tuyas (comprueba con listPharmacies).'),
|
|
29
|
+
})
|
|
30
|
+
.describe('Farmacia sobre la que operar (obligatorio con una key de partner). Indica pharmacy_id (preferido) o cif. Se resuelve ANTES de operar: si no es una farmacia tuya y activa, no se hace nada (nunca se da de alta una nueva).');
|
|
31
|
+
export const normalizeCif = (cif) => cif.trim().toUpperCase();
|
|
32
|
+
const NOT_FOUND = 'Farmacia no encontrada entre las tuyas. No se ha llamado a la API operativa (para no dar de alta una farmacia nueva por error). ' +
|
|
33
|
+
'Comprueba el id o el CIF con listPharmacies; si la farmacia no existe todavia, crea con createPharmacy.';
|
|
34
|
+
/**
|
|
35
|
+
* Devuelve el CIF que debe ir en `X-Pharmacy-Ref`, o el resultado de error que la herramienta debe devolver tal cual.
|
|
36
|
+
* Con una key de farmacia devuelve `{ok:true, cif: undefined}` y no llama a nada.
|
|
37
|
+
*/
|
|
38
|
+
export async function resolvePharmacy(client, principal, ref) {
|
|
39
|
+
if (principal.role === 'pharmacy')
|
|
40
|
+
return { ok: true, cif: undefined };
|
|
41
|
+
if (!ref || (ref.pharmacy_id === undefined && !ref.cif)) {
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
result: errResult('Falta la farmacia. Con una key de partner indica pharmacy: { pharmacy_id } (preferido) o pharmacy: { cif }. Consulta tus farmacias con listPharmacies.', { error_code: 'pharmacy_required' }),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
if (ref.cif !== undefined && !CIF_VALIDO.test(normalizeCif(ref.cif))) {
|
|
48
|
+
return { ok: false, result: errResult('El CIF no es valido: son 8 a 12 letras o numeros, sin espacios ni guiones.', { error_code: 'invalid_cif' }) };
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
let found;
|
|
52
|
+
if (ref.pharmacy_id !== undefined) {
|
|
53
|
+
const res = await client.request({ method: 'GET', path: `/v1/pharmacies/${ref.pharmacy_id}` });
|
|
54
|
+
if (res.status === 404)
|
|
55
|
+
return { ok: false, result: errResult(NOT_FOUND, { error_code: 'pharmacy_not_found', request_id: res.requestId }) };
|
|
56
|
+
if (!res.ok)
|
|
57
|
+
return { ok: false, result: apiErrorResult(res) };
|
|
58
|
+
found = res.body;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const cif = normalizeCif(ref.cif);
|
|
62
|
+
const res = await client.request({ method: 'GET', path: '/v1/pharmacies', query: { cif, per_page: 2 } });
|
|
63
|
+
if (!res.ok)
|
|
64
|
+
return { ok: false, result: apiErrorResult(res) };
|
|
65
|
+
const lista = (res.body?.pharmacies ?? []).filter((f) => normalizeCif(f.cif) === cif);
|
|
66
|
+
found = lista.find((f) => f.active) ?? lista[0];
|
|
67
|
+
if (!found)
|
|
68
|
+
return { ok: false, result: errResult(NOT_FOUND, { error_code: 'pharmacy_not_found', request_id: res.requestId }) };
|
|
69
|
+
}
|
|
70
|
+
if (!found || !found.cif)
|
|
71
|
+
return { ok: false, result: errResult(NOT_FOUND, { error_code: 'pharmacy_not_found' }) };
|
|
72
|
+
// Si se dieron las dos cosas, tienen que ser la misma farmacia
|
|
73
|
+
if (ref.pharmacy_id !== undefined && ref.cif !== undefined && normalizeCif(ref.cif) !== normalizeCif(found.cif)) {
|
|
74
|
+
return {
|
|
75
|
+
ok: false,
|
|
76
|
+
result: errResult(`El CIF indicado no coincide con el de la farmacia ${found.id}. No se ha operado. Indica solo pharmacy_id o revisa el CIF.`, { error_code: 'pharmacy_mismatch' }),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (!found.active) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
result: errResult(`La farmacia ${found.id} (${found.nombre}) esta DADA DE BAJA y no puede operar. Llama a reactivatePharmacy con id ${found.id} (genera una API key nueva) y vuelve a intentarlo. No se ha hecho nada.`, { error_code: 'pharmacy_inactive' }),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
return { ok: true, cif: normalizeCif(found.cif), pharmacy: { id: found.id, cif: normalizeCif(found.cif), nombre: found.nombre } };
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
if (err instanceof ApiNetworkError)
|
|
89
|
+
return { ok: false, result: networkErrorResult(err) };
|
|
90
|
+
throw err;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { PharmadateClient } from './client.js';
|
|
2
|
+
export type Role = 'partner' | 'pharmacy';
|
|
3
|
+
export interface Principal {
|
|
4
|
+
role: Role;
|
|
5
|
+
partnerId?: number;
|
|
6
|
+
partnerName?: string;
|
|
7
|
+
pharmacyId?: number;
|
|
8
|
+
pharmacyName?: string;
|
|
9
|
+
}
|
|
10
|
+
/** La credencial no es valida (401) o la API no pudo confirmarla. */
|
|
11
|
+
export declare class PrincipalError extends Error {
|
|
12
|
+
readonly kind: 'unauthorized' | 'unavailable';
|
|
13
|
+
constructor(message: string, kind: 'unauthorized' | 'unavailable');
|
|
14
|
+
}
|
|
15
|
+
export declare function detectPrincipal(client: PharmadateClient): Promise<Principal>;
|
|
16
|
+
/** Hash estable de una credencial, para cachear y para limitar sin guardar la key. */
|
|
17
|
+
export declare function credentialId(apiKey: string): string;
|
|
18
|
+
/** Cache de roles con TTL. Acotada para que un barrido de keys no agote la memoria. */
|
|
19
|
+
export declare class PrincipalCache {
|
|
20
|
+
private readonly ttlMs;
|
|
21
|
+
private readonly max;
|
|
22
|
+
private readonly ahora;
|
|
23
|
+
private readonly mapa;
|
|
24
|
+
constructor(ttlMs?: number, max?: number, ahora?: () => number);
|
|
25
|
+
get(id: string): Principal | undefined;
|
|
26
|
+
set(id: string, principal: Principal): void;
|
|
27
|
+
/** Devuelve el rol cacheado o lo detecta (y lo guarda). */
|
|
28
|
+
resolve(apiKey: string, client: PharmadateClient): Promise<Principal>;
|
|
29
|
+
get size(): number;
|
|
30
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Deteccion del rol de una credencial.
|
|
3
|
+
*
|
|
4
|
+
* Se hace con `GET /api/v1/ping`:
|
|
5
|
+
* - `partner != null` -> partner
|
|
6
|
+
* - `partner == null && pharmacy != null` -> farmacia
|
|
7
|
+
* - 401 -> credencial invalida (en remoto, el cliente relanza el OAuth)
|
|
8
|
+
*
|
|
9
|
+
* El rol se cachea 60 s por `sha256(key)`: solo `{rol, partner_id, pharmacy_id}`, nunca la key. La autorizacion real la
|
|
10
|
+
* hace la API en cada llamada, asi que una key revocada deja de servir aunque el rol siga en cache.
|
|
11
|
+
*
|
|
12
|
+
* @module principal
|
|
13
|
+
*/
|
|
14
|
+
import { createHash } from 'node:crypto';
|
|
15
|
+
/** La credencial no es valida (401) o la API no pudo confirmarla. */
|
|
16
|
+
export class PrincipalError extends Error {
|
|
17
|
+
kind;
|
|
18
|
+
constructor(message, kind) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.kind = kind;
|
|
21
|
+
this.name = 'PrincipalError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
export async function detectPrincipal(client) {
|
|
25
|
+
const res = await client.request({ method: 'GET', path: '/v1/ping' });
|
|
26
|
+
if (res.status === 401)
|
|
27
|
+
throw new PrincipalError('Credencial invalida, caducada o revocada.', 'unauthorized');
|
|
28
|
+
if (!res.ok)
|
|
29
|
+
throw new PrincipalError(`La API respondio ${res.status} al comprobar la credencial.`, 'unavailable');
|
|
30
|
+
const body = (res.body ?? {});
|
|
31
|
+
if (body.partner) {
|
|
32
|
+
return { role: 'partner', partnerId: body.partner.id, partnerName: body.partner.nombre };
|
|
33
|
+
}
|
|
34
|
+
if (body.pharmacy) {
|
|
35
|
+
return { role: 'pharmacy', pharmacyId: body.pharmacy.id, pharmacyName: body.pharmacy.nombre };
|
|
36
|
+
}
|
|
37
|
+
throw new PrincipalError('La API no devolvio ni partner ni farmacia para esta credencial.', 'unauthorized');
|
|
38
|
+
}
|
|
39
|
+
/** Hash estable de una credencial, para cachear y para limitar sin guardar la key. */
|
|
40
|
+
export function credentialId(apiKey) {
|
|
41
|
+
return createHash('sha256').update(apiKey).digest('hex');
|
|
42
|
+
}
|
|
43
|
+
/** Cache de roles con TTL. Acotada para que un barrido de keys no agote la memoria. */
|
|
44
|
+
export class PrincipalCache {
|
|
45
|
+
ttlMs;
|
|
46
|
+
max;
|
|
47
|
+
ahora;
|
|
48
|
+
mapa = new Map();
|
|
49
|
+
constructor(ttlMs = 60_000, max = 2_000, ahora = Date.now) {
|
|
50
|
+
this.ttlMs = ttlMs;
|
|
51
|
+
this.max = max;
|
|
52
|
+
this.ahora = ahora;
|
|
53
|
+
}
|
|
54
|
+
get(id) {
|
|
55
|
+
const e = this.mapa.get(id);
|
|
56
|
+
if (!e)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (e.expira <= this.ahora()) {
|
|
59
|
+
this.mapa.delete(id);
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return e.principal;
|
|
63
|
+
}
|
|
64
|
+
set(id, principal) {
|
|
65
|
+
if (this.mapa.size >= this.max) {
|
|
66
|
+
const primera = this.mapa.keys().next().value;
|
|
67
|
+
if (primera !== undefined)
|
|
68
|
+
this.mapa.delete(primera);
|
|
69
|
+
}
|
|
70
|
+
this.mapa.set(id, { principal, expira: this.ahora() + this.ttlMs });
|
|
71
|
+
}
|
|
72
|
+
/** Devuelve el rol cacheado o lo detecta (y lo guarda). */
|
|
73
|
+
async resolve(apiKey, client) {
|
|
74
|
+
const id = credentialId(apiKey);
|
|
75
|
+
const cacheado = this.get(id);
|
|
76
|
+
if (cacheado)
|
|
77
|
+
return cacheado;
|
|
78
|
+
const principal = await detectPrincipal(client);
|
|
79
|
+
this.set(id, principal);
|
|
80
|
+
return principal;
|
|
81
|
+
}
|
|
82
|
+
get size() {
|
|
83
|
+
return this.mapa.size;
|
|
84
|
+
}
|
|
85
|
+
}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Prompts MCP: plantillas de las tareas habituales.
|
|
3
|
+
*
|
|
4
|
+
* @module prompts
|
|
5
|
+
*/
|
|
6
|
+
import { z } from 'zod';
|
|
7
|
+
const user = (text) => ({ messages: [{ role: 'user', content: { type: 'text', text } }] });
|
|
8
|
+
export function registerPrompts(server, role) {
|
|
9
|
+
server.registerPrompt('placeOrder', {
|
|
10
|
+
title: 'Hacer un pedido',
|
|
11
|
+
description: 'Guia el pedido completo: buscar productos, valorar, confirmar y crear.',
|
|
12
|
+
argsSchema: { productos: z.string().describe('Que se quiere pedir (nombres o CN con unidades).'), ...(role === 'partner' ? { farmacia: z.string().describe('Id o CIF de la farmacia.') } : {}) },
|
|
13
|
+
}, (args) => user(`Quiero hacer este pedido en Pharmadate: ${args['productos']}.` +
|
|
14
|
+
(role === 'partner' ? ` Farmacia: ${args['farmacia']}.` : '') +
|
|
15
|
+
'\n\nSigue este orden: 1) localiza cada producto con getCatalog (q) y confirma conmigo los CN y las unidades dudosas; ' +
|
|
16
|
+
'2) valora la cesta con createQuote y ensename el resumen (total, lineas no servibles, pedido minimo); ' +
|
|
17
|
+
'3) NO crees el pedido hasta que yo confirme explicitamente; ' +
|
|
18
|
+
'4) con mi confirmacion, createOrder con una external_ref nueva que inventes una sola vez; 5) resume el resultado (order_id, envios).'));
|
|
19
|
+
server.registerPrompt('orderStatus', {
|
|
20
|
+
title: 'Estado de un pedido',
|
|
21
|
+
description: 'Resume el estado de un pedido: envios, faltas y entrega.',
|
|
22
|
+
argsSchema: { pedido: z.string().describe('order_id o external_ref.'), ...(role === 'partner' ? { farmacia: z.string().describe('Id o CIF de la farmacia.') } : {}) },
|
|
23
|
+
}, (args) => user(`Dime el estado del pedido ${args['pedido']}` +
|
|
24
|
+
(role === 'partner' ? ` de la farmacia ${args['farmacia']}` : '') +
|
|
25
|
+
'. Si es una referencia, busca con listOrders (external_ref). Despues mira listOrderShipments y getOrderModification y resumeme: estado, que envios hay, que falta y la entrega estimada.'));
|
|
26
|
+
if (role !== 'partner')
|
|
27
|
+
return;
|
|
28
|
+
server.registerPrompt('onboardPharmacy', {
|
|
29
|
+
title: 'Dar de alta una farmacia',
|
|
30
|
+
description: 'Alta guiada de una farmacia nueva comprobando antes que no existe.',
|
|
31
|
+
argsSchema: { datos: z.string().describe('CIF, nombre, direccion, codigo postal, localidad, provincia y email de la farmacia.') },
|
|
32
|
+
}, (args) => user(`Quiero dar de alta esta farmacia: ${args['datos']}.\n\n1) Comprueba con listPharmacies (cif) que no existe ya, tambien entre las dadas de baja. ` +
|
|
33
|
+
'2) Si existe y esta de baja, ofrece reactivatePharmacy en vez de crearla. 3) Si falta algun dato obligatorio, preguntamelo. ' +
|
|
34
|
+
'4) ENSEÑAME los datos y espera mi confirmacion. 5) createPharmacy con reveal_api_key=true para que yo reciba la API key y la guarde.'));
|
|
35
|
+
server.registerPrompt('recoverPharmacyAccess', {
|
|
36
|
+
title: 'Recuperar el acceso de una farmacia',
|
|
37
|
+
description: 'Para una farmacia que ha perdido su API key o esta de baja.',
|
|
38
|
+
argsSchema: { farmacia: z.string().describe('Id o CIF de la farmacia.') },
|
|
39
|
+
}, (args) => user(`La farmacia ${args['farmacia']} ha perdido el acceso. Localizala con listPharmacies/getPharmacy. ` +
|
|
40
|
+
'Si esta de baja: explicamelo y, con mi confirmacion, reactivatePharmacy (key nueva). Si esta activa: avisame de que rotatePharmacyApiKey invalida la key actual en el acto y, con mi confirmacion, rotala. ' +
|
|
41
|
+
'En ambos casos usa reveal_api_key=true para que la key me llegue a mi.'));
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resources MCP:
|
|
3
|
+
* pharmadate://docs/integration-guide guia corta de integracion
|
|
4
|
+
* pharmadate://docs/errors codigos de error y la accion siguiente
|
|
5
|
+
* pharmadate://docs/openapi el OpenAPI vigente (se descarga de /api-docs/openapi.yaml)
|
|
6
|
+
* pharmadate://me quien eres para esta credencial
|
|
7
|
+
*
|
|
8
|
+
* @module resources
|
|
9
|
+
*/
|
|
10
|
+
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
11
|
+
import type { PharmadateClient } from './client.js';
|
|
12
|
+
import type { Principal } from './principal.js';
|
|
13
|
+
export declare const INTEGRATION_GUIDE = "# Guia de integracion (resumen)\n\nBase: https://www.pharmadatepedido.es/api/v1 . Autenticacion: `Authorization: Bearer <api key>`.\n\n## Credenciales\n- **Partner** (`pk_partner_...`): gestiona sus farmacias (/pharmacies) y opera en nombre de ellas con la cabecera `X-Pharmacy-Ref` (CIF).\n- **Farmacia** (`pk_live_...`): identifica a la farmacia por si sola; sirve para /catalog, /conditions, /quote y /orders.\n\n## Pedido\n1. GET /catalog?q=... -> productos (el precio es orientativo).\n2. POST /quote {items:[{cn,unidades}]} -> precio firme por linea, ofertas, envios, impuestos y pedido minimo. No crea nada.\n3. POST /orders {items, external_ref} -> pedido REAL. `external_ref` es la clave de idempotencia:\n misma ref y misma cesta = 200 con el pedido existente; misma ref y otra cesta = 409 external_ref_conflict.\n4. GET /orders, /orders/{id}, /orders/{id}/shipments, /orders/{id}/modification -> seguimiento.\n\n## Pedido minimo\nLa parte que sirve Pharmadate tiene que llegar al minimo (150 EUR netos de momento). Si no, 422 below_minimum_order y no se crea nada.\n\n## Farmacias de un partner\nAlta (POST /pharmacies), edicion (PATCH), baja logica (DELETE, reversible), reactivacion (POST .../reactivate, key nueva) y rotacion de la API key (POST .../api-key).\nEl CIF es unico en toda la plataforma y no se puede cambiar.\n\n## Limites\nLa API admite 120 peticiones por minuto y credencial. Este MCP limita ademas a 30 escrituras/hora y 10 pedidos/hora por credencial, y 500 lineas por quote/pedido.\n";
|
|
14
|
+
export declare const ERRORS_GUIDE = "# Errores de la API y que hacer\n\n| HTTP | error | Que hacer |\n|---|---|---|\n| 400 | invalid_request, invalid_item, invalid_cn, too_many_items | Corregir la peticion segun el mensaje |\n| 400 | missing_pharmacy_ref | (partner) indicar la farmacia |\n| 401 | unauthorized | Credencial invalida, revocada o rotada: reconectar con una key vigente |\n| 403 | pharmacy_inactive | La farmacia esta de baja: reactivatePharmacy |\n| 403 | forbidden | La credencial no puede hacer esto (p. ej. una key de farmacia en /pharmacies) |\n| 404 | not_found | No existe entre los datos de esta credencial |\n| 409 | external_ref_conflict | Misma ref, otra cesta: getOrder del order_id o usar otra ref nueva |\n| 409 | pharmacy_exists | CIF ya existente (con pharmacy_id: es vuestra; sin el: Conflicto, no es vuestra) |\n| 409 | pharmacy_active | Ya esta activa: rotatePharmacyApiKey si se necesita key nueva |\n| 409 | pharmacy_inactive | Edicion/rotacion de una farmacia de baja: reactivatePharmacy |\n| 409 | partner_pharmacy_id_exists, pharmacy_email_conflict | Ese dato ya lo usa otra farmacia |\n| 422 | below_minimum_order | No llega al minimo: ver order_minimum; no se creo nada |\n| 422 | no_orderable_lines | Ninguna linea es servible: revisar createQuote |\n| 429 | rate_limited | Esperar Retry-After |\n| 5xx | internal_error | Reintentar; si persiste, dar el request_id a Pharmadate |\n\nCada respuesta de la API lleva `X-Request-Id`; el MCP lo devuelve en `_meta.request_id`.\n";
|
|
15
|
+
export declare function registerResources(server: McpServer, client: PharmadateClient, principal: Principal): void;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { apiOrigin } from './config.js';
|
|
2
|
+
export const INTEGRATION_GUIDE = `# Guia de integracion (resumen)
|
|
3
|
+
|
|
4
|
+
Base: https://www.pharmadatepedido.es/api/v1 . Autenticacion: \`Authorization: Bearer <api key>\`.
|
|
5
|
+
|
|
6
|
+
## Credenciales
|
|
7
|
+
- **Partner** (\`pk_partner_...\`): gestiona sus farmacias (/pharmacies) y opera en nombre de ellas con la cabecera \`X-Pharmacy-Ref\` (CIF).
|
|
8
|
+
- **Farmacia** (\`pk_live_...\`): identifica a la farmacia por si sola; sirve para /catalog, /conditions, /quote y /orders.
|
|
9
|
+
|
|
10
|
+
## Pedido
|
|
11
|
+
1. GET /catalog?q=... -> productos (el precio es orientativo).
|
|
12
|
+
2. POST /quote {items:[{cn,unidades}]} -> precio firme por linea, ofertas, envios, impuestos y pedido minimo. No crea nada.
|
|
13
|
+
3. POST /orders {items, external_ref} -> pedido REAL. \`external_ref\` es la clave de idempotencia:
|
|
14
|
+
misma ref y misma cesta = 200 con el pedido existente; misma ref y otra cesta = 409 external_ref_conflict.
|
|
15
|
+
4. GET /orders, /orders/{id}, /orders/{id}/shipments, /orders/{id}/modification -> seguimiento.
|
|
16
|
+
|
|
17
|
+
## Pedido minimo
|
|
18
|
+
La parte que sirve Pharmadate tiene que llegar al minimo (150 EUR netos de momento). Si no, 422 below_minimum_order y no se crea nada.
|
|
19
|
+
|
|
20
|
+
## Farmacias de un partner
|
|
21
|
+
Alta (POST /pharmacies), edicion (PATCH), baja logica (DELETE, reversible), reactivacion (POST .../reactivate, key nueva) y rotacion de la API key (POST .../api-key).
|
|
22
|
+
El CIF es unico en toda la plataforma y no se puede cambiar.
|
|
23
|
+
|
|
24
|
+
## Limites
|
|
25
|
+
La API admite 120 peticiones por minuto y credencial. Este MCP limita ademas a 30 escrituras/hora y 10 pedidos/hora por credencial, y 500 lineas por quote/pedido.
|
|
26
|
+
`;
|
|
27
|
+
export const ERRORS_GUIDE = `# Errores de la API y que hacer
|
|
28
|
+
|
|
29
|
+
| HTTP | error | Que hacer |
|
|
30
|
+
|---|---|---|
|
|
31
|
+
| 400 | invalid_request, invalid_item, invalid_cn, too_many_items | Corregir la peticion segun el mensaje |
|
|
32
|
+
| 400 | missing_pharmacy_ref | (partner) indicar la farmacia |
|
|
33
|
+
| 401 | unauthorized | Credencial invalida, revocada o rotada: reconectar con una key vigente |
|
|
34
|
+
| 403 | pharmacy_inactive | La farmacia esta de baja: reactivatePharmacy |
|
|
35
|
+
| 403 | forbidden | La credencial no puede hacer esto (p. ej. una key de farmacia en /pharmacies) |
|
|
36
|
+
| 404 | not_found | No existe entre los datos de esta credencial |
|
|
37
|
+
| 409 | external_ref_conflict | Misma ref, otra cesta: getOrder del order_id o usar otra ref nueva |
|
|
38
|
+
| 409 | pharmacy_exists | CIF ya existente (con pharmacy_id: es vuestra; sin el: Conflicto, no es vuestra) |
|
|
39
|
+
| 409 | pharmacy_active | Ya esta activa: rotatePharmacyApiKey si se necesita key nueva |
|
|
40
|
+
| 409 | pharmacy_inactive | Edicion/rotacion de una farmacia de baja: reactivatePharmacy |
|
|
41
|
+
| 409 | partner_pharmacy_id_exists, pharmacy_email_conflict | Ese dato ya lo usa otra farmacia |
|
|
42
|
+
| 422 | below_minimum_order | No llega al minimo: ver order_minimum; no se creo nada |
|
|
43
|
+
| 422 | no_orderable_lines | Ninguna linea es servible: revisar createQuote |
|
|
44
|
+
| 429 | rate_limited | Esperar Retry-After |
|
|
45
|
+
| 5xx | internal_error | Reintentar; si persiste, dar el request_id a Pharmadate |
|
|
46
|
+
|
|
47
|
+
Cada respuesta de la API lleva \`X-Request-Id\`; el MCP lo devuelve en \`_meta.request_id\`.
|
|
48
|
+
`;
|
|
49
|
+
export function registerResources(server, client, principal) {
|
|
50
|
+
const texto = (uri, text, mimeType) => ({ contents: [{ uri, mimeType, text }] });
|
|
51
|
+
server.registerResource('integration-guide', 'pharmadate://docs/integration-guide', { title: 'Guia de integracion', description: 'Resumen del flujo catalogo -> quote -> pedido -> seguimiento.', mimeType: 'text/markdown' }, async (uri) => texto(uri.href, INTEGRATION_GUIDE, 'text/markdown'));
|
|
52
|
+
server.registerResource('errors', 'pharmadate://docs/errors', { title: 'Errores y accion siguiente', description: 'Codigos de error de la API y que hacer con cada uno.', mimeType: 'text/markdown' }, async (uri) => texto(uri.href, ERRORS_GUIDE, 'text/markdown'));
|
|
53
|
+
server.registerResource('openapi', 'pharmadate://docs/openapi', { title: 'OpenAPI de la API v1', description: 'Especificacion completa vigente (YAML), descargada del servidor.', mimeType: 'application/yaml' }, async (uri) => {
|
|
54
|
+
const yaml = await client.fetchPublicText(`${apiOrigin(client.baseUrl)}/api-docs/openapi.yaml`);
|
|
55
|
+
return texto(uri.href, yaml, 'application/yaml');
|
|
56
|
+
});
|
|
57
|
+
server.registerResource('me', 'pharmadate://me', { title: 'Quien soy', description: 'El rol y la entidad asociados a la credencial con la que estas conectado.', mimeType: 'application/json' }, async (uri) => texto(uri.href, JSON.stringify(principal, null, 2), 'application/json'));
|
|
58
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resultados de herramienta: exito, error y traduccion de los errores de la API a la accion siguiente.
|
|
3
|
+
*
|
|
4
|
+
* @module results
|
|
5
|
+
*/
|
|
6
|
+
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { ApiNetworkError, type ApiResponse } from './client.js';
|
|
8
|
+
/** Maximo de caracteres que se devuelven al modelo en un resultado. */
|
|
9
|
+
export declare const MAX_RESULT_CHARS = 120000;
|
|
10
|
+
export interface ResultMeta {
|
|
11
|
+
request_id?: string | null;
|
|
12
|
+
http_status?: number;
|
|
13
|
+
[k: string]: unknown;
|
|
14
|
+
}
|
|
15
|
+
export declare function jsonText(valor: unknown): string;
|
|
16
|
+
export declare function okResult(data: unknown, meta?: ResultMeta, extraBlocks?: CallToolResult['content']): CallToolResult;
|
|
17
|
+
export declare function errResult(texto: string, meta?: ResultMeta): CallToolResult;
|
|
18
|
+
export declare function networkErrorResult(err: ApiNetworkError): CallToolResult;
|
|
19
|
+
export interface ErrorContext {
|
|
20
|
+
/** `id` de farmacia sobre la que se opera (si lo hay), para citarlo en las instrucciones. */
|
|
21
|
+
pharmacyId?: number | undefined;
|
|
22
|
+
externalRef?: string | undefined;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Traduce una respuesta de error de la API a un `isError` con la ACCION SIGUIENTE para el modelo.
|
|
26
|
+
* Siempre incluye el codigo y el mensaje originales de la API; `_meta.request_id` sale de `X-Request-Id`.
|
|
27
|
+
*/
|
|
28
|
+
export declare function apiErrorResult(res: ApiResponse, ctx?: ErrorContext): CallToolResult;
|
|
29
|
+
/** `true` si el valor es un objeto con un `api_key` en claro. */
|
|
30
|
+
export declare function hasApiKey(data: unknown): data is Record<string, unknown> & {
|
|
31
|
+
api_key: string;
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Aplica la politica de keys reveladas.
|
|
35
|
+
*
|
|
36
|
+
* - Sin `reveal`: se quita `api_key` y queda `api_key_prefix`, mas un aviso. NO hay bloque con la key.
|
|
37
|
+
* - Con `reveal`: el bloque para el MODELO sigue enmascarado y la key completa va en un bloque aparte con
|
|
38
|
+
* `annotations.audience: ["user"]` (el cliente decide si lo muestra solo a la persona).
|
|
39
|
+
*
|
|
40
|
+
* La key queda en el historial del chat si el cliente la envia de vuelta al modelo: las descripciones lo avisan.
|
|
41
|
+
*/
|
|
42
|
+
export declare function applyKeyPolicy(data: unknown, reveal: boolean, meta?: ResultMeta): CallToolResult;
|
package/dist/results.js
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { scrubText } from './scrub.js';
|
|
2
|
+
/** Maximo de caracteres que se devuelven al modelo en un resultado. */
|
|
3
|
+
export const MAX_RESULT_CHARS = 120_000;
|
|
4
|
+
function limitar(texto) {
|
|
5
|
+
if (texto.length <= MAX_RESULT_CHARS)
|
|
6
|
+
return texto;
|
|
7
|
+
return texto.slice(0, MAX_RESULT_CHARS) + '\n... [resultado truncado: usa filtros o paginacion para ver menos datos]';
|
|
8
|
+
}
|
|
9
|
+
export function jsonText(valor) {
|
|
10
|
+
return limitar(scrubText(JSON.stringify(valor, null, 2)));
|
|
11
|
+
}
|
|
12
|
+
export function okResult(data, meta, extraBlocks = []) {
|
|
13
|
+
const r = { content: [{ type: 'text', text: jsonText(data) }, ...extraBlocks] };
|
|
14
|
+
if (meta)
|
|
15
|
+
r._meta = meta;
|
|
16
|
+
return r;
|
|
17
|
+
}
|
|
18
|
+
export function errResult(texto, meta) {
|
|
19
|
+
const r = { isError: true, content: [{ type: 'text', text: limitar(scrubText(texto)) }] };
|
|
20
|
+
if (meta)
|
|
21
|
+
r._meta = meta;
|
|
22
|
+
return r;
|
|
23
|
+
}
|
|
24
|
+
export function networkErrorResult(err) {
|
|
25
|
+
return errResult(`${err.message} No se ha confirmado que la operacion se haya ejecutado. ` +
|
|
26
|
+
'Si era un pedido, NO lo repitas con otra external_ref: reintentalo con la MISMA external_ref (es idempotente) o consulta listOrders con external_ref.', { error_code: 'network_error', timed_out: err.timedOut });
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Traduce una respuesta de error de la API a un `isError` con la ACCION SIGUIENTE para el modelo.
|
|
30
|
+
* Siempre incluye el codigo y el mensaje originales de la API; `_meta.request_id` sale de `X-Request-Id`.
|
|
31
|
+
*/
|
|
32
|
+
export function apiErrorResult(res, ctx = {}) {
|
|
33
|
+
const body = (res.body && typeof res.body === 'object' ? res.body : {});
|
|
34
|
+
const code = typeof body.error === 'string' ? body.error : `http_${res.status}`;
|
|
35
|
+
const apiMessage = typeof body.message === 'string' ? body.message : '';
|
|
36
|
+
const meta = { request_id: res.requestId, http_status: res.status, error_code: code };
|
|
37
|
+
const pid = body.pharmacy_id ?? ctx.pharmacyId;
|
|
38
|
+
let accion = '';
|
|
39
|
+
switch (true) {
|
|
40
|
+
case res.status === 401:
|
|
41
|
+
accion = 'La credencial no es valida o ha sido revocada o rotada. Pide al usuario que vuelva a conectar el MCP con una API key vigente.';
|
|
42
|
+
break;
|
|
43
|
+
case code === 'pharmacy_active' || body.next_action === 'rotatePharmacyApiKey':
|
|
44
|
+
accion = `La farmacia ya esta activa y no se ha tocado su API key. Si el usuario necesita una key nueva, llama a rotatePharmacyApiKey con id ${pid ?? '<id de la farmacia>'} (invalida la anterior en el acto).`;
|
|
45
|
+
break;
|
|
46
|
+
case code === 'pharmacy_exists' && pid !== undefined:
|
|
47
|
+
accion = body.active === false
|
|
48
|
+
? `Esa farmacia ya existe entre las tuyas (id ${pid}) y esta DADA DE BAJA. Llama a reactivatePharmacy con id ${pid} para recuperarla (genera una API key nueva).`
|
|
49
|
+
: `Esa farmacia ya existe entre las tuyas (id ${pid}) y esta activa. Usa getPharmacy / updatePharmacy con id ${pid}; no la vuelvas a crear.`;
|
|
50
|
+
break;
|
|
51
|
+
case code === 'pharmacy_exists':
|
|
52
|
+
accion = 'Ese CIF ya existe en Pharmadate y no es de este partner (de otro partner o del canal web). La API no da mas detalles a proposito. Comprueba el CIF con el usuario.';
|
|
53
|
+
break;
|
|
54
|
+
case code === 'pharmacy_inactive':
|
|
55
|
+
accion = `La farmacia esta dada de baja. Llama a reactivatePharmacy con id ${pid ?? '<id de la farmacia>'} (genera una API key nueva) y vuelve a intentarlo.`;
|
|
56
|
+
break;
|
|
57
|
+
case code === 'external_ref_conflict':
|
|
58
|
+
accion = `Ya existe el pedido ${body.order_id ?? '?'} con la external_ref ${ctx.externalRef ?? ''} y una cesta DISTINTA. No se ha creado nada. Si es el mismo pedido, consulta getOrder con id ${body.order_id ?? '?'}; si es un pedido nuevo, usa OTRA external_ref (una sola vez) y confirmalo con el usuario.`;
|
|
59
|
+
break;
|
|
60
|
+
case code === 'below_minimum_order': {
|
|
61
|
+
const m = body.order_minimum;
|
|
62
|
+
accion = `El pedido no llega al minimo de Pharmadate${m ? ` (exigido ${m.required_eur} EUR, actual ${m.current_eur} EUR, faltan ${m.missing_eur} EUR)` : ''}. No se ha creado nada. Enseña la diferencia al usuario: que anada lineas o renuncie al pedido. La external_ref queda libre.`;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
case code === 'no_orderable_lines':
|
|
66
|
+
accion = 'Ninguna linea se puede servir, asi que no se ha creado el pedido. Revisa el detalle con createQuote (motivo por linea) y corrige la cesta.';
|
|
67
|
+
break;
|
|
68
|
+
case code === 'partner_pharmacy_id_exists' || code === 'pharmacy_email_conflict':
|
|
69
|
+
accion = 'Ese dato ya lo usa otra farmacia. Cambialo o localiza la existente con listPharmacies.';
|
|
70
|
+
break;
|
|
71
|
+
case code === 'cif_immutable':
|
|
72
|
+
accion = 'El CIF de una farmacia no se puede cambiar. Si el CIF es erroneo, da de baja la farmacia y crea otra.';
|
|
73
|
+
break;
|
|
74
|
+
case res.status === 404:
|
|
75
|
+
accion = 'No encontrado entre los datos de esta credencial.';
|
|
76
|
+
break;
|
|
77
|
+
case res.status === 429:
|
|
78
|
+
accion = `Limite de peticiones de la API. Espera ${res.retryAfter ?? 'unos segundos'} s antes de reintentar.`;
|
|
79
|
+
break;
|
|
80
|
+
case res.status >= 500:
|
|
81
|
+
accion = `Error interno de la API. Reintenta en un momento; si persiste, comunica el request_id ${res.requestId ?? ''} a Pharmadate.`;
|
|
82
|
+
break;
|
|
83
|
+
default:
|
|
84
|
+
accion = res.status === 403 ? 'La credencial no tiene permiso para esta operacion.' : 'Corrige la peticion segun el mensaje de la API.';
|
|
85
|
+
}
|
|
86
|
+
const detalle = JSON.stringify(res.body);
|
|
87
|
+
return errResult(`Error ${res.status} (${code})${apiMessage ? `: ${apiMessage}` : ''}\n${accion}\nDetalle de la API: ${detalle}${res.requestId ? `\nrequest_id: ${res.requestId}` : ''}`, meta);
|
|
88
|
+
}
|
|
89
|
+
/** `true` si el valor es un objeto con un `api_key` en claro. */
|
|
90
|
+
export function hasApiKey(data) {
|
|
91
|
+
return !!data && typeof data === 'object' && typeof data['api_key'] === 'string';
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Aplica la politica de keys reveladas.
|
|
95
|
+
*
|
|
96
|
+
* - Sin `reveal`: se quita `api_key` y queda `api_key_prefix`, mas un aviso. NO hay bloque con la key.
|
|
97
|
+
* - Con `reveal`: el bloque para el MODELO sigue enmascarado y la key completa va en un bloque aparte con
|
|
98
|
+
* `annotations.audience: ["user"]` (el cliente decide si lo muestra solo a la persona).
|
|
99
|
+
*
|
|
100
|
+
* La key queda en el historial del chat si el cliente la envia de vuelta al modelo: las descripciones lo avisan.
|
|
101
|
+
*/
|
|
102
|
+
export function applyKeyPolicy(data, reveal, meta) {
|
|
103
|
+
if (!hasApiKey(data))
|
|
104
|
+
return okResult(data, meta);
|
|
105
|
+
const { api_key, ...resto } = data;
|
|
106
|
+
const nombre = typeof resto['nombre'] === 'string' ? resto['nombre'] : `id ${String(resto['id'] ?? '?')}`;
|
|
107
|
+
if (!reveal) {
|
|
108
|
+
return okResult({
|
|
109
|
+
...resto,
|
|
110
|
+
api_key_hidden: true,
|
|
111
|
+
note: 'La API key completa NO se ha mostrado (reveal_api_key=false). Si la anterior ya no vale, repite con reveal_api_key=true: rotatePharmacyApiKey genera otra y invalida la actual.',
|
|
112
|
+
}, meta);
|
|
113
|
+
}
|
|
114
|
+
return okResult({ ...resto, api_key_hidden: true, note: 'La API key completa se entrega al usuario en un bloque aparte (audience: user).' }, meta, [
|
|
115
|
+
{
|
|
116
|
+
type: 'text',
|
|
117
|
+
text: `API key de la farmacia ${nombre}: ${api_key}\nGuardala ahora en un lugar seguro: no se vuelve a mostrar.`,
|
|
118
|
+
annotations: { audience: ['user'], priority: 1 },
|
|
119
|
+
},
|
|
120
|
+
]);
|
|
121
|
+
}
|
package/dist/scrub.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Redaccion de credenciales.
|
|
3
|
+
*
|
|
4
|
+
* Ninguna API key ni token del MCP puede acabar en un log, en un mensaje de error ni en un resultado de herramienta
|
|
5
|
+
* (salvo la key que el usuario ha pedido ver con `reveal_api_key: true`, que sale por su propio bloque).
|
|
6
|
+
*
|
|
7
|
+
* @module scrub
|
|
8
|
+
*/
|
|
9
|
+
/** Sustituye las credenciales de un texto por marcas inofensivas. */
|
|
10
|
+
export declare function scrubText(texto: string): string;
|
|
11
|
+
/** Un `Error` como objeto plano y redactado: sus campos no son enumerables y `Object.entries(err)` daba `{}` (se perdia el motivo). */
|
|
12
|
+
export declare function scrubError(err: Error): {
|
|
13
|
+
name: string;
|
|
14
|
+
message: string;
|
|
15
|
+
stack?: string;
|
|
16
|
+
};
|
|
17
|
+
/** Igual que {@link scrubText} pero recorre objetos y listas. No muta el original. Los `Error` salen como `{name, message, stack}`. */
|
|
18
|
+
export declare function scrubDeep<T>(valor: T, profundidad?: number): T;
|