@pharmadate/mcp 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +183 -2
- package/dist/auth/crypto.d.ts +16 -0
- package/dist/auth/crypto.js +83 -0
- package/dist/auth/login-page.d.ts +23 -0
- package/dist/auth/login-page.js +67 -0
- package/dist/auth/oauth-provider.d.ts +84 -0
- package/dist/auth/oauth-provider.js +188 -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 +68 -0
- package/dist/http.js +232 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +111 -0
- package/dist/limits.d.ts +61 -0
- package/dist/limits.js +122 -0
- package/dist/logger.d.ts +8 -0
- package/dist/logger.js +30 -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 +12 -0
- package/dist/scrub.js +40 -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 +223 -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 +74 -4
- package/server.json +56 -0
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,68 @@
|
|
|
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
|
+
/** Origenes de navegador admitidos por defecto (los clientes MCP nativos no mandan `Origin`). */
|
|
26
|
+
export declare const DEFAULT_ALLOWED_ORIGINS: string[];
|
|
27
|
+
export interface McpRouterOptions {
|
|
28
|
+
/** API por loopback, p. ej. `http://127.0.0.1:3001/api`. */
|
|
29
|
+
apiBaseUrl: string;
|
|
30
|
+
/** URL publica de la plataforma (issuer del OAuth y base de `/mcp`), p. ej. `https://www.pharmadatepedido.es`. */
|
|
31
|
+
publicUrl: string;
|
|
32
|
+
/** Secreto de 32+ caracteres para cifrar los tokens. */
|
|
33
|
+
oauthSecret: string;
|
|
34
|
+
/** Hostnames admitidos en la cabecera Host (por defecto, el de `publicUrl` y los de loopback). */
|
|
35
|
+
allowedHosts?: string[];
|
|
36
|
+
/** Origenes de navegador admitidos (se suman a los de {@link DEFAULT_ALLOWED_ORIGINS} y al de `publicUrl`). */
|
|
37
|
+
allowedOrigins?: string[];
|
|
38
|
+
/** Proxies de confianza delante de la app para leer `X-Forwarded-For` (ver {@link clientIp}). Por defecto 2 (ALB + nginx); 1 en la beta. */
|
|
39
|
+
proxyHops?: number;
|
|
40
|
+
/** Modo de operacion del transporte remoto. Por defecto STANDARD. */
|
|
41
|
+
mode?: OperationMode;
|
|
42
|
+
limits?: McpLimits;
|
|
43
|
+
/** Peticiones por minuto y por IP en los endpoints sin autenticar. Por defecto 30 (OAuth y 401) y 10 (login). */
|
|
44
|
+
ipRateLimits?: {
|
|
45
|
+
oauth?: number;
|
|
46
|
+
login?: number;
|
|
47
|
+
unauthenticated?: number;
|
|
48
|
+
};
|
|
49
|
+
fetchImpl?: typeof fetch;
|
|
50
|
+
}
|
|
51
|
+
export interface McpRouterHandle {
|
|
52
|
+
router: Router;
|
|
53
|
+
/** Para los tests y para parar los temporizadores. */
|
|
54
|
+
shutdown: () => void;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* IP real del cliente a partir de `X-Forwarded-For`.
|
|
58
|
+
*
|
|
59
|
+
* 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
|
|
60
|
+
* se puede falsear. `hops` es cuantos proxies de confianza hay delante de la app: la IP real es la `hops`-esima por la derecha.
|
|
61
|
+
* - Elastic Beanstalk con ALB + nginx: 2 (nginx anade la IP del ALB y el ALB la del cliente) <- por defecto
|
|
62
|
+
* - Beta (instancia unica, solo nginx): 1
|
|
63
|
+
* Sin cabecera (ejecucion local) se usa la direccion del socket.
|
|
64
|
+
*/
|
|
65
|
+
export declare function clientIp(req: Request, hops?: number): string;
|
|
66
|
+
/** Extrae el token de `Authorization: Bearer ...` sin expresiones regulares (lineal, longitud acotada). */
|
|
67
|
+
export declare function extractBearer(header: unknown): string | undefined;
|
|
68
|
+
export declare function createMcpRouter(options: McpRouterOptions): McpRouterHandle;
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
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 express, { Router } from 'express';
|
|
23
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
24
|
+
import { mcpAuthRouter, getOAuthProtectedResourceMetadataUrl } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
25
|
+
import { hostHeaderValidation } from '@modelcontextprotocol/sdk/server/middleware/hostHeaderValidation.js';
|
|
26
|
+
import { ApiNetworkError, PharmadateClient, buildUserAgent } from './client.js';
|
|
27
|
+
import { McpLimits, IpRateLimiter } from './limits.js';
|
|
28
|
+
import { PrincipalCache, PrincipalError, credentialId, detectPrincipal } from './principal.js';
|
|
29
|
+
import { createPharmadateServer } from './server.js';
|
|
30
|
+
import { PharmadateOAuthProvider, SCOPE } from './auth/oauth-provider.js';
|
|
31
|
+
import { looksLikeMcpToken } from './auth/crypto.js';
|
|
32
|
+
import { normalizeApiUrl, parseMode } from './config.js';
|
|
33
|
+
import { logger } from './logger.js';
|
|
34
|
+
import { scrubText } from './scrub.js';
|
|
35
|
+
import { VERSION } from './version.js';
|
|
36
|
+
/** Origenes de navegador admitidos por defecto (los clientes MCP nativos no mandan `Origin`). */
|
|
37
|
+
export const DEFAULT_ALLOWED_ORIGINS = ['https://claude.ai', 'https://claude.com', 'https://chatgpt.com', 'https://chat.openai.com'];
|
|
38
|
+
/**
|
|
39
|
+
* IP real del cliente a partir de `X-Forwarded-For`.
|
|
40
|
+
*
|
|
41
|
+
* 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
|
|
42
|
+
* se puede falsear. `hops` es cuantos proxies de confianza hay delante de la app: la IP real es la `hops`-esima por la derecha.
|
|
43
|
+
* - Elastic Beanstalk con ALB + nginx: 2 (nginx anade la IP del ALB y el ALB la del cliente) <- por defecto
|
|
44
|
+
* - Beta (instancia unica, solo nginx): 1
|
|
45
|
+
* Sin cabecera (ejecucion local) se usa la direccion del socket.
|
|
46
|
+
*/
|
|
47
|
+
export function clientIp(req, hops = 2) {
|
|
48
|
+
const xff = req.headers['x-forwarded-for'];
|
|
49
|
+
const lista = (Array.isArray(xff) ? xff.join(',') : xff ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
50
|
+
if (!lista.length)
|
|
51
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
52
|
+
return lista[Math.max(0, lista.length - Math.max(1, hops))];
|
|
53
|
+
}
|
|
54
|
+
/** Extrae el token de `Authorization: Bearer ...` sin expresiones regulares (lineal, longitud acotada). */
|
|
55
|
+
export function extractBearer(header) {
|
|
56
|
+
if (typeof header !== 'string' || header.length > 8_192)
|
|
57
|
+
return undefined;
|
|
58
|
+
if (header.slice(0, 7).toLowerCase() !== 'bearer ')
|
|
59
|
+
return undefined;
|
|
60
|
+
const token = header.slice(7).trim();
|
|
61
|
+
if (!token || /\s/.test(token))
|
|
62
|
+
return undefined;
|
|
63
|
+
return token;
|
|
64
|
+
}
|
|
65
|
+
const jsonRpcError = (code, message) => ({ jsonrpc: '2.0', error: { code, message }, id: null });
|
|
66
|
+
export function createMcpRouter(options) {
|
|
67
|
+
const apiBaseUrl = normalizeApiUrl(options.apiBaseUrl);
|
|
68
|
+
const publicUrl = new URL(options.publicUrl);
|
|
69
|
+
const resourceUrl = new URL('/mcp', publicUrl);
|
|
70
|
+
const mode = options.mode ?? parseMode(undefined, 'STANDARD');
|
|
71
|
+
const limits = options.limits ?? new McpLimits();
|
|
72
|
+
const stopTimers = limits.startPurgeTimers();
|
|
73
|
+
const cache = new PrincipalCache();
|
|
74
|
+
const unauthLimiter = new IpRateLimiter(options.ipRateLimits?.unauthenticated ?? 30);
|
|
75
|
+
const loginLimiter = new IpRateLimiter(options.ipRateLimits?.login ?? 10);
|
|
76
|
+
const oauthLimiter = new IpRateLimiter(options.ipRateLimits?.oauth ?? 30);
|
|
77
|
+
const fetchImpl = options.fetchImpl;
|
|
78
|
+
const hops = options.proxyHops ?? 2;
|
|
79
|
+
const allowedHosts = options.allowedHosts ?? [publicUrl.hostname, 'localhost', '127.0.0.1', '[::1]'];
|
|
80
|
+
const allowedOrigins = new Set([...DEFAULT_ALLOWED_ORIGINS, publicUrl.origin, ...(options.allowedOrigins ?? [])]);
|
|
81
|
+
const hostCheck = hostHeaderValidation(allowedHosts);
|
|
82
|
+
const newClient = (apiKey, req) => new PharmadateClient({
|
|
83
|
+
baseUrl: apiBaseUrl,
|
|
84
|
+
apiKey,
|
|
85
|
+
userAgent: buildUserAgent(VERSION, 'remote', String(req.headers['user-agent'] ?? '').split(' ')[0]),
|
|
86
|
+
forwardedFor: clientIp(req, hops),
|
|
87
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
88
|
+
});
|
|
89
|
+
/** Comprueba una key para el login y el refresh del OAuth. Solo partners y farmacias. */
|
|
90
|
+
const validateKey = async (apiKey) => {
|
|
91
|
+
try {
|
|
92
|
+
await detectPrincipal(new PharmadateClient({ baseUrl: apiBaseUrl, apiKey, userAgent: buildUserAgent(VERSION, 'remote', 'oauth'), ...(fetchImpl ? { fetchImpl } : {}) }));
|
|
93
|
+
return { ok: true };
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
if (err instanceof PrincipalError && err.kind === 'unauthorized')
|
|
97
|
+
return { ok: false, reason: 'invalid', message: err.message };
|
|
98
|
+
return { ok: false, reason: 'unavailable', message: err instanceof Error ? scrubText(err.message) : 'error' };
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
const provider = new PharmadateOAuthProvider({ secret: options.oauthSecret, validateKey, resourceUrl });
|
|
102
|
+
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceUrl);
|
|
103
|
+
const limitIp = (limiter) => (req, res, next) => {
|
|
104
|
+
if (limiter.allow(clientIp(req, hops)))
|
|
105
|
+
return next();
|
|
106
|
+
res.set('Retry-After', '60').status(429).json({ error: 'too_many_requests', error_description: 'Demasiadas peticiones, espera un minuto.' });
|
|
107
|
+
};
|
|
108
|
+
const unauthorized = (res, descripcion) => {
|
|
109
|
+
res
|
|
110
|
+
.set('WWW-Authenticate', `Bearer error="invalid_token", error_description="${descripcion.replace(/"/g, "'")}", resource_metadata="${resourceMetadataUrl}"`)
|
|
111
|
+
.status(401)
|
|
112
|
+
.json({ error: 'invalid_token', error_description: descripcion });
|
|
113
|
+
};
|
|
114
|
+
/** CORS manual para /mcp: solo origenes de la lista; sin `Origin` (clientes nativos) pasa. */
|
|
115
|
+
const mcpCors = (req, res, next) => {
|
|
116
|
+
const origin = req.headers.origin;
|
|
117
|
+
if (origin) {
|
|
118
|
+
if (!allowedOrigins.has(origin)) {
|
|
119
|
+
res.status(403).json(jsonRpcError(-32000, 'Origin no permitido.'));
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
res.set({ 'Access-Control-Allow-Origin': origin, Vary: 'Origin', 'Access-Control-Expose-Headers': 'WWW-Authenticate, X-Request-Id' });
|
|
123
|
+
}
|
|
124
|
+
if (req.method === 'OPTIONS') {
|
|
125
|
+
res
|
|
126
|
+
.set({
|
|
127
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
128
|
+
'Access-Control-Allow-Headers': String(req.headers['access-control-request-headers'] ?? 'Authorization, Content-Type, Accept, Mcp-Protocol-Version'),
|
|
129
|
+
'Access-Control-Max-Age': '600',
|
|
130
|
+
})
|
|
131
|
+
.status(204)
|
|
132
|
+
.end();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
next();
|
|
136
|
+
};
|
|
137
|
+
const router = Router();
|
|
138
|
+
// ---------------------------------------------------------------- OAuth
|
|
139
|
+
const oauthPaths = ['/authorize', '/token', '/register', '/oauth', '/.well-known'];
|
|
140
|
+
router.use(oauthPaths, hostCheck, limitIp(oauthLimiter));
|
|
141
|
+
router.post('/oauth/login', limitIp(loginLimiter), express.urlencoded({ extended: false, limit: '10kb' }), (req, res, next) => {
|
|
142
|
+
provider.handleLoginPost((req.body ?? {}), res).catch(next);
|
|
143
|
+
});
|
|
144
|
+
router.use(mcpAuthRouter({
|
|
145
|
+
provider,
|
|
146
|
+
issuerUrl: publicUrl,
|
|
147
|
+
baseUrl: publicUrl,
|
|
148
|
+
resourceServerUrl: resourceUrl,
|
|
149
|
+
scopesSupported: [SCOPE],
|
|
150
|
+
resourceName: 'Pharmadate',
|
|
151
|
+
// El limitador del SDK se basa en req.ip (tras el ALB seria la IP del balanceador, compartida por todos): lo hacemos arriba.
|
|
152
|
+
authorizationOptions: { rateLimit: false },
|
|
153
|
+
tokenOptions: { rateLimit: false },
|
|
154
|
+
clientRegistrationOptions: { rateLimit: false },
|
|
155
|
+
}));
|
|
156
|
+
// ---------------------------------------------------------------- /mcp
|
|
157
|
+
router.options('/mcp', hostCheck, mcpCors);
|
|
158
|
+
router.all('/mcp', hostCheck, mcpCors, (req, res, next) => {
|
|
159
|
+
if (req.method === 'POST')
|
|
160
|
+
return next();
|
|
161
|
+
res.set('Allow', 'POST, OPTIONS').status(405).json(jsonRpcError(-32000, 'Metodo no permitido: este servidor es stateless, usa POST.'));
|
|
162
|
+
});
|
|
163
|
+
router.post('/mcp', express.json({ limit: '1mb' }), async (req, res) => {
|
|
164
|
+
const bearer = extractBearer(req.headers.authorization);
|
|
165
|
+
if (!bearer) {
|
|
166
|
+
if (!unauthLimiter.allow(clientIp(req, hops))) {
|
|
167
|
+
res.set('Retry-After', '60').status(429).json(jsonRpcError(-32000, 'Demasiadas peticiones sin autenticar.'));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
return unauthorized(res, 'Falta la credencial.');
|
|
171
|
+
}
|
|
172
|
+
let apiKey;
|
|
173
|
+
if (looksLikeMcpToken(bearer)) {
|
|
174
|
+
try {
|
|
175
|
+
const info = await provider.verifyAccessToken(bearer);
|
|
176
|
+
if (info.resource && info.resource.toString().replace(/\/$/, '') !== resourceUrl.toString().replace(/\/$/, '')) {
|
|
177
|
+
return unauthorized(res, 'Token emitido para otro recurso.');
|
|
178
|
+
}
|
|
179
|
+
apiKey = String(info.extra.apiKey);
|
|
180
|
+
}
|
|
181
|
+
catch {
|
|
182
|
+
return unauthorized(res, 'Token invalido o caducado.');
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
else if (bearer.startsWith('pk_')) {
|
|
186
|
+
apiKey = bearer;
|
|
187
|
+
}
|
|
188
|
+
else {
|
|
189
|
+
return unauthorized(res, 'Credencial no reconocida.');
|
|
190
|
+
}
|
|
191
|
+
const client = newClient(apiKey, req);
|
|
192
|
+
let principal;
|
|
193
|
+
try {
|
|
194
|
+
principal = await cache.resolve(apiKey, client);
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
if (err instanceof PrincipalError && err.kind === 'unauthorized')
|
|
198
|
+
return unauthorized(res, 'La API key no es valida, o fue rotada o revocada.');
|
|
199
|
+
logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'no se pudo confirmar la credencial');
|
|
200
|
+
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
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const server = createPharmadateServer({ principal, client, credentialId: credentialId(apiKey), mode, limits });
|
|
204
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
205
|
+
res.on('close', () => {
|
|
206
|
+
void transport.close();
|
|
207
|
+
void server.close();
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
await server.connect(transport);
|
|
211
|
+
await transport.handleRequest(req, res, req.body);
|
|
212
|
+
}
|
|
213
|
+
catch (err) {
|
|
214
|
+
logger.error({ err }, 'error en POST /mcp');
|
|
215
|
+
if (!res.headersSent)
|
|
216
|
+
res.status(500).json(jsonRpcError(-32603, 'Error interno del servidor MCP.'));
|
|
217
|
+
}
|
|
218
|
+
});
|
|
219
|
+
// JSON mal formado, cuerpo demasiado grande, etc.
|
|
220
|
+
router.use((err, req, res, next) => {
|
|
221
|
+
if (res.headersSent)
|
|
222
|
+
return next(err);
|
|
223
|
+
const status = err.status ?? 500;
|
|
224
|
+
if (req.path === '/mcp') {
|
|
225
|
+
res.status(status >= 400 && status < 500 ? status : 500).json(jsonRpcError(status === 400 ? -32700 : -32603, status === 413 ? 'Cuerpo demasiado grande.' : 'Peticion no valida.'));
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
logger.error({ err }, 'error en una ruta OAuth');
|
|
229
|
+
res.status(status >= 400 && status < 500 ? status : 500).json({ error: 'server_error' });
|
|
230
|
+
});
|
|
231
|
+
return { router, shutdown: stopTimers };
|
|
232
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI de pharmadate-mcp.
|
|
4
|
+
*
|
|
5
|
+
* npx -y pharmadate-mcp stdio (Claude Desktop, Claude Code, Cursor, VS Code...)
|
|
6
|
+
* npx -y pharmadate-mcp --transport=http servidor HTTP local de desarrollo (con OAuth)
|
|
7
|
+
*
|
|
8
|
+
* Variables de entorno (stdio):
|
|
9
|
+
* PHARMADATE_API_KEY obligatoria: key de partner (pk_partner_...) o de farmacia (pk_live_...)
|
|
10
|
+
* PHARMADATE_API_URL por defecto https://www.pharmadatepedido.es/api
|
|
11
|
+
* PHARMADATE_PHARMACY_REF opcional (solo partners): CIF de la farmacia con la que operar por defecto
|
|
12
|
+
* PHARMADATE_MCP_MODE READ_ONLY (por defecto) | STANDARD (permite escrituras)
|
|
13
|
+
*
|
|
14
|
+
* El stdout es el canal del protocolo: todo lo demas va a stderr.
|
|
15
|
+
*
|
|
16
|
+
* @module index
|
|
17
|
+
*/
|
|
18
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
19
|
+
import { PharmadateClient, buildUserAgent } from './client.js';
|
|
20
|
+
import { DEFAULT_API_URL, normalizeApiUrl, parseMode } from './config.js';
|
|
21
|
+
import { McpLimits } from './limits.js';
|
|
22
|
+
import { logger } from './logger.js';
|
|
23
|
+
import { PrincipalError, credentialId, detectPrincipal } from './principal.js';
|
|
24
|
+
import { createPharmadateServer } from './server.js';
|
|
25
|
+
import { scrubText } from './scrub.js';
|
|
26
|
+
import { VERSION } from './version.js';
|
|
27
|
+
const AYUDA = `pharmadate-mcp ${VERSION}
|
|
28
|
+
|
|
29
|
+
Uso:
|
|
30
|
+
pharmadate-mcp servidor stdio (lo normal)
|
|
31
|
+
pharmadate-mcp --transport=http servidor HTTP local de desarrollo (MCP_PORT, MCP_PUBLIC_URL, MCP_OAUTH_SECRET)
|
|
32
|
+
pharmadate-mcp --version
|
|
33
|
+
|
|
34
|
+
Variables de entorno (stdio):
|
|
35
|
+
PHARMADATE_API_KEY key de partner (pk_partner_...) o de farmacia (pk_live_...) [obligatoria]
|
|
36
|
+
PHARMADATE_API_URL por defecto ${DEFAULT_API_URL}
|
|
37
|
+
PHARMADATE_PHARMACY_REF CIF de la farmacia por defecto (solo partners)
|
|
38
|
+
PHARMADATE_MCP_MODE READ_ONLY (defecto) | STANDARD
|
|
39
|
+
`;
|
|
40
|
+
function fail(mensaje) {
|
|
41
|
+
process.stderr.write(`pharmadate-mcp: ${scrubText(mensaje)}\n`);
|
|
42
|
+
process.exit(1);
|
|
43
|
+
}
|
|
44
|
+
async function runStdio() {
|
|
45
|
+
const apiKey = (process.env['PHARMADATE_API_KEY'] ?? '').trim();
|
|
46
|
+
if (!apiKey)
|
|
47
|
+
fail('falta PHARMADATE_API_KEY (tu key de partner pk_partner_... o de farmacia pk_live_...).');
|
|
48
|
+
let apiUrl;
|
|
49
|
+
try {
|
|
50
|
+
apiUrl = normalizeApiUrl(process.env['PHARMADATE_API_URL']);
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
return fail(err instanceof Error ? err.message : 'PHARMADATE_API_URL no es valida.');
|
|
54
|
+
}
|
|
55
|
+
const mode = parseMode(process.env['PHARMADATE_MCP_MODE'], 'READ_ONLY');
|
|
56
|
+
const refCif = (process.env['PHARMADATE_PHARMACY_REF'] ?? '').trim();
|
|
57
|
+
const client = new PharmadateClient({ baseUrl: apiUrl, apiKey, userAgent: buildUserAgent(VERSION, 'stdio', undefined) });
|
|
58
|
+
let principal;
|
|
59
|
+
try {
|
|
60
|
+
principal = await detectPrincipal(client);
|
|
61
|
+
}
|
|
62
|
+
catch (err) {
|
|
63
|
+
if (err instanceof PrincipalError && err.kind === 'unauthorized')
|
|
64
|
+
fail('la API key no es valida, esta revocada o es de un tipo no admitido. Revisa PHARMADATE_API_KEY.');
|
|
65
|
+
return fail(`no se pudo comprobar la API key contra ${apiUrl}: ${err instanceof Error ? err.message : String(err)}`);
|
|
66
|
+
}
|
|
67
|
+
const server = createPharmadateServer({
|
|
68
|
+
principal,
|
|
69
|
+
client,
|
|
70
|
+
credentialId: credentialId(apiKey),
|
|
71
|
+
mode,
|
|
72
|
+
limits: new McpLimits(),
|
|
73
|
+
transport: 'stdio',
|
|
74
|
+
defaultPharmacy: principal.role === 'partner' && refCif ? { cif: refCif } : undefined,
|
|
75
|
+
});
|
|
76
|
+
await server.connect(new StdioServerTransport());
|
|
77
|
+
logger.info({ role: principal.role, mode, apiUrl }, 'pharmadate-mcp (stdio) listo');
|
|
78
|
+
}
|
|
79
|
+
async function runHttp() {
|
|
80
|
+
const { default: express } = await import('express');
|
|
81
|
+
const { createMcpRouter } = await import('./http.js');
|
|
82
|
+
const port = Number(process.env['MCP_PORT'] ?? 3333);
|
|
83
|
+
const publicUrl = process.env['MCP_PUBLIC_URL'] ?? `http://localhost:${port}`;
|
|
84
|
+
const secret = process.env['MCP_OAUTH_SECRET'] ?? '';
|
|
85
|
+
if (secret.length < 32)
|
|
86
|
+
fail('MCP_OAUTH_SECRET debe tener al menos 32 caracteres.');
|
|
87
|
+
const { router } = createMcpRouter({
|
|
88
|
+
apiBaseUrl: process.env['PHARMADATE_API_URL'] ?? DEFAULT_API_URL,
|
|
89
|
+
publicUrl,
|
|
90
|
+
oauthSecret: secret,
|
|
91
|
+
mode: parseMode(process.env['MCP_REMOTE_MODE'], 'STANDARD'),
|
|
92
|
+
allowedOrigins: (process.env['MCP_ALLOWED_ORIGINS'] ?? '').split(',').map((s) => s.trim()).filter(Boolean),
|
|
93
|
+
});
|
|
94
|
+
const app = express();
|
|
95
|
+
app.disable('x-powered-by');
|
|
96
|
+
app.use(router);
|
|
97
|
+
app.listen(port, () => logger.info({ port, publicUrl }, 'pharmadate-mcp (http) escuchando'));
|
|
98
|
+
}
|
|
99
|
+
const args = process.argv.slice(2);
|
|
100
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
101
|
+
process.stdout.write(`${VERSION}\n`);
|
|
102
|
+
}
|
|
103
|
+
else if (args.includes('--help') || args.includes('-h')) {
|
|
104
|
+
process.stdout.write(AYUDA);
|
|
105
|
+
}
|
|
106
|
+
else if (args.some((a) => a === '--transport=http' || a === '--http')) {
|
|
107
|
+
runHttp().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
108
|
+
}
|
|
109
|
+
else {
|
|
110
|
+
runStdio().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
111
|
+
}
|