@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.
- package/Dockerfile +16 -0
- package/README.md +59 -4
- package/dist/client.d.ts +7 -1
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +5 -5
- package/dist/client.js.map +1 -1
- package/dist/config.d.ts +1 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +1 -0
- package/dist/config.js.map +1 -1
- package/dist/http-server.d.ts +8 -0
- package/dist/http-server.d.ts.map +1 -0
- package/dist/http-server.js +466 -0
- package/dist/http-server.js.map +1 -0
- package/dist/index.js +8 -71
- package/dist/index.js.map +1 -1
- package/dist/server-factory.d.ts +8 -0
- package/dist/server-factory.d.ts.map +1 -0
- package/dist/server-factory.js +82 -0
- package/dist/server-factory.js.map +1 -0
- package/dist/tools/admin.d.ts +132 -0
- package/dist/tools/admin.d.ts.map +1 -1
- package/dist/tools/admin.js +105 -101
- package/dist/tools/admin.js.map +1 -1
- package/dist/tools/ai-designer.d.ts +148 -0
- package/dist/tools/ai-designer.d.ts.map +1 -1
- package/dist/tools/ai-designer.js +80 -76
- package/dist/tools/ai-designer.js.map +1 -1
- package/dist/tools/analytics.d.ts +47 -0
- package/dist/tools/analytics.d.ts.map +1 -1
- package/dist/tools/analytics.js +38 -34
- package/dist/tools/analytics.js.map +1 -1
- package/dist/tools/auth.d.ts +30 -0
- package/dist/tools/auth.d.ts.map +1 -1
- package/dist/tools/auth.js +31 -27
- package/dist/tools/auth.js.map +1 -1
- package/dist/tools/blocks.d.ts +200 -0
- package/dist/tools/blocks.d.ts.map +1 -1
- package/dist/tools/blocks.js +154 -150
- package/dist/tools/blocks.js.map +1 -1
- package/dist/tools/connections.d.ts +86 -0
- package/dist/tools/connections.d.ts.map +1 -1
- package/dist/tools/connections.js +70 -66
- package/dist/tools/connections.js.map +1 -1
- package/dist/tools/eduflow.d.ts +223 -0
- package/dist/tools/eduflow.d.ts.map +1 -1
- package/dist/tools/eduflow.js +114 -93
- package/dist/tools/eduflow.js.map +1 -1
- package/dist/tools/participants.d.ts +110 -0
- package/dist/tools/participants.d.ts.map +1 -1
- package/dist/tools/participants.js +62 -58
- package/dist/tools/participants.js.map +1 -1
- package/dist/tools/presentation.d.ts +116 -0
- package/dist/tools/presentation.d.ts.map +1 -1
- package/dist/tools/presentation.js +51 -47
- package/dist/tools/presentation.js.map +1 -1
- package/dist/tools/projects.d.ts +65 -0
- package/dist/tools/projects.d.ts.map +1 -1
- package/dist/tools/projects.js +48 -44
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/resources.d.ts +46 -0
- package/dist/tools/resources.d.ts.map +1 -1
- package/dist/tools/resources.js +32 -28
- package/dist/tools/resources.js.map +1 -1
- package/dist/tools/store.d.ts +62 -0
- package/dist/tools/store.d.ts.map +1 -1
- package/dist/tools/store.js +59 -55
- package/dist/tools/store.js.map +1 -1
- package/mcp-config.example.json +4 -5
- package/package.json +7 -2
- package/src/client.ts +12 -5
- package/src/config.ts +1 -0
- package/src/http-server.ts +573 -0
- package/src/index.ts +7 -96
- package/src/server-factory.ts +109 -0
- package/src/tools/admin.ts +20 -14
- package/src/tools/ai-designer.ts +16 -10
- package/src/tools/analytics.ts +13 -7
- package/src/tools/auth.ts +32 -26
- package/src/tools/blocks.ts +18 -12
- package/src/tools/connections.ts +14 -8
- package/src/tools/eduflow.ts +36 -12
- package/src/tools/participants.ts +15 -9
- package/src/tools/presentation.ts +12 -6
- package/src/tools/projects.ts +14 -8
- package/src/tools/resources.ts +12 -6
- 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
|
-
//
|
|
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
|
|
102
|
-
console.error('⚠️ LYRRA_CLIENT_SECRET non défini.
|
|
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
|
}
|