@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.
Files changed (2) hide show
  1. package/build/main.js +230 -34
  2. 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('127.0.0.1'),
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
- // Enable CORS for all routes
19
- app.use(cors());
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: 'https://api.hh.ru',
48
- authorization_endpoint: 'https://hh.ru/oauth/authorize',
49
- token_endpoint: 'https://api.hh.ru/token',
50
- registration_endpoint: `${req.protocol}://${req.get('host')}/oauth/register`,
51
- jwks_uri: `${req.protocol}://${req.get('host')}/.well-known/jwks.json`,
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
- // Note: This endpoint simulates DCR for Inspector compatibility
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 simulation: Returning HeadHunter client credentials for "${client_name}"`);
95
- // Return successful registration response with HeadHunter client_id
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, // Use the redirect_uris from the request
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', // HeadHunter uses client_secret
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: 'https://hh.ru/oauth/authorize',
126
- token_endpoint: 'https://api.hh.ru/token',
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 = getOrCreateSession(req);
197
- res.set('Mcp-Session-Id', sessionId);
198
- res.set('Content-Type', 'application/json');
199
- // Handle JSON-RPC request through MCP server
200
- const result = await mcpServer.server.request(req.body, z.any());
201
- res.json(result);
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
- console.error('MCP request error:', error);
205
- res.status(500).json({
206
- jsonrpc: '2.0',
207
- error: {
208
- code: -32603,
209
- message: 'Internal error',
210
- data: error instanceof Error ? error.message : String(error),
211
- },
212
- id: req.body?.id || null,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sargonpiraev/hh-mcp-server",
3
- "version": "1.15.0",
3
+ "version": "1.17.0",
4
4
  "description": "HeadHunter API MCP Server provides seamless integration between AI tools and HeadHunter's comprehensive job search platform.",
5
5
  "keywords": [
6
6
  "mcp",