trackly-cli 0.1.3 → 0.1.5

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.
@@ -1,6 +1,27 @@
1
1
  ## Trackly Job Tracker (MCP)
2
2
 
3
- Trackly MCP server provides job search and tracking tools. Add to your Claude Code settings:
3
+ Trackly MCP server provides job search and tracking tools. Start it with:
4
+
5
+ ```bash
6
+ trackly mcp
7
+ ```
8
+
9
+ ### Claude Code
10
+
11
+ ```json
12
+ {
13
+ "mcpServers": {
14
+ "trackly": {
15
+ "command": "trackly",
16
+ "args": ["mcp"]
17
+ }
18
+ }
19
+ }
20
+ ```
21
+
22
+ ### Cursor
23
+
24
+ Add to `.cursor/mcp.json`:
4
25
 
5
26
  ```json
6
27
  {
@@ -13,6 +34,22 @@ Trackly MCP server provides job search and tracking tools. Add to your Claude Co
13
34
  }
14
35
  ```
15
36
 
37
+ ### Authentication
38
+
39
+ Use either interactive OAuth:
40
+
41
+ ```bash
42
+ trackly login
43
+ ```
44
+
45
+ Or an API key:
46
+
47
+ ```bash
48
+ trackly config --api-key trk_xxxxxxxxxxxxxxxxxxxx
49
+ ```
50
+
51
+ You can also pass `TRACKLY_API_KEY` as an environment variable for one-off runs.
52
+
16
53
  ### Available Tools
17
54
 
18
55
  - **trackly_search_jobs** — Search/filter jobs by function, location, modality, keywords
package/lib/client.js CHANGED
@@ -5,33 +5,70 @@ const http = require('http');
5
5
  const fs = require('fs');
6
6
  const path = require('path');
7
7
  const os = require('os');
8
+ const { version: PACKAGE_VERSION } = require('../package.json');
8
9
 
9
- const CONFIG_DIR = path.join(os.homedir(), '.trackly');
10
- const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
10
+ const DEFAULT_CONFIG_DIR = path.join(os.homedir(), '.trackly');
11
11
  const DEFAULT_BASE_URL = 'https://closeai.mba';
12
+ const DEFAULT_HTTP_TIMEOUT_MS = 30000;
13
+ const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1']);
14
+
15
+ class TracklyConfigError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'TracklyConfigError';
19
+ }
20
+ }
21
+
22
+ function getConfigPaths() {
23
+ const dir = process.env.TRACKLY_CONFIG_DIR || DEFAULT_CONFIG_DIR;
24
+ return {
25
+ dir,
26
+ file: path.join(dir, 'config.json'),
27
+ };
28
+ }
12
29
 
13
30
  function loadConfig() {
31
+ const { file } = getConfigPaths();
14
32
  try {
15
- if (fs.existsSync(CONFIG_FILE)) {
16
- return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
33
+ if (fs.existsSync(file)) {
34
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
17
35
  }
18
- } catch (e) {}
36
+ } catch (error) {
37
+ if (error instanceof SyntaxError) {
38
+ throw new TracklyConfigError(`Trackly config is invalid JSON. Fix or delete ${file}.`);
39
+ }
40
+ throw error;
41
+ }
19
42
  return {};
20
43
  }
21
44
 
22
- function saveConfig(config) {
23
- if (!fs.existsSync(CONFIG_DIR)) {
24
- fs.mkdirSync(CONFIG_DIR, { recursive: true, mode: 0o700 });
45
+ function ensureConfigDir() {
46
+ const { dir } = getConfigPaths();
47
+ if (!fs.existsSync(dir)) {
48
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
25
49
  }
26
- fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), { mode: 0o600 });
50
+ try { fs.chmodSync(dir, 0o700); } catch (e) {}
51
+ return dir;
52
+ }
53
+
54
+ function saveConfig(config) {
55
+ ensureConfigDir();
56
+ const { file } = getConfigPaths();
57
+ fs.writeFileSync(file, JSON.stringify(config, null, 2), { mode: 0o600 });
58
+ try { fs.chmodSync(file, 0o600); } catch (e) {}
27
59
  }
28
60
 
29
61
  function clearConfig() {
30
- try { fs.unlinkSync(CONFIG_FILE); } catch (e) {}
62
+ const { file } = getConfigPaths();
63
+ try { fs.unlinkSync(file); } catch (e) {}
31
64
  }
32
65
 
33
66
  function getBaseUrl() {
34
- return loadConfig().baseUrl || DEFAULT_BASE_URL;
67
+ return process.env.TRACKLY_BASE_URL || loadConfig().baseUrl || DEFAULT_BASE_URL;
68
+ }
69
+
70
+ function getApiKey() {
71
+ return process.env.TRACKLY_API_KEY || loadConfig().apiKey || null;
35
72
  }
36
73
 
37
74
  function getToken() {
@@ -42,15 +79,88 @@ function getRefreshToken() {
42
79
  return loadConfig().refreshToken || null;
43
80
  }
44
81
 
82
+ function getAuthMethod() {
83
+ if (getApiKey()) return 'apiKey';
84
+ if (getToken()) return 'token';
85
+ return null;
86
+ }
87
+
88
+ function hasAuth() {
89
+ return Boolean(getAuthMethod());
90
+ }
91
+
92
+ function getAuthHeader(skipAuth = false) {
93
+ if (skipAuth) return null;
94
+
95
+ const apiKey = getApiKey();
96
+ if (apiKey) return `Bearer ${apiKey}`;
97
+
98
+ const token = getToken();
99
+ if (token) return `Bearer ${token}`;
100
+
101
+ return null;
102
+ }
103
+
104
+ function normalizeEndpoint(endpoint, baseUrl = getBaseUrl()) {
105
+ const base = new URL(baseUrl);
106
+ const url = new URL(endpoint, base);
107
+
108
+ if (url.origin !== base.origin) {
109
+ throw new Error(
110
+ `Refusing to send Trackly requests to a different origin (${url.origin}). ` +
111
+ 'Configure TRACKLY_BASE_URL or use `trackly config --base-url <url>` instead.'
112
+ );
113
+ }
114
+
115
+ return url;
116
+ }
117
+
118
+ function isLocalHttpUrl(url) {
119
+ return url.protocol === 'http:' && LOCAL_HOSTNAMES.has(url.hostname);
120
+ }
121
+
122
+ function ensureSecureUrl(url, reason = 'credentials') {
123
+ if (url.protocol === 'https:' || isLocalHttpUrl(url)) {
124
+ return;
125
+ }
126
+
127
+ throw new Error(
128
+ `Refusing to send ${reason} over insecure connection (${url.origin}). Use HTTPS or localhost.`
129
+ );
130
+ }
131
+
132
+ function getRequestTimeoutMs() {
133
+ const configuredValue = process.env.TRACKLY_HTTP_TIMEOUT_MS || loadConfig().timeoutMs;
134
+ const timeoutMs = Number(configuredValue);
135
+
136
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
137
+ return timeoutMs;
138
+ }
139
+
140
+ return DEFAULT_HTTP_TIMEOUT_MS;
141
+ }
142
+
143
+ function getRequestHeaders(skipAuth = false, userAgent = null) {
144
+ const headers = {
145
+ 'Content-Type': 'application/json',
146
+ 'Accept': 'application/json',
147
+ 'User-Agent': userAgent || `trackly-cli/${PACKAGE_VERSION}`,
148
+ };
149
+
150
+ const authHeader = getAuthHeader(skipAuth);
151
+ if (authHeader) {
152
+ headers['Authorization'] = authHeader;
153
+ }
154
+
155
+ return headers;
156
+ }
157
+
45
158
  async function refreshAccessToken() {
46
159
  const rt = getRefreshToken();
47
160
  if (!rt) return null;
48
- const baseUrl = getBaseUrl();
49
- if (!baseUrl.startsWith('https://') && !baseUrl.startsWith('http://localhost')) {
50
- console.error('Warning: Refusing to send refresh token over insecure connection');
51
- return null;
52
- }
161
+
53
162
  try {
163
+ ensureSecureUrl(normalizeEndpoint('/api/auth/refresh'), 'refresh tokens');
54
164
  const result = await apiRequest('POST', '/api/auth/refresh', { refreshToken: rt }, true);
55
165
  if (result.success && result.token) {
56
166
  const config = loadConfig();
@@ -65,20 +175,28 @@ async function refreshAccessToken() {
65
175
 
66
176
  function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry = false, userAgent = null) {
67
177
  return new Promise((resolve, reject) => {
68
- const baseUrl = getBaseUrl();
69
- const url = new URL(endpoint, baseUrl);
178
+ let url;
179
+ try {
180
+ url = normalizeEndpoint(endpoint);
181
+ } catch (error) {
182
+ reject(error);
183
+ return;
184
+ }
185
+
70
186
  const isHttps = url.protocol === 'https:';
71
187
  const lib = isHttps ? https : http;
188
+ const headers = getRequestHeaders(skipAuth, userAgent);
189
+ const authMethod = getAuthMethod();
190
+ const timeoutMs = getRequestTimeoutMs();
191
+ const hasSensitivePayload = Boolean(data && typeof data === 'object' && 'refreshToken' in data);
72
192
 
73
- const headers = {
74
- 'Content-Type': 'application/json',
75
- 'Accept': 'application/json',
76
- 'User-Agent': userAgent || 'trackly-cli/0.1.3',
77
- };
78
-
79
- if (!skipAuth) {
80
- const token = getToken();
81
- if (token) headers['Authorization'] = `Bearer ${token}`;
193
+ try {
194
+ if (headers['Authorization'] || hasSensitivePayload) {
195
+ ensureSecureUrl(url, hasSensitivePayload ? 'refresh tokens' : 'credentials');
196
+ }
197
+ } catch (error) {
198
+ reject(error);
199
+ return;
82
200
  }
83
201
 
84
202
  const options = {
@@ -87,6 +205,7 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
87
205
  path: url.pathname + url.search,
88
206
  method,
89
207
  headers,
208
+ timeout: timeoutMs,
90
209
  };
91
210
 
92
211
  const req = lib.request(options, (res) => {
@@ -94,9 +213,9 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
94
213
  res.on('data', chunk => body += chunk);
95
214
  res.on('end', async () => {
96
215
  let json;
97
- try { json = JSON.parse(body); } catch { json = { raw: body }; }
216
+ try { json = body ? JSON.parse(body) : {}; } catch { json = { raw: body }; }
98
217
 
99
- if (res.statusCode === 401 && !skipAuth && !_isRetry) {
218
+ if (res.statusCode === 401 && !skipAuth && authMethod === 'token' && !_isRetry) {
100
219
  // Try token refresh (once only)
101
220
  const newToken = await refreshAccessToken();
102
221
  if (newToken) {
@@ -115,10 +234,35 @@ function apiRequest(method, endpoint, data = null, skipAuth = false, _isRetry =
115
234
  });
116
235
  });
117
236
 
237
+ req.on('timeout', () => {
238
+ req.destroy(new Error(`Trackly request timed out after ${timeoutMs}ms`));
239
+ });
118
240
  req.on('error', reject);
119
241
  if (data) req.write(JSON.stringify(data));
120
242
  req.end();
121
243
  });
122
244
  }
123
245
 
124
- module.exports = { loadConfig, saveConfig, clearConfig, apiRequest, getToken, CONFIG_DIR, CONFIG_FILE };
246
+ module.exports = {
247
+ DEFAULT_BASE_URL,
248
+ DEFAULT_CONFIG_DIR,
249
+ DEFAULT_HTTP_TIMEOUT_MS,
250
+ TracklyConfigError,
251
+ apiRequest,
252
+ clearConfig,
253
+ ensureSecureUrl,
254
+ getApiKey,
255
+ getAuthHeader,
256
+ getAuthMethod,
257
+ getBaseUrl,
258
+ getConfigPaths,
259
+ getRequestHeaders,
260
+ getRequestTimeoutMs,
261
+ getToken,
262
+ hasAuth,
263
+ loadConfig,
264
+ normalizeEndpoint,
265
+ saveConfig,
266
+ CONFIG_DIR: DEFAULT_CONFIG_DIR,
267
+ CONFIG_FILE: path.join(DEFAULT_CONFIG_DIR, 'config.json'),
268
+ };
package/mcp/server.js CHANGED
@@ -3,163 +3,190 @@
3
3
  const { McpServer } = require('@modelcontextprotocol/sdk/server/mcp.js');
4
4
  const { StdioServerTransport } = require('@modelcontextprotocol/sdk/server/stdio.js');
5
5
  const { z } = require('zod');
6
- const { apiRequest, getToken } = require('../lib/client');
7
-
8
- const MCP_USER_AGENT = 'trackly-mcp/0.1.3';
9
-
10
- const server = new McpServer({
11
- name: 'trackly',
12
- version: '0.1.3',
13
- });
14
-
15
- // Tool: Search/filter jobs
16
- server.tool(
17
- 'trackly_search_jobs',
18
- 'Search and filter job postings. Returns matching jobs with title, company, location, modality.',
19
- {
20
- function: z.enum(['product_management','engineering','design','data_science','marketing','sales','finance','operations','legal','hr','other']).optional().describe('Job function filter: product_management, engineering, design, data_science, marketing, sales, finance, operations, legal, hr, other'),
21
- location: z.string().optional().describe('Location filter (city or state)'),
22
- modality: z.enum(['remote','hybrid','onsite']).optional().describe('Work modality: remote, hybrid, onsite'),
23
- status: z.enum(['new','saved','applied','dismissed']).optional().describe('Application status: new, saved, applied, dismissed'),
24
- sort: z.enum(['newest','oldest','company']).optional().describe('Sort order: newest, oldest, company'),
25
- limit: z.number().max(50).optional().describe('Max results (default 20, max 50)'),
26
- offset: z.number().min(0).optional().describe('Pagination offset'),
27
- keywords: z.string().max(500).optional().describe('Keyword search in title/description'),
28
- },
29
- async (params) => {
30
- const qs = new URLSearchParams();
31
- for (const [k, v] of Object.entries(params)) {
32
- if (v !== undefined && v !== null) qs.set(k, String(v));
33
- }
34
- try {
35
- const result = await apiRequest('GET', `/api/jobscout/jobs?${qs.toString()}`, null, false, false, MCP_USER_AGENT);
36
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
37
- } catch (e) {
38
- return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
39
- }
40
- }
41
- );
42
-
43
- // Tool: Get job detail
44
- server.tool(
45
- 'trackly_get_job',
46
- 'Get full details for a specific job posting including description.',
47
- {
48
- id: z.number().describe('Job posting ID'),
49
- },
50
- async ({ id }) => {
51
- try {
52
- const result = await apiRequest('GET', `/api/jobscout/jobs/${id}`, null, false, false, MCP_USER_AGENT);
53
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
54
- } catch (e) {
55
- return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
56
- }
57
- }
58
- );
59
-
60
- // Tool: Search companies
61
- server.tool(
62
- 'trackly_search_companies',
63
- 'Semantic search for companies by name, domain, or keywords.',
64
- {
65
- query: z.string().max(500).describe('Search query'),
66
- limit: z.number().max(50).optional().describe('Max results (default 10)'),
67
- },
68
- async ({ query, limit }) => {
69
- const qs = new URLSearchParams({ q: query });
70
- if (limit) qs.set('limit', String(limit));
71
- try {
72
- const result = await apiRequest('GET', `/api/jobscout/companies/search?${qs.toString()}`, null, false, false, MCP_USER_AGENT);
73
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
74
- } catch (e) {
75
- return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
76
- }
77
- }
78
- );
79
-
80
- // Tool: List companies
81
- server.tool(
82
- 'trackly_list_companies',
83
- 'List all tracked companies with their active job counts.',
84
- {
85
- limit: z.number().max(50).optional().describe('Max results'),
86
- offset: z.number().min(0).optional().describe('Pagination offset'),
87
- },
88
- async ({ limit, offset }) => {
89
- const qs = new URLSearchParams();
90
- if (limit) qs.set('limit', String(limit));
91
- if (offset) qs.set('offset', String(offset));
92
- try {
93
- const result = await apiRequest('GET', `/api/jobscout/companies?${qs.toString()}`, null, false, false, MCP_USER_AGENT);
94
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
95
- } catch (e) {
96
- return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
97
- }
6
+ const { apiRequest, hasAuth } = require('../lib/client');
7
+ const { version: PACKAGE_VERSION } = require('../package.json');
8
+
9
+ const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
10
+ const AUTH_HINT = 'Run `trackly login` or configure an API key with `trackly config --api-key <key>`.';
11
+
12
+ function createErrorResult(error, fallbackMessage, extra = {}) {
13
+ const payload = {
14
+ error: error?.error || error?.message || fallbackMessage,
15
+ ...extra,
16
+ };
17
+
18
+ if (error?.status) {
19
+ payload.status = error.status;
98
20
  }
99
- );
100
-
101
- // Tool: Get stats
102
- server.tool(
103
- 'trackly_get_stats',
104
- 'Get job tracker metrics: total jobs, companies, application status counts.',
105
- {},
106
- async () => {
107
- try {
108
- const result = await apiRequest('GET', '/api/jobscout/me', null, false, false, MCP_USER_AGENT);
109
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
110
- } catch (e) {
111
- return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
112
- }
21
+
22
+ if (error?.status === 429 && !payload.hint) {
23
+ payload.hint = 'Daily limit reached (20 natural language queries per day).';
113
24
  }
114
- );
115
-
116
- // Tool: Update job status
117
- server.tool(
118
- 'trackly_update_status',
119
- 'Update a job application status (apply, save, or dismiss).',
120
- {
121
- id: z.number().describe('Job posting ID'),
122
- action: z.enum(['applied', 'saved', 'dismissed']).describe('Status action'),
123
- },
124
- async ({ id, action }) => {
25
+
26
+ return {
27
+ content: [{ type: 'text', text: JSON.stringify(payload, null, 2) }],
28
+ isError: true,
29
+ };
30
+ }
31
+
32
+ function createAuthErrorResult() {
33
+ return createErrorResult(
34
+ { message: 'Not authenticated', status: 401 },
35
+ 'Not authenticated',
36
+ { hint: AUTH_HINT }
37
+ );
38
+ }
39
+
40
+ function wrapTool(handler, fallbackMessage) {
41
+ return async (params) => {
125
42
  try {
126
- const result = await apiRequest('POST', '/api/jobscout-tracker/status', { jobId: id, action }, false, false, MCP_USER_AGENT);
127
- return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
128
- } catch (e) {
129
- return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
43
+ if (!hasAuth()) {
44
+ return createAuthErrorResult();
45
+ }
46
+
47
+ const result = await handler(params);
48
+ return {
49
+ content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
50
+ };
51
+ } catch (error) {
52
+ return createErrorResult(
53
+ error,
54
+ fallbackMessage,
55
+ error?.status === 401 ? { hint: AUTH_HINT } : {}
56
+ );
130
57
  }
131
- }
132
- );
133
-
134
- // Tool: Natural language search
135
- server.tool(
136
- 'trackly_ask',
137
- 'Natural language job search. Describe what you are looking for and the AI parses it into structured filters. Limited to 20 queries per day.',
138
- {
139
- query: z.string().max(500).describe('Natural language search query, e.g. "PM jobs at fintech companies in SF"'),
140
- },
141
- async ({ query }) => {
142
- try {
58
+ };
59
+ }
60
+
61
+ function createServer() {
62
+ const server = new McpServer({
63
+ name: 'trackly',
64
+ version: PACKAGE_VERSION,
65
+ });
66
+
67
+ server.tool(
68
+ 'trackly_search_jobs',
69
+ 'Search and filter job postings. Returns matching jobs with title, company, location, modality.',
70
+ {
71
+ function: z.enum(['product_management', 'engineering', 'design', 'data_science', 'marketing', 'sales', 'finance', 'operations', 'legal', 'hr', 'other']).optional().describe('Job function filter: product_management, engineering, design, data_science, marketing, sales, finance, operations, legal, hr, other'),
72
+ location: z.string().optional().describe('Location filter (city or state)'),
73
+ modality: z.enum(['remote', 'hybrid', 'onsite']).optional().describe('Work modality: remote, hybrid, onsite'),
74
+ status: z.enum(['new', 'saved', 'applied', 'dismissed']).optional().describe('Application status: new, saved, applied, dismissed'),
75
+ sort: z.enum(['newest', 'oldest', 'company']).optional().describe('Sort order: newest, oldest, company'),
76
+ limit: z.number().max(50).optional().describe('Max results (default 20, max 50)'),
77
+ offset: z.number().min(0).optional().describe('Pagination offset'),
78
+ keywords: z.string().max(500).optional().describe('Keyword search in title/description'),
79
+ },
80
+ wrapTool(async (params) => {
81
+ const qs = new URLSearchParams();
82
+ for (const [key, value] of Object.entries(params)) {
83
+ if (value !== undefined && value !== null) qs.set(key, String(value));
84
+ }
85
+ return apiRequest('GET', `/api/jobscout/jobs?${qs.toString()}`, null, false, false, MCP_USER_AGENT);
86
+ }, 'Failed to search jobs')
87
+ );
88
+
89
+ server.tool(
90
+ 'trackly_get_job',
91
+ 'Get full details for a specific job posting including description.',
92
+ {
93
+ id: z.number().describe('Job posting ID'),
94
+ },
95
+ wrapTool(async ({ id }) => {
96
+ return apiRequest('GET', `/api/jobscout/jobs/${id}`, null, false, false, MCP_USER_AGENT);
97
+ }, 'Failed to fetch job')
98
+ );
99
+
100
+ server.tool(
101
+ 'trackly_search_companies',
102
+ 'Semantic search for companies by name, domain, or keywords.',
103
+ {
104
+ query: z.string().max(500).describe('Search query'),
105
+ limit: z.number().max(50).optional().describe('Max results (default 10)'),
106
+ },
107
+ wrapTool(async ({ query, limit }) => {
108
+ const qs = new URLSearchParams({ q: query });
109
+ if (limit) qs.set('limit', String(limit));
110
+ return apiRequest('GET', `/api/jobscout/companies/search?${qs.toString()}`, null, false, false, MCP_USER_AGENT);
111
+ }, 'Failed to search companies')
112
+ );
113
+
114
+ server.tool(
115
+ 'trackly_list_companies',
116
+ 'List all tracked companies with their active job counts.',
117
+ {
118
+ limit: z.number().max(50).optional().describe('Max results'),
119
+ offset: z.number().min(0).optional().describe('Pagination offset'),
120
+ },
121
+ wrapTool(async ({ limit, offset }) => {
122
+ const qs = new URLSearchParams();
123
+ if (limit) qs.set('limit', String(limit));
124
+ if (offset !== undefined) qs.set('offset', String(offset));
125
+ return apiRequest('GET', `/api/jobscout/companies?${qs.toString()}`, null, false, false, MCP_USER_AGENT);
126
+ }, 'Failed to list companies')
127
+ );
128
+
129
+ server.tool(
130
+ 'trackly_get_stats',
131
+ 'Get job tracker metrics: total jobs, companies, application status counts.',
132
+ {},
133
+ wrapTool(async () => {
134
+ return apiRequest('GET', '/api/jobscout/me', null, false, false, MCP_USER_AGENT);
135
+ }, 'Failed to fetch stats')
136
+ );
137
+
138
+ server.tool(
139
+ 'trackly_update_status',
140
+ 'Update a job application status (apply, save, or dismiss).',
141
+ {
142
+ id: z.number().describe('Job posting ID'),
143
+ action: z.enum(['applied', 'saved', 'dismissed']).describe('Status action'),
144
+ },
145
+ wrapTool(async ({ id, action }) => {
146
+ return apiRequest('POST', '/api/jobscout-tracker/status', { jobId: id, action }, false, false, MCP_USER_AGENT);
147
+ }, 'Failed to update job status')
148
+ );
149
+
150
+ server.tool(
151
+ 'trackly_ask',
152
+ 'Natural language job search. Describe what you are looking for and the AI parses it into structured filters. Limited to 20 queries per day.',
153
+ {
154
+ query: z.string().max(500).describe('Natural language search query, e.g. "PM jobs at fintech companies in SF"'),
155
+ },
156
+ wrapTool(async ({ query }) => {
143
157
  const askResult = await apiRequest('GET', `/api/jobscout/ask?q=${encodeURIComponent(query)}`, null, false, false, MCP_USER_AGENT);
144
- // Auto-fetch jobs with parsed filters
145
- if (askResult.jobsUrl && askResult.jobsUrl.startsWith('/api/')) {
158
+ if (askResult.jobsUrl) {
146
159
  const jobsResult = await apiRequest('GET', askResult.jobsUrl, null, false, false, MCP_USER_AGENT);
147
- return { content: [{ type: 'text', text: JSON.stringify({ ...askResult, jobs: jobsResult.jobs || jobsResult.data || [] }, null, 2) }] };
160
+ return {
161
+ ...askResult,
162
+ jobs: jobsResult.jobs || jobsResult.data || [],
163
+ };
148
164
  }
149
- return { content: [{ type: 'text', text: JSON.stringify(askResult, null, 2) }] };
150
- } catch (e) {
151
- return { content: [{ type: 'text', text: JSON.stringify({ error: e.error || e.message }) }], isError: true };
152
- }
153
- }
154
- );
165
+ return askResult;
166
+ }, 'Failed to process natural language query')
167
+ );
168
+
169
+ return server;
170
+ }
155
171
 
156
- // Start server
157
- async function main() {
172
+ async function startMcpServer() {
173
+ const server = createServer();
158
174
  const transport = new StdioServerTransport();
159
175
  await server.connect(transport);
176
+ return server;
177
+ }
178
+
179
+ if (require.main === module) {
180
+ startMcpServer().catch((error) => {
181
+ console.error('MCP server error:', error);
182
+ process.exit(1);
183
+ });
160
184
  }
161
185
 
162
- main().catch((e) => {
163
- console.error('MCP server error:', e);
164
- process.exit(1);
165
- });
186
+ module.exports = {
187
+ AUTH_HINT,
188
+ createAuthErrorResult,
189
+ createErrorResult,
190
+ createServer,
191
+ startMcpServer,
192
+ };