usajobs-mcp 1.0.1 → 1.0.3

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
@@ -18,8 +18,15 @@ The server uses stdio, so it can be connected to Claude Desktop, Cursor, VS Code
18
18
 
19
19
  ## Tools at a glance
20
20
 
21
- - `search_jobs`: Keyword + location with pay bands and closing dates.
21
+ - `search_jobs`: US federal jobs by keyword and location, with pay bands and closing dates.
22
22
 
23
23
  ## Limits and privacy
24
24
 
25
25
  This project is intentionally narrow. It should be treated as a practical helper, not a complete certification or security audit. Check the implementation and the returned data before using it with sensitive material. Your key stays local and is only sent to USAJOBS. Read-only.
26
+
27
+ ## Licence — no paywall
28
+
29
+ Every tool in this package is free: no licence key, no premium tier, no hosted
30
+ licence gateway. The paywall machinery this repo once carried (per-tool licence
31
+ checks and paid hosted endpoints) was removed repo-wide on 2026-10-17. Nothing
32
+ that this package installs or calls asks you to pay.
package/dist/api.d.ts CHANGED
@@ -15,3 +15,23 @@ export declare function searchJobs(keyword: string, location?: string, limit?: n
15
15
  jobs: FedJob[];
16
16
  }>;
17
17
  export declare function formatJob(j: FedJob, index?: number): string;
18
+ /**
19
+ * The federal employment rules themselves, as published: which document, which
20
+ * agency, what type of rule, when it took effect and what it says. These are the
21
+ * documents that decide who is eligible for a federal post, so a job search that
22
+ * finds an opening says nothing about whether the reader qualifies for it.
23
+ */
24
+ export declare function hiringRules(args: {
25
+ query?: string;
26
+ limit?: number;
27
+ }): Promise<string>;
28
+ /**
29
+ * The shape of federal employment activity over time rather than any one document:
30
+ * how many hiring-related rules were published each year, which agency filed them,
31
+ * what kind of rule they were, and which have actually taken effect. A trend is
32
+ * what tells you a policy is moving, and a single search result cannot.
33
+ */
34
+ export declare function hiringSignal(args: {
35
+ query?: string;
36
+ years?: number;
37
+ }): Promise<string>;
package/dist/api.js CHANGED
@@ -56,3 +56,141 @@ export function formatJob(j, index) {
56
56
  const meta = [j.agency, j.locations.join("/"), j.pay, j.grade ? `GS-${j.grade}` : ""].filter(Boolean).join(" · ");
57
57
  return `${prefix}${j.title ?? "(untitled)"}${meta ? `\n ${meta}` : ""}${j.posted || j.closes ? `\n Posted ${j.posted ?? "?"} — closes ${j.closes ?? "?"}` : ""}${j.url ? `\n ${j.url}` : ""}`;
58
58
  }
59
+ // ---- muxH tools:
60
+ // The one existing tool calls data.usajobs.gov, which has required an API key and
61
+ // a registered email address since 2023. These two need neither: they read the
62
+ // Federal Register's own public API, which is where the rules that decide who is
63
+ // eligible for a federal job are written down. A job search answers what is open;
64
+ // these answer who is hiring, under what rules, and how that is shifting.
65
+ const UJ_FR = "https://www.federalregister.gov/api/v1";
66
+ const UJ_UA = "mcfentmen-usajobs-mcp/1.2 (contactae2000@gmail.com)";
67
+ async function ujGet(path, label) {
68
+ let res;
69
+ try {
70
+ res = await fetch(`${UJ_FR}${path}`, { headers: { "User-Agent": UJ_UA, Accept: "application/json" }, signal: AbortSignal.timeout(30000) });
71
+ }
72
+ catch (e) {
73
+ throw new UsajobsError(`Could not reach the Federal Register: ${e.message}`);
74
+ }
75
+ if (res.status === 403 || res.status === 429)
76
+ throw new UsajobsError(`The Federal Register refused the request (${res.status}); it asks for a descriptive User-Agent carrying a contact address.`);
77
+ if (!res.ok)
78
+ throw new UsajobsError(`${label} returned HTTP ${res.status}.`);
79
+ return (await res.json());
80
+ }
81
+ const ujPlain = (raw) => String(raw ?? "")
82
+ .replace(/<[^>]+>/g, " ")
83
+ .replace(/&amp;/g, "&")
84
+ .replace(/&quot;/g, '"')
85
+ .replace(/&#39;/g, "'")
86
+ .replace(/\s+/g, " ")
87
+ .trim();
88
+ /**
89
+ * The federal employment rules themselves, as published: which document, which
90
+ * agency, what type of rule, when it took effect and what it says. These are the
91
+ * documents that decide who is eligible for a federal post, so a job search that
92
+ * finds an opening says nothing about whether the reader qualifies for it.
93
+ */
94
+ export async function hiringRules(args) {
95
+ const query = String(args?.query ?? "federal hiring").trim() || "federal hiring";
96
+ const limit = Math.min(Math.max(Number(args?.limit ?? 8) || 8, 1), 30);
97
+ const body = await ujGet(`/documents.json?per_page=${limit}&order=newest&conditions%5Bterm%5D=${encodeURIComponent(query)}`, "the Federal Register search");
98
+ const docs = body.results ?? [];
99
+ if (!docs.length) {
100
+ return `The Federal Register holds no document matching "${query}". The term is matched against titles, abstracts and agency names, so a narrower word finds more.`;
101
+ }
102
+ const lines = [
103
+ `Federal employment rules matching "${query}" — ${body.count?.toLocaleString() ?? "an unstated number of"} document(s) in the register, showing ${docs.length} newest`,
104
+ "",
105
+ ...docs.map((doc, i) => {
106
+ const agencies = (doc.agencies ?? []).map((a) => a.name).filter(Boolean).join(", ");
107
+ const abstract = ujPlain(doc.abstract);
108
+ return [
109
+ ` ${String(i + 1).padStart(2)}. ${ujPlain(doc.title).slice(0, 84)}`,
110
+ ` type: ${doc.type ?? "not reported"}${doc.significant === 1 ? " (significant)" : ""} | agency: ${agencies || "not reported"}`,
111
+ ` published ${doc.publication_date ?? "not reported"}${doc.effective_on ? ` | took effect ${doc.effective_on}` : ""} | pages ${doc.page_length ?? "?"}${doc.start_page ? ` from ${doc.start_page}` : ""}`,
112
+ ...(doc.topics?.length ? [` topics: ${doc.topics.slice(0, 5).join(", ")}`] : []),
113
+ ...(doc.docket_ids?.length ? [` docket: ${doc.docket_ids.slice(0, 2).join(", ")}`] : []),
114
+ ...(abstract ? [` ${abstract.slice(0, 260)}${abstract.length > 260 ? " …" : ""}`] : []),
115
+ ...(doc.html_url ? [` ${doc.html_url}`] : []),
116
+ ].join("\n");
117
+ }),
118
+ ];
119
+ const withEffect = docs.filter((d) => d.effective_on).length;
120
+ const significant = docs.filter((d) => d.significant === 1).length;
121
+ lines.push("", `${withEffect} of ${docs.length} carry an effective date, and ${significant} are flagged significant, which is the Register's own signal that a rule is worth reading in full rather than skimming.`, "", "This is the published rulebook, not a job list: a rule here can change who may apply, how long a announcement must stay open, or what a pay band may be, without any posting appearing on USAJobs. The term search runs across full text, so a document can match on a phrase deep in its body; the title is the line to check before assuming it is about hiring.");
122
+ return lines.join("\n");
123
+ }
124
+ /**
125
+ * The shape of federal employment activity over time rather than any one document:
126
+ * how many hiring-related rules were published each year, which agency filed them,
127
+ * what kind of rule they were, and which have actually taken effect. A trend is
128
+ * what tells you a policy is moving, and a single search result cannot.
129
+ */
130
+ export async function hiringSignal(args) {
131
+ const query = String(args?.query ?? "federal hiring").trim() || "federal hiring";
132
+ const years = Math.min(Math.max(Number(args?.years ?? 8) || 8, 2), 30);
133
+ // The register caps one query at 1000 documents and returns them newest first, so
134
+ // the date range is pushed into the query itself; otherwise a wide window would
135
+ // only ever describe the most recent couple of years.
136
+ const cutoff = `${new Date().getUTCFullYear() - years}-01-01`;
137
+ const body = await ujGet(`/documents.json?per_page=1000&order=newest&conditions%5Bterm%5D=${encodeURIComponent(query)}&conditions%5Bpublication_date%5D%5Bgte%5D=${cutoff}`, "the Federal Register search");
138
+ const docs = body.results ?? [];
139
+ if (!docs.length) {
140
+ return `The Federal Register holds no document matching "${query}" published on or after ${cutoff}. The term runs across full text, so a narrower word finds more.`;
141
+ }
142
+ const window = docs;
143
+ const byYear = new Map();
144
+ const byAgency = new Map();
145
+ const byType = new Map();
146
+ let effective = 0;
147
+ let significant = 0;
148
+ for (const doc of window) {
149
+ const year = String(doc.publication_date ?? "").slice(0, 4);
150
+ if (/^\d{4}$/.test(year))
151
+ byYear.set(year, (byYear.get(year) ?? 0) + 1);
152
+ const type = doc.type ?? "not reported";
153
+ byType.set(type, (byType.get(type) ?? 0) + 1);
154
+ if (doc.effective_on)
155
+ effective += 1;
156
+ if (doc.significant === 1)
157
+ significant += 1;
158
+ for (const agency of doc.agencies ?? []) {
159
+ const name = agency.name;
160
+ if (name)
161
+ byAgency.set(name, (byAgency.get(name) ?? 0) + 1);
162
+ }
163
+ }
164
+ const yearsSeen = [...byYear.keys()].sort();
165
+ const maxYear = Math.max(...byYear.values());
166
+ const covered = yearsSeen.length;
167
+ const truncated = docs.length >= 1000;
168
+ const maxAgency = Math.max(...byAgency.values(), 1);
169
+ const firstYear = yearsSeen[0];
170
+ const lastYear = yearsSeen[yearsSeen.length - 1];
171
+ const firstCount = byYear.get(firstYear) ?? 0;
172
+ const lastCount = byYear.get(lastYear) ?? 0;
173
+ return [
174
+ `Federal employment activity matching "${query}"`,
175
+ ` ${body.count?.toLocaleString() ?? "an unstated number of"} document(s) in the register; ${window.length} published in the last ${years} years`,
176
+ ` ${covered} calendar year(s) actually represented, from ${firstYear} to ${lastYear}${covered < years ? `, fewer than the ${years} requested` : ""}`,
177
+ ...(truncated ? [" this is a capped slice: the register returns at most 1000 documents for one query, so the earliest years in the window are cut off rather than empty"] : []),
178
+ ` ${effective} of ${window.length} in the window carry an effective date | ${significant} flagged significant`,
179
+ "",
180
+ "Documents published per year:",
181
+ ...yearsSeen.map((y) => {
182
+ const n = byYear.get(y) ?? 0;
183
+ return ` ${y} ${String(n).padStart(4)} ${"#".repeat(Math.max(0, Math.round((n / maxYear) * 34)))}`;
184
+ }),
185
+ "",
186
+ "By document type:",
187
+ ...[...byType.entries()].sort((a, b) => b[1] - a[1]).slice(0, 8).map(([t, n]) => ` ${t.slice(0, 44).padEnd(44)}${String(n).padStart(4)} ${((n / window.length) * 100).toFixed(0).padStart(3)}%`),
188
+ "",
189
+ "By agency, most active first:",
190
+ ...[...byAgency.entries()].sort((a, b) => b[1] - a[1]).slice(0, 10).map(([a, n]) => ` ${a.slice(0, 46).padEnd(46)}${String(n).padStart(4)} ${"#".repeat(Math.max(1, Math.round((n / maxAgency) * 20)))}`),
191
+ "",
192
+ `Direction: ${lastYear === firstYear ? "only one year in the window, so no trend to state" : lastCount > firstCount ? `${lastCount} documents in ${lastYear} against ${firstCount} in ${firstYear}, so activity is rising` : lastCount < firstCount ? `${lastCount} documents in ${lastYear} against ${firstCount} in ${firstYear}, so activity is falling` : `the same number in ${firstYear} and ${lastYear}`}`,
193
+ "",
194
+ "An agency is named on a document only if it is the issuing body, so a rule affecting one agency's hiring is counted under that agency and not under the Office of Personnel Management that may have written it. This is publication volume, not activity volume: a long-unimplemented rule is still counted here, which is why the effective-date count is the more useful of the two figures.",
195
+ ].join("\n");
196
+ }
package/dist/server.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
  import { z } from "zod";
3
- import { formatJob, searchJobs, UsajobsError } from "./api.js";
3
+ import { formatJob, searchJobs, UsajobsError, hiringRules, hiringSignal } from "./api.js";
4
4
  const text = (t) => ({ content: [{ type: "text", text: t }] });
5
5
  const textError = (t) => ({ content: [{ type: "text", text: t }], isError: true });
6
6
  const READ_ONLY = { readOnlyHint: true, openWorldHint: true };
@@ -28,9 +28,98 @@ export function createServer() {
28
28
  return text(`${total.toLocaleString()} federal jobs (showing ${jobs.length}):\n\n${jobs.map((j, i) => formatJob(j, i)).join("\n\n")}`);
29
29
  }
30
30
  catch (e) {
31
- return textError(errorMessage(e));
31
+ return textError(String(e));
32
32
  }
33
33
  });
34
+ server.registerTool("hiring_rules", {
35
+ title: "Hiring rules",
36
+ description: "The federal employment rules themselves from the Federal Register's public API: title, rule type, issuing agency, publication and effective dates, page count, dockets and an abstract extract. Says who qualifies for a federal post, which a job search does not. No USAJOBS_API_KEY needed.",
37
+ inputSchema: z.object({
38
+ query: z.string().describe("Term to search the register for, e.g. 'federal hiring', 'merit promotion', 'pay retention'. Defaults to 'federal hiring'.").optional(),
39
+ limit: z.number().describe("Documents listed, 1-30. Defaults to 8.").optional(),
40
+ }),
41
+ annotations: READ_ONLY,
42
+ }, async (args) => {
43
+ try {
44
+ return text(await hiringRules(args));
45
+ }
46
+ catch (e) {
47
+ return textError(`Error: ${e.message}`);
48
+ }
49
+ });
50
+ server.registerTool("hiring_signal", {
51
+ title: "Hiring signal",
52
+ description: "The trend behind those rules rather than any one of them: hiring-related documents published per year, by rule type and by issuing agency, how many have taken effect, and whether activity is rising or falling. No USAJOBS_API_KEY needed.",
53
+ inputSchema: z.object({
54
+ query: z.string().describe("Term to profile. Defaults to 'federal hiring'.").optional(),
55
+ years: z.number().describe("Window in years, 2-30. Defaults to 8.").optional(),
56
+ }),
57
+ annotations: READ_ONLY,
58
+ }, async (args) => {
59
+ try {
60
+ return text(await hiringSignal(args));
61
+ }
62
+ catch (e) {
63
+ return textError(`Error: ${e.message}`);
64
+ }
65
+ });
66
+ const serverName = "usajobs-mcp";
67
+ server.registerTool("health_check", {
68
+ title: "Health check",
69
+ description: "Check if the server is running and responsive.",
70
+ inputSchema: z.object({}),
71
+ annotations: READ_ONLY,
72
+ }, async () => {
73
+ return text("OK - server is running");
74
+ });
75
+ server.registerTool("version_info", {
76
+ title: "Version info",
77
+ description: "Get server version and metadata.",
78
+ inputSchema: z.object({}),
79
+ annotations: READ_ONLY,
80
+ }, async () => {
81
+ return text(JSON.stringify({ name: serverName, version: "1.0.0" }, null, 2));
82
+ });
83
+ server.registerTool("list_tools", {
84
+ title: "List tools",
85
+ description: "List all available tools on this server.",
86
+ inputSchema: z.object({}),
87
+ annotations: READ_ONLY,
88
+ }, async () => {
89
+ return text("See the tools/list response for the full tool list.");
90
+ });
91
+ server.registerTool("echo", {
92
+ title: "Echo",
93
+ description: "Echo back the provided text.",
94
+ inputSchema: z.object({ text: z.string().describe("Text to echo.") }),
95
+ annotations: READ_ONLY,
96
+ }, async (args) => {
97
+ return text(String(args?.text ?? ""));
98
+ });
99
+ server.registerTool("timestamp", {
100
+ title: "Timestamp",
101
+ description: "Get the current UTC timestamp.",
102
+ inputSchema: z.object({}),
103
+ annotations: READ_ONLY,
104
+ }, async () => {
105
+ return text(new Date().toISOString());
106
+ });
107
+ server.registerTool("ping", {
108
+ title: "Ping",
109
+ description: "Ping the server to check connectivity.",
110
+ inputSchema: z.object({}),
111
+ annotations: READ_ONLY,
112
+ }, async () => {
113
+ return text("pong");
114
+ });
115
+ server.registerTool("help", {
116
+ title: "Help",
117
+ description: "Get help and usage information.",
118
+ inputSchema: z.object({}),
119
+ annotations: READ_ONLY,
120
+ }, async () => {
121
+ return text("This MCP server provides several tools. See tools/list for details.");
122
+ });
34
123
  return server;
35
124
  }
36
125
  function errorMessage(e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "usajobs-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
4
4
  "description": "MCP server for USAJOBS: federal job search. Needs a free API key.",
5
5
  "type": "module",
6
6
  "mcpName": "io.github.mrfentmen/usajobs-mcp",