findyet-mcp 0.0.0-stage → 1.0.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 freehire contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,145 @@
1
- # Temporary Holding Version
1
+ # Findyet MCP server
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An [MCP](https://modelcontextprotocol.io) server over the [Findyet](https://findyet.devjoys.com)
4
+ job API. It lets any MCP host — Claude Desktop, Claude Code, or a compatible agent —
5
+ **search, filter, track and tailor applications** without a browser, authenticating with a
6
+ personal API key. Postings come straight from company career pages, normalized into one
7
+ schema and tagged with stack, seniority, region and work mode.
8
+
9
+ ## Install
10
+
11
+ ### Hosted endpoint (no install)
12
+
13
+ Findyet serves MCP over Streamable HTTP, so most hosts need nothing installed:
14
+
15
+ - `https://findyet.devjoys.com/api/v1/mcp` — read-only catalogue (search, jobs, employers,
16
+ salaries, market stats). No key.
17
+ - `https://findyet.devjoys.com/api/v1/mcp/account` — your account (profile, saved jobs,
18
+ tracking, market fit). Send your key as `Authorization: Bearer fyk_…`.
19
+
20
+ In Claude Code:
21
+
22
+ ```bash
23
+ claude mcp add --transport http findyet https://findyet.devjoys.com/api/v1/mcp/account \
24
+ --header "Authorization: Bearer fyk_xxxxxxxx"
25
+ ```
26
+
27
+ In other hosts, add the URL as a custom remote connector with that header.
28
+
29
+ ### Local server (stdio)
30
+
31
+ This package is the full tool set (CV tailoring, experience bank, submissions) as a local
32
+ stdio server. Build it and point your host at `dist/index.js`:
33
+
34
+ ```json
35
+ {
36
+ "mcpServers": {
37
+ "findyet": {
38
+ "command": "node",
39
+ "args": ["/path/to/findyet/mcp/dist/index.js"],
40
+ "env": { "FINDYET_TOKEN": "fyk_xxxxxxxx" }
41
+ }
42
+ }
43
+ }
44
+ ```
45
+
46
+ Create the `fyk_…` key in the web app (findyet.devjoys.com → account menu → **API keys**).
47
+
48
+ ## Authentication
49
+
50
+ The token and API base URL resolve with precedence
51
+ **env → `~/.findyet/creds.json` → default `https://findyet.devjoys.com`**:
52
+
53
+ | What | Sources |
54
+ |------|---------|
55
+ | Token | `FINDYET_TOKEN` → creds file |
56
+ | API base URL | `FINDYET_API_URL` → creds file → `https://findyet.devjoys.com` |
57
+
58
+ The server only reads the credentials file (it never writes it). If no token is configured,
59
+ tools return a clear "not authenticated" error rather than the server failing to start.
60
+
61
+ ## Tools
62
+
63
+ | Tool | Purpose |
64
+ |------|---------|
65
+ | `whoami` | Authenticated user (verify the key). |
66
+ | `facets` | The filter/skill vocabulary: every facet's live values with counts. **Call first.** |
67
+ | `search` | Keyword + facet job search; returns jobs **with their full description as markdown** and the total match count. |
68
+ | `market_fit` | Score a skill list against live market demand (coverage + gaps). |
69
+ | `job` | A single job's full content by slug. |
70
+ | `company` | A company and its open jobs by slug. |
71
+ | `apply` | Mark a job applied. |
72
+ | `save` / `unsave` | Bookmark / remove a bookmark. |
73
+ | `stage` | Set the application stage (server-validated). |
74
+ | `note` | Attach a free-text note. |
75
+ | `my` | The caller's tracked jobs (all/viewed/saved/applied) with stage + note. |
76
+ | `cv_tailor` | Start (or reopen) tailoring for a vacancy; returns the CV id the other `cv_*` tools take. |
77
+ | `cv_list` | The caller's tailored CVs with the vacancy each was written for. |
78
+ | `cv_context` | The fit analysis a tailored CV should reframe toward (missing_have vs missing_gap). |
79
+ | `cv_get` | A tailored CV's full document. |
80
+ | `cv_edit` | Apply a batch of path-addressed edits to a tailored CV, atomically (server-validated; uncited claims are refused). |
81
+ | `cv_render` | Render a tailored CV to a PDF, returned as a base64 `application/pdf` resource. |
82
+ | `experience_list` | The candidate's experience bank, with each achievement's **provenance**. `cv_edit`'s `evidence_id` comes from here. |
83
+ | `experience_add_employment` / `experience_add_achievement` | Record a place, or one piece of evidence. |
84
+ | `experience_update_employment` / `experience_update_achievement` | Correct one. Field-level: what you do not name is kept. |
85
+ | `experience_remove_employment` / `experience_remove_achievement` | Delete one. No undo; a place must be empty first. |
86
+ | `submit` | Submit a vacancy for moderation. |
87
+ | `my_submissions` | The caller's submissions with status. |
88
+ | `jobs_add` / `jobs_edit` | Moderator: author / edit a job (403 without the role). |
89
+ | `submissions_pending` | Moderator: the review queue. |
90
+ | `submission_approve` / `submission_reject` | Moderator: decide on a submission. |
91
+
92
+ **Filters.** `search`, `market_fit`, and `facets` share the same market-filter
93
+ parameters: `remote`, `region`, `country`, `city`, `company`, `category`, `role`,
94
+ `seniority`, `employment_type`, `english_level`, `exclude_skill`, `salary_min`, `visa`,
95
+ plus a generic `facets` map (`{"source": "greenhouse"}`) for any other facet in the
96
+ vocabulary. Discover valid values with the `facets` tool — do not invent them. In
97
+ `search`, `skills` is a filter; in `market_fit`, `skills` is the measured set.
98
+
99
+ **Geography widens.** `region`, `country` and `city` are ONE OR-group: `region: ["eu"]`
100
+ with `country: ["IT"]` means "in Europe **or** in Italy" and returns everything the
101
+ region alone would. To search a single country, pass `country` and omit `region`. The
102
+ three name a single concept — *where* — so picking two places reads as "either", which
103
+ is what makes `region: ["eu"]` with `country: ["BR"]` ("Europe or Brazil") useful. There
104
+ is no AND to switch on: `_mode=and` does not apply to geography.
105
+
106
+ **Unread params are ignored, not refused.** A filter key the API does not recognize
107
+ does not fail the request, it widens it. Such keys come back in the result's `ignored`
108
+ list, with `did_you_mean` when only the grammatical number was wrong. `search` reports it
109
+ alongside `total`; `facets` and `market_fit` answer a single object, so they wrap it as
110
+ `{data, ignored}` — and only then, leaving a clean call's shape untouched. Any number from
111
+ a result carrying `ignored` answers a broader question than the one asked — retry with the
112
+ suggested name before reporting it.
113
+
114
+ **Descriptions.** `search` reads the API's agent endpoint, so every hit already carries
115
+ the posting's full description rendered as markdown — a host can screen a result set
116
+ without a `job` call per hit. Descriptions are long, so keep `limit` modest.
117
+
118
+ **The evidence rule.** Every achievement in the bank records who asserted it.
119
+ `cv_import`, `stated_in_chat` and `manual` mean the candidate did, and may be cited on a
120
+ CV; `agent_inferred` means a model read it into the record, and may not. `cv_edit`
121
+ refuses any claim about the candidate without an `evidence_id` pointing at a citable one,
122
+ which is why `experience_list` is the tool that makes `cv_edit` usable at all.
123
+
124
+ Correcting an achievement does not move that label: an `agent_inferred` one stays
125
+ uncitable however it is reworded. The only way it becomes citable is to ask the
126
+ candidate, then record what **they** say with `experience_add_achievement`.
127
+
128
+ **Removing is final** — the bank has no undo. A place must be emptied before it can go,
129
+ because deleting one would take every achievement under it. Folding two achievements into
130
+ one, keeping the numbers from both, is on the site.
131
+
132
+ Each tool returns the raw API `data` as JSON text; an API error becomes an `isError`
133
+ result carrying the HTTP status (a 401 adds an auth hint).
134
+
135
+ ## Develop
136
+
137
+ ```bash
138
+ npm install
139
+ npm test # vitest: config, client (mock server), facets, tool dispatch
140
+ npm run build # tsc → dist/
141
+ ```
142
+
143
+ ## License
144
+
145
+ MIT — see [LICENSE](LICENSE).
package/dist/client.js ADDED
@@ -0,0 +1,391 @@
1
+ // A thin HTTP client for the freehire API. It authenticates with an API key
2
+ // (Authorization: Bearer) and returns the raw `data` field of each response, so
3
+ // tools can serialize it verbatim for the agent. Mirrors the freehire CLI client.
4
+ /** ApiError is a non-2xx API response, carrying the HTTP status so callers can
5
+ * branch on it (e.g. 401 → prompt to authenticate). */
6
+ export class ApiError extends Error {
7
+ status;
8
+ constructor(status, message) {
9
+ super(message ? `api error ${status}: ${message}` : `api error ${status}`);
10
+ this.name = "ApiError";
11
+ this.status = status;
12
+ }
13
+ }
14
+ /** Client talks to the freehire API, sending the API key as a bearer token on
15
+ * every request. fetchImpl is injectable for testing. */
16
+ export class Client {
17
+ baseURL;
18
+ token;
19
+ fetchImpl;
20
+ constructor(baseURL, token, fetchImpl = fetch) {
21
+ this.baseURL = baseURL.replace(/\/+$/, "");
22
+ this.token = token;
23
+ this.fetchImpl = fetchImpl;
24
+ }
25
+ /** me returns the authenticated user (GET /auth/me); the whoami by API key. */
26
+ async me() {
27
+ return (await this.do("GET", "/api/v1/auth/me")).data;
28
+ }
29
+ /** search runs a keyword job search with optional facet filters
30
+ * (GET /agent/jobs/search). That endpoint runs the same query as the web's
31
+ * /jobs/search but, for programmatic consumers, replaces the index's truncated
32
+ * preview with each job's full description — so a host can screen a result set
33
+ * without a follow-up `job` call per hit. Markdown keeps the posting's lists and
34
+ * headings intact. params carries the facet query values; q/limit/offset are set here. */
35
+ async search(query, limit, offset, params) {
36
+ params.set("q", query);
37
+ params.set("limit", String(limit));
38
+ params.set("offset", String(offset));
39
+ // The endpoint always returns full descriptions, so only the rendering needs
40
+ // asking for. It used to also send semantic_ratio=0 and include_description=true;
41
+ // the first died with the hybrid index and the second was never read, and the API
42
+ // now reports unread params as warnings — so sending them would hand the model two
43
+ // it cannot act on.
44
+ params.set("description_format", "markdown");
45
+ const env = await this.do("GET", `/api/v1/agent/jobs/search?${params.toString()}`);
46
+ const page = { data: env.data, total: env.meta?.total ?? 0 };
47
+ if (env.meta?.ignored_params?.length)
48
+ page.ignored = env.meta.ignored_params;
49
+ return page;
50
+ }
51
+ /** coverage scores a skill list against the facet-filtered market
52
+ * (POST /market/coverage): skills go in the body, facets in the query string. */
53
+ async coverage(skills, params) {
54
+ return withIgnored(await this.do("POST", withQuery("/api/v1/market/coverage", params), { skills }));
55
+ }
56
+ /** facets returns the market's facet-value distributions under an optional
57
+ * filter (GET /jobs/facets): the filter/skill vocabulary with counts. */
58
+ async facets(params) {
59
+ return withIgnored(await this.do("GET", withQuery("/api/v1/jobs/facets", params)));
60
+ }
61
+ /** recommendations returns open jobs ranked for the caller's CV skills and job
62
+ * preferences, each with a 0-100 score, reasons and gaps (GET /me/recommendations). */
63
+ async recommendations(limit, offset, refresh) {
64
+ const q = new URLSearchParams({ limit: String(limit), offset: String(offset) });
65
+ if (refresh)
66
+ q.set("refresh", "1");
67
+ const env = await this.do("GET", `/api/v1/me/recommendations?${q.toString()}`);
68
+ return { data: env.data, meta: env.meta };
69
+ }
70
+ /** getJob fetches a single job by its public slug (GET /jobs/:slug). */
71
+ async getJob(slug) {
72
+ return (await this.do("GET", `/api/v1/jobs/${encodeURIComponent(slug)}`)).data;
73
+ }
74
+ /** getCompany fetches a company and its open jobs by slug (GET /companies/:slug). */
75
+ async getCompany(slug) {
76
+ return (await this.do("GET", `/api/v1/companies/${encodeURIComponent(slug)}`)).data;
77
+ }
78
+ /** apply marks a job applied for the authenticated user (POST /jobs/:slug/apply). */
79
+ async apply(slug) {
80
+ return (await this.do("POST", `/api/v1/jobs/${encodeURIComponent(slug)}/apply`)).data;
81
+ }
82
+ /** save bookmarks a job (POST /jobs/:slug/save). */
83
+ async save(slug) {
84
+ return (await this.do("POST", `/api/v1/jobs/${encodeURIComponent(slug)}/save`)).data;
85
+ }
86
+ /** unsave removes a job's bookmark (DELETE /jobs/:slug/save). */
87
+ async unsave(slug) {
88
+ return (await this.do("DELETE", `/api/v1/jobs/${encodeURIComponent(slug)}/save`)).data;
89
+ }
90
+ /** track sets a job's application stage and/or notes (PATCH /jobs/:slug/track).
91
+ * An omitted field is left unchanged by the server (partial update). */
92
+ async track(slug, fields) {
93
+ return (await this.do("PATCH", `/api/v1/jobs/${encodeURIComponent(slug)}/track`, fields)).data;
94
+ }
95
+ /** myJobs lists the caller's tracked jobs (GET /me/tracking), filtered by
96
+ * all/viewed/saved/applied. */
97
+ async myJobs(filter, limit, offset) {
98
+ const q = new URLSearchParams();
99
+ if (filter)
100
+ q.set("filter", filter);
101
+ q.set("limit", String(limit));
102
+ q.set("offset", String(offset));
103
+ const env = await this.do("GET", `/api/v1/me/tracking?${q.toString()}`);
104
+ return { data: env.data, total: env.meta?.total ?? 0 };
105
+ }
106
+ /** createJob creates a hand-curated job (POST /jobs, moderator only). Re-creating
107
+ * the same URL updates the posting (idempotent upsert on the server). */
108
+ async createJob(params) {
109
+ return (await this.do("POST", "/api/v1/jobs", params)).data;
110
+ }
111
+ /** editJob partially updates a manual job (PATCH /jobs/:slug, moderator only). */
112
+ async editJob(slug, params) {
113
+ return (await this.do("PATCH", `/api/v1/jobs/${encodeURIComponent(slug)}`, params)).data;
114
+ }
115
+ /** submit queues a vacancy for moderation (POST /submissions). */
116
+ async submit(params) {
117
+ return (await this.do("POST", "/api/v1/submissions", params)).data;
118
+ }
119
+ /** mySubmissions lists the caller's own submissions with their status
120
+ * (GET /me/submissions). */
121
+ async mySubmissions() {
122
+ return (await this.do("GET", "/api/v1/me/submissions")).data;
123
+ }
124
+ /** pendingSubmissions lists the moderator review queue (GET /submissions). */
125
+ async pendingSubmissions() {
126
+ return (await this.do("GET", "/api/v1/submissions")).data;
127
+ }
128
+ /** approveSubmission approves a pending submission, minting a live job
129
+ * (POST /submissions/:id/approve, moderator only). */
130
+ async approveSubmission(id) {
131
+ return (await this.do("POST", `/api/v1/submissions/${id}/approve`)).data;
132
+ }
133
+ /** rejectSubmission rejects a pending submission with an optional reason
134
+ * (POST /submissions/:id/reject, moderator only). */
135
+ async rejectSubmission(id, reason) {
136
+ return (await this.do("POST", `/api/v1/submissions/${id}/reject`, { reason })).data;
137
+ }
138
+ // CV-tailoring endpoints (beta-gated on the server), acting as the authenticated
139
+ // user. Mirrors the freehire CLI's `cv` command group.
140
+ /** listCVs returns the caller's tailored CVs, newest edit first, each with the vacancy
141
+ * it was written for (GET /me/cvs). This is where a CV id comes from: every other CV
142
+ * method takes one, and the id is opaque, so it has to be read rather than constructed. */
143
+ async listCVs() {
144
+ return (await this.do("GET", "/api/v1/me/cvs")).data;
145
+ }
146
+ /** tailorCV starts (or reopens) tailoring for a vacancy and returns the tailored CV's
147
+ * id, the base it was copied from, and the bound agent session (POST /me/cvs/tailor).
148
+ *
149
+ * Idempotent per vacancy: calling it again for the same slug returns the copy that
150
+ * already exists rather than making a second one, so it is safe to call when you do not
151
+ * know whether one exists. It debits an AI credit the first time it creates the copy
152
+ * (402 when the balance will not cover it) and 409s when the candidate has no résumé to
153
+ * seed a base CV from. It never calls a model itself. */
154
+ async tailorCV(jobSlug) {
155
+ return (await this.do("POST", "/api/v1/me/cvs/tailor", { job_slug: jobSlug })).data;
156
+ }
157
+ /** tailorCVContext returns the cached fit-analysis context a tailored CV should
158
+ * reframe toward — verdict, recommendation, and the missing_have / missing_gap
159
+ * requirement split (GET /me/cvs/:id/tailor-context). */
160
+ async tailorCVContext(cvID) {
161
+ return (await this.do("GET", `${cvPath(cvID)}/tailor-context`)).data;
162
+ }
163
+ /** getCV fetches a CV with its full document (GET /me/cvs/:id). */
164
+ async getCV(cvID) {
165
+ return (await this.do("GET", cvPath(cvID))).data;
166
+ }
167
+ /** patchCV applies a batch of path operations to a CV (PATCH /me/cvs/:id).
168
+ *
169
+ * The server decodes the body strictly — unknown fields are rejected — so it has to
170
+ * be exactly `{ops, note?}`, with each op addressed by a path into the document
171
+ * (`experience[0].bullets[1]`). Sending a bare patch object is a 422, not a
172
+ * best-effort apply.
173
+ *
174
+ * The whole batch applies or none of it does, and it lands as one entry in the
175
+ * candidate's revision history, so related edits belong in one call. */
176
+ async patchCV(cvID, ops, note) {
177
+ const body = { ops };
178
+ if (note)
179
+ body.note = note;
180
+ return (await this.do("PATCH", cvPath(cvID), body)).data;
181
+ }
182
+ /** renderCV downloads a CV rendered to PDF (GET /me/cvs/:id/pdf). Unlike the other
183
+ * endpoints this returns raw PDF bytes, not the JSON envelope, so it bypasses do(). */
184
+ async renderCV(cvID) {
185
+ const resp = await this.fetchImpl(this.baseURL + `${cvPath(cvID)}/pdf`, {
186
+ method: "GET",
187
+ headers: { Authorization: `Bearer ${this.token}`, Accept: "application/pdf" },
188
+ });
189
+ const buf = new Uint8Array(await resp.arrayBuffer());
190
+ if (!resp.ok) {
191
+ let message = "";
192
+ try {
193
+ message = JSON.parse(Buffer.from(buf).toString("utf8")).error ?? "";
194
+ }
195
+ catch {
196
+ // A non-JSON error body just leaves the message empty; the status still carries.
197
+ }
198
+ throw new ApiError(resp.status, message);
199
+ }
200
+ return buf;
201
+ }
202
+ /** listExperience returns the caller's whole bank, grouped by employment
203
+ * (GET /me/experience). Achievements attached to no place come back under `unplaced`.
204
+ *
205
+ * This is where an `evidence_id` for patchCV comes from — without it the honest wall
206
+ * has no key, since every claim about the candidate has to cite a banked achievement. */
207
+ async listExperience() {
208
+ return (await this.do("GET", "/api/v1/me/experience")).data;
209
+ }
210
+ /** addEmployment records a place evidence was produced — a job or a project
211
+ * (POST /me/experience/employments). */
212
+ async addEmployment(params) {
213
+ return (await this.do("POST", "/api/v1/me/experience/employments", params)).data;
214
+ }
215
+ /** addAchievement records one piece of evidence (POST /me/experience/atoms).
216
+ *
217
+ * Provenance is not a parameter: the server stamps a POST-created achievement `manual`
218
+ * whatever is sent, because there is no transcript behind a plain HTTP call to check a
219
+ * stronger claim against. A claim already in the bank, under any spelling, comes back
220
+ * as 409 rather than a duplicate row — which is why correcting one matters. */
221
+ async addAchievement(params) {
222
+ return (await this.do("POST", "/api/v1/me/experience/atoms", params)).data;
223
+ }
224
+ /** updateEmployment corrects a banked place (PUT /me/experience/employments/:id).
225
+ *
226
+ * The API REPLACES the whole row, so this reads the bank first and sends it back with
227
+ * only the named fields changed. Passing the caller's fields straight through would
228
+ * write empty over everything they did not mention. */
229
+ async updateEmployment(id, changes) {
230
+ const current = await this.findEmployment(id);
231
+ const body = { ...current, ...definedOnly(changes) };
232
+ return (await this.do("PUT", `/api/v1/me/experience/employments/${encodeURIComponent(id)}`, body)).data;
233
+ }
234
+ /** updateAchievement corrects a banked achievement (PUT /me/experience/atoms/:id), with
235
+ * the same read-then-replace care as updateEmployment — a claim-only fix must not delete
236
+ * the metrics and skills that made the achievement worth citing.
237
+ *
238
+ * A keyed correction does NOT move the claim's provenance: the server keeps whatever the
239
+ * row already carried, so an agent cannot promote its own inference into something
240
+ * citable on a CV. Only the candidate's own edit on the site stamps `manual`. */
241
+ async updateAchievement(id, changes) {
242
+ const current = await this.findAchievement(id);
243
+ const body = { ...current, ...definedOnly(changes) };
244
+ return (await this.do("PUT", `/api/v1/me/experience/atoms/${encodeURIComponent(id)}`, body)).data;
245
+ }
246
+ /** removeAchievement deletes one owned achievement (DELETE /me/experience/atoms/:id).
247
+ * It takes nothing else with it, and there is no undo. */
248
+ async removeAchievement(id) {
249
+ await this.do("DELETE", `/api/v1/me/experience/atoms/${encodeURIComponent(id)}`);
250
+ return { removed: id };
251
+ }
252
+ /** removeEmployment deletes one owned place (DELETE /me/experience/employments/:id).
253
+ *
254
+ * The server refuses this with 409 while achievements still hang off the place, because
255
+ * the row's foreign key cascades and would delete them too. Move them with
256
+ * updateAchievement first, then remove the empty place. */
257
+ async removeEmployment(id) {
258
+ await this.do("DELETE", `/api/v1/me/experience/employments/${encodeURIComponent(id)}`);
259
+ return { removed: id };
260
+ }
261
+ /** findEmployment reads one banked place, so an update can replace the row without
262
+ * losing what the caller did not name. A missing id fails here rather than being sent
263
+ * as a write the server would refuse anyway. */
264
+ async findEmployment(id) {
265
+ const bank = (await this.listExperience());
266
+ const found = (bank?.employments ?? []).find((e) => e.id === id);
267
+ if (!found) {
268
+ throw new ApiError(404, `no employment ${id} in your bank — read experience_list for the ids`);
269
+ }
270
+ return {
271
+ kind: found.kind,
272
+ // A project's label is served under `name`; a job's under `company`. Both map to the
273
+ // same stored field, and the server picks by kind on the way back in.
274
+ company: found.company ?? found.name,
275
+ role: found.role,
276
+ location: found.location,
277
+ start: found.start,
278
+ end: found.end,
279
+ current: found.current,
280
+ summary: found.summary,
281
+ stack: found.stack,
282
+ link: found.link,
283
+ };
284
+ }
285
+ async findAchievement(id) {
286
+ const bank = (await this.listExperience());
287
+ for (const e of bank?.employments ?? []) {
288
+ const hit = (e.atoms ?? []).find((a) => a.id === id);
289
+ // The grouping is the authority on which place an achievement belongs to, so the
290
+ // employment id comes from the group rather than the row.
291
+ if (hit)
292
+ return { ...achievementFields(hit), employment_id: e.id };
293
+ }
294
+ const loose = (bank?.unplaced ?? []).find((a) => a.id === id);
295
+ if (loose)
296
+ return achievementFields(loose);
297
+ throw new ApiError(404, `no achievement ${id} in your bank — read experience_list for the ids`);
298
+ }
299
+ async mailboxStatus() {
300
+ return (await this.do("GET", "/api/v1/me/mailbox")).data;
301
+ }
302
+ async mailConversations(folder, q) {
303
+ const params = new URLSearchParams();
304
+ if (folder)
305
+ params.set("folder", folder);
306
+ if (q)
307
+ params.set("q", q);
308
+ return (await this.do("GET", withQuery("/api/v1/me/mail/conversations", params))).data;
309
+ }
310
+ async mailThread(id) {
311
+ const params = new URLSearchParams({ id });
312
+ return (await this.do("GET", withQuery("/api/v1/me/mail/thread", params))).data;
313
+ }
314
+ async mailReplyDraft(threadId, instruction) {
315
+ return (await this.do("POST", "/api/v1/me/mail/thread/reply-draft", {
316
+ thread_id: threadId,
317
+ instruction,
318
+ })).data;
319
+ }
320
+ async mailReply(threadId, body, idempotencyKey) {
321
+ return (await this.do("POST", "/api/v1/me/mail/reply", {
322
+ thread_id: threadId,
323
+ body,
324
+ idempotency_key: idempotencyKey,
325
+ })).data;
326
+ }
327
+ async mailLink(threadId, applicationId) {
328
+ return (await this.do("POST", "/api/v1/me/mail/thread/link", {
329
+ thread_id: threadId,
330
+ application_id: applicationId,
331
+ })).data;
332
+ }
333
+ async do(method, path, body) {
334
+ const headers = {
335
+ Authorization: `Bearer ${this.token}`,
336
+ Accept: "application/json",
337
+ };
338
+ let payload;
339
+ if (body !== undefined) {
340
+ headers["Content-Type"] = "application/json";
341
+ payload = JSON.stringify(body);
342
+ }
343
+ const resp = await this.fetchImpl(this.baseURL + path, { method, headers, body: payload });
344
+ const text = await resp.text();
345
+ let env = {};
346
+ if (text.length > 0) {
347
+ try {
348
+ env = JSON.parse(text);
349
+ }
350
+ catch (err) {
351
+ // A malformed body on a 2xx is unexpected; surface it. On a non-2xx an
352
+ // unparseable body just leaves env.error empty (the status still carries).
353
+ if (resp.ok)
354
+ throw new Error(`decode response: ${err.message}`);
355
+ }
356
+ }
357
+ if (!resp.ok)
358
+ throw new ApiError(resp.status, env.error ?? "");
359
+ return env;
360
+ }
361
+ }
362
+ /** withQuery appends the encoded params to path, omitting the "?" when empty. */
363
+ function withQuery(path, params) {
364
+ const enc = params.toString();
365
+ return enc ? `${path}?${enc}` : path;
366
+ }
367
+ /** cvPath is the base API path for a tailored CV by id. */
368
+ function cvPath(cvID) {
369
+ return `/api/v1/me/cvs/${encodeURIComponent(cvID)}`;
370
+ }
371
+ function achievementFields(a) {
372
+ return { claim: a.claim, context: a.context, metrics: a.metrics, skills: a.skills };
373
+ }
374
+ /** definedOnly drops undefined entries so a spread of caller-supplied changes leaves the
375
+ * banked value in place instead of overwriting it with undefined. */
376
+ function definedOnly(o) {
377
+ return Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
378
+ }
379
+ /** withIgnored returns an envelope's `data`, wrapped as `{data, ignored}` when the
380
+ * API reported params it did not read.
381
+ *
382
+ * Wrapped only then: these endpoints answer a single object, and hosts already
383
+ * parse it directly, so a permanent wrapper would be churn on every clean call.
384
+ * The warning has to travel in the payload — a count or a coverage percentage
385
+ * computed under a dropped filter reads exactly as authoritative as a real one. */
386
+ function withIgnored(env) {
387
+ const ignored = env.meta?.ignored_params;
388
+ if (!ignored?.length)
389
+ return env.data;
390
+ return { data: env.data, ignored };
391
+ }
package/dist/config.js ADDED
@@ -0,0 +1,58 @@
1
+ // Resolves and reads the Findyet credentials: the API token and base URL.
2
+ // Precedence: environment variable (FINDYET_*, then the upstream FREEHIRE_* names, which
3
+ // keep working) > ~/.findyet/creds.json > ~/.freehire/creds.json (written by the upstream
4
+ // CLI) > built-in default, the production Findyet API.
5
+ // The MCP server only reads the credentials file.
6
+ import fs from "node:fs";
7
+ import os from "node:os";
8
+ import path from "node:path";
9
+ /** DEFAULT_API_URL is the production API base when none is configured. */
10
+ export const DEFAULT_API_URL = "https://findyet.devjoys.com";
11
+ /** Environment variables that override the stored credentials. */
12
+ export const ENV_TOKEN = "FINDYET_TOKEN";
13
+ export const ENV_API_URL = "FINDYET_API_URL";
14
+ /** The upstream variable names, still honoured so existing configurations keep working. */
15
+ export const LEGACY_ENV_TOKEN = "FREEHIRE_TOKEN";
16
+ export const LEGACY_ENV_API_URL = "FREEHIRE_API_URL";
17
+ /** NotAuthenticatedError is thrown by resolveConfig when no token is configured. */
18
+ export class NotAuthenticatedError extends Error {
19
+ constructor() {
20
+ super("not authenticated: set FINDYET_TOKEN (create an API key in Findyet: account menu → API keys)");
21
+ this.name = "NotAuthenticatedError";
22
+ }
23
+ }
24
+ /** credsPath returns Findyet's credentials file path (~/.findyet/creds.json). */
25
+ export function credsPath() {
26
+ return path.join(os.homedir(), ".findyet", "creds.json");
27
+ }
28
+ /** legacyCredsPath is the upstream CLI's credentials file, read when Findyet's is absent. */
29
+ export function legacyCredsPath() {
30
+ return path.join(os.homedir(), ".freehire", "creds.json");
31
+ }
32
+ /** loadCreds reads the first credentials file that exists; none yields empty creds. */
33
+ export function loadCreds() {
34
+ for (const file of [credsPath(), legacyCredsPath()]) {
35
+ try {
36
+ return JSON.parse(fs.readFileSync(file, "utf8"));
37
+ }
38
+ catch (err) {
39
+ if (err.code === "ENOENT")
40
+ continue;
41
+ throw err;
42
+ }
43
+ }
44
+ return {};
45
+ }
46
+ /**
47
+ * resolveConfig computes the effective token and API URL with precedence
48
+ * env > creds file > default. The token is required (else NotAuthenticatedError);
49
+ * the URL falls back to DEFAULT_API_URL.
50
+ */
51
+ export function resolveConfig(env = process.env) {
52
+ const creds = loadCreds();
53
+ const token = env[ENV_TOKEN] || env[LEGACY_ENV_TOKEN] || creds.token || "";
54
+ if (!token)
55
+ throw new NotAuthenticatedError();
56
+ const apiURL = env[ENV_API_URL] || env[LEGACY_ENV_API_URL] || creds.api_url || DEFAULT_API_URL;
57
+ return { token, apiURL };
58
+ }
package/dist/facets.js ADDED
@@ -0,0 +1,92 @@
1
+ // The shared market-filter surface for the search, market_fit, and facets tools.
2
+ // Mirrors the freehire CLI's facet flags: named convenience filters for the
3
+ // high-traffic facets plus a generic `facets` escape hatch for the long tail.
4
+ import { z } from "zod";
5
+ /** marketFacetShape is a Zod raw shape spread into the input schema of every tool
6
+ * that filters the market. Each named facet maps to an API query param; `facets`
7
+ * reaches any other param in the vocabulary. */
8
+ export const marketFacetShape = {
9
+ remote: z.boolean().optional().describe("Only remote jobs (sets work_mode=remote)."),
10
+ region: z
11
+ .array(z.string())
12
+ .optional()
13
+ .describe("Region codes: global|ru|cis|central_asia|eu|us. WIDENS with country/city — " +
14
+ "region+country means 'in the region OR the country', not 'the country inside " +
15
+ "the region'. To search one country, pass country and OMIT region."),
16
+ country: z
17
+ .array(z.string())
18
+ .optional()
19
+ .describe("ISO-3166 country codes, e.g. BR, US. Omit `region` when narrowing to a country — " +
20
+ "the two OR together, so region+country returns everything the region alone would."),
21
+ city: z.array(z.string()).optional().describe("City slugs. ORs with region and country."),
22
+ company: z.array(z.string()).optional().describe("Company slugs."),
23
+ category: z
24
+ .array(z.string())
25
+ .optional()
26
+ .describe("Role categories: backend|frontend|fullstack|devops|ml_ai|qa|..."),
27
+ role: z.array(z.string()).optional().describe("Role facet values, e.g. senior_backend."),
28
+ seniority: z
29
+ .array(z.string())
30
+ .optional()
31
+ .describe("Seniority: intern|junior|middle|senior|staff|principal|lead|c_level."),
32
+ employment_type: z
33
+ .array(z.string())
34
+ .optional()
35
+ .describe("Employment type, e.g. full_time, contract."),
36
+ english_level: z.array(z.string()).optional().describe("English level, e.g. a2, b1, b2, c1."),
37
+ exclude_skill: z
38
+ .array(z.string())
39
+ .optional()
40
+ .describe("Skills to filter OUT, e.g. python — drops jobs TAGGED with any of them. Tags come " +
41
+ "from a curated dictionary read off the description, so this is a discovery filter, " +
42
+ "not a fit test: a mention the dictionary does not recognise leaves the job untagged " +
43
+ "and in the results, and a job whose real core is a stack the candidate lacks is not " +
44
+ 'removed by excluding a different one. Any other facet excludes the same way through ' +
45
+ '`facets`, e.g. {"company_type_exclude": "outstaff"}.'),
46
+ salary_min: z.number().int().min(0).optional().describe("Minimum salary (enrichment.salary_min)."),
47
+ visa: z.boolean().optional().describe("Only jobs offering visa sponsorship."),
48
+ facets: z
49
+ .record(z.string(), z.union([z.string(), z.array(z.string())]))
50
+ .optional()
51
+ .describe('Any other facet param as key→value(s), e.g. {"source": "greenhouse"}. ' +
52
+ "Discover valid keys and values with the `facets` tool."),
53
+ };
54
+ /** namedFacets binds each convenience filter to the API facet param it fills. */
55
+ const namedFacets = {
56
+ region: "regions",
57
+ country: "countries",
58
+ city: "cities",
59
+ company: "company_slug",
60
+ category: "category",
61
+ role: "role",
62
+ seniority: "seniority",
63
+ employment_type: "employment_type",
64
+ english_level: "english_level",
65
+ exclude_skill: "skills_exclude",
66
+ };
67
+ /** buildFacetParams collects the shared market-filter inputs into API query params. */
68
+ export function buildFacetParams(f) {
69
+ const q = new URLSearchParams();
70
+ for (const [key, param] of Object.entries(namedFacets)) {
71
+ const vals = f[key];
72
+ if (vals)
73
+ for (const v of vals)
74
+ q.append(param, v);
75
+ }
76
+ if (f.remote)
77
+ q.set("work_mode", "remote");
78
+ if (f.salary_min && f.salary_min > 0)
79
+ q.set("salary_min", String(f.salary_min));
80
+ if (f.visa)
81
+ q.set("visa_sponsorship", "true");
82
+ if (f.facets) {
83
+ for (const [k, v] of Object.entries(f.facets)) {
84
+ if (Array.isArray(v))
85
+ for (const x of v)
86
+ q.append(k, x);
87
+ else
88
+ q.append(k, v);
89
+ }
90
+ }
91
+ return q;
92
+ }
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // findyet-mcp (forked from freehire-mcp) — an MCP server over the Findyet job API. The host (Claude Desktop,
3
+ // Claude Code, …) launches this binary over stdio; it exposes the freehire API as
4
+ // tools, authenticating with a personal API key resolved from the environment or
5
+ // the freehire CLI's credentials file.
6
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
+ import { Client } from "./client.js";
9
+ import { resolveConfig } from "./config.js";
10
+ import { registerTools } from "./tools.js";
11
+ import { VERSION } from "./version.js";
12
+ /** makeClientResolver returns a getClient that resolves the config once and caches
13
+ * the client. It resolves lazily (on first tool call, not at launch) so a missing
14
+ * token surfaces as a tool error instead of a failed server start — and a token
15
+ * added after launch is still picked up on the next attempt. */
16
+ function makeClientResolver() {
17
+ let cached = null;
18
+ return () => {
19
+ if (cached)
20
+ return cached;
21
+ const { token, apiURL } = resolveConfig(process.env);
22
+ cached = new Client(apiURL, token);
23
+ return cached;
24
+ };
25
+ }
26
+ async function main() {
27
+ const server = new McpServer({ name: "findyet", version: VERSION }, {
28
+ instructions: "Tools over the Findyet job API (a local Findyet instance by default). Start with `facets` to discover real filter values and skill slugs, " +
29
+ "then `search`. Address jobs by their public_slug. Use `recommendations` for jobs ranked against the user's own CV and preferences, and `market_fit` to measure skills against demand.",
30
+ });
31
+ registerTools(server, makeClientResolver());
32
+ const transport = new StdioServerTransport();
33
+ await server.connect(transport);
34
+ }
35
+ main().catch((err) => {
36
+ console.error("findyet-mcp: fatal:", err);
37
+ process.exit(1);
38
+ });
package/dist/tools.js ADDED
@@ -0,0 +1,408 @@
1
+ // Registers the Findyet tools on an McpServer. Each tool is a thin wrapper over a
2
+ // client method: it returns the raw API `data` as JSON text, and an API failure
3
+ // becomes an isError result (so the agent sees the status, not a crash).
4
+ import { z } from "zod";
5
+ import { ApiError } from "./client.js";
6
+ import { buildFacetParams, marketFacetShape } from "./facets.js";
7
+ /** ok serializes API data as pretty JSON text — faithful to the API for the agent. */
8
+ function ok(data) {
9
+ return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
10
+ }
11
+ /** fail turns an error into an isError result, adding an auth hint on a 401. */
12
+ function fail(err) {
13
+ let msg = err instanceof Error ? err.message : String(err);
14
+ if (err instanceof ApiError && err.status === 401) {
15
+ msg += " (set FINDYET_TOKEN to an API key created in Findyet)";
16
+ }
17
+ return { content: [{ type: "text", text: msg }], isError: true };
18
+ }
19
+ /** run executes a client call, mapping success/failure to a tool result. */
20
+ async function run(fn) {
21
+ try {
22
+ return ok(await fn());
23
+ }
24
+ catch (err) {
25
+ return fail(err);
26
+ }
27
+ }
28
+ const slug = z.string().min(1).describe("The job's public slug (from search or facets results).");
29
+ /** registerTools wires every Findyet tool onto server, resolving the client lazily
30
+ * through getClient on each call. */
31
+ export function registerTools(server, getClient) {
32
+ server.registerTool("whoami", {
33
+ description: "Return the authenticated Findyet user (verifies the API key). Call this to confirm auth before other tools.",
34
+ inputSchema: {},
35
+ annotations: { readOnlyHint: true },
36
+ }, async () => run(() => getClient().me()));
37
+ server.registerTool("facets", {
38
+ description: "List the market's filter vocabulary: every facet's live values with a vacancy count each, plus the skills list and numeric ranges. Call this FIRST to discover real values for `search` and `market_fit` — do not invent facet values. A filter key the API does not recognise is ignored rather than refused, so a result wrapped as {data, ignored} counted a broader market than the one asked for — retry with the suggested name before quoting any number from it.",
39
+ inputSchema: { ...marketFacetShape },
40
+ annotations: { readOnlyHint: true },
41
+ }, async (input) => run(() => getClient().facets(buildFacetParams(input))));
42
+ server.registerTool("search", {
43
+ description: "Search open jobs by keyword with optional facet filters. Each result carries the job's FULL description as markdown alongside title, company, location and public_slug, plus the total match count — so you can screen a whole result set without calling `job` per hit. Use the returned slug with `job`, `apply`, `save`, etc. " +
44
+ "Two traps worth knowing. Geography (region/country/city) is ONE OR-group: passing region AND country widens rather than narrows, so drop the region to search a single country. And a filter param the API does not recognize is ignored rather than refused — the search still runs, just broader — so when a result carries an `ignored` list, its `total` answers a wider question than the one asked; retry with the suggested name before reporting the number.",
45
+ inputSchema: {
46
+ query: z.string().describe("Keyword query, e.g. 'golang backend'. Empty string matches all."),
47
+ limit: z
48
+ .number()
49
+ .int()
50
+ .min(1)
51
+ .max(100)
52
+ .default(20)
53
+ .describe("Max results to return. Each result includes a full job description, so prefer a modest value."),
54
+ offset: z.number().int().min(0).default(0).describe("Pagination offset."),
55
+ skills: z
56
+ .array(z.string())
57
+ .optional()
58
+ .describe("Filter to jobs listing these skills (canonical slugs from the `facets` tool)."),
59
+ ...marketFacetShape,
60
+ },
61
+ annotations: { readOnlyHint: true },
62
+ }, async ({ query, limit, offset, skills, ...facets }) => {
63
+ const params = buildFacetParams(facets);
64
+ if (skills)
65
+ for (const s of skills)
66
+ params.append("skills", s);
67
+ return run(() => getClient().search(query, limit, offset, params));
68
+ });
69
+ server.registerTool("market_fit", {
70
+ description: "Score a skill list against the live open-vacancy market for a filtered role: headline coverage (% of vacancies listing ≥1 skill), must-have skills held, and the missing skills that unlock the most vacancies. Here `skills` is the MEASURED set, not a filter — use facet params to define the role. One skill probes that skill's demand.",
71
+ inputSchema: {
72
+ skills: z
73
+ .array(z.string())
74
+ .min(1)
75
+ .describe("The candidate's skills to measure (canonical slugs). One value probes a single skill."),
76
+ ...marketFacetShape,
77
+ },
78
+ annotations: { readOnlyHint: true },
79
+ }, async ({ skills, ...facets }) => run(() => getClient().coverage(skills, buildFacetParams(facets))));
80
+ server.registerTool("recommendations", {
81
+ description: "Personalized job recommendations for the authenticated user, computed from their Findyet profile/CV skills, seniority and location/work-mode preferences. Each item has the job, a 0-100 `score`, `reasons` (why it fits) and `gaps` (what to check), as codes with params. `meta.state` explains an empty feed (no_profile, no_skills). Dismissed and applied jobs are excluded.",
82
+ inputSchema: {
83
+ limit: z.number().int().min(1).max(50).default(10).describe("How many recommendations to return."),
84
+ offset: z.number().int().min(0).default(0).describe("Pagination offset."),
85
+ refresh: z.boolean().default(false).describe("Recompute instead of using the server's short-lived cache."),
86
+ },
87
+ annotations: { readOnlyHint: true },
88
+ }, async ({ limit, offset, refresh }) => run(() => getClient().recommendations(limit, offset, refresh)));
89
+ server.registerTool("job", {
90
+ description: "Fetch a single job's full content by slug (title, company, location, posting URL, description).",
91
+ inputSchema: { slug },
92
+ annotations: { readOnlyHint: true },
93
+ }, async ({ slug }) => run(() => getClient().getJob(slug)));
94
+ server.registerTool("company", {
95
+ description: "Fetch a company and its open jobs by company slug.",
96
+ inputSchema: { slug: z.string().min(1).describe("The company slug (from a job's company_slug).") },
97
+ annotations: { readOnlyHint: true },
98
+ }, async ({ slug }) => run(() => getClient().getCompany(slug)));
99
+ server.registerTool("apply", {
100
+ description: "Mark a job as applied for the authenticated user. Idempotent.",
101
+ inputSchema: { slug },
102
+ annotations: { readOnlyHint: false, idempotentHint: true },
103
+ }, async ({ slug }) => run(() => getClient().apply(slug)));
104
+ server.registerTool("save", {
105
+ description: "Bookmark a job for later. Idempotent.",
106
+ inputSchema: { slug },
107
+ annotations: { readOnlyHint: false, idempotentHint: true },
108
+ }, async ({ slug }) => run(() => getClient().save(slug)));
109
+ server.registerTool("unsave", {
110
+ description: "Remove a job's bookmark. A no-op if it was not saved.",
111
+ inputSchema: { slug },
112
+ annotations: { readOnlyHint: false, idempotentHint: true },
113
+ }, async ({ slug }) => run(() => getClient().unsave(slug)));
114
+ server.registerTool("stage", {
115
+ description: "Set a job's application stage. The server validates the value; valid stages are applied/screening/responded/interview/offer/accepted/rejected/withdrawn.",
116
+ inputSchema: {
117
+ slug,
118
+ stage: z.string().min(1).describe("Application stage, e.g. interview, offer, rejected."),
119
+ },
120
+ annotations: { readOnlyHint: false, idempotentHint: true },
121
+ }, async ({ slug, stage }) => run(() => getClient().track(slug, { stage })));
122
+ server.registerTool("note", {
123
+ description: "Attach a free-text note to a tracked job (overwrites the existing note).",
124
+ inputSchema: {
125
+ slug,
126
+ note: z.string().describe("Free-text note to store on the job."),
127
+ },
128
+ annotations: { readOnlyHint: false, idempotentHint: true },
129
+ }, async ({ slug, note }) => run(() => getClient().track(slug, { notes: note })));
130
+ server.registerTool("my", {
131
+ description: "List the caller's tracked jobs (viewed/saved/applied) with their stage and note. Filter narrows the set.",
132
+ inputSchema: {
133
+ filter: z
134
+ .enum(["all", "viewed", "saved", "applied"])
135
+ .default("all")
136
+ .describe("Which tracked jobs to list."),
137
+ limit: z.number().int().min(1).max(100).default(20),
138
+ offset: z.number().int().min(0).default(0),
139
+ },
140
+ annotations: { readOnlyHint: true },
141
+ }, async ({ filter, limit, offset }) => run(() => getClient().myJobs(filter, limit, offset)));
142
+ // CV tailoring — read the fit context, read/patch the CV document, render a PDF.
143
+ // Beta-gated on the server; acts as the authenticated user. Addressed by CV id.
144
+ // The id is opaque: the API hands it out and the caller passes it back. The schema
145
+ // deliberately does not describe its format — a client that validates the shape
146
+ // bakes today's format into a released package, which is what an opaque id avoids.
147
+ const cvId = z
148
+ .string()
149
+ .min(1)
150
+ .describe("The CV id, from cv_tailor or cv_list (or the tailoring workspace URL /tailor/<job>?cv=<id>). Opaque — never construct or guess one.");
151
+ // The two entry points. Every other CV tool takes an id, so without these the cycle can
152
+ // be driven but not started.
153
+ server.registerTool("cv_list", {
154
+ description: "List the caller's tailored CVs, newest edit first, each with the vacancy it was written for. Use this to find the id the other cv_* tools take, or to check whether a vacancy already has a tailored copy.",
155
+ inputSchema: {},
156
+ annotations: { readOnlyHint: true },
157
+ }, async () => run(() => getClient().listCVs()));
158
+ server.registerTool("cv_tailor", {
159
+ description: "Start tailoring a CV to a vacancy, returning the tailored CV's id (plus the base it was copied from and the bound session). Idempotent per vacancy — calling it again for the same slug reopens the copy that already exists, so it is safe when you do not know whether one exists. It spends one of the candidate's AI credits the first time it creates the copy (402 when the balance will not cover it) and 409s when they have no résumé on the site to seed a base CV from — tell them to upload one; you cannot. It does not call a model: the reframing is your work, through cv_context and cv_edit.",
160
+ inputSchema: { job_slug: slug },
161
+ annotations: { readOnlyHint: false },
162
+ }, async ({ job_slug }) => run(() => getClient().tailorCV(job_slug)));
163
+ server.registerTool("cv_context", {
164
+ description: "Show the cached fit-analysis context a tailored CV should reframe toward: verdict, recommendation, dimension comments, and the requirement split — missing_have (reframe existing evidence) vs missing_gap (ask the candidate before adding). Read this before editing.",
165
+ inputSchema: { id: cvId },
166
+ annotations: { readOnlyHint: true },
167
+ }, async ({ id }) => run(() => getClient().tailorCVContext(id)));
168
+ server.registerTool("cv_get", {
169
+ description: "Fetch a tailored CV with its full document (header, summary, experience bullets, skill groups).",
170
+ inputSchema: { id: cvId },
171
+ annotations: { readOnlyHint: true },
172
+ }, async ({ id }) => run(() => getClient().getCV(id)));
173
+ // Edits are addressed by a path into the document, the same model the CLI and the web
174
+ // editor use. Send everything that belongs together in ONE call: the batch is atomic
175
+ // and lands as a single, individually undoable entry in the candidate's history.
176
+ const cvOp = z
177
+ .object({
178
+ kind: z
179
+ .enum(["set", "insert", "remove", "move"])
180
+ .describe("set replaces a node, insert adds one, remove deletes one, move reorders within its list."),
181
+ path: z
182
+ .string()
183
+ .min(1)
184
+ .describe("Where to edit, 0-indexed over what cv_get returned: summary, experience[2].bullets[1], experience[0].stack[0], skills[0].items[3], education[1].degree, style.font_size."),
185
+ value: z.unknown().optional().describe("The new content. Omit for remove and move."),
186
+ to: z.number().int().optional().describe("Destination index. Only for move."),
187
+ evidence_id: z
188
+ .string()
189
+ .optional()
190
+ .describe("Id of the banked achievement backing this claim. Required for anything stating what the candidate did."),
191
+ })
192
+ .describe("One path-addressed operation.");
193
+ server.registerTool("cv_edit", {
194
+ description: "Apply a batch of path-addressed edits to a tailored CV, atomically. Read cv_context and cv_get first — indices are counted over the document cv_get returned. THE HONEST WALL: editing with an API key edits as the tailoring agent, and the server enforces it — the candidate's own name, email, phone and links are refused, and anything stating what they DID needs evidence_id, the id of something they asserted themselves. One uncited op rejects the whole batch. For a missing_have requirement, reframe an existing bullet; for a missing_gap, ask the candidate before writing anything. A bad path is a 422 and the CV is untouched. Export with cv_render.",
195
+ inputSchema: {
196
+ id: cvId,
197
+ ops: z.array(cvOp).min(1).describe("Every edit that belongs together, in one call."),
198
+ note: z
199
+ .string()
200
+ .optional()
201
+ .describe("Your own one-line reason for the change; shown to the candidate as the agent's words."),
202
+ },
203
+ annotations: { readOnlyHint: false },
204
+ }, async ({ id, ops, note }) => run(() => getClient().patchCV(id, ops, note)));
205
+ server.registerTool("cv_render", {
206
+ description: "Render a tailored CV to an ATS PDF, returned as a base64 resource (application/pdf). The bytes are large and not human-readable to the model — call it to produce the deliverable, not to inspect content.",
207
+ inputSchema: { id: cvId },
208
+ annotations: { readOnlyHint: true, idempotentHint: true },
209
+ }, async ({ id }) => {
210
+ try {
211
+ const pdf = await getClient().renderCV(id);
212
+ return {
213
+ content: [
214
+ {
215
+ type: "resource",
216
+ resource: {
217
+ uri: `cv://${id}.pdf`,
218
+ mimeType: "application/pdf",
219
+ blob: Buffer.from(pdf).toString("base64"),
220
+ },
221
+ },
222
+ ],
223
+ };
224
+ }
225
+ catch (err) {
226
+ return fail(err);
227
+ }
228
+ });
229
+ // The experience bank is the durable record of what the candidate has actually done, and
230
+ // it is what cv_edit's evidence_id points into. Without these tools the honest wall has
231
+ // no key: every claim about the candidate needs a citation and there would be no way to
232
+ // obtain one.
233
+ const employmentId = z
234
+ .string()
235
+ .min(1)
236
+ .describe("The employment id, from experience_list. Opaque — read it, never construct one.");
237
+ const achievementId = z
238
+ .string()
239
+ .min(1)
240
+ .describe("The achievement id, from experience_list. This is also cv_edit's evidence_id.");
241
+ server.registerTool("experience_list", {
242
+ description: "Read the candidate's whole experience bank: every employment and the achievements attached to it, plus the ones attached to no place under `unplaced`. Each achievement carries a PROVENANCE — cv_import, stated_in_chat and manual mean the candidate asserted it and it may be cited on a CV; agent_inferred means a model read it into the record and it may NOT. Take evidence_id for cv_edit from here.",
243
+ inputSchema: {},
244
+ annotations: { readOnlyHint: true },
245
+ }, async () => run(() => getClient().listExperience()));
246
+ const employmentShape = {
247
+ kind: z.enum(["job", "project"]).optional().describe('"job" or "project"; defaults to job on create.'),
248
+ company: z.string().optional().describe("Company name, or the project's name."),
249
+ role: z.string().optional().describe("The candidate's title or role there."),
250
+ location: z.string().optional().describe("Free-text location."),
251
+ start: z.string().optional().describe('A free-form date as it would be printed, e.g. "Mar 2021".'),
252
+ end: z.string().optional().describe("Same format as start; omit while ongoing."),
253
+ current: z.boolean().optional().describe("True while the candidate is still there."),
254
+ summary: z.string().optional().describe("One line about the place, for context."),
255
+ };
256
+ server.registerTool("experience_add_employment", {
257
+ description: "Record a place where evidence was produced — a job or a side project. At least company or role is required. Use the returned id to attach achievements with experience_add_achievement.",
258
+ inputSchema: employmentShape,
259
+ annotations: { readOnlyHint: false },
260
+ }, async (input) => run(() => getClient().addEmployment(input)));
261
+ server.registerTool("experience_add_achievement", {
262
+ description: "Record one piece of evidence — the sentence a CV bullet would carry. Only record what the CANDIDATE told you; the server stamps anything created here `manual`, which means they asserted it, and that stamp is what lets it be cited on a CV. A claim already in the bank, under any spelling, is refused as 409 rather than duplicated — so correct the existing one with experience_update_achievement instead of re-adding it.",
263
+ inputSchema: {
264
+ claim: z.string().min(1).describe("The achievement as one CV-bullet-grade sentence."),
265
+ context: z.string().optional().describe("How it was done, in a sentence or two."),
266
+ metrics: z.array(z.string()).optional().describe('Numbers as stated, e.g. ["20s->1s"].'),
267
+ skills: z.array(z.string()).optional().describe('Canonical skill slugs, e.g. ["go", "kubernetes"].'),
268
+ employment_id: employmentId.optional().describe("The place this belongs to; omit to leave it unplaced."),
269
+ },
270
+ annotations: { readOnlyHint: false },
271
+ }, async (input) => run(() => getClient().addAchievement(input)));
272
+ server.registerTool("experience_update_employment", {
273
+ description: "Correct a place already in the bank. Only the fields you pass change — everything else is carried over from what is banked, because the API replaces the whole row. Use this to fix a name, a role, or dates.",
274
+ inputSchema: { id: employmentId, ...employmentShape },
275
+ annotations: { readOnlyHint: false },
276
+ }, async ({ id, ...changes }) => run(() => getClient().updateEmployment(id, changes)));
277
+ server.registerTool("experience_update_achievement", {
278
+ description: "Correct an achievement already in the bank — most often a typo in the claim, which cannot be fixed by re-adding it. Only the fields you pass change; metrics and skills REPLACE the whole list when given. Correcting does NOT change who is held to have said it: an agent_inferred achievement stays agent_inferred and stays uncitable on a CV. Confirming it with the candidate then recording what THEY said is the only way it becomes citable.",
279
+ inputSchema: {
280
+ id: achievementId,
281
+ claim: z.string().optional().describe("The corrected sentence."),
282
+ context: z.string().optional().describe("How it was done."),
283
+ metrics: z.array(z.string()).optional().describe("Replaces the whole metrics list."),
284
+ skills: z.array(z.string()).optional().describe("Replaces the whole skills list."),
285
+ employment_id: employmentId.optional().describe("Move it to this place."),
286
+ },
287
+ annotations: { readOnlyHint: false },
288
+ }, async ({ id, ...changes }) => run(() => getClient().updateAchievement(id, changes)));
289
+ server.registerTool("experience_remove_achievement", {
290
+ description: "Delete one achievement from the bank. There is no undo, so confirm with the candidate first — read it back to them and remove it only on a clear yes. It takes nothing else with it. This is how a duplicate goes: keep the richer entry, remove the other.",
291
+ inputSchema: { id: achievementId },
292
+ annotations: { readOnlyHint: false, destructiveHint: true },
293
+ }, async ({ id }) => run(() => getClient().removeAchievement(id)));
294
+ server.registerTool("experience_remove_employment", {
295
+ description: "Delete a place from the bank. It must hold no achievements — removing a place would delete everything recorded under it, and the server refuses that here (409). To retire a duplicate place, move its achievements to the one being kept with experience_update_achievement, then remove the empty shell. There is no undo; confirm with the candidate first.",
296
+ inputSchema: { id: employmentId },
297
+ annotations: { readOnlyHint: false, destructiveHint: true },
298
+ }, async ({ id }) => run(() => getClient().removeEmployment(id)));
299
+ server.registerTool("submit", {
300
+ description: "Submit a vacancy for moderation. The server stores it as pending and returns it. URL (the dedup key), title, and company are required.",
301
+ inputSchema: createJobShape(),
302
+ annotations: { readOnlyHint: false },
303
+ }, async (input) => run(() => getClient().submit(input)));
304
+ server.registerTool("my_submissions", {
305
+ description: "List the caller's own vacancy submissions with their moderation status.",
306
+ inputSchema: {},
307
+ annotations: { readOnlyHint: true },
308
+ }, async () => run(() => getClient().mySubmissions()));
309
+ // Moderator-only tools. A regular API key gets a 403 (surfaced as isError).
310
+ server.registerTool("jobs_add", {
311
+ description: "Moderator: create a hand-curated job. URL is the dedup key — re-adding the same URL updates the posting. `description` is stored and rendered as HTML. Requires the moderator role (403 otherwise).",
312
+ inputSchema: createJobShape(),
313
+ annotations: { readOnlyHint: false, idempotentHint: true },
314
+ }, async (input) => run(() => getClient().createJob(input)));
315
+ server.registerTool("jobs_edit", {
316
+ description: "Moderator: partially update a manual job by slug. Only the provided fields change; the URL identity is not editable. Requires the moderator role (403 otherwise).",
317
+ inputSchema: {
318
+ slug,
319
+ title: z.string().optional(),
320
+ company: z.string().optional(),
321
+ location: z.string().optional(),
322
+ remote: z.boolean().optional(),
323
+ description: z.string().optional().describe("Stored and rendered as HTML."),
324
+ posted_at: z.string().optional().describe("RFC3339 timestamp."),
325
+ },
326
+ annotations: { readOnlyHint: false, idempotentHint: true },
327
+ }, async ({ slug, ...params }) => run(() => getClient().editJob(slug, params)));
328
+ server.registerTool("submissions_pending", {
329
+ description: "Moderator: list the pending submission review queue. Requires the moderator role (403 otherwise).",
330
+ inputSchema: {},
331
+ annotations: { readOnlyHint: true },
332
+ }, async () => run(() => getClient().pendingSubmissions()));
333
+ server.registerTool("submission_approve", {
334
+ description: "Moderator: approve a pending submission, minting a live job. Requires the moderator role (403 otherwise).",
335
+ inputSchema: { id: z.number().int().describe("The submission id.") },
336
+ annotations: { readOnlyHint: false },
337
+ }, async ({ id }) => run(() => getClient().approveSubmission(id)));
338
+ server.registerTool("submission_reject", {
339
+ description: "Moderator: reject a pending submission with an optional reason. Requires the moderator role (403 otherwise).",
340
+ inputSchema: {
341
+ id: z.number().int().describe("The submission id."),
342
+ reason: z.string().default("").describe("Optional rejection reason."),
343
+ },
344
+ annotations: { readOnlyHint: false },
345
+ }, async ({ id, reason }) => run(() => getClient().rejectSubmission(id, reason)));
346
+ server.registerTool("mail_mailbox_status", {
347
+ description: "Read the signed-in user's Findyet mailbox: whether it is claimed, the address, and whether delivery is local testing or external. Does not create an alias.",
348
+ inputSchema: {},
349
+ annotations: { readOnlyHint: true },
350
+ }, async () => run(() => getClient().mailboxStatus()));
351
+ server.registerTool("mail_conversations_list", {
352
+ description: "List the signed-in user's Findyet Mail conversations. folder is inbox, archived, sent, or all.",
353
+ inputSchema: {
354
+ folder: z.enum(["inbox", "archived", "sent", "all"]).default("inbox"),
355
+ q: z.string().default(""),
356
+ },
357
+ annotations: { readOnlyHint: true },
358
+ }, async ({ folder, q }) => run(() => getClient().mailConversations(folder, q)));
359
+ server.registerTool("mail_thread_read", {
360
+ description: "Read one Findyet Mail thread, including messages and attachment metadata. Marks it read.",
361
+ inputSchema: { thread_id: z.string().min(1) },
362
+ annotations: { readOnlyHint: false, idempotentHint: true },
363
+ }, async ({ thread_id }) => run(() => getClient().mailThread(thread_id)));
364
+ server.registerTool("mail_reply_draft", {
365
+ description: "Ask AI for a reply draft. This never sends mail. The draft is returned for the user to edit and confirm.",
366
+ inputSchema: {
367
+ thread_id: z.string().min(1),
368
+ instruction: z.string().default(""),
369
+ },
370
+ annotations: { readOnlyHint: true },
371
+ }, async ({ thread_id, instruction }) => run(() => getClient().mailReplyDraft(thread_id, instruction)));
372
+ server.registerTool("mail_reply", {
373
+ description: "Send a reply from the claimed Findyet mailbox, or from the alias already on a historical thread. Requires confirm: true. Local development captures the message in Mailpit; it is not delivered on the public internet.",
374
+ inputSchema: {
375
+ thread_id: z.string().min(1),
376
+ body: z.string().min(1),
377
+ idempotency_key: z.string().min(1),
378
+ confirm: z.literal(true).describe("Must be true. The tool does not send without this explicit authorization."),
379
+ },
380
+ annotations: { readOnlyHint: false, idempotentHint: true },
381
+ }, async ({ thread_id, body, idempotency_key, confirm }) => {
382
+ if (confirm !== true) {
383
+ return fail(new Error("explicit confirm: true is required before sending"));
384
+ }
385
+ return run(() => getClient().mailReply(thread_id, body, idempotency_key));
386
+ });
387
+ server.registerTool("mail_link", {
388
+ description: "Link a Findyet Mail conversation to one of the user's tracked applications. Does not change the application stage.",
389
+ inputSchema: {
390
+ thread_id: z.string().min(1),
391
+ application_id: z.number().int(),
392
+ },
393
+ annotations: { readOnlyHint: false, idempotentHint: true },
394
+ }, async ({ thread_id, application_id }) => run(() => getClient().mailLink(thread_id, application_id)));
395
+ }
396
+ /** createJobShape is the shared input schema for `submit` and `jobs_add`. */
397
+ function createJobShape() {
398
+ return {
399
+ url: z.string().url().describe("The posting URL — the dedup key. Required."),
400
+ title: z.string().min(1).describe("Job title. Required."),
401
+ company: z.string().min(1).describe("Company name. Required."),
402
+ source: z.string().optional().describe("The posting's real origin (defaults to 'manual' server-side)."),
403
+ location: z.string().optional(),
404
+ remote: z.boolean().optional(),
405
+ description: z.string().optional().describe("Stored and rendered as HTML."),
406
+ posted_at: z.string().optional().describe("RFC3339 timestamp."),
407
+ };
408
+ }
@@ -0,0 +1,11 @@
1
+ import { createRequire } from "node:module";
2
+ /** The version the host displays in serverInfo. Read from package.json rather than
3
+ * written out by hand: this string sat at 0.1.0 through four releases, because
4
+ * nothing fails when it goes stale.
5
+ *
6
+ * `../package.json` resolves from `dist/` in the npm package and from `server/` in
7
+ * the MCPB bundle alike.
8
+ *
9
+ * It lives in its own module so tests can assert on it without importing index.ts,
10
+ * which starts the stdio server as a side effect of being imported. */
11
+ export const VERSION = createRequire(import.meta.url)("../package.json").version;
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "findyet-mcp",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Findyet MCP server: search jobs, get CV-based recommendations, track applications and tailor CVs with your Findyet API key.",
5
+ "type": "module",
6
+ "bin": {
7
+ "findyet-mcp": "dist/index.js"
8
+ },
9
+ "files": [
10
+ "dist"
11
+ ],
12
+ "engines": {
13
+ "node": ">=18"
14
+ },
15
+ "scripts": {
16
+ "build": "tsc",
17
+ "start": "node dist/index.js",
18
+ "test": "vitest run",
19
+ "build:mcpb": "node scripts/build-mcpb.mjs",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "keywords": [
23
+ "mcp",
24
+ "modelcontextprotocol",
25
+ "findyet",
26
+ "jobs",
27
+ "ats"
28
+ ],
29
+ "license": "MIT",
30
+ "dependencies": {
31
+ "@modelcontextprotocol/sdk": "^1.12.0",
32
+ "zod": "^3.23.8"
33
+ },
34
+ "devDependencies": {
35
+ "@types/node": "^20.14.0",
36
+ "typescript": "^5.5.0",
37
+ "vitest": "^4.1.10"
38
+ },
39
+ "homepage": "https://findyet.devjoys.com/cli#mcp"
40
+ }