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
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
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