diff --git a/docs/agents/mcp-apps-spec-notes.md b/docs/agents/mcp-apps-spec-notes.md index b481f69af9..56a48f04d4 100644 --- a/docs/agents/mcp-apps-spec-notes.md +++ b/docs/agents/mcp-apps-spec-notes.md @@ -15,9 +15,8 @@ over project-local conventions. - The repo exposes a single generic shell via `open_generated_ui`. - Saved apps are reopened by `app_id`; inline renders are ephemeral. -- Saved apps are hidden from `search` by default; set - `hidden: false` in `ui_save_app` only for reusable apps that - should be discoverable. +- Saved apps are hidden from `search` by default; set `hidden: false` in + `ui_save_app` only for reusable apps that should be discoverable. - If an OAuth provider requires a callback URL, use a persisted hosted saved app rather than an inline render. - For secret-bearing requests and host approval policy, also read diff --git a/docs/agents/mcp-skills.md b/docs/agents/mcp-skills.md index 7620694484..855573a4bb 100644 --- a/docs/agents/mcp-skills.md +++ b/docs/agents/mcp-skills.md @@ -15,27 +15,27 @@ work should use **`execute`** only; do not persist them as skills. (skills only when the MCP caller has user context). Search accepts an optional `skill_collection` filter and skill hits include `collection` plus `collectionSlug`. -- **`meta_save_skill`** — persists code and trust flags; server infers static - `codemode.*` usage with Acorn (after `normalizeCode` from - `@cloudflare/codemode`, matching execute). Optional `collection` assigns the - skill to a first-class user-defined grouping. Optional `uses_capabilities` - merges explicit names when inference is incomplete. +- **`meta_save_skill`** — upserts by unique per-user skill `name`, persists code + and trust flags, and rewrites the existing skill when that `name` already + exists; server infers static `codemode.*` usage with Acorn (after + `normalizeCode` from `@cloudflare/codemode`, matching execute). Optional + `collection` assigns the skill to a first-class user-defined grouping. + Optional `uses_capabilities` merges explicit names when inference is + incomplete. - **`meta_get_skill`**, **`meta_run_skill`**, **`meta_delete_skill`** — load, execute (same sandbox path as `execute`), or remove skill + vector row. -- **`meta_update_skill`** — same payload as `meta_save_skill` plus `skill_id`; - replaces code and metadata in place and re-embeds (D1 + Vectorize). - **`meta_list_skill_collections`** — returns normalized collection names/slugs with skill counts for browsing and filter confirmation. When **`meta_run_skill`** fails (`ok: false`), the structured output includes a -**`hint`** directing the client to **`meta_get_skill`** then -**`meta_update_skill`** (or delete + save). +**`hint`** directing the client to inspect the skill with **`meta_get_skill`** +and then call **`meta_save_skill`** again with the same `name` to replace it. ## Parameters -Skills can declare **parameters** when saved or updated. Each parameter includes -`name`, `description`, `type`, and optional `required`/`default` values. Types -are: `string`, `number`, `boolean`, or `json`. +Skills can declare **parameters** when saved. Each parameter includes `name`, +`description`, `type`, and optional `required`/`default` values. Types are: +`string`, `number`, `boolean`, or `json`. When running a skill, pass values via `meta_run_skill` **`params`**. The codemode receives them as the `params` variable (and as the first function @@ -43,13 +43,13 @@ argument when present). Missing required parameters or unknown names are rejected; defaults are applied when provided. Example: -`meta_run_skill({ "skill_id": "", "params": { "owner": "kentcdodds" } })` +`meta_run_skill({ "name": "github-pr-summary", "params": { "owner": "kentcdodds" } })` ## Collections -Skills may include an optional **`collection`** string when saved or updated. -This is a first-class grouping label for related skills, separate from built-in -capability domains such as `coding`, `meta`, or `home`. +Skills may include an optional **`collection`** string when saved. This is a +first-class grouping label for related skills, separate from built-in capability +domains such as `coding`, `meta`, or `home`. The server stores both: @@ -58,7 +58,8 @@ The server stores both: browsing UX If no collection is provided, the skill remains ungrouped. Existing skills stay -valid after migration and simply have `null` collection fields until updated. +valid after migration and simply have `null` collection fields until they are +saved again. Use `meta_list_skill_collections({})` to inspect available groupings before reusing one, and use `search({ query, skill_collection: "" })` to narrow @@ -85,6 +86,5 @@ out of sync for saved skills (e.g. after a restore or manual D1 edits). saved UI artifacts and upserts `ui_artifact_` vectors after app-search embed text changes or any D1/Vectorize drift. -For broken skills, prefer **`meta_update_skill`** to fix stored code in place; -alternatively **`meta_delete_skill`** + **`meta_save_skill`**. There is no -versioning. +For broken skills, prefer **`meta_save_skill`** with the same `name` to replace +stored code in place. There is no versioning. diff --git a/docs/setup-manifest.md b/docs/setup-manifest.md index 946113e98c..5d40c85e15 100644 --- a/docs/setup-manifest.md +++ b/docs/setup-manifest.md @@ -91,9 +91,9 @@ automatically: under `/client/v4/`.) - `CAPABILITY_REINDEX_SECRET` (optional Worker secret; bearer auth for `POST /__maintenance/reindex-capabilities`, - `POST /__maintenance/reindex-skills`, and - `POST /__maintenance/reindex-apps` to embed and upsert builtin capabilities, - all user skills, and discoverable saved apps into Vectorize) + `POST /__maintenance/reindex-skills`, and `POST /__maintenance/reindex-apps` + to embed and upsert builtin capabilities, all user skills, and discoverable + saved apps into Vectorize) Tests run with `CLOUDFLARE_ENV=test` (set by Playwright) and still read local secrets from `packages/worker/.env`. @@ -180,9 +180,9 @@ How to get/set each value: Cloudflare Browser Rendering `/markdown`) - Create a Cloudflare API token with the account permissions needed for the product APIs you want to call. This same secret already powers production - deploys and can also be used by the `cloudflare_rest` and - `page_to_markdown` MCP capabilities. For Browser Rendering fallback, include - the **Browser Rendering - Edit** permission. + deploys and can also be used by the `cloudflare_rest` and `page_to_markdown` + MCP capabilities. For Browser Rendering fallback, include the **Browser + Rendering - Edit** permission. - `CAPABILITY_REINDEX_SECRET` (optional) - Generate a long random secret (for example `openssl rand -hex 32`), store it as the repository secret `CAPABILITY_REINDEX_SECRET`, and let the deploy diff --git a/packages/mock-servers/cloudflare/src/worker.ts b/packages/mock-servers/cloudflare/src/worker.ts index 4d682b566d..f209623157 100644 --- a/packages/mock-servers/cloudflare/src/worker.ts +++ b/packages/mock-servers/cloudflare/src/worker.ts @@ -284,16 +284,15 @@ async function routeApi(request: Request, env: MockCloudflareEnv, url: URL) { { status: 404 }, ) } - const hasUrl = typeof payload.url === 'string' && payload.url.trim().length > 0 + const hasUrl = + typeof payload.url === 'string' && payload.url.trim().length > 0 const hasHtml = typeof payload.html === 'string' && payload.html.trim().length > 0 if (!hasUrl && !hasHtml) { return json( { success: false, - errors: [ - { code: 1003, message: 'Either url or html is required.' }, - ], + errors: [{ code: 1003, message: 'Either url or html is required.' }], messages: [], result: null, }, diff --git a/packages/worker/client/routes/connect-secret.tsx b/packages/worker/client/routes/connect-secret.tsx index a199ffdfa5..61e90d1e0a 100644 --- a/packages/worker/client/routes/connect-secret.tsx +++ b/packages/worker/client/routes/connect-secret.tsx @@ -749,7 +749,8 @@ export function ConnectSecretRoute(handle: Handle) { value={state.name} placeholder="api-token" on={{ - input: (event) => setState({ name: event.currentTarget.value }), + input: (event) => + setState({ name: event.currentTarget.value }), }} css={inputCss} /> diff --git a/packages/worker/migrations/0014-skill-names.sql b/packages/worker/migrations/0014-skill-names.sql new file mode 100644 index 0000000000..9e4c4a2b5e --- /dev/null +++ b/packages/worker/migrations/0014-skill-names.sql @@ -0,0 +1,113 @@ +ALTER TABLE mcp_skills ADD COLUMN name TEXT; + +WITH base_names AS ( + SELECT + id, + user_id, + lower(replace(trim(title), ' ', '-')) AS base_name + FROM mcp_skills + WHERE name IS NULL +), +suffixes AS ( + SELECT + user_id, + base_name, + rtrim(base_name, '0123456789') AS trimmed_base, + CASE + WHEN rtrim(base_name, '0123456789') <> base_name + AND length(rtrim(base_name, '0123456789')) > 1 + AND substr(rtrim(base_name, '0123456789'), -1) = '-' + THEN substr( + rtrim(base_name, '0123456789'), + 1, + length(rtrim(base_name, '0123456789')) - 1 + ) + ELSE NULL + END AS prefix, + CASE + WHEN rtrim(base_name, '0123456789') <> base_name + AND length(rtrim(base_name, '0123456789')) > 1 + AND substr(rtrim(base_name, '0123456789'), -1) = '-' + THEN CAST( + substr(base_name, length(rtrim(base_name, '0123456789')) + 1) AS INTEGER + ) + ELSE NULL + END AS suffix_number + FROM base_names +), +max_suffixes AS ( + SELECT + user_id, + prefix AS base_name, + MAX(suffix_number) AS max_suffix + FROM suffixes + WHERE prefix IS NOT NULL AND suffix_number IS NOT NULL + GROUP BY user_id, prefix +), +ranked AS ( + SELECT + id, + user_id, + base_name, + COUNT(*) OVER (PARTITION BY user_id, base_name) AS name_count, + ROW_NUMBER() OVER (PARTITION BY user_id, base_name ORDER BY id) AS name_index + FROM base_names +) +UPDATE mcp_skills +SET name = ( + SELECT + CASE + WHEN ranked.name_count = 1 OR ranked.name_index = 1 THEN ranked.base_name + ELSE ranked.base_name || '-' || (COALESCE(max_suffixes.max_suffix, 1) + ranked.name_index - 1) + END + FROM ranked + LEFT JOIN max_suffixes + ON max_suffixes.user_id = ranked.user_id + AND max_suffixes.base_name = ranked.base_name + WHERE ranked.id = mcp_skills.id +) +WHERE name IS NULL; + +CREATE TABLE IF NOT EXISTS mcp_skills_next ( + id TEXT PRIMARY KEY NOT NULL, + user_id TEXT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL, + keywords TEXT NOT NULL, + code TEXT NOT NULL, + search_text TEXT, + uses_capabilities TEXT, + inferred_capabilities TEXT NOT NULL, + inference_partial INTEGER NOT NULL DEFAULT 0, + read_only INTEGER NOT NULL, + idempotent INTEGER NOT NULL, + destructive INTEGER NOT NULL, + created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + updated_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP), + parameters TEXT, + collection_name TEXT, + collection_slug TEXT, + name TEXT NOT NULL +); + +INSERT INTO mcp_skills_next ( + id, user_id, title, description, keywords, code, search_text, + uses_capabilities, inferred_capabilities, inference_partial, read_only, + idempotent, destructive, created_at, updated_at, parameters, + collection_name, collection_slug, name +) +SELECT + id, user_id, title, description, keywords, code, search_text, + uses_capabilities, inferred_capabilities, inference_partial, read_only, + idempotent, destructive, created_at, updated_at, parameters, + collection_name, collection_slug, name +FROM mcp_skills; + +DROP TABLE mcp_skills; +ALTER TABLE mcp_skills_next RENAME TO mcp_skills; + +CREATE INDEX IF NOT EXISTS idx_mcp_skills_user_id ON mcp_skills(user_id); +CREATE INDEX IF NOT EXISTS idx_mcp_skills_user_collection_slug +ON mcp_skills(user_id, collection_slug); +CREATE UNIQUE INDEX IF NOT EXISTS idx_mcp_skills_user_name +ON mcp_skills(user_id, name); diff --git a/packages/worker/src/mcp/capabilities/coding/coding-capabilities.node.test.ts b/packages/worker/src/mcp/capabilities/coding/coding-capabilities.node.test.ts index 7089de34f2..1ee451e355 100644 --- a/packages/worker/src/mcp/capabilities/coding/coding-capabilities.node.test.ts +++ b/packages/worker/src/mcp/capabilities/coding/coding-capabilities.node.test.ts @@ -153,7 +153,9 @@ test('page_to_markdown does not treat markdown error responses as negotiated suc mode: 'url', }) expect(result.markdown).toContain('# Mock Browser Rendering') - expect(result.markdown).toContain(`source: ${mock.origin}/__mocks/markdown-error`) + expect(result.markdown).toContain( + `source: ${mock.origin}/__mocks/markdown-error`, + ) }) test('page_to_markdown falls back to Browser Rendering for html pages', async () => { diff --git a/packages/worker/src/mcp/capabilities/coding/fetch-page-markdown.ts b/packages/worker/src/mcp/capabilities/coding/fetch-page-markdown.ts index e967136726..4e5ab75c10 100644 --- a/packages/worker/src/mcp/capabilities/coding/fetch-page-markdown.ts +++ b/packages/worker/src/mcp/capabilities/coding/fetch-page-markdown.ts @@ -213,4 +213,3 @@ export async function fetchNegotiatedThenMaybeBrowserRender( }, } } - diff --git a/packages/worker/src/mcp/capabilities/meta/domain.ts b/packages/worker/src/mcp/capabilities/meta/domain.ts index 32601f46cc..a70c2d9ed6 100644 --- a/packages/worker/src/mcp/capabilities/meta/domain.ts +++ b/packages/worker/src/mcp/capabilities/meta/domain.ts @@ -7,19 +7,17 @@ import { metaListCapabilitiesCapability } from './meta-list-capabilities.ts' import { metaListSkillCollectionsCapability } from './meta-list-skill-collections.ts' import { metaRunSkillCapability } from './meta-run-skill.ts' import { metaSaveSkillCapability } from './meta-save-skill.ts' -import { metaUpdateSkillCapability } from './meta-update-skill.ts' export const metaDomain = defineDomain({ name: capabilityDomainNames.meta, description: - 'Save, update, list via search, load, run, and delete user-scoped codemode skills. Inspect the current runtime capability registry when search results seem incomplete. Save skills only for reasonably repeatable workflows (reusable patterns), not one-off or highly bespoke tasks.', + 'Save, list via search, load, run, and delete user-scoped codemode skills. Skill saves upsert by unique name per user. Inspect the current runtime capability registry when search results seem incomplete. Save skills only for reasonably repeatable workflows (reusable patterns), not one-off or highly bespoke tasks.', keywords: ['skill', 'meta', 'save', 'recipe', 'codemode', 'capabilities'], capabilities: [ metaListCapabilitiesCapability, metaGetHomeConnectorStatusCapability, metaListSkillCollectionsCapability, metaSaveSkillCapability, - metaUpdateSkillCapability, metaDeleteSkillCapability, metaGetSkillCapability, metaRunSkillCapability, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-delete-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-delete-skill.ts index ef56a6226d..d82f56b692 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-delete-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-delete-skill.ts @@ -2,7 +2,10 @@ import { z } from 'zod' import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { deleteMcpSkill } from '#mcp/skills/mcp-skills-repo.ts' +import { + deleteMcpSkill, + getMcpSkillByNameInput, +} from '#mcp/skills/mcp-skills-repo.ts' import { deleteSkillVector } from '#mcp/skills/skill-vectorize.ts' import { requireMcpUser } from './require-user.ts' @@ -20,21 +23,26 @@ export const metaDeleteSkillCapability = defineDomainCapability( idempotent: true, destructive: true, inputSchema: z.object({ - skill_id: z - .string() - .min(1) - .describe('Skill id returned by meta_save_skill.'), + name: z.string().min(1).describe('Unique lower-kebab-case skill name.'), }), outputSchema, async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) + const existing = await getMcpSkillByNameInput( + ctx.env.APP_DB, + user.userId, + args.name, + ) + if (!existing) { + return { deleted: false } + } const removed = await deleteMcpSkill( ctx.env.APP_DB, user.userId, - args.skill_id, + existing.name, ) if (removed) { - await deleteSkillVector(ctx.env, args.skill_id) + await deleteSkillVector(ctx.env, existing.id) } return { deleted: removed } }, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-get-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-get-skill.ts index 4d7e24bfc2..73acf085e7 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-get-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-get-skill.ts @@ -2,7 +2,7 @@ import { z } from 'zod' import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { getMcpSkillById } from '#mcp/skills/mcp-skills-repo.ts' +import { getMcpSkillByNameInput } from '#mcp/skills/mcp-skills-repo.ts' import { parseSkillParameters, skillParameterSchema, @@ -21,7 +21,7 @@ function parseStringArray(raw: string | null): Array | null { } const outputSchema = z.object({ - skill_id: z.string(), + name: z.string(), title: z.string(), description: z.string(), collection: z.string().nullable(), @@ -51,18 +51,15 @@ export const metaGetSkillCapability = defineDomainCapability( idempotent: true, destructive: false, inputSchema: z.object({ - skill_id: z - .string() - .min(1) - .describe('Skill id returned by meta_save_skill.'), + name: z.string().min(1).describe('Unique saved skill name.'), }), outputSchema, async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) - const row = await getMcpSkillById( + const row = await getMcpSkillByNameInput( ctx.env.APP_DB, user.userId, - args.skill_id, + args.name, ) if (!row) { throw new Error('Skill not found for this user.') @@ -72,7 +69,7 @@ export const metaGetSkillCapability = defineDomainCapability( const uses = parseStringArray(row.uses_capabilities) const parameters = parseSkillParameters(row.parameters) return { - skill_id: row.id, + name: row.name, title: row.title, description: row.description, collection: row.collection_name, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts index 787e6cd838..621712264b 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-run-skill.ts @@ -3,7 +3,7 @@ import { z } from 'zod' import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { getMcpSkillById } from '#mcp/skills/mcp-skills-repo.ts' +import { getMcpSkillByNameInput } from '#mcp/skills/mcp-skills-repo.ts' import { applySkillParameters, parseSkillParameters, @@ -11,7 +11,7 @@ import { import { requireMcpUser } from './require-user.ts' const runFailureHint = - 'If the saved codemode is wrong, use meta_get_skill to inspect it, then meta_update_skill to replace code and metadata in place (same skill_id), or meta_delete_skill followed by meta_save_skill.' + 'If the saved codemode is wrong, use meta_get_skill to inspect it, then call meta_save_skill again with the same skill name to replace the stored code and metadata.' const outputSchema = z.object({ ok: z.boolean(), @@ -33,16 +33,18 @@ export const metaRunSkillCapability = defineDomainCapability( { name: 'meta_run_skill', description: - 'Execute a saved skill\'s codemode in the same sandbox as the MCP execute tool. When the skill defines parameters, pass them in params; the code receives them via the params variable or the first function argument. Example: meta_run_skill({ "skill_id": "", "params": { "owner": "kentcdodds", "days": 3 } }). On failure, the structured result includes a hint for updating the skill (meta_update_skill).', + 'Execute a saved skill by name in the same sandbox as the MCP execute tool. Skill names are lower-kebab-case and unique per user. When the skill defines parameters, pass them in params; the code receives them via the params variable or the first function argument. Example: meta_run_skill({ "name": "github-pr-summary", "params": { "owner": "kentcdodds", "days": 3 } }). On failure, the structured result includes a hint for re-saving the skill with corrected code or metadata.', keywords: ['skill', 'run', 'execute'], readOnly: false, idempotent: false, destructive: false, inputSchema: z.object({ - skill_id: z + name: z .string() .min(1) - .describe('Skill id returned by meta_save_skill.'), + .describe( + 'Unique lower-kebab-case skill name to execute for the signed-in user.', + ), params: z .record(z.string(), z.unknown()) .optional() @@ -53,10 +55,10 @@ export const metaRunSkillCapability = defineDomainCapability( outputSchema, async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) - const row = await getMcpSkillById( + const row = await getMcpSkillByNameInput( ctx.env.APP_DB, user.userId, - args.skill_id, + args.name, ) if (!row) { throw new Error('Skill not found for this user.') diff --git a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts index 5bcd028773..e4da4db145 100644 --- a/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts +++ b/packages/worker/src/mcp/capabilities/meta/meta-save-skill.ts @@ -2,13 +2,28 @@ import { z } from 'zod' import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { insertMcpSkill, deleteMcpSkill } from '#mcp/skills/mcp-skills-repo.ts' -import { prepareSkillPersistence } from '#mcp/skills/skill-mutation.ts' +import { + deleteMcpSkill, + getMcpSkillByName, + insertMcpSkill, + isDuplicateSkillNameError, + updateMcpSkill, +} from '#mcp/skills/mcp-skills-repo.ts' +import { + buildSkillEmbedTextFromStoredRow, + prepareSkillPersistence, +} from '#mcp/skills/skill-mutation.ts' import { skillParameterSchema } from '#mcp/skills/skill-parameters.ts' import { upsertSkillVector } from '#mcp/skills/skill-vectorize.ts' import { requireMcpUser } from './require-user.ts' const inputSchema = z.object({ + name: z + .string() + .min(1) + .describe( + 'Unique lower-kebab-case skill name for this user. This is the public way to refer to the skill in search, get, run, update, and delete flows.', + ), title: z.string().min(1).describe('Short title for the skill.'), description: z .string() @@ -65,7 +80,7 @@ const inputSchema = z.object({ }) const outputSchema = z.object({ - skill_id: z.string(), + name: z.string(), collection: z.string().nullable(), collection_slug: z.string().nullable(), inferred_capabilities: z.array(z.string()), @@ -81,7 +96,7 @@ export const metaSaveSkillCapability = defineDomainCapability( { name: 'meta_save_skill', description: - 'Save a reusable codemode skill for the signed-in user when the workflow is reasonably repeatable (a pattern you expect to run again with similar structure or inputs). Do not save one-off tasks or highly bespoke work—use execute for those. To change an existing skill in place, use meta_update_skill instead.', + 'Save or replace a reusable codemode skill for the signed-in user by name when the workflow is reasonably repeatable (a pattern you expect to run again with similar structure or inputs). The lower-kebab-case skill name is the public identifier, so calling this again with the same name replaces the stored skill in place. Do not save one-off tasks or highly bespoke work—use execute for those.', keywords: [ 'skill', 'save', @@ -99,16 +114,43 @@ export const metaSaveSkillCapability = defineDomainCapability( async handler(args, ctx: CapabilityContext) { const user = requireMcpUser(ctx.callerContext) const prep = await prepareSkillPersistence(args) + const existing = await getMcpSkillByName( + ctx.env.APP_DB, + user.userId, + prep.rowPayload.name, + ) - const skillId = crypto.randomUUID() + const skillId = existing?.id ?? crypto.randomUUID() const now = new Date().toISOString() - await insertMcpSkill(ctx.env.APP_DB, { - id: skillId, - user_id: user.userId, - ...prep.rowPayload, - created_at: now, - updated_at: now, - }) + + if (existing) { + const updated = await updateMcpSkill( + ctx.env.APP_DB, + user.userId, + existing.name, + prep.rowPayload, + ) + if (!updated) { + throw new Error('Skill not found for this user.') + } + } else { + try { + await insertMcpSkill(ctx.env.APP_DB, { + id: skillId, + user_id: user.userId, + ...prep.rowPayload, + created_at: now, + updated_at: now, + }) + } catch (error) { + if (isDuplicateSkillNameError(error)) { + throw new Error( + `A saved skill named "${prep.rowPayload.name}" already exists for this user.`, + ) + } + throw error + } + } try { await upsertSkillVector(ctx.env, { @@ -118,12 +160,43 @@ export const metaSaveSkillCapability = defineDomainCapability( collectionSlug: prep.rowPayload.collection_slug, }) } catch (cause) { - await deleteMcpSkill(ctx.env.APP_DB, user.userId, skillId) + if (existing) { + await updateMcpSkill(ctx.env.APP_DB, user.userId, existing.name, { + name: existing.name, + title: existing.title, + description: existing.description, + collection_name: existing.collection_name, + collection_slug: existing.collection_slug, + keywords: existing.keywords, + code: existing.code, + search_text: existing.search_text, + uses_capabilities: existing.uses_capabilities, + parameters: existing.parameters, + inferred_capabilities: existing.inferred_capabilities, + inference_partial: existing.inference_partial, + read_only: existing.read_only, + idempotent: existing.idempotent, + destructive: existing.destructive, + }) + const oldEmbed = await buildSkillEmbedTextFromStoredRow(existing) + await upsertSkillVector(ctx.env, { + skillId: existing.id, + userId: user.userId, + embedText: oldEmbed, + collectionSlug: existing.collection_slug, + }) + } else { + await deleteMcpSkill( + ctx.env.APP_DB, + user.userId, + prep.rowPayload.name, + ) + } throw cause } return { - skill_id: skillId, + name: prep.rowPayload.name, collection: prep.rowPayload.collection_name, collection_slug: prep.rowPayload.collection_slug, inferred_capabilities: prep.merged, diff --git a/packages/worker/src/mcp/capabilities/meta/meta-update-skill.ts b/packages/worker/src/mcp/capabilities/meta/meta-update-skill.ts deleted file mode 100644 index c219fa035a..0000000000 --- a/packages/worker/src/mcp/capabilities/meta/meta-update-skill.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { z } from 'zod' -import { defineDomainCapability } from '#mcp/capabilities/define-domain-capability.ts' -import { capabilityDomainNames } from '#mcp/capabilities/domain-metadata.ts' -import { type CapabilityContext } from '#mcp/capabilities/types.ts' -import { getMcpSkillById, updateMcpSkill } from '#mcp/skills/mcp-skills-repo.ts' -import { - buildSkillEmbedTextFromStoredRow, - prepareSkillPersistence, -} from '#mcp/skills/skill-mutation.ts' -import { skillParameterSchema } from '#mcp/skills/skill-parameters.ts' -import { upsertSkillVector } from '#mcp/skills/skill-vectorize.ts' -import { requireMcpUser } from './require-user.ts' - -const inputSchema = z.object({ - skill_id: z - .string() - .min(1) - .describe('Existing skill id (same as returned by meta_save_skill).'), - title: z.string().min(1).describe('Short title for the skill.'), - description: z - .string() - .min(1) - .describe('What this skill does (shown in search and to users).'), - collection: z - .string() - .optional() - .describe( - 'Optional user-defined collection/domain for grouping related saved skills.', - ), - keywords: z - .array(z.string()) - .describe('Extra search keywords for this skill.'), - code: z - .string() - .min(1) - .describe( - 'Replacement codemode snippet as accepted by execute (async arrow or equivalent after normalization).', - ), - search_text: z.string().optional(), - uses_capabilities: z.array(z.string()).optional(), - parameters: z - .array(skillParameterSchema) - .optional() - .describe('Replacement parameter definitions for the skill.'), - read_only: z.boolean(), - idempotent: z.boolean(), - destructive: z.boolean(), -}) - -const outputSchema = z.object({ - skill_id: z.string(), - collection: z.string().nullable(), - collection_slug: z.string().nullable(), - inferred_capabilities: z.array(z.string()), - inference_partial: z.boolean(), - destructive_derived: z.boolean(), - read_only_derived: z.boolean().nullable(), - idempotent_derived: z.boolean().nullable(), - warnings: z.array(z.string()).optional(), -}) - -export const metaUpdateSkillCapability = defineDomainCapability( - capabilityDomainNames.meta, - { - name: 'meta_update_skill', - description: - 'Replace fields and codemode for an existing skill (same skill_id). Re-runs inference, validation, D1 update, and Vectorize upsert. Use when meta_run_skill fails due to bad stored code.', - keywords: [ - 'skill', - 'update', - 'edit', - 'replace', - 'fix', - 'codemode', - 'collection', - ], - readOnly: false, - idempotent: true, - destructive: false, - inputSchema, - outputSchema, - async handler(args, ctx: CapabilityContext) { - const user = requireMcpUser(ctx.callerContext) - const existing = await getMcpSkillById( - ctx.env.APP_DB, - user.userId, - args.skill_id, - ) - if (!existing) { - throw new Error('Skill not found for this user.') - } - - const { skill_id, ...rest } = args - const prep = await prepareSkillPersistence(rest) - - const updated = await updateMcpSkill( - ctx.env.APP_DB, - user.userId, - skill_id, - prep.rowPayload, - ) - if (!updated) { - throw new Error('Skill not found for this user.') - } - - try { - await upsertSkillVector(ctx.env, { - skillId: skill_id, - userId: user.userId, - embedText: prep.embedText, - collectionSlug: prep.rowPayload.collection_slug, - }) - } catch (cause) { - await updateMcpSkill(ctx.env.APP_DB, user.userId, skill_id, { - title: existing.title, - description: existing.description, - collection_name: existing.collection_name, - collection_slug: existing.collection_slug, - keywords: existing.keywords, - code: existing.code, - search_text: existing.search_text, - uses_capabilities: existing.uses_capabilities, - parameters: existing.parameters, - inferred_capabilities: existing.inferred_capabilities, - inference_partial: existing.inference_partial, - read_only: existing.read_only, - idempotent: existing.idempotent, - destructive: existing.destructive, - }) - const oldEmbed = await buildSkillEmbedTextFromStoredRow(existing) - await upsertSkillVector(ctx.env, { - skillId: skill_id, - userId: user.userId, - embedText: oldEmbed, - collectionSlug: existing.collection_slug, - }) - throw cause - } - - return { - skill_id, - collection: prep.rowPayload.collection_name, - collection_slug: prep.rowPayload.collection_slug, - inferred_capabilities: prep.merged, - inference_partial: prep.inferencePartial, - destructive_derived: prep.derived.destructiveDerived, - read_only_derived: prep.derived.readOnlyDerived, - idempotent_derived: prep.derived.idempotentDerived, - ...(prep.warnings.length > 0 ? { warnings: prep.warnings } : {}), - } - }, - }, -) diff --git a/packages/worker/src/mcp/capabilities/unified-search.ts b/packages/worker/src/mcp/capabilities/unified-search.ts index 2dfa89f7da..8d42bb463d 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.ts @@ -14,9 +14,7 @@ import { import { type CapabilitySpec } from './types.ts' import { buildSkillEmbedText } from '#mcp/skills/skill-embed-and-flags.ts' import { type McpSkillRow } from '#mcp/skills/mcp-skills-types.ts' -import { - parseSkillParameters, -} from '#mcp/skills/skill-parameters.ts' +import { parseSkillParameters } from '#mcp/skills/skill-parameters.ts' import { type SecretMetadata, type SecretSearchRow, @@ -37,8 +35,8 @@ function parseJsonStringArray(raw: string): Array { } } -function buildSkillUsage(skillId: string): string { - const runArgs = JSON.stringify({ skill_id: skillId }) +function buildSkillUsage(skillName: string): string { + const runArgs = JSON.stringify({ name: skillName }) return `Run with meta_run_skill: ${runArgs}. Optionally include "params": { ... }. To inspect code, call meta_get_skill then execute.` } @@ -72,7 +70,7 @@ function skillRowEmbedDoc( export type SkillSearchHitSummary = { type: 'skill' - skillId: string + skillName: string domain: 'meta' collection: string | null collectionSlug: string | null @@ -145,14 +143,14 @@ function rowToSkillHit( const keywords = parseJsonStringArray(row.keywords) return { type: 'skill', - skillId: row.id, + skillName: row.name, domain: 'meta', collection: row.collection_name, collectionSlug: row.collection_slug, title: row.title, description: row.description, keywords, - usage: buildSkillUsage(row.id), + usage: buildSkillUsage(row.name), readOnly: row.read_only === 1, idempotent: row.idempotent === 1, destructive: row.destructive === 1, @@ -413,7 +411,7 @@ export async function searchUnified(input: { } const capKeys = capResult.matches.map((m) => `c:${m.name}`) - const skillKeys = skillResult.matches.map((m) => `s:${m.skillId}`) + const skillKeys = skillResult.matches.map((m) => `s:${m.skillName}`) const secretKeys = secretResult.matches.map((m) => `u:${m.name}`) const uiArtifactKeys = uiArtifactResult.matches.map((m) => `a:${m.appId}`) const fusedCross = reciprocalRankFusion( @@ -429,8 +427,8 @@ export async function searchUnified(input: { ).slice(0, Math.max(1, input.limit)) const capByName = new Map(capResult.matches.map((m) => [m.name, m] as const)) - const skillById = new Map( - skillResult.matches.map((m) => [m.skillId, m] as const), + const skillByName = new Map( + skillResult.matches.map((m) => [m.skillName, m] as const), ) const secretByName = new Map( secretResult.matches.map((m) => [m.name, m] as const), @@ -449,8 +447,8 @@ export async function searchUnified(input: { matches.push({ type: 'capability', ...hit, fusedScore: score }) } } else if (key.startsWith('s:')) { - const id = key.slice(2) - const hit = skillById.get(id) + const name = key.slice(2) + const hit = skillByName.get(name) if (hit) { matches.push({ ...hit, fusedScore: score }) } diff --git a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts index 05588e2998..0ea0e67469 100644 --- a/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts +++ b/packages/worker/src/mcp/capabilities/unified-search.workers.test.ts @@ -8,6 +8,7 @@ function createSkillRow(skillId: string): McpSkillRow { return { id: skillId, user_id: 'user-123', + name: `skill-${skillId}`, title: 'Cursor agents with open PRs', description: 'Fetch Cursor agents with open GitHub PRs.', collection_name: 'GitHub Workflows', @@ -74,10 +75,11 @@ test('skill search hits include usage hints', async () => { } expect(skill.usage).toContain('meta_run_skill') - expect(skill.usage).toContain(skillRow.id) + expect(skill.usage).toContain(skillRow.name) expect(skill.usage).toContain('"params"') expect(skill.collection).toBe('GitHub Workflows') expect(skill.collectionSlug).toBe('github-workflows') + expect(skill.skillName).toBe(skillRow.name) }) test('skill collection filter narrows saved skill matches', async () => { @@ -114,7 +116,7 @@ test('skill collection filter narrows saved skill matches', async () => { if (!skill || skill.type !== 'skill') { throw new Error('Expected a skill match in results.') } - expect(skill.skillId).toBe(matchingRow.id) + expect(skill.skillName).toBe(matchingRow.name) expect(skill.collection).toBe('GitHub Workflows') }) diff --git a/packages/worker/src/mcp/index.ts b/packages/worker/src/mcp/index.ts index d6eeae060e..9d8139255a 100644 --- a/packages/worker/src/mcp/index.ts +++ b/packages/worker/src/mcp/index.ts @@ -10,7 +10,9 @@ import { parseMcpCallerContext, type McpServerProps } from './context.ts' import { registerResources } from './register-resources.ts' import { registerTools } from './register-tools.ts' -export type State = {} +export type State = { + searchConversationIdsWithPreamble?: Array +} export type Props = McpServerProps const domainInstructions = builtinDomains @@ -18,7 +20,7 @@ const domainInstructions = builtinDomains .join('\n') export const conversationIdGuidance = - 'The public MCP tools accept optional `conversationId` and `memoryContext` fields. Clients should generate and reuse a short random `conversationId` across related calls when possible, attempting to avoid collisions; if omitted, Kody generates a short random value and returns it in `structuredContent.conversationId`.' + 'The public MCP tools accept optional `conversationId` and `memoryContext` fields. `conversationId` ties related calls together. On the first call, omit it to receive a server-generated ID, or supply your own. Pass the returned `conversationId` on every subsequent call in the same conversation - this enables optimizations like reduced response size. Generated values should be short and random enough to avoid collisions.' const serverMetadata = { implementation: { @@ -40,7 +42,7 @@ Quick start - 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. +- 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 call 'meta_save_skill' again with the same name to replace an existing skill in place. - When a saved skill declares parameters, pass values via meta_run_skill params; the codemode can read them from the params variable. - Use 'ui_save_app' to persist reusable UI source for later reopening via 'app_id'. Saved apps are user-scoped UI artifacts, not codemode skills. They are hidden from search by default unless you explicitly set \`hidden: false\` for reusable apps. - Use \`codemode.secret_list(args)\` during execute-time code to list secret metadata only; it does not return plaintext values. @@ -64,7 +66,7 @@ How to use search - Example: search({ query: 'repository automation pull requests' }) - Example: search({ query: 'Cloudflare API zones dns workers d1' }) - Example: search({ entity: 'cloudflare_rest:capability' }) -- Example: search({ entity: '70a6a63f-2711-4617-b273-ec95f3be114b:skill' }) +- Example: search({ entity: 'github-pr-summary:skill' }) Destructive Cloudflare access - The cloudflare_rest capability can change or delete Cloudflare resources such as DNS records, Workers settings, routes, or account configuration (POST/PUT/PATCH/DELETE). @@ -106,6 +108,9 @@ MCP App tools } as const class MCPBase extends McpAgent { + initialState: State = { + searchConversationIdsWithPreamble: [], + } server = new McpServer(serverMetadata.implementation, { instructions: serverMetadata.instructions, jsonSchemaValidator: new CfWorkerJsonSchemaValidator(), 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 338c85474b..4cc1e9e501 100644 --- a/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts +++ b/packages/worker/src/mcp/mcp-server.mcp-e2e.test.ts @@ -27,6 +27,28 @@ test('mcp server returns built-in instructions and base server metadata', async await using server = await startDevServer(database.persistDir) await using mcpClient = await createMcpClient(server.origin, database.user) + const tools = await mcpClient.client.listTools() + const toolDescriptions = new Map( + tools.tools.map((tool) => [tool.name, tool.inputSchema] as const), + ) + const conversationIdDescription = + 'Ties related calls together. On the first call, omit this to receive a server-generated ID, or supply your own. Pass the returned `conversationId` on every subsequent call in the same conversation - this enables optimizations like reduced response size.' + + for (const toolName of ['search', 'execute', 'open_generated_ui']) { + const schema = toolDescriptions.get(toolName) as + | { + properties?: { + conversationId?: { + description?: string + } + } + } + | undefined + expect(schema?.properties?.conversationId?.description).toContain( + conversationIdDescription, + ) + } + const basicSearchResult = await mcpClient.client.callTool({ name: 'search', arguments: { @@ -93,6 +115,7 @@ test('mcp server saves and browses skill collections', async () => { arguments: { code: `async () => { return await codemode.meta_save_skill({ + name: 'summarize-agent-prs', title: 'Summarize agent PRs', description: 'Summarize open pull requests for agents.', collection: 'GitHub Workflows', @@ -110,14 +133,14 @@ test('mcp server saves and browses skill collections', async () => { const saveStructured = (saveResult as CallToolResult).structuredContent as | { result?: { - skill_id?: string + name?: string collection?: string | null collection_slug?: string | null } } | undefined const savedSkill = saveStructured?.result - expect(typeof savedSkill?.skill_id).toBe('string') + expect(savedSkill?.name).toBe('summarize-agent-prs') expect(savedSkill?.collection).toBe('GitHub Workflows') expect(savedSkill?.collection_slug).toBe('github-workflows') @@ -155,7 +178,7 @@ test('mcp server saves and browses skill collections', async () => { arguments: { code: `async () => { return await codemode.meta_get_skill({ - skill_id: ${JSON.stringify(savedSkill?.skill_id)}, + name: ${JSON.stringify(savedSkill?.name)}, }) }`, }, @@ -163,14 +186,17 @@ test('mcp server saves and browses skill collections', async () => { const getStructured = (getResult as CallToolResult).structuredContent as | { result?: { + name?: string collection?: string | null collection_slug?: string | null } } | undefined + expect(getStructured?.result?.name).toBe('summarize-agent-prs') expect(getStructured?.result?.collection).toBe('GitHub Workflows') expect(getStructured?.result?.collection_slug).toBe('github-workflows') + const conversationId = 'skills-search-flow' const searchResult = await mcpClient.client.callTool({ name: 'search', arguments: { @@ -178,6 +204,7 @@ test('mcp server saves and browses skill collections', async () => { skill_collection: 'github-workflows', limit: 25, maxResponseSize: 20_000, + conversationId, }, }) const searchText = getTextContent((searchResult as CallToolResult).content) @@ -187,6 +214,7 @@ test('mcp server saves and browses skill collections', async () => { matches?: Array<{ type?: string id?: string + name?: string collection?: string | null collectionSlug?: string | null }> @@ -194,21 +222,38 @@ test('mcp server saves and browses skill collections', async () => { } | undefined expect(searchText).toContain('# Search results') + expect(searchText).toContain('**How to run matches:**') expect(searchText).toContain('## Skill — Summarize agent PRs') + expect(searchText).toContain('(name: `summarize-agent-prs`)') expect( searchStructured?.result?.matches?.find((match) => match.type === 'skill'), ).toEqual( expect.objectContaining({ - id: savedSkill?.skill_id, + id: savedSkill?.name, + name: savedSkill?.name, collection: 'GitHub Workflows', collectionSlug: 'github-workflows', }), ) + const followupSearchResult = await mcpClient.client.callTool({ + name: 'search', + arguments: { + query: 'github pull requests', + limit: 10, + conversationId, + }, + }) + const followupSearchText = getTextContent( + (followupSearchResult as CallToolResult).content, + ) + expect(followupSearchText).toContain('# Search results') + expect(followupSearchText).not.toContain('**How to run matches:**') + const entityResult = await mcpClient.client.callTool({ name: 'search', arguments: { - entity: `${savedSkill?.skill_id}:skill`, + entity: `${savedSkill?.name}:skill`, }, }) const entityText = getTextContent((entityResult as CallToolResult).content) @@ -218,18 +263,50 @@ test('mcp server saves and browses skill collections', async () => { kind?: string type?: string id?: string + name?: string collection?: string | null } } | undefined expect(entityText).toContain('# Skill — Summarize agent PRs') expect(entityText).toContain('## Run this skill') + expect(entityText).toContain('Name: `summarize-agent-prs`') expect(entityStructured?.result).toEqual( expect.objectContaining({ kind: 'entity', type: 'skill', - id: savedSkill?.skill_id, + id: savedSkill?.name, collection: 'GitHub Workflows', + collectionSlug: 'github-workflows', + }), + ) + + const runResult = await mcpClient.client.callTool({ + name: 'execute', + arguments: { + code: `async () => { + return await codemode.meta_run_skill({ + name: ${JSON.stringify(savedSkill?.name)}, + }) + }`, + }, + }) + const runStructured = (runResult as CallToolResult).structuredContent as + | { + result?: { + ok?: boolean + result?: { + ok?: boolean + } + } + } + | undefined + expect(runStructured?.result).toEqual( + expect.objectContaining({ + ok: true, + result: { + ok: true, + }, }), ) }) @@ -272,7 +349,9 @@ test('mcp server executes user code against codemode and tracks execute context' query: 'Execute generated app', }, }) - const hiddenSearchText = getTextContent((hiddenSearchResult as CallToolResult).content) + const hiddenSearchText = getTextContent( + (hiddenSearchResult as CallToolResult).content, + ) const hiddenSearchStructured = (hiddenSearchResult as CallToolResult) .structuredContent as | { @@ -287,8 +366,7 @@ test('mcp server executes user code against codemode and tracks execute context' expect(hiddenSearchText).toContain('# Search results') expect( hiddenSearchStructured?.result?.matches?.some( - (match) => - match.type === 'app' && match.id === executeResult?.app_id, + (match) => match.type === 'app' && match.id === executeResult?.app_id, ), ).toBe(false) @@ -326,9 +404,8 @@ test('mcp server executes user code against codemode and tracks execute context' }`, }, }) - const searchableUpdateStructured = ( - searchableUpdateResult as CallToolResult - ).structuredContent as + const searchableUpdateStructured = (searchableUpdateResult as CallToolResult) + .structuredContent as | { result?: { hidden?: boolean @@ -345,7 +422,9 @@ test('mcp server executes user code against codemode and tracks execute context' maxResponseSize: 20_000, }, }) - const visibleSearchText = getTextContent((visibleSearchResult as CallToolResult).content) + const visibleSearchText = getTextContent( + (visibleSearchResult as CallToolResult).content, + ) const visibleSearchStructured = (visibleSearchResult as CallToolResult) .structuredContent as | { @@ -364,8 +443,7 @@ test('mcp server executes user code against codemode and tracks execute context' ) expect( visibleSearchStructured?.result?.matches?.find( - (match) => - match.type === 'app' && match.id === executeResult?.app_id, + (match) => match.type === 'app' && match.id === executeResult?.app_id, ), ).toEqual( expect.objectContaining({ @@ -381,7 +459,9 @@ test('mcp server executes user code against codemode and tracks execute context' entity: `${executeResult?.app_id}:app`, }, }) - const appEntityText = getTextContent((appEntityResult as CallToolResult).content) + const appEntityText = getTextContent( + (appEntityResult as CallToolResult).content, + ) const appEntityStructured = (appEntityResult as CallToolResult) .structuredContent as | { diff --git a/packages/worker/src/mcp/observability.workers.test.ts b/packages/worker/src/mcp/observability.workers.test.ts index dbc88ccc62..7a12def009 100644 --- a/packages/worker/src/mcp/observability.workers.test.ts +++ b/packages/worker/src/mcp/observability.workers.test.ts @@ -201,7 +201,7 @@ test('ui_save_app logs vector refresh failure for in-place updates and still suc title: 'Observed app', description: 'Observation test app.', code: '

Observed app

', - hidden: false, + hidden: false, }, { env: { diff --git a/packages/worker/src/mcp/skills/mcp-skills-repo.ts b/packages/worker/src/mcp/skills/mcp-skills-repo.ts index 844d690acf..a273aaf1d8 100644 --- a/packages/worker/src/mcp/skills/mcp-skills-repo.ts +++ b/packages/worker/src/mcp/skills/mcp-skills-repo.ts @@ -1,9 +1,19 @@ import { type McpSkillRow } from './mcp-skills-types.ts' +import { getSkillNameCandidates, normalizeSkillName } from './skill-names.ts' export function skillVectorId(skillId: string): string { return `skill_${skillId}` } +export function isDuplicateSkillNameError(error: unknown): boolean { + return ( + error instanceof Error && + error.message.includes( + 'UNIQUE constraint failed: mcp_skills.user_id, mcp_skills.name', + ) + ) +} + export async function insertMcpSkill( db: D1Database, row: Omit & { @@ -15,15 +25,16 @@ export async function insertMcpSkill( await db .prepare( `INSERT INTO mcp_skills ( - id, user_id, title, description, keywords, code, search_text, + id, user_id, name, title, description, keywords, code, search_text, uses_capabilities, parameters, collection_name, collection_slug, inferred_capabilities, inference_partial, read_only, idempotent, destructive, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( row.id, row.user_id, + row.name, row.title, row.description, row.keywords, @@ -44,30 +55,95 @@ export async function insertMcpSkill( .run() } -export async function getMcpSkillById( +export async function getMcpSkillByName( + db: D1Database, + userId: string, + skillName: string, +): Promise { + const normalizedSkillName = normalizeSkillName(skillName) + const result = await db + .prepare( + `SELECT id, user_id, name, title, description, keywords, code, search_text, + uses_capabilities, parameters, collection_name, collection_slug, + inferred_capabilities, inference_partial, read_only, idempotent, + destructive, created_at, updated_at + FROM mcp_skills WHERE name = ? AND user_id = ?`, + ) + .bind(normalizedSkillName, userId) + .first>() + if (!result) return null + return mapRow(result) +} + +export async function getMcpSkillByNameCandidates( db: D1Database, userId: string, - skillId: string, + skillNames: Array, ): Promise { + if (skillNames.length === 0) return null + const placeholders = skillNames.map(() => '?').join(', ') const result = await db .prepare( - `SELECT id, user_id, title, description, keywords, code, search_text, + `SELECT id, user_id, name, title, description, keywords, code, search_text, uses_capabilities, parameters, collection_name, collection_slug, inferred_capabilities, inference_partial, read_only, idempotent, destructive, created_at, updated_at - FROM mcp_skills WHERE id = ? AND user_id = ?`, + FROM mcp_skills + WHERE user_id = ? AND name IN (${placeholders}) + ORDER BY CASE name + ${skillNames.map((_, index) => `WHEN ? THEN ${index + 1}`).join(' ')} + ELSE ${skillNames.length + 1} + END + LIMIT 1`, ) - .bind(skillId, userId) + .bind(userId, ...skillNames, ...skillNames) .first>() if (!result) return null return mapRow(result) } +function matchesNormalizedTitle( + row: McpSkillRow, + normalizedInput: string, +): boolean { + try { + return normalizeSkillName(row.title) === normalizedInput + } catch { + return false + } +} + +export async function getMcpSkillByNameInput( + db: D1Database, + userId: string, + inputName: string, +): Promise { + const candidates = getSkillNameCandidates(inputName) + const direct = await getMcpSkillByNameCandidates(db, userId, candidates) + if (direct) return direct + let normalizedInput: string + try { + normalizedInput = normalizeSkillName(inputName) + } catch { + return null + } + const rows = await listMcpSkillsByUserId(db, userId) + let match: McpSkillRow | null = null + for (const row of rows) { + if (!matchesNormalizedTitle(row, normalizedInput)) continue + if (match) return null + match = row + } + return match +} + export async function updateMcpSkill( db: D1Database, userId: string, - skillId: string, + skillName: string, fields: { + /** Changing this value renames the skill and must remain unique per user. */ + name: string title: string description: string keywords: string @@ -85,16 +161,18 @@ export async function updateMcpSkill( }, ): Promise { const now = new Date().toISOString() + const normalizedSkillName = normalizeSkillName(skillName) const out = await db .prepare( `UPDATE mcp_skills SET - title = ?, description = ?, keywords = ?, code = ?, search_text = ?, + name = ?, title = ?, description = ?, keywords = ?, code = ?, search_text = ?, uses_capabilities = ?, parameters = ?, collection_name = ?, collection_slug = ?, inferred_capabilities = ?, inference_partial = ?, read_only = ?, idempotent = ?, destructive = ?, updated_at = ? - WHERE id = ? AND user_id = ?`, + WHERE name = ? AND user_id = ?`, ) .bind( + fields.name, fields.title, fields.description, fields.keywords, @@ -110,7 +188,7 @@ export async function updateMcpSkill( fields.idempotent, fields.destructive, now, - skillId, + normalizedSkillName, userId, ) .run() @@ -120,11 +198,11 @@ export async function updateMcpSkill( export async function deleteMcpSkill( db: D1Database, userId: string, - skillId: string, + skillName: string, ): Promise { const out = await db - .prepare(`DELETE FROM mcp_skills WHERE id = ? AND user_id = ?`) - .bind(skillId, userId) + .prepare(`DELETE FROM mcp_skills WHERE name = ? AND user_id = ?`) + .bind(skillName, userId) .run() return (out.meta.changes ?? 0) > 0 } @@ -135,7 +213,7 @@ export async function listMcpSkillsByUserId( ): Promise> { const { results } = await db .prepare( - `SELECT id, user_id, title, description, keywords, code, search_text, + `SELECT id, user_id, name, title, description, keywords, code, search_text, uses_capabilities, parameters, collection_name, collection_slug, inferred_capabilities, inference_partial, read_only, idempotent, destructive, created_at, updated_at @@ -182,7 +260,7 @@ export async function listAllMcpSkills( ): Promise> { const { results } = await db .prepare( - `SELECT id, user_id, title, description, keywords, code, search_text, + `SELECT id, user_id, name, title, description, keywords, code, search_text, uses_capabilities, parameters, collection_name, collection_slug, inferred_capabilities, inference_partial, read_only, idempotent, destructive, created_at, updated_at @@ -196,6 +274,7 @@ function mapRow(r: Record): McpSkillRow { return { id: String(r['id']), user_id: String(r['user_id']), + name: String(r['name']), title: String(r['title']), description: String(r['description']), keywords: String(r['keywords']), diff --git a/packages/worker/src/mcp/skills/mcp-skills-types.ts b/packages/worker/src/mcp/skills/mcp-skills-types.ts index 4fbba947eb..a8847ccf64 100644 --- a/packages/worker/src/mcp/skills/mcp-skills-types.ts +++ b/packages/worker/src/mcp/skills/mcp-skills-types.ts @@ -1,6 +1,7 @@ export type McpSkillRow = { id: string user_id: string + name: string title: string description: string collection_name: string | null diff --git a/packages/worker/src/mcp/skills/skill-mutation.ts b/packages/worker/src/mcp/skills/skill-mutation.ts index 23df99fe6e..8fd4558eda 100644 --- a/packages/worker/src/mcp/skills/skill-mutation.ts +++ b/packages/worker/src/mcp/skills/skill-mutation.ts @@ -8,6 +8,7 @@ import { } from './skill-embed-and-flags.ts' import { parseSkillCollection } from './skill-collections.ts' import { type McpSkillRow } from './mcp-skills-types.ts' +import { normalizeSkillName } from './skill-names.ts' import { normalizeSkillParameters, parseSkillParameters, @@ -53,6 +54,7 @@ export async function buildSkillEmbedTextFromStoredRow( } export type SkillPersistenceArgs = { + name: string title: string description: string collection?: string | undefined @@ -73,6 +75,7 @@ export type PreparedSkillPersistence = { warnings: Array embedText: string rowPayload: { + name: string title: string description: string collection_name: string | null @@ -130,6 +133,7 @@ export async function prepareSkillPersistence( const parameters = normalizeSkillParameters(args.parameters) const collection = parseSkillCollection(args.collection) + const skillName = normalizeSkillName(args.name) const embedText = buildSkillEmbedText({ title: args.title, description: args.description, @@ -143,6 +147,7 @@ export async function prepareSkillPersistence( }) const rowPayload = { + name: skillName, title: args.title, description: args.description, collection_name: collection?.name ?? null, diff --git a/packages/worker/src/mcp/skills/skill-names.ts b/packages/worker/src/mcp/skills/skill-names.ts new file mode 100644 index 0000000000..9ecc1cb425 --- /dev/null +++ b/packages/worker/src/mcp/skills/skill-names.ts @@ -0,0 +1,48 @@ +const maxSkillNameLength = 80 + +function collapseWhitespace(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +export function normalizeSkillName(value: string): string { + const normalized = collapseWhitespace(value) + const ascii = normalized + .normalize('NFKD') + .replace(/[\u0300-\u036f]/g, '') + .toLowerCase() + const slug = ascii.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') + if (!slug) { + throw new Error('Skill names must contain at least one letter or number.') + } + const sliced = slug.slice(0, maxSkillNameLength).replace(/-+$/g, '') + if (!sliced) { + throw new Error('Skill names must contain at least one letter or number.') + } + return sliced +} + +function normalizeLegacySkillName(value: string): string { + return value.trim().toLowerCase().replace(/ /g, '-') +} + +export function getSkillNameCandidates(value: string): Array { + const trimmed = value.trim() + let normalized: string | null = null + try { + normalized = normalizeSkillName(value) + } catch { + normalized = null + } + const legacy = normalizeLegacySkillName(value) + const candidates: Array = [] + const pushCandidate = (candidate: string) => { + if (!candidate || candidates.includes(candidate)) return + candidates.push(candidate) + } + pushCandidate(trimmed) + if (normalized) { + pushCandidate(normalized) + } + pushCandidate(legacy) + return candidates +} diff --git a/packages/worker/src/mcp/tools/execute.ts b/packages/worker/src/mcp/tools/execute.ts index dd123f35fa..f2061fdd8a 100644 --- a/packages/worker/src/mcp/tools/execute.ts +++ b/packages/worker/src/mcp/tools/execute.ts @@ -26,7 +26,7 @@ const executeTool = { Execute JavaScript code against Kody capabilities. First use \`search\` to find the right capability, then call it through \`codemode\`. -To run a saved skill by id, prefer \`meta_run_skill\` with \`skill_id\` and +To run a saved skill by name, prefer \`meta_run_skill\` with \`name\` and optional \`params\`. If you need the saved code, call \`meta_get_skill\` and pass the returned code into this tool. diff --git a/packages/worker/src/mcp/tools/search-format.ts b/packages/worker/src/mcp/tools/search-format.ts index 9213268bee..c83f758448 100644 --- a/packages/worker/src/mcp/tools/search-format.ts +++ b/packages/worker/src/mcp/tools/search-format.ts @@ -29,16 +29,17 @@ export type SlimSearchMatch = title: string description: string usage: string - } + } | { type: 'skill' id: string + name: string title: string description: string usage: string collection: string | null collectionSlug: string | null - } + } | { type: 'app' id: string @@ -46,14 +47,14 @@ export type SlimSearchMatch = description: string usage: string hostedUrl: string - } + } | { type: 'secret' id: string title: string description: string usage: string - } + } export type SearchEntityDetailStructured = | { @@ -69,7 +70,7 @@ export type SearchEntityDetailStructured = destructive: boolean inputSchema: unknown outputSchema?: unknown - } + } | { kind: 'entity' type: 'skill' @@ -86,7 +87,7 @@ export type SearchEntityDetailStructured = readOnly: boolean idempotent: boolean destructive: boolean - } + } | { kind: 'entity' type: 'app' @@ -98,7 +99,7 @@ export type SearchEntityDetailStructured = runtime: string parameters: ReturnType hidden: boolean - } + } | { kind: 'entity' type: 'secret' @@ -108,7 +109,7 @@ export type SearchEntityDetailStructured = usage: string scope: string updatedAt: string - } + } export type SearchEntityDetail = | { @@ -117,14 +118,14 @@ export type SearchEntityDetail = title: string description: string spec: CapabilitySpec - } + } | { type: 'skill' id: string title: string description: string row: McpSkillRow - } + } | { type: 'app' id: string @@ -132,14 +133,14 @@ export type SearchEntityDetail = description: string row: UiArtifactRow hostedUrl: string - } + } | { type: 'secret' id: string title: string description: string row: SecretSearchRow - } + } export function parseEntityRef(entity: string): { id: string @@ -174,18 +175,21 @@ export function formatSearchMarkdown(input: { matches: Array warnings: Array baseUrl: string + includePreamble?: boolean }) { const lines: Array = ['# Search results', ''] - lines.push( - 'For full detail on one hit, call `search` with `entity: "{id}:{type}"` (example: `cloudflare_api_docs:capability`).', - '', - '**How to run matches:**', - '', - '- Builtin capabilities — `execute` / `codemode.(args)`', - '- Saved skills — `codemode.meta_run_skill({ skill_id, params })`', - '- Saved apps — `open_generated_ui({ app_id })`; users can also open the hosted URL for the saved app', - '- Secrets — placeholders in execute-time fetches or `codemode.secret_list` (never paste raw secrets in chat)', - ) + if (input.includePreamble ?? true) { + lines.push( + 'For full detail on one hit, call `search` with `entity: "{id}:{type}"` (example: `cloudflare_api_docs:capability`).', + '', + '**How to run matches:**', + '', + '- Builtin capabilities — `execute` / `codemode.(args)`', + '- Saved skills — `codemode.meta_run_skill({ name, params })`', + '- Saved apps — `open_generated_ui({ app_id })`; users can also open the hosted URL for the saved app', + '- Secrets — placeholders in execute-time fetches or `codemode.secret_list` (never paste raw secrets in chat)', + ) + } if (input.warnings.length > 0) { lines.push('', '## Warnings', '') @@ -217,7 +221,11 @@ function formatMatchBlock(match: UnifiedSearchMatch, baseUrl: string) { ] } if (match.type === 'skill') { - return [`## Skill — ${match.title}`, '', match.description] + return [ + `## Skill — ${match.title} (name: \`${match.skillName}\`)`, + '', + match.description, + ] } if (match.type === 'app') { const hostedUrl = buildSavedUiUrl(baseUrl, match.appId) @@ -229,11 +237,7 @@ function formatMatchBlock(match: UnifiedSearchMatch, baseUrl: string) { `**Hosted URL:** \`${hostedUrl}\``, ] } - return [ - `## Secret — \`${match.name}\``, - '', - match.description, - ] + return [`## Secret — \`${match.name}\``, '', match.description] } export function toSlimStructuredMatches(input: { @@ -256,10 +260,11 @@ export function toSlimStructuredMatches(input: { if (match.type === 'skill') { return { type: 'skill', - id: match.skillId, + id: match.skillName, + name: match.skillName, title: match.title, description: match.description, - usage: `codemode.meta_run_skill({ skill_id: "${match.skillId}", params: { ... } })`, + usage: `codemode.meta_run_skill({ name: "${match.skillName}", params: { ... } })`, collection: match.collection, collectionSlug: match.collectionSlug, } @@ -322,7 +327,12 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { `- \`${JSON.stringify(inputSchema)}\``, ] if (outputSchema !== undefined) { - lines.push('', '## Output schema', '', `- \`${JSON.stringify(outputSchema)}\``) + lines.push( + '', + '## Output schema', + '', + `- \`${JSON.stringify(outputSchema)}\``, + ) } return { markdown: lines.join('\n'), @@ -355,7 +365,7 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { '', '## Summary', '', - `- Skill ID: \`${detail.row.id}\``, + `- Name: \`${detail.row.name}\``, `- Collection: ${detail.row.collection_name ?? 'none'}`, `- Collection slug: ${detail.row.collection_slug ?? 'none'}`, `- Read-only: ${detail.row.read_only === 1 ? 'yes' : 'no'}`, @@ -364,7 +374,7 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { '', '## Run this skill', '', - `- \`codemode.meta_run_skill({ skill_id: "${detail.row.id}", params: { ... } })\``, + `- \`codemode.meta_run_skill({ name: "${detail.row.name}", params: { ... } })\``, '- Use `meta_get_skill` separately if you need the stored source code.', ] if (parameters && parameters.length > 0) { @@ -378,10 +388,14 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { if (inferredCapabilities.length > 0 || usesCapabilities?.length) { lines.push('', '## Capability hints', '') if (inferredCapabilities.length > 0) { - lines.push(`- Inferred capabilities: ${inferredCapabilities.join(', ')}`) + lines.push( + `- Inferred capabilities: ${inferredCapabilities.join(', ')}`, + ) } if (usesCapabilities && usesCapabilities.length > 0) { - lines.push(`- Declared uses_capabilities: ${usesCapabilities.join(', ')}`) + lines.push( + `- Declared uses_capabilities: ${usesCapabilities.join(', ')}`, + ) } } if (detail.row.search_text) { @@ -392,10 +406,10 @@ export function formatEntityDetailMarkdown(detail: SearchEntityDetail) { structured: { kind: 'entity', type: 'skill', - id: detail.id, + id: detail.row.name, title: detail.title, description: detail.description, - usage: `codemode.meta_run_skill({ skill_id: "${detail.row.id}", params: { ... } })`, + usage: `codemode.meta_run_skill({ name: "${detail.row.name}", params: { ... } })`, collection: detail.row.collection_name, collectionSlug: detail.row.collection_slug, parameters, diff --git a/packages/worker/src/mcp/tools/search.ts b/packages/worker/src/mcp/tools/search.ts index 0cc10b91dc..dfc109ef23 100644 --- a/packages/worker/src/mcp/tools/search.ts +++ b/packages/worker/src/mcp/tools/search.ts @@ -9,7 +9,7 @@ import { } from '#mcp/secrets/service.ts' import { type SecretSearchRow } from '#mcp/secrets/types.ts' import { - getMcpSkillById, + getMcpSkillByName, listMcpSkillsByUserId, } from '#mcp/skills/mcp-skills-repo.ts' import { slugifySkillCollectionName } from '#mcp/skills/skill-collections.ts' @@ -98,9 +98,9 @@ const searchTool = { description: ` Search Kody **builtin capabilities**, your saved **skills** (meta domain), your saved **apps** (apps domain), and your reusable **user secret references** by natural language before calling \`execute\` or opening a UI. -Search returns compact **markdown** plus slim structured results. Use \`query\` for ranked search, or use \`entity\` with an exact reference like \`cloudflare_rest:capability\` or \`70a6a63f-2711-4617-b273-ec95f3be114b:skill\` to get focused markdown detail for a single result. +Search returns compact **markdown** plus slim structured results. Use \`query\` for ranked search, or use \`entity\` with an exact reference like \`cloudflare_rest:capability\` or \`github-pr-summary:skill\` to get focused markdown detail for a single result. -Each match has **type** \`capability\`, \`skill\`, \`app\`, or \`secret\`. To run a saved skill, call \`meta_run_skill\` with the \`skill_id\` and optional \`params\`. If you need to inspect the code, call \`meta_get_skill\`. To reopen a saved app, call \`open_generated_ui\` with the \`app_id\` or share its hosted URL with the user. User-scoped secret references never include raw secret values; inspect secret metadata during execution with \`codemode.secret_list(...)\`, and use generated UI when the user needs to provide a missing secret. App-bound secrets are attached to their corresponding app results rather than returned as standalone secret hits. +Each match has **type** \`capability\`, \`skill\`, \`app\`, or \`secret\`. Saved skills are identified by a unique lower-kebab-case **name**. To run a saved skill, call \`meta_run_skill\` with the \`name\` and optional \`params\`. If you need to inspect the code, call \`meta_get_skill\`. To reopen a saved app, call \`open_generated_ui\` with the \`app_id\` or share its hosted URL with the user. User-scoped secret references never include raw secret values; inspect secret metadata during execution with \`codemode.secret_list(...)\`, and use generated UI when the user needs to provide a missing secret. App-bound secrets are attached to their corresponding app results rather than returned as standalone secret hits. If search results seem incomplete, call \`meta_list_capabilities\` to inspect the exact current runtime capability registry (including dynamic capabilities such as connected home tools), or call \`meta_get_home_connector_status\` to confirm whether the home connector is connected. @@ -117,7 +117,7 @@ Example arguments: - \`{ "query": "saved interactive dashboard app", "limit": 10 }\` - \`{ "query": "github automation", "skill_collection": "release-engineering" }\` - \`{ "entity": "cloudflare_rest:capability" }\` -- To run a skill: \`meta_run_skill({ "skill_id": "", "params": { "owner": "kentcdodds" } })\` +- To run a skill: \`meta_run_skill({ "name": "github-pr-summary", "params": { "owner": "kentcdodds" } })\` - To reopen a saved app: \`open_generated_ui({ "app_id": "" })\` `.trim(), annotations: { @@ -144,9 +144,7 @@ function shouldIncludeHomeConnectorStatus(status: HomeConnectorStatus) { return status.state !== 'connected' || status.toolCount === 0 } -function serializeHomeConnectorStatus( - status: HomeConnectorStatus | null, -): +function serializeHomeConnectorStatus(status: HomeConnectorStatus | null): | { connectorId: string state: string @@ -235,7 +233,8 @@ async function loadSearchRowsAndRegistry(input: { }) const optionalRows = await loadOptionalSearchRows({ userId: input.userId, - loadSkills: () => listMcpSkillsByUserId(input.agent.getEnv().APP_DB, input.userId!), + loadSkills: () => + listMcpSkillsByUserId(input.agent.getEnv().APP_DB, input.userId!), loadUiArtifacts: () => listUiArtifactsByUserId(input.agent.getEnv().APP_DB, input.userId!, { hidden: false, @@ -291,7 +290,7 @@ async function resolveEntityDetail(input: { } if (ref.type === 'skill') { - const row = await getMcpSkillById( + const row = await getMcpSkillByName( input.agent.getEnv().APP_DB, input.userId, ref.id, @@ -301,7 +300,7 @@ async function resolveEntityDetail(input: { } return { type: 'skill' as const, - id: row.id, + id: row.name, title: row.title, description: row.description, row, @@ -327,7 +326,9 @@ async function resolveEntityDetail(input: { } } - const row = input.searchRows.userSecretRows.find((secret) => secret.name === ref.id) + const row = input.searchRows.userSecretRows.find( + (secret) => secret.name === ref.id, + ) if (!row) { throw new Error('Secret not found for this user.') } @@ -570,6 +571,31 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { } : {}), } + const statefulAgent = agent as McpRegistrationAgent & { + state?: { + searchConversationIdsWithPreamble?: Array + } + setState?: (state: { + searchConversationIdsWithPreamble?: Array + }) => void + } + const searchConversationIdsWithPreamble = Array.isArray( + statefulAgent.state?.searchConversationIdsWithPreamble, + ) + ? (statefulAgent.state?.searchConversationIdsWithPreamble ?? []) + : [] + const includePreamble = + !args.conversationId || + !searchConversationIdsWithPreamble.includes(conversationId) + if (includePreamble && typeof statefulAgent.setState === 'function') { + statefulAgent.setState({ + ...(statefulAgent.state ?? {}), + searchConversationIdsWithPreamble: [ + ...searchConversationIdsWithPreamble, + conversationId, + ], + }) + } const { payload: trimmedPayload, serialized } = applyMaxResponseSize( payload, maxResponseSize, @@ -578,6 +604,7 @@ export async function registerSearchTool(agent: McpRegistrationAgent) { matches: value.matches, warnings: value.warnings, baseUrl, + includePreamble, }), (value, count) => ({ ...value, diff --git a/packages/worker/src/mcp/tools/tool-call-context.ts b/packages/worker/src/mcp/tools/tool-call-context.ts index c39400ff32..81e3b50152 100644 --- a/packages/worker/src/mcp/tools/tool-call-context.ts +++ b/packages/worker/src/mcp/tools/tool-call-context.ts @@ -4,7 +4,7 @@ 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, and generated values should be short and random enough to avoid collisions. If omitted, Kody generates a short random value and returns it in `structuredContent.conversationId`.' + 'Optional short conversation identifier. Ties related calls together. On the first call, omit this to receive a server-generated ID, or supply your own. Pass the returned `conversationId` on every subsequent call in the same conversation - this enables optimizations like reduced response size. Generated values should be short and random enough to avoid collisions.' const memoryContextDescription = 'Optional short, structured task context for future memory-aware behavior. Keep it brief and factual rather than hidden reasoning.' diff --git a/packages/worker/src/mcp/ui-artifact-reindex.ts b/packages/worker/src/mcp/ui-artifact-reindex.ts index 621d997742..11361adfec 100644 --- a/packages/worker/src/mcp/ui-artifact-reindex.ts +++ b/packages/worker/src/mcp/ui-artifact-reindex.ts @@ -5,7 +5,10 @@ import { } from '#mcp/capabilities/capability-search.ts' import { buildUiArtifactEmbedText } from '#mcp/ui-artifacts-embed.ts' import { parseUiArtifactParameters } from '#mcp/ui-artifact-parameters.ts' -import { listUiArtifactsByUserId, uiArtifactVectorId } from '#mcp/ui-artifacts-repo.ts' +import { + listUiArtifactsByUserId, + uiArtifactVectorId, +} from '#mcp/ui-artifacts-repo.ts' import { listAppSecretsByAppIds } from '#mcp/secrets/service.ts' const upsertBatchSize = 16 @@ -41,7 +44,10 @@ export async function reindexUiArtifactVectors(env: Env): Promise<{ return { upserted: 0 } } - const appSecretsByAppId = new Map>() + const appSecretsByAppId = new Map< + string, + Array<{ name: string; description: string }> + >() const rowsByUser = new Map>() for (const row of rows) { const current = rowsByUser.get(row.user_id) ?? [] diff --git a/packages/worker/src/mcp/ui-artifacts-repo.ts b/packages/worker/src/mcp/ui-artifacts-repo.ts index c872a649e8..f45e3d3245 100644 --- a/packages/worker/src/mcp/ui-artifacts-repo.ts +++ b/packages/worker/src/mcp/ui-artifacts-repo.ts @@ -93,12 +93,7 @@ export async function updateUiArtifact( updates: Partial< Pick< UiArtifactRow, - | 'title' - | 'description' - | 'code' - | 'runtime' - | 'parameters' - | 'hidden' + 'title' | 'description' | 'code' | 'runtime' | 'parameters' | 'hidden' > >, ): Promise { @@ -153,10 +148,7 @@ export async function listUiArtifactsByUserId( WHERE user_id = ? ${hidden === undefined ? '' : 'AND hidden = ?'}`, ) - .bind( - userId, - ...(hidden === undefined ? [] : [hidden ? 1 : 0]), - ) + .bind(userId, ...(hidden === undefined ? [] : [hidden ? 1 : 0])) .all>() return (results ?? []).map(mapRow) } @@ -171,9 +163,7 @@ function mapRow(row: Record): UiArtifactRow { runtime: String(row['source_type']) as UiArtifactRow['runtime'], parameters: row['parameters'] == null ? null : String(row['parameters']), hidden: - row['hidden'] === 1 || - row['hidden'] === '1' || - row['hidden'] === true, + row['hidden'] === 1 || row['hidden'] === '1' || row['hidden'] === true, created_at: String(row['created_at']), updated_at: String(row['updated_at']), } diff --git a/tools/mcp-test-support.ts b/tools/mcp-test-support.ts index e57ae41710..7fac4a813e 100644 --- a/tools/mcp-test-support.ts +++ b/tools/mcp-test-support.ts @@ -36,6 +36,7 @@ type TestCallToolParams = CallToolRequest['params'] & { type ConnectedTestClient = { callTool(params: TestCallToolParams): ReturnType + listTools(): ReturnType } export async function createTestDatabase() { @@ -144,6 +145,9 @@ export async function createMcpClient( }) const client: ConnectedTestClient = { + listTools() { + return defaultConnection.client.listTools() + }, async callTool(params) { const { headers, ...callToolParams } = params if (!headers || Object.keys(headers).length === 0) {