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 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) |
@@ -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. Turn off future
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.
@@ -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(error?.message || fallbackMessage, { cause: error });
259
+ : new Error(
260
+ `${statusPrefix}${error?.error || error?.message || fallbackMessage}`,
261
+ { cause: error },
262
+ );
238
263
  }
239
- return createErrorResult(
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 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.',
370
391
  {
371
- 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.'),
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 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.',
532
553
  {
533
- 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.'),
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(
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "trackly-cli",
3
- "version": "0.21.1",
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.1",
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.1",
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.1",
20
+ "version": "0.22.0",
21
21
  "packages": [
22
22
  {
23
23
  "registryType": "npm",
24
24
  "identifier": "trackly-cli",
25
- "version": "0.21.1",
25
+ "version": "0.22.0",
26
26
  "runtimeHint": "npx",
27
27
  "runtimeArguments": [
28
28
  {