@pharmadate/mcp 0.0.0-stage → 0.1.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.
Files changed (51) hide show
  1. package/README.md +183 -2
  2. package/dist/auth/crypto.d.ts +16 -0
  3. package/dist/auth/crypto.js +83 -0
  4. package/dist/auth/login-page.d.ts +23 -0
  5. package/dist/auth/login-page.js +67 -0
  6. package/dist/auth/oauth-provider.d.ts +84 -0
  7. package/dist/auth/oauth-provider.js +188 -0
  8. package/dist/client.d.ts +54 -0
  9. package/dist/client.js +131 -0
  10. package/dist/config.d.ts +16 -0
  11. package/dist/config.js +28 -0
  12. package/dist/http.d.ts +68 -0
  13. package/dist/http.js +232 -0
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.js +111 -0
  16. package/dist/limits.d.ts +61 -0
  17. package/dist/limits.js +122 -0
  18. package/dist/logger.d.ts +8 -0
  19. package/dist/logger.js +30 -0
  20. package/dist/operation-mode.d.ts +14 -0
  21. package/dist/operation-mode.js +10 -0
  22. package/dist/pharmacy-resolver.d.ts +54 -0
  23. package/dist/pharmacy-resolver.js +92 -0
  24. package/dist/principal.d.ts +30 -0
  25. package/dist/principal.js +85 -0
  26. package/dist/prompts.d.ts +3 -0
  27. package/dist/prompts.js +42 -0
  28. package/dist/resources.d.ts +15 -0
  29. package/dist/resources.js +58 -0
  30. package/dist/results.d.ts +42 -0
  31. package/dist/results.js +121 -0
  32. package/dist/scrub.d.ts +12 -0
  33. package/dist/scrub.js +40 -0
  34. package/dist/server-instructions.d.ts +8 -0
  35. package/dist/server-instructions.js +39 -0
  36. package/dist/server.d.ts +31 -0
  37. package/dist/server.js +59 -0
  38. package/dist/tools/common.d.ts +9 -0
  39. package/dist/tools/common.js +223 -0
  40. package/dist/tools/index.d.ts +15 -0
  41. package/dist/tools/index.js +6 -0
  42. package/dist/tools/operate.d.ts +15 -0
  43. package/dist/tools/operate.js +24 -0
  44. package/dist/tools/partner.d.ts +2 -0
  45. package/dist/tools/partner.js +176 -0
  46. package/dist/tools/types.d.ts +39 -0
  47. package/dist/tools/types.js +3 -0
  48. package/dist/version.d.ts +1 -0
  49. package/dist/version.js +15 -0
  50. package/package.json +74 -4
  51. package/server.json +56 -0
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Limites propios del MCP, por credencial (sha256 de la key). Se suman a los de la API (120 peticiones/min).
3
+ *
4
+ * - 30 escrituras por hora (`createOrder`, `createPharmacy`, `updatePharmacy`, `deactivatePharmacy`,
5
+ * `reactivatePharmacy`, `rotatePharmacyApiKey`)
6
+ * - 10 `createOrder` por hora
7
+ * - 500 lineas como maximo por quote/pedido (lo comprueba el esquema de la herramienta)
8
+ *
9
+ * Los contadores viven en memoria del proceso: un reinicio los pone a cero y, con varias instancias detras del
10
+ * balanceador, cada una tiene el suyo. Para lo que protegen (una conversacion desbocada o inyectada que lance
11
+ * escrituras sin freno) es suficiente. Una llamada rechazada no alarga la ventana.
12
+ *
13
+ * @module limits
14
+ */
15
+ export declare const MAX_LINES = 500;
16
+ export declare const DEFAULT_WRITES_PER_HOUR = 30;
17
+ export declare const DEFAULT_ORDERS_PER_HOUR = 10;
18
+ export type RateLimitResult = {
19
+ allowed: true;
20
+ } | {
21
+ allowed: false;
22
+ retryAfterSeconds: number;
23
+ };
24
+ /** Ventana deslizante por clave. */
25
+ export declare class WriteRateLimiter {
26
+ readonly max: number;
27
+ private readonly windowMs;
28
+ private readonly now;
29
+ private readonly hits;
30
+ constructor(max: number, windowMs?: number, now?: () => number);
31
+ tryConsume(key: string): RateLimitResult;
32
+ /** Purga periodica de claves vencidas. El temporizador no mantiene vivo el proceso. */
33
+ startPurgeTimer(intervalMs?: number): () => void;
34
+ get size(): number;
35
+ private purge;
36
+ }
37
+ /** Los dos contadores de escritura. Una instancia por proceso (no por peticion). */
38
+ export declare class McpLimits {
39
+ readonly writes: WriteRateLimiter;
40
+ readonly orders: WriteRateLimiter;
41
+ constructor(opts?: {
42
+ writesPerHour?: number;
43
+ ordersPerHour?: number;
44
+ now?: () => number;
45
+ });
46
+ /**
47
+ * Consume una escritura (y un pedido si `isOrder`). Devuelve el mensaje de rechazo, o `null` si puede seguir.
48
+ */
49
+ check(credentialId: string, isOrder: boolean): string | null;
50
+ startPurgeTimers(): () => void;
51
+ }
52
+ /** Limitador simple por IP para los endpoints sin autenticar (OAuth, 401). */
53
+ export declare class IpRateLimiter {
54
+ readonly max: number;
55
+ private readonly windowMs;
56
+ private readonly now;
57
+ private readonly hits;
58
+ constructor(max: number, windowMs?: number, now?: () => number);
59
+ /** `true` si puede pasar. */
60
+ allow(ip: string): boolean;
61
+ }
package/dist/limits.js ADDED
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Limites propios del MCP, por credencial (sha256 de la key). Se suman a los de la API (120 peticiones/min).
3
+ *
4
+ * - 30 escrituras por hora (`createOrder`, `createPharmacy`, `updatePharmacy`, `deactivatePharmacy`,
5
+ * `reactivatePharmacy`, `rotatePharmacyApiKey`)
6
+ * - 10 `createOrder` por hora
7
+ * - 500 lineas como maximo por quote/pedido (lo comprueba el esquema de la herramienta)
8
+ *
9
+ * Los contadores viven en memoria del proceso: un reinicio los pone a cero y, con varias instancias detras del
10
+ * balanceador, cada una tiene el suyo. Para lo que protegen (una conversacion desbocada o inyectada que lance
11
+ * escrituras sin freno) es suficiente. Una llamada rechazada no alarga la ventana.
12
+ *
13
+ * @module limits
14
+ */
15
+ export const MAX_LINES = 500;
16
+ export const DEFAULT_WRITES_PER_HOUR = 30;
17
+ export const DEFAULT_ORDERS_PER_HOUR = 10;
18
+ const HOUR_MS = 3_600_000;
19
+ const PURGE_THRESHOLD = 5_000;
20
+ /** Ventana deslizante por clave. */
21
+ export class WriteRateLimiter {
22
+ max;
23
+ windowMs;
24
+ now;
25
+ hits = new Map();
26
+ constructor(max, windowMs = HOUR_MS, now = Date.now) {
27
+ this.max = max;
28
+ this.windowMs = windowMs;
29
+ this.now = now;
30
+ }
31
+ tryConsume(key) {
32
+ const now = this.now();
33
+ if (this.hits.size > PURGE_THRESHOLD)
34
+ this.purge(now);
35
+ const recent = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);
36
+ if (recent.length >= this.max) {
37
+ this.hits.set(key, recent);
38
+ const retryAfterMs = recent[0] + this.windowMs - now;
39
+ return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)) };
40
+ }
41
+ recent.push(now);
42
+ this.hits.set(key, recent);
43
+ return { allowed: true };
44
+ }
45
+ /** Purga periodica de claves vencidas. El temporizador no mantiene vivo el proceso. */
46
+ startPurgeTimer(intervalMs = 600_000) {
47
+ const timer = setInterval(() => this.purge(this.now()), intervalMs);
48
+ timer.unref();
49
+ return () => clearInterval(timer);
50
+ }
51
+ get size() {
52
+ return this.hits.size;
53
+ }
54
+ purge(now) {
55
+ for (const [k, ts] of this.hits) {
56
+ if (ts.every((t) => now - t >= this.windowMs))
57
+ this.hits.delete(k);
58
+ }
59
+ }
60
+ }
61
+ /** Los dos contadores de escritura. Una instancia por proceso (no por peticion). */
62
+ export class McpLimits {
63
+ writes;
64
+ orders;
65
+ constructor(opts = {}) {
66
+ this.writes = new WriteRateLimiter(opts.writesPerHour ?? DEFAULT_WRITES_PER_HOUR, HOUR_MS, opts.now);
67
+ this.orders = new WriteRateLimiter(opts.ordersPerHour ?? DEFAULT_ORDERS_PER_HOUR, HOUR_MS, opts.now);
68
+ }
69
+ /**
70
+ * Consume una escritura (y un pedido si `isOrder`). Devuelve el mensaje de rechazo, o `null` si puede seguir.
71
+ */
72
+ check(credentialId, isOrder) {
73
+ if (isOrder) {
74
+ const o = this.orders.tryConsume(credentialId);
75
+ if (!o.allowed) {
76
+ return `Limite de pedidos alcanzado: como maximo ${this.orders.max} createOrder por hora y credencial. No se ha enviado nada. Reintenta en unos ${Math.ceil(o.retryAfterSeconds / 60)} minuto(s).`;
77
+ }
78
+ }
79
+ const w = this.writes.tryConsume(credentialId);
80
+ if (!w.allowed) {
81
+ return `Limite de escrituras alcanzado: como maximo ${this.writes.max} operaciones de escritura por hora y credencial. No se ha ejecutado nada. Reintenta en unos ${Math.ceil(w.retryAfterSeconds / 60)} minuto(s).`;
82
+ }
83
+ return null;
84
+ }
85
+ startPurgeTimers() {
86
+ const a = this.writes.startPurgeTimer();
87
+ const b = this.orders.startPurgeTimer();
88
+ return () => {
89
+ a();
90
+ b();
91
+ };
92
+ }
93
+ }
94
+ /** Limitador simple por IP para los endpoints sin autenticar (OAuth, 401). */
95
+ export class IpRateLimiter {
96
+ max;
97
+ windowMs;
98
+ now;
99
+ hits = new Map();
100
+ constructor(max, windowMs = 60_000, now = Date.now) {
101
+ this.max = max;
102
+ this.windowMs = windowMs;
103
+ this.now = now;
104
+ }
105
+ /** `true` si puede pasar. */
106
+ allow(ip) {
107
+ const now = this.now();
108
+ if (this.hits.size > PURGE_THRESHOLD) {
109
+ for (const [k, ts] of this.hits)
110
+ if (ts.every((t) => now - t >= this.windowMs))
111
+ this.hits.delete(k);
112
+ }
113
+ const recent = (this.hits.get(ip) ?? []).filter((t) => now - t < this.windowMs);
114
+ if (recent.length >= this.max) {
115
+ this.hits.set(ip, recent);
116
+ return false;
117
+ }
118
+ recent.push(now);
119
+ this.hits.set(ip, recent);
120
+ return true;
121
+ }
122
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Logger estructurado (pino). Siempre a stderr: en stdio el stdout es el canal del protocolo y no se puede ensuciar.
3
+ * Todo lo que pasa por aqui se redacta con {@link scrubDeep}: una key o un token no llegan nunca al log.
4
+ *
5
+ * @module logger
6
+ */
7
+ import pino from 'pino';
8
+ export declare const logger: pino.Logger<never, boolean>;
package/dist/logger.js ADDED
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Logger estructurado (pino). Siempre a stderr: en stdio el stdout es el canal del protocolo y no se puede ensuciar.
3
+ * Todo lo que pasa por aqui se redacta con {@link scrubDeep}: una key o un token no llegan nunca al log.
4
+ *
5
+ * @module logger
6
+ */
7
+ import pino from 'pino';
8
+ import { scrubDeep, scrubText } from './scrub.js';
9
+ import { VERSION } from './version.js';
10
+ const nivel = process.env['LOG_LEVEL'] ?? 'info';
11
+ export const logger = pino({
12
+ level: nivel,
13
+ base: { service: 'pharmadate-mcp', version: VERSION },
14
+ redact: {
15
+ paths: ['authorization', 'req.headers.authorization', 'headers.authorization', '*.authorization', 'api_key', '*.api_key', 'access_token', 'refresh_token', '*.access_token', '*.refresh_token'],
16
+ censor: '[REDACTED]',
17
+ },
18
+ timestamp: pino.stdTimeFunctions.isoTime,
19
+ formatters: {
20
+ level: (label) => ({ level: label }),
21
+ log: (objeto) => scrubDeep(objeto),
22
+ },
23
+ hooks: {
24
+ logMethod(args, method) {
25
+ // El mensaje (primer string) tambien se redacta
26
+ const copia = args.map((a) => (typeof a === 'string' ? scrubText(a) : a));
27
+ return method.apply(this, copia);
28
+ },
29
+ },
30
+ }, pino.destination({ dest: 2, sync: false }));
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import type { Role } from './principal.js';
3
+ export declare function registerPrompts(server: McpServer, role: Role): void;
@@ -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
+ }