whistler-mcp 1.0.0

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 ADDED
@@ -0,0 +1,37 @@
1
+ # Whistler MCP Server
2
+
3
+ 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
+
5
+ ## Features
6
+ - **Job Placement Data:** Fetch lists of companies and specific job roles for any requested year.
7
+ - **PDF Reading:** Natively extracts text from job description PDFs (respects your daily view limit).
8
+ - **Feedback & Requests:** Submit bug reports, feature ideas, and PDF limit increase requests dynamically.
9
+ - **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`).
10
+
11
+ ## Usage via npx (Claude Desktop)
12
+
13
+ To use this server locally with Claude Desktop, add the following to your `claude_desktop_config.json`:
14
+
15
+ ```json
16
+ {
17
+ "mcpServers": {
18
+ "whistler-mcp": {
19
+ "command": "npx",
20
+ "args": [
21
+ "-y",
22
+ "whistler-mcp"
23
+ ],
24
+ "env": {
25
+ "API_URL": "https://whistler-backend.onrender.com/api"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ ```
31
+
32
+ *Note: You do not need to provide a JWT token in the config. Try asking Claude a Whistler-related question, and it will prompt you to securely log in directly within the chat interface!*
33
+
34
+ ## Local Development
35
+ 1. Clone the repository.
36
+ 2. Run `npm install` inside the `/mcp` folder.
37
+ 3. Test locally using the `node server.js` command.
@@ -0,0 +1,108 @@
1
+ import 'dotenv/config';
2
+ import fs from 'fs';
3
+ import path from 'path';
4
+ import os from 'os';
5
+
6
+ const API_BASE_URL = process.env.API_URL || 'http://localhost:5000/api';
7
+ export const TOKEN_PATH = path.join(os.homedir(), '.whistler_mcp_token.json');
8
+
9
+ export function getSavedToken() {
10
+ if (fs.existsSync(TOKEN_PATH)) {
11
+ try {
12
+ const data = JSON.parse(fs.readFileSync(TOKEN_PATH, 'utf8'));
13
+ return data.token;
14
+ } catch(e) {}
15
+ }
16
+ return process.env.WHISTLER_API_TOKEN || null;
17
+ }
18
+
19
+ export function saveToken(token, user) {
20
+ fs.writeFileSync(TOKEN_PATH, JSON.stringify({ token, user }), 'utf8');
21
+ }
22
+
23
+ export function clearToken() {
24
+ if (fs.existsSync(TOKEN_PATH)) fs.unlinkSync(TOKEN_PATH);
25
+ }
26
+
27
+ /**
28
+ * Core adapter: Makes an HTTP request to the running Express application.
29
+ */
30
+ export async function invokeApi(endpoint, method = 'GET', body = null) {
31
+ const headers = { 'Content-Type': 'application/json' };
32
+
33
+ const token = getSavedToken();
34
+ if (!token) {
35
+ 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.');
36
+ }
37
+
38
+ // Inject the real user token
39
+ headers['Authorization'] = `Bearer ${token}`;
40
+
41
+ const options = { method, headers };
42
+ if (body) {
43
+ options.body = JSON.stringify(body);
44
+ }
45
+
46
+ let response;
47
+ try {
48
+ response = await fetch(`${API_BASE_URL}${endpoint}`, options);
49
+ } catch (error) {
50
+ throw new Error(`Failed to connect to API at ${API_BASE_URL}. Is the server running? ${error.message}`);
51
+ }
52
+
53
+ let data;
54
+ try {
55
+ data = await response.json();
56
+ } catch (err) {
57
+ const text = await response.text();
58
+ data = { message: text || 'Non-JSON response' };
59
+ }
60
+
61
+ if (!response.ok) {
62
+ if (response.status === 401) {
63
+ clearToken(); // Token might be expired
64
+ 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.`);
65
+ }
66
+ throw new Error(`API Error ${response.status}: ${data.message || JSON.stringify(data)}`);
67
+ }
68
+
69
+ return data;
70
+ }
71
+
72
+ /**
73
+ * Fetch binary data (like PDFs) from the API.
74
+ */
75
+ export async function invokeApiBinary(endpoint, method = 'GET', body = null) {
76
+ const headers = {};
77
+
78
+ const token = getSavedToken();
79
+ if (!token) {
80
+ 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.');
81
+ }
82
+
83
+ headers['Authorization'] = `Bearer ${token}`;
84
+
85
+ const options = { method, headers };
86
+ if (body) {
87
+ options.body = JSON.stringify(body);
88
+ headers['Content-Type'] = 'application/json';
89
+ }
90
+
91
+ let response;
92
+ try {
93
+ response = await fetch(`${API_BASE_URL}${endpoint}`, options);
94
+ } catch (error) {
95
+ throw new Error(`Failed to connect to API at ${API_BASE_URL}. Is the server running? ${error.message}`);
96
+ }
97
+
98
+ if (!response.ok) {
99
+ if (response.status === 401) {
100
+ clearToken();
101
+ 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.`);
102
+ }
103
+ const text = await response.text();
104
+ throw new Error(`API Error ${response.status}: ${text}`);
105
+ }
106
+
107
+ return await response.arrayBuffer();
108
+ }
package/package.json ADDED
@@ -0,0 +1,27 @@
1
+ {
2
+ "name": "whistler-mcp",
3
+ "version": "1.0.0",
4
+ "description": "An MCP Server for retrieving placement data, job roles, and platform statistics from the Whistler API.",
5
+ "main": "server.js",
6
+ "bin": {
7
+ "whistler-mcp": "server.js"
8
+ },
9
+ "type": "module",
10
+ "dependencies": {
11
+ "@modelcontextprotocol/sdk": "^1.2.0",
12
+ "dotenv": "^16.4.5",
13
+ "pdf-parse": "^1.1.1",
14
+ "zod": "^3.23.0"
15
+ },
16
+ "keywords": [
17
+ "mcp",
18
+ "whistler",
19
+ "ai",
20
+ "claude",
21
+ "cursor",
22
+ "IIT",
23
+ "Placement data archive"
24
+ ],
25
+ "author": "",
26
+ "license": "ISC"
27
+ }
package/server.js ADDED
@@ -0,0 +1,204 @@
1
+ #!/usr/bin/env node
2
+ import 'dotenv/config';
3
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
4
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
5
+ import {
6
+ CallToolRequestSchema,
7
+ ListToolsRequestSchema,
8
+ } from '@modelcontextprotocol/sdk/types.js';
9
+ import { z } from 'zod';
10
+ import { createRequire } from 'module';
11
+
12
+ const require = createRequire(import.meta.url);
13
+ const pdfParse = require('pdf-parse');
14
+
15
+ import { invokeApi, invokeApiBinary, saveToken, clearToken } from './adapters/apiClient.js';
16
+
17
+ const API_BASE_URL = process.env.API_URL || 'https://whistler-backend.onrender.com/api';
18
+
19
+ const server = new Server(
20
+ {
21
+ name: "whistler-mcp-server",
22
+ version: "2.0.0",
23
+ },
24
+ {
25
+ capabilities: { tools: {} },
26
+ }
27
+ );
28
+
29
+ const toolsDefinition = {
30
+ // Authentication
31
+ login_to_whistler: {
32
+ description: "Log into Whistler and save the authentication token. Use this automatically if a request fails with 401 Unauthorized or missing token.",
33
+ schema: z.object({ email: z.string().email(), password: z.string() }),
34
+ inputSchema: { type: "object", properties: { email: { type: "string" }, password: { type: "string" } }, required: ["email", "password"] }
35
+ },
36
+ logout_whistler: {
37
+ description: "Log out of Whistler and clear the saved authentication token.",
38
+ schema: z.object({}),
39
+ inputSchema: { type: "object", properties: {} }
40
+ },
41
+
42
+ // Placement Data
43
+ get_companies: {
44
+ description: "Retrieve a list of companies. Optionally filter by year.",
45
+ schema: z.object({ year: z.number().optional() }),
46
+ inputSchema: { type: "object", properties: { year: { type: "number" } } }
47
+ },
48
+ get_company_roles: {
49
+ description: "Fetch specific job roles for a company.",
50
+ schema: z.object({ companyName: z.string(), year: z.number().optional() }),
51
+ inputSchema: { type: "object", properties: { companyName: { type: "string" }, year: { type: "number" } }, required: ["companyName"] }
52
+ },
53
+
54
+ // Platform Stats
55
+ get_public_stats: {
56
+ description: "Retrieve public platform statistics.",
57
+ schema: z.object({}),
58
+ inputSchema: { type: "object", properties: {} }
59
+ },
60
+
61
+ // Feedback Management
62
+ submit_feedback: {
63
+ description: "Submit new user feedback.",
64
+ schema: z.object({
65
+ name: z.string(), email: z.string().email(), type: z.enum(['bug', 'feature', 'improvement', 'other']),
66
+ subject: z.string(), message: z.string()
67
+ }),
68
+ inputSchema: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, type: { type: "string" }, subject: { type: "string" }, message: { type: "string" } }, required: ["name", "email", "type", "subject", "message"] }
69
+ },
70
+
71
+ // PDF Access Requests
72
+ submit_pdf_request: {
73
+ description: "Submit a request to increase a user's daily PDF view limit.",
74
+ schema: z.object({ requestedLimit: z.number(), reason: z.string() }),
75
+ inputSchema: { type: "object", properties: { requestedLimit: { type: "number" }, reason: { type: "string" } }, required: ["requestedLimit", "reason"] }
76
+ },
77
+
78
+ // Buy Bids
79
+ submit_buy_bid: {
80
+ description: "Submit an inquiry or bid to buy the platform.",
81
+ schema: z.object({ name: z.string(), email: z.string().email(), phone: z.string(), amount: z.number(), message: z.string().optional() }),
82
+ inputSchema: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, phone: { type: "string" }, amount: { type: "number" }, message: { type: "string" } }, required: ["name", "email", "phone", "amount"] }
83
+ },
84
+
85
+ // Read PDF
86
+ view_job_pdf: {
87
+ description: "Read the specific textual content of a company's job role PDF. This will consume 1 from the user's daily PDF view limit. Ensure you pass the exact `pdfUrl` obtained from `get_company_roles`.",
88
+ schema: z.object({ pdfUrl: z.string() }),
89
+ inputSchema: { type: "object", properties: { pdfUrl: { type: "string" } }, required: ["pdfUrl"] }
90
+ }
91
+ };
92
+
93
+ // Register tools
94
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
95
+ return {
96
+ tools: Object.entries(toolsDefinition).map(([name, def]) => ({
97
+ name,
98
+ description: def.description,
99
+ inputSchema: def.inputSchema,
100
+ }))
101
+ };
102
+ });
103
+
104
+ // Execute logic (Mapping Tools to the API Adapter)
105
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
106
+ const { name, arguments: args } = request.params;
107
+
108
+ if (!toolsDefinition[name]) {
109
+ throw new Error(`Unknown tool: ${name}`);
110
+ }
111
+
112
+ let validatedArgs;
113
+ try {
114
+ validatedArgs = toolsDefinition[name].schema.parse(args || {});
115
+ } catch (error) {
116
+ if (error instanceof z.ZodError) {
117
+ throw new Error(`Invalid arguments: ${error.errors.map(e => e.path.join('.') + ': ' + e.message).join(', ')}`);
118
+ }
119
+ throw error;
120
+ }
121
+
122
+ const params = validatedArgs;
123
+ let result;
124
+
125
+ try {
126
+ switch (name) {
127
+ case 'login_to_whistler': {
128
+ const loginRes = await fetch(`${API_BASE_URL}/auth/login`, {
129
+ method: 'POST',
130
+ headers: { 'Content-Type': 'application/json' },
131
+ body: JSON.stringify({ email: params.email, password: params.password })
132
+ });
133
+
134
+ // Handle non-JSON or weird responses gracefully
135
+ let loginData = {};
136
+ try {
137
+ loginData = await loginRes.json();
138
+ } catch(e) {
139
+ const text = await loginRes.text();
140
+ throw new Error(`Login failed with weird response: ${text.substring(0, 100)}`);
141
+ }
142
+
143
+ if (!loginRes.ok) throw new Error(loginData.message || 'Login failed');
144
+ if (!loginData.token) throw new Error('Login succeeded but no token was returned.');
145
+
146
+ saveToken(loginData.token, loginData);
147
+ result = { success: true, message: `Successfully authenticated! Token saved securely.` };
148
+ break;
149
+ }
150
+ case 'logout_whistler':
151
+ clearToken();
152
+ result = { success: true, message: 'Logged out successfully. Token cleared.' };
153
+ break;
154
+ case 'get_companies':
155
+ result = await invokeApi(`/pdfs/companies${params.year ? '?year='+params.year : ''}`);
156
+ break;
157
+ case 'get_company_roles':
158
+ result = await invokeApi(`/pdfs/company/${params.companyName}${params.year ? '?year='+params.year : ''}`, 'GET');
159
+ break;
160
+ case 'get_public_stats':
161
+ result = await invokeApi('/pdfs/stats');
162
+ break;
163
+ case 'submit_feedback':
164
+ result = await invokeApi('/feedback', 'POST', params);
165
+ break;
166
+ case 'submit_pdf_request':
167
+ result = await invokeApi('/pdf-access/request', 'POST', params);
168
+ break;
169
+ case 'submit_buy_bid':
170
+ result = await invokeApi('/buy-bid', 'POST', params);
171
+ break;
172
+ case 'view_job_pdf': {
173
+ const arrayBuffer = await invokeApiBinary(params.pdfUrl);
174
+ const buffer = Buffer.from(arrayBuffer);
175
+ const pdfData = await pdfParse(buffer);
176
+ result = {
177
+ success: true,
178
+ text: pdfData.text,
179
+ pages: pdfData.numpages,
180
+ message: "PDF successfully read and your daily limit has been updated."
181
+ };
182
+ break;
183
+ }
184
+ }
185
+
186
+ return {
187
+ content: [{ type: "text", text: JSON.stringify(result, null, 2) }]
188
+ };
189
+
190
+ } catch (error) {
191
+ return {
192
+ content: [{ type: "text", text: `Tool Execution Error: ${error.message}` }],
193
+ isError: true
194
+ };
195
+ }
196
+ });
197
+
198
+ // Start the server
199
+ async function main() {
200
+ const transport = new StdioServerTransport();
201
+ await server.connect(transport);
202
+ }
203
+
204
+ main().catch(console.error);