iflow-mcp_gmen1057-headhunter-mcp-server 0.1.0__py3-none-any.whl

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.
server.py ADDED
@@ -0,0 +1,496 @@
1
+ #!/usr/bin/env python3
2
+ """HeadHunter MCP Server.
3
+
4
+ This module provides a Model Context Protocol (MCP) server for interacting with
5
+ the HeadHunter job search API. The server exposes various tools for searching
6
+ vacancies, retrieving detailed job information, managing applications, and
7
+ handling user authentication through OAuth.
8
+
9
+ The module implements the MCP protocol to allow AI assistants to interact with
10
+ HeadHunter's API services, including both public API endpoints (for searching
11
+ vacancies and employers) and authenticated endpoints (for managing resumes and
12
+ applications).
13
+
14
+ Main components:
15
+ - MCP Server setup with stdio transport
16
+ - Tool definitions for various HeadHunter API operations
17
+ - Request handling and response formatting
18
+ - OAuth authentication support
19
+
20
+ Tools:
21
+ hh_search_vacancies: Search for job vacancies with various filters
22
+ hh_get_vacancy: Get detailed information about a specific vacancy
23
+ hh_get_employer: Retrieve employer/company information
24
+ hh_get_similar: Find similar vacancies for a given vacancy
25
+ hh_get_areas: Get list of available regions/areas for filtering
26
+ hh_get_dictionaries: Retrieve all filter dictionaries from HeadHunter
27
+ hh_apply_to_vacancy: Submit job applications (requires OAuth)
28
+ hh_get_negotiations: Get user's application history (requires OAuth)
29
+ hh_get_resumes: List user's resumes (requires OAuth)
30
+ hh_get_resume: Get detailed resume information (requires OAuth)
31
+ """
32
+
33
+ import asyncio
34
+ import json
35
+ from typing import Any
36
+ from dotenv import load_dotenv
37
+
38
+ from mcp.server import Server
39
+ from mcp.types import (
40
+ Tool,
41
+ TextContent,
42
+ ImageContent,
43
+ EmbeddedResource,
44
+ )
45
+ import mcp.server.stdio
46
+
47
+ from hh_client import HHClient
48
+
49
+ load_dotenv()
50
+
51
+ app = Server("hh-api")
52
+ hh_client = HHClient()
53
+
54
+
55
+ @app.list_tools()
56
+ async def list_tools() -> list[Tool]:
57
+ """Return the list of available MCP tools for HeadHunter API.
58
+
59
+ This function defines all the tools that can be called by MCP clients
60
+ to interact with the HeadHunter API. It includes both public tools
61
+ (for searching vacancies and getting employer information) and
62
+ authenticated tools (for managing applications and resumes).
63
+
64
+ The tools are organized into several categories:
65
+ - Vacancy search and retrieval
66
+ - Employer information
67
+ - Similar vacancies
68
+ - Reference data (areas, dictionaries)
69
+ - User operations (applications, resumes) - require OAuth
70
+
71
+ Returns:
72
+ list[Tool]: A list of Tool objects defining the available MCP tools,
73
+ each with its name, description, and input schema.
74
+ """
75
+ return [
76
+ Tool(
77
+ name="hh_search_vacancies",
78
+ description="Search for job vacancies on HeadHunter. Returns list of vacancies matching criteria.",
79
+ inputSchema={
80
+ "type": "object",
81
+ "properties": {
82
+ "text": {
83
+ "type": "string",
84
+ "description": "Search query (job title, keywords, skills)",
85
+ },
86
+ "area": {
87
+ "type": "integer",
88
+ "description": "Region ID (1=Moscow, 2=SPb, 113=Russia). Use hh_get_areas to find IDs.",
89
+ },
90
+ "experience": {
91
+ "type": "string",
92
+ "enum": [
93
+ "noExperience",
94
+ "between1And3",
95
+ "between3And6",
96
+ "moreThan6",
97
+ ],
98
+ "description": "Required experience level",
99
+ },
100
+ "employment": {
101
+ "type": "string",
102
+ "enum": ["full", "part", "project", "volunteer", "probation"],
103
+ "description": "Employment type",
104
+ },
105
+ "schedule": {
106
+ "type": "string",
107
+ "enum": [
108
+ "fullDay",
109
+ "shift",
110
+ "flexible",
111
+ "remote",
112
+ "flyInFlyOut",
113
+ ],
114
+ "description": "Work schedule",
115
+ },
116
+ "salary": {"type": "integer", "description": "Minimum salary"},
117
+ "only_with_salary": {
118
+ "type": "boolean",
119
+ "description": "Show only vacancies with specified salary",
120
+ },
121
+ "per_page": {
122
+ "type": "integer",
123
+ "description": "Results per page (max 100)",
124
+ "default": 20,
125
+ },
126
+ "page": {
127
+ "type": "integer",
128
+ "description": "Page number (0-indexed)",
129
+ "default": 0,
130
+ },
131
+ },
132
+ },
133
+ ),
134
+ Tool(
135
+ name="hh_get_vacancy",
136
+ description="Get detailed information about specific vacancy by ID",
137
+ inputSchema={
138
+ "type": "object",
139
+ "properties": {
140
+ "vacancy_id": {
141
+ "type": "string",
142
+ "description": "Vacancy ID from search results",
143
+ }
144
+ },
145
+ "required": ["vacancy_id"],
146
+ },
147
+ ),
148
+ Tool(
149
+ name="hh_get_employer",
150
+ description="Get information about employer/company by ID",
151
+ inputSchema={
152
+ "type": "object",
153
+ "properties": {
154
+ "employer_id": {
155
+ "type": "string",
156
+ "description": "Employer ID from vacancy data",
157
+ }
158
+ },
159
+ "required": ["employer_id"],
160
+ },
161
+ ),
162
+ Tool(
163
+ name="hh_get_similar",
164
+ description="Get similar vacancies for a specific vacancy",
165
+ inputSchema={
166
+ "type": "object",
167
+ "properties": {
168
+ "vacancy_id": {"type": "string", "description": "Vacancy ID"}
169
+ },
170
+ "required": ["vacancy_id"],
171
+ },
172
+ ),
173
+ Tool(
174
+ name="hh_get_areas",
175
+ description="Get list of all available regions/areas with IDs for filtering",
176
+ inputSchema={"type": "object", "properties": {}},
177
+ ),
178
+ Tool(
179
+ name="hh_get_dictionaries",
180
+ description="Get all dictionaries (experience, employment, schedule, etc.) for filtering",
181
+ inputSchema={"type": "object", "properties": {}},
182
+ ),
183
+ Tool(
184
+ name="hh_apply_to_vacancy",
185
+ description="Apply to vacancy (requires OAuth authentication). User must authorize first.",
186
+ inputSchema={
187
+ "type": "object",
188
+ "properties": {
189
+ "vacancy_id": {
190
+ "type": "string",
191
+ "description": "Vacancy ID to apply to",
192
+ },
193
+ "resume_id": {
194
+ "type": "string",
195
+ "description": "Resume ID to use for application",
196
+ },
197
+ "letter": {
198
+ "type": "string",
199
+ "description": "Cover letter text (optional)",
200
+ },
201
+ },
202
+ "required": ["vacancy_id", "resume_id"],
203
+ },
204
+ ),
205
+ Tool(
206
+ name="hh_get_negotiations",
207
+ description="Get user's application history and status (requires OAuth). Supports pagination.",
208
+ inputSchema={
209
+ "type": "object",
210
+ "properties": {
211
+ "per_page": {
212
+ "type": "integer",
213
+ "description": "Results per page (max 100)",
214
+ "default": 20,
215
+ },
216
+ "page": {
217
+ "type": "integer",
218
+ "description": "Page number (0-indexed)",
219
+ "default": 0,
220
+ },
221
+ },
222
+ },
223
+ ),
224
+ Tool(
225
+ name="hh_get_resumes",
226
+ description="Get list of user's resumes (requires OAuth)",
227
+ inputSchema={"type": "object", "properties": {}},
228
+ ),
229
+ Tool(
230
+ name="hh_get_resume",
231
+ description="Get detailed information about specific resume (requires OAuth)",
232
+ inputSchema={
233
+ "type": "object",
234
+ "properties": {
235
+ "resume_id": {"type": "string", "description": "Resume ID"}
236
+ },
237
+ "required": ["resume_id"],
238
+ },
239
+ ),
240
+ ]
241
+
242
+
243
+ @app.call_tool()
244
+ async def call_tool(
245
+ name: str, arguments: Any
246
+ ) -> list[TextContent | ImageContent | EmbeddedResource]:
247
+ """Execute a HeadHunter API tool call.
248
+
249
+ This function serves as the main dispatcher for all HeadHunter MCP tool
250
+ calls. It handles the execution of various tools including vacancy search,
251
+ detailed information retrieval, application management, and resume
252
+ operations. The function processes the tool arguments, makes the
253
+ appropriate API calls through the HHClient, and formats the responses
254
+ for the MCP client.
255
+
256
+ The function handles both public API calls (no authentication required)
257
+ and authenticated calls (OAuth token required). For authenticated calls,
258
+ appropriate error handling is provided when tokens are missing or invalid.
259
+
260
+ Args:
261
+ name (str): The name of the tool to execute. Must be one of the
262
+ supported tool names defined in list_tools().
263
+ arguments (Any): The arguments for the tool call. The structure
264
+ depends on the specific tool being called and matches the
265
+ inputSchema defined for that tool.
266
+
267
+ Returns:
268
+ list[TextContent | ImageContent | EmbeddedResource]: A list containing
269
+ the formatted response from the HeadHunter API. Typically contains
270
+ a single TextContent object with the formatted result data.
271
+
272
+ Raises:
273
+ Exception: Various exceptions may be raised during API calls,
274
+ authentication issues, or data processing errors. All exceptions
275
+ are caught and returned as error messages to the MCP client.
276
+ """
277
+ try:
278
+ if name == "hh_search_vacancies":
279
+ result = await hh_client.search_vacancies(**arguments)
280
+
281
+ items = result.get("items", [])
282
+ summary = f"Found {result.get('found', 0)} vacancies (showing {len(items)})"
283
+
284
+ formatted_items = []
285
+ for item in items[:10]:
286
+ salary_info = "Not specified"
287
+ if item.get("salary"):
288
+ sal = item["salary"]
289
+ from_sal = sal.get("from", "")
290
+ to_sal = sal.get("to", "")
291
+ currency = sal.get("currency", "")
292
+ if from_sal and to_sal:
293
+ salary_info = f"{from_sal}-{to_sal} {currency}"
294
+ elif from_sal:
295
+ salary_info = f"from {from_sal} {currency}"
296
+ elif to_sal:
297
+ salary_info = f"up to {to_sal} {currency}"
298
+
299
+ formatted_items.append(
300
+ f"[{item['id']}] {item['name']}\n"
301
+ f" Company: {item.get('employer', {}).get('name', 'N/A')}\n"
302
+ f" Salary: {salary_info}\n"
303
+ f" Area: {item.get('area', {}).get('name', 'N/A')}\n"
304
+ f" URL: {item.get('alternate_url', 'N/A')}\n"
305
+ )
306
+
307
+ return [
308
+ TextContent(
309
+ type="text", text=f"{summary}\n\n" + "\n".join(formatted_items)
310
+ )
311
+ ]
312
+
313
+ elif name == "hh_get_vacancy":
314
+ result = await hh_client.get_vacancy(arguments["vacancy_id"])
315
+
316
+ salary_info = "Not specified"
317
+ if result.get("salary"):
318
+ sal = result["salary"]
319
+ from_sal = sal.get("from", "")
320
+ to_sal = sal.get("to", "")
321
+ currency = sal.get("currency", "")
322
+ if from_sal and to_sal:
323
+ salary_info = f"{from_sal}-{to_sal} {currency}"
324
+ elif from_sal:
325
+ salary_info = f"from {from_sal} {currency}"
326
+ elif to_sal:
327
+ salary_info = f"up to {to_sal} {currency}"
328
+
329
+ formatted = f"""
330
+ Vacancy: {result.get('name')}
331
+ Company: {result.get('employer', {}).get('name', 'N/A')}
332
+ Salary: {salary_info}
333
+ Area: {result.get('area', {}).get('name', 'N/A')}
334
+ Experience: {result.get('experience', {}).get('name', 'N/A')}
335
+ Employment: {result.get('employment', {}).get('name', 'N/A')}
336
+ Schedule: {result.get('schedule', {}).get('name', 'N/A')}
337
+
338
+ Description:
339
+ {result.get('description', 'No description')}
340
+
341
+ Key Skills:
342
+ {', '.join([s.get('name', '') for s in result.get('key_skills', [])])}
343
+
344
+ URL: {result.get('alternate_url', 'N/A')}
345
+ """
346
+
347
+ return [TextContent(type="text", text=formatted)]
348
+
349
+ elif name == "hh_get_employer":
350
+ result = await hh_client.get_employer(arguments["employer_id"])
351
+ return [
352
+ TextContent(
353
+ type="text", text=json.dumps(result, indent=2, ensure_ascii=False)
354
+ )
355
+ ]
356
+
357
+ elif name == "hh_get_similar":
358
+ result = await hh_client.get_similar_vacancies(arguments["vacancy_id"])
359
+ items = result.get("items", [])
360
+
361
+ formatted_items = []
362
+ for item in items:
363
+ formatted_items.append(
364
+ f"[{item['id']}] {item['name']} - {item.get('employer', {}).get('name', 'N/A')}"
365
+ )
366
+
367
+ return [
368
+ TextContent(
369
+ type="text",
370
+ text="\n".join(formatted_items)
371
+ if formatted_items
372
+ else "No similar vacancies found",
373
+ )
374
+ ]
375
+
376
+ elif name == "hh_get_areas":
377
+ result = await hh_client.get_areas()
378
+ return [
379
+ TextContent(
380
+ type="text", text=json.dumps(result, indent=2, ensure_ascii=False)
381
+ )
382
+ ]
383
+
384
+ elif name == "hh_get_dictionaries":
385
+ result = await hh_client.get_dictionaries()
386
+ return [
387
+ TextContent(
388
+ type="text", text=json.dumps(result, indent=2, ensure_ascii=False)
389
+ )
390
+ ]
391
+
392
+ elif name == "hh_apply_to_vacancy":
393
+ result = await hh_client.apply_to_vacancy(**arguments)
394
+ return [
395
+ TextContent(
396
+ type="text",
397
+ text=f"Application submitted successfully!\n{json.dumps(result, indent=2, ensure_ascii=False)}",
398
+ )
399
+ ]
400
+
401
+ elif name == "hh_get_negotiations":
402
+ per_page = arguments.get("per_page", 20)
403
+ page = arguments.get("page", 0)
404
+ result = await hh_client.get_negotiations(per_page=per_page, page=page)
405
+
406
+ items = result.get("items", [])
407
+ total = result.get("found", 0)
408
+ summary = f"Total applications: {total} (showing page {page}, {len(items)} items)\n\n"
409
+
410
+ formatted_items = []
411
+ for item in items:
412
+ vacancy = item.get("vacancy", {})
413
+ state = item.get("state", {}).get("name", "Unknown")
414
+ created = item.get("created_at", "N/A")
415
+
416
+ formatted_items.append(
417
+ f"[{vacancy.get('id', 'N/A')}] {vacancy.get('name', 'N/A')}\n"
418
+ f" Company: {vacancy.get('employer', {}).get('name', 'N/A')}\n"
419
+ f" Status: {state}\n"
420
+ f" Applied: {created}\n"
421
+ )
422
+
423
+ return [TextContent(type="text", text=summary + "\n".join(formatted_items))]
424
+
425
+ elif name == "hh_get_resumes":
426
+ result = await hh_client.get_resumes()
427
+ items = result.get("items", [])
428
+
429
+ formatted_items = []
430
+ for item in items:
431
+ status = (
432
+ "✅ Published"
433
+ if item.get("status", {}).get("id") == "published"
434
+ else "⏸️ Not published"
435
+ )
436
+ formatted_items.append(
437
+ f"[{item['id']}] {item.get('title', 'No title')}\n"
438
+ f" Status: {status}\n"
439
+ f" Updated: {item.get('updated_at', 'N/A')}\n"
440
+ f" Views: {item.get('views_count', 0)}\n"
441
+ )
442
+
443
+ return [
444
+ TextContent(
445
+ type="text",
446
+ text=f"Your resumes ({len(items)}):\n\n"
447
+ + "\n".join(formatted_items),
448
+ )
449
+ ]
450
+
451
+ elif name == "hh_get_resume":
452
+ result = await hh_client.get_resume(arguments["resume_id"])
453
+
454
+ formatted = f"""
455
+ Resume: {result.get('title', 'No title')}
456
+ Status: {result.get('status', {}).get('name', 'N/A')}
457
+ Updated: {result.get('updated_at', 'N/A')}
458
+ Views: {result.get('views_count', 0)}
459
+
460
+ Experience:
461
+ """
462
+ for exp in result.get("experience", []):
463
+ formatted += (
464
+ f"- {exp.get('company', 'N/A')}: {exp.get('position', 'N/A')}\n"
465
+ )
466
+
467
+ formatted += f"\nSkills: {result.get('skills', 'N/A')}"
468
+
469
+ return [TextContent(type="text", text=formatted)]
470
+
471
+ else:
472
+ return [TextContent(type="text", text=f"Unknown tool: {name}")]
473
+
474
+ except Exception as e:
475
+ return [TextContent(type="text", text=f"Error: {str(e)}")]
476
+
477
+
478
+ async def main():
479
+ """Initialize and run the HeadHunter MCP server.
480
+
481
+ This function serves as the entry point for the HeadHunter MCP server.
482
+ It sets up the standard input/output (stdio) transport for communication
483
+ with MCP clients and starts the server with the necessary initialization
484
+ options.
485
+
486
+ The server runs indefinitely, handling incoming MCP requests until
487
+ terminated. The stdio transport allows the server to communicate with
488
+ MCP clients through standard input and output streams, making it suitable
489
+ for use with AI assistants and other MCP-compatible applications.
490
+ """
491
+ async with mcp.server.stdio.stdio_server() as (read_stream, write_stream):
492
+ await app.run(read_stream, write_stream, app.create_initialization_options())
493
+
494
+
495
+ if __name__ == "__main__":
496
+ asyncio.run(main())