@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.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +201 -2
  3. package/dist/auth/crypto.d.ts +24 -0
  4. package/dist/auth/crypto.js +110 -0
  5. package/dist/auth/login-page.d.ts +32 -0
  6. package/dist/auth/login-page.js +98 -0
  7. package/dist/auth/oauth-provider.d.ts +92 -0
  8. package/dist/auth/oauth-provider.js +208 -0
  9. package/dist/auth/redirect-trust.d.ts +26 -0
  10. package/dist/auth/redirect-trust.js +94 -0
  11. package/dist/client.d.ts +54 -0
  12. package/dist/client.js +131 -0
  13. package/dist/config.d.ts +16 -0
  14. package/dist/config.js +28 -0
  15. package/dist/http.d.ts +79 -0
  16. package/dist/http.js +272 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.js +114 -0
  19. package/dist/limits.d.ts +74 -0
  20. package/dist/limits.js +152 -0
  21. package/dist/logger.d.ts +10 -0
  22. package/dist/logger.js +34 -0
  23. package/dist/operation-mode.d.ts +14 -0
  24. package/dist/operation-mode.js +10 -0
  25. package/dist/pharmacy-resolver.d.ts +54 -0
  26. package/dist/pharmacy-resolver.js +92 -0
  27. package/dist/principal.d.ts +30 -0
  28. package/dist/principal.js +85 -0
  29. package/dist/prompts.d.ts +3 -0
  30. package/dist/prompts.js +42 -0
  31. package/dist/resources.d.ts +15 -0
  32. package/dist/resources.js +58 -0
  33. package/dist/results.d.ts +42 -0
  34. package/dist/results.js +121 -0
  35. package/dist/scrub.d.ts +18 -0
  36. package/dist/scrub.js +49 -0
  37. package/dist/server-instructions.d.ts +8 -0
  38. package/dist/server-instructions.js +39 -0
  39. package/dist/server.d.ts +31 -0
  40. package/dist/server.js +59 -0
  41. package/dist/tools/common.d.ts +9 -0
  42. package/dist/tools/common.js +225 -0
  43. package/dist/tools/index.d.ts +15 -0
  44. package/dist/tools/index.js +6 -0
  45. package/dist/tools/operate.d.ts +15 -0
  46. package/dist/tools/operate.js +24 -0
  47. package/dist/tools/partner.d.ts +2 -0
  48. package/dist/tools/partner.js +176 -0
  49. package/dist/tools/types.d.ts +39 -0
  50. package/dist/tools/types.js +3 -0
  51. package/dist/version.d.ts +1 -0
  52. package/dist/version.js +15 -0
  53. package/package.json +75 -4
  54. package/server.json +56 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Nubiia (https://nubiia.es)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,202 @@
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
+ [![mcptry](https://mcptry.com/badge/es.pharmadatepedido/mcp)](https://mcptry.com/servers/es.pharmadatepedido/mcp)
4
+
5
+ Servidor [MCP](https://modelcontextprotocol.io) de Pharmadate. Permite a un asistente de IA (Claude, ChatGPT, Cursor, VS Code...)
6
+ consultar el catálogo, valorar y cursar pedidos y, si eres partner (TopFarma...), gestionar tus farmacias, **sobre la API v1 de
7
+ Pharmadate** (`/api-docs`). No accede a la base de datos: toda la autorización la hace la API.
8
+
9
+ > **Estado (fase 1):** partners y farmacias. Las herramientas de administración interna llegan en la fase 2.
10
+ > El paquete está en npm (`npx -y @pharmadate/mcp`) y el servidor remoto está activo en producción.
11
+
12
+ - Paquete npm: `@pharmadate/mcp` · Registro MCP: `es.pharmadatepedido/mcp`
13
+ - Remoto (Streamable HTTP): `https://www.pharmadatepedido.es/mcp`
14
+ - 19 herramientas; el **nombre de cada una es el `operationId`** del OpenAPI (`frontend/api-docs/openapi.yaml`).
15
+
16
+ ## Qué puede hacer cada credencial
17
+
18
+ | Credencial | Prefijo | Herramientas |
19
+ |---|---|---|
20
+ | **Partner** | `pk_partner_...` | Las 19: las operativas (con el argumento `pharmacy`) y la gestión de sus farmacias |
21
+ | **Farmacia** | `pk_live_...` | 12: las operativas, sobre su propia farmacia (sin argumento `pharmacy`) |
22
+
23
+ ### Operativas (12) — farmacia, o partner con `pharmacy`
24
+
25
+ | Herramienta | Tipo | Para qué |
26
+ |---|---|---|
27
+ | `ping` | lectura | Comprobar la credencial y ver quién eres |
28
+ | `getCatalog` | lectura | Buscar en el catálogo (`q` por nombre o laboratorio; 50 por página) |
29
+ | `getConditions` | lectura | Condiciones de un código nacional |
30
+ | `lookupConditions` | lectura | Condiciones de hasta 1000 códigos |
31
+ | `createQuote` | lectura | Valorar una cesta (no crea nada). **Siempre antes de pedir** |
32
+ | `createOrder` | escritura, idempotente | Pedido **REAL**. `external_ref` obligatoria |
33
+ | `listOrders`, `getOrder`, `getOrderModification` | lectura | Seguimiento de pedidos, faltas y modificaciones |
34
+ | `listOrderShipments`, `getOrderShipment`, `getShipment` | lectura | Envíos |
35
+
36
+ ### Solo partner (7)
37
+
38
+ | Herramienta | Tipo | Para qué |
39
+ |---|---|---|
40
+ | `listPharmacies`, `getPharmacy` | lectura | Tus farmacias (nunca devuelven la API key completa) |
41
+ | `createPharmacy` | escritura | Alta |
42
+ | `updatePharmacy` | escritura (destructiva) | Modificar datos |
43
+ | `deactivatePharmacy` | destructiva, idempotente | Baja lógica (se deshace con `reactivatePharmacy`) |
44
+ | `reactivatePharmacy` | escritura | Deshacer una baja (API key nueva) |
45
+ | `rotatePharmacyApiKey` | destructiva | Key nueva; **invalida la anterior en el acto** |
46
+
47
+ Cada herramienta lleva las *annotations* MCP (`readOnlyHint`, `destructiveHint`, `idempotentHint`, `openWorldHint`) para que el cliente
48
+ pida confirmación en las que escriben. Además hay 4 *resources* (`pharmadate://docs/integration-guide`, `.../errors`, `.../openapi`,
49
+ `pharmadate://me`), 4 *prompts* (`placeOrder`, `orderStatus` y, para partners, `onboardPharmacy` y `recoverPharmacyAccess`) y
50
+ unas *instrucciones del servidor* con las reglas de uso.
51
+
52
+ ## Conectarlo
53
+
54
+ ### Claude Code (remoto)
55
+
56
+ ```bash
57
+ claude mcp add --transport http pharmadate https://www.pharmadatepedido.es/mcp \
58
+ --header "Authorization: Bearer pk_partner_XXXXXXXX"
59
+ ```
60
+
61
+ Sin `--header`, Claude Code arranca el login OAuth en el navegador (`/mcp` dentro de Claude Code).
62
+
63
+ ### Claude Code / Claude Desktop (local, stdio)
64
+
65
+ ```bash
66
+ claude mcp add pharmadate --env PHARMADATE_API_KEY=pk_partner_XXXXXXXX --env PHARMADATE_MCP_MODE=STANDARD -- npx -y @pharmadate/mcp
67
+ ```
68
+
69
+ Claude Desktop (`claude_desktop_config.json`):
70
+
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "pharmadate": {
75
+ "command": "npx",
76
+ "args": ["-y", "@pharmadate/mcp"],
77
+ "env": {
78
+ "PHARMADATE_API_KEY": "pk_partner_XXXXXXXX",
79
+ "PHARMADATE_MCP_MODE": "STANDARD"
80
+ }
81
+ }
82
+ }
83
+ }
84
+ ```
85
+
86
+ ### Cursor / VS Code
87
+
88
+ Cursor (`~/.cursor/mcp.json`) o VS Code (`.vscode/mcp.json`, clave `servers`):
89
+
90
+ ```json
91
+ {
92
+ "mcpServers": {
93
+ "pharmadate": {
94
+ "url": "https://www.pharmadatepedido.es/mcp",
95
+ "headers": { "Authorization": "Bearer pk_partner_XXXXXXXX" }
96
+ }
97
+ }
98
+ }
99
+ ```
100
+
101
+ ### Claude.ai y ChatGPT (OAuth)
102
+
103
+ *Ajustes > Conectores > Añadir conector personalizado* y la URL `https://www.pharmadatepedido.es/mcp`. El cliente abre una página de
104
+ Pharmadate donde **pegas tu API key**; no hace falta configurar ningún cliente OAuth (el registro es dinámico).
105
+
106
+ > **Consejo para partners:** crea una credencial dedicada «MCP» en `/partner.html` (`POST /api/partner/tokens`). Así la puedes revocar
107
+ > sin tocar tu integración.
108
+
109
+ ## Variables de entorno (stdio)
110
+
111
+ | Variable | Obligatoria | Descripción |
112
+ |---|---|---|
113
+ | `PHARMADATE_API_KEY` | sí | Key de partner o de farmacia |
114
+ | `PHARMADATE_MCP_MODE` | no | `READ_ONLY` (**por defecto**: solo lectura y valoración) o `STANDARD` (pedidos y gestión de farmacias) |
115
+ | `PHARMADATE_PHARMACY_REF` | no | Solo partners: CIF de la farmacia por defecto (se verifica antes de usarla) |
116
+ | `PHARMADATE_API_URL` | no | Por defecto `https://www.pharmadatepedido.es/api` (la beta: `.../api` de su URL) |
117
+
118
+ El servidor remoto trabaja siempre en modo `STANDARD` (cada cliente pide confirmación por las *annotations*); el operador puede dejarlo
119
+ en `READ_ONLY` con `MCP_REMOTE_MODE`.
120
+
121
+ ## Seguridad
122
+
123
+ - **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
124
+ un CIF desconocido en cualquier ruta operativa. Por eso el MCP, con `pharmacy: { pharmacy_id }` (preferido) o `{ cif }`, la busca antes
125
+ 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
126
+ **no llama a la API operativa**. Solo entonces envía `X-Pharmacy-Ref`, y **nunca** `pharmacy` ni `pharmacy_ref` en el cuerpo.
127
+ - **Sin estado de negocio:** cada llamada se autoriza en la API. Un partner nunca ve las farmacias de otro.
128
+ - **Keys:** nunca van a logs ni a mensajes de error (se redactan por patrón). `createPharmacy`, `reactivatePharmacy` y `rotatePharmacyApiKey` solo
129
+ muestran el prefijo; con `reveal_api_key: true` la key completa va en un bloque aparte con `annotations.audience: ["user"]`. **Puede quedar en el
130
+ 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.
131
+ - **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
132
+ endpoints sin autenticar, 30 peticiones/minuto por IP, y 20 credenciales inválidas por minuto y IP en `/mcp` (después, 429 sin llamar a la API). Los
133
+ contadores por IP tienen tope de tamaño. La API añade sus 120 peticiones/minuto.
134
+ - **Pedidos:** `createOrder` es real, firme e **idempotente por `external_ref`** (obligatoria): reintentar con la misma ref y la misma cesta no duplica.
135
+ Si se corta la llamada, el modelo debe reintentar con la **misma** ref.
136
+ - **Auditoría:** cada llamada queda en `api_audit` con `User-Agent: pharmadate-mcp/<versión> (remote|stdio; client=<nombre>)` y la IP real.
137
+
138
+ ### OAuth 2.1 sin base de datos
139
+
140
+ `/.well-known/oauth-authorization-server`, `/register` (DCR), `/authorize`, `/token` y `/.well-known/oauth-protected-resource/mcp`, en la raíz.
141
+ 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
142
+ `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**:
143
+ cada llamada se valida contra la API y un refresh con la key revocada se rechaza. Cambiar `MCP_OAUTH_SECRET` cierra todas las sesiones.
144
+
145
+ **Registro abierto y phishing.** Cualquiera puede registrar un cliente (DCR) con la `redirect_uri` y el nombre que quiera y mandarte el enlace de
146
+ `/authorize`; si pegaras tu key, el `code` iría a su dirección. Por eso la página de login:
147
+
148
+ - enseña de forma destacada **«Tras conectar, volverás a: `<host>`»**;
149
+ - trata como **de confianza** solo los callbacks de Claude (`claude.ai`, `claude.com`), ChatGPT/OpenAI (`chatgpt.com`, `chat.openai.com`, `platform.openai.com`),
150
+ loopback (`127.0.0.1`, `localhost`, `[::1]`, con cualquier puerto, RFC 8252) y los de `MCP_ALLOWED_REDIRECTS`;
151
+ - con cualquier otro host sale un **aviso rojo** y una casilla obligatoria **«Reconozco esta aplicación y su dirección»**, que el servidor exige también en el `POST`;
152
+ - el nombre que el cliente dice tener **nunca titula la página** si no es de confianza: sale como «dice llamarse X (no verificado)»;
153
+ - recomienda usar **un token de partner dedicado al MCP**, no la key de tu integración.
154
+
155
+ ## Despliegue (operador)
156
+
157
+ 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.
158
+
159
+ | Variable (EB) | Descripción |
160
+ |---|---|
161
+ | `MCP_ENABLED=1` | Activa `POST /mcp` y las rutas OAuth |
162
+ | `MCP_PUBLIC_URL` | URL pública, p. ej. `https://www.pharmadatepedido.es` (issuer y recurso) |
163
+ | `MCP_OAUTH_SECRET` | Hex o base64 de **32+ bytes** aleatorios (`openssl rand -base64 48`, 64 caracteres). Uno corto o sin aleatoriedad (`xxxx…`) se rechaza y el MCP no se monta |
164
+ | `MCP_ALLOWED_REDIRECTS` | Opcional: callbacks OAuth de confianza además de Claude, ChatGPT/OpenAI y loopback; hosts u orígenes separados por comas (`app.socio.es,https://otro.es:8443`). Sin ella, la lista extra queda vacía |
165
+ | `MCP_ALLOWED_ORIGINS` | Opcional: orígenes de navegador extra (por defecto claude.ai, claude.com, chatgpt.com) |
166
+ | `MCP_ALLOWED_HOSTS` | Opcional: hostnames extra para la cabecera `Host` (protección contra DNS rebinding) |
167
+ | `MCP_REMOTE_MODE` | Opcional: `READ_ONLY` para un remoto de solo lectura |
168
+ | `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). La API v1 usa la misma cuenta (`backend/services/ipCliente.js`; también acepta `TRUSTED_PROXY_HOPS`, que manda sobre esta) |
169
+
170
+ 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
171
+ dependencias (`@modelcontextprotocol/sdk`, `zod`, `pino`) están en `backend/package.json` porque EB solo instala las de producción. **La beta solo sirve HTTP**
172
+ (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**).
173
+
174
+ ### Publicar (requiere aprobación)
175
+
176
+ 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).
177
+ 2. Secrets: `NPM_TOKEN` y `MCP_REGISTRY_DNS_PRIVATE_KEY`.
178
+ 3. Namespace `es.pharmadatepedido/*` del registro: verificación por DNS. Genera un par Ed25519 (`openssl genpkey -algorithm Ed25519`), publica en GoDaddy el TXT
179
+ `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`.
180
+
181
+ ## Desarrollo
182
+
183
+ ```bash
184
+ cd mcp && npm ci
185
+ npm test # unitarios: contrato con el OpenAPI, snapshots por rol, resolución de la farmacia, aislamiento, OAuth, límites
186
+ npm run build
187
+ npm run test:integration # backend real (SQLite de pruebas, requiere `cd backend && npm ci`) con MCP_ENABLED=1: HTTP, OAuth y stdio
188
+ npm run dev # stdio con tsx (PHARMADATE_API_KEY=...)
189
+ MCP_OAUTH_SECRET=$(openssl rand -hex 32) npm run dev -- --transport=http # HTTP local en :3333
190
+ ```
191
+
192
+ Probar con el inspector: `npx @modelcontextprotocol/inspector` y la URL `http://localhost:3333/mcp` (Streamable HTTP, cabecera `Authorization: Bearer <key>`).
193
+
194
+ Diseño completo y decisiones: [`docs/diseno_mcp.md`](../docs/diseno_mcp.md).
195
+
196
+ ## Licencia
197
+
198
+ [MIT](LICENSE) © 2026 Nubiia.
199
+
200
+ ## Desarrollado por
201
+
202
+ Desarrollado por [Nubiia](https://nubiia.es) · Contacto: samuel.fraga@nubiia.es
@@ -0,0 +1,24 @@
1
+ export type TokenKind = 'code' | 'access' | 'refresh';
2
+ export declare const MIN_SECRET_LENGTH = 32;
3
+ /** Entropia minima del secreto, en bytes (`openssl rand -base64 48` da 48; `openssl rand -hex 32` da 32). */
4
+ export declare const MIN_SECRET_BYTES = 32;
5
+ /**
6
+ * Comprueba `MCP_OAUTH_SECRET`: tiene que ser hex o base64 y, decodificado, de al menos {@link MIN_SECRET_BYTES} bytes, con
7
+ * caracteres variados (un `xxxxxxxx...` de 40 letras tiene longitud de sobra y entropia cero). Devuelve el motivo del rechazo, o
8
+ * `null` si vale.
9
+ */
10
+ export declare function secretProblem(secret: unknown): string | null;
11
+ export interface DerivedKeys {
12
+ enc: Buffer;
13
+ mac: Buffer;
14
+ }
15
+ export declare function deriveKeys(secret: string): DerivedKeys;
16
+ /** Cifra un objeto JSON en un token opaco con el prefijo de su tipo. */
17
+ export declare function seal(kind: TokenKind, payload: object, keys: DerivedKeys): string;
18
+ /** Descifra y verifica. Devuelve `null` si el token esta manipulado, es de otro tipo o no es nuestro. */
19
+ export declare function open<T extends object>(kind: TokenKind, token: string, keys: DerivedKeys): T | null;
20
+ /** Si el texto parece un token del OAuth del MCP (de cualquier tipo). */
21
+ export declare function looksLikeMcpToken(token: string): boolean;
22
+ /** Firma un objeto: `base64url(json).base64url(hmac)`. Es el `client_id` del registro dinamico. */
23
+ export declare function sign(payload: object, keys: DerivedKeys): string;
24
+ export declare function verify<T extends object>(signed: string, keys: DerivedKeys): T | null;
@@ -0,0 +1,110 @@
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
+ /** Entropia minima del secreto, en bytes (`openssl rand -base64 48` da 48; `openssl rand -hex 32` da 32). */
20
+ export const MIN_SECRET_BYTES = 32;
21
+ /**
22
+ * Comprueba `MCP_OAUTH_SECRET`: tiene que ser hex o base64 y, decodificado, de al menos {@link MIN_SECRET_BYTES} bytes, con
23
+ * caracteres variados (un `xxxxxxxx...` de 40 letras tiene longitud de sobra y entropia cero). Devuelve el motivo del rechazo, o
24
+ * `null` si vale.
25
+ */
26
+ export function secretProblem(secret) {
27
+ const ayuda = ' Genera uno con `openssl rand -base64 48`.';
28
+ if (typeof secret !== 'string' || secret.length < MIN_SECRET_LENGTH)
29
+ return `MCP_OAUTH_SECRET debe tener al menos ${MIN_SECRET_LENGTH} caracteres.${ayuda}`;
30
+ let bytes;
31
+ if (/^[0-9a-fA-F]+$/.test(secret)) {
32
+ bytes = secret.length % 2 === 0 ? secret.length / 2 : 0;
33
+ }
34
+ else if (/^[A-Za-z0-9+/_-]+={0,2}$/.test(secret)) {
35
+ bytes = Buffer.from(secret, 'base64').length;
36
+ }
37
+ else {
38
+ return `MCP_OAUTH_SECRET debe ser hex o base64 (sin espacios ni otros simbolos).${ayuda}`;
39
+ }
40
+ if (bytes < MIN_SECRET_BYTES)
41
+ return `MCP_OAUTH_SECRET debe equivaler a al menos ${MIN_SECRET_BYTES} bytes aleatorios una vez decodificado (hex o base64).${ayuda}`;
42
+ if (new Set(secret).size < 10)
43
+ return `MCP_OAUTH_SECRET tiene muy pocos caracteres distintos: no parece aleatorio.${ayuda}`;
44
+ return null;
45
+ }
46
+ export function deriveKeys(secret) {
47
+ const problema = secretProblem(secret);
48
+ if (problema)
49
+ throw new Error(problema);
50
+ const salt = Buffer.from('pharmadate-mcp/oauth/v1');
51
+ return {
52
+ enc: Buffer.from(hkdfSync('sha256', secret, salt, 'enc', 32)),
53
+ mac: Buffer.from(hkdfSync('sha256', secret, salt, 'mac', 32)),
54
+ };
55
+ }
56
+ const b64u = (b) => b.toString('base64url');
57
+ /** Cifra un objeto JSON en un token opaco con el prefijo de su tipo. */
58
+ export function seal(kind, payload, keys) {
59
+ const iv = randomBytes(12);
60
+ const cipher = createCipheriv('aes-256-gcm', keys.enc, iv);
61
+ cipher.setAAD(Buffer.from(kind));
62
+ const data = Buffer.concat([cipher.update(JSON.stringify(payload), 'utf8'), cipher.final()]);
63
+ return PREFIX[kind] + b64u(Buffer.concat([iv, cipher.getAuthTag(), data]));
64
+ }
65
+ /** Descifra y verifica. Devuelve `null` si el token esta manipulado, es de otro tipo o no es nuestro. */
66
+ export function open(kind, token, keys) {
67
+ if (typeof token !== 'string' || !token.startsWith(PREFIX[kind]) || token.length > 8_192)
68
+ return null;
69
+ try {
70
+ const raw = Buffer.from(token.slice(PREFIX[kind].length), 'base64url');
71
+ if (raw.length < 12 + 16 + 2)
72
+ return null;
73
+ const decipher = createDecipheriv('aes-256-gcm', keys.enc, raw.subarray(0, 12));
74
+ decipher.setAAD(Buffer.from(kind));
75
+ decipher.setAuthTag(raw.subarray(12, 28));
76
+ const json = Buffer.concat([decipher.update(raw.subarray(28)), decipher.final()]).toString('utf8');
77
+ return JSON.parse(json);
78
+ }
79
+ catch {
80
+ return null;
81
+ }
82
+ }
83
+ /** Si el texto parece un token del OAuth del MCP (de cualquier tipo). */
84
+ export function looksLikeMcpToken(token) {
85
+ return Object.values(PREFIX).some((p) => token.startsWith(p));
86
+ }
87
+ /** Firma un objeto: `base64url(json).base64url(hmac)`. Es el `client_id` del registro dinamico. */
88
+ export function sign(payload, keys) {
89
+ const body = b64u(Buffer.from(JSON.stringify(payload)));
90
+ const mac = createHmac('sha256', keys.mac).update(body).digest();
91
+ return `${body}.${b64u(mac)}`;
92
+ }
93
+ export function verify(signed, keys) {
94
+ if (typeof signed !== 'string' || signed.length > 8_192)
95
+ return null;
96
+ const i = signed.lastIndexOf('.');
97
+ if (i <= 0)
98
+ return null;
99
+ const body = signed.slice(0, i);
100
+ const mac = Buffer.from(signed.slice(i + 1), 'base64url');
101
+ const expected = createHmac('sha256', keys.mac).update(body).digest();
102
+ if (mac.length !== expected.length || !timingSafeEqual(mac, expected))
103
+ return null;
104
+ try {
105
+ return JSON.parse(Buffer.from(body, 'base64url').toString('utf8'));
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
@@ -0,0 +1,32 @@
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
+ /** Nombre que el cliente SE PONE al registrarse: no esta verificado. */
18
+ clientName?: string | undefined;
19
+ /** Host al que se vuelve tras conectar (se muestra de forma destacada). */
20
+ redirectHost: string;
21
+ /** `true` si el callback es de confianza ({@link classifyRedirect}); si no, aviso rojo y casilla obligatoria. */
22
+ trusted: boolean;
23
+ /** Callback de loopback: sin aviso ni casilla, pero el nombre del cliente tampoco esta verificado. */
24
+ loopback?: boolean | undefined;
25
+ errorMessage?: string | undefined;
26
+ }
27
+ /** Quita saltos de linea y caracteres de control del nombre autodeclarado (no puede disfrazarse de otra frase). */
28
+ export declare function cleanClientName(nombre: string | undefined): string;
29
+ export declare function escapeHtml(valor: string): string;
30
+ export declare const LOGIN_PATH = "/oauth/login";
31
+ export declare function renderLoginPage(p: LoginPageParams): string;
32
+ export declare function loginPageHeaders(): Record<string, string>;
@@ -0,0 +1,98 @@
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
+ /** Quita saltos de linea y caracteres de control del nombre autodeclarado (no puede disfrazarse de otra frase). */
11
+ export function cleanClientName(nombre) {
12
+ // eslint-disable-next-line no-control-regex
13
+ return (nombre ?? '').replace(/[\u0000-\u001f\u007f\u2028\u2029\u202a-\u202e\u2066-\u2069]+/g, ' ').trim().slice(0, 100);
14
+ }
15
+ export function escapeHtml(valor) {
16
+ return valor.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
17
+ }
18
+ const hidden = (name, value) => value === undefined || value === '' ? '' : `<input type="hidden" name="${name}" value="${escapeHtml(value)}">`;
19
+ export const LOGIN_PATH = '/oauth/login';
20
+ export function renderLoginPage(p) {
21
+ const nombre = cleanClientName(p.clientName);
22
+ const destino = escapeHtml(p.redirectHost);
23
+ // Un cliente de confianza puede titular la pagina con su nombre; uno que no lo es, no: el nombre lo escribe quien lo registro.
24
+ // Solo un destino de confianza que NO es loopback (claude.ai, chatgpt.com...) puede titular la pagina con el nombre que dice tener
25
+ const nombreFiable = p.trusted && !p.loopback;
26
+ const cliente = nombreFiable ? escapeHtml(nombre || 'tu asistente de IA') : (p.loopback ? 'una aplicación local' : 'una aplicación externa');
27
+ const titular = nombreFiable
28
+ ? ''
29
+ : `<p class="unverified">${nombre ? `Dice llamarse &laquo;${escapeHtml(nombre)}&raquo; <strong>(no verificado)</strong>.` : 'No ha indicado ningún nombre.'}</p>`;
30
+ const aviso = p.trusted
31
+ ? ''
32
+ : `<div class="warn" role="alert"><strong>Atención: esta dirección no es de un asistente de confianza.</strong> No es Claude, ni ChatGPT, ni una aplicación de tu equipo. Si sigues, quien controle <strong>${destino}</strong> podrá consultar el catálogo, cursar pedidos y, si eres partner, gestionar tus farmacias con los permisos de tu API key. Continúa solo si has iniciado tú esta conexión y reconoces la aplicación.</div>`;
33
+ const reconocer = p.trusted
34
+ ? ''
35
+ : `<label class="ack"><input type="checkbox" name="ack" value="1" required> Reconozco esta aplicación y su dirección (<strong>${destino}</strong>)</label>`;
36
+ const error = p.errorMessage ? `<div class="error" role="alert">${escapeHtml(p.errorMessage)}</div>` : '';
37
+ return `<!DOCTYPE html>
38
+ <html lang="es">
39
+ <head>
40
+ <meta charset="utf-8">
41
+ <meta name="viewport" content="width=device-width, initial-scale=1">
42
+ <meta name="robots" content="noindex">
43
+ <title>Conectar Pharmadate</title>
44
+ <style>
45
+ *{box-sizing:border-box}
46
+ 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}
47
+ 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)}
48
+ h1{font-size:1.25rem;margin:0 0 6px}
49
+ p{margin:0 0 16px;color:#475569;font-size:.92rem;line-height:1.45}
50
+ label{display:block;font-weight:600;font-size:.85rem;margin-bottom:6px}
51
+ input[type=password]{width:100%;padding:11px 12px;border:1px solid #cbd5e1;border-radius:8px;font-size:.95rem;font-family:ui-monospace,Menlo,monospace}
52
+ input[type=password]:focus{outline:2px solid #2563eb;border-color:#2563eb}
53
+ 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}
54
+ button:hover{background:#1e293b}
55
+ .error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;border-radius:8px;padding:10px 12px;margin-bottom:16px;font-size:.88rem}
56
+ .hint{font-size:.8rem;color:#64748b;margin-top:14px}
57
+ .dest{background:#f1f5f9;border:1px solid #cbd5e1;border-radius:8px;padding:10px 12px;margin-bottom:16px;font-size:.92rem;color:#0f172a;word-break:break-all}
58
+ .dest strong{font-size:1.02rem}
59
+ .warn{background:#fef2f2;color:#991b1b;border:2px solid #dc2626;border-radius:8px;padding:12px;margin-bottom:16px;font-size:.88rem;line-height:1.45}
60
+ .unverified{color:#991b1b;font-size:.88rem}
61
+ .ack{display:flex;gap:8px;align-items:flex-start;font-weight:600;font-size:.88rem;margin-top:14px;color:#991b1b}
62
+ .ack input{margin-top:3px}
63
+ .tip{background:#fffbeb;border:1px solid #fde68a;color:#78350f;border-radius:8px;padding:10px 12px;margin-top:14px;font-size:.82rem;line-height:1.45}
64
+ .brand{font-weight:700;letter-spacing:.02em;color:#0f172a;margin-bottom:18px}
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <main>
69
+ <div class="brand">Pharmadate</div>
70
+ <h1>Conectar con ${cliente}</h1>
71
+ ${titular}
72
+ <div class="dest">Tras conectar, volverás a: <strong>${destino}</strong></div>
73
+ <p>${nombreFiable ? cliente : 'Esta aplicación'} podrá consultar el catálogo, valorar y cursar pedidos y, si eres partner, gestionar tus farmacias, con los permisos de la API key que pegues aquí.</p>
74
+ ${aviso}
75
+ ${error}
76
+ <form method="post" action="${LOGIN_PATH}" autocomplete="off">
77
+ ${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)}
78
+ <label for="api_key">API key de Pharmadate</label>
79
+ <input id="api_key" name="api_key" type="password" required placeholder="pk_partner_... o pk_live_..." spellcheck="false" autocapitalize="off">
80
+ ${reconocer}
81
+ <button type="submit">Conectar</button>
82
+ </form>
83
+ <div class="tip"><strong>Usa un token de partner dedicado al MCP.</strong> Crea una credencial &laquo;MCP&raquo; propia en tu panel (/partner.html) y pega esa, no la que usa tu integración: así puedes revocarla en cualquier momento sin cortar nada más. Para desconectar basta con rotar o revocar la key.</div>
84
+ <p class="hint">Esta versión solo admite keys de partner o de farmacia.</p>
85
+ </main>
86
+ </body>
87
+ </html>`;
88
+ }
89
+ export function loginPageHeaders() {
90
+ return {
91
+ 'Content-Type': 'text/html; charset=utf-8',
92
+ 'Cache-Control': 'no-store',
93
+ 'X-Frame-Options': 'DENY',
94
+ 'Content-Security-Policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'; base-uri 'none'",
95
+ 'Referrer-Policy': 'no-referrer',
96
+ 'X-Content-Type-Options': 'nosniff',
97
+ };
98
+ }
@@ -0,0 +1,92 @@
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
+ /** Mensaje de `verifyAccessToken` con un token caducado (el servidor lo distingue de uno falsificado). */
31
+ export declare const TOKEN_EXPIRED_MESSAGE = "Token caducado.";
32
+ export declare const CODE_TTL_S = 60;
33
+ export declare const ACCESS_TTL_S = 3600;
34
+ export declare const REFRESH_TTL_S: number;
35
+ export declare const CHAIN_MAX_S: number;
36
+ /** Resultado de comprobar una API key contra la API. */
37
+ export type KeyCheck = {
38
+ ok: true;
39
+ } | {
40
+ ok: false;
41
+ reason: 'invalid' | 'unavailable';
42
+ message: string;
43
+ };
44
+ export type KeyValidator = (apiKey: string) => Promise<KeyCheck>;
45
+ /** DCR sin base de datos: el client_id lleva la metadata firmada. Todos los clientes son publicos (PKCE, sin secreto). */
46
+ export declare class StatelessClientsStore implements OAuthRegisteredClientsStore {
47
+ private readonly keys;
48
+ constructor(keys: DerivedKeys);
49
+ getClient(clientId: string): OAuthClientInformationFull | undefined;
50
+ registerClient(client: Omit<OAuthClientInformationFull, 'client_id' | 'client_id_issued_at'>): OAuthClientInformationFull;
51
+ }
52
+ export interface OAuthProviderOptions {
53
+ secret: string;
54
+ /** Comprueba una API key (GET /api/v1/ping). */
55
+ validateKey: KeyValidator;
56
+ /** URL canonica del recurso MCP (p. ej. https://www.pharmadatepedido.es/mcp). */
57
+ resourceUrl: URL;
58
+ /** Callbacks de confianza ADEMAS de los de Claude, ChatGPT/OpenAI y loopback (`MCP_ALLOWED_REDIRECTS`): hosts u origenes. */
59
+ trustedRedirects?: string[];
60
+ }
61
+ export declare class PharmadateOAuthProvider implements OAuthServerProvider {
62
+ readonly clientsStore: StatelessClientsStore;
63
+ private readonly keys;
64
+ private readonly validateKey;
65
+ readonly resourceUrl: URL;
66
+ private readonly extraRedirects;
67
+ constructor(options: OAuthProviderOptions);
68
+ /** GET /authorize: muestra la pagina donde se pega la API key (con el destino y, si no es de confianza, el aviso). */
69
+ authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
70
+ /**
71
+ * POST /oauth/login: valida la API key y, si es buena, redirige al cliente con un code.
72
+ * `redirect_uri` se vuelve a comprobar contra las registradas en el client_id (anti open-redirect), con la misma regla que
73
+ * `/authorize` (exacta, salvo el puerto en loopback). Si el callback no es de confianza hace falta `ack=1` (la casilla
74
+ * "Reconozco esta aplicacion y su direccion"): se exige AQUI, no solo en la pagina, y antes de mirar la API key.
75
+ */
76
+ handleLoginPost(form: {
77
+ api_key?: string;
78
+ client_id?: string;
79
+ redirect_uri?: string;
80
+ code_challenge?: string;
81
+ scope?: string;
82
+ state?: string;
83
+ resource?: string;
84
+ ack?: string;
85
+ }, res: Response): Promise<void>;
86
+ challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
87
+ exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string, resource?: URL): Promise<OAuthTokens>;
88
+ exchangeRefreshToken(client: OAuthClientInformationFull, refreshToken: string, scopes?: string[], resource?: URL): Promise<OAuthTokens>;
89
+ /** Descifra un access token. Lo usa `requireBearerAuth` del SDK. */
90
+ verifyAccessToken(token: string): Promise<AuthInfo>;
91
+ private issue;
92
+ }