Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 11 additions & 6 deletions packages/worker/src/mcp/capabilities/mcp-server/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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.`,
)
}
Expand All @@ -104,20 +105,24 @@ function createCapabilityFromTool(input: {
args: (args ?? {}) as Record<string, unknown>,
})
} 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, {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<unknown>) => 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<unknown>) =>
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/)
})
102 changes: 102 additions & 0 deletions packages/worker/src/mcp/capabilities/repo/repo-search.node.test.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>) => 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)
})
50 changes: 36 additions & 14 deletions packages/worker/src/mcp/capabilities/repo/repo-search.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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
}
11 changes: 9 additions & 2 deletions packages/worker/src/mcp/capabilities/repo/repo-shared.ts
Original file line number Diff line number Diff line change
Expand Up @@ -438,10 +438,17 @@ export const repoTreeNodeSchema: z.ZodType<unknown> = 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)
Expand Down
Loading
Loading