@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
package/dist/http.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
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, TOKEN_EXPIRED_MESSAGE } from './auth/oauth-provider.js';
|
|
31
|
+
import { InvalidTokenError } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
32
|
+
import { looksLikeMcpToken } from './auth/crypto.js';
|
|
33
|
+
import { normalizeApiUrl, parseMode } from './config.js';
|
|
34
|
+
import { logger } from './logger.js';
|
|
35
|
+
import { scrubText } from './scrub.js';
|
|
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;
|
|
39
|
+
/** Origenes de navegador admitidos por defecto (los clientes MCP nativos no mandan `Origin`). */
|
|
40
|
+
export const DEFAULT_ALLOWED_ORIGINS = ['https://claude.ai', 'https://claude.com', 'https://chatgpt.com', 'https://chat.openai.com'];
|
|
41
|
+
/**
|
|
42
|
+
* IP real del cliente a partir de `X-Forwarded-For`.
|
|
43
|
+
*
|
|
44
|
+
* 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
|
|
45
|
+
* se puede falsear. `hops` es cuantos proxies de confianza hay delante de la app: la IP real es la `hops`-esima por la derecha.
|
|
46
|
+
* - Elastic Beanstalk con ALB + nginx: 2 (nginx anade la IP del ALB y el ALB la del cliente) <- por defecto
|
|
47
|
+
* - Beta (instancia unica, solo nginx): 1
|
|
48
|
+
* Sin cabecera (ejecucion local) se usa la direccion del socket.
|
|
49
|
+
*/
|
|
50
|
+
export function clientIp(req, hops = 2) {
|
|
51
|
+
const xff = req.headers['x-forwarded-for'];
|
|
52
|
+
const lista = (Array.isArray(xff) ? xff.join(',') : xff ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
53
|
+
if (!lista.length)
|
|
54
|
+
return req.socket.remoteAddress ?? 'unknown';
|
|
55
|
+
return lista[Math.max(0, lista.length - Math.max(1, hops))];
|
|
56
|
+
}
|
|
57
|
+
/** Extrae el token de `Authorization: Bearer ...` sin expresiones regulares (lineal, longitud acotada). */
|
|
58
|
+
export function extractBearer(header) {
|
|
59
|
+
if (typeof header !== 'string' || header.length > 8_192)
|
|
60
|
+
return undefined;
|
|
61
|
+
if (header.slice(0, 7).toLowerCase() !== 'bearer ')
|
|
62
|
+
return undefined;
|
|
63
|
+
const token = header.slice(7).trim();
|
|
64
|
+
if (!token || /\s/.test(token))
|
|
65
|
+
return undefined;
|
|
66
|
+
return token;
|
|
67
|
+
}
|
|
68
|
+
const jsonRpcError = (code, message) => ({ jsonrpc: '2.0', error: { code, message }, id: null });
|
|
69
|
+
export function createMcpRouter(options) {
|
|
70
|
+
const apiBaseUrl = normalizeApiUrl(options.apiBaseUrl);
|
|
71
|
+
const publicUrl = new URL(options.publicUrl);
|
|
72
|
+
const resourceUrl = new URL('/mcp', publicUrl);
|
|
73
|
+
const mode = options.mode ?? parseMode(undefined, 'STANDARD');
|
|
74
|
+
const limits = options.limits ?? new McpLimits();
|
|
75
|
+
const stopTimers = limits.startPurgeTimers();
|
|
76
|
+
const cache = new PrincipalCache();
|
|
77
|
+
const unauthLimiter = new IpRateLimiter(options.ipRateLimits?.unauthenticated ?? 30);
|
|
78
|
+
const loginLimiter = new IpRateLimiter(options.ipRateLimits?.login ?? 10);
|
|
79
|
+
const oauthLimiter = new IpRateLimiter(options.ipRateLimits?.oauth ?? 30);
|
|
80
|
+
const invalidBearerLimiter = new IpRateLimiter(options.ipRateLimits?.invalidBearer ?? DEFAULT_INVALID_BEARER_PER_MIN);
|
|
81
|
+
const fetchImpl = options.fetchImpl;
|
|
82
|
+
const hops = options.proxyHops ?? 2;
|
|
83
|
+
const allowedHosts = options.allowedHosts ?? [publicUrl.hostname, 'localhost', '127.0.0.1', '[::1]'];
|
|
84
|
+
const allowedOrigins = new Set([...DEFAULT_ALLOWED_ORIGINS, publicUrl.origin, ...(options.allowedOrigins ?? [])]);
|
|
85
|
+
const hostCheck = hostHeaderValidation(allowedHosts);
|
|
86
|
+
const newClient = (apiKey, req) => new PharmadateClient({
|
|
87
|
+
baseUrl: apiBaseUrl,
|
|
88
|
+
apiKey,
|
|
89
|
+
userAgent: buildUserAgent(VERSION, 'remote', String(req.headers['user-agent'] ?? '').split(' ')[0]),
|
|
90
|
+
forwardedFor: clientIp(req, hops),
|
|
91
|
+
...(fetchImpl ? { fetchImpl } : {}),
|
|
92
|
+
});
|
|
93
|
+
/** Comprueba una key para el login y el refresh del OAuth. Solo partners y farmacias. */
|
|
94
|
+
const validateKey = async (apiKey) => {
|
|
95
|
+
try {
|
|
96
|
+
await detectPrincipal(new PharmadateClient({ baseUrl: apiBaseUrl, apiKey, userAgent: buildUserAgent(VERSION, 'remote', 'oauth'), ...(fetchImpl ? { fetchImpl } : {}) }));
|
|
97
|
+
return { ok: true };
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err instanceof PrincipalError && err.kind === 'unauthorized')
|
|
101
|
+
return { ok: false, reason: 'invalid', message: err.message };
|
|
102
|
+
return { ok: false, reason: 'unavailable', message: err instanceof Error ? scrubText(err.message) : 'error' };
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
const provider = new PharmadateOAuthProvider({
|
|
106
|
+
secret: options.oauthSecret,
|
|
107
|
+
validateKey,
|
|
108
|
+
resourceUrl,
|
|
109
|
+
...(options.trustedRedirects ? { trustedRedirects: options.trustedRedirects } : {}),
|
|
110
|
+
});
|
|
111
|
+
const resourceMetadataUrl = getOAuthProtectedResourceMetadataUrl(resourceUrl);
|
|
112
|
+
const limitIp = (limiter) => (req, res, next) => {
|
|
113
|
+
if (limiter.allow(clientIp(req, hops)))
|
|
114
|
+
return next();
|
|
115
|
+
res.set('Retry-After', '60').status(429).json({ error: 'too_many_requests', error_description: 'Demasiadas peticiones, espera un minuto.' });
|
|
116
|
+
};
|
|
117
|
+
const unauthorized = (res, descripcion) => {
|
|
118
|
+
res
|
|
119
|
+
.set('WWW-Authenticate', `Bearer error="invalid_token", error_description="${descripcion.replace(/"/g, "'")}", resource_metadata="${resourceMetadataUrl}"`)
|
|
120
|
+
.status(401)
|
|
121
|
+
.json({ error: 'invalid_token', error_description: descripcion });
|
|
122
|
+
};
|
|
123
|
+
/** CORS manual para /mcp: solo origenes de la lista; sin `Origin` (clientes nativos) pasa. */
|
|
124
|
+
const mcpCors = (req, res, next) => {
|
|
125
|
+
const origin = req.headers.origin;
|
|
126
|
+
if (origin) {
|
|
127
|
+
if (!allowedOrigins.has(origin)) {
|
|
128
|
+
res.status(403).json(jsonRpcError(-32000, 'Origin no permitido.'));
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
res.set({ 'Access-Control-Allow-Origin': origin, Vary: 'Origin', 'Access-Control-Expose-Headers': 'WWW-Authenticate, X-Request-Id' });
|
|
132
|
+
}
|
|
133
|
+
if (req.method === 'OPTIONS') {
|
|
134
|
+
res
|
|
135
|
+
.set({
|
|
136
|
+
'Access-Control-Allow-Methods': 'POST, OPTIONS',
|
|
137
|
+
'Access-Control-Allow-Headers': String(req.headers['access-control-request-headers'] ?? 'Authorization, Content-Type, Accept, Mcp-Protocol-Version'),
|
|
138
|
+
'Access-Control-Max-Age': '600',
|
|
139
|
+
})
|
|
140
|
+
.status(204)
|
|
141
|
+
.end();
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
next();
|
|
145
|
+
};
|
|
146
|
+
const router = Router();
|
|
147
|
+
// ---------------------------------------------------------------- OAuth
|
|
148
|
+
const oauthPaths = ['/authorize', '/token', '/register', '/oauth', '/.well-known'];
|
|
149
|
+
router.use(oauthPaths, hostCheck, limitIp(oauthLimiter));
|
|
150
|
+
router.post('/oauth/login', limitIp(loginLimiter), express.urlencoded({ extended: false, limit: '10kb' }), (req, res, next) => {
|
|
151
|
+
provider.handleLoginPost((req.body ?? {}), res).catch(next);
|
|
152
|
+
});
|
|
153
|
+
router.use(mcpAuthRouter({
|
|
154
|
+
provider,
|
|
155
|
+
issuerUrl: publicUrl,
|
|
156
|
+
baseUrl: publicUrl,
|
|
157
|
+
resourceServerUrl: resourceUrl,
|
|
158
|
+
scopesSupported: [SCOPE],
|
|
159
|
+
resourceName: 'Pharmadate',
|
|
160
|
+
// El limitador del SDK se basa en req.ip (tras el ALB seria la IP del balanceador, compartida por todos): lo hacemos arriba.
|
|
161
|
+
authorizationOptions: { rateLimit: false },
|
|
162
|
+
tokenOptions: { rateLimit: false },
|
|
163
|
+
clientRegistrationOptions: { rateLimit: false },
|
|
164
|
+
}));
|
|
165
|
+
// ---------------------------------------------------------------- /mcp
|
|
166
|
+
router.options('/mcp', hostCheck, mcpCors);
|
|
167
|
+
router.all('/mcp', hostCheck, mcpCors, (req, res, next) => {
|
|
168
|
+
if (req.method === 'POST')
|
|
169
|
+
return next();
|
|
170
|
+
res.set('Allow', 'POST, OPTIONS').status(405).json(jsonRpcError(-32000, 'Metodo no permitido: este servidor es stateless, usa POST.'));
|
|
171
|
+
});
|
|
172
|
+
router.post('/mcp', express.json({ limit: '1mb' }), async (req, res) => {
|
|
173
|
+
const ip = clientIp(req, hops);
|
|
174
|
+
const bearer = extractBearer(req.headers.authorization);
|
|
175
|
+
if (!bearer) {
|
|
176
|
+
if (!unauthLimiter.allow(ip)) {
|
|
177
|
+
res.set('Retry-After', '60').status(429).json(jsonRpcError(-32000, 'Demasiadas peticiones sin autenticar.'));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
return unauthorized(res, 'Falta la credencial.');
|
|
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
|
+
};
|
|
195
|
+
let apiKey;
|
|
196
|
+
let viaOAuth = false;
|
|
197
|
+
if (looksLikeMcpToken(bearer)) {
|
|
198
|
+
try {
|
|
199
|
+
const info = await provider.verifyAccessToken(bearer);
|
|
200
|
+
if (info.resource && info.resource.toString().replace(/\/$/, '') !== resourceUrl.toString().replace(/\/$/, '')) {
|
|
201
|
+
return rechazar('Token emitido para otro recurso.');
|
|
202
|
+
}
|
|
203
|
+
apiKey = String(info.extra.apiKey);
|
|
204
|
+
viaOAuth = true;
|
|
205
|
+
}
|
|
206
|
+
catch (err) {
|
|
207
|
+
const caducado = err instanceof InvalidTokenError && err.message === TOKEN_EXPIRED_MESSAGE;
|
|
208
|
+
return rechazar('Token invalido o caducado.', !caducado);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
else if (bearer.startsWith('pk_')) {
|
|
212
|
+
apiKey = bearer;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
return rechazar('Credencial no reconocida.');
|
|
216
|
+
}
|
|
217
|
+
if (!viaOAuth && bloqueada && !cache.get(credentialId(apiKey)))
|
|
218
|
+
return demasiados();
|
|
219
|
+
const client = newClient(apiKey, req);
|
|
220
|
+
let principal;
|
|
221
|
+
try {
|
|
222
|
+
principal = await cache.resolve(apiKey, client);
|
|
223
|
+
}
|
|
224
|
+
catch (err) {
|
|
225
|
+
if (err instanceof PrincipalError && err.kind === 'unauthorized')
|
|
226
|
+
return rechazar('La API key no es valida, o fue rotada o revocada.', !viaOAuth);
|
|
227
|
+
logger.warn({ err: err instanceof Error ? err.message : String(err) }, 'no se pudo confirmar la credencial');
|
|
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.'));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
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
|
+
}
|
|
245
|
+
res.on('close', () => {
|
|
246
|
+
void transport.close();
|
|
247
|
+
void server.close();
|
|
248
|
+
});
|
|
249
|
+
try {
|
|
250
|
+
await server.connect(transport);
|
|
251
|
+
await transport.handleRequest(req, res, req.body);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
logger.error({ err }, 'error en POST /mcp');
|
|
255
|
+
if (!res.headersSent)
|
|
256
|
+
res.status(500).json(jsonRpcError(-32603, 'Error interno del servidor MCP.'));
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
// JSON mal formado, cuerpo demasiado grande, etc.
|
|
260
|
+
router.use((err, req, res, next) => {
|
|
261
|
+
if (res.headersSent)
|
|
262
|
+
return next(err);
|
|
263
|
+
const status = err.status ?? 500;
|
|
264
|
+
if (req.path === '/mcp') {
|
|
265
|
+
res.status(status >= 400 && status < 500 ? status : 500).json(jsonRpcError(status === 400 ? -32700 : -32603, status === 413 ? 'Cuerpo demasiado grande.' : 'Peticion no valida.'));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
logger.error({ err }, 'error en una ruta OAuth');
|
|
269
|
+
res.status(status >= 400 && status < 500 ? status : 500).json({ error: 'server_error' });
|
|
270
|
+
});
|
|
271
|
+
return { router, shutdown: stopTimers };
|
|
272
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
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 { secretProblem } = await import('./auth/crypto.js');
|
|
83
|
+
const port = Number(process.env['MCP_PORT'] ?? 3333);
|
|
84
|
+
const publicUrl = process.env['MCP_PUBLIC_URL'] ?? `http://localhost:${port}`;
|
|
85
|
+
const secret = process.env['MCP_OAUTH_SECRET'] ?? '';
|
|
86
|
+
const problemaSecreto = secretProblem(secret);
|
|
87
|
+
if (problemaSecreto)
|
|
88
|
+
fail(problemaSecreto);
|
|
89
|
+
const { router } = createMcpRouter({
|
|
90
|
+
apiBaseUrl: process.env['PHARMADATE_API_URL'] ?? DEFAULT_API_URL,
|
|
91
|
+
publicUrl,
|
|
92
|
+
oauthSecret: secret,
|
|
93
|
+
mode: parseMode(process.env['MCP_REMOTE_MODE'], 'STANDARD'),
|
|
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),
|
|
96
|
+
});
|
|
97
|
+
const app = express();
|
|
98
|
+
app.disable('x-powered-by');
|
|
99
|
+
app.use(router);
|
|
100
|
+
app.listen(port, () => logger.info({ port, publicUrl }, 'pharmadate-mcp (http) escuchando'));
|
|
101
|
+
}
|
|
102
|
+
const args = process.argv.slice(2);
|
|
103
|
+
if (args.includes('--version') || args.includes('-v')) {
|
|
104
|
+
process.stdout.write(`${VERSION}\n`);
|
|
105
|
+
}
|
|
106
|
+
else if (args.includes('--help') || args.includes('-h')) {
|
|
107
|
+
process.stdout.write(AYUDA);
|
|
108
|
+
}
|
|
109
|
+
else if (args.some((a) => a === '--transport=http' || a === '--http')) {
|
|
110
|
+
runHttp().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
runStdio().catch((err) => fail(err instanceof Error ? err.message : String(err)));
|
|
114
|
+
}
|
package/dist/limits.d.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limites propios del MCP, por credencial (sha256 de la key). Se suman a los de la API (120 peticiones/min).
|
|
3
|
+
*
|
|
4
|
+
* - 30 escrituras por hora (`createOrder`, `createPharmacy`, `updatePharmacy`, `deactivatePharmacy`,
|
|
5
|
+
* `reactivatePharmacy`, `rotatePharmacyApiKey`)
|
|
6
|
+
* - 10 `createOrder` por hora
|
|
7
|
+
* - 500 lineas como maximo por quote/pedido (lo comprueba el esquema de la herramienta)
|
|
8
|
+
*
|
|
9
|
+
* Los contadores viven en memoria del proceso: un reinicio los pone a cero y, con varias instancias detras del
|
|
10
|
+
* balanceador, cada una tiene el suyo. Para lo que protegen (una conversacion desbocada o inyectada que lance
|
|
11
|
+
* escrituras sin freno) es suficiente. Una llamada rechazada no alarga la ventana.
|
|
12
|
+
*
|
|
13
|
+
* @module limits
|
|
14
|
+
*/
|
|
15
|
+
export declare const MAX_LINES = 500;
|
|
16
|
+
export declare const DEFAULT_WRITES_PER_HOUR = 30;
|
|
17
|
+
export declare const DEFAULT_ORDERS_PER_HOUR = 10;
|
|
18
|
+
export type RateLimitResult = {
|
|
19
|
+
allowed: true;
|
|
20
|
+
} | {
|
|
21
|
+
allowed: false;
|
|
22
|
+
retryAfterSeconds: number;
|
|
23
|
+
};
|
|
24
|
+
/** Ventana deslizante por clave. */
|
|
25
|
+
export declare class WriteRateLimiter {
|
|
26
|
+
readonly max: number;
|
|
27
|
+
private readonly windowMs;
|
|
28
|
+
private readonly now;
|
|
29
|
+
private readonly hits;
|
|
30
|
+
constructor(max: number, windowMs?: number, now?: () => number);
|
|
31
|
+
tryConsume(key: string): RateLimitResult;
|
|
32
|
+
/** Purga periodica de claves vencidas. El temporizador no mantiene vivo el proceso. */
|
|
33
|
+
startPurgeTimer(intervalMs?: number): () => void;
|
|
34
|
+
get size(): number;
|
|
35
|
+
private purge;
|
|
36
|
+
}
|
|
37
|
+
/** Los dos contadores de escritura. Una instancia por proceso (no por peticion). */
|
|
38
|
+
export declare class McpLimits {
|
|
39
|
+
readonly writes: WriteRateLimiter;
|
|
40
|
+
readonly orders: WriteRateLimiter;
|
|
41
|
+
constructor(opts?: {
|
|
42
|
+
writesPerHour?: number;
|
|
43
|
+
ordersPerHour?: number;
|
|
44
|
+
now?: () => number;
|
|
45
|
+
});
|
|
46
|
+
/**
|
|
47
|
+
* Consume una escritura (y un pedido si `isOrder`). Devuelve el mensaje de rechazo, o `null` si puede seguir.
|
|
48
|
+
*/
|
|
49
|
+
check(credentialId: string, isOrder: boolean): string | null;
|
|
50
|
+
startPurgeTimers(): () => void;
|
|
51
|
+
}
|
|
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
|
+
*/
|
|
60
|
+
export declare class IpRateLimiter {
|
|
61
|
+
readonly max: number;
|
|
62
|
+
private readonly windowMs;
|
|
63
|
+
private readonly now;
|
|
64
|
+
private readonly maxEntries;
|
|
65
|
+
private readonly hits;
|
|
66
|
+
constructor(max: number, windowMs?: number, now?: () => number, maxEntries?: number);
|
|
67
|
+
/** `true` si puede pasar, y lo cuenta. */
|
|
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;
|
|
74
|
+
}
|
package/dist/limits.js
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Limites propios del MCP, por credencial (sha256 de la key). Se suman a los de la API (120 peticiones/min).
|
|
3
|
+
*
|
|
4
|
+
* - 30 escrituras por hora (`createOrder`, `createPharmacy`, `updatePharmacy`, `deactivatePharmacy`,
|
|
5
|
+
* `reactivatePharmacy`, `rotatePharmacyApiKey`)
|
|
6
|
+
* - 10 `createOrder` por hora
|
|
7
|
+
* - 500 lineas como maximo por quote/pedido (lo comprueba el esquema de la herramienta)
|
|
8
|
+
*
|
|
9
|
+
* Los contadores viven en memoria del proceso: un reinicio los pone a cero y, con varias instancias detras del
|
|
10
|
+
* balanceador, cada una tiene el suyo. Para lo que protegen (una conversacion desbocada o inyectada que lance
|
|
11
|
+
* escrituras sin freno) es suficiente. Una llamada rechazada no alarga la ventana.
|
|
12
|
+
*
|
|
13
|
+
* @module limits
|
|
14
|
+
*/
|
|
15
|
+
export const MAX_LINES = 500;
|
|
16
|
+
export const DEFAULT_WRITES_PER_HOUR = 30;
|
|
17
|
+
export const DEFAULT_ORDERS_PER_HOUR = 10;
|
|
18
|
+
const HOUR_MS = 3_600_000;
|
|
19
|
+
const PURGE_THRESHOLD = 5_000;
|
|
20
|
+
/** Ventana deslizante por clave. */
|
|
21
|
+
export class WriteRateLimiter {
|
|
22
|
+
max;
|
|
23
|
+
windowMs;
|
|
24
|
+
now;
|
|
25
|
+
hits = new Map();
|
|
26
|
+
constructor(max, windowMs = HOUR_MS, now = Date.now) {
|
|
27
|
+
this.max = max;
|
|
28
|
+
this.windowMs = windowMs;
|
|
29
|
+
this.now = now;
|
|
30
|
+
}
|
|
31
|
+
tryConsume(key) {
|
|
32
|
+
const now = this.now();
|
|
33
|
+
if (this.hits.size > PURGE_THRESHOLD)
|
|
34
|
+
this.purge(now);
|
|
35
|
+
const recent = (this.hits.get(key) ?? []).filter((t) => now - t < this.windowMs);
|
|
36
|
+
if (recent.length >= this.max) {
|
|
37
|
+
this.hits.set(key, recent);
|
|
38
|
+
const retryAfterMs = recent[0] + this.windowMs - now;
|
|
39
|
+
return { allowed: false, retryAfterSeconds: Math.max(1, Math.ceil(retryAfterMs / 1000)) };
|
|
40
|
+
}
|
|
41
|
+
recent.push(now);
|
|
42
|
+
this.hits.set(key, recent);
|
|
43
|
+
return { allowed: true };
|
|
44
|
+
}
|
|
45
|
+
/** Purga periodica de claves vencidas. El temporizador no mantiene vivo el proceso. */
|
|
46
|
+
startPurgeTimer(intervalMs = 600_000) {
|
|
47
|
+
const timer = setInterval(() => this.purge(this.now()), intervalMs);
|
|
48
|
+
timer.unref();
|
|
49
|
+
return () => clearInterval(timer);
|
|
50
|
+
}
|
|
51
|
+
get size() {
|
|
52
|
+
return this.hits.size;
|
|
53
|
+
}
|
|
54
|
+
purge(now) {
|
|
55
|
+
for (const [k, ts] of this.hits) {
|
|
56
|
+
if (ts.every((t) => now - t >= this.windowMs))
|
|
57
|
+
this.hits.delete(k);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/** Los dos contadores de escritura. Una instancia por proceso (no por peticion). */
|
|
62
|
+
export class McpLimits {
|
|
63
|
+
writes;
|
|
64
|
+
orders;
|
|
65
|
+
constructor(opts = {}) {
|
|
66
|
+
this.writes = new WriteRateLimiter(opts.writesPerHour ?? DEFAULT_WRITES_PER_HOUR, HOUR_MS, opts.now);
|
|
67
|
+
this.orders = new WriteRateLimiter(opts.ordersPerHour ?? DEFAULT_ORDERS_PER_HOUR, HOUR_MS, opts.now);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Consume una escritura (y un pedido si `isOrder`). Devuelve el mensaje de rechazo, o `null` si puede seguir.
|
|
71
|
+
*/
|
|
72
|
+
check(credentialId, isOrder) {
|
|
73
|
+
if (isOrder) {
|
|
74
|
+
const o = this.orders.tryConsume(credentialId);
|
|
75
|
+
if (!o.allowed) {
|
|
76
|
+
return `Limite de pedidos alcanzado: como maximo ${this.orders.max} createOrder por hora y credencial. No se ha enviado nada. Reintenta en unos ${Math.ceil(o.retryAfterSeconds / 60)} minuto(s).`;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
const w = this.writes.tryConsume(credentialId);
|
|
80
|
+
if (!w.allowed) {
|
|
81
|
+
return `Limite de escrituras alcanzado: como maximo ${this.writes.max} operaciones de escritura por hora y credencial. No se ha ejecutado nada. Reintenta en unos ${Math.ceil(w.retryAfterSeconds / 60)} minuto(s).`;
|
|
82
|
+
}
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
startPurgeTimers() {
|
|
86
|
+
const a = this.writes.startPurgeTimer();
|
|
87
|
+
const b = this.orders.startPurgeTimer();
|
|
88
|
+
return () => {
|
|
89
|
+
a();
|
|
90
|
+
b();
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
}
|
|
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
|
+
*/
|
|
102
|
+
export class IpRateLimiter {
|
|
103
|
+
max;
|
|
104
|
+
windowMs;
|
|
105
|
+
now;
|
|
106
|
+
maxEntries;
|
|
107
|
+
hits = new Map();
|
|
108
|
+
constructor(max, windowMs = 60_000, now = Date.now, maxEntries = DEFAULT_IP_LIMITER_ENTRIES) {
|
|
109
|
+
this.max = max;
|
|
110
|
+
this.windowMs = windowMs;
|
|
111
|
+
this.now = now;
|
|
112
|
+
this.maxEntries = maxEntries;
|
|
113
|
+
}
|
|
114
|
+
/** `true` si puede pasar, y lo cuenta. */
|
|
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) {
|
|
131
|
+
const now = this.now();
|
|
132
|
+
if (this.hits.size >= PURGE_THRESHOLD) {
|
|
133
|
+
for (const [k, ts] of this.hits)
|
|
134
|
+
if (ts.every((t) => now - t >= this.windowMs))
|
|
135
|
+
this.hits.delete(k);
|
|
136
|
+
}
|
|
137
|
+
const recent = (this.hits.get(ip) ?? []).filter((t) => now - t < this.windowMs);
|
|
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);
|
|
141
|
+
this.hits.set(ip, recent);
|
|
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;
|
|
151
|
+
}
|
|
152
|
+
}
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger estructurado (pino). Siempre a stderr: en stdio el stdout es el canal del protocolo y no se puede ensuciar.
|
|
3
|
+
* Todo lo que pasa por aqui se redacta con {@link scrubDeep}: una key o un token no llegan nunca al log.
|
|
4
|
+
*
|
|
5
|
+
* @module logger
|
|
6
|
+
*/
|
|
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>;
|
|
10
|
+
export declare const logger: pino.Logger<never, boolean>;
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Logger estructurado (pino). Siempre a stderr: en stdio el stdout es el canal del protocolo y no se puede ensuciar.
|
|
3
|
+
* Todo lo que pasa por aqui se redacta con {@link scrubDeep}: una key o un token no llegan nunca al log.
|
|
4
|
+
*
|
|
5
|
+
* @module logger
|
|
6
|
+
*/
|
|
7
|
+
import pino from 'pino';
|
|
8
|
+
import { scrubDeep, scrubText } from './scrub.js';
|
|
9
|
+
import { VERSION } from './version.js';
|
|
10
|
+
const nivel = process.env['LOG_LEVEL'] ?? 'info';
|
|
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]',
|
|
19
|
+
},
|
|
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 }));
|