@lyrra/mcp-server 1.1.7 → 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
@@ -1,28 +1,39 @@
1
1
  # Lyrra Studio MCP server
2
2
 
3
- **stdio** process (Model Context Protocol): one tool per backend **OpenAPI** operation, plus `lyrra_meta`, `lyrra_search_operations`, and `lyrra://…` resources.
3
+ **Model Context Protocol (MCP)** server for **Lyrra Studio**: your assistant (Claude Desktop, Cursor, n8n MCP Client, …) connects via **MCP**. This process exposes one **MCP tool** per backend **OpenAPI** operation, plus `lyrra_meta`, `lyrra_search_operations`, and `lyrra://…` resources.
4
+
5
+ ## MCP vs “calling the API”
6
+
7
+ | What | Role |
8
+ |------|------|
9
+ | **MCP** | How the AI client talks **to this package** (`stdio` or Streamable HTTP `/mcp`). |
10
+ | **`LYRRA_API_URL`** | Where **this MCP server** reaches your **Lyrra Studio** app over HTTP (`…/api`). It is **not** a separate integration mode: every MCP tool call is translated into REST requests to that base. |
11
+
12
+ If you integrate **without** MCP (scripts, Postman, custom backend), call Lyrra’s REST API directly. If you use **this npm package**, you use **MCP**; the env var only points the server at your Lyrra instance.
4
13
 
5
14
  ## Prerequisites
6
15
 
7
16
  - Node.js ≥ 20
8
- - Reachable Lyrra backend with generated `openapi/openapi.json` (`npm run openapi:generate` in `apps/backend`)
17
+ - A running **Lyrra Studio** backend with `openapi/openapi.json` generated (`npm run openapi:generate` in `apps/backend` of the monorepo)
9
18
 
10
19
  ## Install from npm (Claude / Cursor)
11
20
 
12
21
  Published as **`@lyrra/mcp-server`**. No local clone required:
13
22
 
14
23
  ```bash
15
- npx -y @lyrra/mcp-server
24
+ npx -y @lyrra/mcp-server lyrra-mcp
16
25
  ```
17
26
 
18
- In **Claude Desktop** (`claude_desktop_config.json`), prefer:
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
+
29
+ In **Claude Desktop** (`claude_desktop_config.json`):
19
30
 
20
31
  ```json
21
32
  {
22
33
  "mcpServers": {
23
34
  "lyrra-studio": {
24
35
  "command": "npx",
25
- "args": ["-y", "@lyrra/mcp-server"],
36
+ "args": ["-y", "@lyrra/mcp-server", "lyrra-mcp"],
26
37
  "env": {
27
38
  "LYRRA_API_URL": "https://your-domain.com/api",
28
39
  "LYRRA_CLIENT_ID": "rak_xxxxxxxx",
@@ -33,6 +44,8 @@ In **Claude Desktop** (`claude_desktop_config.json`), prefer:
33
44
  }
34
45
  ```
35
46
 
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`.
48
+
36
49
  Use **Header Auth** keys from the institution dashboard instead of client id/secret:
37
50
 
38
51
  ```json
@@ -43,17 +56,29 @@ Use **Header Auth** keys from the institution dashboard instead of client id/sec
43
56
  }
44
57
  ```
45
58
 
46
- Global install (optional): `npm install -g @lyrra/mcp-server` then run **`lyrra-mcp`** (binary name 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` |
47
71
 
48
- ## Streamable HTTP / n8n (`https://…/mcp`)
72
+ ## Streamable HTTP / n8n (MCP URL `https://…/mcp`)
49
73
 
50
- Clients that need an **HTTPS URL** (e.g. n8n **MCP Client**) use the [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) transport on path **`/mcp`**.
74
+ Some clients need an **HTTPS MCP endpoint** (not stdio). They use [Streamable HTTP](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http) on path **`/mcp`** — still **MCP**, not “the REST API” as the client protocol.
51
75
 
52
- - **Docker:** enable service **`mcp-http`** in `docker-compose.yml` and Nginx **`location /mcp`** (see `apps/frontend/nginx.default.conf`).
76
+ - **Docker:** service **`mcp-http`** in `docker-compose.yml` and Nginx **`location /mcp`** (see `apps/frontend/nginx.default.conf` in the monorepo).
53
77
  - **Auth:** n8n **Header Auth** — same header name and **full** secret as an institution **Header auth** key (e.g. `X-Lyrra-Api-Key` + `rak_…`).
54
- - **Run locally:** `LYRRA_API_URL=http://localhost:3001/api npm run start:http` → listens on **`LYRRA_MCP_HTTP_PORT`** (default **3457**), path `/mcp`.
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).
55
80
 
56
- Binaries after global install: **`lyrra-mcp`** (stdio) and **`lyrra-mcp-http`** (HTTP).
81
+ Binaries after global install: **`lyrra-mcp`** (stdio MCP) and **`lyrra-mcp-http`** (HTTP MCP).
57
82
 
58
83
  ## Develop from this monorepo
59
84
 
@@ -80,27 +105,27 @@ Run `npm pkg fix` in this folder to apply npm’s suggested `package.json` fixes
80
105
 
81
106
  | Variable | Purpose |
82
107
  |----------|---------|
83
- | `LYRRA_API_URL` | API base, e.g. `https://yourdomain/api` or `http://localhost:3001/api` |
84
- | `LYRRA_CLIENT_ID` | Key prefix (`keyPrefix`) |
108
+ | `LYRRA_API_URL` | **Lyrra Studio REST base** the MCP server uses **internally** to run tools (e.g. `https://yourdomain/api`). Required for MCP to reach your app — not a choice between “API or MCP”. |
109
+ | `LYRRA_CLIENT_ID` | Key prefix (`keyPrefix`) for **client_credentials** keys |
85
110
  | `LYRRA_CLIENT_SECRET` | Full secret `rak_…` |
86
111
  | `LYRRA_ACCESS_TOKEN` | *(optional)* Bearer JWT if not using key exchange |
87
- | `LYRRA_MCP_HEADER_NAME` | *(optional)* HTTP header name for **Header Auth** keys (e.g. `X-Lyrra-Api-Key`; alias `LYRRA_HEADER_AUTH_NAME`) |
88
- | `LYRRA_MCP_HEADER_VALUE` | *(optional)* Same secret as shown once at key creation (alias `LYRRA_MCP_HEADER_SECRET` or `LYRRA_HEADER_AUTH_VALUE`) |
89
- | `LYRRA_OPENAPI_URL` | *(optional)* OpenAPI JSON URL |
90
- | `LYRRA_MCP_MAX_TOOLS` | *(optional)* Max number of tools (integer) |
91
- | `LYRRA_MCP_HTTP_PORT` | *(HTTP mode only)* Listen port (default `3457`) |
92
- | `LYRRA_MCP_SKIP_AUTH_VALIDATE` | *(optional)* Set to `1` to skip `GET /api/auth/me` on each MCP request (insecure; dev only) |
93
- | `LYRRA_MCP_EXTRA_INBOUND_HEADERS` | *(HTTP mode)* Comma-separated extra header names to copy from the MCP request into Lyrra API calls |
112
+ | `LYRRA_MCP_HEADER_NAME` | *(optional)* HTTP header for **Header Auth** keys (e.g. `X-Lyrra-Api-Key`; alias `LYRRA_HEADER_AUTH_NAME`) |
113
+ | `LYRRA_MCP_HEADER_VALUE` | *(optional)* Same secret as shown once at key creation (aliases `LYRRA_MCP_HEADER_SECRET`, `LYRRA_HEADER_AUTH_VALUE`) |
114
+ | `LYRRA_OPENAPI_URL` | *(optional)* Full OpenAPI JSON URL if not `{origin}/api/openapi.json` |
115
+ | `LYRRA_MCP_MAX_TOOLS` | *(optional)* Cap registered tools (integer) |
116
+ | `LYRRA_MCP_HTTP_PORT` | *(HTTP MCP only)* Listen port (default `3457`) |
117
+ | `LYRRA_MCP_SKIP_AUTH_VALIDATE` | *(optional)* `1` = skip `GET /api/auth/me` on each HTTP MCP request (insecure; dev only) |
118
+ | `LYRRA_MCP_EXTRA_INBOUND_HEADERS` | *(HTTP MCP)* Extra incoming header names to forward to Lyrra when calling REST |
94
119
 
95
- Client ID + Secret → JWT exchange uses `POST /api/auth/api-key/token`. **Header Auth** keys do not use that route: send the header on each API request instead (same as n8n MCP « Header Auth »).
120
+ **Client ID + Secret** → `POST /api/auth/api-key/token` for a JWT. **Header Auth** keys skip that: the MCP server sends the header on **each REST call it makes to Lyrra** (same idea as n8n Header Auth).
96
121
 
97
- ## EduFlow block documentation
122
+ ## EduFlow block documentation (MCP tools)
98
123
 
99
- - Tool **`lyrra_eduflow_blocks_index`**: list of documented types.
100
- - One tool per type: **`lyrra_eduflow_block_<type>`** (e.g. `lyrra_eduflow_block_quiz_mcq`) — role, `data` / `settings` fields, graph, persistence.
101
- - Sheet source: `src/eduflow-block-docs.ts` (keep aligned with the React designer).
124
+ - **`lyrra_eduflow_blocks_index`**: documented block types.
125
+ - **`lyrra_eduflow_block_<type>`** (e.g. `lyrra_eduflow_block_quiz_mcq`): per-type sheet — role, `data` / `settings`, graph, persistence.
126
+ - Source: `src/eduflow-block-docs.ts` in the monorepo.
102
127
 
103
- ## Run
128
+ ## Run (local / monorepo)
104
129
 
105
130
  ```bash
106
131
  npm start
@@ -110,14 +135,14 @@ Development (no pre-build): `npm run dev`
110
135
 
111
136
  ## Automated test (no Lyrra backend)
112
137
 
113
- A minimal OpenAPI is served locally; the script checks `initialize`, `tools/list`, and a `tools/call` on a block sheet:
138
+ A minimal OpenAPI is served locally; the script checks MCP `initialize`, `tools/list`, and a `tools/call` on a block sheet:
114
139
 
115
140
  ```bash
116
141
  npm run test:mcp
117
142
  ```
118
143
 
119
- ## MCP client (local build vs npm)
144
+ ## MCP client wiring (npm vs monorepo)
120
145
 
121
146
  - **Recommended:** `command` `npx`, `args` `["-y", "@lyrra/mcp-server"]` (see above).
122
- - **Local monorepo:** `command` `node`, `args`: **absolute** path to `apps/mcp-server/dist/index.js`
123
- `env`: `LYRRA_API_URL`, `LYRRA_CLIENT_ID`, `LYRRA_CLIENT_SECRET` (or `LYRRA_ACCESS_TOKEN` / header variables).
147
+ - **Local monorepo:** `command` `node`, `args`: absolute path to `apps/mcp-server/dist/index.js` after `npm run build`.
148
+ Same `env` as npm (`LYRRA_API_URL`, credentials or header variables).
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.7",
4
- "description": "Lyrra Studio stdio MCP server OpenAPI-aligned tools for Claude Desktop, Cursor, and compatible MCP clients",
3
+ "version": "1.1.10",
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
  },