@pharmadate/mcp 0.1.0 → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE 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,5 +1,7 @@
1
1
  # @pharmadate/mcp
2
2
 
3
+ [![mcptry](https://mcptry.com/badge/es.pharmadatepedido/mcp)](https://mcptry.com/servers/es.pharmadatepedido/mcp)
4
+
3
5
  Servidor [MCP](https://modelcontextprotocol.io) de Pharmadate. Permite a un asistente de IA (Claude, ChatGPT, Cursor, VS Code...)
4
6
  consultar el catálogo, valorar y cursar pedidos y, si eres partner (TopFarma...), gestionar tus farmacias, **sobre la API v1 de
5
7
  Pharmadate** (`/api-docs`). No accede a la base de datos: toda la autorización la hace la API.
@@ -127,7 +129,8 @@ en `READ_ONLY` con `MCP_REMOTE_MODE`.
127
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
128
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.
129
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
130
- endpoints sin autenticar, 30 peticiones/minuto por IP. La API añade sus 120 peticiones/minuto.
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.
131
134
  - **Pedidos:** `createOrder` es real, firme e **idempotente por `external_ref`** (obligatoria): reintentar con la misma ref y la misma cesta no duplica.
132
135
  Si se corta la llamada, el modelo debe reintentar con la **misma** ref.
133
136
  - **Auditoría:** cada llamada queda en `api_audit` con `User-Agent: pharmadate-mcp/<versión> (remote|stdio; client=<nombre>)` y la IP real.
@@ -139,6 +142,16 @@ El `code` (60 s), el access token (1 h) y el refresh token (30 días, tope de 90
139
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**:
140
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.
141
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
+
142
155
  ## Despliegue (operador)
143
156
 
144
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.
@@ -147,11 +160,12 @@ El MCP vive en `mcp/` y se monta **dentro de la app de Elastic Beanstalk**, detr
147
160
  |---|---|
148
161
  | `MCP_ENABLED=1` | Activa `POST /mcp` y las rutas OAuth |
149
162
  | `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`) |
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 |
151
165
  | `MCP_ALLOWED_ORIGINS` | Opcional: orígenes de navegador extra (por defecto claude.ai, claude.com, chatgpt.com) |
152
166
  | `MCP_ALLOWED_HOSTS` | Opcional: hostnames extra para la cabecera `Host` (protección contra DNS rebinding) |
153
167
  | `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) |
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) |
155
169
 
156
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
157
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**
@@ -179,6 +193,10 @@ Probar con el inspector: `npx @modelcontextprotocol/inspector` y la URL `http://
179
193
 
180
194
  Diseño completo y decisiones: [`docs/diseno_mcp.md`](../docs/diseno_mcp.md).
181
195
 
196
+ ## Licencia
197
+
198
+ [MIT](LICENSE) © 2026 Nubiia.
199
+
182
200
  ## Desarrollado por
183
201
 
184
202
  Desarrollado por [Nubiia](https://nubiia.es) · Contacto: samuel.fraga@nubiia.es
@@ -1,5 +1,13 @@
1
1
  export type TokenKind = 'code' | 'access' | 'refresh';
2
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;
3
11
  export interface DerivedKeys {
4
12
  enc: Buffer;
5
13
  mac: Buffer;
@@ -16,10 +16,37 @@
16
16
  import { createCipheriv, createDecipheriv, createHmac, hkdfSync, randomBytes, timingSafeEqual } from 'node:crypto';
17
17
  const PREFIX = { code: 'pdc1.', access: 'pda1.', refresh: 'pdr1.' };
18
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.`);
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;
22
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);
23
50
  const salt = Buffer.from('pharmadate-mcp/oauth/v1');
24
51
  return {
25
52
  enc: Buffer.from(hkdfSync('sha256', secret, salt, 'enc', 32)),
@@ -14,9 +14,18 @@ export interface LoginPageParams {
14
14
  scope?: string | undefined;
15
15
  state?: string | undefined;
16
16
  resource?: string | undefined;
17
+ /** Nombre que el cliente SE PONE al registrarse: no esta verificado. */
17
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;
18
25
  errorMessage?: string | undefined;
19
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;
20
29
  export declare function escapeHtml(valor: string): string;
21
30
  export declare const LOGIN_PATH = "/oauth/login";
22
31
  export declare function renderLoginPage(p: LoginPageParams): string;
@@ -7,13 +7,32 @@
7
7
  *
8
8
  * @module auth/login-page
9
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
+ }
10
15
  export function escapeHtml(valor) {
11
16
  return valor.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
12
17
  }
13
18
  const hidden = (name, value) => value === undefined || value === '' ? '' : `<input type="hidden" name="${name}" value="${escapeHtml(value)}">`;
14
19
  export const LOGIN_PATH = '/oauth/login';
15
20
  export function renderLoginPage(p) {
16
- const cliente = escapeHtml(p.clientName || 'tu asistente de IA');
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>`;
17
36
  const error = p.errorMessage ? `<div class="error" role="alert">${escapeHtml(p.errorMessage)}</div>` : '';
18
37
  return `<!DOCTYPE html>
19
38
  <html lang="es">
@@ -35,6 +54,13 @@ export function renderLoginPage(p) {
35
54
  button:hover{background:#1e293b}
36
55
  .error{background:#fef2f2;color:#991b1b;border:1px solid #fecaca;border-radius:8px;padding:10px 12px;margin-bottom:16px;font-size:.88rem}
37
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}
38
64
  .brand{font-weight:700;letter-spacing:.02em;color:#0f172a;margin-bottom:18px}
39
65
  </style>
40
66
  </head>
@@ -42,15 +68,20 @@ export function renderLoginPage(p) {
42
68
  <main>
43
69
  <div class="brand">Pharmadate</div>
44
70
  <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>
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}
46
75
  ${error}
47
76
  <form method="post" action="${LOGIN_PATH}" autocomplete="off">
48
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)}
49
78
  <label for="api_key">API key de Pharmadate</label>
50
79
  <input id="api_key" name="api_key" type="password" required placeholder="pk_partner_... o pk_live_..." spellcheck="false" autocapitalize="off">
80
+ ${reconocer}
51
81
  <button type="submit">Conectar</button>
52
82
  </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>
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>
54
85
  </main>
55
86
  </body>
56
87
  </html>`;
@@ -27,6 +27,8 @@ import type { OAuthClientInformationFull, OAuthTokens } from '@modelcontextproto
27
27
  import type { AuthInfo } from '@modelcontextprotocol/sdk/server/auth/types.js';
28
28
  import { type DerivedKeys } from './crypto.js';
29
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.";
30
32
  export declare const CODE_TTL_S = 60;
31
33
  export declare const ACCESS_TTL_S = 3600;
32
34
  export declare const REFRESH_TTL_S: number;
@@ -53,18 +55,23 @@ export interface OAuthProviderOptions {
53
55
  validateKey: KeyValidator;
54
56
  /** URL canonica del recurso MCP (p. ej. https://www.pharmadatepedido.es/mcp). */
55
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[];
56
60
  }
57
61
  export declare class PharmadateOAuthProvider implements OAuthServerProvider {
58
62
  readonly clientsStore: StatelessClientsStore;
59
63
  private readonly keys;
60
64
  private readonly validateKey;
61
65
  readonly resourceUrl: URL;
66
+ private readonly extraRedirects;
62
67
  constructor(options: OAuthProviderOptions);
63
- /** GET /authorize: muestra la pagina donde se pega la API key. */
68
+ /** GET /authorize: muestra la pagina donde se pega la API key (con el destino y, si no es de confianza, el aviso). */
64
69
  authorize(client: OAuthClientInformationFull, params: AuthorizationParams, res: Response): Promise<void>;
65
70
  /**
66
71
  * 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).
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.
68
75
  */
69
76
  handleLoginPost(form: {
70
77
  api_key?: string;
@@ -74,6 +81,7 @@ export declare class PharmadateOAuthProvider implements OAuthServerProvider {
74
81
  scope?: string;
75
82
  state?: string;
76
83
  resource?: string;
84
+ ack?: string;
77
85
  }, res: Response): Promise<void>;
78
86
  challengeForAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string): Promise<string>;
79
87
  exchangeAuthorizationCode(client: OAuthClientInformationFull, authorizationCode: string, _codeVerifier?: string, redirectUri?: string, resource?: URL): Promise<OAuthTokens>;
@@ -1,8 +1,11 @@
1
1
  import { InvalidGrantError, InvalidTokenError, InvalidRequestError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
2
2
  import { deriveKeys, open, seal, sign, verify } from './crypto.js';
3
3
  import { loginPageHeaders, renderLoginPage } from './login-page.js';
4
+ import { classifyRedirect, parseExtraRedirects, redirectIsRegistered } from './redirect-trust.js';
4
5
  import { logger } from '../logger.js';
5
6
  export const SCOPE = 'mcp:tools';
7
+ /** Mensaje de `verifyAccessToken` con un token caducado (el servidor lo distingue de uno falsificado). */
8
+ export const TOKEN_EXPIRED_MESSAGE = 'Token caducado.';
6
9
  export const CODE_TTL_S = 60;
7
10
  export const ACCESS_TTL_S = 3_600;
8
11
  export const REFRESH_TTL_S = 30 * 24 * 3_600;
@@ -56,16 +59,22 @@ export class PharmadateOAuthProvider {
56
59
  keys;
57
60
  validateKey;
58
61
  resourceUrl;
62
+ extraRedirects;
59
63
  constructor(options) {
64
+ this.extraRedirects = parseExtraRedirects(options.trustedRedirects);
60
65
  this.keys = deriveKeys(options.secret);
61
66
  this.clientsStore = new StatelessClientsStore(this.keys);
62
67
  this.validateKey = options.validateKey;
63
68
  this.resourceUrl = options.resourceUrl;
64
69
  }
65
- /** GET /authorize: muestra la pagina donde se pega la API key. */
70
+ /** GET /authorize: muestra la pagina donde se pega la API key (con el destino y, si no es de confianza, el aviso). */
66
71
  async authorize(client, params, res) {
72
+ const trust = classifyRedirect(params.redirectUri, this.extraRedirects);
67
73
  res.set(loginPageHeaders());
68
74
  res.send(renderLoginPage({
75
+ redirectHost: trust.display,
76
+ trusted: trust.trusted,
77
+ loopback: trust.loopback === true,
69
78
  clientId: client.client_id,
70
79
  redirectUri: params.redirectUri,
71
80
  codeChallenge: params.codeChallenge,
@@ -77,11 +86,13 @@ export class PharmadateOAuthProvider {
77
86
  }
78
87
  /**
79
88
  * 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).
89
+ * `redirect_uri` se vuelve a comprobar contra las registradas en el client_id (anti open-redirect), con la misma regla que
90
+ * `/authorize` (exacta, salvo el puerto en loopback). Si el callback no es de confianza hace falta `ack=1` (la casilla
91
+ * "Reconozco esta aplicacion y su direccion"): se exige AQUI, no solo en la pagina, y antes de mirar la API key.
81
92
  */
82
93
  async handleLoginPost(form, res) {
83
94
  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)) {
95
+ if (!client || typeof form.redirect_uri !== 'string' || !redirectIsRegistered(form.redirect_uri, client.redirect_uris.map(String))) {
85
96
  res.status(400).type('text/plain').send('Cliente o redirect_uri no validos.');
86
97
  return;
87
98
  }
@@ -90,9 +101,13 @@ export class PharmadateOAuthProvider {
90
101
  res.status(400).type('text/plain').send('Falta el code_challenge PKCE (S256).');
91
102
  return;
92
103
  }
104
+ const trust = classifyRedirect(form.redirect_uri, this.extraRedirects);
93
105
  const volver = (errorMessage) => {
94
106
  res.status(200).set(loginPageHeaders());
95
107
  res.send(renderLoginPage({
108
+ redirectHost: trust.display,
109
+ trusted: trust.trusted,
110
+ loopback: trust.loopback === true,
96
111
  clientId: client.client_id,
97
112
  redirectUri: form.redirect_uri,
98
113
  codeChallenge: challenge,
@@ -103,6 +118,11 @@ export class PharmadateOAuthProvider {
103
118
  errorMessage,
104
119
  }));
105
120
  };
121
+ if (!trust.trusted && form.ack !== '1') {
122
+ logger.warn({ client: client.client_name ?? 'unknown', redirect_host: trust.display }, 'oauth login sin reconocer un callback no verificado');
123
+ volver('Marca la casilla «Reconozco esta aplicación y su dirección» para continuar.');
124
+ return;
125
+ }
106
126
  const apiKey = typeof form.api_key === 'string' ? form.api_key.trim() : '';
107
127
  if (!apiKey || apiKey.length > 200 || /\s/.test(apiKey)) {
108
128
  volver('Pega tu API key de Pharmadate.');
@@ -128,7 +148,7 @@ export class PharmadateOAuthProvider {
128
148
  destino.searchParams.set('code', code);
129
149
  if (form.state)
130
150
  destino.searchParams.set('state', form.state);
131
- logger.info({ client: client.client_name ?? 'unknown' }, 'oauth login ok');
151
+ logger.info({ client: client.client_name ?? 'unknown', redirect_host: trust.display, trusted: trust.trusted }, 'oauth login ok');
132
152
  res.set('Cache-Control', 'no-store').redirect(302, destino.toString());
133
153
  }
134
154
  async challengeForAuthorizationCode(client, authorizationCode) {
@@ -172,7 +192,7 @@ export class PharmadateOAuthProvider {
172
192
  if (!a)
173
193
  throw new InvalidTokenError('Token invalido.');
174
194
  if (a.exp < nowS())
175
- throw new InvalidTokenError('Token caducado.');
195
+ throw new InvalidTokenError(TOKEN_EXPIRED_MESSAGE);
176
196
  const info = { token, clientId: a.cid, scopes: a.sc, expiresAt: a.exp, extra: { apiKey: a.k } };
177
197
  if (a.res)
178
198
  info.resource = new URL(a.res);
@@ -0,0 +1,26 @@
1
+ /** Hosts https de los asistentes de IA que se conectan de verdad por OAuth. */
2
+ export declare const TRUSTED_REDIRECT_HOSTS: readonly string[];
3
+ /** Extras del operador ya interpretados. */
4
+ export interface ExtraRedirects {
5
+ hosts: Set<string>;
6
+ origins: Set<string>;
7
+ }
8
+ export interface RedirectClassification {
9
+ /** `true` si el callback es de confianza (no hace falta que el usuario lo reconozca). */
10
+ trusted: boolean;
11
+ /** `true` si es loopback: de confianza para no exigir la casilla, pero el nombre del cliente sigue sin estar verificado. */
12
+ loopback?: boolean;
13
+ /** Lo que se ensena al usuario: el host (con puerto si no es el de por defecto). Nunca la URL completa. */
14
+ display: string;
15
+ }
16
+ /** Interpreta `MCP_ALLOWED_REDIRECTS` (hosts u origenes). Lo que no se entiende se ignora. */
17
+ export declare function parseExtraRedirects(entries: readonly string[] | undefined): ExtraRedirects;
18
+ /** Host que se muestra al usuario. Con esquemas que no son http(s) (`cursor://...`) se antepone el esquema. */
19
+ export declare function displayHost(redirectUri: string): string;
20
+ /** Clasifica una `redirect_uri` como de confianza o no. Una URL que no se puede interpretar nunca es de confianza. */
21
+ export declare function classifyRedirect(redirectUri: string, extra?: ExtraRedirects): RedirectClassification;
22
+ /**
23
+ * ¿`requested` es una de las `registered`? Igual que el SDK en `/authorize` (exacta, salvo el puerto de loopback), para que
24
+ * lo que acepta `/authorize` no lo rechace despues `/oauth/login`.
25
+ */
26
+ export declare function redirectIsRegistered(requested: string, registered: readonly string[]): boolean;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Callbacks de confianza del OAuth del MCP.
3
+ *
4
+ * El registro dinamico de clientes (DCR) es abierto: cualquiera puede registrar un cliente con la `redirect_uri` y el
5
+ * `client_name` que quiera y enviarle a un usuario el enlace de `/authorize`. Si el usuario pega su API key en la pagina,
6
+ * el `code` acaba en la direccion del atacante (phishing). El registro no se puede cerrar (Claude y ChatGPT se registran
7
+ * solos), asi que la defensa es que la pagina de login distinga los callbacks conocidos de los que no, y que pida un
8
+ * reconocimiento explicito (checkbox, validado en el servidor) para los segundos.
9
+ *
10
+ * De confianza:
11
+ * - Claude: https://claude.ai, https://claude.com
12
+ * - ChatGPT / OpenAI: https://chatgpt.com, https://chat.openai.com, https://platform.openai.com
13
+ * - Loopback: http(s)://127.0.0.1, localhost, [::1] con CUALQUIER puerto (RFC 8252 §7.3, como el SDK)
14
+ * - Extra: `MCP_ALLOWED_REDIRECTS`, lista separada por comas de hosts (`app.ejemplo.com`) o de origenes
15
+ * (`https://app.ejemplo.com:8443`). Un host a secas solo vale con https; los esquemas propios
16
+ * (`cursor://`, `vscode://`) no se pueden fiar por host y siempre piden el reconocimiento.
17
+ *
18
+ * Los hosts fijos se comparan EXACTOS (`claude.ai.evil.com` o `evil.com/claude.ai` no pasan) y sin puerto ni usuario.
19
+ *
20
+ * @module auth/redirect-trust
21
+ */
22
+ import { redirectUriMatches } from '@modelcontextprotocol/sdk/server/auth/handlers/authorize.js';
23
+ /** Hosts https de los asistentes de IA que se conectan de verdad por OAuth. */
24
+ export const TRUSTED_REDIRECT_HOSTS = ['claude.ai', 'claude.com', 'chatgpt.com', 'chat.openai.com', 'platform.openai.com'];
25
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]']);
26
+ /** Interpreta `MCP_ALLOWED_REDIRECTS` (hosts u origenes). Lo que no se entiende se ignora. */
27
+ export function parseExtraRedirects(entries) {
28
+ const extra = { hosts: new Set(), origins: new Set() };
29
+ for (const crudo of entries ?? []) {
30
+ const e = crudo.trim().toLowerCase();
31
+ if (!e)
32
+ continue;
33
+ if (e.includes('://')) {
34
+ try {
35
+ const u = new URL(e);
36
+ // Solo origenes https: el `origin` de un esquema propio (`cursor://...`) es "null" y no identifica a nadie
37
+ if (u.protocol === 'https:' && u.hostname && !u.username && !u.password)
38
+ extra.origins.add(u.origin);
39
+ }
40
+ catch {
41
+ /* entrada no valida: se ignora */
42
+ }
43
+ }
44
+ else if (/^[a-z0-9.-]+$/.test(e) && !e.startsWith('.') && !e.endsWith('.')) {
45
+ extra.hosts.add(e);
46
+ }
47
+ }
48
+ return extra;
49
+ }
50
+ /** Host que se muestra al usuario. Con esquemas que no son http(s) (`cursor://...`) se antepone el esquema. */
51
+ export function displayHost(redirectUri) {
52
+ try {
53
+ const u = new URL(redirectUri);
54
+ if (u.protocol === 'http:' || u.protocol === 'https:')
55
+ return u.host;
56
+ return u.host ? `${u.protocol}//${u.host}` : u.protocol;
57
+ }
58
+ catch {
59
+ return '(direccion no valida)';
60
+ }
61
+ }
62
+ /** Clasifica una `redirect_uri` como de confianza o no. Una URL que no se puede interpretar nunca es de confianza. */
63
+ export function classifyRedirect(redirectUri, extra = { hosts: new Set(), origins: new Set() }) {
64
+ const display = displayHost(redirectUri);
65
+ let u;
66
+ try {
67
+ u = new URL(redirectUri);
68
+ }
69
+ catch {
70
+ return { trusted: false, display };
71
+ }
72
+ if (u.username || u.password)
73
+ return { trusted: false, display };
74
+ const host = u.hostname.toLowerCase();
75
+ // Loopback: cualquier puerto, http o https
76
+ if ((u.protocol === 'http:' || u.protocol === 'https:') && LOOPBACK_HOSTS.has(host))
77
+ return { trusted: true, loopback: true, display };
78
+ if (u.protocol !== 'https:')
79
+ return { trusted: false, display };
80
+ if (u.port === '' && TRUSTED_REDIRECT_HOSTS.includes(host))
81
+ return { trusted: true, display };
82
+ if (extra.origins.has(u.origin.toLowerCase()))
83
+ return { trusted: true, display };
84
+ if (u.port === '' && extra.hosts.has(host))
85
+ return { trusted: true, display };
86
+ return { trusted: false, display };
87
+ }
88
+ /**
89
+ * ¿`requested` es una de las `registered`? Igual que el SDK en `/authorize` (exacta, salvo el puerto de loopback), para que
90
+ * lo que acepta `/authorize` no lo rechace despues `/oauth/login`.
91
+ */
92
+ export function redirectIsRegistered(requested, registered) {
93
+ return registered.some((r) => redirectUriMatches(requested, String(r)));
94
+ }
package/dist/http.d.ts CHANGED
@@ -22,6 +22,8 @@
22
22
  import { Router, type Request } from 'express';
23
23
  import { McpLimits } from './limits.js';
24
24
  import { type OperationMode } from './config.js';
25
+ /** Credenciales invalidas por minuto y IP a partir de las cuales se bloquean las keys `pk_` crudas sin validar (IPs de egress compartidas: umbral holgado). */
26
+ export declare const DEFAULT_INVALID_BEARER_PER_MIN = 200;
25
27
  /** Origenes de navegador admitidos por defecto (los clientes MCP nativos no mandan `Origin`). */
26
28
  export declare const DEFAULT_ALLOWED_ORIGINS: string[];
27
29
  export interface McpRouterOptions {
@@ -40,11 +42,20 @@ export interface McpRouterOptions {
40
42
  /** Modo de operacion del transporte remoto. Por defecto STANDARD. */
41
43
  mode?: OperationMode;
42
44
  limits?: McpLimits;
43
- /** Peticiones por minuto y por IP en los endpoints sin autenticar. Por defecto 30 (OAuth y 401) y 10 (login). */
45
+ /**
46
+ * Callbacks de confianza ADEMAS de los de Claude, ChatGPT/OpenAI y loopback (`MCP_ALLOWED_REDIRECTS`): hosts (`app.ejemplo.com`) u
47
+ * origenes (`https://app.ejemplo.com:8443`). Cualquier otro callback sale con aviso rojo y casilla obligatoria en el login.
48
+ */
49
+ trustedRedirects?: string[];
50
+ /**
51
+ * Peticiones por minuto y por IP en los endpoints sin autenticar. Por defecto 30 (OAuth y 401 sin credencial), 10 (login) y
52
+ * 200 (`invalidBearer`: credenciales invalidas; pasado el limite, las keys `pk_` crudas no validadas reciben 429 SIN llamar a la API; los tokens OAuth validos nunca se bloquean).
53
+ */
44
54
  ipRateLimits?: {
45
55
  oauth?: number;
46
56
  login?: number;
47
57
  unauthenticated?: number;
58
+ invalidBearer?: number;
48
59
  };
49
60
  fetchImpl?: typeof fetch;
50
61
  }
package/dist/http.js CHANGED
@@ -27,12 +27,15 @@ import { ApiNetworkError, PharmadateClient, buildUserAgent } from './client.js';
27
27
  import { McpLimits, IpRateLimiter } from './limits.js';
28
28
  import { PrincipalCache, PrincipalError, credentialId, detectPrincipal } from './principal.js';
29
29
  import { createPharmadateServer } from './server.js';
30
- import { PharmadateOAuthProvider, SCOPE } from './auth/oauth-provider.js';
30
+ import { PharmadateOAuthProvider, SCOPE, TOKEN_EXPIRED_MESSAGE } from './auth/oauth-provider.js';
31
+ import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
31
32
  import { looksLikeMcpToken } from './auth/crypto.js';
32
33
  import { normalizeApiUrl, parseMode } from './config.js';
33
34
  import { logger } from './logger.js';
34
35
  import { scrubText } from './scrub.js';
35
36
  import { VERSION } from './version.js';
37
+ /** Credenciales invalidas por minuto y IP a partir de las cuales se bloquean las keys `pk_` crudas sin validar (IPs de egress compartidas: umbral holgado). */
38
+ export const DEFAULT_INVALID_BEARER_PER_MIN = 200;
36
39
  /** Origenes de navegador admitidos por defecto (los clientes MCP nativos no mandan `Origin`). */
37
40
  export const DEFAULT_ALLOWED_ORIGINS = ['https://claude.ai', 'https://claude.com', 'https://chatgpt.com', 'https://chat.openai.com'];
38
41
  /**
@@ -74,6 +77,7 @@ export function createMcpRouter(options) {
74
77
  const unauthLimiter = new IpRateLimiter(options.ipRateLimits?.unauthenticated ?? 30);
75
78
  const loginLimiter = new IpRateLimiter(options.ipRateLimits?.login ?? 10);
76
79
  const oauthLimiter = new IpRateLimiter(options.ipRateLimits?.oauth ?? 30);
80
+ const invalidBearerLimiter = new IpRateLimiter(options.ipRateLimits?.invalidBearer ?? DEFAULT_INVALID_BEARER_PER_MIN);
77
81
  const fetchImpl = options.fetchImpl;
78
82
  const hops = options.proxyHops ?? 2;
79
83
  const allowedHosts = options.allowedHosts ?? [publicUrl.hostname, 'localhost', '127.0.0.1', '[::1]'];
@@ -98,7 +102,12 @@ export function createMcpRouter(options) {
98
102
  return { ok: false, reason: 'unavailable', message: err instanceof Error ? scrubText(err.message) : 'error' };
99
103
  }
100
104
  };
101
- const provider = new PharmadateOAuthProvider({ secret: options.oauthSecret, validateKey, resourceUrl });
105
+ const provider = new PharmadateOAuthProvider({
106
+ secret: options.oauthSecret,
107
+ validateKey,
108
+ resourceUrl,
109
+ ...(options.trustedRedirects ? { trustedRedirects: options.trustedRedirects } : {}),
110
+ });
102
111
  const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceUrl);
103
112
  const limitIp = (limiter) => (req, res, next) => {
104
113
  if (limiter.allow(clientIp(req, hops)))
@@ -161,33 +170,52 @@ export function createMcpRouter(options) {
161
170
  res.set('Allow', 'POST, OPTIONS').status(405).json(jsonRpcError(-32000, 'Metodo no permitido: este servidor es stateless, usa POST.'));
162
171
  });
163
172
  router.post('/mcp', express.json({ limit: '1mb' }), async (req, res) => {
173
+ const ip = clientIp(req, hops);
164
174
  const bearer = extractBearer(req.headers.authorization);
165
175
  if (!bearer) {
166
- if (!unauthLimiter.allow(clientIp(req, hops))) {
176
+ if (!unauthLimiter.allow(ip)) {
167
177
  res.set('Retry-After', '60').status(429).json(jsonRpcError(-32000, 'Demasiadas peticiones sin autenticar.'));
168
178
  return;
169
179
  }
170
180
  return unauthorized(res, 'Falta la credencial.');
171
181
  }
182
+ // Credenciales invalidas. El bloqueo por IP protege la llamada por loopback (`cache.resolve`) que cuesta una key cruda sin comprobar, asi que
183
+ // SOLO se aplica a bearers `pk_` crudos que no estan en la cache de roles. Claude y ChatGPT salen por IPs compartidas: un token OAuth que
184
+ // `verifyAccessToken` ha validado nunca se bloquea ni suma al contador (aunque su key haya sido revocada), y un token caducado (lo normal
185
+ // cada hora) tampoco suma. Suman las keys crudas rechazadas por la API, los tokens falsificados y los bearers que no son de este servidor.
186
+ const bloqueada = !invalidBearerLimiter.peek(ip);
187
+ const demasiados = () => {
188
+ res.set('Retry-After', '60').status(429).json(jsonRpcError(-32000, 'Demasiados intentos con una credencial invalida, espera un minuto.'));
189
+ };
190
+ const rechazar = (descripcion, cuenta = true) => {
191
+ if (cuenta)
192
+ invalidBearerLimiter.hit(ip);
193
+ unauthorized(res, descripcion);
194
+ };
172
195
  let apiKey;
196
+ let viaOAuth = false;
173
197
  if (looksLikeMcpToken(bearer)) {
174
198
  try {
175
199
  const info = await provider.verifyAccessToken(bearer);
176
200
  if (info.resource && info.resource.toString().replace(/\/$/, '') !== resourceUrl.toString().replace(/\/$/, '')) {
177
- return unauthorized(res, 'Token emitido para otro recurso.');
201
+ return rechazar('Token emitido para otro recurso.');
178
202
  }
179
203
  apiKey = String(info.extra.apiKey);
204
+ viaOAuth = true;
180
205
  }
181
- catch {
182
- return unauthorized(res, 'Token invalido o caducado.');
206
+ catch (err) {
207
+ const caducado = err instanceof InvalidTokenError && err.message === TOKEN_EXPIRED_MESSAGE;
208
+ return rechazar('Token invalido o caducado.', !caducado);
183
209
  }
184
210
  }
185
211
  else if (bearer.startsWith('pk_')) {
186
212
  apiKey = bearer;
187
213
  }
188
214
  else {
189
- return unauthorized(res, 'Credencial no reconocida.');
215
+ return rechazar('Credencial no reconocida.');
190
216
  }
217
+ if (!viaOAuth && bloqueada && !cache.get(credentialId(apiKey)))
218
+ return demasiados();
191
219
  const client = newClient(apiKey, req);
192
220
  let principal;
193
221
  try {
@@ -195,13 +223,25 @@ export function createMcpRouter(options) {
195
223
  }
196
224
  catch (err) {
197
225
  if (err instanceof PrincipalError && err.kind === 'unauthorized')
198
- return unauthorized(res, 'La API key no es valida, o fue rotada o revocada.');
226
+ return rechazar('La API key no es valida, o fue rotada o revocada.', !viaOAuth);
199
227
  logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'no se pudo confirmar la credencial');
200
228
  res.status(err instanceof ApiNetworkError || err instanceof PrincipalError ? 503 : 500).json(jsonRpcError(-32603, 'No se pudo comprobar la credencial con la API de Pharmadate.'));
201
229
  return;
202
230
  }
203
- const server = createPharmadateServer({ principal, client, credentialId: credentialId(apiKey), mode, limits });
204
- const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
231
+ // Express 4 no recoge las promesas rechazadas de un handler async: una excepcion aqui seria un unhandledRejection (en Node 20,
232
+ // el proceso entero, o sea la plataforma). Se atrapa y se responde un 500 JSON-RPC.
233
+ let server;
234
+ let transport;
235
+ try {
236
+ server = createPharmadateServer({ principal, client, credentialId: credentialId(apiKey), mode, limits });
237
+ transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
238
+ }
239
+ catch (err) {
240
+ logger.error({ err }, 'no se pudo crear el servidor MCP de la peticion');
241
+ if (!res.headersSent)
242
+ res.status(500).json(jsonRpcError(-32603, 'Error interno del servidor MCP.'));
243
+ return;
244
+ }
205
245
  res.on('close', () => {
206
246
  void transport.close();
207
247
  void server.close();
package/dist/index.js CHANGED
@@ -79,17 +79,20 @@ async function runStdio() {
79
79
  async function runHttp() {
80
80
  const { default: express } = await import('express');
81
81
  const { createMcpRouter } = await import('./http.js');
82
+ const { secretProblem } = await import('./auth/crypto.js');
82
83
  const port = Number(process.env['MCP_PORT'] ?? 3333);
83
84
  const publicUrl = process.env['MCP_PUBLIC_URL'] ?? `http://localhost:${port}`;
84
85
  const secret = process.env['MCP_OAUTH_SECRET'] ?? '';
85
- if (secret.length < 32)
86
- fail('MCP_OAUTH_SECRET debe tener al menos 32 caracteres.');
86
+ const problemaSecreto = secretProblem(secret);
87
+ if (problemaSecreto)
88
+ fail(problemaSecreto);
87
89
  const { router } = createMcpRouter({
88
90
  apiBaseUrl: process.env['PHARMADATE_API_URL'] ?? DEFAULT_API_URL,
89
91
  publicUrl,
90
92
  oauthSecret: secret,
91
93
  mode: parseMode(process.env['MCP_REMOTE_MODE'], 'STANDARD'),
92
94
  allowedOrigins: (process.env['MCP_ALLOWED_ORIGINS'] ?? '').split(',').map((s) => s.trim()).filter(Boolean),
95
+ trustedRedirects: (process.env['MCP_ALLOWED_REDIRECTS'] ?? '').split(',').map((s) => s.trim()).filter(Boolean),
93
96
  });
94
97
  const app = express();
95
98
  app.disable('x-powered-by');
package/dist/limits.d.ts CHANGED
@@ -49,13 +49,26 @@ export declare class McpLimits {
49
49
  check(credentialId: string, isOrder: boolean): string | null;
50
50
  startPurgeTimers(): () => void;
51
51
  }
52
- /** Limitador simple por IP para los endpoints sin autenticar (OAuth, 401). */
52
+ /** Tope por defecto de IPs distintas que recuerda un {@link IpRateLimiter}. */
53
+ export declare const DEFAULT_IP_LIMITER_ENTRIES = 10000;
54
+ /**
55
+ * Limitador simple por IP para los endpoints sin autenticar (OAuth, 401, bearers invalidos).
56
+ *
57
+ * El mapa tiene TOPE (`maxEntries`): quien barra IPs distintas (un /64 de IPv6 da 2^64) no puede agotar la memoria del proceso.
58
+ * Al llegar al tope se descartan las IPs menos recientes: perder el contador de una IP antigua solo le da otra ventana.
59
+ */
53
60
  export declare class IpRateLimiter {
54
61
  readonly max: number;
55
62
  private readonly windowMs;
56
63
  private readonly now;
64
+ private readonly maxEntries;
57
65
  private readonly hits;
58
- constructor(max: number, windowMs?: number, now?: () => number);
59
- /** `true` si puede pasar. */
66
+ constructor(max: number, windowMs?: number, now?: () => number, maxEntries?: number);
67
+ /** `true` si puede pasar, y lo cuenta. */
60
68
  allow(ip: string): boolean;
69
+ /** `true` si la IP esta por debajo del limite. NO cuenta nada (para comprobar antes de hacer un trabajo caro). */
70
+ peek(ip: string): boolean;
71
+ /** Cuenta un intento de la IP (p. ej. una credencial rechazada), pase o no el limite. */
72
+ hit(ip: string): void;
73
+ get size(): number;
61
74
  }
package/dist/limits.js CHANGED
@@ -91,32 +91,62 @@ export class McpLimits {
91
91
  };
92
92
  }
93
93
  }
94
- /** Limitador simple por IP para los endpoints sin autenticar (OAuth, 401). */
94
+ /** Tope por defecto de IPs distintas que recuerda un {@link IpRateLimiter}. */
95
+ export const DEFAULT_IP_LIMITER_ENTRIES = 10_000;
96
+ /**
97
+ * Limitador simple por IP para los endpoints sin autenticar (OAuth, 401, bearers invalidos).
98
+ *
99
+ * El mapa tiene TOPE (`maxEntries`): quien barra IPs distintas (un /64 de IPv6 da 2^64) no puede agotar la memoria del proceso.
100
+ * Al llegar al tope se descartan las IPs menos recientes: perder el contador de una IP antigua solo le da otra ventana.
101
+ */
95
102
  export class IpRateLimiter {
96
103
  max;
97
104
  windowMs;
98
105
  now;
106
+ maxEntries;
99
107
  hits = new Map();
100
- constructor(max, windowMs = 60_000, now = Date.now) {
108
+ constructor(max, windowMs = 60_000, now = Date.now, maxEntries = DEFAULT_IP_LIMITER_ENTRIES) {
101
109
  this.max = max;
102
110
  this.windowMs = windowMs;
103
111
  this.now = now;
112
+ this.maxEntries = maxEntries;
104
113
  }
105
- /** `true` si puede pasar. */
114
+ /** `true` si puede pasar, y lo cuenta. */
106
115
  allow(ip) {
116
+ if (!this.peek(ip))
117
+ return false;
118
+ this.hit(ip);
119
+ return true;
120
+ }
121
+ /** `true` si la IP esta por debajo del limite. NO cuenta nada (para comprobar antes de hacer un trabajo caro). */
122
+ peek(ip) {
123
+ const now = this.now();
124
+ const ts = this.hits.get(ip);
125
+ if (!ts)
126
+ return true;
127
+ return ts.filter((t) => now - t < this.windowMs).length < this.max;
128
+ }
129
+ /** Cuenta un intento de la IP (p. ej. una credencial rechazada), pase o no el limite. */
130
+ hit(ip) {
107
131
  const now = this.now();
108
- if (this.hits.size > PURGE_THRESHOLD) {
132
+ if (this.hits.size >= PURGE_THRESHOLD) {
109
133
  for (const [k, ts] of this.hits)
110
134
  if (ts.every((t) => now - t >= this.windowMs))
111
135
  this.hits.delete(k);
112
136
  }
113
137
  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
138
  recent.push(now);
139
+ // delete + set deja la IP la ultima del orden de insercion: lo primero del mapa es lo menos reciente
140
+ this.hits.delete(ip);
119
141
  this.hits.set(ip, recent);
120
- return true;
142
+ while (this.hits.size > this.maxEntries) {
143
+ const masAntigua = this.hits.keys().next().value;
144
+ if (masAntigua === undefined)
145
+ break;
146
+ this.hits.delete(masAntigua);
147
+ }
148
+ }
149
+ get size() {
150
+ return this.hits.size;
121
151
  }
122
152
  }
package/dist/logger.d.ts CHANGED
@@ -4,5 +4,7 @@
4
4
  *
5
5
  * @module logger
6
6
  */
7
- import pino from 'pino';
7
+ import pino, { type DestinationStream } from 'pino';
8
+ /** Crea el logger. Aparte para poder probarlo con un destino propio; el de la aplicacion va a stderr. */
9
+ export declare function createLogger(destination: DestinationStream, level?: string): pino.Logger<never, boolean>;
8
10
  export declare const logger: pino.Logger<never, boolean>;
package/dist/logger.js CHANGED
@@ -8,23 +8,27 @@ import pino from 'pino';
8
8
  import { scrubDeep, scrubText } from './scrub.js';
9
9
  import { VERSION } from './version.js';
10
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);
11
+ /** Crea el logger. Aparte para poder probarlo con un destino propio; el de la aplicacion va a stderr. */
12
+ export function createLogger(destination, level = nivel) {
13
+ return pino({
14
+ level,
15
+ base: { service: 'pharmadate-mcp', version: VERSION },
16
+ redact: {
17
+ paths: ['authorization', 'req.headers.authorization', 'headers.authorization', '*.authorization', 'api_key', '*.api_key', 'access_token', 'refresh_token', '*.access_token', '*.refresh_token'],
18
+ censor: '[REDACTED]',
28
19
  },
29
- },
30
- }, pino.destination({ dest: 2, sync: false }));
20
+ timestamp: pino.stdTimeFunctions.isoTime,
21
+ formatters: {
22
+ level: (label) => ({ level: label }),
23
+ log: (objeto) => scrubDeep(objeto),
24
+ },
25
+ hooks: {
26
+ logMethod(args, method) {
27
+ // El mensaje (primer string) tambien se redacta
28
+ const copia = args.map((a) => (typeof a === 'string' ? scrubText(a) : a));
29
+ return method.apply(this, copia);
30
+ },
31
+ },
32
+ }, destination);
33
+ }
34
+ export const logger = createLogger(pino.destination({ dest: 2, sync: false }));
package/dist/scrub.d.ts CHANGED
@@ -8,5 +8,11 @@
8
8
  */
9
9
  /** Sustituye las credenciales de un texto por marcas inofensivas. */
10
10
  export declare function scrubText(texto: string): string;
11
- /** Igual que {@link scrubText} pero recorre objetos y listas. No muta el original. */
11
+ /** Un `Error` como objeto plano y redactado: sus campos no son enumerables y `Object.entries(err)` daba `{}` (se perdia el motivo). */
12
+ export declare function scrubError(err: Error): {
13
+ name: string;
14
+ message: string;
15
+ stack?: string;
16
+ };
17
+ /** Igual que {@link scrubText} pero recorre objetos y listas. No muta el original. Los `Error` salen como `{name, message, stack}`. */
12
18
  export declare function scrubDeep<T>(valor: T, profundidad?: number): T;
package/dist/scrub.js CHANGED
@@ -19,8 +19,17 @@ export function scrubText(texto) {
19
19
  .replace(TOKEN_RE, '[REDACTED_TOKEN]')
20
20
  .replace(BEARER_RE, '$1[REDACTED]');
21
21
  }
22
- /** Igual que {@link scrubText} pero recorre objetos y listas. No muta el original. */
22
+ /** Un `Error` como objeto plano y redactado: sus campos no son enumerables y `Object.entries(err)` daba `{}` (se perdia el motivo). */
23
+ export function scrubError(err) {
24
+ const salida = { name: err.name, message: scrubText(err.message) };
25
+ if (typeof err.stack === 'string')
26
+ salida.stack = scrubText(err.stack);
27
+ return salida;
28
+ }
29
+ /** Igual que {@link scrubText} pero recorre objetos y listas. No muta el original. Los `Error` salen como `{name, message, stack}`. */
23
30
  export function scrubDeep(valor, profundidad = 0) {
31
+ if (valor instanceof Error)
32
+ return scrubError(valor);
24
33
  if (profundidad > 12)
25
34
  return valor;
26
35
  if (typeof valor === 'string')
@@ -22,8 +22,10 @@ const ITEMS = z
22
22
  .min(1)
23
23
  .max(MAX_LINES)
24
24
  .describe(`Lineas de la cesta (entre 1 y ${MAX_LINES}). Un mismo CN repetido se fusiona.`);
25
+ /** Un CN que va en la ruta (`/conditions/{cn}`): solo digitos. Se comprueba aqui y no se confia en `encodeURIComponent`. */
26
+ const CN_RUTA = z.string().regex(/^\d{1,20}$/, 'Un codigo nacional son solo digitos (hasta 20).');
25
27
  const ORDER_ID = z.number().int().positive().describe('`order_id` del pedido en Pharmadate.');
26
- const SHIPMENT_ID = z.string().min(1).max(100).describe('Identificador del envio (`shp_...`, opaco y global).');
28
+ const SHIPMENT_ID = z.string().max(100).regex(/^shp_[\w-]+$/, 'Un shipment_id tiene la forma shp_... (letras, digitos, _ y -).').describe('Identificador del envio (`shp_...`, opaco y global).');
27
29
  const PAGE = z.number().int().min(1).optional().describe('Pagina (empieza en 1).');
28
30
  /**
29
31
  * Construye las herramientas comunes para un rol: solo el partner ve el argumento `pharmacy` (opcional si hay una farmacia
@@ -85,7 +87,7 @@ export function commonTools(role, opts = {}) {
85
87
  http: { method: 'GET', path: '/api/v1/conditions/{cn}' },
86
88
  roles: BOTH_ROLES,
87
89
  annotations: READ_ANN,
88
- inputSchema: { ...farmacia, cn: z.string().min(1).max(20).describe('Codigo nacional.') },
90
+ inputSchema: { ...farmacia, cn: CN_RUTA.describe('Codigo nacional (solo digitos).') },
89
91
  handler: async (args, ctx) => operate(ctx, args.pharmacy, { method: 'GET', path: `/v1/conditions/${encodeURIComponent(args.cn)}` }),
90
92
  },
91
93
  {
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pharmadate/mcp",
3
3
  "mcpName": "es.pharmadatepedido/mcp",
4
- "version": "0.1.0",
4
+ "version": "0.1.1",
5
5
  "description": "Servidor MCP de Pharmadate: consulta el catalogo, valora y cursa pedidos y gestiona las farmacias de un partner desde Claude, ChatGPT, Cursor o cualquier cliente MCP. 19 herramientas sobre la API v1, stdio, Streamable HTTP y OAuth 2.1.",
6
6
  "type": "module",
7
7
  "main": "dist/index.js",
@@ -12,6 +12,7 @@
12
12
  "files": [
13
13
  "dist/",
14
14
  "README.md",
15
+ "LICENSE",
15
16
  "server.json"
16
17
  ],
17
18
  "engines": {
@@ -22,7 +23,7 @@
22
23
  "email": "samuel.fraga@nubiia.es",
23
24
  "url": "https://nubiia.es"
24
25
  },
25
- "license": "UNLICENSED",
26
+ "license": "MIT",
26
27
  "homepage": "https://www.pharmadatepedido.es/api-docs/mcp.html",
27
28
  "bugs": {
28
29
  "email": "samuel.fraga@nubiia.es"
package/server.json CHANGED
@@ -2,8 +2,8 @@
2
2
  "$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
3
3
  "name": "es.pharmadatepedido/mcp",
4
4
  "title": "Pharmadate",
5
- "description": "Pharmadate: catalogo, valoracion y pedidos de farmacias a laboratorios y almacen, y gestion de farmacias para partners.",
6
- "version": "0.1.0",
5
+ "description": "Pharmadate: catalogo, valoracion y pedidos de farmacia, y gestion de farmacias para partners.",
6
+ "version": "0.1.1",
7
7
  "websiteUrl": "https://www.pharmadatepedido.es/api-docs/mcp.html",
8
8
  "repository": {
9
9
  "url": "https://github.com/Rubenga11/pedido-directo",
@@ -15,7 +15,7 @@
15
15
  "registryType": "npm",
16
16
  "registryBaseUrl": "https://registry.npmjs.org",
17
17
  "identifier": "@pharmadate/mcp",
18
- "version": "0.1.0",
18
+ "version": "0.1.1",
19
19
  "runtimeHint": "npx",
20
20
  "transport": {
21
21
  "type": "stdio"