whistler-mcp 1.0.13 → 1.0.21
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/README.md +3 -3
- package/adapters/apiClient.js +34 -4
- package/package.json +1 -2
- package/server.js +39 -87
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Whistler MCP Server
|
|
2
2
|
 
|
|
3
3
|
|
|
4
|
-
An official [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for interacting with the Whistler placement data platform. This allows AI assistants like Claude Desktop, Cursor, and others to
|
|
4
|
+
An official [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) server for interacting with the Whistler placement data platform. This allows AI assistants like Claude Desktop, Cursor, and others to browse company lists and role metadata on your behalf.
|
|
5
5
|
|
|
6
6
|
## Features
|
|
7
|
-
- **Job Placement Data:** Fetch lists of companies and
|
|
8
|
-
- **
|
|
7
|
+
- **Job Placement Data:** Fetch lists of companies and role metadata (title, CTC, eligibility) for any requested year.
|
|
8
|
+
- **Job descriptions on demand (`get_role_jd`):** The backend derives JD content server-side — extracted text when the PDF has a text layer, or page images when it is image-only. The raw PDF file is never shared. Counts toward your daily view limit.
|
|
9
9
|
- **Feedback & Requests:** Submit bug reports, feature ideas, and PDF limit increase requests dynamically.
|
|
10
10
|
- **Agentic Authentication:** The AI securely prompts you for your email and password when you first connect, fetches a real token from the backend, and saves it locally (`~/.whistler_mcp_token.json`).
|
|
11
11
|
|
package/adapters/apiClient.js
CHANGED
|
@@ -2,18 +2,48 @@ import 'dotenv/config';
|
|
|
2
2
|
import fs from 'fs';
|
|
3
3
|
import path from 'path';
|
|
4
4
|
import os from 'os';
|
|
5
|
+
import { createRequire } from 'module';
|
|
6
|
+
|
|
7
|
+
const require = createRequire(import.meta.url);
|
|
8
|
+
const { version: MCP_VERSION } = require('../package.json');
|
|
5
9
|
|
|
6
10
|
const API_BASE_URL = process.env.API_URL || 'https://whistler-backend.onrender.com/api';
|
|
7
11
|
export const TOKEN_PATH = path.join(os.homedir(), '.whistler_mcp_token.json');
|
|
12
|
+
export const WHISTLER_CLIENT_ID = `whistler-mcp/${MCP_VERSION}`;
|
|
13
|
+
|
|
14
|
+
function withClientHeaders(headers = {}) {
|
|
15
|
+
return {
|
|
16
|
+
...headers,
|
|
17
|
+
'X-Whistler-Client': WHISTLER_CLIENT_ID
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function isTokenExpired(token) {
|
|
22
|
+
try {
|
|
23
|
+
const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString('utf8'));
|
|
24
|
+
return payload.exp && payload.exp < Math.floor(Date.now() / 1000);
|
|
25
|
+
} catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
8
29
|
|
|
9
30
|
export function getSavedToken() {
|
|
10
31
|
if (fs.existsSync(TOKEN_PATH)) {
|
|
11
32
|
try {
|
|
12
33
|
const data = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
|
|
13
|
-
|
|
34
|
+
if (data.token) {
|
|
35
|
+
if (isTokenExpired(data.token)) {
|
|
36
|
+
// Remove the stale token file so the next call prompts re-login
|
|
37
|
+
fs.unlinkSync(TOKEN_PATH);
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return data.token;
|
|
41
|
+
}
|
|
14
42
|
} catch (e) { }
|
|
15
43
|
}
|
|
16
|
-
|
|
44
|
+
const envToken = process.env.WHISTLER_API_TOKEN || null;
|
|
45
|
+
if (envToken && isTokenExpired(envToken)) return null;
|
|
46
|
+
return envToken;
|
|
17
47
|
}
|
|
18
48
|
|
|
19
49
|
export function saveToken(token, user) {
|
|
@@ -29,7 +59,7 @@ export function clearToken() {
|
|
|
29
59
|
*/
|
|
30
60
|
export async function invokeApi(endpoint, method = 'GET', body = null, options = {}) {
|
|
31
61
|
const { requireAuth = true, includeAuthIfAvailable = false } = options;
|
|
32
|
-
const headers = { 'Content-Type': 'application/json' };
|
|
62
|
+
const headers = withClientHeaders({ 'Content-Type': 'application/json' });
|
|
33
63
|
|
|
34
64
|
const token = getSavedToken();
|
|
35
65
|
if (requireAuth) {
|
|
@@ -79,7 +109,7 @@ export async function invokeApi(endpoint, method = 'GET', body = null, options =
|
|
|
79
109
|
*/
|
|
80
110
|
export async function invokeApiBinary(endpoint, method = 'GET', body = null, signal = undefined, options = {}) {
|
|
81
111
|
const { requireAuth = true } = options;
|
|
82
|
-
const headers = {};
|
|
112
|
+
const headers = withClientHeaders({});
|
|
83
113
|
|
|
84
114
|
if (requireAuth) {
|
|
85
115
|
const token = getSavedToken();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whistler-mcp",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.21",
|
|
4
4
|
"description": "An MCP Server for retrieving placement data, job roles, and platform statistics from the Whistler API.",
|
|
5
5
|
"main": "server.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,6 @@
|
|
|
10
10
|
"dependencies": {
|
|
11
11
|
"@modelcontextprotocol/sdk": "^1.2.0",
|
|
12
12
|
"dotenv": "^16.4.5",
|
|
13
|
-
"pdf-parse": "^1.1.1",
|
|
14
13
|
"zod": "^3.23.0"
|
|
15
14
|
},
|
|
16
15
|
"keywords": [
|
package/server.js
CHANGED
|
@@ -7,19 +7,15 @@ import {
|
|
|
7
7
|
ListToolsRequestSchema,
|
|
8
8
|
} from '@modelcontextprotocol/sdk/types.js';
|
|
9
9
|
import { z } from 'zod';
|
|
10
|
-
import { createRequire } from 'module';
|
|
11
10
|
|
|
12
|
-
|
|
13
|
-
const pdfParse = require('pdf-parse');
|
|
14
|
-
|
|
15
|
-
import { invokeApi, invokeApiBinary, saveToken, clearToken } from './adapters/apiClient.js';
|
|
11
|
+
import { invokeApi, saveToken, clearToken, WHISTLER_CLIENT_ID } from './adapters/apiClient.js';
|
|
16
12
|
|
|
17
13
|
const API_BASE_URL = process.env.API_URL || 'https://whistler-backend.onrender.com/api';
|
|
18
14
|
|
|
19
15
|
const server = new Server(
|
|
20
16
|
{
|
|
21
17
|
name: "whistler-mcp-server",
|
|
22
|
-
version: "1.0.
|
|
18
|
+
version: "1.0.21",
|
|
23
19
|
},
|
|
24
20
|
{
|
|
25
21
|
capabilities: { tools: {} },
|
|
@@ -105,7 +101,7 @@ const toolsDefinition = {
|
|
|
105
101
|
}
|
|
106
102
|
},
|
|
107
103
|
get_company_roles: {
|
|
108
|
-
description: "Fetch job
|
|
104
|
+
description: "Fetch job role metadata for a company (title, CTC, eligibility, location). Returns each role with a stable `id`. To read a role's job description, call get_role_jd with that company and role id. The raw PDF file is never shared.",
|
|
109
105
|
schema: z.object({
|
|
110
106
|
companyId: z.string().optional(),
|
|
111
107
|
companyName: z.string().optional(),
|
|
@@ -123,6 +119,24 @@ const toolsDefinition = {
|
|
|
123
119
|
}
|
|
124
120
|
},
|
|
125
121
|
|
|
122
|
+
get_role_jd: {
|
|
123
|
+
description: "Get the job description for a single role. The backend derives content server-side: it returns extracted text when the role's PDF has a text layer, or page images (base64) when the PDF is image-only. The raw PDF file is never returned. Counts toward your daily view limit. Use the company name/id and the role `id` from get_company_roles.",
|
|
124
|
+
schema: z.object({
|
|
125
|
+
companyName: z.string(),
|
|
126
|
+
jobId: z.string(),
|
|
127
|
+
year: z.number().optional()
|
|
128
|
+
}),
|
|
129
|
+
inputSchema: {
|
|
130
|
+
type: "object",
|
|
131
|
+
properties: {
|
|
132
|
+
companyName: { type: "string" },
|
|
133
|
+
jobId: { type: "string" },
|
|
134
|
+
year: { type: "number" }
|
|
135
|
+
},
|
|
136
|
+
required: ["companyName", "jobId"]
|
|
137
|
+
}
|
|
138
|
+
},
|
|
139
|
+
|
|
126
140
|
// Platform Stats
|
|
127
141
|
get_public_stats: {
|
|
128
142
|
description: "Retrieve public platform statistics.",
|
|
@@ -142,7 +156,7 @@ const toolsDefinition = {
|
|
|
142
156
|
|
|
143
157
|
// PDF Access Requests
|
|
144
158
|
submit_pdf_request: {
|
|
145
|
-
description: "Submit a request to increase a user's daily PDF view limit.",
|
|
159
|
+
description: "Submit a request to increase a user's daily PDF view limit (web app only for viewing PDFs).",
|
|
146
160
|
schema: z.object({ requestedLimit: z.number(), reason: z.string() }),
|
|
147
161
|
inputSchema: { type: "object", properties: { requestedLimit: { type: "number" }, reason: { type: "string" } }, required: ["requestedLimit", "reason"] }
|
|
148
162
|
},
|
|
@@ -152,13 +166,6 @@ const toolsDefinition = {
|
|
|
152
166
|
description: "Submit an inquiry or bid to buy the platform.",
|
|
153
167
|
schema: z.object({ name: z.string(), email: z.string().email(), phone: z.string(), amount: z.number(), message: z.string().optional() }),
|
|
154
168
|
inputSchema: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, phone: { type: "string" }, amount: { type: "number" }, message: { type: "string" } }, required: ["name", "email", "phone", "amount"] }
|
|
155
|
-
},
|
|
156
|
-
|
|
157
|
-
// Read PDF
|
|
158
|
-
view_job_pdf: {
|
|
159
|
-
description: "Extract and read the textual content of a company's job role PDF. IMPORTANT: pdfUrl tokens expire after 10 minutes. You MUST call get_company_roles immediately before calling this tool to get a fresh pdfUrl. Never reuse a pdfUrl from a previous tool call. (Consumes 1 daily view).",
|
|
160
|
-
schema: z.object({ pdfUrl: z.string() }),
|
|
161
|
-
inputSchema: { type: "object", properties: { pdfUrl: { type: "string" } }, required: ["pdfUrl"] }
|
|
162
169
|
}
|
|
163
170
|
};
|
|
164
171
|
|
|
@@ -365,7 +372,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
365
372
|
case 'login_to_whistler': {
|
|
366
373
|
const loginRes = await fetch(`${API_BASE_URL}/auth/login`, {
|
|
367
374
|
method: 'POST',
|
|
368
|
-
headers: {
|
|
375
|
+
headers: {
|
|
376
|
+
'Content-Type': 'application/json',
|
|
377
|
+
'X-Whistler-Client': WHISTLER_CLIENT_ID
|
|
378
|
+
},
|
|
369
379
|
body: JSON.stringify({ email: params.email, password: params.password })
|
|
370
380
|
});
|
|
371
381
|
|
|
@@ -388,7 +398,10 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
388
398
|
case 'signup_to_whistler': {
|
|
389
399
|
const signupRes = await fetch(`${API_BASE_URL}/auth/signup`, {
|
|
390
400
|
method: 'POST',
|
|
391
|
-
headers: {
|
|
401
|
+
headers: {
|
|
402
|
+
'Content-Type': 'application/json',
|
|
403
|
+
'X-Whistler-Client': WHISTLER_CLIENT_ID
|
|
404
|
+
},
|
|
392
405
|
body: JSON.stringify({ ...params, acceptedTerms: true })
|
|
393
406
|
});
|
|
394
407
|
|
|
@@ -471,6 +484,15 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
471
484
|
result = await invokeApi(`/pdfs/company/${encodedCompanyIdentifier}${params.year ? '?year=' + params.year : ''}`, 'GET');
|
|
472
485
|
break;
|
|
473
486
|
}
|
|
487
|
+
case 'get_role_jd': {
|
|
488
|
+
const encodedCompany = encodeURIComponent(params.companyName);
|
|
489
|
+
const encodedJobId = encodeURIComponent(params.jobId);
|
|
490
|
+
result = await invokeApi(
|
|
491
|
+
`/pdfs/mcp/role-jd/${encodedCompany}/${encodedJobId}${params.year ? '?year=' + params.year : ''}`,
|
|
492
|
+
'GET'
|
|
493
|
+
);
|
|
494
|
+
break;
|
|
495
|
+
}
|
|
474
496
|
case 'get_public_stats':
|
|
475
497
|
result = await invokeApi('/pdfs/stats', 'GET');
|
|
476
498
|
break;
|
|
@@ -486,76 +508,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
486
508
|
case 'submit_buy_bid':
|
|
487
509
|
result = await invokeApi('/buy-bid', 'POST', params, { requireAuth: false });
|
|
488
510
|
break;
|
|
489
|
-
case 'view_job_pdf': {
|
|
490
|
-
// Guard: pdfUrl must exist and look like a valid API path
|
|
491
|
-
if (!params.pdfUrl || params.pdfUrl === 'undefined' || params.pdfUrl === 'null') {
|
|
492
|
-
result = {
|
|
493
|
-
success: false,
|
|
494
|
-
message: "This job does not have a PDF available. The 'pdfUrl' field was not returned by get_company_roles, which means no JD PDF exists for this role."
|
|
495
|
-
};
|
|
496
|
-
break;
|
|
497
|
-
}
|
|
498
|
-
if (!params.pdfUrl.startsWith('/api/pdfs/')) {
|
|
499
|
-
result = {
|
|
500
|
-
success: false,
|
|
501
|
-
message: `Invalid pdfUrl format: "${params.pdfUrl}". You must pass the exact 'pdfUrl' value returned by get_company_roles (e.g. /api/pdfs/view/<token>). Call get_company_roles again to get a fresh token.`
|
|
502
|
-
};
|
|
503
|
-
break;
|
|
504
|
-
}
|
|
505
|
-
// pdfUrl from the backend is a relative path like /api/pdfs/view/:token
|
|
506
|
-
// invokeApiBinary prepends API_BASE_URL which already ends in /api,
|
|
507
|
-
// so we must strip the leading /api prefix to avoid doubling it.
|
|
508
|
-
const normalizedPdfUrl = params.pdfUrl.replace(/^\/api/, '');
|
|
509
|
-
let binaryEndpoint = normalizedPdfUrl;
|
|
510
|
-
|
|
511
|
-
// New flow: redeem token and download through viewer session document endpoint.
|
|
512
|
-
if (/^\/pdfs\/view\/.+/.test(normalizedPdfUrl)) {
|
|
513
|
-
let viewPayload;
|
|
514
|
-
try {
|
|
515
|
-
viewPayload = await invokeApi(normalizedPdfUrl, 'GET');
|
|
516
|
-
} catch (tokenErr) {
|
|
517
|
-
// A 401/403 error here almost always means the pdfUrl token expired.
|
|
518
|
-
if (tokenErr.message.includes('403') || tokenErr.message.includes('401') || tokenErr.message.toLowerCase().includes('token')) {
|
|
519
|
-
result = {
|
|
520
|
-
success: false,
|
|
521
|
-
message: `The pdfUrl token has expired or is invalid. Please call get_company_roles again for ${params.pdfUrl.split('/').pop()?.substring(0, 8) || 'this company'} to receive a fresh pdfUrl, then immediately call view_job_pdf again with the new value.`
|
|
522
|
-
};
|
|
523
|
-
break;
|
|
524
|
-
}
|
|
525
|
-
throw tokenErr;
|
|
526
|
-
}
|
|
527
|
-
const sessionToken = viewPayload?.viewer?.sessionToken;
|
|
528
|
-
if (!sessionToken) {
|
|
529
|
-
throw new Error('Viewer session was not created for this pdfUrl.');
|
|
530
|
-
}
|
|
531
|
-
binaryEndpoint = `/pdfs/viewer/${sessionToken}/document`;
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
// Fetch binary with a 30-second timeout to avoid indefinite hangs
|
|
535
|
-
const binaryController = new AbortController();
|
|
536
|
-
const binaryTimeout = setTimeout(() => binaryController.abort(), 30000);
|
|
537
|
-
let arrayBuffer;
|
|
538
|
-
try {
|
|
539
|
-
arrayBuffer = await invokeApiBinary(binaryEndpoint, 'GET', null, binaryController.signal);
|
|
540
|
-
} catch (binErr) {
|
|
541
|
-
if (binErr.name === 'AbortError' || binErr.message?.includes('aborted')) {
|
|
542
|
-
throw new Error('PDF download timed out after 30 seconds. The file may be too large or the Cloudinary CDN is slow. Please try again.');
|
|
543
|
-
}
|
|
544
|
-
throw binErr;
|
|
545
|
-
} finally {
|
|
546
|
-
clearTimeout(binaryTimeout);
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const buffer = Buffer.from(arrayBuffer);
|
|
550
|
-
const pdfData = await pdfParse(buffer);
|
|
551
|
-
result = {
|
|
552
|
-
success: true,
|
|
553
|
-
text: pdfData.text,
|
|
554
|
-
pages: pdfData.numpages,
|
|
555
|
-
message: "PDF read successfully."
|
|
556
|
-
};
|
|
557
|
-
break;
|
|
558
|
-
}
|
|
559
511
|
}
|
|
560
512
|
|
|
561
513
|
return {
|