From 2b4608bfb489949e65a0a021130a856d8017ddb1 Mon Sep 17 00:00:00 2001 From: Sean O'Connor Date: Sat, 6 Jun 2026 18:46:30 -0400 Subject: [PATCH] feat: add missing MCP tools and fix undefined content response MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add time_entries_update, time_entries_delete, time_entries_get_summary tools - Fix textResult to use `data ?? null` so JSON.stringify always returns a string — procedures returning undefined (e.g. getRunning with no timer, businesses.getById not found) were dropping the text field from the MCP content item, causing client SDK validation failures Co-Authored-By: Claude Sonnet 4.6 --- src/app/api/mcp/route.ts | 63 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 62 insertions(+), 1 deletion(-) diff --git a/src/app/api/mcp/route.ts b/src/app/api/mcp/route.ts index a12bae4..bc13df6 100644 --- a/src/app/api/mcp/route.ts +++ b/src/app/api/mcp/route.ts @@ -234,7 +234,7 @@ function parseInvoiceItems(items: z.infer[]) { function textResult(data: unknown): ToolResult { return { - content: [{ type: "text", text: JSON.stringify(data, null, 2) }], + content: [{ type: "text", text: JSON.stringify(data ?? null, null, 2) }], }; } @@ -509,6 +509,67 @@ const tools = { endedAt: input.endedAt ? parseDate(input.endedAt, "endedAt") : undefined, }), }), + time_entries_update: defineTool({ + description: "Update an existing time entry by ID. All fields are optional except id.", + inputSchema: { + type: "object", + properties: { + id: { type: "string" }, + description: { type: "string", maxLength: 500 }, + clientId: { type: "string" }, + startedAt: { type: "string", format: "date-time" }, + endedAt: { type: "string", format: "date-time" }, + hours: { type: "number", minimum: 0 }, + rate: { type: "number", minimum: 0 }, + notes: { type: "string", maxLength: 500 }, + }, + required: ["id"], + additionalProperties: false, + }, + schema: z.object({ + id: z.string(), + description: z.string().max(500).optional(), + clientId: z.string().optional().or(z.literal("")), + startedAt: dateString.optional(), + endedAt: dateString.optional(), + hours: z.number().min(0).optional(), + rate: z.number().min(0).optional(), + notes: z.string().max(500).optional().or(z.literal("")), + }), + handler: async (input, caller) => + caller.timeEntries.update({ + ...input, + startedAt: input.startedAt ? parseDate(input.startedAt, "startedAt") : undefined, + endedAt: input.endedAt ? parseDate(input.endedAt, "endedAt") : undefined, + }), + }), + time_entries_delete: defineTool({ + description: "Delete a time entry by ID.", + inputSchema: jsonSchemas.id, + schema: z.object({ id: z.string() }), + handler: async (input, caller) => caller.timeEntries.delete(input), + }), + time_entries_get_summary: defineTool({ + description: + "Get total hours, total earnings, and entry count for the authenticated user, optionally filtered by date range.", + inputSchema: { + type: "object", + properties: { + from: { type: "string", format: "date-time" }, + to: { type: "string", format: "date-time" }, + }, + additionalProperties: false, + }, + schema: z.object({ + from: dateString.optional(), + to: dateString.optional(), + }), + handler: async (input, caller) => + caller.timeEntries.getSummary({ + from: input.from ? parseDate(input.from, "from") : undefined, + to: input.to ? parseDate(input.to, "to") : undefined, + }), + }), } satisfies Record; function rpcResult(id: JsonRpcId, result: unknown, init?: ResponseInit) {