whistler-mcp 1.0.21 → 1.0.24

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
@@ -5,9 +5,9 @@ An official [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) ser
5
5
 
6
6
  ## Features
7
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
- - **Feedback & Requests:** Submit bug reports, feature ideas, and PDF limit increase requests dynamically.
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`).
8
+ - **Job descriptions on demand (`get_role_jd`):** Fetch a role's job description by company and role id. Counts toward your daily view limit.
9
+ - **Feedback & Requests:** Submit bug reports, feature ideas, and daily limit increase requests dynamically.
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`). No account? Sign up via `signup_to_whistler` or the website. If you use Google on the site, sign in there first; otherwise use the password you set on the website.
11
11
 
12
12
  ## Usage via npx (Claude Desktop)
13
13
 
@@ -3,6 +3,7 @@ import fs from 'fs';
3
3
  import path from 'path';
4
4
  import os from 'os';
5
5
  import { createRequire } from 'module';
6
+ import { formatAuthRequiredError, formatTokenExpiredError } from './authGuidance.js';
6
7
 
7
8
  const require = createRequire(import.meta.url);
8
9
  const { version: MCP_VERSION } = require('../package.json');
@@ -64,7 +65,7 @@ export async function invokeApi(endpoint, method = 'GET', body = null, options =
64
65
  const token = getSavedToken();
65
66
  if (requireAuth) {
66
67
  if (!token) {
67
- throw new Error('Not authenticated. Please ask the user for their email and password, and run the login_to_whistler tool to log them in automatically.');
68
+ throw new Error(formatAuthRequiredError());
68
69
  }
69
70
  // Inject the real user token
70
71
  headers['Authorization'] = `Bearer ${token}`;
@@ -95,7 +96,7 @@ export async function invokeApi(endpoint, method = 'GET', body = null, options =
95
96
  if (!response.ok) {
96
97
  if (response.status === 401) {
97
98
  clearToken(); // Token might be expired
98
- throw new Error(`API Error 401: Token expired or invalid. Please ask the user for their email and password to log in again via login_to_whistler.`);
99
+ throw new Error(formatTokenExpiredError());
99
100
  }
100
101
  throw new Error(`API Error ${response.status}: ${data.message || JSON.stringify(data)}`);
101
102
  }
@@ -114,7 +115,7 @@ export async function invokeApiBinary(endpoint, method = 'GET', body = null, sig
114
115
  if (requireAuth) {
115
116
  const token = getSavedToken();
116
117
  if (!token) {
117
- throw new Error('Not authenticated. Please ask the user for their email and password, and run the login_to_whistler tool to log them in automatically.');
118
+ throw new Error(formatAuthRequiredError());
118
119
  }
119
120
  headers['Authorization'] = `Bearer ${token}`;
120
121
  }
@@ -136,7 +137,7 @@ export async function invokeApiBinary(endpoint, method = 'GET', body = null, sig
136
137
  if (!response.ok) {
137
138
  if (response.status === 401) {
138
139
  clearToken();
139
- throw new Error(`API Error 401: Token expired or invalid. Please ask the user for their email and password to log in again via login_to_whistler.`);
140
+ throw new Error(formatTokenExpiredError());
140
141
  }
141
142
  const text = await response.text();
142
143
  throw new Error(`API Error ${response.status}: ${text}`);
@@ -0,0 +1,71 @@
1
+ const FRONTEND_URL = (process.env.FRONTEND_URL || 'https://whistler-six.vercel.app').replace(/\/$/, '');
2
+
3
+ export const AUTH_URLS = {
4
+ login: `${FRONTEND_URL}/login`,
5
+ signup: `${FRONTEND_URL}/signup`
6
+ };
7
+
8
+ const PROFILE_PASSWORD_HINT =
9
+ 'If you are already signed in on the website, update your password from Profile → Set password.';
10
+
11
+ export function formatLoginFailure(status, data = {}) {
12
+ const message = data.message || 'Login failed';
13
+
14
+ if (status === 404 && data.isUserNotFound) {
15
+ return [
16
+ message,
17
+ `No Whistler account exists for this email. Create one at ${AUTH_URLS.signup}, then run login_to_whistler again.`
18
+ ].join(' ');
19
+ }
20
+
21
+ if (status === 401) {
22
+ if (/locked/i.test(message)) {
23
+ return `${message} Wait for the lockout to end, then try again. ${PROFILE_PASSWORD_HINT}`;
24
+ }
25
+ return [
26
+ message,
27
+ `Sign in at ${AUTH_URLS.login} with Google if you use Google on the site, or use the password you set there.`,
28
+ PROFILE_PASSWORD_HINT,
29
+ 'Then run login_to_whistler with the correct password.'
30
+ ].join(' ');
31
+ }
32
+
33
+ if (status === 403 && data.isAccountDeleted) {
34
+ return `${message} Deleted accounts cannot be recovered.`;
35
+ }
36
+
37
+ if (status === 429) {
38
+ return `${message} Try again later.`;
39
+ }
40
+
41
+ return message;
42
+ }
43
+
44
+ export function formatSignupFailure(status, data = {}) {
45
+ const message = data.message || 'Signup failed';
46
+
47
+ if (status === 400 && /already exists/i.test(message)) {
48
+ return [
49
+ message,
50
+ `Sign in with login_to_whistler instead.`
51
+ ].join(' ');
52
+ }
53
+
54
+ return message;
55
+ }
56
+
57
+ export function formatAuthRequiredError() {
58
+ return [
59
+ 'Not authenticated.',
60
+ 'Run login_to_whistler with your email and password.',
61
+ `No account yet? Sign up at ${AUTH_URLS.signup}.`
62
+ ].join(' ');
63
+ }
64
+
65
+ export function formatTokenExpiredError() {
66
+ return [
67
+ 'API Error 401: Token expired or invalid.',
68
+ 'Run login_to_whistler again.',
69
+ `Sign in at ${AUTH_URLS.login} if you need to check your account.`
70
+ ].join(' ');
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "whistler-mcp",
3
- "version": "1.0.21",
3
+ "version": "1.0.24",
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": {
package/server.js CHANGED
@@ -9,13 +9,14 @@ import {
9
9
  import { z } from 'zod';
10
10
 
11
11
  import { invokeApi, saveToken, clearToken, WHISTLER_CLIENT_ID } from './adapters/apiClient.js';
12
+ import { AUTH_URLS, formatLoginFailure, formatSignupFailure } from './adapters/authGuidance.js';
12
13
 
13
14
  const API_BASE_URL = process.env.API_URL || 'https://whistler-backend.onrender.com/api';
14
15
 
15
16
  const server = new Server(
16
17
  {
17
18
  name: "whistler-mcp-server",
18
- version: "1.0.21",
19
+ version: "1.0.24",
19
20
  },
20
21
  {
21
22
  capabilities: { tools: {} },
@@ -25,12 +26,12 @@ const server = new Server(
25
26
  const toolsDefinition = {
26
27
  // Authentication
27
28
  login_to_whistler: {
28
- description: "Log into Whistler and save the authentication token. Use this automatically if a request fails with 401 Unauthorized or missing token.",
29
+ description: `Log into Whistler and save the authentication token. No account? Sign up at ${AUTH_URLS.signup}.`,
29
30
  schema: z.object({ email: z.string().email(), password: z.string() }),
30
31
  inputSchema: { type: "object", properties: { email: { type: "string" }, password: { type: "string" } }, required: ["email", "password"] }
31
32
  },
32
33
  signup_to_whistler: {
33
- description: "Create a new Whistler account. Use this if the user doesn't have an account yet.",
34
+ description: `Create a new Whistler account in MCP. Alternatively sign up at ${AUTH_URLS.signup}. If the email already exists, use login_to_whistler.`,
34
35
  schema: z.object({
35
36
  name: z.string(),
36
37
  email: z.string().email(),
@@ -101,7 +102,7 @@ const toolsDefinition = {
101
102
  }
102
103
  },
103
104
  get_company_roles: {
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.",
105
+ 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.",
105
106
  schema: z.object({
106
107
  companyId: z.string().optional(),
107
108
  companyName: z.string().optional(),
@@ -120,7 +121,7 @@ const toolsDefinition = {
120
121
  },
121
122
 
122
123
  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
+ description: "Get the job description for a single role. Counts toward your daily view limit. Use the company name and role `id` from get_company_roles.",
124
125
  schema: z.object({
125
126
  companyName: z.string(),
126
127
  jobId: z.string(),
@@ -156,7 +157,7 @@ const toolsDefinition = {
156
157
 
157
158
  // PDF Access Requests
158
159
  submit_pdf_request: {
159
- description: "Submit a request to increase a user's daily PDF view limit (web app only for viewing PDFs).",
160
+ description: "Submit a request to increase a user's daily job description view limit.",
160
161
  schema: z.object({ requestedLimit: z.number(), reason: z.string() }),
161
162
  inputSchema: { type: "object", properties: { requestedLimit: { type: "number" }, reason: { type: "string" } }, required: ["requestedLimit", "reason"] }
162
163
  },
@@ -388,7 +389,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
388
389
  throw new Error(`Login failed with weird response: ${text.substring(0, 100)}`);
389
390
  }
390
391
 
391
- if (!loginRes.ok) throw new Error(loginData.message || 'Login failed');
392
+ if (!loginRes.ok) {
393
+ throw new Error(formatLoginFailure(loginRes.status, loginData));
394
+ }
392
395
  if (!loginData.token) throw new Error('Login succeeded but no token was returned.');
393
396
 
394
397
  saveToken(loginData.token, loginData);
@@ -413,7 +416,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
413
416
  throw new Error(`Signup failed with weird response: ${text.substring(0, 100)}`);
414
417
  }
415
418
 
416
- if (!signupRes.ok) throw new Error(signupData.message || 'Signup failed');
419
+ if (!signupRes.ok) {
420
+ throw new Error(formatSignupFailure(signupRes.status, signupData));
421
+ }
417
422
  if (!signupData.token) throw new Error('Signup succeeded but no token was returned.');
418
423
 
419
424
  saveToken(signupData.token, signupData);