@lyrra/mcp-server 1.1.8 → 1.1.10

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 CHANGED
@@ -21,9 +21,11 @@ If you integrate **without** MCP (scripts, Postman, custom backend), call Lyrra
21
21
  Published as **`@lyrra/mcp-server`**. No local clone required:
22
22
 
23
23
  ```bash
24
- npx -y @lyrra/mcp-server
24
+ npx -y @lyrra/mcp-server lyrra-mcp
25
25
  ```
26
26
 
27
+ (Paquet avec **plusieurs** binaires : `lyrra-mcp`, `lyrra-mcp-http`, `mcp-server` — sans nom explicite, `npx` peut répondre *could not determine executable to run*.)
28
+
27
29
  In **Claude Desktop** (`claude_desktop_config.json`):
28
30
 
29
31
  ```json
@@ -31,7 +33,7 @@ In **Claude Desktop** (`claude_desktop_config.json`):
31
33
  "mcpServers": {
32
34
  "lyrra-studio": {
33
35
  "command": "npx",
34
- "args": ["-y", "@lyrra/mcp-server"],
36
+ "args": ["-y", "@lyrra/mcp-server", "lyrra-mcp"],
35
37
  "env": {
36
38
  "LYRRA_API_URL": "https://your-domain.com/api",
37
39
  "LYRRA_CLIENT_ID": "rak_xxxxxxxx",
@@ -42,7 +44,7 @@ In **Claude Desktop** (`claude_desktop_config.json`):
42
44
  }
43
45
  ```
44
46
 
45
- `LYRRA_API_URL` must be your Lyrra **REST base** (usually `https://<host>/api`) so MCP tools can execute against the right environment.
47
+ `LYRRA_API_URL` must be your Lyrra **REST base** (usually `https://<host>/api`) so MCP tools can execute against the right environment. **Do not** use the Streamable HTTP MCP URL here (not `…/mcp/api`) — that is only for clients that speak MCP over HTTPS to path `/mcp`, not for `LYRRA_API_URL`.
46
48
 
47
49
  Use **Header Auth** keys from the institution dashboard instead of client id/secret:
48
50
 
@@ -54,7 +56,18 @@ Use **Header Auth** keys from the institution dashboard instead of client id/sec
54
56
  }
55
57
  ```
56
58
 
57
- Global install (optional): `npm install -g @lyrra/mcp-server` then run **`lyrra-mcp`** (binary on `PATH`).
59
+ Global install (optional): `npm install -g @lyrra/mcp-server` then run **`lyrra-mcp`** or **`mcp-server`** (binaries on `PATH`).
60
+
61
+ ## Troubleshooting (logs Claude / Cursor)
62
+
63
+ | Symptôme | Cause fréquente | Correctif |
64
+ |----------|-----------------|-----------|
65
+ | `Cannot find module '/dist/index.js'` | `command` = `node` et args = `dist/index.js` **sans** répertoire de travail du paquet | Utiliser `npx` + args ci-dessus, **ou** `node` avec chemin **absolu** vers `…/node_modules/@lyrra/mcp-server/dist/index.js` |
66
+ | `node: bad option: -y` | `command` = `node` au lieu de `npx` | `command` doit être **`npx`**, pas `node` |
67
+ | `could not determine executable to run` | Plusieurs `bin` dans le paquet npm | Ajouter **`lyrra-mcp`** en dernier argument : `["-y", "@lyrra/mcp-server", "lyrra-mcp"]` |
68
+ | Réponses API en HTML (`Unexpected token '<'`) | `LYRRA_API_URL` pointe vers le **frontend** ou une URL sans `/api` | Mettre `https://<hôte>/api` (base REST réelle) |
69
+ | `OpenAPI introuvable …/mcp/api/openapi.json` | `LYRRA_API_URL` = `…/mcp/api` (confusion avec l’URL MCP HTTP) | Utiliser `…/api` uniquement ; optionnel : `LYRRA_OPENAPI_URL=https://<hôte>/api/openapi.json` |
70
+ | `@lyrra/mcp-server` 404 sur npm | Scope / publication | Vérifier que le paquet est bien public sur npm sous `@lyrra/mcp-server` |
58
71
 
59
72
  ## Streamable HTTP / n8n (MCP URL `https://…/mcp`)
60
73
 
@@ -63,6 +76,7 @@ Some clients need an **HTTPS MCP endpoint** (not stdio). They use [Streamable HT
63
76
  - **Docker:** service **`mcp-http`** in `docker-compose.yml` and Nginx **`location /mcp`** (see `apps/frontend/nginx.default.conf` in the monorepo).
64
77
  - **Auth:** n8n **Header Auth** — same header name and **full** secret as an institution **Header auth** key (e.g. `X-Lyrra-Api-Key` + `rak_…`).
65
78
  - **Run locally:** `LYRRA_API_URL=http://localhost:3001/api npm run start:http` → listens on **`LYRRA_MCP_HTTP_PORT`** (default **3457**), MCP path `/mcp`.
79
+ - **n8n « Could not connect » :** the MCP SDK requires `Accept` to include **both** `application/json` and `text/event-stream` on POST. **`lyrra-mcp-http` patches missing values** so n8n can connect; ensure **`mcp-http`** + Nginx `/mcp` are deployed, and Header Auth **Value** is the **full** `rak_…` secret (same as at key creation).
66
80
 
67
81
  Binaries after global install: **`lyrra-mcp`** (stdio MCP) and **`lyrra-mcp-http`** (HTTP MCP).
68
82
 
package/dist/http-main.js CHANGED
@@ -1,25 +1,26 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * MCP Streamable HTTP — pour n8n / clients qui attendent une URL https://…/mcp
4
- * (Header Auth : X-Lyrra-Api-Key ou Authorization, aligné tableau de bord institution).
3
+ * MCP Streamable HTTP — URL publique https://lyrrastudio.com/mcp
4
+ * Sessions stateful (comme ReadAudioPDF) : initialize → mcp-session-id POST / GET SSE / DELETE.
5
+ * Auth : Header Auth (X-Lyrra-Api-Key, Authorization, …) validée via GET /api/auth/me.
5
6
  */
7
+ import { createHash, randomUUID } from 'node:crypto';
6
8
  import express from 'express';
7
9
  import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
10
  import { createLyrraMcpServer } from './lyrra-mcp-core.js';
9
11
  import { runWithInboundAuthHeaders, requestOrigin } from './auth-session.js';
10
12
  import { pickIncomingAuthHeaders } from './http-incoming-auth.js';
13
+ import { getOpenApiJsonCached } from './openapi-parse.js';
11
14
  const PORT = parseInt(process.env.LYRRA_MCP_HTTP_PORT ?? '3457', 10);
12
- let mcpSingleton = null;
13
- async function getMcp() {
14
- mcpSingleton ??= createLyrraMcpServer().then((r) => r.mcp);
15
- return mcpSingleton;
15
+ const mcpSessions = new Map();
16
+ function authFingerprint(headers) {
17
+ const keys = Object.keys(headers).sort();
18
+ const payload = keys.map((k) => `${k}:${headers[k]}`).join('\n');
19
+ return createHash('sha256').update(payload).digest('hex');
16
20
  }
17
- /** Sérialise les requêtes MCP : un seul transport à la fois sur l’instance McpServer. */
18
- let mutexChain = Promise.resolve();
19
- function enqueueMcp(fn) {
20
- const next = mutexChain.then(() => fn());
21
- mutexChain = next.then(() => undefined, () => undefined);
22
- return next;
21
+ function setMcpCors(res) {
22
+ res.setHeader('Access-Control-Allow-Origin', '*');
23
+ res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
23
24
  }
24
25
  async function validateAuth(headers) {
25
26
  const origin = requestOrigin();
@@ -29,17 +30,37 @@ async function validateAuth(headers) {
29
30
  });
30
31
  return r.ok;
31
32
  }
32
- const app = express();
33
- app.disable('x-powered-by');
34
- app.use('/mcp', express.json({ limit: '50mb' }));
35
- app.all('/mcp', async (req, res) => {
33
+ /**
34
+ * @modelcontextprotocol/sdk (Streamable HTTP) exige sur POST :
35
+ * Accept contenant à la fois `application/json` et `text/event-stream`.
36
+ */
37
+ function patchAcceptForMcpStreamable(req) {
38
+ const method = (req.method ?? 'GET').toUpperCase();
39
+ const a = String(req.headers.accept ?? '').toLowerCase();
40
+ if (method === 'POST') {
41
+ if (!a.includes('application/json') || !a.includes('text/event-stream')) {
42
+ req.headers.accept = 'application/json, text/event-stream';
43
+ }
44
+ }
45
+ else if (method === 'GET') {
46
+ if (!a.includes('text/event-stream')) {
47
+ const orig = typeof req.headers.accept === 'string' ? req.headers.accept : '';
48
+ req.headers.accept =
49
+ orig.trim() && a.includes('application/json')
50
+ ? `${orig}, text/event-stream`
51
+ : 'text/event-stream, application/json';
52
+ }
53
+ }
54
+ }
55
+ async function requireValidAuth(req, res) {
56
+ setMcpCors(res);
36
57
  const auth = pickIncomingAuthHeaders(req.headers);
37
58
  if (!auth) {
38
59
  res.status(401).json({
39
60
  success: false,
40
61
  error: 'Missing auth: use n8n Header Auth with X-Lyrra-Api-Key (institution key) or Authorization Bearer.',
41
62
  });
42
- return;
63
+ return null;
43
64
  }
44
65
  const skipValidate = process.env.LYRRA_MCP_SKIP_AUTH_VALIDATE === '1';
45
66
  if (!skipValidate) {
@@ -49,37 +70,107 @@ app.all('/mcp', async (req, res) => {
49
70
  success: false,
50
71
  error: 'Invalid Lyrra credentials (GET /api/auth/me failed). Check your Header Auth value.',
51
72
  });
52
- return;
73
+ return null;
53
74
  }
54
75
  }
76
+ return auth;
77
+ }
78
+ function sessionIdFromReq(req) {
79
+ const h = req.headers['mcp-session-id'];
80
+ if (typeof h === 'string' && h.length > 0)
81
+ return h;
82
+ if (Array.isArray(h) && h[0])
83
+ return h[0];
84
+ return undefined;
85
+ }
86
+ function getSessionOr403(req, res, auth) {
87
+ setMcpCors(res);
88
+ const sid = sessionIdFromReq(req);
89
+ if (!sid || !mcpSessions.has(sid)) {
90
+ res.status(404).json({
91
+ error: 'Session not found. Please re-initialize.',
92
+ jsonrpc: '2.0',
93
+ id: null,
94
+ });
95
+ return null;
96
+ }
97
+ const session = mcpSessions.get(sid);
98
+ if (session.authFp !== authFingerprint(auth)) {
99
+ res.status(403).json({
100
+ success: false,
101
+ error: 'Session credentials mismatch.',
102
+ });
103
+ return null;
104
+ }
105
+ return session;
106
+ }
107
+ const app = express();
108
+ app.disable('x-powered-by');
109
+ app.use('/mcp', express.json({ limit: '50mb' }));
110
+ app.options('/mcp', (_req, res) => {
111
+ res.setHeader('Access-Control-Allow-Origin', '*');
112
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
113
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Accept, Authorization, X-Lyrra-Api-Key, X-Api-Key, Mcp-Session-Id, Last-Event-ID');
114
+ res.setHeader('Access-Control-Expose-Headers', 'mcp-session-id');
115
+ res.status(204).end();
116
+ });
117
+ app.post('/mcp', async (req, res) => {
118
+ patchAcceptForMcpStreamable(req);
119
+ const auth = await requireValidAuth(req, res);
120
+ if (!auth)
121
+ return;
122
+ const sessionId = sessionIdFromReq(req);
123
+ const body = req.body;
124
+ const isInitialize = body?.method === 'initialize';
55
125
  try {
56
- await runWithInboundAuthHeaders(auth, async () => enqueueMcp(async () => {
57
- const mcp = await getMcp();
126
+ if (sessionId && mcpSessions.has(sessionId)) {
127
+ const session = mcpSessions.get(sessionId);
128
+ if (session.authFp !== authFingerprint(auth)) {
129
+ setMcpCors(res);
130
+ res.status(403).json({ success: false, error: 'Session credentials mismatch.' });
131
+ return;
132
+ }
133
+ setMcpCors(res);
134
+ await runWithInboundAuthHeaders(auth, () => session.transport.handleRequest(req, res, body));
135
+ return;
136
+ }
137
+ if (sessionId && !isInitialize) {
138
+ setMcpCors(res);
139
+ res.status(404).json({
140
+ jsonrpc: '2.0',
141
+ error: { code: -32000, message: 'Session not found. Please re-initialize.' },
142
+ id: body?.id ?? null,
143
+ });
144
+ return;
145
+ }
146
+ const fp = authFingerprint(auth);
147
+ setMcpCors(res);
148
+ await runWithInboundAuthHeaders(auth, async () => {
149
+ const { mcp: server } = await createLyrraMcpServer();
58
150
  const transport = new StreamableHTTPServerTransport({
59
- sessionIdGenerator: undefined,
151
+ sessionIdGenerator: () => randomUUID(),
60
152
  });
61
- await mcp.connect(transport);
62
- try {
63
- await transport.handleRequest(req, res, req.body);
64
- }
65
- finally {
66
- try {
67
- await transport.close();
68
- }
69
- catch {
70
- /* ignore */
71
- }
153
+ transport.onclose = () => {
154
+ const sid = transport.sessionId;
155
+ if (sid)
156
+ mcpSessions.delete(sid);
72
157
  try {
73
- await mcp.close();
158
+ void server.close();
74
159
  }
75
160
  catch {
76
161
  /* ignore */
77
162
  }
163
+ };
164
+ await server.connect(transport);
165
+ await transport.handleRequest(req, res, body);
166
+ const newSid = transport.sessionId;
167
+ if (newSid && !mcpSessions.has(newSid)) {
168
+ mcpSessions.set(newSid, { transport, server, authFp: fp });
78
169
  }
79
- }));
170
+ });
80
171
  }
81
172
  catch (e) {
82
- process.stderr.write(`[lyrra-mcp-http] ${e instanceof Error ? e.stack ?? e.message : e}\n`);
173
+ process.stderr.write(`[lyrra-mcp-http] POST ${e instanceof Error ? e.stack ?? e.message : e}\n`);
83
174
  if (!res.headersSent) {
84
175
  res.status(500).json({
85
176
  jsonrpc: '2.0',
@@ -89,11 +180,57 @@ app.all('/mcp', async (req, res) => {
89
180
  }
90
181
  }
91
182
  });
183
+ app.get('/mcp', async (req, res) => {
184
+ patchAcceptForMcpStreamable(req);
185
+ const auth = await requireValidAuth(req, res);
186
+ if (!auth)
187
+ return;
188
+ const session = getSessionOr403(req, res, auth);
189
+ if (!session)
190
+ return;
191
+ try {
192
+ await runWithInboundAuthHeaders(auth, () => session.transport.handleRequest(req, res));
193
+ }
194
+ catch (e) {
195
+ process.stderr.write(`[lyrra-mcp-http] GET ${e instanceof Error ? e.stack ?? e.message : e}\n`);
196
+ if (!res.headersSent) {
197
+ res.status(500).json({ error: 'Internal server error' });
198
+ }
199
+ }
200
+ });
201
+ app.delete('/mcp', async (req, res) => {
202
+ patchAcceptForMcpStreamable(req);
203
+ const auth = await requireValidAuth(req, res);
204
+ if (!auth)
205
+ return;
206
+ const sid = sessionIdFromReq(req);
207
+ const session = getSessionOr403(req, res, auth);
208
+ if (!session)
209
+ return;
210
+ try {
211
+ setMcpCors(res);
212
+ await runWithInboundAuthHeaders(auth, () => session.transport.handleRequest(req, res));
213
+ if (sid)
214
+ mcpSessions.delete(sid);
215
+ }
216
+ catch (e) {
217
+ process.stderr.write(`[lyrra-mcp-http] DELETE ${e instanceof Error ? e.stack ?? e.message : e}\n`);
218
+ if (!res.headersSent) {
219
+ res.status(500).json({ error: 'Internal server error' });
220
+ }
221
+ }
222
+ });
92
223
  app.get('/health', (_req, res) => {
93
224
  res.status(200).type('text/plain').send('ok');
94
225
  });
95
226
  async function main() {
96
- await getMcp();
227
+ try {
228
+ await getOpenApiJsonCached();
229
+ process.stderr.write('[lyrra-mcp-http] OpenAPI cache warmed.\n');
230
+ }
231
+ catch (e) {
232
+ process.stderr.write(`[lyrra-mcp-http] OpenAPI warm skipped (first session will load): ${e instanceof Error ? e.message : e}\n`);
233
+ }
97
234
  app.listen(PORT, '0.0.0.0', () => {
98
235
  process.stderr.write(`[lyrra-mcp-http] listening 0.0.0.0:${PORT} path /mcp health /health\n`);
99
236
  });
@@ -5,7 +5,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
5
  import { z } from 'zod';
6
6
  import { requestOrigin } from './auth-session.js';
7
7
  import { callLyrraOperation } from './lyrra-http.js';
8
- import { fetchOpenApiJson, parseOpenApiOperations } from './openapi-parse.js';
8
+ import { getOpenApiJsonCached, parseOpenApiOperations } from './openapi-parse.js';
9
9
  import { eduflowBlockDocToolCount, registerEduflowBlockDocumentationTools, } from './register-eduflow-block-tools.js';
10
10
  const MAX_DESC_CHARS = 12_000;
11
11
  const MAX_TOOLS_ENV = process.env.LYRRA_MCP_MAX_TOOLS;
@@ -49,7 +49,7 @@ function formatToolResult(result) {
49
49
  }
50
50
  export async function createLyrraMcpServer() {
51
51
  process.stderr.write('[lyrra-mcp] Loading OpenAPI…\n');
52
- const spec = await fetchOpenApiJson();
52
+ const spec = await getOpenApiJsonCached();
53
53
  let ops = parseOpenApiOperations(spec);
54
54
  const maxTools = MAX_TOOLS_ENV ? parseInt(MAX_TOOLS_ENV, 10) : NaN;
55
55
  if (!Number.isNaN(maxTools) && maxTools > 0) {
@@ -49,13 +49,44 @@ export function parseOpenApiOperations(spec) {
49
49
  out.sort((a, b) => a.path.localeCompare(b.path) || a.method.localeCompare(b.method));
50
50
  return out;
51
51
  }
52
- export async function fetchOpenApiJson() {
52
+ /** URLs à essayer pour charger le spec OpenAPI (ordre = priorité). */
53
+ function openApiCandidateUrls() {
53
54
  const override = process.env.LYRRA_OPENAPI_URL?.trim();
55
+ if (override)
56
+ return [override];
54
57
  const origin = requestOrigin();
55
- const url = override || `${origin}/api/openapi.json`;
56
- const r = await fetch(url, { headers: { Accept: 'application/json' } });
57
- if (!r.ok) {
58
- throw new Error(`OpenAPI introuvable (${r.status}) : ${url}`);
58
+ const urls = [`${origin}/api/openapi.json`];
59
+ /**
60
+ * Si `LYRRA_API_URL` vaut `https://hôte/mcp/api` (confusion avec l’URL MCP HTTP),
61
+ * `requestOrigin()` devient `https://hôte/mcp` et l’OpenAPI testée est fausse (502, etc.).
62
+ * On retente alors la base API standard.
63
+ */
64
+ const m = /^(.+)\/mcp$/i.exec(origin);
65
+ if (m?.[1]) {
66
+ urls.push(`${m[1]}/api/openapi.json`);
67
+ }
68
+ return urls;
69
+ }
70
+ export async function fetchOpenApiJson() {
71
+ const urls = openApiCandidateUrls();
72
+ let lastStatus = 0;
73
+ let lastUrl = '';
74
+ for (const url of urls) {
75
+ lastUrl = url;
76
+ const r = await fetch(url, { headers: { Accept: 'application/json' } });
77
+ if (r.ok) {
78
+ return r.json();
79
+ }
80
+ lastStatus = r.status;
59
81
  }
60
- return r.json();
82
+ throw new Error(`OpenAPI introuvable (${lastStatus}) : ${lastUrl}` +
83
+ (urls.length > 1
84
+ ? ` (essayé aussi : ${urls.slice(1).join(', ')})`
85
+ : ''));
86
+ }
87
+ let openApiJsonPromise = null;
88
+ /** Une seule récupération OpenAPI en mémoire — les sessions MCP HTTP créent chacune un McpServer mais partagent le spec. */
89
+ export function getOpenApiJsonCached() {
90
+ openApiJsonPromise ??= fetchOpenApiJson();
91
+ return openApiJsonPromise;
61
92
  }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "@lyrra/mcp-server",
3
- "version": "1.1.8",
3
+ "version": "1.1.10",
4
4
  "description": "MCP server for Lyrra Studio — Claude/Cursor/n8n connect via MCP; tools call your Lyrra REST API (LYRRA_API_URL). Not a REST client substitute.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "bin": {
8
+ "mcp-server": "./dist/index.js",
8
9
  "lyrra-mcp": "./dist/index.js",
9
10
  "lyrra-mcp-http": "./dist/http-main.js"
10
11
  },