@sargonpiraev/hh-mcp-server 1.15.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 +140 -14
- package/package.json +1 -1
package/build/main.js
CHANGED
|
@@ -6,7 +6,7 @@ import { z } from 'zod';
|
|
|
6
6
|
import { mcpServer, envSchema as baseEnvSchema } from './server.js';
|
|
7
7
|
const envSchema = baseEnvSchema.extend({
|
|
8
8
|
PORT: z.string().optional().default('3000'),
|
|
9
|
-
HOST: z.string().optional().default('
|
|
9
|
+
HOST: z.string().optional().default('localhost'),
|
|
10
10
|
HH_CLIENT_ID: z.string(),
|
|
11
11
|
HH_CLIENT_SECRET: z.string(),
|
|
12
12
|
HH_USER_AGENT: z.string(),
|
|
@@ -43,19 +43,19 @@ app.get('/.well-known/oauth-protected-resource', (req, res) => {
|
|
|
43
43
|
});
|
|
44
44
|
// OAuth 2.0 Authorization Server Metadata endpoint - HeadHunter doesn't provide this, so we create it
|
|
45
45
|
app.get('/.well-known/oauth-authorization-server', (req, res) => {
|
|
46
|
+
const baseUrl = `http://${env.HOST}:${env.PORT}`;
|
|
46
47
|
res.json({
|
|
47
|
-
issuer:
|
|
48
|
-
authorization_endpoint:
|
|
49
|
-
token_endpoint:
|
|
50
|
-
registration_endpoint: `${
|
|
51
|
-
jwks_uri: `${
|
|
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`,
|
|
52
53
|
response_types_supported: ['code'],
|
|
53
54
|
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
54
55
|
token_endpoint_auth_methods_supported: ['client_secret_post', 'client_secret_basic'],
|
|
55
56
|
code_challenge_methods_supported: ['S256'],
|
|
56
57
|
scopes_supported: [],
|
|
57
|
-
//
|
|
58
|
-
// Real HeadHunter OAuth happens via pre-registered clients at dev.hh.ru
|
|
58
|
+
// This is now a real OAuth Authorization Server, not just metadata
|
|
59
59
|
});
|
|
60
60
|
});
|
|
61
61
|
// JWKS endpoint - HeadHunter doesn't provide this, return empty keys for now
|
|
@@ -91,16 +91,16 @@ app.post('/oauth/register', (req, res) => {
|
|
|
91
91
|
// Return pre-configured OAuth client credentials
|
|
92
92
|
// This simulates DCR but actually uses static credentials
|
|
93
93
|
const client_id = env.HH_CLIENT_ID;
|
|
94
|
-
logger.log(`DCR
|
|
95
|
-
// Return successful registration response
|
|
94
|
+
logger.log(`DCR facade: Accepting any redirect_uri for "${client_name}"`);
|
|
95
|
+
// Return successful registration response - now we accept ANY redirect_uri
|
|
96
96
|
res.json({
|
|
97
97
|
client_id,
|
|
98
98
|
client_name,
|
|
99
99
|
grant_types,
|
|
100
100
|
response_types,
|
|
101
|
-
redirect_uris, //
|
|
101
|
+
redirect_uris, // Accept client's redirect_uris as-is (facade supports any URI)
|
|
102
102
|
scope,
|
|
103
|
-
token_endpoint_auth_method: 'client_secret_basic',
|
|
103
|
+
token_endpoint_auth_method: 'client_secret_basic',
|
|
104
104
|
client_id_issued_at: Math.floor(Date.now() / 1000),
|
|
105
105
|
});
|
|
106
106
|
}
|
|
@@ -120,16 +120,142 @@ app.get('/.well-known/oauth-client-config', (req, res) => {
|
|
|
120
120
|
error_description: 'OAuth client ID not configured. Set HH_CLIENT_ID environment variable.',
|
|
121
121
|
});
|
|
122
122
|
}
|
|
123
|
+
const baseUrl = `http://${env.HOST}:${env.PORT}`;
|
|
123
124
|
res.json({
|
|
124
125
|
client_id: env.HH_CLIENT_ID,
|
|
125
|
-
authorization_endpoint:
|
|
126
|
-
token_endpoint:
|
|
126
|
+
authorization_endpoint: `${baseUrl}/oauth/authorize`, // Our facade endpoint
|
|
127
|
+
token_endpoint: `${baseUrl}/oauth/token`, // Our facade endpoint
|
|
127
128
|
scope: '', // OAuth server doesn't use explicit scopes
|
|
128
129
|
response_type: 'code',
|
|
129
130
|
redirect_uri: env.HH_REDIRECT_URI || 'http://localhost:6274/oauth/callback/debug', // Configurable redirect URI
|
|
130
131
|
code_challenge_method: 'S256', // PKCE for security
|
|
131
132
|
});
|
|
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
|
+
});
|
|
133
259
|
// Session management middleware
|
|
134
260
|
function getOrCreateSession(req) {
|
|
135
261
|
let sessionId = req.get('Mcp-Session-Id');
|
package/package.json
CHANGED