@pharmadate/mcp 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +201 -2
- package/dist/auth/crypto.d.ts +24 -0
- package/dist/auth/crypto.js +110 -0
- package/dist/auth/login-page.d.ts +32 -0
- package/dist/auth/login-page.js +98 -0
- package/dist/auth/oauth-provider.d.ts +92 -0
- package/dist/auth/oauth-provider.js +208 -0
- package/dist/auth/redirect-trust.d.ts +26 -0
- package/dist/auth/redirect-trust.js +94 -0
- package/dist/client.d.ts +54 -0
- package/dist/client.js +131 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +28 -0
- package/dist/http.d.ts +79 -0
- package/dist/http.js +272 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +114 -0
- package/dist/limits.d.ts +74 -0
- package/dist/limits.js +152 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.js +34 -0
- package/dist/operation-mode.d.ts +14 -0
- package/dist/operation-mode.js +10 -0
- package/dist/pharmacy-resolver.d.ts +54 -0
- package/dist/pharmacy-resolver.js +92 -0
- package/dist/principal.d.ts +30 -0
- package/dist/principal.js +85 -0
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.js +42 -0
- package/dist/resources.d.ts +15 -0
- package/dist/resources.js +58 -0
- package/dist/results.d.ts +42 -0
- package/dist/results.js +121 -0
- package/dist/scrub.d.ts +18 -0
- package/dist/scrub.js +49 -0
- package/dist/server-instructions.d.ts +8 -0
- package/dist/server-instructions.js +39 -0
- package/dist/server.d.ts +31 -0
- package/dist/server.js +59 -0
- package/dist/tools/common.d.ts +9 -0
- package/dist/tools/common.js +225 -0
- package/dist/tools/index.d.ts +15 -0
- package/dist/tools/index.js +6 -0
- package/dist/tools/operate.d.ts +15 -0
- package/dist/tools/operate.js +24 -0
- package/dist/tools/partner.d.ts +2 -0
- package/dist/tools/partner.js +176 -0
- package/dist/tools/types.d.ts +39 -0
- package/dist/tools/types.js +3 -0
- package/dist/version.d.ts +1 -0
- package/dist/version.js +15 -0
- package/package.json +75 -4
- package/server.json +56 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
import { InvalidGrantError, InvalidTokenError, InvalidRequestError, ServerError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
2
|
+
import { deriveKeys, open, seal, sign, verify } from './crypto.js';
|
|
3
|
+
import { loginPageHeaders, renderLoginPage } from './login-page.js';
|
|
4
|
+
import { classifyRedirect, parseExtraRedirects, redirectIsRegistered } from './redirect-trust.js';
|
|
5
|
+
import { logger } from '../logger.js';
|
|
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.';
|
|
9
|
+
export const CODE_TTL_S = 60;
|
|
10
|
+
export const ACCESS_TTL_S = 3_600;
|
|
11
|
+
export const REFRESH_TTL_S = 30 * 24 * 3_600;
|
|
12
|
+
export const CHAIN_MAX_S = 90 * 24 * 3_600;
|
|
13
|
+
const MAX_REDIRECT_URIS = 10;
|
|
14
|
+
const MAX_REDIRECT_URI_LENGTH = 512;
|
|
15
|
+
const nowS = () => Math.floor(Date.now() / 1000);
|
|
16
|
+
/** DCR sin base de datos: el client_id lleva la metadata firmada. Todos los clientes son publicos (PKCE, sin secreto). */
|
|
17
|
+
export class StatelessClientsStore {
|
|
18
|
+
keys;
|
|
19
|
+
constructor(keys) {
|
|
20
|
+
this.keys = keys;
|
|
21
|
+
}
|
|
22
|
+
getClient(clientId) {
|
|
23
|
+
const meta = verify(clientId, this.keys);
|
|
24
|
+
if (!meta || !Array.isArray(meta.redirect_uris))
|
|
25
|
+
return undefined;
|
|
26
|
+
return {
|
|
27
|
+
client_id: clientId,
|
|
28
|
+
client_id_issued_at: meta.iat,
|
|
29
|
+
redirect_uris: meta.redirect_uris,
|
|
30
|
+
client_name: meta.client_name,
|
|
31
|
+
grant_types: meta.grant_types,
|
|
32
|
+
response_types: meta.response_types,
|
|
33
|
+
token_endpoint_auth_method: 'none',
|
|
34
|
+
scope: meta.scope,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
registerClient(client) {
|
|
38
|
+
const uris = (client.redirect_uris ?? []).map(String);
|
|
39
|
+
if (uris.length === 0 || uris.length > MAX_REDIRECT_URIS || uris.some((u) => u.length > MAX_REDIRECT_URI_LENGTH)) {
|
|
40
|
+
throw new InvalidRequestError(`redirect_uris: entre 1 y ${MAX_REDIRECT_URIS} URLs de hasta ${MAX_REDIRECT_URI_LENGTH} caracteres.`);
|
|
41
|
+
}
|
|
42
|
+
const meta = {
|
|
43
|
+
redirect_uris: uris,
|
|
44
|
+
grant_types: ['authorization_code', 'refresh_token'],
|
|
45
|
+
response_types: ['code'],
|
|
46
|
+
token_endpoint_auth_method: 'none',
|
|
47
|
+
iat: nowS(),
|
|
48
|
+
};
|
|
49
|
+
if (client.client_name)
|
|
50
|
+
meta.client_name = String(client.client_name).slice(0, 100);
|
|
51
|
+
if (client.scope)
|
|
52
|
+
meta.scope = String(client.scope).slice(0, 100);
|
|
53
|
+
const clientId = sign(meta, this.keys);
|
|
54
|
+
return { ...this.getClient(clientId) };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
export class PharmadateOAuthProvider {
|
|
58
|
+
clientsStore;
|
|
59
|
+
keys;
|
|
60
|
+
validateKey;
|
|
61
|
+
resourceUrl;
|
|
62
|
+
extraRedirects;
|
|
63
|
+
constructor(options) {
|
|
64
|
+
this.extraRedirects = parseExtraRedirects(options.trustedRedirects);
|
|
65
|
+
this.keys = deriveKeys(options.secret);
|
|
66
|
+
this.clientsStore = new StatelessClientsStore(this.keys);
|
|
67
|
+
this.validateKey = options.validateKey;
|
|
68
|
+
this.resourceUrl = options.resourceUrl;
|
|
69
|
+
}
|
|
70
|
+
/** GET /authorize: muestra la pagina donde se pega la API key (con el destino y, si no es de confianza, el aviso). */
|
|
71
|
+
async authorize(client, params, res) {
|
|
72
|
+
const trust = classifyRedirect(params.redirectUri, this.extraRedirects);
|
|
73
|
+
res.set(loginPageHeaders());
|
|
74
|
+
res.send(renderLoginPage({
|
|
75
|
+
redirectHost: trust.display,
|
|
76
|
+
trusted: trust.trusted,
|
|
77
|
+
loopback: trust.loopback === true,
|
|
78
|
+
clientId: client.client_id,
|
|
79
|
+
redirectUri: params.redirectUri,
|
|
80
|
+
codeChallenge: params.codeChallenge,
|
|
81
|
+
scope: params.scopes?.join(' '),
|
|
82
|
+
state: params.state,
|
|
83
|
+
resource: params.resource?.toString(),
|
|
84
|
+
clientName: client.client_name,
|
|
85
|
+
}));
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* POST /oauth/login: valida la API key y, si es buena, redirige al cliente con un code.
|
|
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.
|
|
92
|
+
*/
|
|
93
|
+
async handleLoginPost(form, res) {
|
|
94
|
+
const client = typeof form.client_id === 'string' ? this.clientsStore.getClient(form.client_id) : undefined;
|
|
95
|
+
if (!client || typeof form.redirect_uri !== 'string' || !redirectIsRegistered(form.redirect_uri, client.redirect_uris.map(String))) {
|
|
96
|
+
res.status(400).type('text/plain').send('Cliente o redirect_uri no validos.');
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const challenge = form.code_challenge ?? '';
|
|
100
|
+
if (!/^[A-Za-z0-9_-]{43,128}$/.test(challenge)) {
|
|
101
|
+
res.status(400).type('text/plain').send('Falta el code_challenge PKCE (S256).');
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
const trust = classifyRedirect(form.redirect_uri, this.extraRedirects);
|
|
105
|
+
const volver = (errorMessage) => {
|
|
106
|
+
res.status(200).set(loginPageHeaders());
|
|
107
|
+
res.send(renderLoginPage({
|
|
108
|
+
redirectHost: trust.display,
|
|
109
|
+
trusted: trust.trusted,
|
|
110
|
+
loopback: trust.loopback === true,
|
|
111
|
+
clientId: client.client_id,
|
|
112
|
+
redirectUri: form.redirect_uri,
|
|
113
|
+
codeChallenge: challenge,
|
|
114
|
+
scope: form.scope,
|
|
115
|
+
state: form.state,
|
|
116
|
+
resource: form.resource,
|
|
117
|
+
clientName: client.client_name,
|
|
118
|
+
errorMessage,
|
|
119
|
+
}));
|
|
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
|
+
}
|
|
126
|
+
const apiKey = typeof form.api_key === 'string' ? form.api_key.trim() : '';
|
|
127
|
+
if (!apiKey || apiKey.length > 200 || /\s/.test(apiKey)) {
|
|
128
|
+
volver('Pega tu API key de Pharmadate.');
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const check = await this.validateKey(apiKey);
|
|
132
|
+
if (!check.ok) {
|
|
133
|
+
volver(check.reason === 'invalid' ? 'La API key no es valida, esta revocada o es de un tipo no admitido. Revisala y vuelve a intentarlo.' : 'No se ha podido comprobar la API key ahora mismo. Vuelve a intentarlo en un momento.');
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
const scopes = (form.scope ?? '').split(' ').filter(Boolean);
|
|
137
|
+
const payload = {
|
|
138
|
+
k: apiKey,
|
|
139
|
+
cid: client.client_id,
|
|
140
|
+
ru: form.redirect_uri,
|
|
141
|
+
cc: challenge,
|
|
142
|
+
sc: scopes.length ? scopes : [SCOPE],
|
|
143
|
+
exp: nowS() + CODE_TTL_S,
|
|
144
|
+
...(form.resource ? { res: form.resource } : {}),
|
|
145
|
+
};
|
|
146
|
+
const code = seal('code', payload, this.keys);
|
|
147
|
+
const destino = new URL(form.redirect_uri);
|
|
148
|
+
destino.searchParams.set('code', code);
|
|
149
|
+
if (form.state)
|
|
150
|
+
destino.searchParams.set('state', form.state);
|
|
151
|
+
logger.info({ client: client.client_name ?? 'unknown', redirect_host: trust.display, trusted: trust.trusted }, 'oauth login ok');
|
|
152
|
+
res.set('Cache-Control', 'no-store').redirect(302, destino.toString());
|
|
153
|
+
}
|
|
154
|
+
async challengeForAuthorizationCode(client, authorizationCode) {
|
|
155
|
+
const c = open('code', authorizationCode, this.keys);
|
|
156
|
+
if (!c || c.exp < nowS() || c.cid !== client.client_id)
|
|
157
|
+
throw new InvalidGrantError('Codigo de autorizacion invalido o caducado.');
|
|
158
|
+
return c.cc;
|
|
159
|
+
}
|
|
160
|
+
async exchangeAuthorizationCode(client, authorizationCode, _codeVerifier, redirectUri, resource) {
|
|
161
|
+
const c = open('code', authorizationCode, this.keys);
|
|
162
|
+
if (!c || c.exp < nowS())
|
|
163
|
+
throw new InvalidGrantError('Codigo de autorizacion invalido o caducado.');
|
|
164
|
+
if (c.cid !== client.client_id)
|
|
165
|
+
throw new InvalidGrantError('El codigo no se emitio para este cliente.');
|
|
166
|
+
if (redirectUri && redirectUri !== c.ru)
|
|
167
|
+
throw new InvalidGrantError('redirect_uri no coincide con el de la autorizacion.');
|
|
168
|
+
return this.issue(c.k, client.client_id, c.sc, resource?.toString() ?? c.res, nowS());
|
|
169
|
+
}
|
|
170
|
+
async exchangeRefreshToken(client, refreshToken, scopes, resource) {
|
|
171
|
+
const r = open('refresh', refreshToken, this.keys);
|
|
172
|
+
if (!r || r.exp < nowS())
|
|
173
|
+
throw new InvalidGrantError('Refresh token invalido o caducado.');
|
|
174
|
+
if (r.cid !== client.client_id)
|
|
175
|
+
throw new InvalidGrantError('El refresh token no se emitio para este cliente.');
|
|
176
|
+
if (nowS() - r.cs > CHAIN_MAX_S)
|
|
177
|
+
throw new InvalidGrantError('La sesion ha superado su vida maxima; vuelve a iniciar sesion.');
|
|
178
|
+
if (scopes?.length && scopes.some((s) => !r.sc.includes(s)))
|
|
179
|
+
throw new InvalidGrantError('Los scopes pedidos no son un subconjunto de los concedidos.');
|
|
180
|
+
// Si la key se ha rotado o revocado, no se renueva: el cliente tiene que volver a autorizar
|
|
181
|
+
const check = await this.validateKey(r.k);
|
|
182
|
+
if (!check.ok) {
|
|
183
|
+
if (check.reason === 'invalid')
|
|
184
|
+
throw new InvalidGrantError('La API key ya no es valida; vuelve a iniciar sesion.');
|
|
185
|
+
throw new ServerError('No se pudo comprobar la API key; reintenta.');
|
|
186
|
+
}
|
|
187
|
+
return this.issue(r.k, client.client_id, scopes?.length ? scopes : r.sc, resource?.toString() ?? r.res, r.cs);
|
|
188
|
+
}
|
|
189
|
+
/** Descifra un access token. Lo usa `requireBearerAuth` del SDK. */
|
|
190
|
+
async verifyAccessToken(token) {
|
|
191
|
+
const a = open('access', token, this.keys);
|
|
192
|
+
if (!a)
|
|
193
|
+
throw new InvalidTokenError('Token invalido.');
|
|
194
|
+
if (a.exp < nowS())
|
|
195
|
+
throw new InvalidTokenError(TOKEN_EXPIRED_MESSAGE);
|
|
196
|
+
const info = { token, clientId: a.cid, scopes: a.sc, expiresAt: a.exp, extra: { apiKey: a.k } };
|
|
197
|
+
if (a.res)
|
|
198
|
+
info.resource = new URL(a.res);
|
|
199
|
+
return info;
|
|
200
|
+
}
|
|
201
|
+
issue(apiKey, clientId, scopes, resource, chainStart) {
|
|
202
|
+
const now = nowS();
|
|
203
|
+
const base = { k: apiKey, cid: clientId, sc: scopes, ...(resource ? { res: resource } : {}) };
|
|
204
|
+
const access = seal('access', { ...base, exp: now + ACCESS_TTL_S }, this.keys);
|
|
205
|
+
const refresh = seal('refresh', { ...base, exp: Math.min(now + REFRESH_TTL_S, chainStart + CHAIN_MAX_S), cs: chainStart }, this.keys);
|
|
206
|
+
return { access_token: access, token_type: 'bearer', expires_in: ACCESS_TTL_S, scope: scopes.join(' '), refresh_token: refresh };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
@@ -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/client.d.ts
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export declare const TIMEOUT_MS = 15000;
|
|
2
|
+
export declare const LONG_TIMEOUT_MS = 30000;
|
|
3
|
+
/** Campos del cuerpo que dispararian el alta automatica de una farmacia. Prohibidos. */
|
|
4
|
+
export declare const FORBIDDEN_BODY_FIELDS: readonly ["pharmacy", "pharmacy_ref"];
|
|
5
|
+
export interface ClientOptions {
|
|
6
|
+
/** URL de la API terminada en `/api`, p. ej. `https://www.pharmadatepedido.es/api`. */
|
|
7
|
+
baseUrl: string;
|
|
8
|
+
apiKey: string;
|
|
9
|
+
userAgent: string;
|
|
10
|
+
/** IP real del usuario (modo remoto). Va en `X-Forwarded-For`. */
|
|
11
|
+
forwardedFor?: string;
|
|
12
|
+
fetchImpl?: typeof fetch;
|
|
13
|
+
}
|
|
14
|
+
export interface ApiRequest {
|
|
15
|
+
method: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
|
16
|
+
/** Ruta bajo `/api`, p. ej. `/v1/orders`. */
|
|
17
|
+
path: string;
|
|
18
|
+
query?: Record<string, string | number | boolean | undefined>;
|
|
19
|
+
body?: Record<string, unknown>;
|
|
20
|
+
/** CIF de la farmacia ya resuelta: va en `X-Pharmacy-Ref`. Solo con credencial de partner. */
|
|
21
|
+
pharmacyCif?: string;
|
|
22
|
+
timeoutMs?: number;
|
|
23
|
+
}
|
|
24
|
+
export interface ApiResponse {
|
|
25
|
+
status: number;
|
|
26
|
+
ok: boolean;
|
|
27
|
+
/** JSON de la respuesta; si no era JSON, `{ __text }`. */
|
|
28
|
+
body: unknown;
|
|
29
|
+
requestId: string | null;
|
|
30
|
+
retryAfter: string | null;
|
|
31
|
+
}
|
|
32
|
+
/** La API no respondio (red, DNS, timeout). El mensaje no contiene credenciales. */
|
|
33
|
+
export declare class ApiNetworkError extends Error {
|
|
34
|
+
readonly timedOut: boolean;
|
|
35
|
+
constructor(message: string, timedOut: boolean);
|
|
36
|
+
}
|
|
37
|
+
export declare class PharmadateClient {
|
|
38
|
+
readonly baseUrl: string;
|
|
39
|
+
private readonly apiKey;
|
|
40
|
+
private userAgent;
|
|
41
|
+
private readonly forwardedFor;
|
|
42
|
+
private readonly fetchImpl;
|
|
43
|
+
constructor(options: ClientOptions);
|
|
44
|
+
/** Actualiza el User-Agent (stdio: cuando el cliente MCP dice como se llama). */
|
|
45
|
+
setUserAgent(userAgent: string): void;
|
|
46
|
+
/** Hace la llamada. Un 4xx/5xx NO lanza: devuelve la respuesta para que quien llama la traduzca. */
|
|
47
|
+
request(req: ApiRequest): Promise<ApiResponse>;
|
|
48
|
+
/** Descarga un texto publico (sin credenciales): el OpenAPI que sirve el frontend. */
|
|
49
|
+
fetchPublicText(absoluteUrl: string, timeoutMs?: number): Promise<string>;
|
|
50
|
+
}
|
|
51
|
+
/** Lanza si el cuerpo trae un campo que daria de alta una farmacia por la puerta de atras. */
|
|
52
|
+
export declare function assertNoPharmacyInBody(body: Record<string, unknown>): void;
|
|
53
|
+
/** `pharmadate-mcp/<version> (remote|stdio; client=<nombre>)` */
|
|
54
|
+
export declare function buildUserAgent(version: string, transport: 'remote' | 'stdio', clientLabel: string | undefined): string;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cliente HTTP de la API v1 de Pharmadate.
|
|
3
|
+
*
|
|
4
|
+
* Es el UNICO sitio por donde el MCP habla con la plataforma: no hay acceso a base de datos y toda la autorizacion la
|
|
5
|
+
* hace la API. Responsabilidades:
|
|
6
|
+
*
|
|
7
|
+
* - Timeouts y errores de red convertidos en {@link ApiNetworkError} (con las keys ya redactadas).
|
|
8
|
+
* - `User-Agent: pharmadate-mcp/<version> (remote|stdio; client=...)` y la IP real reenviada en `X-Forwarded-For`,
|
|
9
|
+
* para que la auditoria de la API (`api_audit`) atribuya cada llamada.
|
|
10
|
+
* - **Salvaguarda de seguridad**: nunca se envia `pharmacy` ni `pharmacy_ref` en el cuerpo. Con una credencial de partner,
|
|
11
|
+
* la API da de alta AUTOMATICAMENTE la farmacia de un CIF desconocido; por eso la farmacia se resuelve antes
|
|
12
|
+
* ({@link ./pharmacy-resolver.ts}) y aqui se impide, como defensa en profundidad, que un cuerpo lleve esos campos.
|
|
13
|
+
*
|
|
14
|
+
* @module client
|
|
15
|
+
*/
|
|
16
|
+
import { scrubText } from './scrub.js';
|
|
17
|
+
export const TIMEOUT_MS = 15_000;
|
|
18
|
+
export const LONG_TIMEOUT_MS = 30_000;
|
|
19
|
+
/** Campos del cuerpo que dispararian el alta automatica de una farmacia. Prohibidos. */
|
|
20
|
+
export const FORBIDDEN_BODY_FIELDS = ['pharmacy', 'pharmacy_ref'];
|
|
21
|
+
/** La API no respondio (red, DNS, timeout). El mensaje no contiene credenciales. */
|
|
22
|
+
export class ApiNetworkError extends Error {
|
|
23
|
+
timedOut;
|
|
24
|
+
constructor(message, timedOut) {
|
|
25
|
+
super(scrubText(message));
|
|
26
|
+
this.timedOut = timedOut;
|
|
27
|
+
this.name = 'ApiNetworkError';
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
export class PharmadateClient {
|
|
31
|
+
baseUrl;
|
|
32
|
+
apiKey;
|
|
33
|
+
userAgent;
|
|
34
|
+
forwardedFor;
|
|
35
|
+
fetchImpl;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.baseUrl = options.baseUrl.replace(/\/+$/, '');
|
|
38
|
+
this.apiKey = options.apiKey;
|
|
39
|
+
this.userAgent = options.userAgent;
|
|
40
|
+
this.forwardedFor = options.forwardedFor;
|
|
41
|
+
this.fetchImpl = options.fetchImpl ?? fetch;
|
|
42
|
+
}
|
|
43
|
+
/** Actualiza el User-Agent (stdio: cuando el cliente MCP dice como se llama). */
|
|
44
|
+
setUserAgent(userAgent) {
|
|
45
|
+
this.userAgent = userAgent;
|
|
46
|
+
}
|
|
47
|
+
/** Hace la llamada. Un 4xx/5xx NO lanza: devuelve la respuesta para que quien llama la traduzca. */
|
|
48
|
+
async request(req) {
|
|
49
|
+
if (req.body)
|
|
50
|
+
assertNoPharmacyInBody(req.body);
|
|
51
|
+
const url = new URL(this.baseUrl + req.path);
|
|
52
|
+
for (const [k, v] of Object.entries(req.query ?? {})) {
|
|
53
|
+
if (v !== undefined)
|
|
54
|
+
url.searchParams.set(k, String(v));
|
|
55
|
+
}
|
|
56
|
+
const headers = {
|
|
57
|
+
Authorization: `Bearer ${this.apiKey}`,
|
|
58
|
+
Accept: 'application/json',
|
|
59
|
+
'User-Agent': this.userAgent,
|
|
60
|
+
};
|
|
61
|
+
if (this.forwardedFor)
|
|
62
|
+
headers['X-Forwarded-For'] = this.forwardedFor;
|
|
63
|
+
if (req.pharmacyCif)
|
|
64
|
+
headers['X-Pharmacy-Ref'] = req.pharmacyCif;
|
|
65
|
+
if (req.body !== undefined)
|
|
66
|
+
headers['Content-Type'] = 'application/json';
|
|
67
|
+
const timeoutMs = req.timeoutMs ?? TIMEOUT_MS;
|
|
68
|
+
let res;
|
|
69
|
+
try {
|
|
70
|
+
res = await this.fetchImpl(url, {
|
|
71
|
+
method: req.method,
|
|
72
|
+
headers,
|
|
73
|
+
body: req.body !== undefined ? JSON.stringify(req.body) : undefined,
|
|
74
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
75
|
+
redirect: 'error',
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError');
|
|
80
|
+
throw new ApiNetworkError(timedOut ? `La API de Pharmadate no respondio en ${Math.round(timeoutMs / 1000)} s.` : `No se pudo contactar con la API de Pharmadate: ${err instanceof Error ? err.message : String(err)}`, timedOut);
|
|
81
|
+
}
|
|
82
|
+
const texto = await res.text();
|
|
83
|
+
let body = null;
|
|
84
|
+
if (texto) {
|
|
85
|
+
try {
|
|
86
|
+
body = JSON.parse(texto);
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
body = { __text: texto.slice(0, 500) };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
status: res.status,
|
|
94
|
+
ok: res.ok,
|
|
95
|
+
body,
|
|
96
|
+
requestId: res.headers.get('x-request-id'),
|
|
97
|
+
retryAfter: res.headers.get('retry-after'),
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/** Descarga un texto publico (sin credenciales): el OpenAPI que sirve el frontend. */
|
|
101
|
+
async fetchPublicText(absoluteUrl, timeoutMs = TIMEOUT_MS) {
|
|
102
|
+
try {
|
|
103
|
+
const res = await this.fetchImpl(absoluteUrl, {
|
|
104
|
+
headers: { 'User-Agent': this.userAgent },
|
|
105
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
106
|
+
redirect: 'error',
|
|
107
|
+
});
|
|
108
|
+
if (!res.ok)
|
|
109
|
+
throw new ApiNetworkError(`Respuesta ${res.status} al descargar ${absoluteUrl}`, false);
|
|
110
|
+
return await res.text();
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
if (err instanceof ApiNetworkError)
|
|
114
|
+
throw err;
|
|
115
|
+
throw new ApiNetworkError(`No se pudo descargar ${absoluteUrl}: ${err instanceof Error ? err.message : String(err)}`, false);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/** Lanza si el cuerpo trae un campo que daria de alta una farmacia por la puerta de atras. */
|
|
120
|
+
export function assertNoPharmacyInBody(body) {
|
|
121
|
+
for (const campo of FORBIDDEN_BODY_FIELDS) {
|
|
122
|
+
if (Object.prototype.hasOwnProperty.call(body, campo)) {
|
|
123
|
+
throw new Error(`Bug del MCP: el cuerpo no puede llevar "${campo}" (daria de alta una farmacia automaticamente).`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
/** `pharmadate-mcp/<version> (remote|stdio; client=<nombre>)` */
|
|
128
|
+
export function buildUserAgent(version, transport, clientLabel) {
|
|
129
|
+
const limpio = (clientLabel ?? 'unknown').replace(/[^\w ./()-]/g, '').slice(0, 80).trim() || 'unknown';
|
|
130
|
+
return `pharmadate-mcp/${version} (${transport}; client=${limpio})`;
|
|
131
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuracion por variables de entorno.
|
|
3
|
+
*
|
|
4
|
+
* stdio: PHARMADATE_API_KEY, PHARMADATE_API_URL, PHARMADATE_PHARMACY_REF (opcional), PHARMADATE_MCP_MODE=READ_ONLY|STANDARD
|
|
5
|
+
* remoto: MCP_PUBLIC_URL, MCP_OAUTH_SECRET, MCP_ALLOWED_ORIGINS (opcional), MCP_REMOTE_MODE=READ_ONLY|STANDARD
|
|
6
|
+
*
|
|
7
|
+
* @module config
|
|
8
|
+
*/
|
|
9
|
+
export declare const DEFAULT_API_URL = "https://www.pharmadatepedido.es/api";
|
|
10
|
+
export type OperationMode = 'READ_ONLY' | 'STANDARD';
|
|
11
|
+
/** Interpreta un modo; lo desconocido cae en `porDefecto`. */
|
|
12
|
+
export declare function parseMode(raw: string | undefined, porDefecto: OperationMode): OperationMode;
|
|
13
|
+
/** Quita la barra final y valida que sea http(s). */
|
|
14
|
+
export declare function normalizeApiUrl(raw: string | undefined): string;
|
|
15
|
+
/** Origen (esquema + host) de la API, de donde se sirve /api-docs/openapi.yaml. */
|
|
16
|
+
export declare function apiOrigin(apiUrl: string): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuracion por variables de entorno.
|
|
3
|
+
*
|
|
4
|
+
* stdio: PHARMADATE_API_KEY, PHARMADATE_API_URL, PHARMADATE_PHARMACY_REF (opcional), PHARMADATE_MCP_MODE=READ_ONLY|STANDARD
|
|
5
|
+
* remoto: MCP_PUBLIC_URL, MCP_OAUTH_SECRET, MCP_ALLOWED_ORIGINS (opcional), MCP_REMOTE_MODE=READ_ONLY|STANDARD
|
|
6
|
+
*
|
|
7
|
+
* @module config
|
|
8
|
+
*/
|
|
9
|
+
export const DEFAULT_API_URL = 'https://www.pharmadatepedido.es/api';
|
|
10
|
+
/** Interpreta un modo; lo desconocido cae en `porDefecto`. */
|
|
11
|
+
export function parseMode(raw, porDefecto) {
|
|
12
|
+
const v = (raw ?? '').trim().toUpperCase();
|
|
13
|
+
if (v === 'READ_ONLY' || v === 'STANDARD')
|
|
14
|
+
return v;
|
|
15
|
+
return porDefecto;
|
|
16
|
+
}
|
|
17
|
+
/** Quita la barra final y valida que sea http(s). */
|
|
18
|
+
export function normalizeApiUrl(raw) {
|
|
19
|
+
const valor = (raw ?? '').trim() || DEFAULT_API_URL;
|
|
20
|
+
const url = new URL(valor);
|
|
21
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:')
|
|
22
|
+
throw new Error('PHARMADATE_API_URL tiene que ser http(s).');
|
|
23
|
+
return valor.replace(/\/+$/, '');
|
|
24
|
+
}
|
|
25
|
+
/** Origen (esquema + host) de la API, de donde se sirve /api-docs/openapi.yaml. */
|
|
26
|
+
export function apiOrigin(apiUrl) {
|
|
27
|
+
return new URL(apiUrl).origin;
|
|
28
|
+
}
|
package/dist/http.d.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transporte Streamable HTTP stateless + OAuth 2.1 sin estado, como un `express.Router`.
|
|
3
|
+
*
|
|
4
|
+
* const { router } = createMcpRouter({ apiBaseUrl: 'http://127.0.0.1:3001/api', publicUrl, oauthSecret });
|
|
5
|
+
* app.use(router); // ANTES de cors() y de express.json() de la app anfitriona (backend/server.js)
|
|
6
|
+
*
|
|
7
|
+
* Rutas (en la raiz, como exige OAuth/RFC 8414 y RFC 9728):
|
|
8
|
+
* POST /mcp JSON-RPC (Bearer: API key o access token del OAuth)
|
|
9
|
+
* GET|DELETE /mcp 405 (stateless: no hay sesiones ni SSE)
|
|
10
|
+
* GET /.well-known/oauth-authorization-server metadata del servidor de autorizacion
|
|
11
|
+
* GET /.well-known/oauth-protected-resource/mcp metadata del recurso (RFC 9728)
|
|
12
|
+
* POST /register registro dinamico de clientes (DCR)
|
|
13
|
+
* GET /authorize + POST /oauth/login pagina donde se pega la API key
|
|
14
|
+
* POST /token code / refresh -> tokens
|
|
15
|
+
*
|
|
16
|
+
* STATELESS: se crea un `McpServer` por peticion, atado a la credencial de ESA peticion (`sessionIdGenerator: undefined`,
|
|
17
|
+
* `enableJsonResponse: true`). Asi funciona con varias instancias detras del balanceador. Proteccion contra DNS
|
|
18
|
+
* rebinding: `Host` y `Origin` se validan.
|
|
19
|
+
*
|
|
20
|
+
* @module http
|
|
21
|
+
*/
|
|
22
|
+
import { Router, type Request } from 'express';
|
|
23
|
+
import { McpLimits } from './limits.js';
|
|
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;
|
|
27
|
+
/** Origenes de navegador admitidos por defecto (los clientes MCP nativos no mandan `Origin`). */
|
|
28
|
+
export declare const DEFAULT_ALLOWED_ORIGINS: string[];
|
|
29
|
+
export interface McpRouterOptions {
|
|
30
|
+
/** API por loopback, p. ej. `http://127.0.0.1:3001/api`. */
|
|
31
|
+
apiBaseUrl: string;
|
|
32
|
+
/** URL publica de la plataforma (issuer del OAuth y base de `/mcp`), p. ej. `https://www.pharmadatepedido.es`. */
|
|
33
|
+
publicUrl: string;
|
|
34
|
+
/** Secreto de 32+ caracteres para cifrar los tokens. */
|
|
35
|
+
oauthSecret: string;
|
|
36
|
+
/** Hostnames admitidos en la cabecera Host (por defecto, el de `publicUrl` y los de loopback). */
|
|
37
|
+
allowedHosts?: string[];
|
|
38
|
+
/** Origenes de navegador admitidos (se suman a los de {@link DEFAULT_ALLOWED_ORIGINS} y al de `publicUrl`). */
|
|
39
|
+
allowedOrigins?: string[];
|
|
40
|
+
/** Proxies de confianza delante de la app para leer `X-Forwarded-For` (ver {@link clientIp}). Por defecto 2 (ALB + nginx); 1 en la beta. */
|
|
41
|
+
proxyHops?: number;
|
|
42
|
+
/** Modo de operacion del transporte remoto. Por defecto STANDARD. */
|
|
43
|
+
mode?: OperationMode;
|
|
44
|
+
limits?: McpLimits;
|
|
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
|
+
*/
|
|
54
|
+
ipRateLimits?: {
|
|
55
|
+
oauth?: number;
|
|
56
|
+
login?: number;
|
|
57
|
+
unauthenticated?: number;
|
|
58
|
+
invalidBearer?: number;
|
|
59
|
+
};
|
|
60
|
+
fetchImpl?: typeof fetch;
|
|
61
|
+
}
|
|
62
|
+
export interface McpRouterHandle {
|
|
63
|
+
router: Router;
|
|
64
|
+
/** Para los tests y para parar los temporizadores. */
|
|
65
|
+
shutdown: () => void;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* IP real del cliente a partir de `X-Forwarded-For`.
|
|
69
|
+
*
|
|
70
|
+
* Cada proxy de confianza AÑADE por la derecha la direccion de quien le hablo, y lo que el cliente haya puesto a la izquierda
|
|
71
|
+
* se puede falsear. `hops` es cuantos proxies de confianza hay delante de la app: la IP real es la `hops`-esima por la derecha.
|
|
72
|
+
* - Elastic Beanstalk con ALB + nginx: 2 (nginx anade la IP del ALB y el ALB la del cliente) <- por defecto
|
|
73
|
+
* - Beta (instancia unica, solo nginx): 1
|
|
74
|
+
* Sin cabecera (ejecucion local) se usa la direccion del socket.
|
|
75
|
+
*/
|
|
76
|
+
export declare function clientIp(req: Request, hops?: number): string;
|
|
77
|
+
/** Extrae el token de `Authorization: Bearer ...` sin expresiones regulares (lineal, longitud acotada). */
|
|
78
|
+
export declare function extractBearer(header: unknown): string | undefined;
|
|
79
|
+
export declare function createMcpRouter(options: McpRouterOptions): McpRouterHandle;
|