trackly-cli 0.21.2 → 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 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 full details for a specific job |
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 for a job (company signal, top contact, actions) |
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) |
@@ -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.
@@ -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/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,18 +220,38 @@ 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') {
235
256
  const statusPrefix = Number.isInteger(error?.status) ? `HTTP ${error.status}: ` : '';
236
257
  extra.__mcp_analytics_error = error instanceof Error
@@ -240,11 +261,7 @@ function wrapTool(handler, fallbackMessage) {
240
261
  { cause: error },
241
262
  );
242
263
  }
243
- return createErrorResult(
244
- error,
245
- fallbackMessage,
246
- error?.status === 401 ? { hint: AUTH_HINT } : {}
247
- );
264
+ return failureResult(error);
248
265
  }
249
266
  };
250
267
  }
@@ -370,13 +387,13 @@ function createServer() {
370
387
 
371
388
  server.tool(
372
389
  'trackly_get_job',
373
- 'Get full details for a specific job posting including description.',
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.',
374
391
  {
375
- id: z.number().describe('Job posting ID'),
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.'),
376
393
  },
377
394
  wrapTool(async ({ id }) => {
378
395
  return apiRequest('GET', `/api/jobscout/jobs/${id}`, null, false, false, MCP_USER_AGENT);
379
- }, 'Failed to fetch job')
396
+ }, 'Failed to fetch job', 'id')
380
397
  );
381
398
 
382
399
  server.tool(
@@ -532,13 +549,13 @@ function createServer() {
532
549
 
533
550
  server.tool(
534
551
  'trackly_get_job_brief',
535
- 'Get a network brief for a specific job. Returns company signal, recommended motion, top contact, and suggested actions.',
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.',
536
553
  {
537
- jobId: z.number().describe('Job posting ID'),
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.'),
538
555
  },
539
556
  wrapTool(async ({ jobId }) => {
540
557
  return apiRequest('GET', `/api/jobscout/jobs/${jobId}/network-brief`, null, false, false, MCP_USER_AGENT);
541
- }, 'Failed to fetch network brief')
558
+ }, 'Failed to fetch network brief', 'jobId')
542
559
  );
543
560
 
544
561
  server.tool(
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.21.2",
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.21.2",
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.21.2",
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.21.2",
20
+ "version": "0.22.0",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.21.2",
25
+ "version": "0.22.0",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {