mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-07-10 21:15:04 +10:00
Fix MCP tool names for Claude Desktop incompatibility (#2885)
* fixes #2884, rename tool names for claude to work * update dependencies
This commit is contained in:
@@ -59,6 +59,27 @@ async function getUserFromApiKey(apiKey: string): Promise<User | null> {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the authenticated user from the same headers oRPC uses (`x-api-key`, `Authorization: Bearer`, or session cookies).
|
||||
* For callers outside oRPC handlers (e.g. MCP tools) where `context.user` is not in scope.
|
||||
*/
|
||||
export async function resolveUserFromRequestHeaders(headers: Headers): Promise<User | null> {
|
||||
// Try API key authentication first
|
||||
const apiKey = headers.get("x-api-key");
|
||||
if (apiKey) {
|
||||
const user = await getUserFromApiKey(apiKey);
|
||||
if (user) return user;
|
||||
} else {
|
||||
// Fall back to Bearer token authentication
|
||||
const user = await getUserFromBearerToken(headers);
|
||||
if (user) return user;
|
||||
}
|
||||
|
||||
// Finally, try session authentication (cookies)
|
||||
const user = await getUserFromHeaders(headers);
|
||||
return user ?? null;
|
||||
}
|
||||
|
||||
const base = os.$context<ORPCContext>();
|
||||
|
||||
export const publicProcedure = base.use(async ({ context, next }) => {
|
||||
|
||||
@@ -61,7 +61,9 @@ export const resumeDto = {
|
||||
.pick({ name: true, slug: true, tags: true, data: true, isPublic: true })
|
||||
.partial()
|
||||
.extend({ id: z.string() }),
|
||||
output: resumeSchema.omit({ password: true, userId: true, createdAt: true, updatedAt: true }),
|
||||
output: resumeSchema
|
||||
.omit({ password: true, userId: true, createdAt: true, updatedAt: true })
|
||||
.extend({ hasPassword: z.boolean() }),
|
||||
},
|
||||
|
||||
setLocked: {
|
||||
|
||||
@@ -48,6 +48,16 @@ export function buildMcpServerCard(appVersion: string) {
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.listResumes],
|
||||
},
|
||||
{
|
||||
name: T.listResumeTags,
|
||||
title: "List Resume Tags",
|
||||
description: [
|
||||
"Returns a sorted list of every distinct tag used across your resumes.",
|
||||
"Useful for choosing tag filters when calling list tools or keeping naming consistent.",
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(z.object({})),
|
||||
annotations: TOOL_ANNOTATIONS[T.listResumeTags],
|
||||
},
|
||||
{
|
||||
name: T.getResume,
|
||||
title: "Get Resume",
|
||||
@@ -64,6 +74,17 @@ export function buildMcpServerCard(appVersion: string) {
|
||||
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResume],
|
||||
},
|
||||
{
|
||||
name: T.getResumeAnalysis,
|
||||
title: "Get Resume Analysis",
|
||||
description: [
|
||||
"Returns the latest saved AI analysis for a resume (scorecard, strengths, suggestions), if any.",
|
||||
"Analyses are created from the Reactive Resume web app AI flow, not from MCP.",
|
||||
`Returns JSON or a short message if none exists. Use \`${T.listResumes}\` to find resume IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
|
||||
annotations: TOOL_ANNOTATIONS[T.getResumeAnalysis],
|
||||
},
|
||||
{
|
||||
name: T.createResume,
|
||||
title: "Create Resume",
|
||||
@@ -92,6 +113,24 @@ export function buildMcpServerCard(appVersion: string) {
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.createResume],
|
||||
},
|
||||
{
|
||||
name: T.importResume,
|
||||
title: "Import Resume",
|
||||
description: [
|
||||
"Create a new resume from a full ResumeData JSON object (e.g. an exported file from Reactive Resume).",
|
||||
"A random name and slug are assigned automatically, like the web importer.",
|
||||
`For small edits to an existing resume, prefer \`${T.patchResume}\` instead of re-importing.`,
|
||||
"Large payloads may exceed MCP client message limits — in that case, use the web UI or the HTTP API.",
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(
|
||||
z.object({
|
||||
data: z
|
||||
.unknown()
|
||||
.describe("Complete ResumeData JSON (same shape as get_resume output or `resume://_meta/schema`)."),
|
||||
}),
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.importResume],
|
||||
},
|
||||
{
|
||||
name: T.duplicateResume,
|
||||
title: "Duplicate Resume",
|
||||
@@ -134,6 +173,38 @@ export function buildMcpServerCard(appVersion: string) {
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.patchResume],
|
||||
},
|
||||
{
|
||||
name: T.updateResume,
|
||||
title: "Update Resume (metadata)",
|
||||
description: [
|
||||
"Update resume metadata only: display name, URL slug, tags, and/or public visibility.",
|
||||
"Does not change section content — use JSON Patch via the patch tool for body edits.",
|
||||
`Locked resumes cannot be updated; use \`${T.unlockResume}\` first.`,
|
||||
"Password protection cannot be set or removed via MCP; use the web app for that.",
|
||||
"",
|
||||
"Always returns your canonical share URL (`{app}/{username}/{slug}`). Anonymous viewers can use it only when `isPublic` is true; password protection from the web app still applies.",
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(
|
||||
z.object({
|
||||
id: resumeId,
|
||||
name: z.string().min(1).max(64).optional().describe("Display name for the resume."),
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(64)
|
||||
.optional()
|
||||
.describe("URL-friendly slug; must stay unique among your resumes."),
|
||||
tags: z.array(z.string()).optional().describe("Replace the resume's tags (omit to leave unchanged)."),
|
||||
isPublic: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true, anyone with the link can view the public resume (subject to password if set in the app).",
|
||||
),
|
||||
}),
|
||||
),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateResume],
|
||||
},
|
||||
{
|
||||
name: T.deleteResume,
|
||||
title: "Delete Resume",
|
||||
@@ -152,7 +223,7 @@ export function buildMcpServerCard(appVersion: string) {
|
||||
description: [
|
||||
"Lock a resume to prevent any modifications.",
|
||||
"",
|
||||
`When locked, a resume cannot be edited (${T.patchResume}), updated, or deleted.`,
|
||||
`When locked, a resume cannot be edited (${T.patchResume}, ${T.updateResume}), or deleted.`,
|
||||
`Use \`${T.unlockResume}\` to re-enable editing.`,
|
||||
].join("\n"),
|
||||
inputSchema: toJsonSchemaCompat(z.object({ id: resumeId })),
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
/** Hierarchical MCP tool names (SEP-986-style namespacing). */
|
||||
/**
|
||||
* Prefixed MCP tool names for multi-server clients.
|
||||
*/
|
||||
export const MCP_TOOL_NAME = {
|
||||
listResumes: "reactive_resume.list_resumes",
|
||||
getResume: "reactive_resume.get_resume",
|
||||
createResume: "reactive_resume.create_resume",
|
||||
duplicateResume: "reactive_resume.duplicate_resume",
|
||||
patchResume: "reactive_resume.patch_resume",
|
||||
deleteResume: "reactive_resume.delete_resume",
|
||||
lockResume: "reactive_resume.lock_resume",
|
||||
unlockResume: "reactive_resume.unlock_resume",
|
||||
exportResumePdf: "reactive_resume.export_resume_pdf",
|
||||
getResumeScreenshot: "reactive_resume.get_resume_screenshot",
|
||||
getResumeStatistics: "reactive_resume.get_resume_statistics",
|
||||
listResumes: "reactive_resume_list_resumes",
|
||||
listResumeTags: "reactive_resume_list_resume_tags",
|
||||
getResume: "reactive_resume_get_resume",
|
||||
getResumeAnalysis: "reactive_resume_get_resume_analysis",
|
||||
createResume: "reactive_resume_create_resume",
|
||||
importResume: "reactive_resume_import_resume",
|
||||
duplicateResume: "reactive_resume_duplicate_resume",
|
||||
patchResume: "reactive_resume_patch_resume",
|
||||
updateResume: "reactive_resume_update_resume",
|
||||
deleteResume: "reactive_resume_delete_resume",
|
||||
lockResume: "reactive_resume_lock_resume",
|
||||
unlockResume: "reactive_resume_unlock_resume",
|
||||
exportResumePdf: "reactive_resume_export_resume_pdf",
|
||||
getResumeScreenshot: "reactive_resume_get_resume_screenshot",
|
||||
getResumeStatistics: "reactive_resume_get_resume_statistics",
|
||||
} as const;
|
||||
|
||||
@@ -12,18 +12,36 @@ export const TOOL_ANNOTATIONS: Record<McpRegisteredToolName, ToolAnnotations> =
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.listResumeTags]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.getResume]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.getResumeAnalysis]: {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.createResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.importResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.duplicateResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
@@ -36,6 +54,12 @@ export const TOOL_ANNOTATIONS: Record<McpRegisteredToolName, ToolAnnotations> =
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.updateResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
},
|
||||
[MCP_TOOL_NAME.deleteResume]: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
|
||||
|
||||
import { getRequestHeaders } from "@tanstack/react-start/server";
|
||||
import z from "zod";
|
||||
|
||||
import { client } from "@/integrations/orpc/client";
|
||||
import { resolveUserFromRequestHeaders } from "@/integrations/orpc/context";
|
||||
import { resumeDataSchema } from "@/schema/resume/data";
|
||||
import { env } from "@/utils/env";
|
||||
import { jsonPatchOperationSchema } from "@/utils/resume/patch";
|
||||
|
||||
import { MCP_TOOL_NAME } from "./mcp-tool-names";
|
||||
@@ -55,6 +59,25 @@ 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;
|
||||
@@ -107,6 +130,27 @@ export function registerTools(server: McpServer) {
|
||||
),
|
||||
);
|
||||
|
||||
// ── List Resume Tags ──────────────────────────────────────────
|
||||
server.registerTool(
|
||||
T.listResumeTags,
|
||||
{
|
||||
title: "List Resume Tags",
|
||||
description: [
|
||||
"Returns a sorted list of every distinct tag used across your resumes.",
|
||||
"Useful for choosing tag filters when calling list tools or keeping naming consistent.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({}),
|
||||
annotations: TOOL_ANNOTATIONS[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));
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Get Resume ────────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
T.getResume,
|
||||
@@ -132,6 +176,28 @@ export function registerTools(server: McpServer) {
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Get Resume Analysis ───────────────────────────────────────
|
||||
server.registerTool(
|
||||
T.getResumeAnalysis,
|
||||
{
|
||||
title: "Get Resume Analysis",
|
||||
description: [
|
||||
"Returns the latest saved AI analysis for a resume (scorecard, strengths, suggestions), if any.",
|
||||
"Analyses are created from the Reactive Resume web app AI flow, not from MCP.",
|
||||
`Returns JSON or a short message if none exists. Use \`${T.listResumes}\` to find resume IDs.`,
|
||||
].join("\n"),
|
||||
inputSchema: z.object({ id: resumeIdSchema }),
|
||||
annotations: TOOL_ANNOTATIONS[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));
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Create Resume ─────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
T.createResume,
|
||||
@@ -182,6 +248,47 @@ export function registerTools(server: McpServer) {
|
||||
),
|
||||
);
|
||||
|
||||
// ── Import Resume ─────────────────────────────────────────────
|
||||
server.registerTool(
|
||||
T.importResume,
|
||||
{
|
||||
title: "Import Resume",
|
||||
description: [
|
||||
"Create a new resume from a full ResumeData JSON object (e.g. an exported file from Reactive Resume).",
|
||||
"A random name and slug are assigned automatically, like the web importer.",
|
||||
`For small edits to an existing resume, prefer \`${T.patchResume}\` instead of re-importing.`,
|
||||
"Large payloads may exceed MCP client message limits — in that case, use the web UI or the HTTP API.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({
|
||||
data: z
|
||||
.unknown()
|
||||
.describe(
|
||||
"Complete ResumeData JSON (same shape as `reactive_resume_get_resume` body or `resume://_meta/schema`).",
|
||||
),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[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,
|
||||
@@ -259,6 +366,80 @@ export function registerTools(server: McpServer) {
|
||||
}),
|
||||
);
|
||||
|
||||
// ── Update Resume (metadata) ─────────────────────────────────
|
||||
server.registerTool(
|
||||
T.updateResume,
|
||||
{
|
||||
title: "Update Resume (metadata)",
|
||||
description: [
|
||||
"Update resume metadata only: display name, URL slug, tags, and/or public visibility.",
|
||||
"Does not change section content — use JSON Patch via the patch tool for body edits.",
|
||||
`Locked resumes cannot be updated; use \`${T.unlockResume}\` first.`,
|
||||
"Password protection cannot be set or removed via MCP; use the web app for that.",
|
||||
"",
|
||||
"Always returns your canonical share URL (`{app}/{username}/{slug}`). Anonymous viewers can use it only when `isPublic` is true; password protection from the web app still applies.",
|
||||
].join("\n"),
|
||||
inputSchema: z.object({
|
||||
id: resumeIdSchema,
|
||||
name: z.string().min(1).max(64).optional().describe("Display name for the resume."),
|
||||
slug: z.string().min(1).max(64).optional().describe("URL-friendly slug; must stay unique among your resumes."),
|
||||
tags: z.array(z.string()).optional().describe("Replace the resume's tags (omit to leave unchanged)."),
|
||||
isPublic: z
|
||||
.boolean()
|
||||
.optional()
|
||||
.describe(
|
||||
"When true, anyone with the link can view the public resume (subject to password if set in the app).",
|
||||
),
|
||||
}),
|
||||
annotations: TOOL_ANNOTATIONS[T.updateResume],
|
||||
},
|
||||
withErrorHandling(
|
||||
"updating resume",
|
||||
async (params: { id: string; name?: string; slug?: string; tags?: string[]; isPublic?: boolean }) => {
|
||||
const { id, name, slug, tags, isPublic } = params;
|
||||
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 headers = getRequestHeaders();
|
||||
const user = await resolveUserFromRequestHeaders(headers);
|
||||
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,
|
||||
@@ -288,7 +469,7 @@ export function registerTools(server: McpServer) {
|
||||
description: [
|
||||
"Lock a resume to prevent any modifications.",
|
||||
"",
|
||||
`When locked, a resume cannot be edited (${T.patchResume}), updated, or deleted.`,
|
||||
`When locked, a resume cannot be edited (${T.patchResume}, ${T.updateResume}), or deleted.`,
|
||||
"Useful for protecting finalized resumes from accidental changes.",
|
||||
`Use \`${T.unlockResume}\` to re-enable editing.`,
|
||||
].join("\n"),
|
||||
|
||||
@@ -34,9 +34,12 @@ function createMcpServer() {
|
||||
instructions: [
|
||||
"You are connected to Reactive Resume over MCP.",
|
||||
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
|
||||
"Discover resume IDs with `reactive_resume.list_resumes` (not `resources/list`).",
|
||||
"Read schema at `resume://_meta/schema`; read resume JSON via `resume://{id}` or `reactive_resume.get_resume`.",
|
||||
"Apply edits with JSON Patch through `reactive_resume.patch_resume`.",
|
||||
"Discover resume IDs with `reactive_resume_list_resumes` (not `resources/list`).",
|
||||
"List distinct tags with `reactive_resume_list_resume_tags`.",
|
||||
"Read schema at `resume://_meta/schema`; read resume JSON via `resume://{id}` or `reactive_resume_get_resume`.",
|
||||
"Apply body edits with JSON Patch through `reactive_resume_patch_resume`.",
|
||||
"Change name, slug, tags, or public visibility with `reactive_resume_update_resume` (returns canonical share URL; anonymous access only when `isPublic` is true; passwords are managed in the web app only).",
|
||||
"Import full ResumeData JSON with `reactive_resume_import_resume`; read saved AI analysis with `reactive_resume_get_resume_analysis`.",
|
||||
].join(" "),
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user