@sargonpiraev/hh-mcp-server 1.14.0 → 1.16.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 ADDED
@@ -0,0 +1,421 @@
1
+ #!/usr/bin/env node
2
+ import express from 'express';
3
+ import cors from 'cors';
4
+ import crypto from 'crypto';
5
+ import { z } from 'zod';
6
+ import { mcpServer, envSchema as baseEnvSchema } from './server.js';
7
+ const envSchema = baseEnvSchema.extend({
8
+ PORT: z.string().optional().default('3000'),
9
+ HOST: z.string().optional().default('localhost'),
10
+ HH_CLIENT_ID: z.string(),
11
+ HH_CLIENT_SECRET: z.string(),
12
+ HH_USER_AGENT: z.string(),
13
+ HH_REDIRECT_URI: z.string(),
14
+ });
15
+ const env = envSchema.parse(process.env);
16
+ const app = express();
17
+ const sessions = new Map();
18
+ // Enable CORS for all routes
19
+ app.use(cors());
20
+ app.use(express.json({ limit: '10mb' }));
21
+ const logger = {
22
+ log: (...message) => {
23
+ console.log('[MCP]', ...message);
24
+ },
25
+ error: (...message) => {
26
+ console.error('[MCP ERROR]', ...message);
27
+ },
28
+ debug: (...message) => {
29
+ console.log('[MCP DEBUG]', ...message);
30
+ },
31
+ };
32
+ // OAuth 2.0 Protected Resource Metadata endpoint
33
+ app.get('/.well-known/oauth-protected-resource', (req, res) => {
34
+ const baseUrl = `http://${env.HOST}:${env.PORT}`;
35
+ res.json({
36
+ resource: baseUrl,
37
+ authorization_servers: [baseUrl], // This MCP server provides authorization server metadata
38
+ jwks_uri: `${baseUrl}/.well-known/jwks.json`,
39
+ bearer_methods_supported: ['header'],
40
+ // Note: HeadHunter does not support dynamic client registration
41
+ // resource_registration_endpoint is omitted to indicate this
42
+ });
43
+ });
44
+ // OAuth 2.0 Authorization Server Metadata endpoint - HeadHunter doesn't provide this, so we create it
45
+ app.get('/.well-known/oauth-authorization-server', (req, res) => {
46
+ const baseUrl = `http://${env.HOST}:${env.PORT}`;
47
+ res.json({
48
+ issuer: baseUrl, // This MCP server IS the authorization server now
49
+ authorization_endpoint: `${baseUrl}/oauth/authorize`, // Our facade endpoint
50
+ token_endpoint: `${baseUrl}/oauth/token`, // Our facade endpoint
51
+ registration_endpoint: `${baseUrl}/oauth/register`,
52
+ jwks_uri: `${baseUrl}/.well-known/jwks.json`,
53
+ response_types_supported: ['code'],
54
+ grant_types_supported: ['authorization_code', 'refresh_token'],
55
+ token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
56
+ code_challenge_methods_supported: ['S256'],
57
+ scopes_supported: [],
58
+ // This is now a real OAuth Authorization Server, not just metadata
59
+ });
60
+ });
61
+ // JWKS endpoint - HeadHunter doesn't provide this, return empty keys for now
62
+ app.get('/.well-known/jwks.json', (req, res) => {
63
+ res.json({
64
+ keys: [],
65
+ });
66
+ });
67
+ // OAuth 2.0 Dynamic Client Registration endpoint (HeadHunter compatibility)
68
+ app.post('/oauth/register', (req, res) => {
69
+ try {
70
+ const { client_name, grant_types = ['authorization_code'], response_types = ['code'], redirect_uris, scope = '', } = req.body;
71
+ // Validate required fields
72
+ if (!client_name) {
73
+ return res.status(400).json({
74
+ error: 'invalid_client_metadata',
75
+ error_description: 'client_name is required',
76
+ });
77
+ }
78
+ if (!redirect_uris || !Array.isArray(redirect_uris) || redirect_uris.length === 0) {
79
+ return res.status(400).json({
80
+ error: 'invalid_redirect_uri',
81
+ error_description: 'redirect_uris is required and must be a non-empty array',
82
+ });
83
+ }
84
+ // Check if OAuth credentials are configured
85
+ if (!env.HH_CLIENT_ID) {
86
+ return res.status(503).json({
87
+ error: 'oauth_not_configured',
88
+ error_description: 'OAuth client credentials not configured. Set HH_CLIENT_ID and HH_CLIENT_SECRET environment variables.',
89
+ });
90
+ }
91
+ // Return pre-configured OAuth client credentials
92
+ // This simulates DCR but actually uses static credentials
93
+ const client_id = env.HH_CLIENT_ID;
94
+ logger.log(`DCR facade: Accepting any redirect_uri for "${client_name}"`);
95
+ // Return successful registration response - now we accept ANY redirect_uri
96
+ res.json({
97
+ client_id,
98
+ client_name,
99
+ grant_types,
100
+ response_types,
101
+ redirect_uris, // Accept client's redirect_uris as-is (facade supports any URI)
102
+ scope,
103
+ token_endpoint_auth_method: 'client_secret_basic',
104
+ client_id_issued_at: Math.floor(Date.now() / 1000),
105
+ });
106
+ }
107
+ catch (error) {
108
+ logger.error('DCR simulation error:', error instanceof Error ? error.message : String(error));
109
+ res.status(400).json({
110
+ error: 'invalid_request',
111
+ error_description: 'Invalid client registration request',
112
+ });
113
+ }
114
+ });
115
+ // OAuth Client Configuration endpoint for MCP Inspector
116
+ app.get('/.well-known/oauth-client-config', (req, res) => {
117
+ if (!env.HH_CLIENT_ID) {
118
+ return res.status(503).json({
119
+ error: 'oauth_not_configured',
120
+ error_description: 'OAuth client ID not configured. Set HH_CLIENT_ID environment variable.',
121
+ });
122
+ }
123
+ const baseUrl = `http://${env.HOST}:${env.PORT}`;
124
+ res.json({
125
+ client_id: env.HH_CLIENT_ID,
126
+ authorization_endpoint: `${baseUrl}/oauth/authorize`, // Our facade endpoint
127
+ token_endpoint: `${baseUrl}/oauth/token`, // Our facade endpoint
128
+ scope: '', // OAuth server doesn't use explicit scopes
129
+ response_type: 'code',
130
+ redirect_uri: env.HH_REDIRECT_URI || 'http://localhost:6274/oauth/callback/debug', // Configurable redirect URI
131
+ code_challenge_method: 'S256', // PKCE for security
132
+ });
133
+ });
134
+ // Facade OAuth Authorization endpoint - accepts any redirect_uri and delegates to HeadHunter
135
+ app.get('/oauth/authorize', (req, res) => {
136
+ const { client_id, redirect_uri, state, code_challenge, code_challenge_method = 'S256' } = req.query;
137
+ // Validate required parameters
138
+ if (!client_id || !redirect_uri) {
139
+ return res.status(400).json({
140
+ error: 'invalid_request',
141
+ error_description: 'client_id and redirect_uri are required',
142
+ });
143
+ }
144
+ // Store original request parameters for later use in callback
145
+ const stateKey = `auth_${crypto.randomUUID()}`;
146
+ sessions.set(stateKey, {
147
+ originalRedirectUri: redirect_uri,
148
+ originalState: state,
149
+ clientId: client_id,
150
+ codeChallenge: code_challenge,
151
+ codeChallengeMethod: code_challenge_method,
152
+ createdAt: new Date(),
153
+ });
154
+ // Construct HeadHunter authorization URL with our callback
155
+ const hhAuthUrl = new URL('https://hh.ru/oauth/authorize');
156
+ hhAuthUrl.searchParams.set('client_id', env.HH_CLIENT_ID || '');
157
+ hhAuthUrl.searchParams.set('response_type', 'code');
158
+ hhAuthUrl.searchParams.set('redirect_uri', env.HH_REDIRECT_URI || `http://${env.HOST}:${env.PORT}/oauth/callback/debug`);
159
+ hhAuthUrl.searchParams.set('state', stateKey); // Use our state key
160
+ logger.log(`Facade authorize: Redirecting to HeadHunter with state=${stateKey}`);
161
+ // Redirect user to HeadHunter authorization
162
+ res.redirect(hhAuthUrl.toString());
163
+ });
164
+ // Facade OAuth Token endpoint - exchanges authorization code for access token
165
+ app.post('/oauth/token', express.urlencoded({ extended: true }), async (req, res) => {
166
+ const { grant_type, client_id, code, redirect_uri, code_verifier } = req.body;
167
+ // Validate required parameters
168
+ if (grant_type !== 'authorization_code' || !client_id || !code || !redirect_uri) {
169
+ return res.status(400).json({
170
+ error: 'invalid_request',
171
+ error_description: 'Invalid token request parameters',
172
+ });
173
+ }
174
+ try {
175
+ // Use the configured redirect_uri instead of the client's redirect_uri
176
+ const configuredRedirectUri = env.HH_REDIRECT_URI || `http://${env.HOST}:${env.PORT}/oauth/callback/debug`;
177
+ // Prepare token request for HeadHunter
178
+ const tokenParams = new URLSearchParams({
179
+ grant_type: 'authorization_code',
180
+ client_id: env.HH_CLIENT_ID || '',
181
+ client_secret: env.HH_CLIENT_SECRET || '',
182
+ code: code,
183
+ redirect_uri: configuredRedirectUri, // Use our configured redirect_uri, not client's
184
+ });
185
+ // Add PKCE code_verifier if provided by client
186
+ if (code_verifier) {
187
+ tokenParams.set('code_verifier', code_verifier);
188
+ }
189
+ logger.log(`Facade token: Exchanging code with HeadHunter, redirect_uri=${configuredRedirectUri}`);
190
+ // Exchange code with HeadHunter token endpoint
191
+ const tokenResponse = await fetch('https://api.hh.ru/token', {
192
+ method: 'POST',
193
+ headers: {
194
+ 'Content-Type': 'application/x-www-form-urlencoded',
195
+ 'User-Agent': 'MCP-Facade-Server/1.0',
196
+ },
197
+ body: tokenParams,
198
+ });
199
+ if (!tokenResponse.ok) {
200
+ const errorText = await tokenResponse.text();
201
+ logger.error(`HeadHunter token exchange failed: ${tokenResponse.status} ${errorText}`);
202
+ return res.status(400).json({
203
+ error: 'invalid_grant',
204
+ error_description: 'Authorization code is invalid or expired',
205
+ });
206
+ }
207
+ const tokenData = (await tokenResponse.json());
208
+ logger.log(`Facade token: Successfully exchanged code for HeadHunter access token`);
209
+ // Return the token response from HeadHunter (with our issuer)
210
+ res.json({
211
+ ...tokenData,
212
+ issuer: `http://${env.HOST}:${env.PORT}`, // Override issuer to point to our facade
213
+ });
214
+ }
215
+ catch (error) {
216
+ logger.error('Token exchange error:', error instanceof Error ? error.message : String(error));
217
+ res.status(500).json({
218
+ error: 'server_error',
219
+ error_description: 'Internal server error during token exchange',
220
+ });
221
+ }
222
+ });
223
+ // Enhanced proxy callback endpoint for facade authorization flow
224
+ app.get('/oauth/callback/debug', (req, res) => {
225
+ const { code, state, error, error_description } = req.query;
226
+ if (error) {
227
+ logger.error(`HeadHunter authorization error: ${error} - ${error_description}`);
228
+ return res.status(400).json({
229
+ error: error,
230
+ error_description: error_description,
231
+ });
232
+ }
233
+ if (!code || !state) {
234
+ return res.status(400).json({
235
+ error: 'invalid_request',
236
+ error_description: 'Missing authorization code or state parameter',
237
+ });
238
+ }
239
+ // Retrieve stored authorization request
240
+ const authSession = sessions.get(state);
241
+ if (!authSession) {
242
+ logger.error(`No session found for state: ${state}`);
243
+ return res.status(400).json({
244
+ error: 'invalid_state',
245
+ error_description: 'Invalid or expired authorization request',
246
+ });
247
+ }
248
+ // Clean up session
249
+ sessions.delete(state);
250
+ // Redirect back to original client with authorization code
251
+ const callbackUrl = new URL(authSession.originalRedirectUri);
252
+ callbackUrl.searchParams.set('code', code);
253
+ if (authSession.originalState) {
254
+ callbackUrl.searchParams.set('state', authSession.originalState);
255
+ }
256
+ logger.log(`Facade callback: Redirecting to original client: ${callbackUrl.toString()}`);
257
+ res.redirect(callbackUrl.toString());
258
+ });
259
+ // Session management middleware
260
+ function getOrCreateSession(req) {
261
+ let sessionId = req.get('Mcp-Session-Id');
262
+ if (!sessionId || !sessions.has(sessionId)) {
263
+ sessionId = crypto.randomUUID();
264
+ sessions.set(sessionId, {
265
+ id: sessionId,
266
+ createdAt: new Date(),
267
+ lastAccessAt: new Date(),
268
+ });
269
+ }
270
+ else {
271
+ const session = sessions.get(sessionId);
272
+ if (session) {
273
+ session.lastAccessAt = new Date();
274
+ }
275
+ }
276
+ return sessionId;
277
+ }
278
+ // Authorization middleware
279
+ async function requireAuth(req, res, next) {
280
+ const authHeader = req.get('Authorization');
281
+ if (!authHeader || !authHeader.startsWith('Bearer ')) {
282
+ return res
283
+ .status(401)
284
+ .set('WWW-Authenticate', `Bearer realm="MCP Server", resource="http://${env.HOST}:${env.PORT}/.well-known/oauth-protected-resource"`)
285
+ .json({ error: 'Authorization required' });
286
+ }
287
+ const token = authHeader.substring(7);
288
+ if (!token) {
289
+ return res.status(401).json({ error: 'Invalid token' });
290
+ }
291
+ // Validate HeadHunter access token
292
+ try {
293
+ const response = await fetch('https://api.hh.ru/me', {
294
+ headers: {
295
+ Authorization: `Bearer ${token}`,
296
+ 'User-Agent': 'MCP-Server/1.0',
297
+ },
298
+ });
299
+ if (!response.ok) {
300
+ logger.debug(`Token validation failed: ${response.status} ${response.statusText}`);
301
+ return res
302
+ .status(401)
303
+ .set('WWW-Authenticate', `Bearer realm="MCP Server", resource="http://${env.HOST}:${env.PORT}/.well-known/oauth-protected-resource", error="invalid_token"`)
304
+ .json({ error: 'Invalid or expired token' });
305
+ }
306
+ const userInfo = (await response.json());
307
+ const userEmail = userInfo.email;
308
+ const userId = userInfo.id;
309
+ logger.debug(`Authenticated user: ${userEmail || userId}`);
310
+ // Store user info in request for later use
311
+ req.user = userInfo;
312
+ next();
313
+ }
314
+ catch (error) {
315
+ logger.error('Token validation error:', error instanceof Error ? error.message : String(error));
316
+ return res.status(500).json({ error: 'Token validation failed' });
317
+ }
318
+ }
319
+ // MCP endpoint - POST for JSON-RPC requests
320
+ app.post('/mcp', requireAuth, async (req, res) => {
321
+ try {
322
+ const sessionId = getOrCreateSession(req);
323
+ res.set('Mcp-Session-Id', sessionId);
324
+ res.set('Content-Type', 'application/json');
325
+ // Handle JSON-RPC request through MCP server
326
+ const result = await mcpServer.server.request(req.body, z.any());
327
+ res.json(result);
328
+ }
329
+ catch (error) {
330
+ console.error('MCP request error:', error);
331
+ res.status(500).json({
332
+ jsonrpc: '2.0',
333
+ error: {
334
+ code: -32603,
335
+ message: 'Internal error',
336
+ data: error instanceof Error ? error.message : String(error),
337
+ },
338
+ id: req.body?.id || null,
339
+ });
340
+ }
341
+ });
342
+ // MCP endpoint - GET for SSE streams
343
+ app.get('/mcp', requireAuth, (req, res) => {
344
+ const sessionId = getOrCreateSession(req);
345
+ res.writeHead(200, {
346
+ 'Content-Type': 'text/event-stream',
347
+ 'Cache-Control': 'no-cache',
348
+ Connection: 'keep-alive',
349
+ 'Access-Control-Allow-Origin': req.get('Origin') || '*',
350
+ 'Access-Control-Allow-Headers': 'Authorization, Mcp-Session-Id',
351
+ 'Mcp-Session-Id': sessionId,
352
+ });
353
+ // Send initial connection event
354
+ res.write(`id: ${Date.now()}\n`);
355
+ res.write(`event: connected\n`);
356
+ res.write(`data: {"sessionId": "${sessionId}"}\n\n`);
357
+ // Handle client disconnect
358
+ req.on('close', () => {
359
+ res.end();
360
+ });
361
+ // Keep connection alive
362
+ const keepAlive = setInterval(() => {
363
+ res.write(`id: ${Date.now()}\n`);
364
+ res.write(`event: heartbeat\n`);
365
+ res.write(`data: {"timestamp": "${new Date().toISOString()}"}\n\n`);
366
+ }, 30000);
367
+ req.on('close', () => {
368
+ clearInterval(keepAlive);
369
+ });
370
+ });
371
+ // Session termination endpoint
372
+ app.delete('/mcp', requireAuth, (req, res) => {
373
+ const sessionId = req.get('Mcp-Session-Id');
374
+ if (sessionId && sessions.has(sessionId)) {
375
+ sessions.delete(sessionId);
376
+ res.json({ message: 'Session terminated' });
377
+ }
378
+ else {
379
+ res.status(404).json({ error: 'Session not found' });
380
+ }
381
+ });
382
+ // OAuth endpoints are handled by the authorization server directly:
383
+ // - Authorization: https://api.hh.ru/oauth/authorize (adjusted for auth domain)
384
+ // - Token: https://api.hh.ru/token
385
+ // - Inspector will communicate with the authorization server directly for OAuth flow
386
+ // Health check endpoint
387
+ app.get('/health', (req, res) => {
388
+ res.json({
389
+ status: 'healthy',
390
+ timestamp: new Date().toISOString(),
391
+ version: '',
392
+ });
393
+ });
394
+ async function main() {
395
+ try {
396
+ const port = parseInt(env.PORT);
397
+ const host = env.HOST;
398
+ app.listen(port, host, () => {
399
+ logger.log(` MCP Server (OAuth HTTP) started on http://${host}:${port}`);
400
+ logger.log(`MCP endpoint: http://${host}:${port}/mcp`);
401
+ logger.log(`OAuth metadata: http://${host}:${port}/.well-known/oauth-protected-resource`);
402
+ });
403
+ }
404
+ catch (error) {
405
+ console.error('Server startup error:', error);
406
+ process.exit(1);
407
+ }
408
+ }
409
+ // Graceful shutdown
410
+ process.on('SIGINT', () => {
411
+ logger.log('Shutting down MCP Server...');
412
+ process.exit(0);
413
+ });
414
+ process.on('SIGTERM', () => {
415
+ logger.log('Shutting down MCP Server...');
416
+ process.exit(0);
417
+ });
418
+ main().catch((error) => {
419
+ console.error('Server error:', error);
420
+ process.exit(1);
421
+ });
@@ -0,0 +1,27 @@
1
+ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
2
+ import { z } from 'zod';
3
+ import { AxiosInstance } from 'axios';
4
+ export declare const envSchema: z.ZodObject<{
5
+ HH_CLIENT_ID: z.ZodString;
6
+ HH_CLIENT_SECRET: z.ZodString;
7
+ HH_USER_AGENT: z.ZodString;
8
+ HH_REDIRECT_URI: z.ZodString;
9
+ }, "strip", z.ZodTypeAny, {
10
+ HH_CLIENT_ID: string;
11
+ HH_CLIENT_SECRET: string;
12
+ HH_USER_AGENT: string;
13
+ HH_REDIRECT_URI: string;
14
+ }, {
15
+ HH_CLIENT_ID: string;
16
+ HH_CLIENT_SECRET: string;
17
+ HH_USER_AGENT: string;
18
+ HH_REDIRECT_URI: string;
19
+ }>;
20
+ export declare const mcpServer: McpServer;
21
+ export declare const env: {
22
+ HH_CLIENT_ID: string;
23
+ HH_CLIENT_SECRET: string;
24
+ HH_USER_AGENT: string;
25
+ HH_REDIRECT_URI: string;
26
+ };
27
+ export declare const apiClient: AxiosInstance;