@sargonpiraev/hh-mcp-server 1.15.0 → 1.17.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/build/main.js +230 -34
- package/package.json +1 -1
package/build/main.js
CHANGED
|
@@ -4,9 +4,11 @@ import cors from 'cors';
|
|
|
4
4
|
import crypto from 'crypto';
|
|
5
5
|
import { z } from 'zod';
|
|
6
6
|
import { mcpServer, envSchema as baseEnvSchema } from './server.js';
|
|
7
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
8
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
7
9
|
const envSchema = baseEnvSchema.extend({
|
|
8
10
|
PORT: z.string().optional().default('3000'),
|
|
9
|
-
HOST: z.string().optional().default('
|
|
11
|
+
HOST: z.string().optional().default('localhost'),
|
|
10
12
|
HH_CLIENT_ID: z.string(),
|
|
11
13
|
HH_CLIENT_SECRET: z.string(),
|
|
12
14
|
HH_USER_AGENT: z.string(),
|
|
@@ -15,8 +17,12 @@ const envSchema = baseEnvSchema.extend({
|
|
|
15
17
|
const env = envSchema.parse(process.env);
|
|
16
18
|
const app = express();
|
|
17
19
|
const sessions = new Map();
|
|
18
|
-
|
|
19
|
-
|
|
20
|
+
const transports = {};
|
|
21
|
+
// Enable CORS for all routes and expose MCP session header
|
|
22
|
+
app.use(cors({
|
|
23
|
+
origin: '*', // Allow all origins
|
|
24
|
+
exposedHeaders: ['Mcp-Session-Id'],
|
|
25
|
+
}));
|
|
20
26
|
app.use(express.json({ limit: '10mb' }));
|
|
21
27
|
const logger = {
|
|
22
28
|
log: (...message) => {
|
|
@@ -43,19 +49,19 @@ app.get('/.well-known/oauth-protected-resource', (req, res) => {
|
|
|
43
49
|
});
|
|
44
50
|
// OAuth 2.0 Authorization Server Metadata endpoint - HeadHunter doesn't provide this, so we create it
|
|
45
51
|
app.get('/.well-known/oauth-authorization-server', (req, res) => {
|
|
52
|
+
const baseUrl = `http://${env.HOST}:${env.PORT}`;
|
|
46
53
|
res.json({
|
|
47
|
-
issuer:
|
|
48
|
-
authorization_endpoint:
|
|
49
|
-
token_endpoint:
|
|
50
|
-
registration_endpoint: `${
|
|
51
|
-
jwks_uri: `${
|
|
54
|
+
issuer: baseUrl, // This MCP server IS the authorization server now
|
|
55
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`, // Our facade endpoint
|
|
56
|
+
token_endpoint: `${baseUrl}/oauth/token`, // Our facade endpoint
|
|
57
|
+
registration_endpoint: `${baseUrl}/oauth/register`,
|
|
58
|
+
jwks_uri: `${baseUrl}/.well-known/jwks.json`,
|
|
52
59
|
response_types_supported: ['code'],
|
|
53
60
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
54
61
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
|
55
62
|
code_challenge_methods_supported: ['S256'],
|
|
56
63
|
scopes_supported: [],
|
|
57
|
-
//
|
|
58
|
-
// Real HeadHunter OAuth happens via pre-registered clients at dev.hh.ru
|
|
64
|
+
// This is now a real OAuth Authorization Server, not just metadata
|
|
59
65
|
});
|
|
60
66
|
});
|
|
61
67
|
// JWKS endpoint - HeadHunter doesn't provide this, return empty keys for now
|
|
@@ -91,16 +97,16 @@ app.post('/oauth/register', (req, res) => {
|
|
|
91
97
|
// Return pre-configured OAuth client credentials
|
|
92
98
|
// This simulates DCR but actually uses static credentials
|
|
93
99
|
const client_id = env.HH_CLIENT_ID;
|
|
94
|
-
logger.log(`DCR
|
|
95
|
-
// Return successful registration response
|
|
100
|
+
logger.log(`DCR facade: Accepting any redirect_uri for "${client_name}"`);
|
|
101
|
+
// Return successful registration response - now we accept ANY redirect_uri
|
|
96
102
|
res.json({
|
|
97
103
|
client_id,
|
|
98
104
|
client_name,
|
|
99
105
|
grant_types,
|
|
100
106
|
response_types,
|
|
101
|
-
redirect_uris, //
|
|
107
|
+
redirect_uris, // Accept client's redirect_uris as-is (facade supports any URI)
|
|
102
108
|
scope,
|
|
103
|
-
token_endpoint_auth_method: 'client_secret_basic',
|
|
109
|
+
token_endpoint_auth_method: 'client_secret_basic',
|
|
104
110
|
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
105
111
|
});
|
|
106
112
|
}
|
|
@@ -120,16 +126,142 @@ app.get('/.well-known/oauth-client-config', (req, res) => {
|
|
|
120
126
|
error_description: 'OAuth client ID not configured. Set HH_CLIENT_ID environment variable.',
|
|
121
127
|
});
|
|
122
128
|
}
|
|
129
|
+
const baseUrl = `http://${env.HOST}:${env.PORT}`;
|
|
123
130
|
res.json({
|
|
124
131
|
client_id: env.HH_CLIENT_ID,
|
|
125
|
-
authorization_endpoint:
|
|
126
|
-
token_endpoint:
|
|
132
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`, // Our facade endpoint
|
|
133
|
+
token_endpoint: `${baseUrl}/oauth/token`, // Our facade endpoint
|
|
127
134
|
scope: '', // OAuth server doesn't use explicit scopes
|
|
128
135
|
response_type: 'code',
|
|
129
136
|
redirect_uri: env.HH_REDIRECT_URI || 'http://localhost:6274/oauth/callback/debug', // Configurable redirect URI
|
|
130
137
|
code_challenge_method: 'S256', // PKCE for security
|
|
131
138
|
});
|
|
132
139
|
});
|
|
140
|
+
// Facade OAuth Authorization endpoint - accepts any redirect_uri and delegates to HeadHunter
|
|
141
|
+
app.get('/oauth/authorize', (req, res) => {
|
|
142
|
+
const { client_id, redirect_uri, state, code_challenge, code_challenge_method = 'S256' } = req.query;
|
|
143
|
+
// Validate required parameters
|
|
144
|
+
if (!client_id || !redirect_uri) {
|
|
145
|
+
return res.status(400).json({
|
|
146
|
+
error: 'invalid_request',
|
|
147
|
+
error_description: 'client_id and redirect_uri are required',
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
// Store original request parameters for later use in callback
|
|
151
|
+
const stateKey = `auth_${crypto.randomUUID()}`;
|
|
152
|
+
sessions.set(stateKey, {
|
|
153
|
+
originalRedirectUri: redirect_uri,
|
|
154
|
+
originalState: state,
|
|
155
|
+
clientId: client_id,
|
|
156
|
+
codeChallenge: code_challenge,
|
|
157
|
+
codeChallengeMethod: code_challenge_method,
|
|
158
|
+
createdAt: new Date(),
|
|
159
|
+
});
|
|
160
|
+
// Construct HeadHunter authorization URL with our callback
|
|
161
|
+
const hhAuthUrl = new URL('https://hh.ru/oauth/authorize');
|
|
162
|
+
hhAuthUrl.searchParams.set('client_id', env.HH_CLIENT_ID || '');
|
|
163
|
+
hhAuthUrl.searchParams.set('response_type', 'code');
|
|
164
|
+
hhAuthUrl.searchParams.set('redirect_uri', env.HH_REDIRECT_URI || `http://${env.HOST}:${env.PORT}/oauth/callback/debug`);
|
|
165
|
+
hhAuthUrl.searchParams.set('state', stateKey); // Use our state key
|
|
166
|
+
logger.log(`Facade authorize: Redirecting to HeadHunter with state=${stateKey}`);
|
|
167
|
+
// Redirect user to HeadHunter authorization
|
|
168
|
+
res.redirect(hhAuthUrl.toString());
|
|
169
|
+
});
|
|
170
|
+
// Facade OAuth Token endpoint - exchanges authorization code for access token
|
|
171
|
+
app.post('/oauth/token', express.urlencoded({ extended: true }), async (req, res) => {
|
|
172
|
+
const { grant_type, client_id, code, redirect_uri, code_verifier } = req.body;
|
|
173
|
+
// Validate required parameters
|
|
174
|
+
if (grant_type !== 'authorization_code' || !client_id || !code || !redirect_uri) {
|
|
175
|
+
return res.status(400).json({
|
|
176
|
+
error: 'invalid_request',
|
|
177
|
+
error_description: 'Invalid token request parameters',
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
// Use the configured redirect_uri instead of the client's redirect_uri
|
|
182
|
+
const configuredRedirectUri = env.HH_REDIRECT_URI || `http://${env.HOST}:${env.PORT}/oauth/callback/debug`;
|
|
183
|
+
// Prepare token request for HeadHunter
|
|
184
|
+
const tokenParams = new URLSearchParams({
|
|
185
|
+
grant_type: 'authorization_code',
|
|
186
|
+
client_id: env.HH_CLIENT_ID || '',
|
|
187
|
+
client_secret: env.HH_CLIENT_SECRET || '',
|
|
188
|
+
code: code,
|
|
189
|
+
redirect_uri: configuredRedirectUri, // Use our configured redirect_uri, not client's
|
|
190
|
+
});
|
|
191
|
+
// Add PKCE code_verifier if provided by client
|
|
192
|
+
if (code_verifier) {
|
|
193
|
+
tokenParams.set('code_verifier', code_verifier);
|
|
194
|
+
}
|
|
195
|
+
logger.log(`Facade token: Exchanging code with HeadHunter, redirect_uri=${configuredRedirectUri}`);
|
|
196
|
+
// Exchange code with HeadHunter token endpoint
|
|
197
|
+
const tokenResponse = await fetch('https://api.hh.ru/token', {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: {
|
|
200
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
201
|
+
'User-Agent': 'MCP-Facade-Server/1.0',
|
|
202
|
+
},
|
|
203
|
+
body: tokenParams,
|
|
204
|
+
});
|
|
205
|
+
if (!tokenResponse.ok) {
|
|
206
|
+
const errorText = await tokenResponse.text();
|
|
207
|
+
logger.error(`HeadHunter token exchange failed: ${tokenResponse.status} ${errorText}`);
|
|
208
|
+
return res.status(400).json({
|
|
209
|
+
error: 'invalid_grant',
|
|
210
|
+
error_description: 'Authorization code is invalid or expired',
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
const tokenData = (await tokenResponse.json());
|
|
214
|
+
logger.log(`Facade token: Successfully exchanged code for HeadHunter access token`);
|
|
215
|
+
// Return the token response from HeadHunter (with our issuer)
|
|
216
|
+
res.json({
|
|
217
|
+
...tokenData,
|
|
218
|
+
issuer: `http://${env.HOST}:${env.PORT}`, // Override issuer to point to our facade
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch (error) {
|
|
222
|
+
logger.error('Token exchange error:', error instanceof Error ? error.message : String(error));
|
|
223
|
+
res.status(500).json({
|
|
224
|
+
error: 'server_error',
|
|
225
|
+
error_description: 'Internal server error during token exchange',
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
});
|
|
229
|
+
// Enhanced proxy callback endpoint for facade authorization flow
|
|
230
|
+
app.get('/oauth/callback/debug', (req, res) => {
|
|
231
|
+
const { code, state, error, error_description } = req.query;
|
|
232
|
+
if (error) {
|
|
233
|
+
logger.error(`HeadHunter authorization error: ${error} - ${error_description}`);
|
|
234
|
+
return res.status(400).json({
|
|
235
|
+
error: error,
|
|
236
|
+
error_description: error_description,
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
if (!code || !state) {
|
|
240
|
+
return res.status(400).json({
|
|
241
|
+
error: 'invalid_request',
|
|
242
|
+
error_description: 'Missing authorization code or state parameter',
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// Retrieve stored authorization request
|
|
246
|
+
const authSession = sessions.get(state);
|
|
247
|
+
if (!authSession) {
|
|
248
|
+
logger.error(`No session found for state: ${state}`);
|
|
249
|
+
return res.status(400).json({
|
|
250
|
+
error: 'invalid_state',
|
|
251
|
+
error_description: 'Invalid or expired authorization request',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
// Clean up session
|
|
255
|
+
sessions.delete(state);
|
|
256
|
+
// Redirect back to original client with authorization code
|
|
257
|
+
const callbackUrl = new URL(authSession.originalRedirectUri);
|
|
258
|
+
callbackUrl.searchParams.set('code', code);
|
|
259
|
+
if (authSession.originalState) {
|
|
260
|
+
callbackUrl.searchParams.set('state', authSession.originalState);
|
|
261
|
+
}
|
|
262
|
+
logger.log(`Facade callback: Redirecting to original client: ${callbackUrl.toString()}`);
|
|
263
|
+
res.redirect(callbackUrl.toString());
|
|
264
|
+
});
|
|
133
265
|
// Session management middleware
|
|
134
266
|
function getOrCreateSession(req) {
|
|
135
267
|
let sessionId = req.get('Mcp-Session-Id');
|
|
@@ -193,24 +325,64 @@ async function requireAuth(req, res, next) {
|
|
|
193
325
|
// MCP endpoint - POST for JSON-RPC requests
|
|
194
326
|
app.post('/mcp', requireAuth, async (req, res) => {
|
|
195
327
|
try {
|
|
196
|
-
const sessionId =
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
328
|
+
const sessionId = req.get('Mcp-Session-Id');
|
|
329
|
+
logger.log(sessionId ? `MCP request for session: ${sessionId}` : 'New MCP request');
|
|
330
|
+
let transport;
|
|
331
|
+
if (sessionId && transports[sessionId]) {
|
|
332
|
+
// Reuse existing transport
|
|
333
|
+
transport = transports[sessionId];
|
|
334
|
+
}
|
|
335
|
+
else if (!sessionId && isInitializeRequest(req.body)) {
|
|
336
|
+
// New initialization request
|
|
337
|
+
transport = new StreamableHTTPServerTransport({
|
|
338
|
+
sessionIdGenerator: () => crypto.randomUUID(),
|
|
339
|
+
enableJsonResponse: true, // Enable JSON response mode for simplicity
|
|
340
|
+
onsessioninitialized: (sessionId) => {
|
|
341
|
+
logger.log(`Session initialized with ID: ${sessionId}`);
|
|
342
|
+
transports[sessionId] = transport;
|
|
343
|
+
},
|
|
344
|
+
});
|
|
345
|
+
// Set up cleanup on close
|
|
346
|
+
transport.onclose = () => {
|
|
347
|
+
const sid = transport.sessionId;
|
|
348
|
+
if (sid && transports[sid]) {
|
|
349
|
+
logger.log(`Transport closed for session ${sid}`);
|
|
350
|
+
delete transports[sid];
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
// Connect transport to MCP server BEFORE handling request
|
|
354
|
+
await mcpServer.connect(transport);
|
|
355
|
+
await transport.handleRequest(req, res, req.body);
|
|
356
|
+
return; // Already handled
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
// Invalid request - no session ID or not initialization request
|
|
360
|
+
res.status(400).json({
|
|
361
|
+
jsonrpc: '2.0',
|
|
362
|
+
error: {
|
|
363
|
+
code: -32000,
|
|
364
|
+
message: 'Bad Request: No valid session ID provided',
|
|
365
|
+
},
|
|
366
|
+
id: null,
|
|
367
|
+
});
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
// Handle the request with existing transport
|
|
371
|
+
await transport.handleRequest(req, res, req.body);
|
|
202
372
|
}
|
|
203
373
|
catch (error) {
|
|
204
|
-
|
|
205
|
-
res.
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
374
|
+
logger.error('MCP request error:', error instanceof Error ? error.message : String(error));
|
|
375
|
+
if (!res.headersSent) {
|
|
376
|
+
res.status(500).json({
|
|
377
|
+
jsonrpc: '2.0',
|
|
378
|
+
error: {
|
|
379
|
+
code: -32603,
|
|
380
|
+
message: 'Internal error',
|
|
381
|
+
data: error instanceof Error ? error.message : String(error),
|
|
382
|
+
},
|
|
383
|
+
id: req.body?.id || null,
|
|
384
|
+
});
|
|
385
|
+
}
|
|
214
386
|
}
|
|
215
387
|
});
|
|
216
388
|
// MCP endpoint - GET for SSE streams
|
|
@@ -281,12 +453,36 @@ async function main() {
|
|
|
281
453
|
}
|
|
282
454
|
}
|
|
283
455
|
// Graceful shutdown
|
|
284
|
-
process.on('SIGINT', () => {
|
|
456
|
+
process.on('SIGINT', async () => {
|
|
285
457
|
logger.log('Shutting down MCP Server...');
|
|
458
|
+
// Close all active transports
|
|
459
|
+
for (const sessionId in transports) {
|
|
460
|
+
try {
|
|
461
|
+
logger.log(`Closing transport for session ${sessionId}`);
|
|
462
|
+
await transports[sessionId].close();
|
|
463
|
+
delete transports[sessionId];
|
|
464
|
+
}
|
|
465
|
+
catch (error) {
|
|
466
|
+
logger.error(`Error closing transport for session ${sessionId}:`, error instanceof Error ? error.message : String(error));
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
logger.log('Server shutdown complete');
|
|
286
470
|
process.exit(0);
|
|
287
471
|
});
|
|
288
|
-
process.on('SIGTERM', () => {
|
|
472
|
+
process.on('SIGTERM', async () => {
|
|
289
473
|
logger.log('Shutting down MCP Server...');
|
|
474
|
+
// Close all active transports
|
|
475
|
+
for (const sessionId in transports) {
|
|
476
|
+
try {
|
|
477
|
+
logger.log(`Closing transport for session ${sessionId}`);
|
|
478
|
+
await transports[sessionId].close();
|
|
479
|
+
delete transports[sessionId];
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
logger.error(`Error closing transport for session ${sessionId}:`, error instanceof Error ? error.message : String(error));
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
logger.log('Server shutdown complete');
|
|
290
486
|
process.exit(0);
|
|
291
487
|
});
|
|
292
488
|
main().catch((error) => {
|
package/package.json
CHANGED