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 CHANGED
@@ -1,11 +1,11 @@
1
1
  # Whistler MCP Server
2
2
  ![npm downloads](https://img.shields.io/npm/dt/whistler-mcp?color=indigo&style=flat) ![npm version](https://img.shields.io/npm/v/whistler-mcp?color=indigo&style=flat)
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 seamlessly read job roles, platform statistics, and PDFs on your behalf.
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 specific job roles for any requested year.
8
- - **PDF Reading:** Natively extracts text from job description PDFs (respects your daily view limit).
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
 
@@ -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
- return data.token;
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
- return process.env.WHISTLER_API_TOKEN || null;
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.13",
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
- const require = createRequire(import.meta.url);
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.13",
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 roles for a company using companyId or companyName.",
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: { 'Content-Type': 'application/json' },
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: { 'Content-Type': 'application/json' },
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 {