@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 +21 -0
- package/README.md +21 -3
- package/dist/auth/crypto.d.ts +8 -0
- package/dist/auth/crypto.js +30 -3
- package/dist/auth/login-page.d.ts +9 -0
- package/dist/auth/login-page.js +34 -3
- package/dist/auth/oauth-provider.d.ts +10 -2
- package/dist/auth/oauth-provider.js +25 -5
- package/dist/auth/redirect-trust.d.ts +26 -0
- package/dist/auth/redirect-trust.js +94 -0
- package/dist/http.d.ts +12 -1
- package/dist/http.js +50 -10
- package/dist/index.js +5 -2
- package/dist/limits.d.ts +16 -3
- package/dist/limits.js +39 -9
- package/dist/logger.d.ts +3 -1
- package/dist/logger.js +23 -19
- package/dist/scrub.d.ts +7 -1
- package/dist/scrub.js +10 -1
- package/dist/tools/common.js +4 -2
- package/package.json +3 -2
- package/server.json +3 -3
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
|
+
[](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
|
|
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+
|
|
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
|
package/dist/auth/crypto.d.ts
CHANGED
|
@@ -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;
|
package/dist/auth/crypto.js
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
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;
|
package/dist/auth/login-page.js
CHANGED
|
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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
|
|
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 «${escapeHtml(nombre)}» <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
|
-
|
|
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
|
-
<
|
|
83
|
+
<div class="tip"><strong>Usa un token de partner dedicado al MCP.</strong> Crea una credencial «MCP» 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)
|
|
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(
|
|
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
|
-
/**
|
|
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({
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
204
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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')
|
package/dist/tools/common.js
CHANGED
|
@@ -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().
|
|
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:
|
|
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.
|
|
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": "
|
|
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
|
|
6
|
-
"version": "0.1.
|
|
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.
|
|
18
|
+
"version": "0.1.1",
|
|
19
19
|
"runtimeHint": "npx",
|
|
20
20
|
"transport": {
|
|
21
21
|
"type": "stdio"
|