diff --git a/packages/worker/src/mcp/capabilities/mcp-server/index.ts b/packages/worker/src/mcp/capabilities/mcp-server/index.ts index 75d17aa1ed..3ca3b57c94 100644 --- a/packages/worker/src/mcp/capabilities/mcp-server/index.ts +++ b/packages/worker/src/mcp/capabilities/mcp-server/index.ts @@ -1,4 +1,5 @@ import { type McpServerRef } from '@kody-internal/shared/mcp-servers.ts' +import { McpCallerError } from '#mcp/caller-error.ts' import { defineCapability } from '#mcp/capabilities/define-capability.ts' import { type CapabilityDomain } from '#mcp/capabilities/domain-metadata.ts' import { @@ -7,6 +8,7 @@ import { tokenizeCapabilityKeywords, } from '#mcp/capabilities/synthesized-domain.ts' import { type Capability, type DomainSpec } from '#mcp/capabilities/types.ts' +import { wrapDownstreamMcpToolResult } from '#mcp/downstream-mcp-result.ts' import { createMcpClientHubClient } from '#worker/mcp-client/hub-client.ts' import { mcpServerCapabilityId, @@ -22,7 +24,6 @@ import { type McpServerSnapshot, type McpServerToolDescriptor, } from '#worker/mcp-client/types.ts' -import { wrapDownstreamMcpToolResult } from '#mcp/downstream-mcp-result.ts' type McpServerToolCapabilityBinding = { capabilityName: string @@ -88,7 +89,7 @@ function createCapabilityFromTool(input: { async handler(args, ctx) { const userId = ctx.callerContext.user?.userId if (!userId) { - throw new Error( + throw new McpCallerError( `MCP server capability "${ref.name}:${tool.name}" requires an authenticated user.`, ) } @@ -104,20 +105,24 @@ function createCapabilityFromTool(input: { args: (args ?? {}) as Record, }) } catch (error) { - // The status check adds context for disconnected servers, but it - // must never mask the original tool-call error if it fails itself. + // Downstream / user-connected MCP servers fail for many + // caller-clearable reasons (auth, schema drift, provider 5xx). + // Keep those on mcp-event; they are not Kody platform defects. const status = await getMcpServerStatus({ env: ctx.env, userId, ref, }).catch(() => null) if (status && (!status.ready || status.toolCount === 0)) { - throw new Error(formatMcpServerUnavailableMessage(status)) + throw new McpCallerError(formatMcpServerUnavailableMessage(status), { + cause: error, + }) } const message = error instanceof Error ? error.message : 'Unknown MCP server error.' - throw new Error( + throw new McpCallerError( `MCP server capability "${ref.name}:${tool.name}" failed: ${message}`, + { cause: error }, ) } return wrapDownstreamMcpToolResult(result, { diff --git a/packages/worker/src/mcp/capabilities/mcp-server/mcp-server-caller-error.node.test.ts b/packages/worker/src/mcp/capabilities/mcp-server/mcp-server-caller-error.node.test.ts new file mode 100644 index 0000000000..36c5e4d698 --- /dev/null +++ b/packages/worker/src/mcp/capabilities/mcp-server/mcp-server-caller-error.node.test.ts @@ -0,0 +1,126 @@ +import { expect, test, vi } from 'vitest' +import { McpCallerError } from '#mcp/caller-error.ts' +import { createMcpCallerContext } from '#mcp/context.ts' +import { type McpServerSnapshot } from '#worker/mcp-client/types.ts' + +const mocks = vi.hoisted(() => ({ + callTool: vi.fn(), + getMcpServerStatus: vi.fn(), +})) + +vi.mock('#worker/mcp-client/hub-client.ts', () => ({ + createMcpClientHubClient: () => ({ + callTool: (...args: Array) => mocks.callTool(...args), + }), +})) + +vi.mock('#worker/mcp-client/status.ts', async () => { + const actual = await vi.importActual< + typeof import('#worker/mcp-client/status.ts') + >('#worker/mcp-client/status.ts') + return { + ...actual, + getMcpServerStatus: (...args: Array) => + mocks.getMcpServerStatus(...args), + } +}) + +const { synthesizeMcpServerToolDomain } = await import('./index.ts') + +const ref = { serverId: 'server-1', name: 'supermemory' } + +function createSnapshot(): McpServerSnapshot { + return { + serverId: 'server-1', + name: 'supermemory', + url: 'https://mcp.example.com/mcp', + state: 'ready', + authUrl: null, + error: null, + instructions: null, + tools: [ + { + name: 'listMemories', + description: 'List memories.', + inputSchema: { type: 'object', properties: {} }, + }, + ], + } +} + +function createContext() { + return { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-alice', + email: 'alice@example.com', + displayName: 'Alice', + }, + }), + } +} + +test('mcp-server tool failures throw McpCallerError for downstream ProtocolError', async () => { + mocks.callTool.mockRejectedValueOnce( + new Error( + "ProtocolError: Structured content does not match the tool's output schema", + ), + ) + mocks.getMcpServerStatus.mockResolvedValueOnce({ + state: 'ready', + serverId: 'server-1', + name: 'supermemory', + ready: true, + toolCount: 1, + message: 'connected', + error: null, + }) + + const synthesized = synthesizeMcpServerToolDomain({ + ref, + snapshot: createSnapshot(), + }) + const capability = synthesized?.domain.capabilities[0] + expect(capability).toBeDefined() + + const error = await capability!.handler({}, createContext()).then( + () => null, + (thrown: unknown) => thrown, + ) + + expect(error).toBeInstanceOf(McpCallerError) + expect((error as Error).message).toContain( + 'MCP server capability "supermemory:listMemories" failed:', + ) + expect((error as Error).message).toContain('Structured content') +}) + +test('mcp-server unavailable servers throw McpCallerError', async () => { + mocks.callTool.mockRejectedValueOnce(new Error('fetch failed')) + mocks.getMcpServerStatus.mockResolvedValueOnce({ + state: 'disconnected', + serverId: 'server-1', + name: 'supermemory', + ready: false, + toolCount: 0, + message: 'The MCP server "supermemory" is not connected.', + error: null, + }) + + const synthesized = synthesizeMcpServerToolDomain({ + ref, + snapshot: createSnapshot(), + }) + const capability = synthesized?.domain.capabilities[0] + expect(capability).toBeDefined() + + const error = await capability!.handler({}, createContext()).then( + () => null, + (thrown: unknown) => thrown, + ) + + expect(error).toBeInstanceOf(McpCallerError) + expect((error as Error).message).toMatch(/not connected/) +}) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-search.node.test.ts b/packages/worker/src/mcp/capabilities/repo/repo-search.node.test.ts new file mode 100644 index 0000000000..9e74722e3c --- /dev/null +++ b/packages/worker/src/mcp/capabilities/repo/repo-search.node.test.ts @@ -0,0 +1,102 @@ +import { expect, test, vi } from 'vitest' +import { McpCallerError } from '#mcp/caller-error.ts' +import { createMcpCallerContext } from '#mcp/context.ts' +import { buildRepoSearchInvalidRegexCallerMessage } from '#worker/repo/repo-session-caller-error.ts' + +const mocks = vi.hoisted(() => ({ + repoSessionRpc: vi.fn(), +})) + +vi.mock('#worker/repo/repo-session-do.ts', () => ({ + repoSessionRpc: (...args: Array) => mocks.repoSessionRpc(...args), +})) + +const { repoSearchCapability } = await import('./repo-search.ts') + +function createContext() { + return { + env: {} as Env, + callerContext: createMcpCallerContext({ + baseUrl: 'https://example.com', + user: { + userId: 'user-alice', + email: 'alice@example.com', + displayName: 'Alice', + }, + }), + } +} + +test('repo_search maps invalid regex DO errors to McpCallerError', async () => { + const compileMessage = + 'Invalid regular expression: /(?s).|^$/gi: Invalid group' + const search = vi + .fn() + .mockRejectedValue( + new Error(buildRepoSearchInvalidRegexCallerMessage(compileMessage)), + ) + mocks.repoSessionRpc.mockReturnValue({ search }) + + const error = await repoSearchCapability + .handler( + { + session_id: 'session-1', + pattern: '(?s).|^$', + mode: 'regex', + }, + createContext(), + ) + .then( + () => null, + (thrown: unknown) => thrown, + ) + + expect(error).toBeInstanceOf(McpCallerError) + expect((error as Error).message).toContain('invalid regex') + expect((error as Error).message).toContain('JavaScript RegExp') +}) + +test('repo_search maps nested invalid regex causes to McpCallerError', async () => { + const nestedMessage = buildRepoSearchInvalidRegexCallerMessage( + 'Invalid regular expression: /(?i)foo/: Invalid group', + ) + const search = vi.fn().mockRejectedValue( + new Error('Repo session search failed', { + cause: new Error(nestedMessage), + }), + ) + mocks.repoSessionRpc.mockReturnValue({ search }) + + const error = await repoSearchCapability + .handler( + { + session_id: 'session-1', + pattern: '(?i)foo', + mode: 'regex', + }, + createContext(), + ) + .then( + () => null, + (thrown: unknown) => thrown, + ) + + expect(error).toBeInstanceOf(McpCallerError) + expect((error as Error).message).toBe(nestedMessage) +}) + +test('repo_search rethrows non-regex failures unchanged', async () => { + const boom = new Error('Durable Object storage unavailable') + const search = vi.fn().mockRejectedValue(boom) + mocks.repoSessionRpc.mockReturnValue({ search }) + + await expect( + repoSearchCapability.handler( + { + session_id: 'session-1', + pattern: 'hello', + }, + createContext(), + ), + ).rejects.toBe(boom) +}) diff --git a/packages/worker/src/mcp/capabilities/repo/repo-search.ts b/packages/worker/src/mcp/capabilities/repo/repo-search.ts index 0594a42b9d..1ac47c638c 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-search.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-search.ts @@ -1,6 +1,9 @@ +import { getErrorCauseChain } from '@kody-internal/shared/error-message.ts' +import { McpCallerError } from '#mcp/caller-error.ts' import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { requireMcpUser } from '#mcp/capabilities/meta/require-user.ts' +import { isRepoSearchInvalidRegexMessage } from '#worker/repo/repo-session-caller-error.ts' import { repoSessionRpc } from '#worker/repo/repo-session-do.ts' import { repoSearchInputSchema, repoSearchOutputSchema } from './repo-shared.ts' @@ -9,7 +12,7 @@ export const repoSearchCapability = defineDomainCapability( { name: 'repo_search', description: - 'Search within a repo session using rg-style lexical matching over the live session workspace. This is scoped code/file search, not semantic retrieval.', + 'Search within a repo session using rg-style lexical matching over the live session workspace. This is scoped code/file search, not semantic retrieval. When mode=regex, patterns must be valid JavaScript RegExp syntax (not Python/PCRE inline flags).', keywords: ['repo', 'search', 'ripgrep', 'regex', 'literal', 'files'], readOnly: true, idempotent: true, @@ -19,19 +22,30 @@ export const repoSearchCapability = defineDomainCapability( async handler(args, ctx) { const user = requireMcpUser(ctx.callerContext) const session = repoSessionRpc(ctx.env, args.session_id) - const result = await session.search({ - sessionId: args.session_id, - userId: user.userId, - pattern: args.pattern, - mode: args.mode, - glob: args.glob, - path: args.path, - caseSensitive: args.case_sensitive, - before: args.before, - after: args.after, - limit: args.limit, - outputMode: args.output_mode, - }) + let result + try { + result = await session.search({ + sessionId: args.session_id, + userId: user.userId, + pattern: args.pattern, + mode: args.mode, + glob: args.glob, + path: args.path, + caseSensitive: args.case_sensitive, + before: args.before, + after: args.after, + limit: args.limit, + outputMode: args.output_mode, + }) + } catch (error) { + // Invalid regex is a caller-correctable input mistake. DO RPC may + // lose Error subclass identity, so match the stable message phrase. + const invalidRegexMessage = getRepoSearchInvalidRegexMessage(error) + if (invalidRegexMessage) { + throw new McpCallerError(invalidRegexMessage, { cause: error }) + } + throw error + } return { files: result.files, total_files: result.totalFiles, @@ -42,3 +56,11 @@ export const repoSearchCapability = defineDomainCapability( }, }, ) + +function getRepoSearchInvalidRegexMessage(error: unknown) { + const match = getErrorCauseChain(error).find( + (entry) => + entry instanceof Error && isRepoSearchInvalidRegexMessage(entry.message), + ) + return match instanceof Error ? match.message : null +} diff --git a/packages/worker/src/mcp/capabilities/repo/repo-shared.ts b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts index a20cf3ef13..2f107ae8ea 100644 --- a/packages/worker/src/mcp/capabilities/repo/repo-shared.ts +++ b/packages/worker/src/mcp/capabilities/repo/repo-shared.ts @@ -438,10 +438,17 @@ export const repoTreeNodeSchema: z.ZodType = z.lazy(() => ) export const repoSearchInputSchema = repoSessionIdSchema.extend({ - pattern: z.string().min(1).describe('Literal text or regex to search for.'), + pattern: z + .string() + .min(1) + .describe( + 'Literal text or regex to search for. In regex mode this must be valid JavaScript RegExp syntax (no Python/PCRE inline flags such as (?s) or (?i)).', + ), mode: repoSearchModeSchema .optional() - .describe('Search mode. Defaults to literal.'), + .describe( + 'Search mode. Defaults to literal. regex uses JavaScript RegExp syntax.', + ), glob: z .string() .min(1) diff --git a/packages/worker/src/mcp/observability.node.test.ts b/packages/worker/src/mcp/observability.node.test.ts index b83372f9a7..3fe8ed3f3a 100644 --- a/packages/worker/src/mcp/observability.node.test.ts +++ b/packages/worker/src/mcp/observability.node.test.ts @@ -245,9 +245,54 @@ test('logMcpEvent keeps sandbox and caller failures off Sentry and still reports }, cause: new Error('no such table: notes: SQLITE_ERROR'), }) + + // Published repo session (KODY-CLOUDFLARE-4A). Plain Error from DO RPC. + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'repo_status', + domain: 'repo', + capabilitySource: 'builtin', + failurePhase: 'handler', + errorName: 'Error', + errorMessage: + 'Repo session "de72ddd6-e277-4f69-a5db-3d6ece06ca6b" is published; open a new session before continuing.', + cause: new Error( + 'Repo session "de72ddd6-e277-4f69-a5db-3d6ece06ca6b" is published; open a new session before continuing.', + ), + }) + + // Invalid repo_search regex (KODY-CLOUDFLARE-49). Plain Error from DO RPC. + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'repo_search', + domain: 'repo', + capabilitySource: 'builtin', + failurePhase: 'handler', + errorName: 'Error', + errorMessage: + 'repo_search received an invalid regex: Invalid regular expression: /(?s).|^$/gi: Invalid group. mode=regex uses JavaScript RegExp syntax (no inline flags like (?s) or (?i); for dotall matching use [\\s\\S] instead of `.` with (?s)).', + cause: new Error( + 'repo_search received an invalid regex: Invalid regular expression: /(?s).|^$/gi: Invalid group. mode=regex uses JavaScript RegExp syntax (no inline flags like (?s) or (?i); for dotall matching use [\\s\\S] instead of `.` with (?s)).', + ), + }) + + // Downstream user-connected MCP server tool failure (KODY-CLOUDFLARE-4B). + logMcpEvent({ + ...callerFailureBase, + capabilityName: 'mcp:supermemory:listMemories', + domain: 'mcp:supermemory', + capabilitySource: 'mcp-server', + failurePhase: 'handler', + errorName: 'McpCallerError', + errorMessage: + 'MCP server capability "supermemory:listMemories" failed: ProtocolError: Structured content does not match the tool\'s output schema', + cause: new McpCallerError( + 'MCP server capability "supermemory:listMemories" failed: ProtocolError: Structured content does not match the tool\'s output schema', + ), + }) }) - expect(payloads).toHaveLength(13) + expect(payloads).toHaveLength(16) expect(JSON.parse(payloads[0]!)).toMatchObject({ tool: 'execute', outcome: 'failure', diff --git a/packages/worker/src/mcp/observability.ts b/packages/worker/src/mcp/observability.ts index 8611d39586..b2394b5e1f 100644 --- a/packages/worker/src/mcp/observability.ts +++ b/packages/worker/src/mcp/observability.ts @@ -5,6 +5,10 @@ import { CommunityActionError } from '#worker/community/errors.ts' import { isEntitlementLimitError } from '#worker/entitlements/errors.ts' import { isRemoteConnectorUnavailableMessage } from '#worker/remote-connector/status.ts' import { isRepoLargeFileMessage } from '#worker/repo/large-file-policy.ts' +import { + isRepoSearchInvalidRegexMessage, + isRepoSessionInactiveMessage, +} from '#worker/repo/repo-session-caller-error.ts' import { isUserStorageSqlCallerMessage } from '#worker/storage-sql-caller-error.ts' import { isUserCodeError } from '#worker/user-code-error.ts' import { isMcpCallerError } from './caller-error.ts' @@ -140,6 +144,20 @@ function isCallerFailure(payload: McpObservabilityPayload, cause?: unknown) { ) { return true } + // Published / inactive repo sessions and invalid repo_search regexes are + // thrown inside the RepoSession Durable Object as plain Errors. Match the + // stable phrases so they stay out of Sentry even when a capability forgets + // to re-wrap as McpCallerError. + if ( + getErrorCauseChain(cause).some( + (entry) => + entry instanceof Error && + (isRepoSessionInactiveMessage(entry.message) || + isRepoSearchInvalidRegexMessage(entry.message)), + ) + ) { + return true + } return isMcpCallerError(cause) } diff --git a/packages/worker/src/repo/repo-session-caller-error.node.test.ts b/packages/worker/src/repo/repo-session-caller-error.node.test.ts new file mode 100644 index 0000000000..ef066647c5 --- /dev/null +++ b/packages/worker/src/repo/repo-session-caller-error.node.test.ts @@ -0,0 +1,44 @@ +import { expect, test } from 'vitest' +import { + buildRepoSearchInvalidRegexCallerMessage, + isRepoSearchInvalidRegexMessage, + isRepoSessionInactiveMessage, + repoSearchInvalidRegexMessagePrefix, + repoSessionInactiveMessagePhrase, +} from './repo-session-caller-error.ts' + +test('isRepoSessionInactiveMessage matches published and other inactive statuses', () => { + expect( + isRepoSessionInactiveMessage( + `Repo session "de72ddd6-e277-4f69-a5db-3d6ece06ca6b" is published${repoSessionInactiveMessagePhrase}`, + ), + ).toBe(true) + expect( + isRepoSessionInactiveMessage( + `Repo session "abc" is discarded${repoSessionInactiveMessagePhrase}`, + ), + ).toBe(true) + expect( + isRepoSessionInactiveMessage('Repo session "abc" was not found.'), + ).toBe(false) + expect( + isRepoSessionInactiveMessage( + `Something else${repoSessionInactiveMessagePhrase}`, + ), + ).toBe(false) +}) + +test('invalid regex helper keeps stable prefix and JS RegExp guidance', () => { + const message = buildRepoSearchInvalidRegexCallerMessage( + 'Invalid regular expression: /(?s).|^$/gi: Invalid group', + ) + expect(message.startsWith(repoSearchInvalidRegexMessagePrefix)).toBe(true) + expect(isRepoSearchInvalidRegexMessage(message)).toBe(true) + expect(message).toContain('JavaScript RegExp') + expect(message).toContain('(?s)') + expect( + isRepoSearchInvalidRegexMessage( + 'repo_search requires a non-empty pattern.', + ), + ).toBe(false) +}) diff --git a/packages/worker/src/repo/repo-session-caller-error.ts b/packages/worker/src/repo/repo-session-caller-error.ts new file mode 100644 index 0000000000..b1f7f9a040 --- /dev/null +++ b/packages/worker/src/repo/repo-session-caller-error.ts @@ -0,0 +1,37 @@ +/** + * Stable phrase from RepoSession DO `requireSession` when a session is no + * longer `active` (typically `published`). Subclass identity does not survive + * Durable Object RPC, so MCP observability matches this phrase to keep those + * expected workflow misses on `mcp-event` and out of Sentry. + */ +export const repoSessionInactiveMessagePhrase = + '; open a new session before continuing.' + +export function isRepoSessionInactiveMessage(message: string) { + return ( + message.startsWith('Repo session "') && + message.includes(repoSessionInactiveMessagePhrase) + ) +} + +/** + * Stable prefix for invalid `mode=regex` patterns from repo session search. + * Agents often pass Python/PCRE inline flags (`(?s)`, `(?i)`) that JavaScript + * RegExp rejects; treat those as caller-correctable input, not platform bugs. + */ +export const repoSearchInvalidRegexMessagePrefix = + 'repo_search received an invalid regex' + +export function isRepoSearchInvalidRegexMessage(message: string) { + return message.startsWith(repoSearchInvalidRegexMessagePrefix) +} + +export function buildRepoSearchInvalidRegexCallerMessage( + compileMessage: string, +) { + return ( + `${repoSearchInvalidRegexMessagePrefix}: ${compileMessage}. ` + + 'mode=regex uses JavaScript RegExp syntax (no inline flags like (?s) or (?i); ' + + 'for dotall matching use [\\s\\S] instead of `.` with (?s)).' + ) +} diff --git a/packages/worker/src/repo/repo-session-search.node.test.ts b/packages/worker/src/repo/repo-session-search.node.test.ts index b4f82c21cd..af6e1b79b5 100644 --- a/packages/worker/src/repo/repo-session-search.node.test.ts +++ b/packages/worker/src/repo/repo-session-search.node.test.ts @@ -1,4 +1,5 @@ import { expect, test, vi } from 'vitest' +import { repoSearchInvalidRegexMessagePrefix } from './repo-session-caller-error.ts' import { expandBraceGlobs, searchRepoWorkspace } from './repo-session-search.ts' test('brace globs expand for Workspace.glob and reject pathological nesting', async () => { @@ -87,3 +88,52 @@ test('brace globs expand for Workspace.glob and reject pathological nesting', as ).rejects.toThrow(/expands to more than 64 patterns/) expect(workspace.glob).not.toHaveBeenCalled() }) + +test('regex mode rejects Python-style inline flags with JS guidance', async () => { + const workspace = { + glob: vi.fn(async () => [{ path: '/src/a.ts', type: 'file' }]), + readFile: vi.fn(async () => 'hello world'), + } + + await expect( + searchRepoWorkspace({ + root: '/', + pattern: '(?s).|^$', + mode: 'regex', + workspace, + toExternalPath: (path) => path, + }), + ).rejects.toThrow( + new RegExp( + `${repoSearchInvalidRegexMessagePrefix}.*JavaScript RegExp`, + 's', + ), + ) + // Invalid patterns fail before scanning files. + expect(workspace.glob).not.toHaveBeenCalled() + expect(workspace.readFile).not.toHaveBeenCalled() +}) + +test('invalid regex fails even when the workspace has no readable files', async () => { + const workspace = { + glob: vi.fn(async () => []), + readFile: vi.fn(async () => null), + } + + await expect( + searchRepoWorkspace({ + root: '/', + pattern: '(?s).|^$', + mode: 'regex', + workspace, + toExternalPath: (path) => path, + }), + ).rejects.toThrow( + new RegExp( + `${repoSearchInvalidRegexMessagePrefix}.*JavaScript RegExp`, + 's', + ), + ) + expect(workspace.glob).not.toHaveBeenCalled() + expect(workspace.readFile).not.toHaveBeenCalled() +}) diff --git a/packages/worker/src/repo/repo-session-search.ts b/packages/worker/src/repo/repo-session-search.ts index fe6d62634a..b0131fea41 100644 --- a/packages/worker/src/repo/repo-session-search.ts +++ b/packages/worker/src/repo/repo-session-search.ts @@ -1,3 +1,4 @@ +import { buildRepoSearchInvalidRegexCallerMessage } from './repo-session-caller-error.ts' import { type RepoSearchFileMatch, type RepoSearchMatch, @@ -143,35 +144,45 @@ function normalizeSearchQuery(input: { } } -function searchInText(input: { - content: string +function compileSearchMatcher(input: { query: string regex: boolean caseSensitive: boolean - contextBefore: number - contextAfter: number - maxMatches: number }) { - const inputTruncated = input.content.length > maxRepoSearchBytes - // Bound regex work so a single pathological search cannot monopolize the DO. - const source = inputTruncated - ? input.content.slice(0, maxRepoSearchBytes) - : input.content const flags = input.caseSensitive ? 'g' : 'gi' const pattern = input.regex ? input.query : escapeRegex(input.query) if (input.regex) { assertSafeRepoSearchRegex(pattern) } - let matcher: RegExp try { - matcher = new RegExp(pattern, flags) + return new RegExp(pattern, flags) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown regex compilation error.' - throw new Error(`repo_search received an invalid regex: ${message}`) + // Caller-supplied pattern failed JS RegExp compilation (often Python/PCRE + // inline flags). Keep the stable prefix so MCP can classify this as a + // caller error after DO RPC loses Error subclass identity. + throw new Error(buildRepoSearchInvalidRegexCallerMessage(message)) } +} + +function searchInText(input: { + content: string + matcher: RegExp + contextBefore: number + contextAfter: number + maxMatches: number +}) { + const inputTruncated = input.content.length > maxRepoSearchBytes + // Bound regex work so a single pathological search cannot monopolize the DO. + const source = inputTruncated + ? input.content.slice(0, maxRepoSearchBytes) + : input.content + const matcher = input.matcher + // Reused across files; early truncation can leave lastIndex mid-string. + matcher.lastIndex = 0 const lines = source.split('\n') const lineOffsets: number[] = [] let offset = 0 @@ -241,6 +252,13 @@ export async function searchRepoWorkspace(input: { pattern: input.pattern, mode: input.mode, }) + // Validate/compile before scanning so invalid regex fails even when the + // workspace has no readable files. + const matcher = compileSearchMatcher({ + query: search.query, + regex: search.regex, + caseSensitive: input.caseSensitive ?? false, + }) const globPattern = input.glob?.trim() || `${input.root.replace(/\/+$/, '')}/**/*.{ts,tsx,js,jsx,json,md,css}` @@ -260,9 +278,7 @@ export async function searchRepoWorkspace(input: { if (content == null) continue const result = searchInText({ content, - query: search.query, - regex: search.regex, - caseSensitive: input.caseSensitive ?? false, + matcher, contextBefore: input.before ?? 0, contextAfter: input.after ?? 0, maxMatches: remaining,