From 3212ecda68d15b17e4fc9c965bb0a38678c99bea Mon Sep 17 00:00:00 2001 From: Waleed Latif Date: Sun, 8 Nov 2026 00:53:47 -0800 Subject: [PATCH] fix(search): read Search MCP date filters without an offset as UTC A date filter sent without its UTC offset failed validation, and the client resent the same value with Z appended, costing a full extra round. Accept the offset-less form and read it as UTC up front. tools/list is unchanged. --- apps/sim/lib/api/contracts/knowledge/mcp.ts | 25 ++++++++++++------- .../lib/knowledge/mcp/server.protocol.test.ts | 20 +++++++++++++++ 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/apps/sim/lib/api/contracts/knowledge/mcp.ts b/apps/sim/lib/api/contracts/knowledge/mcp.ts index 74d78021dcb..f06b87c0843 100644 --- a/apps/sim/lib/api/contracts/knowledge/mcp.ts +++ b/apps/sim/lib/api/contracts/knowledge/mcp.ts @@ -19,23 +19,30 @@ export const organizationKnowledgeMcpContract = defineRouteContract({ const LIVE_SEARCH_MCP_DEFAULT_TOP_K = { federated: 30, scoped: 20 } as const +const HAS_UTC_OFFSET = /(Z|[+-]\d{2}:\d{2})$/ + /** - * A datetime field without the regex its JSON Schema would otherwise carry beside - * `format: "date-time"`. Every tools/list call resends the schema, and parsing still rejects a - * string that is not an ISO datetime with an offset. + * An ISO datetime filter that reads a value without an offset as UTC, which is what a client + * resends after that value is rejected, so the call succeeds on the first try. Its JSON Schema + * keeps `format: "date-time"` and the field's description but not the regex, which every + * tools/list call would resend. */ -function withoutPatternInJsonSchema(schema: T): T { - return schema.meta({ pattern: undefined }) +function mcpDateFilter(field: z.ZodOptional) { + return z.iso + .datetime({ offset: true, local: true }) + .transform((value) => (HAS_UTC_OFFSET.test(value) ? value : `${value}Z`)) + .optional() + .meta({ pattern: undefined, description: field.description }) } const { startDate, endDate, modifiedAfter, modifiedBefore } = workspaceSearchFiltersSchema.shape /** The date filters of the Search MCP tools, as their tools/list schema presents them. */ const mcpDateFilterFields = { - startDate: withoutPatternInJsonSchema(startDate), - endDate: withoutPatternInJsonSchema(endDate), - modifiedAfter: withoutPatternInJsonSchema(modifiedAfter), - modifiedBefore: withoutPatternInJsonSchema(modifiedBefore), + startDate: mcpDateFilter(startDate), + endDate: mcpDateFilter(endDate), + modifiedAfter: mcpDateFilter(modifiedAfter), + modifiedBefore: mcpDateFilter(modifiedBefore), } export const liveSearchMcpSchema = searchWorkspaceInputFieldsSchema diff --git a/apps/sim/lib/knowledge/mcp/server.protocol.test.ts b/apps/sim/lib/knowledge/mcp/server.protocol.test.ts index d08592dc8b1..62c34aa3dfc 100644 --- a/apps/sim/lib/knowledge/mcp/server.protocol.test.ts +++ b/apps/sim/lib/knowledge/mcp/server.protocol.test.ts @@ -98,6 +98,26 @@ describe('Search MCP protocol', () => { expect(read.content).toEqual([ { type: 'text', text: expect.stringContaining('Read evidence') }, ]) + const offsetless = await client.callTool({ + name: 'search', + arguments: { + query: 'release', + startDate: '2026-09-28T00:00:00', + endDate: '2026-10-08T00:00', + modifiedAfter: '2026-09-01T08:00:00-07:00', + }, + }) + expect(offsetless.isError).not.toBe(true) + expect(mocks.liveSearch.mock.lastCall?.[0].input.filters).toMatchObject({ + startDate: '2026-09-28T00:00:00Z', + endDate: '2026-10-08T00:00Z', + modifiedAfter: '2026-09-01T08:00:00-07:00', + }) + const notADate = await client.callTool({ + name: 'search', + arguments: { query: 'release', startDate: '2026-09-28' }, + }) + expect(notADate.isError).toBe(true) } finally { await client.close() await server.close()