@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
package/README.md CHANGED
@@ -1,3 +1,184 @@
1
- # Temporary Holding Version
1
+ # @pharmadate/mcp
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Servidor [MCP](https://modelcontextprotocol.io) de Pharmadate. Permite a un asistente de IA (Claude, ChatGPT, Cursor, VS Code...)
4
+ consultar el catálogo, valorar y cursar pedidos y, si eres partner (TopFarma...), gestionar tus farmacias, **sobre la API v1 de
5
+ Pharmadate** (`/api-docs`). No accede a la base de datos: toda la autorización la hace la API.
6
+
7
+ > **Estado (fase 1):** partners y farmacias. Las herramientas de administración interna llegan en la fase 2.
8
+ > El paquete está en npm (`npx -y @pharmadate/mcp`) y el servidor remoto está activo en producción.
9
+
10
+ - Paquete npm: `@pharmadate/mcp` · Registro MCP: `es.pharmadatepedido/mcp`
11
+ - Remoto (Streamable HTTP): `https://www.pharmadatepedido.es/mcp`
12
+ - 19 herramientas; el **nombre de cada una es el `operationId`** del OpenAPI (`frontend/api-docs/openapi.yaml`).
13
+
14
+ ## Qué puede hacer cada credencial
15
+
16
+ | Credencial | Prefijo | Herramientas |
17
+ |---|---|---|
18
+ | **Partner** | `pk_partner_...` | Las 19: las operativas (con el argumento `pharmacy`) y la gestión de sus farmacias |
19
+ | **Farmacia** | `pk_live_...` | 12: las operativas, sobre su propia farmacia (sin argumento `pharmacy`) |
20
+
21
+ ### Operativas (12) — farmacia, o partner con `pharmacy`
22
+
23
+ | Herramienta | Tipo | Para qué |
24
+ |---|---|---|
25
+ | `ping` | lectura | Comprobar la credencial y ver quién eres |
26
+ | `getCatalog` | lectura | Buscar en el catálogo (`q` por nombre o laboratorio; 50 por página) |
27
+ | `getConditions` | lectura | Condiciones de un código nacional |
28
+ | `lookupConditions` | lectura | Condiciones de hasta 1000 códigos |
29
+ | `createQuote` | lectura | Valorar una cesta (no crea nada). **Siempre antes de pedir** |
30
+ | `createOrder` | escritura, idempotente | Pedido **REAL**. `external_ref` obligatoria |
31
+ | `listOrders`, `getOrder`, `getOrderModification` | lectura | Seguimiento de pedidos, faltas y modificaciones |
32
+ | `listOrderShipments`, `getOrderShipment`, `getShipment` | lectura | Envíos |
33
+
34
+ ### Solo partner (7)
35
+
36
+ | Herramienta | Tipo | Para qué |
37
+ |---|---|---|
38
+ | `listPharmacies`, `getPharmacy` | lectura | Tus farmacias (nunca devuelven la API key completa) |
39
+ | `createPharmacy` | escritura | Alta |
40
+ | `updatePharmacy` | escritura (destructiva) | Modificar datos |
41
+ | `deactivatePharmacy` | destructiva, idempotente | Baja lógica (se deshace con `reactivatePharmacy`) |
42
+ | `reactivatePharmacy` | escritura | Deshacer una baja (API key nueva) |
43
+ | `rotatePharmacyApiKey` | destructiva | Key nueva; **invalida la anterior en el acto** |
44
+
45
+ Cada herramienta lleva las *annotations* MCP (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) para que el cliente
46
+ pida confirmación en las que escriben. Además hay 4 *resources* (`pharmadate://docs/integration-guide`, `.../errors`, `.../openapi`,
47
+ `pharmadate://me`), 4 *prompts* (`placeOrder`, `orderStatus` y, para partners, `onboardPharmacy` y `recoverPharmacyAccess`) y
48
+ unas *instrucciones del servidor* con las reglas de uso.
49
+
50
+ ## Conectarlo
51
+
52
+ ### Claude Code (remoto)
53
+
54
+ ```bash
55
+ claude mcp add --transport http pharmadate https://www.pharmadatepedido.es/mcp \
56
+ --header "Authorization: Bearer pk_partner_XXXXXXXX"
57
+ ```
58
+
59
+ Sin `--header`, Claude Code arranca el login OAuth en el navegador (`/mcp` dentro de Claude Code).
60
+
61
+ ### Claude Code / Claude Desktop (local, stdio)
62
+
63
+ ```bash
64
+ claude mcp add pharmadate --env PHARMADATE_API_KEY=pk_partner_XXXXXXXX --env PHARMADATE_MCP_MODE=STANDARD -- npx -y @pharmadate/mcp
65
+ ```
66
+
67
+ Claude Desktop (`claude_desktop_config.json`):
68
+
69
+ ```json
70
+ {
71
+ "mcpServers": {
72
+ "pharmadate": {
73
+ "command": "npx",
74
+ "args": ["-y", "@pharmadate/mcp"],
75
+ "env": {
76
+ "PHARMADATE_API_KEY": "pk_partner_XXXXXXXX",
77
+ "PHARMADATE_MCP_MODE": "STANDARD"
78
+ }
79
+ }
80
+ }
81
+ }
82
+ ```
83
+
84
+ ### Cursor / VS Code
85
+
86
+ Cursor (`~/.cursor/mcp.json`) o VS Code (`.vscode/mcp.json`, clave `servers`):
87
+
88
+ ```json
89
+ {
90
+ "mcpServers": {
91
+ "pharmadate": {
92
+ "url": "https://www.pharmadatepedido.es/mcp",
93
+ "headers": { "Authorization": "Bearer pk_partner_XXXXXXXX" }
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ ### Claude.ai y ChatGPT (OAuth)
100
+
101
+ *Ajustes > Conectores > Añadir conector personalizado* y la URL `https://www.pharmadatepedido.es/mcp`. El cliente abre una página de
102
+ Pharmadate donde **pegas tu API key**; no hace falta configurar ningún cliente OAuth (el registro es dinámico).
103
+
104
+ > **Consejo para partners:** crea una credencial dedicada «MCP» en `/partner.html` (`POST /api/partner/tokens`). Así la puedes revocar
105
+ > sin tocar tu integración.
106
+
107
+ ## Variables de entorno (stdio)
108
+
109
+ | Variable | Obligatoria | Descripción |
110
+ |---|---|---|
111
+ | `PHARMADATE_API_KEY` | sí | Key de partner o de farmacia |
112
+ | `PHARMADATE_MCP_MODE` | no | `READ_ONLY` (**por defecto**: solo lectura y valoración) o `STANDARD` (pedidos y gestión de farmacias) |
113
+ | `PHARMADATE_PHARMACY_REF` | no | Solo partners: CIF de la farmacia por defecto (se verifica antes de usarla) |
114
+ | `PHARMADATE_API_URL` | no | Por defecto `https://www.pharmadatepedido.es/api` (la beta: `.../api` de su URL) |
115
+
116
+ El servidor remoto trabaja siempre en modo `STANDARD` (cada cliente pide confirmación por las *annotations*); el operador puede dejarlo
117
+ en `READ_ONLY` con `MCP_REMOTE_MODE`.
118
+
119
+ ## Seguridad
120
+
121
+ - **La farmacia se resuelve antes de operar (punto crítico).** Con una key de partner, la API da de alta *automáticamente* la farmacia de
122
+ un CIF desconocido en cualquier ruta operativa. Por eso el MCP, con `pharmacy: { pharmacy_id }` (preferido) o `{ cif }`, la busca antes
123
+ entre las **tuyas** y activas; si no aparece (o es de otro partner, o está de baja) responde «farmacia no encontrada entre las tuyas» y
124
+ **no llama a la API operativa**. Solo entonces envía `X-Pharmacy-Ref`, y **nunca** `pharmacy` ni `pharmacy_ref` en el cuerpo.
125
+ - **Sin estado de negocio:** cada llamada se autoriza en la API. Un partner nunca ve las farmacias de otro.
126
+ - **Keys:** nunca van a logs ni a mensajes de error (se redactan por patrón). `createPharmacy`, `reactivatePharmacy` y `rotatePharmacyApiKey` solo
127
+ muestran el prefijo; con `reveal_api_key: true` la key completa va en un bloque aparte con `annotations.audience: ["user"]`. **Puede quedar en el
128
+ historial del chat**: úsalo cuando la necesites de verdad. Si no la revelas al reactivar o rotar, esa key no se muestra en ningún sitio.
129
+ - **Límites del MCP** (por credencial, en memoria del proceso): 30 escrituras/hora, 10 `createOrder`/hora y 500 líneas por quote o pedido; en los
130
+ endpoints sin autenticar, 30 peticiones/minuto por IP. La API añade sus 120 peticiones/minuto.
131
+ - **Pedidos:** `createOrder` es real, firme e **idempotente por `external_ref`** (obligatoria): reintentar con la misma ref y la misma cesta no duplica.
132
+ Si se corta la llamada, el modelo debe reintentar con la **misma** ref.
133
+ - **Auditoría:** cada llamada queda en `api_audit` con `User-Agent: pharmadate-mcp/<versión> (remote|stdio; client=<nombre>)` y la IP real.
134
+
135
+ ### OAuth 2.1 sin base de datos
136
+
137
+ `/.well-known/oauth-authorization-server`, `/register` (DCR), `/authorize`, `/token` y `/.well-known/oauth-protected-resource/mcp`, en la raíz.
138
+ El `code` (60 s), el access token (1 h) y el refresh token (30 días, tope de 90 días de sesión) son **la API key cifrada con AES-256-GCM** con
139
+ `MCP_OAUTH_SECRET` (más PKCE, cliente y caducidad); el `client_id` es la metadata del registro firmada con HMAC. **Revocar es rotar o revocar la key**:
140
+ cada llamada se valida contra la API y un refresh con la key revocada se rechaza. Cambiar `MCP_OAUTH_SECRET` cierra todas las sesiones.
141
+
142
+ ## Despliegue (operador)
143
+
144
+ El MCP vive en `mcp/` y se monta **dentro de la app de Elastic Beanstalk**, detrás de `MCP_ENABLED=1`. Sin esa variable no se carga ni se expone nada.
145
+
146
+ | Variable (EB) | Descripción |
147
+ |---|---|
148
+ | `MCP_ENABLED=1` | Activa `POST /mcp` y las rutas OAuth |
149
+ | `MCP_PUBLIC_URL` | URL pública, p. ej. `https://www.pharmadatepedido.es` (issuer y recurso) |
150
+ | `MCP_OAUTH_SECRET` | 32+ caracteres aleatorios (`openssl rand -base64 48`) |
151
+ | `MCP_ALLOWED_ORIGINS` | Opcional: orígenes de navegador extra (por defecto claude.ai, claude.com, chatgpt.com) |
152
+ | `MCP_ALLOWED_HOSTS` | Opcional: hostnames extra para la cabecera `Host` (protección contra DNS rebinding) |
153
+ | `MCP_REMOTE_MODE` | Opcional: `READ_ONLY` para un remoto de solo lectura |
154
+ | `MCP_PROXY_HOPS` | Opcional: proxies de confianza delante de la app para leer `X-Forwarded-For` (**2** con ALB + nginx, por defecto; **1** en la beta) |
155
+
156
+ Los workflows `deploy-prod.yml`, `deploy-dev.yml` y `deploy-beta.yml` compilan el MCP (`cd mcp && npm ci && npm run build`) y meten `mcp/dist` en el zip; las
157
+ dependencias (`@modelcontextprotocol/sdk`, `zod`, `pino`) están en `backend/package.json` porque EB solo instala las de producción. **La beta solo sirve HTTP**
158
+ (sin certificado): el OAuth exige HTTPS salvo en `localhost`, así que allí se activa además `MCP_DANGEROUSLY_ALLOW_INSECURE_ISSUER_URL=true` (**nunca en producción**).
159
+
160
+ ### Publicar (requiere aprobación)
161
+
162
+ 1. `mcp/package.json` y `mcp/server.json` con la misma versión; tag `mcp-vX.Y.Z` → `.github/workflows/mcp-publish.yml` (npm con procedencia + registro MCP).
163
+ 2. Secrets: `NPM_TOKEN` y `MCP_REGISTRY_DNS_PRIVATE_KEY`.
164
+ 3. Namespace `es.pharmadatepedido/*` del registro: verificación por DNS. Genera un par Ed25519 (`openssl genpkey -algorithm Ed25519`), publica en GoDaddy el TXT
165
+ `pharmadatepedido.es TXT "v=MCPv1; k=ed25519; p=<clave pública base64>"` y guarda la privada (hex) en el secret. Detalles: documentación de `mcp-publisher`.
166
+
167
+ ## Desarrollo
168
+
169
+ ```bash
170
+ cd mcp && npm ci
171
+ npm test # unitarios: contrato con el OpenAPI, snapshots por rol, resolución de la farmacia, aislamiento, OAuth, límites
172
+ npm run build
173
+ npm run test:integration # backend real (SQLite de pruebas, requiere `cd backend && npm ci`) con MCP_ENABLED=1: HTTP, OAuth y stdio
174
+ npm run dev # stdio con tsx (PHARMADATE_API_KEY=...)
175
+ MCP_OAUTH_SECRET=$(openssl rand -hex 32) npm run dev -- --transport=http # HTTP local en :3333
176
+ ```
177
+
178
+ Probar con el inspector: `npx @modelcontextprotocol/inspector` y la URL `http://localhost:3333/mcp` (Streamable HTTP, cabecera `Authorization: Bearer <key>`).
179
+
180
+ Diseño completo y decisiones: [`docs/diseno_mcp.md`](../docs/diseno_mcp.md).
181
+
182
+ ## Desarrollado por
183
+
184
+ Desarrollado por [Nubiia](https://nubiia.es) · Contacto: samuel.fraga@nubiia.es
@@ -0,0 +1,16 @@
1
+ export type TokenKind = 'code' | 'access' | 'refresh';
2
+ export declare const MIN_SECRET_LENGTH = 32;
3
+ export interface DerivedKeys {
4
+ enc: Buffer;
5
+ mac: Buffer;
6
+ }
7
+ export declare function deriveKeys(secret: string): DerivedKeys;
8
+ /** Cifra un objeto JSON en un token opaco con el prefijo de su tipo. */
9
+ export declare function seal(kind: TokenKind, payload: object, keys: DerivedKeys): string;
10
+ /** Descifra y verifica. Devuelve `null` si el token esta manipulado, es de otro tipo o no es nuestro. */
11
+ export declare function open<T extends object>(kind: TokenKind, token: string, keys: DerivedKeys): T | null;
12
+ /** Si el texto parece un token del OAuth del MCP (de cualquier tipo). */
13
+ export declare function looksLikeMcpToken(token: string): boolean;
14
+ /** Firma un objeto: `base64url(json).base64url(hmac)`. Es el `client_id` del registro dinamico. */
15
+ export declare function sign(payload: object, keys: DerivedKeys): string;
16
+ export declare function verify<T extends object>(signed: string, keys: DerivedKeys): T | null;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Cifrado y firma sin estado del OAuth del MCP.
3
+ *
4
+ * No hay base de datos: todo lo que el servidor necesita recordar viaja DENTRO del propio token, cifrado con
5
+ * AES-256-GCM (confidencialidad + integridad) con claves derivadas de `MCP_OAUTH_SECRET` por HKDF.
6
+ *
7
+ * - code (60 s) API key + PKCE + client + redirect_uri
8
+ * - access token (1 h) API key + client + scopes + resource
9
+ * - refresh token (30 d) API key + client + scopes + inicio de la cadena (tope 90 d)
10
+ * - client_id (DCR) metadata del registro firmada con HMAC-SHA256
11
+ *
12
+ * Cada tipo usa un AAD distinto: un code no se puede presentar como access token ni al reves.
13
+ *
14
+ * @module auth/crypto
15
+ */
16
+ import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes, timingSafeEqual } from 'node:crypto';
17
+ const PREFIX = { code: 'pdc1.', access: 'pda1.', refresh: 'pdr1.' };
18
+ export const MIN_SECRET_LENGTH = 32;
19
+ export function deriveKeys(secret) {
20
+ if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH) {
21
+ throw new Error(`MCP_OAUTH_SECRET debe tener al menos ${MIN_SECRET_LENGTH} caracteres.`);
22
+ }
23
+ const salt = Buffer.from('pharmadate-mcp/oauth/v1');
24
+ return {
25
+ enc: Buffer.from(hkdfSync('sha256', secret, salt, 'enc', 32)),
26
+ mac: Buffer.from(hkdfSync('sha256', secret, salt, 'mac', 32)),
27
+ };
28
+ }
29
+ const b64u = (b) => b.toString('base64url');
30
+ /** Cifra un objeto JSON en un token opaco con el prefijo de su tipo. */
31
+ export function seal(kind, payload, keys) {
32
+ const iv = randomBytes(12);
33
+ const cipher = createCipheriv('aes-256-gcm', keys.enc, iv);
34
+ cipher.setAAD(Buffer.from(kind));
35
+ const data = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
36
+ return PREFIX[kind] + b64u(Buffer.concat([iv, cipher.getAuthTag(), data]));
37
+ }
38
+ /** Descifra y verifica. Devuelve `null` si el token esta manipulado, es de otro tipo o no es nuestro. */
39
+ export function open(kind, token, keys) {
40
+ if (typeof token !== 'string' || !token.startsWith(PREFIX[kind]) || token.length > 8_192)
41
+ return null;
42
+ try {
43
+ const raw = Buffer.from(token.slice(PREFIX[kind].length), 'base64url');
44
+ if (raw.length < 12 + 16 + 2)
45
+ return null;
46
+ const decipher = createDecipheriv('aes-256-gcm', keys.enc, raw.subarray(0, 12));
47
+ decipher.setAAD(Buffer.from(kind));
48
+ decipher.setAuthTag(raw.subarray(12, 28));
49
+ const json = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
50
+ return JSON.parse(json);
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }
56
+ /** Si el texto parece un token del OAuth del MCP (de cualquier tipo). */
57
+ export function looksLikeMcpToken(token) {
58
+ return Object.values(PREFIX).some((p) => token.startsWith(p));
59
+ }
60
+ /** Firma un objeto: `base64url(json).base64url(hmac)`. Es el `client_id` del registro dinamico. */
61
+ export function sign(payload, keys) {
62
+ const body = b64u(Buffer.from(JSON.stringify(payload)));
63
+ const mac = createHmac('sha256', keys.mac).update(body).digest();
64
+ return `${body}.${b64u(mac)}`;
65
+ }
66
+ export function verify(signed, keys) {
67
+ if (typeof signed !== 'string' || signed.length > 8_192)
68
+ return null;
69
+ const i = signed.lastIndexOf('.');
70
+ if (i <= 0)
71
+ return null;
72
+ const body = signed.slice(0, i);
73
+ const mac = Buffer.from(signed.slice(i + 1), 'base64url');
74
+ const expected = createHmac('sha256', keys.mac).update(body).digest();
75
+ if (mac.length !== expected.length || !timingSafeEqual(mac, expected))
76
+ return null;
77
+ try {
78
+ return JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
79
+ }
80
+ catch {
81
+ return null;
82
+ }
83
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Pagina de login del OAuth del MCP: el usuario pega su API key de Pharmadate.
3
+ *
4
+ * HTML + CSS en linea, sin JavaScript. Todos los textos que vienen de fuera (nombre del cliente, errores) se escapan.
5
+ * La respuesta lleva `frame-ancestors 'none'` (anti-clickjacking) y `no-store`. No lleva `form-action`: Chrome lo aplica
6
+ * tambien a la redireccion posterior y bloquearia la vuelta al cliente (claude.ai, localhost...).
7
+ *
8
+ * @module auth/login-page
9
+ */
10
+ export interface LoginPageParams {
11
+ clientId: string;
12
+ redirectUri: string;
13
+ codeChallenge: string;
14
+ scope?: string | undefined;
15
+ state?: string | undefined;
16
+ resource?: string | undefined;
17
+ clientName?: string | undefined;
18
+ errorMessage?: string | undefined;
19
+ }
20
+ export declare function escapeHtml(valor: string): string;
21
+ export declare const LOGIN_PATH = "/oauth/login";
22
+ export declare function renderLoginPage(p: LoginPageParams): string;
23
+ export declare function loginPageHeaders(): Record<string, string>;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Pagina de login del OAuth del MCP: el usuario pega su API key de Pharmadate.
3
+ *
4
+ * HTML + CSS en linea, sin JavaScript. Todos los textos que vienen de fuera (nombre del cliente, errores) se escapan.
5
+ * La respuesta lleva `frame-ancestors 'none'` (anti-clickjacking) y `no-store`. No lleva `form-action`: Chrome lo aplica
6
+ * tambien a la redireccion posterior y bloquearia la vuelta al cliente (claude.ai, localhost...).
7
+ *
8
+ * @module auth/login-page
9
+ */
10
+ export function escapeHtml(valor) {
11
+ return valor.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
12
+ }
13
+ const hidden = (name, value) => value === undefined || value === '' ? '' : `<input type="hidden" name="${name}" value="${escapeHtml(value)}">`;
14
+ export const LOGIN_PATH = '/oauth/login';
15
+ export function renderLoginPage(p) {
16
+ const cliente = escapeHtml(p.clientName || 'tu asistente de IA');
17
+ const error = p.errorMessage ? `<div class="error" role="alert">${escapeHtml(p.errorMessage)}</div>` : '';
18
+ return `<!DOCTYPE html>
19
+ <html lang="es">
20
+ <head>
21
+ <meta charset="utf-8">
22
+ <meta name="viewport" content="width=device-width, initial-scale=1">
23
+ <meta name="robots" content="noindex">
24
+ <title>Conectar Pharmadate</title>
25
+ <style>
26
+ *{box-sizing:border-box}
27
+ body{margin:0;min-height:100vh;display:flex;align-items:center;justify-content:center;background:#f1f5f9;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#0f172a}
28
+ main{width:100%;max-width:420px;margin:16px;background:#fff;border-radius:14px;padding:32px 28px;box-shadow:0 10px 30px rgba(15,23,42,.12)}
29
+ h1{font-size:1.25rem;margin:0 0 6px}
30
+ p{margin:0 0 16px;color:#475569;font-size:.92rem;line-height:1.45}
31
+ label{display:block;font-weight:600;font-size:.85rem;margin-bottom:6px}
32
+ input[type=password]{width:100%;padding:11px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:.95rem;font-family:ui-monospace,Menlo,monospace}
33
+ input[type=password]:focus{outline:2px solid #2563eb;border-color:#2563eb}
34
+ button{margin-top:16px;width:100%;padding:12px;border:0;border-radius:8px;background:#0f172a;color:#fff;font-size:1rem;font-weight:600;cursor:pointer}
35
+ button:hover{background:#1e293b}
36
+ .error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;border-radius:8px;padding:10px 12px;margin-bottom:16px;font-size:.88rem}
37
+ .hint{font-size:.8rem;color:#64748b;margin-top:14px}
38
+ .brand{font-weight:700;letter-spacing:.02em;color:#0f172a;margin-bottom:18px}
39
+ </style>
40
+ </head>
41
+ <body>
42
+ <main>
43
+ <div class="brand">Pharmadate</div>
44
+ <h1>Conectar con ${cliente}</h1>
45
+ <p>${cliente} podra consultar el catalogo, valorar y cursar pedidos y, si eres partner, gestionar tus farmacias, con los permisos de la API key que pegues aqui.</p>
46
+ ${error}
47
+ <form method="post" action="${LOGIN_PATH}" autocomplete="off">
48
+ ${hidden('client_id', p.clientId)}${hidden('redirect_uri', p.redirectUri)}${hidden('code_challenge', p.codeChallenge)}${hidden('scope', p.scope)}${hidden('state', p.state)}${hidden('resource', p.resource)}
49
+ <label for="api_key">API key de Pharmadate</label>
50
+ <input id="api_key" name="api_key" type="password" required placeholder="pk_partner_... o pk_live_..." spellcheck="false" autocapitalize="off">
51
+ <button type="submit">Conectar</button>
52
+ </form>
53
+ <p class="hint">Consejo para partners: crea una credencial dedicada &laquo;MCP&raquo; en tu panel (/partner.html) y revocala cuando quieras. Para desconectar basta con rotar o revocar la key. Esta version solo admite keys de partner o de farmacia.</p>
54
+ </main>
55
+ </body>
56
+ </html>`;
57
+ }
58
+ export function loginPageHeaders() {
59
+ return {
60
+ 'Content-Type': 'text/html; charset=utf-8',
61
+ 'Cache-Control': 'no-store',
62
+ 'X-Frame-Options': 'DENY',
63
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
64
+ 'Referrer-Policy': 'no-referrer',
65
+ 'X-Content-Type-Options': 'nosniff',
66
+ };
67
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Proveedor OAuth 2.1 SIN ESTADO del MCP de Pharmadate.
3
+ *
4
+ * Estructura tomada de `mcp-nevent/src/auth/oauth-provider.ts`, sin las stores de MongoDB: no hay base de datos y el
5
+ * servidor no recuerda nada entre peticiones (funciona con varias instancias de Elastic Beanstalk detras del ALB).
6
+ *
7
+ * Flujo:
8
+ * 1. El cliente descubre `/.well-known/oauth-authorization-server` y se registra (`POST /register`, DCR). El
9
+ * `client_id` ES la metadata del registro firmada con HMAC ({@link StatelessClientsStore}).
10
+ * 2. `GET /authorize` -> pagina donde el usuario PEGA SU API KEY. `POST /oauth/login` la valida con `GET /api/v1/ping`.
11
+ * 3. Se emite un `code` (60 s): la key cifrada con AES-256-GCM + PKCE + client + redirect_uri.
12
+ * 4. `POST /token`: el SDK comprueba PKCE; aqui se canjea por un access token (1 h) y un refresh token (30 d).
13
+ * 5. Cada llamada a `/mcp` trae el access token; se descifra y la key se usa contra la API, que es quien autoriza.
14
+ *
15
+ * REVOCAR ES ROTAR LA KEY: no hay lista de tokens revocados. Una key rotada o revocada hace que cada llamada de la API
16
+ * (y cualquier refresco) devuelva 401, y el cliente vuelve a pedir el login. Por eso no se anuncia `revocation_endpoint`.
17
+ *
18
+ * El code no es de un solo uso (no hay estado donde marcarlo); lo acotan su vida de 60 s y PKCE (quien lo robe no tiene el
19
+ * `code_verifier`).
20
+ *
21
+ * @module auth/oauth-provider
22
+ */
23
+ import type { Response } from 'express';
24
+ import type { OAuthServerProvider, AuthorizationParams } from '@modelcontextprotocol/sdk/server/auth/provider.js';
25
+ import type { OAuthRegisteredClientsStore } from '@modelcontextprotocol/sdk/server/auth/clients.js';
26
+ import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextprotocol/sdk/shared/auth.js';
27
+ import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
28
+ import { type DerivedKeys } from './crypto.js';
29
+ export declare const SCOPE = "mcp:tools";
30
+ export declare const CODE_TTL_S = 60;
31
+ export declare const ACCESS_TTL_S = 3600;
32
+ export declare const REFRESH_TTL_S: number;
33
+ export declare const CHAIN_MAX_S: number;
34
+ /** Resultado de comprobar una API key contra la API. */
35
+ export type KeyCheck = {
36
+ ok: true;
37
+ } | {
38
+ ok: false;
39
+ reason: 'invalid' | 'unavailable';
40
+ message: string;
41
+ };
42
+ export type KeyValidator = (apiKey: string) => Promise<KeyCheck>;
43
+ /** DCR sin base de datos: el client_id lleva la metadata firmada. Todos los clientes son publicos (PKCE, sin secreto). */
44
+ export declare class StatelessClientsStore implements OAuthRegisteredClientsStore {
45
+ private readonly keys;
46
+ constructor(keys: DerivedKeys);
47
+ getClient(clientId: string): OAuthClientInformationFull | undefined;
48
+ registerClient(client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>): OAuthClientInformationFull;
49
+ }
50
+ export interface OAuthProviderOptions {
51
+ secret: string;
52
+ /** Comprueba una API key (GET /api/v1/ping). */
53
+ validateKey: KeyValidator;
54
+ /** URL canonica del recurso MCP (p. ej. https://www.pharmadatepedido.es/mcp). */
55
+ resourceUrl: URL;
56
+ }
57
+ export declare class PharmadateOAuthProvider implements OAuthServerProvider {
58
+ readonly clientsStore: StatelessClientsStore;
59
+ private readonly keys;
60
+ private readonly validateKey;
61
+ readonly resourceUrl: URL;
62
+ constructor(options: OAuthProviderOptions);
63
+ /** GET /authorize: muestra la pagina donde se pega la API key. */
64
+ authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
65
+ /**
66
+ * POST /oauth/login: valida la API key y, si es buena, redirige al cliente con un code.
67
+ * `redirect_uri` se vuelve a comprobar contra las registradas en el client_id (anti open-redirect).
68
+ */
69
+ handleLoginPost(form: {
70
+ api_key?: string;
71
+ client_id?: string;
72
+ redirect_uri?: string;
73
+ code_challenge?: string;
74
+ scope?: string;
75
+ state?: string;
76
+ resource?: string;
77
+ }, res: Response): Promise<void>;
78
+ challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
79
+ exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string, resource?: URL): Promise<OAuthTokens>;
80
+ exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise<OAuthTokens>;
81
+ /** Descifra un access token. Lo usa `requireBearerAuth` del SDK. */
82
+ verifyAccessToken(token: string): Promise<AuthInfo>;
83
+ private issue;
84
+ }
@@ -0,0 +1,188 @@
1
+ import { InvalidGrantError, InvalidTokenError, InvalidRequestError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
2
+ import { deriveKeys, open, seal, sign, verify } from './crypto.js';
3
+ import { loginPageHeaders, renderLoginPage } from './login-page.js';
4
+ import { logger } from '../logger.js';
5
+ export const SCOPE = 'mcp:tools';
6
+ export const CODE_TTL_S = 60;
7
+ export const ACCESS_TTL_S = 3_600;
8
+ export const REFRESH_TTL_S = 30 * 24 * 3_600;
9
+ export const CHAIN_MAX_S = 90 * 24 * 3_600;
10
+ const MAX_REDIRECT_URIS = 10;
11
+ const MAX_REDIRECT_URI_LENGTH = 512;
12
+ const nowS = () => Math.floor(Date.now() / 1000);
13
+ /** DCR sin base de datos: el client_id lleva la metadata firmada. Todos los clientes son publicos (PKCE, sin secreto). */
14
+ export class StatelessClientsStore {
15
+ keys;
16
+ constructor(keys) {
17
+ this.keys = keys;
18
+ }
19
+ getClient(clientId) {
20
+ const meta = verify(clientId, this.keys);
21
+ if (!meta || !Array.isArray(meta.redirect_uris))
22
+ return undefined;
23
+ return {
24
+ client_id: clientId,
25
+ client_id_issued_at: meta.iat,
26
+ redirect_uris: meta.redirect_uris,
27
+ client_name: meta.client_name,
28
+ grant_types: meta.grant_types,
29
+ response_types: meta.response_types,
30
+ token_endpoint_auth_method: 'none',
31
+ scope: meta.scope,
32
+ };
33
+ }
34
+ registerClient(client) {
35
+ const uris = (client.redirect_uris ?? []).map(String);
36
+ if (uris.length === 0 || uris.length > MAX_REDIRECT_URIS || uris.some((u) => u.length > MAX_REDIRECT_URI_LENGTH)) {
37
+ throw new InvalidRequestError(`redirect_uris: entre 1 y ${MAX_REDIRECT_URIS} URLs de hasta ${MAX_REDIRECT_URI_LENGTH} caracteres.`);
38
+ }
39
+ const meta = {
40
+ redirect_uris: uris,
41
+ grant_types: ['authorization_code', 'refresh_token'],
42
+ response_types: ['code'],
43
+ token_endpoint_auth_method: 'none',
44
+ iat: nowS(),
45
+ };
46
+ if (client.client_name)
47
+ meta.client_name = String(client.client_name).slice(0, 100);
48
+ if (client.scope)
49
+ meta.scope = String(client.scope).slice(0, 100);
50
+ const clientId = sign(meta, this.keys);
51
+ return { ...this.getClient(clientId) };
52
+ }
53
+ }
54
+ export class PharmadateOAuthProvider {
55
+ clientsStore;
56
+ keys;
57
+ validateKey;
58
+ resourceUrl;
59
+ constructor(options) {
60
+ this.keys = deriveKeys(options.secret);
61
+ this.clientsStore = new StatelessClientsStore(this.keys);
62
+ this.validateKey = options.validateKey;
63
+ this.resourceUrl = options.resourceUrl;
64
+ }
65
+ /** GET /authorize: muestra la pagina donde se pega la API key. */
66
+ async authorize(client, params, res) {
67
+ res.set(loginPageHeaders());
68
+ res.send(renderLoginPage({
69
+ clientId: client.client_id,
70
+ redirectUri: params.redirectUri,
71
+ codeChallenge: params.codeChallenge,
72
+ scope: params.scopes?.join(' '),
73
+ state: params.state,
74
+ resource: params.resource?.toString(),
75
+ clientName: client.client_name,
76
+ }));
77
+ }
78
+ /**
79
+ * POST /oauth/login: valida la API key y, si es buena, redirige al cliente con un code.
80
+ * `redirect_uri` se vuelve a comprobar contra las registradas en el client_id (anti open-redirect).
81
+ */
82
+ async handleLoginPost(form, res) {
83
+ const client = typeof form.client_id === 'string' ? this.clientsStore.getClient(form.client_id) : undefined;
84
+ if (!client || typeof form.redirect_uri !== 'string' || !client.redirect_uris.map(String).includes(form.redirect_uri)) {
85
+ res.status(400).type('text/plain').send('Cliente o redirect_uri no validos.');
86
+ return;
87
+ }
88
+ const challenge = form.code_challenge ?? '';
89
+ if (!/^[A-Za-z0-9_-]{43,128}$/.test(challenge)) {
90
+ res.status(400).type('text/plain').send('Falta el code_challenge PKCE (S256).');
91
+ return;
92
+ }
93
+ const volver = (errorMessage) => {
94
+ res.status(200).set(loginPageHeaders());
95
+ res.send(renderLoginPage({
96
+ clientId: client.client_id,
97
+ redirectUri: form.redirect_uri,
98
+ codeChallenge: challenge,
99
+ scope: form.scope,
100
+ state: form.state,
101
+ resource: form.resource,
102
+ clientName: client.client_name,
103
+ errorMessage,
104
+ }));
105
+ };
106
+ const apiKey = typeof form.api_key === 'string' ? form.api_key.trim() : '';
107
+ if (!apiKey || apiKey.length > 200 || /\s/.test(apiKey)) {
108
+ volver('Pega tu API key de Pharmadate.');
109
+ return;
110
+ }
111
+ const check = await this.validateKey(apiKey);
112
+ if (!check.ok) {
113
+ volver(check.reason === 'invalid' ? 'La API key no es valida, esta revocada o es de un tipo no admitido. Revisala y vuelve a intentarlo.' : 'No se ha podido comprobar la API key ahora mismo. Vuelve a intentarlo en un momento.');
114
+ return;
115
+ }
116
+ const scopes = (form.scope ?? '').split(' ').filter(Boolean);
117
+ const payload = {
118
+ k: apiKey,
119
+ cid: client.client_id,
120
+ ru: form.redirect_uri,
121
+ cc: challenge,
122
+ sc: scopes.length ? scopes : [SCOPE],
123
+ exp: nowS() + CODE_TTL_S,
124
+ ...(form.resource ? { res: form.resource } : {}),
125
+ };
126
+ const code = seal('code', payload, this.keys);
127
+ const destino = new URL(form.redirect_uri);
128
+ destino.searchParams.set('code', code);
129
+ if (form.state)
130
+ destino.searchParams.set('state', form.state);
131
+ logger.info({ client: client.client_name ?? 'unknown' }, 'oauth login ok');
132
+ res.set('Cache-Control', 'no-store').redirect(302, destino.toString());
133
+ }
134
+ async challengeForAuthorizationCode(client, authorizationCode) {
135
+ const c = open('code', authorizationCode, this.keys);
136
+ if (!c || c.exp < nowS() || c.cid !== client.client_id)
137
+ throw new InvalidGrantError('Codigo de autorizacion invalido o caducado.');
138
+ return c.cc;
139
+ }
140
+ async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
141
+ const c = open('code', authorizationCode, this.keys);
142
+ if (!c || c.exp < nowS())
143
+ throw new InvalidGrantError('Codigo de autorizacion invalido o caducado.');
144
+ if (c.cid !== client.client_id)
145
+ throw new InvalidGrantError('El codigo no se emitio para este cliente.');
146
+ if (redirectUri && redirectUri !== c.ru)
147
+ throw new InvalidGrantError('redirect_uri no coincide con el de la autorizacion.');
148
+ return this.issue(c.k, client.client_id, c.sc, resource?.toString() ?? c.res, nowS());
149
+ }
150
+ async exchangeRefreshToken(client, refreshToken, scopes, resource) {
151
+ const r = open('refresh', refreshToken, this.keys);
152
+ if (!r || r.exp < nowS())
153
+ throw new InvalidGrantError('Refresh token invalido o caducado.');
154
+ if (r.cid !== client.client_id)
155
+ throw new InvalidGrantError('El refresh token no se emitio para este cliente.');
156
+ if (nowS() - r.cs > CHAIN_MAX_S)
157
+ throw new InvalidGrantError('La sesion ha superado su vida maxima; vuelve a iniciar sesion.');
158
+ if (scopes?.length && scopes.some((s) => !r.sc.includes(s)))
159
+ throw new InvalidGrantError('Los scopes pedidos no son un subconjunto de los concedidos.');
160
+ // Si la key se ha rotado o revocado, no se renueva: el cliente tiene que volver a autorizar
161
+ const check = await this.validateKey(r.k);
162
+ if (!check.ok) {
163
+ if (check.reason === 'invalid')
164
+ throw new InvalidGrantError('La API key ya no es valida; vuelve a iniciar sesion.');
165
+ throw new ServerError('No se pudo comprobar la API key; reintenta.');
166
+ }
167
+ return this.issue(r.k, client.client_id, scopes?.length ? scopes : r.sc, resource?.toString() ?? r.res, r.cs);
168
+ }
169
+ /** Descifra un access token. Lo usa `requireBearerAuth` del SDK. */
170
+ async verifyAccessToken(token) {
171
+ const a = open('access', token, this.keys);
172
+ if (!a)
173
+ throw new InvalidTokenError('Token invalido.');
174
+ if (a.exp < nowS())
175
+ throw new InvalidTokenError('Token caducado.');
176
+ const info = { token, clientId: a.cid, scopes: a.sc, expiresAt: a.exp, extra: { apiKey: a.k } };
177
+ if (a.res)
178
+ info.resource = new URL(a.res);
179
+ return info;
180
+ }
181
+ issue(apiKey, clientId, scopes, resource, chainStart) {
182
+ const now = nowS();
183
+ const base = { k: apiKey, cid: clientId, sc: scopes, ...(resource ? { res: resource } : {}) };
184
+ const access = seal('access', { ...base, exp: now + ACCESS_TTL_S }, this.keys);
185
+ const refresh = seal('refresh', { ...base, exp: Math.min(now + REFRESH_TTL_S, chainStart + CHAIN_MAX_S), cs: chainStart }, this.keys);
186
+ return { access_token: access, token_type: 'bearer', expires_in: ACCESS_TTL_S, scope: scopes.join(' '), refresh_token: refresh };
187
+ }
188
+ }