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
5 changes: 2 additions & 3 deletions docs/agents/mcp-apps-spec-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
40 changes: 20 additions & 20 deletions docs/agents/mcp-skills.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,41 +15,41 @@ 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
argument when present). Missing required parameters or unknown names are
rejected; defaults are applied when provided.

Example:
`meta_run_skill({ "skill_id": "<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:

Expand All @@ -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: "<slug>" })` to narrow
Expand All @@ -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_<uuid>` 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.
12 changes: 6 additions & 6 deletions docs/setup-manifest.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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
Expand Down
7 changes: 3 additions & 4 deletions packages/mock-servers/cloudflare/src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
},
Expand Down
3 changes: 2 additions & 1 deletion packages/worker/client/routes/connect-secret.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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}
/>
Expand Down
113 changes: 113 additions & 0 deletions packages/worker/migrations/0014-skill-names.sql
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +56 to +69

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Critical: Backfill formula doesn't match normalizeSkillName, causing lookup failures for skills with special characters.

The SQL backfill uses lower(replace(trim(title), ' ', '-')), but the application's normalizeSkillName (in skill-names.ts) applies significantly more transformations:

  • NFKD normalization and diacritic stripping
  • Converts ALL non-alphanumeric characters (not just spaces) to hyphens
  • Collapses consecutive hyphens and trims edge hyphens

For any skill with accents, punctuation, or special characters, the backfilled name will not match what normalizeSkillName produces, causing lookup failures in meta_run_skill, meta_get_skill, and meta_delete_skill.

Example:

  • Title: "Résumé Builder!"
  • SQL backfill: "résumé-builder!"
  • normalizeSkillName: "resume-builder"
Proposed fix: Use a backfill that mimics normalizeSkillName more closely

SQLite lacks full Unicode normalization, so consider a two-phase approach:

Option 1: Fix in migration with closer approximation (won't handle all edge cases but handles common ones):

 UPDATE mcp_skills
-SET name = lower(replace(trim(title), ' ', '-'))
+SET name = lower(
+  replace(
+    replace(
+      replace(
+        replace(trim(title), ' ', '-'),
+        '_', '-'
+      ),
+      '.', '-'
+    ),
+    '!', ''
+  )
+)
 WHERE name IS NULL;

Option 2 (recommended): Run a TypeScript migration script post-deploy that re-normalizes all skill names using the actual normalizeSkillName function to ensure consistency.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/migrations/0014-skill-names.sql` around lines 3 - 5, The SQL
backfill in 0014-skill-names.sql uses a simplistic lower/replace formula that
differs from the application's normalizeSkillName (in skill-names.ts), causing
lookup mismatches (e.g., meta_run_skill/meta_get_skill/meta_delete_skill); fix
by creating a post-deploy TypeScript migration that imports normalizeSkillName
from skill-names.ts, selects rows from mcp_skills where name IS NULL (or where
normalizeSkillName(title) != name), computes normalized =
normalizeSkillName(title) for each row and updates the name column to that
normalized value, and ensure the migration runs as part of deployment so DB
names exactly match the app normalization logic.

Comment thread
cursor[bot] marked this conversation as resolved.
Comment thread
cursor[bot] marked this conversation as resolved.

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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,4 +213,3 @@ export async function fetchNegotiatedThenMaybeBrowserRender(
},
}
}

4 changes: 1 addition & 3 deletions packages/worker/src/mcp/capabilities/meta/domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
22 changes: 15 additions & 7 deletions packages/worker/src/mcp/capabilities/meta/meta-delete-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Comment on lines +5 to +8

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Delete should use exact-name resolution only.

getMcpSkillByNameInput falls back to matching normalized titles when no name matches. That is fine for read-style flows, but here it makes a destructive operation accept aliases/titles even though the input contract says name is the unique public identifier. Resolve with getMcpSkillByName instead.

🔒 Safer lookup for delete
 import {
 	deleteMcpSkill,
-	getMcpSkillByNameInput,
+	getMcpSkillByName,
 } from '#mcp/skills/mcp-skills-repo.ts'
@@
-			const existing = await getMcpSkillByNameInput(
+			const existing = await getMcpSkillByName(
 				ctx.env.APP_DB,
 				user.userId,
 				args.name,
 			)

Also applies to: 31-35

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/worker/src/mcp/capabilities/meta/meta-delete-skill.ts` around lines
5 - 8, The delete flow currently uses getMcpSkillByNameInput which allows
fallback to normalized-title matching and can resolve aliases; change it to use
the exact-match resolver getMcpSkillByName so deletion only accepts the unique
public identifier; update the lookup call(s) in meta-delete-skill.ts (replace
getMcpSkillByNameInput with getMcpSkillByName) and keep calling deleteMcpSkill
with the resulting exact-match record to ensure deletes never run on
alias/normalized-title matches.

import { deleteSkillVector } from '#mcp/skills/skill-vectorize.ts'
import { requireMcpUser } from './require-user.ts'

Expand All @@ -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,
Comment thread
cursor[bot] marked this conversation as resolved.
)
if (removed) {
await deleteSkillVector(ctx.env, args.skill_id)
await deleteSkillVector(ctx.env, existing.id)
}
return { deleted: removed }
},
Expand Down
15 changes: 6 additions & 9 deletions packages/worker/src/mcp/capabilities/meta/meta-get-skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -21,7 +21,7 @@ function parseStringArray(raw: string | null): Array<string> | null {
}

const outputSchema = z.object({
skill_id: z.string(),
name: z.string(),
title: z.string(),
description: z.string(),
collection: z.string().nullable(),
Expand Down Expand Up @@ -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,
Comment thread
cursor[bot] marked this conversation as resolved.
)
if (!row) {
throw new Error('Skill not found for this user.')
Expand All @@ -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,
Expand Down
Loading
Loading