@lyrra/mcp-server 1.0.0 → 1.1.0

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.
Files changed (87) hide show
  1. package/Dockerfile +16 -0
  2. package/README.md +59 -4
  3. package/dist/client.d.ts +7 -1
  4. package/dist/client.d.ts.map +1 -1
  5. package/dist/client.js +5 -5
  6. package/dist/client.js.map +1 -1
  7. package/dist/config.d.ts +1 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +1 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/http-server.d.ts +8 -0
  12. package/dist/http-server.d.ts.map +1 -0
  13. package/dist/http-server.js +466 -0
  14. package/dist/http-server.js.map +1 -0
  15. package/dist/index.js +8 -71
  16. package/dist/index.js.map +1 -1
  17. package/dist/server-factory.d.ts +8 -0
  18. package/dist/server-factory.d.ts.map +1 -0
  19. package/dist/server-factory.js +82 -0
  20. package/dist/server-factory.js.map +1 -0
  21. package/dist/tools/admin.d.ts +132 -0
  22. package/dist/tools/admin.d.ts.map +1 -1
  23. package/dist/tools/admin.js +105 -101
  24. package/dist/tools/admin.js.map +1 -1
  25. package/dist/tools/ai-designer.d.ts +148 -0
  26. package/dist/tools/ai-designer.d.ts.map +1 -1
  27. package/dist/tools/ai-designer.js +80 -76
  28. package/dist/tools/ai-designer.js.map +1 -1
  29. package/dist/tools/analytics.d.ts +47 -0
  30. package/dist/tools/analytics.d.ts.map +1 -1
  31. package/dist/tools/analytics.js +38 -34
  32. package/dist/tools/analytics.js.map +1 -1
  33. package/dist/tools/auth.d.ts +30 -0
  34. package/dist/tools/auth.d.ts.map +1 -1
  35. package/dist/tools/auth.js +31 -27
  36. package/dist/tools/auth.js.map +1 -1
  37. package/dist/tools/blocks.d.ts +200 -0
  38. package/dist/tools/blocks.d.ts.map +1 -1
  39. package/dist/tools/blocks.js +154 -150
  40. package/dist/tools/blocks.js.map +1 -1
  41. package/dist/tools/connections.d.ts +86 -0
  42. package/dist/tools/connections.d.ts.map +1 -1
  43. package/dist/tools/connections.js +70 -66
  44. package/dist/tools/connections.js.map +1 -1
  45. package/dist/tools/eduflow.d.ts +223 -0
  46. package/dist/tools/eduflow.d.ts.map +1 -1
  47. package/dist/tools/eduflow.js +114 -93
  48. package/dist/tools/eduflow.js.map +1 -1
  49. package/dist/tools/participants.d.ts +110 -0
  50. package/dist/tools/participants.d.ts.map +1 -1
  51. package/dist/tools/participants.js +62 -58
  52. package/dist/tools/participants.js.map +1 -1
  53. package/dist/tools/presentation.d.ts +116 -0
  54. package/dist/tools/presentation.d.ts.map +1 -1
  55. package/dist/tools/presentation.js +51 -47
  56. package/dist/tools/presentation.js.map +1 -1
  57. package/dist/tools/projects.d.ts +65 -0
  58. package/dist/tools/projects.d.ts.map +1 -1
  59. package/dist/tools/projects.js +48 -44
  60. package/dist/tools/projects.js.map +1 -1
  61. package/dist/tools/resources.d.ts +46 -0
  62. package/dist/tools/resources.d.ts.map +1 -1
  63. package/dist/tools/resources.js +32 -28
  64. package/dist/tools/resources.js.map +1 -1
  65. package/dist/tools/store.d.ts +62 -0
  66. package/dist/tools/store.d.ts.map +1 -1
  67. package/dist/tools/store.js +59 -55
  68. package/dist/tools/store.js.map +1 -1
  69. package/mcp-config.example.json +4 -5
  70. package/package.json +7 -2
  71. package/src/client.ts +12 -5
  72. package/src/config.ts +1 -0
  73. package/src/http-server.ts +573 -0
  74. package/src/index.ts +7 -96
  75. package/src/server-factory.ts +109 -0
  76. package/src/tools/admin.ts +20 -14
  77. package/src/tools/ai-designer.ts +16 -10
  78. package/src/tools/analytics.ts +13 -7
  79. package/src/tools/auth.ts +32 -26
  80. package/src/tools/blocks.ts +18 -12
  81. package/src/tools/connections.ts +14 -8
  82. package/src/tools/eduflow.ts +36 -12
  83. package/src/tools/participants.ts +15 -9
  84. package/src/tools/presentation.ts +12 -6
  85. package/src/tools/projects.ts +14 -8
  86. package/src/tools/resources.ts +12 -6
  87. package/src/tools/store.ts +14 -8
@@ -0,0 +1,573 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * LYRRA Studio MCP HTTP Server
4
+ * Provides Streamable HTTP transport with OAuth 2.0 for Claude.ai integration.
5
+ * All endpoints are under /mcp/ for clean nginx routing.
6
+ */
7
+
8
+ import crypto from 'crypto';
9
+ import express from 'express';
10
+ import type { Request, Response } from 'express';
11
+ import cors from 'cors';
12
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
13
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
14
+ import { createMcpServer } from './server-factory.js';
15
+ import { LyrraClient } from './client.js';
16
+
17
+ // ─── Configuration ───────────────────────────────────────────────────────────
18
+
19
+ const PORT = parseInt(process.env.MCP_HTTP_PORT || '3002', 10);
20
+ const BASE_URL = process.env.MCP_BASE_URL || 'https://lyrrastudio.com';
21
+ const LYRRA_API_URL = process.env.LYRRA_API_URL || 'http://localhost:3001/api';
22
+
23
+ // ─── In-memory stores ────────────────────────────────────────────────────────
24
+
25
+ interface OAuthClient {
26
+ client_id: string;
27
+ client_secret?: string;
28
+ client_id_issued_at: number;
29
+ redirect_uris: string[];
30
+ grant_types: string[];
31
+ response_types: string[];
32
+ token_endpoint_auth_method: string;
33
+ }
34
+ const registeredClients = new Map<string, OAuthClient>();
35
+
36
+ interface AuthCodeData {
37
+ oauthClientId: string;
38
+ codeChallenge: string;
39
+ codeChallengeMethod: string;
40
+ redirectUri: string;
41
+ state?: string;
42
+ apiKey: string;
43
+ expiresAt: number;
44
+ }
45
+ const authCodes = new Map<string, AuthCodeData>();
46
+
47
+ interface TokenData {
48
+ oauthClientId: string;
49
+ apiKey: string;
50
+ expiresAt: number;
51
+ }
52
+ const accessTokens = new Map<string, TokenData>();
53
+ const refreshTokens = new Map<string, { oauthClientId: string; apiKey: string }>();
54
+
55
+ const mcpSessions = new Map<string, { transport: StreamableHTTPServerTransport; server: McpServer }>();
56
+
57
+ // ─── Helper: Validate LYRRA API key ─────────────────────────────────────────
58
+
59
+ async function validateApiKey(apiKey: string): Promise<boolean> {
60
+ try {
61
+ const res = await fetch(`${LYRRA_API_URL}/auth/me`, {
62
+ headers: { 'Content-Type': 'application/json', 'X-API-Key': apiKey },
63
+ });
64
+ return res.ok;
65
+ } catch {
66
+ return false;
67
+ }
68
+ }
69
+
70
+ // ─── Helper: Verify PKCE ────────────────────────────────────────────────────
71
+
72
+ function verifyPkce(codeVerifier: string, codeChallenge: string, method: string): boolean {
73
+ if (method === 'S256') {
74
+ const hash = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
75
+ return hash === codeChallenge;
76
+ }
77
+ return codeVerifier === codeChallenge; // plain
78
+ }
79
+
80
+ // ─── Helper: Verify Bearer token ─────────────────────────────────────────────
81
+
82
+ function verifyBearerToken(req: Request): TokenData | null {
83
+ const auth = req.headers.authorization;
84
+ if (!auth?.startsWith('Bearer ')) return null;
85
+ const token = auth.slice(7);
86
+ const data = accessTokens.get(token);
87
+ if (!data) return null;
88
+ if (Date.now() > data.expiresAt) {
89
+ accessTokens.delete(token);
90
+ return null;
91
+ }
92
+ return data;
93
+ }
94
+
95
+ // ─── Authorize page HTML ─────────────────────────────────────────────────────
96
+
97
+ function renderAuthorizePage(pendingToken: string, showError = false): string {
98
+ return `<!DOCTYPE html>
99
+ <html lang="fr">
100
+ <head>
101
+ <meta charset="UTF-8">
102
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
103
+ <title>LYRRA Studio - Autorisation MCP</title>
104
+ <style>
105
+ * { margin: 0; padding: 0; box-sizing: border-box; }
106
+ body {
107
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
108
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
109
+ min-height: 100vh; display: flex; align-items: center; justify-content: center;
110
+ }
111
+ .card {
112
+ background: white; border-radius: 16px; padding: 40px;
113
+ max-width: 440px; width: 100%; box-shadow: 0 20px 60px rgba(0,0,0,0.2);
114
+ }
115
+ .logo { text-align: center; margin-bottom: 24px; }
116
+ .logo h1 { font-size: 28px; color: #333; }
117
+ .logo span { color: #667eea; }
118
+ .subtitle { text-align: center; color: #666; margin-bottom: 32px; font-size: 14px; line-height: 1.5; }
119
+ label { display: block; font-size: 13px; font-weight: 600; color: #444; margin-bottom: 6px; }
120
+ input {
121
+ width: 100%; padding: 12px 16px; border: 2px solid #e2e8f0;
122
+ border-radius: 8px; font-size: 14px; transition: border-color 0.2s;
123
+ margin-bottom: 16px; outline: none;
124
+ }
125
+ input:focus { border-color: #667eea; }
126
+ button {
127
+ width: 100%; padding: 14px; background: #667eea; color: white;
128
+ border: none; border-radius: 8px; font-size: 16px; font-weight: 600;
129
+ cursor: pointer; transition: background 0.2s;
130
+ }
131
+ button:hover { background: #5a6fd6; }
132
+ button:disabled { background: #a0aec0; cursor: not-allowed; }
133
+ .error { color: #e53e3e; font-size: 13px; margin-bottom: 16px; display: ${showError ? 'block' : 'none'}; }
134
+ .info { font-size: 12px; color: #888; text-align: center; margin-top: 16px; line-height: 1.5; }
135
+ </style>
136
+ </head>
137
+ <body>
138
+ <div class="card">
139
+ <div class="logo"><h1><span>LYRRA</span> Studio</h1></div>
140
+ <p class="subtitle">
141
+ Une application souhaite accéder à votre compte LYRRA Studio via MCP.<br>
142
+ Entrez vos identifiants API pour autoriser l'accès.
143
+ </p>
144
+ <form id="authForm" method="POST" action="/mcp/approve">
145
+ <input type="hidden" name="pending_token" value="${pendingToken}">
146
+ <label for="apiKey">Clé API (Client Secret)</label>
147
+ <input type="password" id="apiKey" name="api_key" placeholder="rak_XXXXXXXX_..." required>
148
+ <div class="error" id="error">Clé API invalide. Vérifiez votre Client Secret dans le tableau de bord.</div>
149
+ <button type="submit" id="submitBtn">Autoriser l'accès</button>
150
+ </form>
151
+ <p class="info">
152
+ Votre clé API est disponible dans le tableau de bord de votre institution,<br>
153
+ section « Serveur MCP » → Client Secret (visible uniquement à la création).
154
+ </p>
155
+ </div>
156
+ <script>
157
+ document.getElementById('authForm').addEventListener('submit', function() {
158
+ document.getElementById('submitBtn').disabled = true;
159
+ document.getElementById('submitBtn').textContent = 'Vérification...';
160
+ });
161
+ </script>
162
+ </body>
163
+ </html>`;
164
+ }
165
+
166
+ // ─── Create Express app ──────────────────────────────────────────────────────
167
+
168
+ const app = express();
169
+
170
+ app.use(cors({ origin: true, credentials: true, exposedHeaders: ['mcp-session-id'] }));
171
+ app.use(express.json());
172
+ app.use(express.urlencoded({ extended: true }));
173
+
174
+ // ─── OAuth Protected Resource Metadata (RFC 9728) ────────────────────────────
175
+ // Claude.ai fetches this to discover the authorization server
176
+
177
+ app.get('/.well-known/oauth-protected-resource/mcp', (_req: Request, res: Response) => {
178
+ res.json({
179
+ resource: `${BASE_URL}/mcp`,
180
+ authorization_servers: [`${BASE_URL}/mcp`],
181
+ scopes_supported: ['mcp'],
182
+ resource_name: 'LYRRA Studio MCP',
183
+ resource_documentation: `${BASE_URL}/docs/mcp`,
184
+ });
185
+ });
186
+
187
+ app.get('/.well-known/oauth-protected-resource', (_req: Request, res: Response) => {
188
+ res.json({
189
+ resource: `${BASE_URL}/mcp`,
190
+ authorization_servers: [`${BASE_URL}/mcp`],
191
+ scopes_supported: ['mcp'],
192
+ resource_name: 'LYRRA Studio MCP',
193
+ });
194
+ });
195
+
196
+ // ─── OAuth Authorization Server Metadata (RFC 8414) ──────────────────────────
197
+
198
+ app.get('/mcp/.well-known/oauth-authorization-server', (_req: Request, res: Response) => {
199
+ res.json({
200
+ issuer: `${BASE_URL}/mcp`,
201
+ authorization_endpoint: `${BASE_URL}/mcp/authorize`,
202
+ token_endpoint: `${BASE_URL}/mcp/token`,
203
+ registration_endpoint: `${BASE_URL}/mcp/register`,
204
+ revocation_endpoint: `${BASE_URL}/mcp/revoke`,
205
+ response_types_supported: ['code'],
206
+ grant_types_supported: ['authorization_code', 'refresh_token'],
207
+ code_challenge_methods_supported: ['S256'],
208
+ token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
209
+ scopes_supported: ['mcp'],
210
+ service_documentation: `${BASE_URL}/docs/mcp`,
211
+ });
212
+ });
213
+
214
+ // ─── Dynamic Client Registration (RFC 7591) ──────────────────────────────────
215
+
216
+ app.post('/mcp/register', (req: Request, res: Response) => {
217
+ const { redirect_uris, grant_types, response_types, token_endpoint_auth_method, client_name } = req.body;
218
+
219
+ const clientId = `mcp_${crypto.randomBytes(16).toString('hex')}`;
220
+ const clientSecret = crypto.randomBytes(32).toString('hex');
221
+
222
+ const client: OAuthClient = {
223
+ client_id: clientId,
224
+ client_secret: clientSecret,
225
+ client_id_issued_at: Math.floor(Date.now() / 1000),
226
+ redirect_uris: redirect_uris || [],
227
+ grant_types: grant_types || ['authorization_code', 'refresh_token'],
228
+ response_types: response_types || ['code'],
229
+ token_endpoint_auth_method: token_endpoint_auth_method || 'client_secret_post',
230
+ };
231
+
232
+ registeredClients.set(clientId, client);
233
+ console.log(`[MCP OAuth] Registered client: ${clientId} (${client_name || 'unnamed'})`);
234
+
235
+ res.status(201).json({
236
+ client_id: clientId,
237
+ client_secret: clientSecret,
238
+ client_id_issued_at: client.client_id_issued_at,
239
+ client_secret_expires_at: 0,
240
+ redirect_uris: client.redirect_uris,
241
+ grant_types: client.grant_types,
242
+ response_types: client.response_types,
243
+ token_endpoint_auth_method: client.token_endpoint_auth_method,
244
+ });
245
+ });
246
+
247
+ // ─── Authorization endpoint ──────────────────────────────────────────────────
248
+
249
+ app.get('/mcp/authorize', (req: Request, res: Response) => {
250
+ const { client_id, redirect_uri, response_type, state, code_challenge, code_challenge_method, scope } = req.query;
251
+
252
+ if (response_type !== 'code') {
253
+ res.status(400).json({ error: 'unsupported_response_type' });
254
+ return;
255
+ }
256
+
257
+ if (!client_id || !redirect_uri || !code_challenge) {
258
+ res.status(400).json({ error: 'invalid_request', error_description: 'Missing required parameters' });
259
+ return;
260
+ }
261
+
262
+ // Verify client is registered
263
+ const client = registeredClients.get(client_id as string);
264
+ if (!client) {
265
+ res.status(400).json({ error: 'invalid_client', error_description: 'Client not registered' });
266
+ return;
267
+ }
268
+
269
+ // Encode auth params for the form
270
+ const pendingData = {
271
+ oauthClientId: client_id as string,
272
+ redirectUri: redirect_uri as string,
273
+ codeChallenge: code_challenge as string,
274
+ codeChallengeMethod: (code_challenge_method as string) || 'S256',
275
+ state: state as string | undefined,
276
+ };
277
+
278
+ const pendingToken = Buffer.from(JSON.stringify(pendingData)).toString('base64url');
279
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
280
+ res.send(renderAuthorizePage(pendingToken));
281
+ });
282
+
283
+ // ─── Approve endpoint (form POST from authorize page) ────────────────────────
284
+
285
+ app.post('/mcp/approve', async (req: Request, res: Response) => {
286
+ try {
287
+ const { pending_token, api_key } = req.body;
288
+
289
+ if (!pending_token || !api_key) {
290
+ res.status(400).send('Paramètres manquants');
291
+ return;
292
+ }
293
+
294
+ let pendingData: {
295
+ oauthClientId: string;
296
+ redirectUri: string;
297
+ codeChallenge: string;
298
+ codeChallengeMethod: string;
299
+ state?: string;
300
+ };
301
+
302
+ try {
303
+ pendingData = JSON.parse(Buffer.from(pending_token, 'base64url').toString('utf-8'));
304
+ } catch {
305
+ res.status(400).send('Token invalide');
306
+ return;
307
+ }
308
+
309
+ // Validate the LYRRA API key
310
+ const isValid = await validateApiKey(api_key);
311
+ if (!isValid) {
312
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
313
+ res.send(renderAuthorizePage(pending_token, true));
314
+ return;
315
+ }
316
+
317
+ // Generate authorization code
318
+ const authCode = crypto.randomBytes(32).toString('hex');
319
+ authCodes.set(authCode, {
320
+ oauthClientId: pendingData.oauthClientId,
321
+ codeChallenge: pendingData.codeChallenge,
322
+ codeChallengeMethod: pendingData.codeChallengeMethod,
323
+ redirectUri: pendingData.redirectUri,
324
+ state: pendingData.state,
325
+ apiKey: api_key,
326
+ expiresAt: Date.now() + 10 * 60 * 1000,
327
+ });
328
+
329
+ // Redirect back with authorization code
330
+ const redirectUrl = new URL(pendingData.redirectUri);
331
+ redirectUrl.searchParams.set('code', authCode);
332
+ if (pendingData.state) {
333
+ redirectUrl.searchParams.set('state', pendingData.state);
334
+ }
335
+
336
+ console.log(`[MCP OAuth] Authorization granted, redirecting...`);
337
+ res.redirect(redirectUrl.toString());
338
+ } catch (error: any) {
339
+ console.error('[MCP OAuth] Approve error:', error);
340
+ res.status(500).send('Erreur interne');
341
+ }
342
+ });
343
+
344
+ // ─── Token endpoint ──────────────────────────────────────────────────────────
345
+
346
+ app.post('/mcp/token', (req: Request, res: Response) => {
347
+ const { grant_type, code, redirect_uri, code_verifier, client_id, client_secret, refresh_token } = req.body;
348
+
349
+ if (grant_type === 'authorization_code') {
350
+ if (!code || !code_verifier) {
351
+ res.status(400).json({ error: 'invalid_request' });
352
+ return;
353
+ }
354
+
355
+ const authCode = authCodes.get(code);
356
+ if (!authCode) {
357
+ res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid authorization code' });
358
+ return;
359
+ }
360
+
361
+ if (Date.now() > authCode.expiresAt) {
362
+ authCodes.delete(code);
363
+ res.status(400).json({ error: 'invalid_grant', error_description: 'Authorization code expired' });
364
+ return;
365
+ }
366
+
367
+ // Verify PKCE
368
+ if (!verifyPkce(code_verifier, authCode.codeChallenge, authCode.codeChallengeMethod)) {
369
+ res.status(400).json({ error: 'invalid_grant', error_description: 'PKCE verification failed' });
370
+ return;
371
+ }
372
+
373
+ // Verify redirect_uri matches
374
+ if (redirect_uri && redirect_uri !== authCode.redirectUri) {
375
+ res.status(400).json({ error: 'invalid_grant', error_description: 'Redirect URI mismatch' });
376
+ return;
377
+ }
378
+
379
+ // Consume the code
380
+ authCodes.delete(code);
381
+
382
+ // Issue tokens
383
+ const accessToken = `at_${crypto.randomBytes(32).toString('hex')}`;
384
+ const newRefreshToken = `rt_${crypto.randomBytes(32).toString('hex')}`;
385
+ const expiresIn = 86400; // 24h
386
+
387
+ accessTokens.set(accessToken, {
388
+ oauthClientId: authCode.oauthClientId,
389
+ apiKey: authCode.apiKey,
390
+ expiresAt: Date.now() + expiresIn * 1000,
391
+ });
392
+
393
+ refreshTokens.set(newRefreshToken, {
394
+ oauthClientId: authCode.oauthClientId,
395
+ apiKey: authCode.apiKey,
396
+ });
397
+
398
+ console.log(`[MCP OAuth] Issued tokens for client ${authCode.oauthClientId}`);
399
+
400
+ res.json({
401
+ access_token: accessToken,
402
+ token_type: 'bearer',
403
+ expires_in: expiresIn,
404
+ refresh_token: newRefreshToken,
405
+ });
406
+ } else if (grant_type === 'refresh_token') {
407
+ if (!refresh_token) {
408
+ res.status(400).json({ error: 'invalid_request' });
409
+ return;
410
+ }
411
+
412
+ const rtData = refreshTokens.get(refresh_token);
413
+ if (!rtData) {
414
+ res.status(400).json({ error: 'invalid_grant', error_description: 'Invalid refresh token' });
415
+ return;
416
+ }
417
+
418
+ const accessToken = `at_${crypto.randomBytes(32).toString('hex')}`;
419
+ const expiresIn = 86400;
420
+
421
+ accessTokens.set(accessToken, {
422
+ oauthClientId: rtData.oauthClientId,
423
+ apiKey: rtData.apiKey,
424
+ expiresAt: Date.now() + expiresIn * 1000,
425
+ });
426
+
427
+ console.log(`[MCP OAuth] Refreshed token for client ${rtData.oauthClientId}`);
428
+
429
+ res.json({
430
+ access_token: accessToken,
431
+ token_type: 'bearer',
432
+ expires_in: expiresIn,
433
+ refresh_token: refresh_token,
434
+ });
435
+ } else {
436
+ res.status(400).json({ error: 'unsupported_grant_type' });
437
+ }
438
+ });
439
+
440
+ // ─── Token revocation ────────────────────────────────────────────────────────
441
+
442
+ app.post('/mcp/revoke', (req: Request, res: Response) => {
443
+ const { token } = req.body;
444
+ if (token) {
445
+ accessTokens.delete(token);
446
+ refreshTokens.delete(token);
447
+ }
448
+ res.status(200).json({});
449
+ });
450
+
451
+ // ─── Bearer auth middleware ──────────────────────────────────────────────────
452
+
453
+ function requireAuth(req: Request, res: Response): TokenData | null {
454
+ const tokenData = verifyBearerToken(req);
455
+ if (!tokenData) {
456
+ res.status(401).json({
457
+ error: 'invalid_token',
458
+ error_description: 'Invalid or expired access token',
459
+ });
460
+ return null;
461
+ }
462
+ return tokenData;
463
+ }
464
+
465
+ // ─── MCP Protocol endpoint (Streamable HTTP) ────────────────────────────────
466
+
467
+ app.post('/mcp', async (req: Request, res: Response) => {
468
+ // Check if this is an OAuth/metadata request (no Authorization header = not an MCP protocol request)
469
+ const hasAuth = req.headers.authorization?.startsWith('Bearer ');
470
+ if (!hasAuth) {
471
+ res.status(401).json({ error: 'unauthorized' });
472
+ return;
473
+ }
474
+
475
+ const tokenData = requireAuth(req, res);
476
+ if (!tokenData) return;
477
+
478
+ try {
479
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
480
+
481
+ if (sessionId && mcpSessions.has(sessionId)) {
482
+ const session = mcpSessions.get(sessionId)!;
483
+ await session.transport.handleRequest(req, res, req.body);
484
+ } else {
485
+ // New session with user's API key
486
+ const client = new LyrraClient({
487
+ clientSecret: tokenData.apiKey,
488
+ apiUrl: LYRRA_API_URL,
489
+ eduflowUrl: process.env.LYRRA_EDUFLOW_API_URL || LYRRA_API_URL.replace('/api', '/eduflow'),
490
+ });
491
+
492
+ const server = createMcpServer(client);
493
+ const transport = new StreamableHTTPServerTransport({
494
+ sessionIdGenerator: () => crypto.randomUUID(),
495
+ });
496
+
497
+ transport.onclose = () => {
498
+ const sid = transport.sessionId;
499
+ if (sid) mcpSessions.delete(sid);
500
+ console.log(`[MCP HTTP] Session closed: ${sid}`);
501
+ };
502
+
503
+ await server.connect(transport);
504
+
505
+ if (transport.sessionId) {
506
+ mcpSessions.set(transport.sessionId, { transport, server });
507
+ console.log(`[MCP HTTP] New session: ${transport.sessionId}`);
508
+ }
509
+
510
+ await transport.handleRequest(req, res, req.body);
511
+ }
512
+ } catch (error: any) {
513
+ console.error('[MCP HTTP] POST error:', error);
514
+ if (!res.headersSent) {
515
+ res.status(500).json({ error: 'Internal server error' });
516
+ }
517
+ }
518
+ });
519
+
520
+ // Handle GET (SSE stream)
521
+ app.get('/mcp', async (req: Request, res: Response) => {
522
+ // Check if it's a .well-known or authorize request (no auth needed)
523
+ // Those are handled by earlier routes, this is only for SSE
524
+
525
+ const tokenData = requireAuth(req, res);
526
+ if (!tokenData) return;
527
+
528
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
529
+ if (!sessionId || !mcpSessions.has(sessionId)) {
530
+ res.status(400).json({ error: 'Invalid or missing session ID' });
531
+ return;
532
+ }
533
+
534
+ const session = mcpSessions.get(sessionId)!;
535
+ await session.transport.handleRequest(req, res);
536
+ });
537
+
538
+ // Handle DELETE (close session)
539
+ app.delete('/mcp', async (req: Request, res: Response) => {
540
+ const tokenData = requireAuth(req, res);
541
+ if (!tokenData) return;
542
+
543
+ const sessionId = req.headers['mcp-session-id'] as string | undefined;
544
+ if (!sessionId || !mcpSessions.has(sessionId)) {
545
+ res.status(404).json({ error: 'Session not found' });
546
+ return;
547
+ }
548
+
549
+ const session = mcpSessions.get(sessionId)!;
550
+ await session.transport.handleRequest(req, res);
551
+ mcpSessions.delete(sessionId);
552
+ });
553
+
554
+ // ─── Health check ────────────────────────────────────────────────────────────
555
+
556
+ app.get('/mcp/health', (_req: Request, res: Response) => {
557
+ res.json({
558
+ status: 'ok',
559
+ service: 'LYRRA Studio MCP HTTP Server',
560
+ sessions: mcpSessions.size,
561
+ registeredClients: registeredClients.size,
562
+ timestamp: new Date().toISOString(),
563
+ });
564
+ });
565
+
566
+ // ─── Start server ────────────────────────────────────────────────────────────
567
+
568
+ app.listen(PORT, () => {
569
+ console.log(`🚀 LYRRA Studio MCP HTTP Server running on port ${PORT}`);
570
+ console.log(` OAuth metadata: ${BASE_URL}/mcp/.well-known/oauth-authorization-server`);
571
+ console.log(` MCP endpoint: ${BASE_URL}/mcp`);
572
+ console.log(` Health check: ${BASE_URL}/mcp/health`);
573
+ });
package/src/index.ts CHANGED
@@ -1,107 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
4
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
-
6
- import { client } from './client.js';
7
4
  import { config } from './config.js';
5
+ import { createMcpServer } from './server-factory.js';
8
6
 
9
- // Tools
10
- import { authTools } from './tools/auth.js';
11
- import { eduflowTools } from './tools/eduflow.js';
12
- import { blocksTools } from './tools/blocks.js';
13
- import { connectionsTools } from './tools/connections.js';
14
- import { participantsTools } from './tools/participants.js';
15
- import { analyticsTools } from './tools/analytics.js';
16
- import { aiDesignerTools } from './tools/ai-designer.js';
17
- import { presentationTools } from './tools/presentation.js';
18
- import { storeTools } from './tools/store.js';
19
- import { projectsTools } from './tools/projects.js';
20
- import { resourcesTools } from './tools/resources.js';
21
- import { adminTools } from './tools/admin.js';
22
-
23
- // Resources
24
- import { BLOCK_TYPES_RESOURCE } from './resources/block-types.js';
25
- import { FLOW_SCHEMA_RESOURCE } from './resources/flow-schema.js';
26
-
27
- const server = new McpServer({
28
- name: 'lyrra-studio',
29
- version: '1.0.0',
30
- description: 'Serveur MCP pour piloter LYRRA Studio - Plateforme EdTech de création de parcours pédagogiques, conversion PDF→Audio, et store d\'audiobooks.',
31
- });
32
-
33
- // --- Register all tools ---
34
- const allTools: Record<string, { description: string; inputSchema: any; handler: (args: any) => Promise<any> }> = {
35
- ...authTools,
36
- ...eduflowTools,
37
- ...blocksTools,
38
- ...connectionsTools,
39
- ...participantsTools,
40
- ...analyticsTools,
41
- ...aiDesignerTools,
42
- ...presentationTools,
43
- ...storeTools,
44
- ...projectsTools,
45
- ...resourcesTools,
46
- ...adminTools,
47
- };
48
-
49
- for (const [name, tool] of Object.entries(allTools)) {
50
- server.tool(
51
- name,
52
- tool.description,
53
- tool.inputSchema.shape ? Object.fromEntries(
54
- Object.entries(tool.inputSchema.shape).map(([key, schema]: [string, any]) => [key, schema])
55
- ) : {},
56
- async (args: any) => {
57
- try {
58
- const result = await tool.handler(args);
59
- return {
60
- content: [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }],
61
- };
62
- } catch (error: any) {
63
- return {
64
- content: [{ type: 'text' as const, text: `Erreur: ${error.message}` }],
65
- isError: true,
66
- };
67
- }
68
- }
69
- );
70
- }
71
-
72
- // --- Register resources ---
73
- server.resource(
74
- 'block-types',
75
- BLOCK_TYPES_RESOURCE.uri,
76
- { description: BLOCK_TYPES_RESOURCE.description, mimeType: BLOCK_TYPES_RESOURCE.mimeType },
77
- async () => ({
78
- contents: [{
79
- uri: BLOCK_TYPES_RESOURCE.uri,
80
- mimeType: BLOCK_TYPES_RESOURCE.mimeType,
81
- text: JSON.stringify(BLOCK_TYPES_RESOURCE.content, null, 2),
82
- }],
83
- })
84
- );
85
-
86
- server.resource(
87
- 'flow-construction-guide',
88
- FLOW_SCHEMA_RESOURCE.uri,
89
- { description: FLOW_SCHEMA_RESOURCE.description, mimeType: FLOW_SCHEMA_RESOURCE.mimeType },
90
- async () => ({
91
- contents: [{
92
- uri: FLOW_SCHEMA_RESOURCE.uri,
93
- mimeType: FLOW_SCHEMA_RESOURCE.mimeType,
94
- text: JSON.stringify(FLOW_SCHEMA_RESOURCE.content, null, 2),
95
- }],
96
- })
97
- );
98
-
99
- // --- Start server ---
7
+ // --- Start server (stdio mode) ---
100
8
  async function main() {
101
- if (!config.clientSecret && !process.env.LYRRA_CLIENT_SECRET) {
102
- console.error('⚠️ LYRRA_CLIENT_SECRET non défini. Utilisez auth_login ou définissez les variables d\'environnement LYRRA_CLIENT_ID et LYRRA_CLIENT_SECRET.');
9
+ if (!config.clientSecret) {
10
+ console.error('⚠️ LYRRA_CLIENT_SECRET non défini. Définissez la variable d\'environnement LYRRA_CLIENT_SECRET.');
11
+ } else {
12
+ console.error('✅ LYRRA Studio MCP connecté (authentification par API Key).');
103
13
  }
104
14
 
15
+ const server = createMcpServer();
105
16
  const transport = new StdioServerTransport();
106
17
  await server.connect(transport);
107
18
  }