fix(mcp): align tool annotations and descriptions with behavior

This commit is contained in:
Amruth Pillai
2026-09-11 00:31:58 +02:00
parent 3fc0896a34
commit 81341a107f
2 changed files with 74 additions and 31 deletions
+40 -6
View File
@@ -30,9 +30,12 @@ describe("MCP_TOOL_NAME", () => {
});
describe("tool annotations", () => {
it("provides annotations for every registered tool", () => {
it("provides explicit submission hints for every registered tool", () => {
for (const name of Object.values(MCP_TOOL_NAME)) {
expect(TOOL_META[name].annotations).toBeDefined();
const annotations = TOOL_META[name].annotations;
expect(typeof annotations.readOnlyHint, name).toBe("boolean");
expect(typeof annotations.destructiveHint, name).toBe("boolean");
expect(typeof annotations.openWorldHint, name).toBe("boolean");
}
});
@@ -82,8 +85,9 @@ describe("tool annotations", () => {
MCP_TOOL_NAME.createResume,
MCP_TOOL_NAME.importResume,
MCP_TOOL_NAME.duplicateResume,
MCP_TOOL_NAME.patchResume,
MCP_TOOL_NAME.updateResume,
MCP_TOOL_NAME.createApplication,
MCP_TOOL_NAME.importApplications,
MCP_TOOL_NAME.draftApplicationMessage,
]) {
const annotations = TOOL_META[name].annotations;
expect(annotations.readOnlyHint, name).toBe(false);
@@ -101,9 +105,39 @@ describe("tool annotations", () => {
}
});
it("declares no tools as open-world", () => {
it("marks tools that replace or remove existing data as destructive", () => {
for (const name of [
MCP_TOOL_NAME.patchResume,
MCP_TOOL_NAME.updateResume,
MCP_TOOL_NAME.updateApplication,
MCP_TOOL_NAME.updateApplicationTimelineEntry,
MCP_TOOL_NAME.bulkUpdateApplications,
MCP_TOOL_NAME.attachApplicationDocument,
MCP_TOOL_NAME.removeApplicationDocument,
MCP_TOOL_NAME.scoreApplicationMatch,
MCP_TOOL_NAME.tailorResumeForApplication,
]) {
expect(TOOL_META[name].annotations.readOnlyHint, name).toBe(false);
expect(TOOL_META[name].annotations.destructiveHint, name).toBe(true);
}
});
it("marks public content changes and external AI calls as open-world", () => {
const openWorldTools = new Set<string>([
MCP_TOOL_NAME.patchResume,
MCP_TOOL_NAME.updateResume,
MCP_TOOL_NAME.deleteResume,
MCP_TOOL_NAME.attachApplicationDocument,
MCP_TOOL_NAME.removeApplicationDocument,
MCP_TOOL_NAME.deleteApplication,
MCP_TOOL_NAME.bulkDeleteApplications,
MCP_TOOL_NAME.autofillApplicationFromJob,
MCP_TOOL_NAME.scoreApplicationMatch,
MCP_TOOL_NAME.tailorResumeForApplication,
MCP_TOOL_NAME.draftApplicationMessage,
]);
for (const [name, { annotations }] of Object.entries(TOOL_META)) {
expect(annotations.openWorldHint, name).toBe(false);
expect(annotations.openWorldHint, name).toBe(openWorldTools.has(name));
}
});
});
+34 -25
View File
@@ -232,6 +232,7 @@ export const TOOL_META = {
"current structure, and `resume://_meta/schema` to understand valid paths and types.",
"",
"Supported operations: add, remove, replace, move, copy, test.",
"Can remove or overwrite existing content; edits to a public resume change its published content.",
"",
"Common path examples:",
" /basics/name — Change the name",
@@ -252,7 +253,7 @@ export const TOOL_META = {
id: resumeIdSchema,
operations: resumePatchOperationsSchema,
}),
annotations: WRITE_NON_IDEMPOTENT,
annotations: { ...WRITE_NON_IDEMPOTENT, destructiveHint: true, openWorldHint: true },
},
[T.updateResume]: {
title: "Update Resume (metadata)",
@@ -276,18 +277,18 @@ export const TOOL_META = {
"When true, anyone with the link can view the public resume (subject to password if set in the app).",
),
}),
annotations: WRITE_NON_IDEMPOTENT,
annotations: { ...WRITE_NON_IDEMPOTENT, destructiveHint: true, openWorldHint: true },
},
[T.deleteResume]: {
title: "Delete Resume",
description: [
"Permanently delete a resume and all its associated files (screenshots, PDFs).",
"Permanently delete a resume and all its associated files (screenshots, PDFs), removing public access if published.",
"",
`This action is IRREVERSIBLE. Locked resumes cannot be deleted; use \`${T.unlockResume}\` first.`,
`Consider using \`${T.duplicateResume}\` to create a backup before deleting.`,
].join("\n"),
inputSchema: z.object({ id: resumeIdSchema }),
annotations: WRITE_DESTRUCTIVE,
annotations: { ...WRITE_DESTRUCTIVE, openWorldHint: true },
},
[T.lockResume]: {
title: "Lock Resume",
@@ -321,7 +322,7 @@ export const TOOL_META = {
[T.listApplications]: {
title: "List Applications",
description:
"List job applications for the authenticated account. Use this before reading or updating existing applications.",
"List job applications for the authenticated account, including contacts, notes, document URLs, and timeline. Use this before reading or updating existing applications.",
inputSchema: z.object({
status: applicationStatusSchema.optional(),
tags: z.array(z.string()).optional().default([]),
@@ -356,13 +357,13 @@ export const TOOL_META = {
[T.updateApplication]: {
title: "Update Application",
description:
"Update application fields, move stages, archive/unarchive, edit contacts, follow-up, tags, or linked resume.",
"Update application fields, move stages, archive/unarchive, edit contacts, follow-up, tags, or linked resume. Provided fields replace existing values, including contact and tag lists.",
inputSchema: z.object({
id: applicationIdSchema,
...applicationMutableFieldsSchema,
archived: z.boolean().optional().describe("Whether the application is hidden from active views."),
}),
annotations: WRITE_IDEMPOTENT,
annotations: WRITE_DESTRUCTIVE,
},
[T.addApplicationNote]: {
title: "Add Application Note",
@@ -385,7 +386,7 @@ export const TOOL_META = {
text: z.string().min(1).optional().describe("Replacement note text. Only note entries can change text."),
})
.refine((value) => value.date !== undefined || value.text !== undefined, "Provide date or text to update."),
annotations: WRITE_IDEMPOTENT,
annotations: WRITE_DESTRUCTIVE,
},
[T.deleteApplicationTimelineEntry]: {
title: "Delete Application Timeline Entry",
@@ -395,9 +396,10 @@ export const TOOL_META = {
},
[T.deleteApplication]: {
title: "Delete Application",
description: "Permanently delete one job application and its owned uploaded documents.",
description:
"Permanently delete one job application and its owned uploaded documents that no remaining application references, removing those public file URLs.",
inputSchema: z.object({ id: applicationIdSchema }),
annotations: WRITE_DESTRUCTIVE,
annotations: { ...WRITE_DESTRUCTIVE, openWorldHint: true },
},
[T.bulkUpdateApplications]: {
title: "Bulk Update Applications",
@@ -408,13 +410,14 @@ export const TOOL_META = {
archived: z.boolean().optional(),
addTags: z.array(z.string()).optional(),
}),
annotations: WRITE_IDEMPOTENT,
annotations: WRITE_DESTRUCTIVE,
},
[T.bulkDeleteApplications]: {
title: "Bulk Delete Applications",
description: "Permanently delete multiple applications.",
description:
"Permanently delete multiple applications and their owned uploaded documents that no remaining application references, removing those public file URLs.",
inputSchema: z.object({ ids: z.array(z.string()).min(1) }),
annotations: WRITE_DESTRUCTIVE,
annotations: { ...WRITE_DESTRUCTIVE, openWorldHint: true },
},
[T.importApplications]: {
title: "Import Applications",
@@ -424,7 +427,8 @@ export const TOOL_META = {
},
[T.attachApplicationDocument]: {
title: "Attach Application Document",
description: "Attach a sent resume or cover-letter PDF to an application using base64-encoded PDF bytes.",
description:
"Upload and attach a resume or cover-letter PDF using base64-encoded PDF bytes (maximum 10MB). Anyone with the resulting file URL can download it without signing in. Replaces the existing attachment of that kind and deletes its owned file if no other application references it. Does not send the document to an employer.",
inputSchema: z.object({
id: applicationIdSchema,
kind: applicationDocumentKindSchema,
@@ -432,36 +436,41 @@ export const TOOL_META = {
contentType: z.literal("application/pdf"),
dataBase64: pdfBase64Schema,
}),
annotations: WRITE_NON_IDEMPOTENT,
annotations: { ...WRITE_NON_IDEMPOTENT, destructiveHint: true, openWorldHint: true },
},
[T.removeApplicationDocument]: {
title: "Remove Application Document",
description: "Remove a sent resume or cover-letter PDF from an application.",
description:
"Clear a resume or cover-letter attachment and delete its owned uploaded file if no other application references it, removing access through its public file URL.",
inputSchema: z.object({ id: applicationIdSchema, kind: applicationDocumentKindSchema }),
annotations: WRITE_IDEMPOTENT,
annotations: { ...WRITE_DESTRUCTIVE, openWorldHint: true },
},
[T.autofillApplicationFromJob]: {
title: "Autofill Application From Job",
description: "Use AI to extract company, role, location, and salary from a pasted job posting.",
description:
"Send a pasted job posting to your configured AI provider to extract company, role, location, and salary. Requires an enabled, tested default AI provider. Returns suggestions without saving an application or fetching a job URL.",
inputSchema: z.object({ jobDescription: z.string().trim().min(1).max(20_000) }),
annotations: READ_NON_IDEMPOTENT,
annotations: { ...READ_NON_IDEMPOTENT, openWorldHint: true },
},
[T.scoreApplicationMatch]: {
title: "Score Application Match",
description: "Score the linked resume against the application's job description and persist match metadata.",
description:
"Send the full linked resume and job description to your configured AI provider to score their match. Requires an enabled, tested default AI provider, a linked resume, and a job description. Overwrites the application's saved match score and AI metadata.",
inputSchema: z.object({ id: applicationIdSchema }),
annotations: WRITE_NON_IDEMPOTENT,
annotations: { ...WRITE_NON_IDEMPOTENT, destructiveHint: true, openWorldHint: true },
},
[T.tailorResumeForApplication]: {
title: "Tailor Resume For Application",
description: "Create and link a tailored copy of the application's linked resume.",
description:
"Send the full linked resume and job description to your configured AI provider to rewrite the summary in a new private resume copy. Requires an enabled, tested default AI provider, a linked resume, and a job description. Replaces the application's resume link with the new copy and adds a timeline note; the original resume is unchanged.",
inputSchema: z.object({ id: applicationIdSchema }),
annotations: WRITE_NON_IDEMPOTENT,
annotations: { ...WRITE_NON_IDEMPOTENT, destructiveHint: true, openWorldHint: true },
},
[T.draftApplicationMessage]: {
title: "Draft Application Message",
description: "Draft either a cover letter or recruiter follow-up from application and resume context.",
description:
"Send application context and the full linked resume, when available, to your configured AI provider to draft a cover letter or recruiter follow-up. Requires an enabled, tested default AI provider. Cover-letter mode saves a new cover letter and returns text plus coverLetterId; follow-up mode returns text without saving it. Neither mode sends a message to a recruiter.",
inputSchema: z.object({ id: applicationIdSchema, kind: z.enum(["cover-letter", "follow-up"]) }),
annotations: READ_NON_IDEMPOTENT,
annotations: { ...WRITE_NON_IDEMPOTENT, openWorldHint: true },
},
} as const;