Files
Reactive-Resume/packages/mcp/src/tools.ts
T
Amruth Pillai 439ae114f9 refactor(mcp): derive tool metadata from single TOOL_META record
- Extract one TOOL_META record (title/description/inputSchema/annotations per
  tool) consumed by both registerTools and buildMcpServerCard, eliminating the
  ~200-line duplication in the server card.
- Collapse TOOL_ANNOTATIONS from 14 × 4-line inline objects to 5 named
  annotation-preset consts (READ_IDEMPOTENT, WRITE_NON_IDEMPOTENT, etc.),
  saving ~55 lines.

Claude-Session: https://claude.ai/code/session_012Bnvt1MghwHj4qQRxuQUGa
2026-07-04 19:26:44 +02:00

343 lines
13 KiB
TypeScript

import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
import type { RouterClient } from "@orpc/server";
import type { resumePatchOperationsSchema } from "@reactive-resume/ai/tools/resume-tool-contracts";
import type router from "@reactive-resume/api/routers";
import type z from "zod";
import { resolveUserFromRequestHeaders } from "@reactive-resume/api/context";
import { createResumePdfDownloadUrl } from "@reactive-resume/api/features/resume/export";
import { env } from "@reactive-resume/env/server";
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
import { MCP_TOOL_NAME } from "./mcp-tool-names";
import { TOOL_META } from "./tool-meta";
export { MCP_TOOL_NAME } from "./mcp-tool-names";
type PatchOperation = z.infer<typeof resumePatchOperationsSchema>[number];
// ── Shared Helpers ─────────────────────────────────────────────
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function errorHint(error: unknown): string {
const msg = errorMessage(error);
const { unlockResume, listResumes, getResume } = MCP_TOOL_NAME;
if (msg.includes("slug already exists")) return "\n\nHint: The slug is already in use. Try a different one.";
if (msg.includes("locked")) return `\n\nHint: This resume is locked. Use \`${unlockResume}\` first.`;
if (msg.includes("404") || msg.includes("not found"))
return `\n\nHint: Resume not found. Use \`${listResumes}\` to find valid IDs.`;
if (msg.includes("400"))
return `\n\nHint: Invalid request. Check the input parameters or use \`${getResume}\` to inspect the resume structure.`;
if (msg.includes("403"))
return `\n\nHint: Permission denied. The resume may be locked — use \`${unlockResume}\` first.`;
return "";
}
/**
* Wraps an async tool handler with consistent error formatting.
* On success, returns the handler's result directly.
* On failure, returns `{ isError: true, content: [{ type: "text", text }] }` with actionable hints.
*/
function withErrorHandling<T>(label: string, handler: (params: T) => Promise<CallToolResult>) {
return async (params: T): Promise<CallToolResult> => {
try {
return await handler(params);
} catch (error) {
return {
isError: true,
content: [{ type: "text", text: `Error ${label}: ${errorMessage(error)}${errorHint(error)}` }],
};
}
};
}
function text(value: string): CallToolResult {
return { content: [{ type: "text", text: value }] };
}
function buildResumeShareUrl(username: string, slug: string): string {
const base = env.APP_URL.replace(/\/$/, "");
return `${base}/${encodeURIComponent(username)}/${encodeURIComponent(slug)}`;
}
function resumeShareUrlNotes(input: { isPublic: boolean; hasPassword: boolean }): string {
const lines = [
"Anyone can open this link without signing in only when the resume is public (`isPublic: true`).",
input.isPublic
? "This resume is currently public."
: "This resume is currently private; the URL is still your canonical share link if you make it public later.",
];
if (input.hasPassword)
lines.push(
"Password protection is enabled in the web app; visitors may need that password before content is shown.",
);
return lines.join("\n");
}
// ── Shared Zod Fragments ───────────────────────────────────────
const T = MCP_TOOL_NAME;
// ── Tool Registration ──────────────────────────────────────────
export function registerTools(server: McpServer, client: RouterClient<typeof router>, requestHeaders: Headers) {
// ── List Resumes ─────────────────────────────────────────────
server.registerTool(
T.listResumes,
TOOL_META[T.listResumes],
withErrorHandling(
"listing resumes",
async ({ tags, sort }: { tags: string[]; sort: "lastUpdatedAt" | "createdAt" | "name" }) => {
const resumes = await client.resume.list({ tags, sort });
if (resumes.length === 0) return text(`No resumes found. Use \`${T.createResume}\` to create one.`);
return text(JSON.stringify(resumes, null, 2));
},
),
);
// ── List Resume Tags ─────────────────────────────────────────
server.registerTool(
T.listResumeTags,
TOOL_META[T.listResumeTags],
withErrorHandling("listing resume tags", async () => {
const tags = await client.resume.tags.list();
if (tags.length === 0) return text("No tags in use yet. Add tags when creating or updating a resume.");
return text(JSON.stringify(tags, null, 2));
}),
);
// ── Read Resume ──────────────────────────────────────────────
server.registerTool(
T.getResume,
TOOL_META[T.getResume],
withErrorHandling("getting resume", async ({ id }: { id: string }) => {
const resume = await client.resume.getById({ id });
return text(JSON.stringify(resume.data, null, 2));
}),
);
// ── Get Resume Analysis ──────────────────────────────────────
server.registerTool(
T.getResumeAnalysis,
TOOL_META[T.getResumeAnalysis],
withErrorHandling("getting resume analysis", async ({ id }: { id: string }) => {
const analysis = await client.resume.analysis.getById({ id });
if (!analysis) return text("No saved analysis for this resume yet.");
return text(JSON.stringify(analysis, null, 2));
}),
);
// ── Download Resume PDF ──────────────────────────────────────
server.registerTool(
T.downloadResumePdf,
TOOL_META[T.downloadResumePdf],
withErrorHandling("creating PDF download URL", async ({ id }: { id: string }) => {
const resume = await client.resume.getById({ id });
const user = await resolveUserFromRequestHeaders(requestHeaders);
if (!user) throw new Error("Unauthorized");
const signedUrl = createResumePdfDownloadUrl({ resumeId: id, userId: user.id });
return text(
JSON.stringify(
{
resumeId: id,
name: resume.name,
downloadUrl: signedUrl.url,
expiresAt: signedUrl.expiresAt,
expiresInSeconds: signedUrl.expiresInSeconds,
contentType: "application/pdf",
},
null,
2,
),
);
}),
);
// ── Create Resume ─────────────────────────────────────────────
server.registerTool(
T.createResume,
TOOL_META[T.createResume],
withErrorHandling(
"creating resume",
async ({
name,
slug,
tags,
withSampleData,
}: {
name: string;
slug: string;
tags: string[];
withSampleData: boolean;
}) => {
const id = await client.resume.create({ name, slug, tags, withSampleData });
return text(
`Created resume "${name}" (ID: ${id}) with slug "${slug}".${withSampleData ? " Pre-filled with sample data." : ""}\n\nNext steps: Use \`${T.getResume}\` to view it, or \`${T.patchResume}\` to start editing.`,
);
},
),
);
// ── Import Resume ────────────────────────────────────────────
server.registerTool(
T.importResume,
TOOL_META[T.importResume],
withErrorHandling("importing resume", async ({ data }: { data: unknown }) => {
const parsed = resumeDataSchema.safeParse(data);
if (!parsed.success)
return {
isError: true,
content: [
{
type: "text",
text: `Invalid ResumeData: ${parsed.error.message}\n\nHint: Ensure the JSON matches the schema at resume://_meta/schema`,
},
],
};
const id = await client.resume.import({ data: parsed.data });
return text(
`Imported resume (ID: ${id}).\n\nNext steps: Use \`${T.getResume}\` to inspect metadata (name/slug were auto-generated), or \`${T.updateResume}\` / \`${T.patchResume}\` to adjust.`,
);
}),
);
// ── Duplicate Resume ─────────────────────────────────────────
server.registerTool(
T.duplicateResume,
TOOL_META[T.duplicateResume],
withErrorHandling(
"duplicating resume",
async ({ id, name, slug, tags }: { id: string; name: string; slug: string; tags: string[] }) => {
const newId = await client.resume.duplicate({ id, name, slug, tags });
return text(
`Duplicated resume as "${name}" (ID: ${newId}) with slug "${slug}".\n\nNext steps: Use \`${T.getResume}\` to view it, or \`${T.patchResume}\` to customize.`,
);
},
),
);
// ── Apply Resume Patch ────────────────────────────────────────
server.registerTool(
T.patchResume,
TOOL_META[T.patchResume],
withErrorHandling("patching resume", async ({ id, operations }: { id: string; operations: PatchOperation[] }) => {
const resume = await client.resume.patch({ id, operations });
const summary = operations.map((op) => `${op.op} ${op.path}`).join(", ");
return text(`Applied ${operations.length} operation(s) to "${resume.name}": ${summary}`);
}),
);
// ── Update Resume (metadata) ────────────────────────────────
server.registerTool(
T.updateResume,
TOOL_META[T.updateResume],
withErrorHandling("updating resume", async (params) => {
const { id, name, slug, tags, isPublic } = params as {
id: string;
name?: string;
slug?: string;
tags?: string[];
isPublic?: boolean;
};
if (name === undefined && slug === undefined && tags === undefined && isPublic === undefined)
throw new Error("Provide at least one of: name, slug, tags, isPublic.");
const resume = await client.resume.update({
id,
...(name !== undefined ? { name } : {}),
...(slug !== undefined ? { slug } : {}),
...(tags !== undefined ? { tags } : {}),
...(isPublic !== undefined ? { isPublic } : {}),
});
const user = await resolveUserFromRequestHeaders(requestHeaders);
const username =
user && "username" in user && typeof (user as { username: unknown }).username === "string"
? (user as { username: string }).username
: "";
const shareUrl =
username !== ""
? buildResumeShareUrl(username, resume.slug)
: "(could not build share URL — missing username on account)";
const payload = {
id: resume.id,
name: resume.name,
slug: resume.slug,
tags: resume.tags,
isPublic: resume.isPublic,
hasPassword: resume.hasPassword,
shareUrl,
};
return text(
[
JSON.stringify(payload, null, 2),
"",
resumeShareUrlNotes({ isPublic: resume.isPublic, hasPassword: resume.hasPassword }),
].join("\n"),
);
}),
);
// ── Delete Resume ────────────────────────────────────────────
server.registerTool(
T.deleteResume,
TOOL_META[T.deleteResume],
withErrorHandling("deleting resume", async ({ id }: { id: string }) => {
await client.resume.delete({ id });
return text(`Successfully deleted resume (${id}) and all associated files.`);
}),
);
// ── Lock Resume ──────────────────────────────────────────────
server.registerTool(
T.lockResume,
TOOL_META[T.lockResume],
withErrorHandling("locking resume", async ({ id }: { id: string }) => {
await client.resume.setLocked({ id, isLocked: true });
return text(`Resume (${id}) is now locked. It cannot be edited, patched, or deleted until unlocked.`);
}),
);
// ── Unlock Resume ────────────────────────────────────────────
server.registerTool(
T.unlockResume,
TOOL_META[T.unlockResume],
withErrorHandling("unlocking resume", async ({ id }: { id: string }) => {
await client.resume.setLocked({ id, isLocked: false });
return text(`Resume (${id}) is now unlocked. It can be edited, patched, and deleted.`);
}),
);
// ── Get Resume Statistics ────────────────────────────────────
server.registerTool(
T.getResumeStatistics,
TOOL_META[T.getResumeStatistics],
withErrorHandling("getting resume statistics", async ({ id }: { id: string }) => {
const stats = await client.resume.statistics.getById({ id });
return text(JSON.stringify(stats, null, 2));
}),
);
}