diff --git a/packages/worker/src/mcp/index.ts b/packages/worker/src/mcp/index.ts index f0b01b1e8f..405da77fcb 100644 --- a/packages/worker/src/mcp/index.ts +++ b/packages/worker/src/mcp/index.ts @@ -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. @@ -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. @@ -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. @@ -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. diff --git a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts index ffee669918..aa196dc965 100644 --- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts +++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts @@ -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 + } + } + | 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) @@ -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) @@ -362,11 +423,17 @@ test('mcp server opens generated ui with inline code and serves runtime resource name: 'open_generated_ui', arguments: { code: '

Hello Shell

Inline app content.

', + 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 @@ -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() diff --git a/packages/worker/src/mcp/tools/execute.ts b/packages/worker/src/mcp/tools/execute.ts index 45cbbaf4b2..26bf48dd49 100644 --- a/packages/worker/src/mcp/tools/execute.ts +++ b/packages/worker/src/mcp/tools/execute.ts @@ -12,6 +12,11 @@ import { errorFields, logMcpEvent, } from '#mcp/observability.ts' +import { + conversationIdInputField, + memoryContextInputField, + resolveConversationId, +} from './tool-call-context.ts' const executeTool = { name: 'execute', @@ -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; @@ -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 + }) => { 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') @@ -179,6 +192,7 @@ export async function registerExecuteTool(agent: McpRegistrationAgent) { }, ], structuredContent: { + conversationId: resolvedConversationId, error: errorMessage, errorDetails, logs: result.logs ?? [], @@ -206,6 +220,7 @@ export async function registerExecuteTool(agent: McpRegistrationAgent) { }, ], structuredContent: { + conversationId: resolvedConversationId, result: result.result, logs: result.logs ?? [], }, diff --git a/packages/worker/src/mcp/tools/open-generated-ui.ts b/packages/worker/src/mcp/tools/open-generated-ui.ts index f971cce39e..608d9467cd 100644 --- a/packages/worker/src/mcp/tools/open-generated-ui.ts +++ b/packages/worker/src/mcp/tools/open-generated-ui.ts @@ -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, @@ -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() @@ -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 @@ -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), diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index ae1aa035e0..bbcb216711 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -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 @@ -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, }, @@ -184,8 +191,11 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { skill_collection?: string limit?: number detail?: boolean + conversationId?: string + memoryContext?: z.infer }) => { const startedAt = performance.now() + const conversationId = resolveConversationId(args.conversationId) const callerContext = agent.getCallerContext() const { baseUrl, hasUser } = callerContextFields(callerContext) const userId = callerContext.user?.userId ?? null @@ -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, @@ -309,6 +320,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { }, ], structuredContent: { + conversationId, result: payload, }, } diff --git a/packages/worker/src/mcp/tools/tool-call-context.ts b/packages/worker/src/mcp/tools/tool-call-context.ts new file mode 100644 index 0000000000..907dfc4596 --- /dev/null +++ b/packages/worker/src/mcp/tools/tool-call-context.ts @@ -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('') +}