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

Add a first-class `cursor` compile target. The standalone Cursor artifact
carries the `.cursor-plugin/plugin.json` manifest with explicit document
pointers, Cursor's auto-discovered typeless `mcp.json`, and shared skills,
scripts, and assets, all validated against the pinned Cursor schemas. The
unified `plugin` bundle now shares one Cursor lowering with the new adapter,
and the target MCP runtime reads shape-discriminated server documents.
14 changes: 10 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# agent-bundle

`agent-bundle` compiles one Agent Bundle project into portable, Codex, and Claude Code artifacts. It discovers skills, validates a typed configuration, bundles local JavaScript/TypeScript entry points, and writes the host-specific metadata needed by each selected target.
`agent-bundle` compiles one Agent Bundle project into portable, Codex, Claude Code, and Cursor artifacts. It discovers skills, validates a typed configuration, bundles local JavaScript/TypeScript entry points, and writes the host-specific metadata needed by each selected target.

It requires Node.js 22.19 or later.

Expand Down Expand Up @@ -38,7 +38,8 @@ shared `skills/`, `hooks/`, `mcp/`, `scripts/`, and `assets/` directories,
plus a generated `AGENTS.md` install matrix. Hooks compile once into
host-detecting wrappers that serve Claude Code and Codex; Cursor consumes the
skills and MCP servers. Per-host artifacts remain available as `claude`,
`codex`, and `portable` targets when a host-specific layout is required.
`codex`, `cursor`, and `portable` targets when a host-specific layout is
required.

## Install and build

Expand Down Expand Up @@ -145,7 +146,7 @@ export default defineConfig({
version: '1.0.0',
description: 'Review helpers for an agent host.',
},
targets: ['portable', 'codex', 'claude'], // or ['plugin'] for the unified multi-host bundle
targets: ['portable', 'codex', 'claude', 'cursor'], // or ['plugin'] for the unified multi-host bundle
skills: ['skills/*'],
scripts: {
report: './src/report.ts',
Expand Down Expand Up @@ -219,11 +220,16 @@ artifact/
.mcp.json
scripts/<name>.mjs
hooks/<name>.mjs
cursor/
.cursor-plugin/plugin.json
mcp.json
scripts/<name>.mjs
skills/<skill>/...
```

`agent-bundle.manifest.json` records each emitted file's path, byte length, and SHA-256 digest. This allows `validate --artifact` and artifact operations to run after the source project is no longer present.

Portable artifacts contain portable plugin, skills, MCP, and App-resource files. Codex and Claude artifacts contain their respective native metadata and generated hook wrappers. Terminal hosts can use normal MCP tools and resources; visual rendering of an MCP App depends on the host supporting the standard resource metadata.
Portable artifacts contain portable plugin, skills, MCP, and App-resource files. Codex and Claude artifacts contain their respective native metadata and generated hook wrappers. Cursor artifacts contain the `.cursor-plugin/plugin.json` manifest, the auto-discovered `mcp.json` (Cursor's typeless server format), and shared skills, scripts, and assets; hooks stay Claude/Codex-only until Cursor's hook stdin contract is pinned. Terminal hosts can use normal MCP tools and resources; visual rendering of an MCP App depends on the host supporting the standard resource metadata.

## Public examples

Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# agent-bundle

Compile a typed Agent Bundle configuration into portable, Codex, and Claude Code artifacts. Node.js 22.19 or later is required.
Compile a typed Agent Bundle configuration into portable, Codex, Claude Code, and Cursor artifacts. Node.js 22.19 or later is required.

```sh
npm install --save-dev agent-bundle
Expand Down
2 changes: 1 addition & 1 deletion packages/agent-bundle/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "agent-bundle",
"version": "0.1.0",
"description": "Compile a typed Agent Bundle configuration into portable, Codex, and Claude Code artifacts.",
"description": "Compile a typed Agent Bundle configuration into portable, Codex, Claude Code, and Cursor artifacts.",
"keywords": [
"agent",
"claude-code",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"host": "cursor",
"mcp": {
"pathTokens": {
"args": [
"${CURSOR_PLUGIN_ROOT}",
"${workspaceFolder}"
],
"env": [
"${CURSOR_PLUGIN_ROOT}",
"${workspaceFolder}"
],
"headers": [
"${CURSOR_PLUGIN_ROOT}",
"${workspaceFolder}"
],
"url": [
"${CURSOR_PLUGIN_ROOT}",
"${workspaceFolder}"
]
},
"stdio": true,
"streamableHttp": true
},
"observedCliVersion": "2026-08-28",
"plugin": {
"manifest": ".cursor-plugin/plugin.json",
"skills": true
}
}
273 changes: 273 additions & 0 deletions packages/agent-bundle/src/adapters/cursor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,273 @@
import { createTargetDiagnostics } from './diagnostics.ts';
import type { Diagnostic } from '../core/diagnostics.ts';
import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts';
import { isPlainDataRecord, ownDataValue } from '../core/strict-json.ts';
import {
pathTokens,
type NormalizedMcpServer,
type NormalizedPlugin,
} from '../core/types.ts';
import {
allMcpPathTokenFields,
createMcpPathTokenResolver,
standardMcpPathTokens,
} from '../services/mcp-path-tokens.ts';
import { createTargetMcpRuntime } from '../services/mcp-runtime.ts';
import capabilityTable from './capabilities/cursor-2026-08-28.json' with { type: 'json' };
import schemaProvenance from './schemas/cursor/PROVENANCE.json' with { type: 'json' };
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,
schemaDescriptorsFrom,
standardArtifactLayout,
standardPluginArtifactPlan,
validateJsonSchemaDocument,
validateModernMcpDocument,
type TargetAdapter,
type TargetArtifactPlan,
} from './types.ts';

const cursorName = 'cursor';

/**
* Cursor's conventional artifact document paths, shared with the unified
* bundle adapter. Cursor auto-discovers `mcp.json` at the plugin root (never
* the Claude-convention `.mcp.json`); the manifest still carries an explicit
* pointer so relocations stay impossible to configure apart. Hooks are not
* emitted by any target until Cursor's hook stdin contract is pinned; the
* hooks document is declared only so a hand-authored one validates against
* the pinned schema.
*/
export const cursorArtifactPaths = Object.freeze({
hooks: 'hooks/hooks.json',
mcp: 'mcp.json',
plugin: '.cursor-plugin/plugin.json',
});

const validator = createAdapterValidator();
const validatePlugin = validator.compile(pluginSchema);
const validateMcp = validator.compile(mcpSchema);
const validateHooks = validator.compile(hooksSchema);

/** The pinned Cursor document validators, shared with the unified bundle adapter. */
export const cursorPluginValidator = validatePlugin;
export const cursorMcpValidator = validateMcp;
export const cursorHooksValidator = validateHooks;

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

/** True when a plugin name satisfies Cursor's lowercase kebab-case contract. */
export const isValidCursorPluginName = (name: string): boolean =>
cursorNamePattern.test(name) && name.length <= 64;

/** The Cursor hooks document every target emits until the hook stdin contract is pinned. */
export const emptyCursorHooksDocument = Object.freeze({ hooks: {}, version: 1 });

/**
* Cursor documents `${env:NAME}` / `${workspaceFolder}` interpolation and
* `${CURSOR_PLUGIN_ROOT}` for hook commands; the same root variable is the
* best-documented spelling for plugin-contained MCP entry paths.
*/
export const expandCursorToken = (value: string): string => value
.replaceAll(pathTokens.pluginRoot, '${CURSOR_PLUGIN_ROOT}')
.replaceAll(pathTokens.workspaceRoot, '${workspaceFolder}');

export interface CursorMcpServerPlan {
readonly diagnostics: readonly Diagnostic[];
readonly value?: Record<string, unknown>;
}

export interface CursorMcpServerPlanContext {
/** Diagnostic code prefix, e.g. `cursor` or the bundle's `plugin.cursor`. */
readonly codePrefix: string;
readonly errorDiagnostic: (code: string, message: string) => Diagnostic;
}

/** Lowers one normalized MCP server into Cursor's typeless document shape. */
export const planCursorMcpServer = (
server: NormalizedMcpServer,
{ codePrefix, errorDiagnostic }: CursorMcpServerPlanContext,
): CursorMcpServerPlan => {
const transport = readMcpTransport(server);
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 ?? {})];
if (values.some((value) => value !== undefined && value.includes(pathTokens.pluginData))) {
return {
diagnostics: [errorDiagnostic(
`${codePrefix}.mcp.token`,
`MCP server ${JSON.stringify(server.name)} uses a plugin-data path token with no documented Cursor equivalent.`,
)],
};
}
if (transport === 'stdio') {
if (server.command === undefined) {
return {
diagnostics: [errorDiagnostic(`${codePrefix}.mcp.command`, `MCP server ${JSON.stringify(server.name)} requires a command.`)],
};
}
const args = server.args?.map(expandCursorToken);
if (server.source !== undefined && server.cwd === pathTokens.pluginRoot && args?.[0] !== undefined) {
args[0] = `\${CURSOR_PLUGIN_ROOT}/${args[0]}`;
}
const env = server.env === undefined
? undefined
: Object.fromEntries(Object.entries(server.env).map(([key, value]) => [key, expandCursorToken(value)]));
return {
diagnostics: [],
value: {
...(args === undefined ? {} : { args }),
command: expandCursorToken(server.command),
...(env === undefined ? {} : { env }),
},
Comment on lines +120 to +124

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reject Cursor stdio working directories instead of dropping them

When a Cursor-scoped command server specifies cwd, this branch emits only args, command, and env, silently discarding the validated working directory. Such a server will run from Cursor's default directory, so relative arguments or files that depended on the configured cwd can fail or reference the wrong location. Because the pinned Cursor document shape cannot represent cwd, the adapter should diagnose unsupported non-entry cwd values rather than compiling a behaviorally different server.

Useful? React with 👍 / 👎.

};
}
if (server.url === undefined) {
return {
diagnostics: [errorDiagnostic(`${codePrefix}.mcp.url`, `MCP server ${JSON.stringify(server.name)} requires a URL.`)],
};
}
const headers = server.headers === undefined
? undefined
: Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, expandCursorToken(value)]));
return {
diagnostics: [],
value: {
...(headers === undefined ? {} : { headers }),
url: expandCursorToken(server.url),
},
};
};

export interface CursorManifestPointers {
readonly hooks?: string;
readonly mcp?: string;
readonly skills?: string;
}

/** Builds the `.cursor-plugin/plugin.json` manifest with explicit document pointers. */
export const cursorManifest = (
model: NormalizedPlugin,
pointers: CursorManifestPointers,
): Record<string, unknown> => ({
description: model.metadata.description ?? model.metadata.name,
displayName: model.metadata.name,
...(pointers.hooks === undefined ? {} : { hooks: pointers.hooks }),
...(pointers.mcp === undefined ? {} : { mcpServers: pointers.mcp }),
name: model.metadata.name,
...(pointers.skills === undefined ? {} : { skills: pointers.skills }),
version: model.metadata.version,
});

const metadata = Object.freeze({
adapterRevision: '1.0.0',
capabilityRevision: capabilityTable.observedCliVersion,
capabilitySha256: 'c9e916ce4caf1865f57078765c27f47a2d225796ac36c1b65ccadf6a5290c86e',
observedVersion: capabilityTable.observedCliVersion,
schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion),
});

const artifactValidation = Object.freeze({
documents: Object.freeze([
Object.freeze({ path: cursorArtifactPaths.hooks, required: false, schema: 'hooks' }),
Object.freeze({ path: cursorArtifactPaths.mcp, required: false, schema: 'mcp' }),
Object.freeze({ path: cursorArtifactPaths.plugin, required: true, schema: 'plugin' }),
]),
schemas: Object.freeze([
Object.freeze({ name: 'hooks', validate: validateJsonSchemaDocument(validateHooks) }),
Object.freeze({ name: 'mcp', validate: validateModernMcpDocument(validateJsonSchemaDocument(validateMcp)) }),
Object.freeze({ name: 'plugin', validate: validateJsonSchemaDocument(validatePlugin) }),
]),
});

/**
* Cursor's MCP document is shape-discriminated: stdio entries declare a
* `command` and remote entries declare a `url`; the format has no `type`
* field. A record declaring both (or neither) has no defined transport.
*/
const cursorServerType = (server: unknown): string | undefined => {
if (!isPlainDataRecord(server)) return undefined;
const command = ownDataValue(server, 'command');
const url = ownDataValue(server, 'url');
if (command === undefined || url === undefined) return undefined;
if (command.found === url.found) return undefined;
if (command.found) return typeof command.value === 'string' ? 'stdio' : undefined;
return typeof url.value === 'string' ? 'streamable-http' : undefined;
};

const mcpRuntime = createTargetMcpRuntime({
manifestPath: cursorArtifactPaths.mcp,
readServerType: cursorServerType,
remoteTypes: ['streamable-http'],
resolveValue: createMcpPathTokenResolver({
knownTokens: Object.freeze([...standardMcpPathTokens, '${CURSOR_PLUGIN_ROOT}', '${workspaceFolder}']),
target: cursorName,
tokens: allMcpPathTokenFields(Object.freeze({
'${CURSOR_PLUGIN_ROOT}': 'pluginRoot',
'${workspaceFolder}': 'workspaceRoot',
})),
}),
});

const { errorDiagnostic, schemaDiagnostics } = createTargetDiagnostics(cursorName, 'Cursor');

const mcpPlanContext: CursorMcpServerPlanContext = Object.freeze({ codePrefix: cursorName, errorDiagnostic });

export const planCursorArtifacts = (model: NormalizedPlugin): TargetArtifactPlan => {
const isSelected = (targets: readonly string[]): boolean => targets.includes(cursorName);
const diagnostics: Diagnostic[] = [];
const servers: Record<string, Record<string, unknown>> = Object.create(null) as Record<string, Record<string, unknown>>;
for (const server of model.mcpServers) {
if (!isSelected(server.targets)) continue;
const serverPlan = planCursorMcpServer(server, mcpPlanContext);
diagnostics.push(...serverPlan.diagnostics);
if (serverPlan.value !== undefined) servers[server.name] = serverPlan.value;
}
const mcp = Object.keys(servers).length === 0 ? undefined : { mcpServers: servers };
const mcpValid = mcp !== undefined && validateMcp(mcp);
if (mcp !== undefined) diagnostics.push(...schemaDiagnostics('mcp', mcpValid, validateMcp.errors));

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

return standardPluginArtifactPlan({
diagnostics,
hookDocumentValid: false,
hookEntries: [],
hookManifestPath: cursorArtifactPaths.hooks,
isSelected,
marketplaceRelativePath: '.cursor-plugin/marketplace.json',
marketplaceValid: false,
mcp,
mcpRelativePath: cursorArtifactPaths.mcp,
mcpValid,
model,
plugin,
pluginRelativePath: cursorArtifactPaths.plugin,
targetName: cursorName,
});
};

export const cursorAdapter: TargetAdapter = Object.freeze({
artifactValidation,
artifactLayout: Object.freeze({
assets: standardArtifactLayout.assets,
mcpApps: standardArtifactLayout.mcpApps,
mcpEntries: standardArtifactLayout.mcpEntries,
scripts: standardArtifactLayout.scripts,
skills: standardArtifactLayout.skills,
}),
capabilities: Object.freeze({
mcp: capabilityTable.mcp.stdio && capabilityTable.mcp.streamableHttp,
skills: capabilityTable.plugin.skills,
}),
metadata,
mcpRuntime,
name: cursorName,
plan: planCursorArtifacts,
});
Loading
Loading