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 +3 -3
- package/adapters/apiClient.js +5 -4
- package/adapters/authGuidance.js +71 -0
- package/package.json +1 -1
- package/server.js +13 -8
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`):**
|
|
9
|
-
- **Feedback & Requests:** Submit bug reports, feature ideas, and
|
|
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
|
|
package/adapters/apiClient.js
CHANGED
|
@@ -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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
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.
|
|
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:
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
|
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)
|
|
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)
|
|
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);
|