@lyrra/mcp-server 1.1.5 → 1.1.7
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 +13 -0
- package/dist/auth-session.js +11 -0
- package/dist/http-incoming-auth.js +48 -0
- package/dist/http-main.js +104 -0
- package/dist/index.js +4 -167
- package/dist/lyrra-mcp-core.js +174 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -45,6 +45,16 @@ Use **Header Auth** keys from the institution dashboard instead of client id/sec
|
|
|
45
45
|
|
|
46
46
|
Global install (optional): `npm install -g @lyrra/mcp-server` then run **`lyrra-mcp`** (binary name on `PATH`).
|
|
47
47
|
|
|
48
|
+
## Streamable HTTP / n8n (`https://…/mcp`)
|
|
49
|
+
|
|
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`**.
|
|
51
|
+
|
|
52
|
+
- **Docker:** enable service **`mcp-http`** in `docker-compose.yml` and Nginx **`location /mcp`** (see `apps/frontend/nginx.default.conf`).
|
|
53
|
+
- **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`.
|
|
55
|
+
|
|
56
|
+
Binaries after global install: **`lyrra-mcp`** (stdio) and **`lyrra-mcp-http`** (HTTP).
|
|
57
|
+
|
|
48
58
|
## Develop from this monorepo
|
|
49
59
|
|
|
50
60
|
```bash
|
|
@@ -78,6 +88,9 @@ Run `npm pkg fix` in this folder to apply npm’s suggested `package.json` fixes
|
|
|
78
88
|
| `LYRRA_MCP_HEADER_VALUE` | *(optional)* Same secret as shown once at key creation (alias `LYRRA_MCP_HEADER_SECRET` or `LYRRA_HEADER_AUTH_VALUE`) |
|
|
79
89
|
| `LYRRA_OPENAPI_URL` | *(optional)* OpenAPI JSON URL |
|
|
80
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 |
|
|
81
94
|
|
|
82
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 »).
|
|
83
96
|
|
package/dist/auth-session.js
CHANGED
|
@@ -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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "1.1.7",
|
|
4
4
|
"description": "Lyrra Studio stdio MCP server — OpenAPI-aligned tools for Claude Desktop, Cursor, and compatible MCP clients",
|
|
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"
|