@lyrra/mcp-server 1.1.5 → 1.1.8

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,11 +1,20 @@
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
 
@@ -15,7 +24,7 @@ Published as **`@lyrra/mcp-server`**. No local clone required:
15
24
  npx -y @lyrra/mcp-server
16
25
  ```
17
26
 
18
- In **Claude Desktop** (`claude_desktop_config.json`), prefer:
27
+ In **Claude Desktop** (`claude_desktop_config.json`):
19
28
 
20
29
  ```json
21
30
  {
@@ -33,6 +42,8 @@ In **Claude Desktop** (`claude_desktop_config.json`), prefer:
33
42
  }
34
43
  ```
35
44
 
45
+ `LYRRA_API_URL` must be your Lyrra **REST base** (usually `https://<host>/api`) so MCP tools can execute against the right environment.
46
+
36
47
  Use **Header Auth** keys from the institution dashboard instead of client id/secret:
37
48
 
38
49
  ```json
@@ -43,7 +54,17 @@ Use **Header Auth** keys from the institution dashboard instead of client id/sec
43
54
  }
44
55
  ```
45
56
 
46
- Global install (optional): `npm install -g @lyrra/mcp-server` then run **`lyrra-mcp`** (binary name on `PATH`).
57
+ Global install (optional): `npm install -g @lyrra/mcp-server` then run **`lyrra-mcp`** (binary on `PATH`).
58
+
59
+ ## Streamable HTTP / n8n (MCP URL `https://…/mcp`)
60
+
61
+ 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.
62
+
63
+ - **Docker:** service **`mcp-http`** in `docker-compose.yml` and Nginx **`location /mcp`** (see `apps/frontend/nginx.default.conf` in the monorepo).
64
+ - **Auth:** n8n **Header Auth** — same header name and **full** secret as an institution **Header auth** key (e.g. `X-Lyrra-Api-Key` + `rak_…`).
65
+ - **Run locally:** `LYRRA_API_URL=http://localhost:3001/api npm run start:http` → listens on **`LYRRA_MCP_HTTP_PORT`** (default **3457**), MCP path `/mcp`.
66
+
67
+ Binaries after global install: **`lyrra-mcp`** (stdio MCP) and **`lyrra-mcp-http`** (HTTP MCP).
47
68
 
48
69
  ## Develop from this monorepo
49
70
 
@@ -70,24 +91,27 @@ Run `npm pkg fix` in this folder to apply npm’s suggested `package.json` fixes
70
91
 
71
92
  | Variable | Purpose |
72
93
  |----------|---------|
73
- | `LYRRA_API_URL` | API base, e.g. `https://yourdomain/api` or `http://localhost:3001/api` |
74
- | `LYRRA_CLIENT_ID` | Key prefix (`keyPrefix`) |
94
+ | `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”. |
95
+ | `LYRRA_CLIENT_ID` | Key prefix (`keyPrefix`) for **client_credentials** keys |
75
96
  | `LYRRA_CLIENT_SECRET` | Full secret `rak_…` |
76
97
  | `LYRRA_ACCESS_TOKEN` | *(optional)* Bearer JWT if not using key exchange |
77
- | `LYRRA_MCP_HEADER_NAME` | *(optional)* HTTP header name for **Header Auth** keys (e.g. `X-Lyrra-Api-Key`; alias `LYRRA_HEADER_AUTH_NAME`) |
78
- | `LYRRA_MCP_HEADER_VALUE` | *(optional)* Same secret as shown once at key creation (alias `LYRRA_MCP_HEADER_SECRET` or `LYRRA_HEADER_AUTH_VALUE`) |
79
- | `LYRRA_OPENAPI_URL` | *(optional)* OpenAPI JSON URL |
80
- | `LYRRA_MCP_MAX_TOOLS` | *(optional)* Max number of tools (integer) |
98
+ | `LYRRA_MCP_HEADER_NAME` | *(optional)* HTTP header for **Header Auth** keys (e.g. `X-Lyrra-Api-Key`; alias `LYRRA_HEADER_AUTH_NAME`) |
99
+ | `LYRRA_MCP_HEADER_VALUE` | *(optional)* Same secret as shown once at key creation (aliases `LYRRA_MCP_HEADER_SECRET`, `LYRRA_HEADER_AUTH_VALUE`) |
100
+ | `LYRRA_OPENAPI_URL` | *(optional)* Full OpenAPI JSON URL if not `{origin}/api/openapi.json` |
101
+ | `LYRRA_MCP_MAX_TOOLS` | *(optional)* Cap registered tools (integer) |
102
+ | `LYRRA_MCP_HTTP_PORT` | *(HTTP MCP only)* Listen port (default `3457`) |
103
+ | `LYRRA_MCP_SKIP_AUTH_VALIDATE` | *(optional)* `1` = skip `GET /api/auth/me` on each HTTP MCP request (insecure; dev only) |
104
+ | `LYRRA_MCP_EXTRA_INBOUND_HEADERS` | *(HTTP MCP)* Extra incoming header names to forward to Lyrra when calling REST |
81
105
 
82
- 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 »).
106
+ **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).
83
107
 
84
- ## EduFlow block documentation
108
+ ## EduFlow block documentation (MCP tools)
85
109
 
86
- - Tool **`lyrra_eduflow_blocks_index`**: list of documented types.
87
- - One tool per type: **`lyrra_eduflow_block_<type>`** (e.g. `lyrra_eduflow_block_quiz_mcq`) — role, `data` / `settings` fields, graph, persistence.
88
- - Sheet source: `src/eduflow-block-docs.ts` (keep aligned with the React designer).
110
+ - **`lyrra_eduflow_blocks_index`**: documented block types.
111
+ - **`lyrra_eduflow_block_<type>`** (e.g. `lyrra_eduflow_block_quiz_mcq`): per-type sheet — role, `data` / `settings`, graph, persistence.
112
+ - Source: `src/eduflow-block-docs.ts` in the monorepo.
89
113
 
90
- ## Run
114
+ ## Run (local / monorepo)
91
115
 
92
116
  ```bash
93
117
  npm start
@@ -97,14 +121,14 @@ Development (no pre-build): `npm run dev`
97
121
 
98
122
  ## Automated test (no Lyrra backend)
99
123
 
100
- A minimal OpenAPI is served locally; the script checks `initialize`, `tools/list`, and a `tools/call` on a block sheet:
124
+ A minimal OpenAPI is served locally; the script checks MCP `initialize`, `tools/list`, and a `tools/call` on a block sheet:
101
125
 
102
126
  ```bash
103
127
  npm run test:mcp
104
128
  ```
105
129
 
106
- ## MCP client (local build vs npm)
130
+ ## MCP client wiring (npm vs monorepo)
107
131
 
108
132
  - **Recommended:** `command` `npx`, `args` `["-y", "@lyrra/mcp-server"]` (see above).
109
- - **Local monorepo:** `command` `node`, `args`: **absolute** path to `apps/mcp-server/dist/index.js`
110
- `env`: `LYRRA_API_URL`, `LYRRA_CLIENT_ID`, `LYRRA_CLIENT_SECRET` (or `LYRRA_ACCESS_TOKEN` / header variables).
133
+ - **Local monorepo:** `command` `node`, `args`: absolute path to `apps/mcp-server/dist/index.js` after `npm run build`.
134
+ Same `env` as npm (`LYRRA_API_URL`, credentials or header variables).
@@ -4,7 +4,14 @@
4
4
  * - or LYRRA_CLIENT_ID (keyPrefix) + LYRRA_CLIENT_SECRET (rak_… secret) → POST /api/auth/api-key/token
5
5
  * - or clé institution « header_auth » : LYRRA_MCP_HEADER_NAME + LYRRA_MCP_HEADER_VALUE
6
6
  * (alias : LYRRA_HEADER_AUTH_NAME / LYRRA_HEADER_AUTH_VALUE) — même en-tête que n8n « Header Auth »
7
+ * - or (HTTP MCP) en-têtes par requête via runWithInboundAuthHeaders (n8n Header Auth sur /mcp)
7
8
  */
9
+ import { AsyncLocalStorage } from 'node:async_hooks';
10
+ const inboundAuthAls = new AsyncLocalStorage();
11
+ /** Exécute une callback avec des en-têtes d’auth injectés pour les appels API (transport MCP HTTP). */
12
+ export function runWithInboundAuthHeaders(headers, fn) {
13
+ return inboundAuthAls.run(headers, fn);
14
+ }
8
15
  let cached = null;
9
16
  function apiBase() {
10
17
  const u = process.env.LYRRA_API_URL?.trim();
@@ -124,6 +131,10 @@ function headerAuthFromEnv() {
124
131
  * En-têtes à envoyer sur chaque appel API Lyrra (Bearer ou clé header_auth).
125
132
  */
126
133
  export async function getLyrraRequestAuthHeaders() {
134
+ const inbound = inboundAuthAls.getStore();
135
+ if (inbound && Object.keys(inbound).length > 0) {
136
+ return { ...inbound };
137
+ }
127
138
  const headerPair = headerAuthFromEnv();
128
139
  if (headerPair) {
129
140
  return { [headerPair.name]: headerPair.value };
@@ -0,0 +1,48 @@
1
+ function single(h) {
2
+ if (h === undefined)
3
+ return undefined;
4
+ return Array.isArray(h) ? h[0] : h;
5
+ }
6
+ const HOP_BY_HOP = new Set([
7
+ 'connection',
8
+ 'keep-alive',
9
+ 'proxy-authenticate',
10
+ 'proxy-authorization',
11
+ 'te',
12
+ 'trailers',
13
+ 'transfer-encoding',
14
+ 'upgrade',
15
+ 'host',
16
+ 'content-length',
17
+ ]);
18
+ /**
19
+ * En-têtes d’auth reçus sur le MCP HTTP à réutiliser vers l’API Lyrra.
20
+ * n8n « Header Auth » envoie en général X-Lyrra-Api-Key (ou Authorization).
21
+ */
22
+ export function pickIncomingAuthHeaders(headers) {
23
+ const out = {};
24
+ const auth = single(headers['authorization']);
25
+ if (auth)
26
+ out.Authorization = auth;
27
+ const lyrra = single(headers['x-lyrra-api-key']);
28
+ if (lyrra)
29
+ out['X-Lyrra-Api-Key'] = lyrra;
30
+ const xak = single(headers['x-api-key']);
31
+ if (xak)
32
+ out['X-Api-Key'] = xak;
33
+ const extra = process.env.LYRRA_MCP_EXTRA_INBOUND_HEADERS?.split(',') ?? [];
34
+ for (const raw of extra) {
35
+ const display = raw.trim();
36
+ if (!display)
37
+ continue;
38
+ const lower = display.toLowerCase();
39
+ if (HOP_BY_HOP.has(lower))
40
+ continue;
41
+ const val = single(headers[lower]);
42
+ if (val)
43
+ out[display] = val;
44
+ }
45
+ if (Object.keys(out).length === 0)
46
+ return null;
47
+ return out;
48
+ }
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
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).
5
+ */
6
+ import express from 'express';
7
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
8
+ import { createLyrraMcpServer } from './lyrra-mcp-core.js';
9
+ import { runWithInboundAuthHeaders, requestOrigin } from './auth-session.js';
10
+ import { pickIncomingAuthHeaders } from './http-incoming-auth.js';
11
+ 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;
16
+ }
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;
23
+ }
24
+ async function validateAuth(headers) {
25
+ const origin = requestOrigin();
26
+ const url = `${origin}/api/auth/me`;
27
+ const r = await fetch(url, {
28
+ headers: { Accept: 'application/json', ...headers },
29
+ });
30
+ return r.ok;
31
+ }
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) => {
36
+ const auth = pickIncomingAuthHeaders(req.headers);
37
+ if (!auth) {
38
+ res.status(401).json({
39
+ success: false,
40
+ error: 'Missing auth: use n8n Header Auth with X-Lyrra-Api-Key (institution key) or Authorization Bearer.',
41
+ });
42
+ return;
43
+ }
44
+ const skipValidate = process.env.LYRRA_MCP_SKIP_AUTH_VALIDATE === '1';
45
+ if (!skipValidate) {
46
+ const ok = await validateAuth(auth);
47
+ if (!ok) {
48
+ res.status(401).json({
49
+ success: false,
50
+ error: 'Invalid Lyrra credentials (GET /api/auth/me failed). Check your Header Auth value.',
51
+ });
52
+ return;
53
+ }
54
+ }
55
+ try {
56
+ await runWithInboundAuthHeaders(auth, async () => enqueueMcp(async () => {
57
+ const mcp = await getMcp();
58
+ const transport = new StreamableHTTPServerTransport({
59
+ sessionIdGenerator: undefined,
60
+ });
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
+ }
72
+ try {
73
+ await mcp.close();
74
+ }
75
+ catch {
76
+ /* ignore */
77
+ }
78
+ }
79
+ }));
80
+ }
81
+ catch (e) {
82
+ process.stderr.write(`[lyrra-mcp-http] ${e instanceof Error ? e.stack ?? e.message : e}\n`);
83
+ if (!res.headersSent) {
84
+ res.status(500).json({
85
+ jsonrpc: '2.0',
86
+ error: { code: -32603, message: 'Internal server error' },
87
+ id: null,
88
+ });
89
+ }
90
+ }
91
+ });
92
+ app.get('/health', (_req, res) => {
93
+ res.status(200).type('text/plain').send('ok');
94
+ });
95
+ async function main() {
96
+ await getMcp();
97
+ app.listen(PORT, '0.0.0.0', () => {
98
+ process.stderr.write(`[lyrra-mcp-http] listening 0.0.0.0:${PORT} path /mcp health /health\n`);
99
+ });
100
+ }
101
+ main().catch((e) => {
102
+ process.stderr.write(`[lyrra-mcp-http] Fatal: ${e instanceof Error ? e.stack ?? e.message : e}\n`);
103
+ process.exit(1);
104
+ });
package/dist/index.js CHANGED
@@ -2,176 +2,13 @@
2
2
  /**
3
3
  * Lyrra Studio MCP stdio server.
4
4
  * Run: `npm run build && npm start` in apps/mcp-server (or `npm run dev`).
5
+ * HTTP (n8n URL) : `npm run start:http` → voir http-main.ts
5
6
  */
6
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
7
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
8
- import { z } from 'zod';
9
- import { getLyrraRequestAuthHeaders, requestOrigin } from './auth-session.js';
10
- import { callLyrraOperation } from './lyrra-http.js';
11
- import { fetchOpenApiJson, parseOpenApiOperations } from './openapi-parse.js';
12
- import { eduflowBlockDocToolCount, registerEduflowBlockDocumentationTools, } from './register-eduflow-block-tools.js';
13
- const MAX_DESC_CHARS = 12_000;
14
- const MAX_TOOLS_ENV = process.env.LYRRA_MCP_MAX_TOOLS;
15
- function truncateDesc(s) {
16
- if (s.length <= MAX_DESC_CHARS)
17
- return s;
18
- return `${s.slice(0, MAX_DESC_CHARS)}\n\n[… truncated description — see OpenAPI / Swagger]`;
19
- }
20
- function zodShapeForOperation(op) {
21
- const shape = {
22
- query: z
23
- .record(z.string(), z.unknown())
24
- .optional()
25
- .describe('Query parameters (key → value). Serialize complex values as JSON in a key if needed.'),
26
- body: z
27
- .unknown()
28
- .optional()
29
- .describe('JSON body (object or array) for POST, PUT, PATCH. Ignored for GET/DELETE.'),
30
- };
31
- for (const p of op.pathParamNames) {
32
- shape[p] = z
33
- .string()
34
- .describe(`Value for path segment "${p}" in ${op.path}`);
35
- }
36
- return shape;
37
- }
38
- function formatToolResult(result) {
39
- const snippet = result.bodyText.length > 120_000 ? `${result.bodyText.slice(0, 120_000)}\n… [truncated]` : result.bodyText;
40
- const text = [
41
- `HTTP ${result.status}`,
42
- result.headers['content-type'] ? `Content-Type: ${result.headers['content-type']}` : '',
43
- '',
44
- snippet || '(empty body)',
45
- ]
46
- .filter(Boolean)
47
- .join('\n');
48
- return {
49
- content: [{ type: 'text', text }],
50
- isError: result.status >= 400,
51
- };
52
- }
8
+ import { getLyrraRequestAuthHeaders } from './auth-session.js';
9
+ import { createLyrraMcpServer } from './lyrra-mcp-core.js';
53
10
  async function main() {
54
- process.stderr.write('[lyrra-mcp] Loading OpenAPI…\n');
55
- const spec = await fetchOpenApiJson();
56
- let ops = parseOpenApiOperations(spec);
57
- const maxTools = MAX_TOOLS_ENV ? parseInt(MAX_TOOLS_ENV, 10) : NaN;
58
- if (!Number.isNaN(maxTools) && maxTools > 0) {
59
- ops = ops.slice(0, maxTools);
60
- process.stderr.write(`[lyrra-mcp] LYRRA_MCP_MAX_TOOLS=${maxTools} — ${ops.length} tools registered.\n`);
61
- }
62
- process.stderr.write(`[lyrra-mcp] ${ops.length} operations → MCP tools.\n`);
63
- const mcp = new McpServer({
64
- name: 'lyrra-studio',
65
- version: '1.0.0',
66
- title: 'Lyrra Studio',
67
- });
68
- registerEduflowBlockDocumentationTools(mcp);
69
- process.stderr.write(`[lyrra-mcp] EduFlow docs: ${eduflowBlockDocToolCount()} tools (index + per-block sheets).\n`);
70
- mcp.registerTool('lyrra_meta', {
71
- description: 'Configuration summary: API origin, registered tool count, useful environment variables (no secrets shown).',
72
- inputSchema: z.object({}),
73
- }, async () => {
74
- let tokenMode = 'CLIENT_ID+SECRET';
75
- if (process.env.LYRRA_ACCESS_TOKEN?.trim())
76
- tokenMode = 'ACCESS_TOKEN';
77
- const hn = process.env.LYRRA_MCP_HEADER_NAME?.trim() || process.env.LYRRA_HEADER_AUTH_NAME?.trim();
78
- const hv = process.env.LYRRA_MCP_HEADER_SECRET?.trim() ||
79
- process.env.LYRRA_MCP_HEADER_VALUE?.trim() ||
80
- process.env.LYRRA_HEADER_AUTH_VALUE?.trim();
81
- if (hn && hv)
82
- tokenMode = 'HEADER_AUTH';
83
- const text = [
84
- `Request origin: ${requestOrigin()}`,
85
- `OpenAPI tools registered: ${ops.length}`,
86
- `EduFlow block doc tools: ${eduflowBlockDocToolCount()} (lyrra_eduflow_blocks_index + lyrra_eduflow_block_*)`,
87
- `Token mode: ${tokenMode}`,
88
- `LYRRA_API_URL: ${process.env.LYRRA_API_URL ? 'set' : 'missing'}`,
89
- `LYRRA_OPENAPI_URL: ${process.env.LYRRA_OPENAPI_URL ? 'set' : 'default /api/openapi.json'}`,
90
- ].join('\n');
91
- return { content: [{ type: 'text', text }] };
92
- });
93
- mcp.registerTool('lyrra_search_operations', {
94
- description: 'Search operations (operationId, method, path, start of description). Useful when the tool list is long.',
95
- inputSchema: z.object({
96
- q: z.string().min(1).describe('Search text (case-insensitive)'),
97
- limit: z.number().int().min(1).max(80).optional().describe('Max results (default 30)'),
98
- }),
99
- }, async ({ q, limit }) => {
100
- const lim = limit ?? 30;
101
- const qq = q.toLowerCase();
102
- const hits = ops
103
- .filter((o) => o.operationId.toLowerCase().includes(qq) ||
104
- o.path.toLowerCase().includes(qq) ||
105
- o.method.toLowerCase().includes(qq) ||
106
- o.description.toLowerCase().includes(qq))
107
- .slice(0, lim);
108
- const text = hits.length === 0
109
- ? 'No operations found.'
110
- : hits.map((o) => `${o.method} ${o.path}\n operationId: ${o.operationId}`).join('\n\n');
111
- return { content: [{ type: 'text', text }] };
112
- });
113
- const staticGuide = `# Lyrra Studio — MCP integration
114
-
115
- - Each tool named after an OpenAPI **operationId** calls **exactly** the documented endpoint.
116
- - **EduFlow blocks**: tools \`lyrra_eduflow_blocks_index\` then \`lyrra_eduflow_block_<type>\` (e.g. \`lyrra_eduflow_block_quiz_mcq\`) — full sheet: role, \`data\`, \`settings\`, graph.
117
- - API args: path segments \`{id}\` → required properties of the same name; \`query\`; \`body\` (JSON).
118
- - Auth: \`LYRRA_CLIENT_ID\` + \`LYRRA_CLIENT_SECRET\` (\`rak_…\`), or \`LYRRA_ACCESS_TOKEN\` (JWT).
119
- `;
120
- mcp.registerResource('lyrra-mcp-guide', 'lyrra://flow-construction-guide', {
121
- description: 'How to use the Lyrra MCP server',
122
- mimeType: 'text/markdown',
123
- }, async () => ({
124
- contents: [{ uri: 'lyrra://flow-construction-guide', mimeType: 'text/markdown', text: staticGuide }],
125
- }));
126
- mcp.registerResource('lyrra-block-types', 'lyrra://block-types', {
127
- description: 'Points to MCP tools lyrra_eduflow_block_* (per-type sheet) + persistence via eduflows API',
128
- mimeType: 'text/markdown',
129
- }, async () => ({
130
- contents: [
131
- {
132
- uri: 'lyrra://block-types',
133
- mimeType: 'text/markdown',
134
- text: [
135
- '# EduFlow block types (MCP)',
136
- '',
137
- `1. Call **lyrra_eduflow_blocks_index** for documented types (${eduflowBlockDocToolCount() - 1} blocks).`,
138
- '2. Call **lyrra_eduflow_block_<type>** (e.g. `lyrra_eduflow_block_video`) for the full sheet: pedagogical role, `BlockData`, `settings`, connections.',
139
- '3. HTTP persistence: OpenAPI routes under `/api/eduflows/{id}/blocks` and flow save.',
140
- '',
141
- 'Code reference: `apps/frontend/src/types/eduflow.ts`, palette `eduflow-designer-palette-blueprint.ts`.',
142
- ].join('\n'),
143
- },
144
- ],
145
- }));
146
- for (const op of ops) {
147
- const inputSchema = z.object(zodShapeForOperation(op));
148
- const desc = truncateDesc(`${op.description}\n\n— HTTP ${op.method} \`${op.path}\``);
149
- mcp.registerTool(op.operationId, {
150
- description: desc,
151
- inputSchema,
152
- }, async (rawArgs) => {
153
- const args = rawArgs;
154
- const pathParams = {};
155
- for (const name of op.pathParamNames) {
156
- const v = args[name];
157
- pathParams[name] = typeof v === 'string' ? v : String(v ?? '');
158
- }
159
- const query = args.query;
160
- const body = args.body;
161
- try {
162
- const result = await callLyrraOperation(op.method, op.path, { pathParams, query, body });
163
- return formatToolResult(result);
164
- }
165
- catch (e) {
166
- const msg = e instanceof Error ? e.message : String(e);
167
- return {
168
- content: [{ type: 'text', text: `Error: ${msg}` }],
169
- isError: true,
170
- };
171
- }
172
- });
173
- }
174
- // Resolve credentials early (clear message if .env is incomplete)
11
+ const { mcp } = await createLyrraMcpServer();
175
12
  try {
176
13
  await getLyrraRequestAuthHeaders();
177
14
  process.stderr.write('[lyrra-mcp] API credentials OK.\n');
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Construction du McpServer Lyrra (outils OpenAPI + EduFlow) — partagé entre stdio et HTTP.
3
+ */
4
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
5
+ import { z } from 'zod';
6
+ import { requestOrigin } from './auth-session.js';
7
+ import { callLyrraOperation } from './lyrra-http.js';
8
+ import { fetchOpenApiJson, parseOpenApiOperations } from './openapi-parse.js';
9
+ import { eduflowBlockDocToolCount, registerEduflowBlockDocumentationTools, } from './register-eduflow-block-tools.js';
10
+ const MAX_DESC_CHARS = 12_000;
11
+ const MAX_TOOLS_ENV = process.env.LYRRA_MCP_MAX_TOOLS;
12
+ function truncateDesc(s) {
13
+ if (s.length <= MAX_DESC_CHARS)
14
+ return s;
15
+ return `${s.slice(0, MAX_DESC_CHARS)}\n\n[… truncated description — see OpenAPI / Swagger]`;
16
+ }
17
+ function zodShapeForOperation(op) {
18
+ const shape = {
19
+ query: z
20
+ .record(z.string(), z.unknown())
21
+ .optional()
22
+ .describe('Query parameters (key → value). Serialize complex values as JSON in a key if needed.'),
23
+ body: z
24
+ .unknown()
25
+ .optional()
26
+ .describe('JSON body (object or array) for POST, PUT, PATCH. Ignored for GET/DELETE.'),
27
+ };
28
+ for (const p of op.pathParamNames) {
29
+ shape[p] = z
30
+ .string()
31
+ .describe(`Value for path segment "${p}" in ${op.path}`);
32
+ }
33
+ return shape;
34
+ }
35
+ function formatToolResult(result) {
36
+ const snippet = result.bodyText.length > 120_000 ? `${result.bodyText.slice(0, 120_000)}\n… [truncated]` : result.bodyText;
37
+ const text = [
38
+ `HTTP ${result.status}`,
39
+ result.headers['content-type'] ? `Content-Type: ${result.headers['content-type']}` : '',
40
+ '',
41
+ snippet || '(empty body)',
42
+ ]
43
+ .filter(Boolean)
44
+ .join('\n');
45
+ return {
46
+ content: [{ type: 'text', text }],
47
+ isError: result.status >= 400,
48
+ };
49
+ }
50
+ export async function createLyrraMcpServer() {
51
+ process.stderr.write('[lyrra-mcp] Loading OpenAPI…\n');
52
+ const spec = await fetchOpenApiJson();
53
+ let ops = parseOpenApiOperations(spec);
54
+ const maxTools = MAX_TOOLS_ENV ? parseInt(MAX_TOOLS_ENV, 10) : NaN;
55
+ if (!Number.isNaN(maxTools) && maxTools > 0) {
56
+ ops = ops.slice(0, maxTools);
57
+ process.stderr.write(`[lyrra-mcp] LYRRA_MCP_MAX_TOOLS=${maxTools} — ${ops.length} tools registered.\n`);
58
+ }
59
+ process.stderr.write(`[lyrra-mcp] ${ops.length} operations → MCP tools.\n`);
60
+ const mcp = new McpServer({
61
+ name: 'lyrra-studio',
62
+ version: '1.0.0',
63
+ title: 'Lyrra Studio',
64
+ });
65
+ registerEduflowBlockDocumentationTools(mcp);
66
+ process.stderr.write(`[lyrra-mcp] EduFlow docs: ${eduflowBlockDocToolCount()} tools (index + per-block sheets).\n`);
67
+ mcp.registerTool('lyrra_meta', {
68
+ description: 'Configuration summary: API origin, registered tool count, useful environment variables (no secrets shown).',
69
+ inputSchema: z.object({}),
70
+ }, async () => {
71
+ let tokenMode = 'CLIENT_ID+SECRET';
72
+ if (process.env.LYRRA_ACCESS_TOKEN?.trim())
73
+ tokenMode = 'ACCESS_TOKEN';
74
+ const hn = process.env.LYRRA_MCP_HEADER_NAME?.trim() || process.env.LYRRA_HEADER_AUTH_NAME?.trim();
75
+ const hv = process.env.LYRRA_MCP_HEADER_SECRET?.trim() ||
76
+ process.env.LYRRA_MCP_HEADER_VALUE?.trim() ||
77
+ process.env.LYRRA_HEADER_AUTH_VALUE?.trim();
78
+ if (hn && hv)
79
+ tokenMode = 'HEADER_AUTH_ENV';
80
+ const text = [
81
+ `Request origin: ${requestOrigin()}`,
82
+ `OpenAPI tools registered: ${ops.length}`,
83
+ `EduFlow block doc tools: ${eduflowBlockDocToolCount()} (lyrra_eduflow_blocks_index + lyrra_eduflow_block_*)`,
84
+ `Token mode: ${tokenMode}`,
85
+ `HTTP MCP: forward Authorization, X-Lyrra-Api-Key, or X-Api-Key from each request (n8n Header Auth).`,
86
+ `LYRRA_API_URL: ${process.env.LYRRA_API_URL ? 'set' : 'missing'}`,
87
+ `LYRRA_OPENAPI_URL: ${process.env.LYRRA_OPENAPI_URL ? 'set' : 'default /api/openapi.json'}`,
88
+ ].join('\n');
89
+ return { content: [{ type: 'text', text }] };
90
+ });
91
+ mcp.registerTool('lyrra_search_operations', {
92
+ description: 'Search operations (operationId, method, path, start of description). Useful when the tool list is long.',
93
+ inputSchema: z.object({
94
+ q: z.string().min(1).describe('Search text (case-insensitive)'),
95
+ limit: z.number().int().min(1).max(80).optional().describe('Max results (default 30)'),
96
+ }),
97
+ }, async ({ q, limit }) => {
98
+ const lim = limit ?? 30;
99
+ const qq = q.toLowerCase();
100
+ const hits = ops
101
+ .filter((o) => o.operationId.toLowerCase().includes(qq) ||
102
+ o.path.toLowerCase().includes(qq) ||
103
+ o.method.toLowerCase().includes(qq) ||
104
+ o.description.toLowerCase().includes(qq))
105
+ .slice(0, lim);
106
+ const text = hits.length === 0
107
+ ? 'No operations found.'
108
+ : hits.map((o) => `${o.method} ${o.path}\n operationId: ${o.operationId}`).join('\n\n');
109
+ return { content: [{ type: 'text', text }] };
110
+ });
111
+ const staticGuide = `# Lyrra Studio — MCP integration
112
+
113
+ - Each tool named after an OpenAPI **operationId** calls **exactly** the documented endpoint.
114
+ - **EduFlow blocks**: tools \`lyrra_eduflow_blocks_index\` then \`lyrra_eduflow_block_<type>\` (e.g. \`lyrra_eduflow_block_quiz_mcq\`) — full sheet: role, \`data\`, \`settings\`, graph.
115
+ - API args: path segments \`{id}\` → required properties of the same name; \`query\`; \`body\` (JSON).
116
+ - Auth (stdio / env): \`LYRRA_CLIENT_ID\` + \`LYRRA_CLIENT_SECRET\`, \`LYRRA_ACCESS_TOKEN\`, or \`LYRRA_MCP_HEADER_NAME\` + value.
117
+ - Auth (HTTP MCP / n8n): send **Header Auth** on every MCP request (e.g. \`X-Lyrra-Api-Key: rak_…\` matching your institution key).
118
+ `;
119
+ mcp.registerResource('lyrra-mcp-guide', 'lyrra://flow-construction-guide', {
120
+ description: 'How to use the Lyrra MCP server',
121
+ mimeType: 'text/markdown',
122
+ }, async () => ({
123
+ contents: [{ uri: 'lyrra://flow-construction-guide', mimeType: 'text/markdown', text: staticGuide }],
124
+ }));
125
+ mcp.registerResource('lyrra-block-types', 'lyrra://block-types', {
126
+ description: 'Points to MCP tools lyrra_eduflow_block_* (per-type sheet) + persistence via eduflows API',
127
+ mimeType: 'text/markdown',
128
+ }, async () => ({
129
+ contents: [
130
+ {
131
+ uri: 'lyrra://block-types',
132
+ mimeType: 'text/markdown',
133
+ text: [
134
+ '# EduFlow block types (MCP)',
135
+ '',
136
+ `1. Call **lyrra_eduflow_blocks_index** for documented types (${eduflowBlockDocToolCount() - 1} blocks).`,
137
+ '2. Call **lyrra_eduflow_block_<type>** (e.g. `lyrra_eduflow_block_video`) for the full sheet: pedagogical role, `BlockData`, `settings`, connections.',
138
+ '3. HTTP persistence: OpenAPI routes under `/api/eduflows/{id}/blocks` and flow save.',
139
+ '',
140
+ 'Code reference: `apps/frontend/src/types/eduflow.ts`, palette `eduflow-designer-palette-blueprint.ts`.',
141
+ ].join('\n'),
142
+ },
143
+ ],
144
+ }));
145
+ for (const op of ops) {
146
+ const inputSchema = z.object(zodShapeForOperation(op));
147
+ const desc = truncateDesc(`${op.description}\n\n— HTTP ${op.method} \`${op.path}\``);
148
+ mcp.registerTool(op.operationId, {
149
+ description: desc,
150
+ inputSchema,
151
+ }, async (rawArgs) => {
152
+ const args = rawArgs;
153
+ const pathParams = {};
154
+ for (const name of op.pathParamNames) {
155
+ const v = args[name];
156
+ pathParams[name] = typeof v === 'string' ? v : String(v ?? '');
157
+ }
158
+ const query = args.query;
159
+ const body = args.body;
160
+ try {
161
+ const result = await callLyrraOperation(op.method, op.path, { pathParams, query, body });
162
+ return formatToolResult(result);
163
+ }
164
+ catch (e) {
165
+ const msg = e instanceof Error ? e.message : String(e);
166
+ return {
167
+ content: [{ type: 'text', text: `Error: ${msg}` }],
168
+ isError: true,
169
+ };
170
+ }
171
+ });
172
+ }
173
+ return { mcp, operationsCount: ops.length };
174
+ }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@lyrra/mcp-server",
3
- "version": "1.1.5",
4
- "description": "Lyrra Studio stdio MCP server OpenAPI-aligned tools for Claude Desktop, Cursor, and compatible MCP clients",
3
+ "version": "1.1.8",
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
- "lyrra-mcp": "./dist/index.js"
8
+ "lyrra-mcp": "./dist/index.js",
9
+ "lyrra-mcp-http": "./dist/http-main.js"
9
10
  },
10
11
  "files": [
11
12
  "dist",
@@ -15,7 +16,9 @@
15
16
  "build": "tsc -p tsconfig.json",
16
17
  "prepublishOnly": "npm run build",
17
18
  "start": "node dist/index.js",
19
+ "start:http": "node dist/http-main.js",
18
20
  "dev": "tsx src/index.ts",
21
+ "dev:http": "tsx src/http-main.ts",
19
22
  "test:mcp": "npm run build && node scripts/mcp-smoke-test.mjs"
20
23
  },
21
24
  "engines": {
@@ -42,9 +45,11 @@
42
45
  },
43
46
  "dependencies": {
44
47
  "@modelcontextprotocol/sdk": "^1.29.0",
48
+ "express": "^5.2.1",
45
49
  "zod": "^3.23.8"
46
50
  },
47
51
  "devDependencies": {
52
+ "@types/express": "^5.0.0",
48
53
  "@types/node": "^20.10.8",
49
54
  "tsx": "^4.7.0",
50
55
  "typescript": "^5.2.2"