Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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
7 changes: 7 additions & 0 deletions packages/worker/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ Quick start
- Call 'search' first to discover what Kody can do (results include type 'capability', 'skill', 'app', or 'secret').
- Call 'execute' or 'meta_run_skill' next to run capability code.
- Call 'open_generated_ui' when you want an interactive UI rendered in an MCP App host.
- The public MCP tools accept optional \`conversationId\` and \`memoryContext\` fields. Clients should generate and reuse a short \`conversationId\` across related calls when possible; if omitted, Kody generates one and returns it in \`structuredContent.conversationId\`.
- Memory context:
- Keep \`memoryContext\` short, structured, and task-focused.
- It is reserved for future memory-aware behavior and is not persisted or used for retrieval yet in this phase.
- Never ask the user to paste secrets, tokens, API keys, passwords, OAuth codes, or client secrets into chat. Use saved secrets when available, or use 'open_generated_ui' to collect and save sensitive values instead.
- Use 'meta_save_skill' only for workflows that are reasonably repeatable—patterns you expect to run again with similar structure or inputs. Do not save one-off tasks, unique ad-hoc work, or highly bespoke requests as skills; run those with 'execute' instead. Use the optional 'collection' field to group related saved skills, and use 'meta_update_skill' to replace an existing skill's code in place.
- When a saved skill declares parameters, pass values via meta_run_skill params; the codemode can read them from the params variable.
Expand All @@ -47,6 +51,7 @@ ${domainInstructions}

How to use search
- Call the 'search' tool with a natural-language 'query' describing what you need (optional 'limit', 'detail').
- Optionally pass \`conversationId\` and reuse it on later related tool calls. If omitted, Kody generates one and returns it in \`structuredContent.conversationId\`.
- Narrow results by rephrasing 'query', or use the optional 'skill_collection' filter when you only want saved skills from one collection slug.
- Saved skills appear when the MCP client provides an authenticated user; use 'meta_get_skill' for full skill code.
- Use domain descriptions above as vocabulary hints in your query text.
Expand Down Expand Up @@ -76,6 +81,7 @@ Destructive Cloudflare access

How to use execute
- The sandbox provides a 'codemode' object with async methods for each capability.
- Optionally pass \`conversationId\` and reuse it on later related tool calls. If omitted, Kody generates one and returns it in \`structuredContent.conversationId\`.
- Use capability names discovered from search.
- Pass one args object that matches the capability inputSchema.
- Each capability call returns that capability's raw structured result value.
Expand All @@ -96,6 +102,7 @@ How to use execute

MCP App tools
- Use 'open_generated_ui' when you want an interactive UI in MCP App compatible hosts.
- Optionally pass \`conversationId\` and reuse it on later related tool calls. If omitted, Kody generates one and returns it in \`structuredContent.conversationId\`.
- Pass either inline source code with 'code' or reopen a saved app with 'app_id' (exactly one is allowed).
- Prefer body-focused HTML fragments when possible, but full HTML documents are also supported.
- Use generated UI whenever the user needs to enter a sensitive value. Do not ask the user to paste secrets or credentials into chat.
Expand Down
68 changes: 68 additions & 0 deletions packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,37 @@ test('mcp server returns built-in instructions and base server metadata', async
).toContain('"matches"')
})

test('mcp server search echoes provided conversationId and accepts memoryContext', async () => {
await using database = await createTestDatabase()
await using server = await startDevServer(database.persistDir)
await using mcpClient = await createMcpClient(server.origin, database.user)

const result = await mcpClient.client.callTool({
name: 'search',
arguments: {
query: 'generated ui',
conversationId: 'searchctx1234',
memoryContext: {
task: 'Find UI-related tools',
entities: ['generated ui'],
constraints: ['brief'],
},
},
})

const structuredResult = (result as CallToolResult).structuredContent as
| {
conversationId?: string
result?: {
matches?: Array<unknown>
}
}
| undefined

expect(structuredResult?.conversationId).toBe('searchctx1234')
expect(Array.isArray(structuredResult?.result?.matches)).toBe(true)
})

test('mcp server saves and browses skill collections', async () => {
await using database = await createTestDatabase()
await using server = await startDevServer(database.persistDir)
Expand Down Expand Up @@ -199,6 +230,36 @@ test('mcp server executes user code against codemode', async () => {
expect(textOutput).toContain('hosted_url')
})

test('mcp server execute generates conversationId when omitted and accepts memoryContext', async () => {
await using database = await createTestDatabase()
await using server = await startDevServer(database.persistDir)
await using mcpClient = await createMcpClient(server.origin, database.user)

const result = await mcpClient.client.callTool({
name: 'execute',
arguments: {
code: `async () => ({ ok: true })`,
memoryContext: {
task: 'Return a small payload',
constraints: ['no side effects'],
},
},
})

const structuredResult = (result as CallToolResult).structuredContent as
| {
conversationId?: string
result?: {
ok?: boolean
}
}
| undefined

expect(typeof structuredResult?.conversationId).toBe('string')
expect((structuredResult?.conversationId ?? '').length).toBeGreaterThan(0)
expect(structuredResult?.result?.ok).toBe(true)
})

test('mcp server executes directly available codemode helpers', async () => {
await using database = await createTestDatabase()
await using server = await startDevServer(database.persistDir)
Expand Down Expand Up @@ -362,11 +423,17 @@ test('mcp server opens generated ui with inline code and serves runtime resource
name: 'open_generated_ui',
arguments: {
code: '<main><h1>Hello Shell</h1><p>Inline app content.</p></main>',
conversationId: 'uictx1234567',
memoryContext: {
task: 'Render inline UI',
entities: ['generated ui'],
},
},
})

const structuredResult = (result as CallToolResult).structuredContent as
| {
conversationId?: string
appId?: string | null
hostedUrl?: string | null
renderSource?: string
Expand All @@ -381,6 +448,7 @@ test('mcp server opens generated ui with inline code and serves runtime resource
)?.text ?? ''

expect(textOutput).toContain('Generated UI ready')
expect(structuredResult?.conversationId).toBe('uictx1234567')
expect(structuredResult?.renderSource).toBe('inline_code')
expect(appId).toBeNull()
expect(hostedUrl).toBeNull()
Expand Down
6 changes: 6 additions & 0 deletions packages/worker/src/mcp/tools/execute.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,4 +22,10 @@ test('execute tool description encourages fewer execute calls', async () => {
expect(registerTool.mock.calls[0]?.[1]?.description).toContain(
'chain the capability calls there and return the final useful result',
)
expect(registerTool.mock.calls[0]?.[1]?.description).not.toContain(
'Pass `conversationId`',
)
expect(registerTool.mock.calls[0]?.[1]?.description).not.toContain(
'Pass `memoryContext`',
)
})
21 changes: 18 additions & 3 deletions packages/worker/src/mcp/tools/execute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ import {
errorFields,
logMcpEvent,
} from '#mcp/observability.ts'
import {
conversationIdInputField,
memoryContextInputField,
resolveConversationId,
} from './tool-call-context.ts'

const executeTool = {
name: 'execute',
Expand All @@ -24,8 +29,6 @@ To run a saved skill by id, prefer \`meta_run_skill\` with \`skill_id\` and
optional \`params\`. If you need the saved code, call \`meta_get_skill\` and
pass the returned code into this tool.

This tool accepts a single argument: \`{ "code": "async () => { ... }" }\`.

Available in your code:

type CapabilityArgs = Record<string, unknown>;
Expand Down Expand Up @@ -118,13 +121,23 @@ export async function registerExecuteTool(agent: McpRegistrationAgent) {
code: z
.string()
.describe('JavaScript async arrow function to execute capabilities.'),
conversationId: conversationIdInputField,
memoryContext: memoryContextInputField,
},
annotations: executeTool.annotations,
},
async ({ code }: { code: string }) => {
async ({
code,
conversationId,
}: {
code: string
conversationId?: string
memoryContext?: z.infer<typeof memoryContextInputField>
}) => {
const startedAt = performance.now()
const env = agent.getEnv()
const callerContext = agent.getCallerContext()
const resolvedConversationId = resolveConversationId(conversationId)
const { baseUrl, hasUser } = callerContextFields(callerContext)
const { getCapabilityRegistryForContext } =
await import('#mcp/capabilities/registry.ts')
Expand Down Expand Up @@ -179,6 +192,7 @@ export async function registerExecuteTool(agent: McpRegistrationAgent) {
},
],
structuredContent: {
conversationId: resolvedConversationId,
error: errorMessage,
errorDetails,
logs: result.logs ?? [],
Expand Down Expand Up @@ -206,6 +220,7 @@ export async function registerExecuteTool(agent: McpRegistrationAgent) {
},
],
structuredContent: {
conversationId: resolvedConversationId,
result: result.result,
logs: result.logs ?? [],
},
Expand Down
9 changes: 9 additions & 0 deletions packages/worker/src/mcp/tools/open-generated-ui.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import { z } from 'zod'
import { generatedUiRuntimeResourceUri } from '#mcp/apps/generated-ui-runtime-html-entry.ts'
import { createGeneratedUiAppSession } from '#mcp/generated-ui-app-session.ts'
import { type McpRegistrationAgent } from '#mcp/mcp-registration-agent.ts'
import {
conversationIdInputField,
memoryContextInputField,
resolveConversationId,
} from '#mcp/tools/tool-call-context.ts'
import {
applyUiArtifactParameters,
parseUiArtifactParameters,
Expand Down Expand Up @@ -69,6 +74,8 @@ const inputSchema = z
.min(1)
.optional()
.describe('Optional short description for the current render session.'),
conversationId: conversationIdInputField,
memoryContext: memoryContextInputField,
params: z
.record(z.string(), z.unknown())
.optional()
Expand Down Expand Up @@ -102,6 +109,7 @@ export async function registerOpenGeneratedUiTool(agent: McpRegistrationAgent) {
},
async (args) => {
const callerContext = agent.getCallerContext()
const conversationId = resolveConversationId(args.conversationId)
const appId = args.app_id ?? null
const title = args.title ?? null
const description = args.description ?? null
Expand Down Expand Up @@ -142,6 +150,7 @@ export async function registerOpenGeneratedUiTool(agent: McpRegistrationAgent) {
})
: null
const structuredContent = {
conversationId,
widget: 'generated_ui' as const,
resourceUri: generatedUiRuntimeResourceUri,
renderSource: appId ? ('saved_app' as const) : ('inline_code' as const),
Expand Down
12 changes: 12 additions & 0 deletions packages/worker/src/mcp/tools/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ import {
errorFields,
logMcpEvent,
} from '#mcp/observability.ts'
import {
conversationIdInputField,
memoryContextInputField,
resolveConversationId,
} from './tool-call-context.ts'

const charsPerToken = 4
const maxTokens = 6_000
Expand Down Expand Up @@ -176,6 +181,8 @@ export async function registerSearchTool(agent: McpRegistrationAgent) {
.boolean()
.optional()
.describe('Include full metadata / schemas when true.'),
conversationId: conversationIdInputField,
memoryContext: memoryContextInputField,
},
annotations: searchTool.annotations,
},
Expand All @@ -184,8 +191,11 @@ export async function registerSearchTool(agent: McpRegistrationAgent) {
skill_collection?: string
limit?: number
detail?: boolean
conversationId?: string
memoryContext?: z.infer<typeof memoryContextInputField>
}) => {
const startedAt = performance.now()
const conversationId = resolveConversationId(args.conversationId)
const callerContext = agent.getCallerContext()
const { baseUrl, hasUser } = callerContextFields(callerContext)
const userId = callerContext.user?.userId ?? null
Expand Down Expand Up @@ -272,6 +282,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) {
return {
content: [{ type: 'text', text: `Error: ${error.message}` }],
structuredContent: {
conversationId,
error: error.message,
},
isError: true,
Expand Down Expand Up @@ -309,6 +320,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) {
},
],
structuredContent: {
conversationId,
result: payload,
},
}
Expand Down
69 changes: 69 additions & 0 deletions packages/worker/src/mcp/tools/tool-call-context.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { z } from 'zod'

const generatedConversationIdLength = 12
const conversationIdAlphabet = '0123456789abcdefghjkmnpqrstvwxyz'

const conversationIdDescription =
'Optional short conversation identifier. Clients should generate and reuse the same value across related tool calls when possible. If omitted, Kody generates one and returns it in `structuredContent.conversationId`.'

const memoryContextDescription =
'Optional short, structured task context for future memory-aware behavior. Keep it brief and factual rather than hidden reasoning.'

const memoryContextFieldDescription =
'Optional task or goal summary for future memory retrieval.'

const memoryContextListDescription =
'Optional short phrases that identify important entities, constraints, or references for future memory retrieval.'

export const conversationIdInputField = z
.string()
.min(1)
.max(64)
.optional()
.describe(conversationIdDescription)

export const memoryContextInputField = z
.object({
task: z
.string()
.min(1)
.max(300)
.optional()
.describe(memoryContextFieldDescription),
query: z
.string()
.min(1)
.max(300)
.optional()
.describe(memoryContextFieldDescription),
entities: z
.array(z.string().min(1).max(120))
.max(8)
.optional()
.describe(memoryContextListDescription),
constraints: z
.array(z.string().min(1).max(120))
.max(8)
.optional()
.describe(memoryContextListDescription),
})
.optional()
.describe(memoryContextDescription)

export function resolveConversationId(
conversationId: string | null | undefined,
) {
const normalizedConversationId = conversationId?.trim() ?? ''
if (normalizedConversationId) return normalizedConversationId
return generateConversationId()
}

function generateConversationId() {
const bytes = crypto.getRandomValues(
new Uint8Array(generatedConversationIdLength),
)
return Array.from(
bytes,
(byte) => conversationIdAlphabet[byte % conversationIdAlphabet.length],
).join('')
}
Loading