trackly-cli 0.1.3 → 0.1.4
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 +54 -7
- package/bin/trackly +446 -122
- package/docs/trackly-tools.md +38 -1
- package/lib/client.js +174 -30
- package/mcp/server.js +175 -148
- package/package.json +5 -1
- package/server.json +42 -0
package/docs/trackly-tools.md
CHANGED
|
@@ -1,6 +1,27 @@
|
|
|
1
1
|
## Trackly Job Tracker (MCP)
|
|
2
2
|
|
|
3
|
-
Trackly MCP server provides job search and tracking tools.
|
|
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
|
|
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(
|
|
16
|
-
return JSON.parse(fs.readFileSync(
|
|
33
|
+
if (fs.existsSync(file)) {
|
|
34
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
17
35
|
}
|
|
18
|
-
} catch (
|
|
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
|
|
23
|
-
|
|
24
|
-
|
|
45
|
+
function ensureConfigDir() {
|
|
46
|
+
const { dir } = getConfigPaths();
|
|
47
|
+
if (!fs.existsSync(dir)) {
|
|
48
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
25
49
|
}
|
|
26
|
-
fs.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
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
|
-
|
|
74
|
-
'
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
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 = {
|
|
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,
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
const
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
135
|
-
server
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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 {
|
|
160
|
+
return {
|
|
161
|
+
...askResult,
|
|
162
|
+
jobs: jobsResult.jobs || jobsResult.data || [],
|
|
163
|
+
};
|
|
148
164
|
}
|
|
149
|
-
return
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
165
|
+
return askResult;
|
|
166
|
+
}, 'Failed to process natural language query')
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
return server;
|
|
170
|
+
}
|
|
155
171
|
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
186
|
+
module.exports = {
|
|
187
|
+
AUTH_HINT,
|
|
188
|
+
createAuthErrorResult,
|
|
189
|
+
createErrorResult,
|
|
190
|
+
createServer,
|
|
191
|
+
startMcpServer,
|
|
192
|
+
};
|
package/package.json
CHANGED
|
@@ -1,16 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackly-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"mcpName": "io.github.kevinastuhuaman/trackly",
|
|
5
5
|
"description": "CLI + MCP server for job tracking — search, apply, and manage applications from terminal or AI agents",
|
|
6
6
|
"bin": {
|
|
7
7
|
"trackly": "./bin/trackly"
|
|
8
8
|
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"test": "node --test"
|
|
11
|
+
},
|
|
9
12
|
"files": [
|
|
10
13
|
"bin/",
|
|
11
14
|
"lib/",
|
|
12
15
|
"mcp/",
|
|
13
16
|
"docs/",
|
|
17
|
+
"server.json",
|
|
14
18
|
"README.md",
|
|
15
19
|
"LICENSE"
|
|
16
20
|
],
|