@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 +55 -30
- package/dist/http-main.js +173 -36
- package/dist/lyrra-mcp-core.js +2 -2
- package/dist/openapi-parse.js +37 -6
- package/package.json +3 -2
package/README.md
CHANGED
|
@@ -1,28 +1,39 @@
|
|
|
1
1
|
# Lyrra Studio MCP server
|
|
2
2
|
|
|
3
|
-
**
|
|
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
|
-
-
|
|
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
|
-
|
|
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`** (
|
|
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
|
-
|
|
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:**
|
|
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` |
|
|
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
|
|
88
|
-
| `LYRRA_MCP_HEADER_VALUE` | *(optional)* Same secret as shown once at key creation (
|
|
89
|
-
| `LYRRA_OPENAPI_URL` | *(optional)* OpenAPI JSON URL |
|
|
90
|
-
| `LYRRA_MCP_MAX_TOOLS` | *(optional)*
|
|
91
|
-
| `LYRRA_MCP_HTTP_PORT` | *(HTTP
|
|
92
|
-
| `LYRRA_MCP_SKIP_AUTH_VALIDATE` | *(optional)*
|
|
93
|
-
| `LYRRA_MCP_EXTRA_INBOUND_HEADERS` | *(HTTP
|
|
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 →
|
|
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
|
-
-
|
|
100
|
-
-
|
|
101
|
-
-
|
|
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 (
|
|
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`:
|
|
123
|
-
`env
|
|
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 —
|
|
4
|
-
* (
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
57
|
-
const
|
|
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:
|
|
151
|
+
sessionIdGenerator: () => randomUUID(),
|
|
60
152
|
});
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
});
|
package/dist/lyrra-mcp-core.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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) {
|
package/dist/openapi-parse.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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.
|
|
4
|
-
"description": "Lyrra Studio
|
|
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
|
},
|