trackly-cli 0.21.1 → 0.22.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 +5 -3
- package/docs/solutions/integration-issues/analytics-must-preserve-strict-mcp-schemas.md +14 -0
- package/docs/solutions/integration-issues/job-lookups-need-availability-and-retry-guidance.md +44 -0
- package/docs/trackly-tools.md +6 -2
- package/lib/client.js +4 -2
- package/lib/job-result.js +109 -0
- package/mcp/analytics.js +63 -2
- package/mcp/server.js +38 -17
- package/npm-shrinkwrap.json +2 -2
- package/package.json +1 -1
- package/server.json +2 -2
package/README.md
CHANGED
|
@@ -210,7 +210,7 @@ The hosted server omits those four and adds `trackly_chat`, for 55 hosted tools.
|
|
|
210
210
|
| Tool | Description |
|
|
211
211
|
|------|-------------|
|
|
212
212
|
| trackly_search_jobs | Search by roles, regions, states/provinces, internship level, favorites, recency, annual USD salary floor, sponsorship, work arrangement, and pipeline status |
|
|
213
|
-
| trackly_get_job | Get
|
|
213
|
+
| trackly_get_job | Get details by Trackly ID, with availability and retry guidance |
|
|
214
214
|
| trackly_search_companies | Semantic company search |
|
|
215
215
|
| trackly_list_companies | List all tracked companies |
|
|
216
216
|
| trackly_get_stats | Job tracker metrics and status counts |
|
|
@@ -218,7 +218,7 @@ The hosted server omits those four and adds `trackly_chat`, for 55 hosted tools.
|
|
|
218
218
|
| trackly_update_experience_limits | Atomically replace role-specific limits on a job's stated minimum years |
|
|
219
219
|
| trackly_update_status | Mark jobs as applied, saved, or dismissed |
|
|
220
220
|
| trackly_ask | Natural language job search (20/day) |
|
|
221
|
-
| trackly_get_job_brief | Get network brief
|
|
221
|
+
| trackly_get_job_brief | Get a network brief, with availability and retry guidance |
|
|
222
222
|
| trackly_contacts_at_company | Search contacts at a specific company |
|
|
223
223
|
| trackly_get_company_workspace | Full company workspace (jobs, contacts, hiring managers, campaigns) |
|
|
224
224
|
| trackly_request_company | Request a company be added to Trackly (rate-limited to 5 pending) |
|
|
@@ -353,7 +353,9 @@ a missing capability. Run `trackly mcp` or use
|
|
|
353
353
|
|
|
354
354
|
MCP usage analytics are on by default and relay through Trackly so the free
|
|
355
355
|
service can be debugged and improved. They exclude résumé text, profile answers,
|
|
356
|
-
demographic or work-authorization answers, and application notes.
|
|
356
|
+
demographic or work-authorization answers, and application notes. Client attribution
|
|
357
|
+
uses a fixed family label; errors use categories, and search text is retained only
|
|
358
|
+
as length. Raw queries and conversation text are excluded. Turn off future
|
|
357
359
|
collection with **Share usage analytics** in Account Settings. Operators may also
|
|
358
360
|
disable local MCP instrumentation with `TRACKLY_MCP_ANALYTICS_DISABLED=1` (or
|
|
359
361
|
`TRACKLY_MCP_ANALYTICS_ENABLED=0`); the account setting remains authoritative
|
|
@@ -37,3 +37,17 @@ Verification: `node --test test/mcp-analytics.test.js test/mcp-schema.test.js`
|
|
|
37
37
|
and `npm test`. The real transport regression failed with the old instrumentation
|
|
38
38
|
and passed after the fix. Keyword telemetry continues to retain length only;
|
|
39
39
|
this change does not permit raw query or context storage.
|
|
40
|
+
|
|
41
|
+
Error and client attribution also need real SDK coverage. The SDK's error type
|
|
42
|
+
may be a JavaScript constructor name, and its vendor field comes from HTTP
|
|
43
|
+
headers that stdio does not have. Normalize both at the privacy boundary:
|
|
44
|
+
derive a closed error category from the failure and a closed client family from
|
|
45
|
+
available metadata, with explicit `none`, `other` and `unknown` states.
|
|
46
|
+
|
|
47
|
+
The HTTP client rejects response records containing numeric status and an
|
|
48
|
+
`error` field. Capturing only `message` loses that status and substitutes a
|
|
49
|
+
generic fallback. Preserve the status in the telemetry-only exception and give
|
|
50
|
+
it precedence over endpoint words such as "network brief". Verify job detail
|
|
51
|
+
and brief calls through a local HTTP fixture with 404, 429 and 500 responses;
|
|
52
|
+
assert unchanged tool results and classified, content-free telemetry. Sanitize
|
|
53
|
+
the result twice because the SDK hook and backend relay both project events.
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Job lookups need availability and retry guidance
|
|
3
|
+
date: 2026-10-20
|
|
4
|
+
category: integration-issues
|
|
5
|
+
module: MCP job lookups
|
|
6
|
+
problem_type: integration_issue
|
|
7
|
+
severity: medium
|
|
8
|
+
symptoms:
|
|
9
|
+
- Expected missing postings appear as tool exceptions
|
|
10
|
+
- Agents repeat unavailable job lookups without a useful next action
|
|
11
|
+
root_cause: missing_error_handling
|
|
12
|
+
resolution_type: code_fix
|
|
13
|
+
applies_when:
|
|
14
|
+
- Changing job detail or brief MCP response handling
|
|
15
|
+
tags: [mcp, jobs, availability, errors, retry]
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
A generic HTTP error wrapper cannot decide whether a 404 means an unavailable
|
|
19
|
+
posting or a broken route. Normalize only the job endpoint's recognized missing
|
|
20
|
+
envelopes (including their explicit failure flag) and availability flags. Preserve unrelated 404s and genuine
|
|
21
|
+
service failures as errors. An absent flag is unknown, never evidence of closure.
|
|
22
|
+
|
|
23
|
+
Keep this decision inside the job tools. Generic HTTP clients and Apply mutations
|
|
24
|
+
have different error and retry contracts. Return ordinary structured results for
|
|
25
|
+
expected unavailability, with a fixed explanation, retryable=false and another
|
|
26
|
+
search as the next step. Closed briefs retain allowed context while omitting obsolete recommended motions and application actions.
|
|
27
|
+
For real errors, expose a closed category and bounded retry guidance that honors longer valid API delays and HTTP Retry-After headers, while
|
|
28
|
+
retaining the original failure only for the existing content-free telemetry path.
|
|
29
|
+
|
|
30
|
+
Test through a real MCP client and local HTTP fixture, including recognized job
|
|
31
|
+
404s, unrelated route 404s, closed/unknown availability, auth, rate limits and
|
|
32
|
+
server failures. Check actual network-request counts and diagnostic events;
|
|
33
|
+
expected unavailable results must not emit exceptions. Validate numeric internal
|
|
34
|
+
IDs before HTTP, and keep SDK validation failures separate from endpoint errors.
|
|
35
|
+
|
|
36
|
+
Sanitized stack coordinates alone do not identify a backend response or rejected
|
|
37
|
+
argument. Reproduce emitted source using the exact build options and version,
|
|
38
|
+
and distinguish the throw-site proof from synthetic input examples. Never report
|
|
39
|
+
a fixture's invalid ID as the recovered cause of a historical incident.
|
|
40
|
+
|
|
41
|
+
Carry Retry-After metadata through the HTTP client without adding retries. Parse
|
|
42
|
+
only positive safe integer seconds or a standard HTTP date, choose the longer
|
|
43
|
+
body/header delay, and preserve it when applying transient access guidance. Test
|
|
44
|
+
these failure branches; healthy responses never exercise them.
|
package/docs/trackly-tools.md
CHANGED
|
@@ -100,7 +100,7 @@ API-key creation attempts.
|
|
|
100
100
|
Example: `{"jobFunctions":["finance","partnerships"],"region":["us","europe"],"sponsorship":"exclude_no","postedWithinDays":7,"limit":20}`.
|
|
101
101
|
The local package and hosted service have separate release lifecycles. Refresh the client's tool discovery after upgrading or reconnecting and use only parameters its current schema advertises.
|
|
102
102
|
|
|
103
|
-
- **trackly_get_job** — Get full job details by ID
|
|
103
|
+
- **trackly_get_job** — Get full job details by positive numeric Trackly ID from search or the saved queue. Employer requisition IDs and URLs are not accepted.
|
|
104
104
|
- **trackly_search_companies** — Semantic company search
|
|
105
105
|
- **trackly_list_companies** — List all tracked companies with job counts
|
|
106
106
|
- **trackly_get_stats** — Job tracker metrics dashboard
|
|
@@ -112,7 +112,11 @@ The local package and hosted service have separate release lifecycles. Refresh t
|
|
|
112
112
|
- When server-side enforcement is active, a job remains visible when its stated minimum is less than or equal to that role's limit. Jobs with no stated minimum remain visible.
|
|
113
113
|
- **trackly_update_status** — Mark a job as applied, saved, or dismissed
|
|
114
114
|
- **trackly_ask** — Natural language job search (20/day limit)
|
|
115
|
-
- **trackly_get_job_brief** — Get network brief for a job (company signal, top contact, actions)
|
|
115
|
+
- **trackly_get_job_brief** — Get network brief for a positive numeric Trackly job ID (company signal, top contact, actions).
|
|
116
|
+
|
|
117
|
+
Both job tools return JSON text and matching `structuredContent`. A recognized missing or removed posting, or an explicitly inactive posting, returns `availability: "unavailable"`, a reason, `retryable: false`, and a `nextStep` directing the agent to search for current openings. This is a normal tool result. Closed briefs retain their allowed stored company and role context, but omit stale recommended motions and application actions. A missing availability flag means unknown, not closed; detecting closed briefs requires a backend that supplies that flag.
|
|
118
|
+
|
|
119
|
+
Actual failures retain `isError: true`, HTTP status when available, a closed `category`, `retryable`, `maxRetries`, and a next step. Categories are `authentication`, `authorization`, `validation`, `not_found`, `conflict`, `rate_limit`, `timeout`, `server`, `network`, `maintenance`, `access`, or `unknown`. Transient read failures permit one retry after `retryAfterSeconds`, honoring the longer valid API body or HTTP `Retry-After` delay (seconds or HTTP date); the tools add no automatic retry loop. The `nextStep.message` always explains what to do; `nextStep.tool` is present when a corresponding MCP tool exists. Authentication and invalid-input failures require a corrected request first. A route or unrelated dependency 404 remains an error. Invalid argument shapes are rejected by MCP schema validation before any API call; correct the arguments before retrying.
|
|
116
120
|
- **trackly_contacts_at_company** — Search contacts at a specific company
|
|
117
121
|
- **trackly_get_company_workspace** — Get full company workspace (jobs, contacts, hiring managers, campaigns)
|
|
118
122
|
- **trackly_request_company** — Request that a company be added to Trackly's tracked companies. Rate-limited to 5 pending requests per user. Parameters: `companyName` (required), `companyUrl` (optional), `notes` (optional).
|
package/lib/client.js
CHANGED
|
@@ -843,15 +843,17 @@ function apiRequest(
|
|
|
843
843
|
headers: res.headers,
|
|
844
844
|
});
|
|
845
845
|
if (maintenanceError) return reject(maintenanceError);
|
|
846
|
+
const retryHeader = res.headers?.['retry-after'];
|
|
847
|
+
const retryMetadata = typeof retryHeader === 'string' ? { retryAfterHeader: retryHeader } : {};
|
|
846
848
|
const accessError = createTracklyAccessError(json, res.statusCode);
|
|
847
|
-
if (accessError) return reject(accessError);
|
|
849
|
+
if (accessError) return reject({ ...accessError, ...retryMetadata });
|
|
848
850
|
// Spread `json` FIRST so the HTTP statusCode always wins. A backend that returns
|
|
849
851
|
// `{"status": "error"}` in the body would otherwise clobber the numeric status with
|
|
850
852
|
// a string, breaking downstream branches like `typeof e.status === 'number'` in
|
|
851
853
|
// refreshAccessToken — which would silently skip the 400/401/403 token-clear and
|
|
852
854
|
// leave dead refresh tokens on disk. (Cursor Bugbot finding #5 on PR #20.)
|
|
853
855
|
const record = json && typeof json === 'object' ? json : {};
|
|
854
|
-
reject({ ...record, status: res.statusCode });
|
|
856
|
+
reject({ ...record, status: res.statusCode, ...retryMetadata });
|
|
855
857
|
} else {
|
|
856
858
|
resolve(json);
|
|
857
859
|
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const NEXT_SEARCH = {
|
|
4
|
+
tool: 'trackly_search_jobs',
|
|
5
|
+
message: 'Search for current openings or use another ID from search or your saved queue. Do not retry this job ID.',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
function storedBrief(brief) {
|
|
9
|
+
const { recommendedMotion, nextActions, actions, ...stored } = brief;
|
|
10
|
+
return stored;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function unavailableJob(jobId, reason, result = {}) {
|
|
14
|
+
return {
|
|
15
|
+
...(result.job ? { job: result.job }
|
|
16
|
+
: result.brief ? { brief: storedBrief(result.brief) } : storedBrief(result)),
|
|
17
|
+
success: true,
|
|
18
|
+
availability: 'unavailable',
|
|
19
|
+
reason,
|
|
20
|
+
jobId,
|
|
21
|
+
message: 'This job is no longer available.',
|
|
22
|
+
retryable: false,
|
|
23
|
+
maxRetries: 0,
|
|
24
|
+
nextStep: { ...NEXT_SEARCH },
|
|
25
|
+
// Stored context remains useful; stale networking recommendations do not.
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function normalizeJobResult(result, jobId) {
|
|
30
|
+
const posting = result?.job || result?.brief || result;
|
|
31
|
+
// isActive is authoritative. Some older projections collapse unknown into
|
|
32
|
+
// active=false, so never let that legacy flag override an explicit unknown.
|
|
33
|
+
const active = posting && Object.hasOwn(posting, 'isActive') ? posting.isActive : posting?.active;
|
|
34
|
+
return active === false
|
|
35
|
+
? unavailableJob(jobId, 'closed', result)
|
|
36
|
+
: result;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function unavailableJobFromError(error, jobId) {
|
|
40
|
+
if (error?.status !== 404 && error?.status !== 410) return null;
|
|
41
|
+
const code = error.code || error.error;
|
|
42
|
+
const reason = code === 'job_not_found' ? 'not_found'
|
|
43
|
+
: code === 'job_removed' ? 'removed'
|
|
44
|
+
: code === 'job_closed' ? 'closed' : null;
|
|
45
|
+
if (reason) return unavailableJob(jobId, reason);
|
|
46
|
+
// These are the two existing job-endpoint envelopes. A missing route, brief
|
|
47
|
+
// dependency or unrelated resource must remain an error even at HTTP 404.
|
|
48
|
+
if (error.status === 404 && error.success === false && (
|
|
49
|
+
error.error === 'Job not found' || error.error === `Job ${jobId} not found`
|
|
50
|
+
)) return unavailableJob(jobId, 'not_found');
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function retryDelayFromHeader(value) {
|
|
55
|
+
if (typeof value !== 'string') return 0;
|
|
56
|
+
const text = value.trim();
|
|
57
|
+
let seconds = 0;
|
|
58
|
+
if (/^\d+$/.test(text)) seconds = Number(text);
|
|
59
|
+
else if (/^[A-Za-z]{3}, \d{2} [A-Za-z]{3} \d{4} \d{2}:\d{2}:\d{2} GMT$/.test(text)) {
|
|
60
|
+
seconds = Math.ceil((Date.parse(text) - Date.now()) / 1000);
|
|
61
|
+
}
|
|
62
|
+
return Number.isSafeInteger(seconds) && seconds > 0 ? seconds : 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function jobFailureGuidance(error) {
|
|
66
|
+
const status = error?.status;
|
|
67
|
+
let category = 'unknown';
|
|
68
|
+
if (Number.isInteger(status)) {
|
|
69
|
+
if (status === 401) category = 'authentication';
|
|
70
|
+
else if (status === 403) category = 'authorization';
|
|
71
|
+
else if ([400, 413, 422].includes(status)) category = 'validation';
|
|
72
|
+
else if ([404, 410].includes(status)) category = 'not_found';
|
|
73
|
+
else if (status === 408) category = 'timeout';
|
|
74
|
+
else if (status === 409) category = 'conflict';
|
|
75
|
+
else if (status === 429) category = 'rate_limit';
|
|
76
|
+
else if (status >= 500 && status <= 599) category = 'server';
|
|
77
|
+
} else if (error?.code === 'ETIMEDOUT' || /timed out/i.test(error?.message || '')) {
|
|
78
|
+
category = 'timeout';
|
|
79
|
+
} else if (['ECONNRESET', 'ECONNREFUSED', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE'].includes(error?.code)) {
|
|
80
|
+
category = 'network';
|
|
81
|
+
}
|
|
82
|
+
const retryable = ['rate_limit', 'timeout', 'server', 'network'].includes(category);
|
|
83
|
+
const declaredDelay = error?.retryAfterSeconds;
|
|
84
|
+
const retryAfterSeconds = Math.max(category === 'rate_limit' ? 60 : 5,
|
|
85
|
+
retryDelayFromHeader(error?.retryAfterHeader),
|
|
86
|
+
Number.isSafeInteger(declaredDelay) && declaredDelay > 0 ? declaredDelay : 0);
|
|
87
|
+
const messages = {
|
|
88
|
+
authentication: 'Sign in again with trackly login or refresh the configured credential before trying again. Do not retry unchanged credentials.',
|
|
89
|
+
authorization: 'Check account access before trying again. Repeating the same request will not grant access.',
|
|
90
|
+
validation: 'Use the positive numeric Trackly job ID returned by search or your saved queue, then correct the request. Do not retry unchanged arguments.',
|
|
91
|
+
not_found: 'The requested route or a brief dependency was not found. Search for a current posting or report the tool failure. Do not retry unchanged.',
|
|
92
|
+
conflict: 'Refresh the job state and resolve the conflict before trying again. Do not retry unchanged.',
|
|
93
|
+
rate_limit: 'Wait for the rate limit to reset before retrying this read once. Do not retry in a loop.',
|
|
94
|
+
timeout: 'The read timed out. Wait briefly, then retry once. If it still fails, stop and try later.',
|
|
95
|
+
server: 'Trackly could not complete this read. Wait briefly, then retry once. If it still fails, stop and try later.',
|
|
96
|
+
network: 'Check the connection, then retry this read once. If it still fails, stop and try later.',
|
|
97
|
+
unknown: 'The failure could not be classified. Check the request or report the tool failure. Do not retry unchanged.',
|
|
98
|
+
};
|
|
99
|
+
return {
|
|
100
|
+
category,
|
|
101
|
+
retryable,
|
|
102
|
+
maxRetries: retryable ? 1 : 0,
|
|
103
|
+
...(retryable ? { retryAfterSeconds } : {}),
|
|
104
|
+
error: 'Unable to retrieve this job right now.',
|
|
105
|
+
nextStep: { message: messages[category] },
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
module.exports = { normalizeJobResult, unavailableJobFromError, jobFailureGuidance };
|
package/mcp/analytics.js
CHANGED
|
@@ -118,6 +118,28 @@ const SAFE_ENUM_KEYS = new Set([
|
|
|
118
118
|
'region', 'regiontag', 'locationfilter', 'sort', 'sponsorship',
|
|
119
119
|
]);
|
|
120
120
|
const SDK_FAILURE_WARNING = /Warning: (?:Failed to instrument server|Failed to setup tool call instrumentation|No PostHog client passed)/i;
|
|
121
|
+
const ERROR_CATEGORIES = new Set([
|
|
122
|
+
'authentication', 'authorization', 'rate_limit', 'timeout', 'not_found',
|
|
123
|
+
'validation', 'conflict', 'network', 'server', 'unknown',
|
|
124
|
+
]);
|
|
125
|
+
const HTTP_ERROR_CATEGORIES = {
|
|
126
|
+
400: 'validation', 401: 'authentication', 403: 'authorization',
|
|
127
|
+
404: 'not_found', 408: 'timeout', 409: 'conflict', 410: 'not_found',
|
|
128
|
+
413: 'validation', 422: 'validation', 429: 'rate_limit',
|
|
129
|
+
};
|
|
130
|
+
const CLIENT_FAMILIES = [
|
|
131
|
+
['chatgpt', /\bchatgpt\b/],
|
|
132
|
+
['claude', /\bclaude(?: desktop)?\b/],
|
|
133
|
+
['codex', /\bcodex\b/],
|
|
134
|
+
['cursor', /\bcursor\b/],
|
|
135
|
+
['windsurf', /\bwindsurf\b/],
|
|
136
|
+
['vscode', /\b(?:vscode|visual studio code)\b/],
|
|
137
|
+
['continue', /\bcontinue\b/],
|
|
138
|
+
['cline', /\bcline\b/],
|
|
139
|
+
['roo', /\broo(?: code)?\b/],
|
|
140
|
+
['zed', /\bzed\b/],
|
|
141
|
+
['mcp-inspector', /\bmcp inspector\b/],
|
|
142
|
+
];
|
|
121
143
|
|
|
122
144
|
const ALLOWED_EVENT_PROPERTY_KEYS = new Set([
|
|
123
145
|
'$exception_list', '$lib', '$lib_version', '$mcp_client_name',
|
|
@@ -250,6 +272,13 @@ function classifyIntent(toolName, value) {
|
|
|
250
272
|
|
|
251
273
|
function classifyError(value) {
|
|
252
274
|
const text = typeof value === 'string' ? value.toLowerCase() : '';
|
|
275
|
+
if (ERROR_CATEGORIES.has(text)) return text;
|
|
276
|
+
// HTTP status is authoritative; endpoint words such as "network brief"
|
|
277
|
+
// must not turn an upstream 500 into a transport/network classification.
|
|
278
|
+
const status = /\bhttp\s+([45]\d\d)\b/.exec(text)?.[1];
|
|
279
|
+
if (status && HTTP_ERROR_CATEGORIES[status]) return HTTP_ERROR_CATEGORIES[status];
|
|
280
|
+
if (status?.startsWith('5')) return 'server';
|
|
281
|
+
if (status) return 'unknown';
|
|
253
282
|
if (/\b(?:401|unauthenticated|authentication|invalid token|expired token)\b/.test(text)) {
|
|
254
283
|
return 'authentication';
|
|
255
284
|
}
|
|
@@ -264,6 +293,38 @@ function classifyError(value) {
|
|
|
264
293
|
return 'unknown';
|
|
265
294
|
}
|
|
266
295
|
|
|
296
|
+
function canonicalClientFamily(value) {
|
|
297
|
+
if (typeof value !== 'string' || !value.trim() || value.length > 240) return undefined;
|
|
298
|
+
const normalized = value.toLowerCase().replace(/[_-]/g, ' ');
|
|
299
|
+
return CLIENT_FAMILIES.find(([, pattern]) => pattern.test(normalized))?.[0];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function projectEventAttribution(properties, eventName) {
|
|
303
|
+
const clientValues = [
|
|
304
|
+
properties.$mcp_vendor_client,
|
|
305
|
+
properties.$mcp_client_name,
|
|
306
|
+
properties.$mcp_client_user_agent,
|
|
307
|
+
];
|
|
308
|
+
const clientFamily = clientValues.map(canonicalClientFamily).find(Boolean)
|
|
309
|
+
|| (clientValues.some((value) => (
|
|
310
|
+
typeof value === 'string' && value.trim() && value !== 'unknown'
|
|
311
|
+
)) ? 'other' : 'unknown');
|
|
312
|
+
properties.$mcp_vendor_client = clientFamily;
|
|
313
|
+
properties.$mcp_client_name = clientFamily;
|
|
314
|
+
if (properties.$mcp_client_user_agent !== undefined) {
|
|
315
|
+
properties.$mcp_client_user_agent = canonicalClientFamily(properties.$mcp_client_user_agent)
|
|
316
|
+
|| 'other';
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const errorMessage = properties.$mcp_error_message;
|
|
320
|
+
const exceptionMessage = properties.$exception_list?.[0]?.value;
|
|
321
|
+
const isError = eventName === '$exception' || properties.$mcp_is_error === true;
|
|
322
|
+
properties.$mcp_error_type = isError
|
|
323
|
+
? classifyError(errorMessage ?? exceptionMessage ?? properties.$mcp_error_type)
|
|
324
|
+
: 'none';
|
|
325
|
+
return errorMessage;
|
|
326
|
+
}
|
|
327
|
+
|
|
267
328
|
function safeFrameName(value, maxLength = 120) {
|
|
268
329
|
if (typeof value !== 'string' || value === '<local-path>') return undefined;
|
|
269
330
|
const basename = value.replace(/\\/g, '/').split('/').pop() || '';
|
|
@@ -311,7 +372,7 @@ function projectExceptionList(value, errorValue) {
|
|
|
311
372
|
}).filter((frame) => Object.keys(frame).length > 0);
|
|
312
373
|
return {
|
|
313
374
|
type: safeFrameName(exception.type, 80) || 'Error',
|
|
314
|
-
category: classifyError(exception.value ?? errorValue),
|
|
375
|
+
category: classifyError(exception.category ?? exception.value ?? errorValue),
|
|
315
376
|
stacktrace: { frames: projectedFrames },
|
|
316
377
|
};
|
|
317
378
|
});
|
|
@@ -521,6 +582,7 @@ function sanitizeMcpAnalyticsEvent(event) {
|
|
|
521
582
|
}
|
|
522
583
|
const properties = sanitized.properties;
|
|
523
584
|
if (!properties || typeof properties !== 'object') return sanitized;
|
|
585
|
+
const sourceErrorMessage = projectEventAttribution(properties, event.event);
|
|
524
586
|
|
|
525
587
|
// Identity is backend-owned. The CLI may carry only its ephemeral anonymous
|
|
526
588
|
// session identifier; verified user/person properties never cross this relay.
|
|
@@ -582,7 +644,6 @@ function sanitizeMcpAnalyticsEvent(event) {
|
|
|
582
644
|
delete properties.$mcp_intent_source;
|
|
583
645
|
}
|
|
584
646
|
|
|
585
|
-
const sourceErrorMessage = properties.$mcp_error_message;
|
|
586
647
|
if (typeof sourceErrorMessage === 'string') {
|
|
587
648
|
properties.$mcp_error_message = classifyError(sourceErrorMessage);
|
|
588
649
|
}
|
package/mcp/server.js
CHANGED
|
@@ -9,6 +9,7 @@ const { version: PACKAGE_VERSION } = require('../package.json');
|
|
|
9
9
|
const APPLY_CONTRACT = require('../contracts/trackly-apply-tools.json');
|
|
10
10
|
const { registerApplyTools } = require('./apply-tools');
|
|
11
11
|
const { configureMcpAnalytics, shutdownMcpAnalytics } = require('./analytics');
|
|
12
|
+
const { normalizeJobResult, unavailableJobFromError, jobFailureGuidance } = require('../lib/job-result');
|
|
12
13
|
|
|
13
14
|
const MCP_USER_AGENT = `trackly-mcp/${PACKAGE_VERSION}`;
|
|
14
15
|
const MCP_SHUTDOWN_TIMEOUT_MS = 2_000;
|
|
@@ -219,28 +220,48 @@ function throwMcpResourceError(error) {
|
|
|
219
220
|
throw error;
|
|
220
221
|
}
|
|
221
222
|
|
|
222
|
-
function wrapTool(handler, fallbackMessage) {
|
|
223
|
+
function wrapTool(handler, fallbackMessage, jobIdKey) {
|
|
223
224
|
return async (params, extra) => {
|
|
225
|
+
const successResult = (value) => ({
|
|
226
|
+
content: [{ type: 'text', text: JSON.stringify(value, null, 2) }],
|
|
227
|
+
...(jobIdKey ? { structuredContent: value } : {}),
|
|
228
|
+
});
|
|
229
|
+
const failureResult = (error) => {
|
|
230
|
+
const result = createErrorResult(error, fallbackMessage, error?.status === 401 ? { hint: AUTH_HINT } : {});
|
|
231
|
+
if (!jobIdKey) return result;
|
|
232
|
+
const payload = JSON.parse(result.content[0].text);
|
|
233
|
+
const guidance = jobFailureGuidance(error);
|
|
234
|
+
if (payload.code === 'maintenance_mode' || createTracklyAccessError(error, error?.status)) {
|
|
235
|
+
guidance.category = payload.code === 'maintenance_mode' ? 'maintenance' : 'access';
|
|
236
|
+
guidance.error = payload.error;
|
|
237
|
+
guidance.retryable = payload.retryable;
|
|
238
|
+
guidance.maxRetries = payload.retryable ? 1 : 0;
|
|
239
|
+
if (!payload.retryable) delete guidance.retryAfterSeconds;
|
|
240
|
+
guidance.nextStep = { message: payload.guidance || payload.message || payload.error };
|
|
241
|
+
}
|
|
242
|
+
if (error?.status === 429) delete payload.hint;
|
|
243
|
+
return { ...result, ...successResult({ ...payload, ...guidance }) };
|
|
244
|
+
};
|
|
224
245
|
try {
|
|
225
246
|
if (!hasAuth()) {
|
|
226
|
-
return createAuthErrorResult();
|
|
247
|
+
return jobIdKey ? failureResult({ message: 'Not authenticated', status: 401 }) : createAuthErrorResult();
|
|
227
248
|
}
|
|
228
249
|
|
|
229
250
|
const result = await handler(params);
|
|
230
|
-
return
|
|
231
|
-
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
232
|
-
};
|
|
251
|
+
return successResult(jobIdKey ? normalizeJobResult(result, params[jobIdKey]) : result);
|
|
233
252
|
} catch (error) {
|
|
253
|
+
const unavailable = jobIdKey && unavailableJobFromError(error, params[jobIdKey]);
|
|
254
|
+
if (unavailable) return successResult(unavailable);
|
|
234
255
|
if (extra && typeof extra === 'object') {
|
|
256
|
+
const statusPrefix = Number.isInteger(error?.status) ? `HTTP ${error.status}: ` : '';
|
|
235
257
|
extra.__mcp_analytics_error = error instanceof Error
|
|
236
258
|
? error
|
|
237
|
-
: new Error(
|
|
259
|
+
: new Error(
|
|
260
|
+
`${statusPrefix}${error?.error || error?.message || fallbackMessage}`,
|
|
261
|
+
{ cause: error },
|
|
262
|
+
);
|
|
238
263
|
}
|
|
239
|
-
return
|
|
240
|
-
error,
|
|
241
|
-
fallbackMessage,
|
|
242
|
-
error?.status === 401 ? { hint: AUTH_HINT } : {}
|
|
243
|
-
);
|
|
264
|
+
return failureResult(error);
|
|
244
265
|
}
|
|
245
266
|
};
|
|
246
267
|
}
|
|
@@ -366,13 +387,13 @@ function createServer() {
|
|
|
366
387
|
|
|
367
388
|
server.tool(
|
|
368
389
|
'trackly_get_job',
|
|
369
|
-
'Get full details for a
|
|
390
|
+
'Get full details for a Trackly job ID returned by search or your saved queue. Missing or closed postings return availability=unavailable with retryable=false and a next step; do not retry that ID. Failures include a category and retry guidance.',
|
|
370
391
|
{
|
|
371
|
-
id: z.number().describe('
|
|
392
|
+
id: z.number().int().positive().safe().describe('Positive numeric Trackly job ID from trackly_search_jobs or your saved queue, not an employer requisition ID or URL. Correct invalid arguments before retrying.'),
|
|
372
393
|
},
|
|
373
394
|
wrapTool(async ({ id }) => {
|
|
374
395
|
return apiRequest('GET', `/api/jobscout/jobs/${id}`, null, false, false, MCP_USER_AGENT);
|
|
375
|
-
}, 'Failed to fetch job')
|
|
396
|
+
}, 'Failed to fetch job', 'id')
|
|
376
397
|
);
|
|
377
398
|
|
|
378
399
|
server.tool(
|
|
@@ -528,13 +549,13 @@ function createServer() {
|
|
|
528
549
|
|
|
529
550
|
server.tool(
|
|
530
551
|
'trackly_get_job_brief',
|
|
531
|
-
'Get a network brief for a
|
|
552
|
+
'Get a network brief for a Trackly job ID, including company signal, recommended motion, top contact and suggested actions. Missing or closed postings return availability=unavailable with retryable=false; search for another opening. Failures include a category and retry guidance.',
|
|
532
553
|
{
|
|
533
|
-
jobId: z.number().describe('
|
|
554
|
+
jobId: z.number().int().positive().safe().describe('Positive numeric Trackly job ID from trackly_search_jobs or your saved queue, not an employer requisition ID or URL. Correct invalid arguments before retrying.'),
|
|
534
555
|
},
|
|
535
556
|
wrapTool(async ({ jobId }) => {
|
|
536
557
|
return apiRequest('GET', `/api/jobscout/jobs/${jobId}/network-brief`, null, false, false, MCP_USER_AGENT);
|
|
537
|
-
}, 'Failed to fetch network brief')
|
|
558
|
+
}, 'Failed to fetch network brief', 'jobId')
|
|
538
559
|
);
|
|
539
560
|
|
|
540
561
|
server.tool(
|
package/npm-shrinkwrap.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackly-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "trackly-cli",
|
|
9
|
-
"version": "0.
|
|
9
|
+
"version": "0.22.0",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"@modelcontextprotocol/sdk": "1.32.1",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "trackly-cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.22.0",
|
|
4
4
|
"mcpName": "io.github.trackly-app/trackly",
|
|
5
5
|
"description": "Search 250K+ jobs across 5,400+ companies with the Trackly CLI and MCP server for Claude, ChatGPT, Codex and Cursor. Filter by role, location, salary and sponsorship; track jobs and prepare applications.",
|
|
6
6
|
"bin": {
|
package/server.json
CHANGED
|
@@ -17,12 +17,12 @@
|
|
|
17
17
|
]
|
|
18
18
|
}
|
|
19
19
|
],
|
|
20
|
-
"version": "0.
|
|
20
|
+
"version": "0.22.0",
|
|
21
21
|
"packages": [
|
|
22
22
|
{
|
|
23
23
|
"registryType": "npm",
|
|
24
24
|
"identifier": "trackly-cli",
|
|
25
|
-
"version": "0.
|
|
25
|
+
"version": "0.22.0",
|
|
26
26
|
"runtimeHint": "npx",
|
|
27
27
|
"runtimeArguments": [
|
|
28
28
|
{
|