From 386375af06bc3b8cb672e7ec55e75d0f0053f536 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 28 Aug 2026 08:01:45 +0000 Subject: [PATCH 1/2] feat(adapters): 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, validated against the already-pinned Cursor schemas. The Cursor lowering that lived inline in the unified plugin bundle moves to the new adapter module so both targets share one contract, and the target MCP runtime gains a shape-discriminated server reader because Cursor's document format has no type field. Hooks stay Claude/Codex-only until Cursor's hook stdin contract is pinned, so the adapter declares no hook capability and the pinned hooks schema only validates hand-authored documents. --- .changeset/cursor-host-adapter.md | 10 + README.md | 14 +- packages/agent-bundle/README.md | 2 +- packages/agent-bundle/package.json | 2 +- .../capabilities/cursor-2026-08-28.json | 30 ++ packages/agent-bundle/src/adapters/cursor.ts | 273 ++++++++++++++++++ packages/agent-bundle/src/adapters/plugin.ts | 129 ++------- .../agent-bundle/src/adapters/registry.ts | 2 + .../agent-bundle/src/services/mcp-runtime.ts | 47 +-- .../tests/adapter-metadata.test.ts | 24 ++ .../agent-bundle/tests/cursor-adapter.test.ts | 207 +++++++++++++ .../agent-bundle/tests/host-adapters.test.ts | 2 +- .../tests/portable-adapter.test.ts | 4 +- .../workbench/src/mcp/mcp-route-client.ts | 4 +- .../src/mcp/mcp-session-controller.ts | 2 +- 15 files changed, 620 insertions(+), 132 deletions(-) create mode 100644 .changeset/cursor-host-adapter.md create mode 100644 packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json create mode 100644 packages/agent-bundle/src/adapters/cursor.ts create mode 100644 packages/agent-bundle/tests/cursor-adapter.test.ts diff --git a/.changeset/cursor-host-adapter.md b/.changeset/cursor-host-adapter.md new file mode 100644 index 000000000..463c35680 --- /dev/null +++ b/.changeset/cursor-host-adapter.md @@ -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. diff --git a/README.md b/README.md index e157b8337..a5fdab894 100644 --- a/README.md +++ b/README.md @@ -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. @@ -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 @@ -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', @@ -219,11 +220,16 @@ artifact/ .mcp.json scripts/.mjs hooks/.mjs + cursor/ + .cursor-plugin/plugin.json + mcp.json + scripts/.mjs + skills//... ``` `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 diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 96f7360bc..d18510649 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -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 diff --git a/packages/agent-bundle/package.json b/packages/agent-bundle/package.json index a48cc50dd..91ef7eee1 100644 --- a/packages/agent-bundle/package.json +++ b/packages/agent-bundle/package.json @@ -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", diff --git a/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json new file mode 100644 index 000000000..aba2cb4c8 --- /dev/null +++ b/packages/agent-bundle/src/adapters/capabilities/cursor-2026-08-28.json @@ -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 + } +} diff --git a/packages/agent-bundle/src/adapters/cursor.ts b/packages/agent-bundle/src/adapters/cursor.ts new file mode 100644 index 000000000..5be8f42ef --- /dev/null +++ b/packages/agent-bundle/src/adapters/cursor.ts @@ -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; +} + +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 }), + }, + }; + } + 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 => ({ + 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> = Object.create(null) as Record>; + 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, +}); diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index 035878c43..8b1ec09a8 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -1,5 +1,3 @@ -import type { ValidateFunction } from 'ajv/dist/2020.js'; - import { createTargetDiagnostics } from './diagnostics.ts'; import type { Diagnostic } from '../core/diagnostics.ts'; import { stableJson } from '../core/digest.ts'; @@ -10,16 +8,20 @@ import { standardMcpPathTokens, } from '../services/mcp-path-tokens.ts'; import { createTargetMcpRuntime } from '../services/mcp-runtime.ts'; -import { readMcpTransport, unsupportedMcpTransportDiagnostic } from '../core/mcp-transport.ts'; -import { pathTokens, type NormalizedMcpServer } from '../core/types.ts'; import claudeCapabilityTable from './capabilities/claude-2.1.250.json' with { type: 'json' }; import codexCapabilityTable from './capabilities/codex-0.147.0.json' with { type: 'json' }; import { claudeAdapter, claudeArtifactPaths, claudeHooksValidator, planClaudeArtifacts } from './claude.ts'; import { codexAdapter, codexArtifactPaths, codexPluginDocumentValidator, planCodexArtifacts } from './codex.ts'; -import cursorSchemaProvenance from './schemas/cursor/PROVENANCE.json' with { type: 'json' }; -import cursorHooksSchema from './schemas/cursor/hooks.schema.json' with { type: 'json' }; -import cursorMcpSchema from './schemas/cursor/mcp.schema.json' with { type: 'json' }; -import cursorPluginSchema from './schemas/cursor/plugin.schema.json' with { type: 'json' }; +import { + cursorAdapter, + cursorHooksValidator, + cursorManifest, + cursorMcpValidator, + cursorPluginValidator, + emptyCursorHooksDocument, + isValidCursorPluginName, + planCursorMcpServer, +} from './cursor.ts'; import { encodeNativeHookPlaygroundInput, encodeNativeHookPlaygroundOutput, @@ -29,8 +31,6 @@ import { type TargetHookContract, } from './hook-contract.ts'; import { - createAdapterValidator, - schemaDescriptorsFrom, sortedEntries, sourceInputs, standardArtifactLayout, @@ -78,18 +78,6 @@ const cursorPaths = Object.freeze({ mcp: '.cursor-plugin/mcp.json', plugin: '.cursor-plugin/plugin.json', }); -const cursorNamePattern = /^[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$/u; - -// Compiled lazily: the bundle target pays for its own validators only when a -// plan or artifact validation actually runs. -const cursorValidator = createAdapterValidator(); -const lazyCompiled = (schema: object): (() => ValidateFunction) => { - let compiled: ValidateFunction | undefined; - return () => compiled ??= cursorValidator.compile(schema); -}; -const validateCursorPlugin = lazyCompiled(cursorPluginSchema); -const validateCursorMcp = lazyCompiled(cursorMcpSchema); -const validateCursorHooks = lazyCompiled(cursorHooksSchema); /** * Union matcher table for the shared hook document. Codex documents Edit and @@ -163,9 +151,9 @@ const artifactValidation = Object.freeze({ // The bundle's Codex manifest points at the relocated MCP document, so its // validator widens the pinned pointer to that one relocation. Object.freeze({ name: 'codex-plugin', validate: (document: unknown) => codexPluginDocumentValidator(codexBundleMcpPath)(document) }), - Object.freeze({ name: 'cursor-hooks', validate: (document: unknown) => validateJsonSchemaDocument(validateCursorHooks())(document) }), - Object.freeze({ name: 'cursor-mcp', validate: (document: unknown) => validateJsonSchemaDocument(validateCursorMcp())(document) }), - Object.freeze({ name: 'cursor-plugin', validate: (document: unknown) => validateJsonSchemaDocument(validateCursorPlugin())(document) }), + Object.freeze({ name: 'cursor-hooks', validate: validateJsonSchemaDocument(cursorHooksValidator) }), + Object.freeze({ name: 'cursor-mcp', validate: validateJsonSchemaDocument(cursorMcpValidator) }), + Object.freeze({ name: 'cursor-plugin', validate: validateJsonSchemaDocument(cursorPluginValidator) }), ]), }); @@ -180,7 +168,7 @@ const metadata = Object.freeze({ schemas: Object.freeze([ ...prefixedSchemas('claude', claudeAdapter.metadata.schemas), ...prefixedSchemas('codex', codexAdapter.metadata.schemas, 'hooks'), - ...prefixedSchemas('cursor', schemaDescriptorsFrom(cursorSchemaProvenance, cursorSchemaProvenance.observedCliVersion)), + ...prefixedSchemas('cursor', cursorAdapter.metadata.schemas), ]), }); @@ -278,65 +266,7 @@ const mergeEntries = ( return [...merged.values()]; }; -/** - * 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. - */ -const expandCursorToken = (value: string): string => value - .replaceAll(pathTokens.pluginRoot, '${CURSOR_PLUGIN_ROOT}') - .replaceAll(pathTokens.workspaceRoot, '${workspaceFolder}'); - -const planCursorMcpServer = ( - server: NormalizedMcpServer, - diagnostics: Diagnostic[], -): Record | undefined => { - const transport = readMcpTransport(server); - const transportDiagnostic = unsupportedMcpTransportDiagnostic(server, transport); - if (transportDiagnostic !== undefined) { - diagnostics.push(transportDiagnostic); - return undefined; - } - 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))) { - diagnostics.push(errorDiagnostic( - 'plugin.cursor.mcp.token', - `MCP server ${JSON.stringify(server.name)} uses a plugin-data path token with no documented Cursor equivalent.`, - )); - return undefined; - } - if (transport === 'stdio') { - if (server.command === undefined) { - diagnostics.push(errorDiagnostic('plugin.cursor.mcp.command', `MCP server ${JSON.stringify(server.name)} requires a command.`)); - return undefined; - } - 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 { - ...(args === undefined ? {} : { args }), - command: expandCursorToken(server.command), - ...(env === undefined ? {} : { env }), - }; - } - if (server.url === undefined) { - diagnostics.push(errorDiagnostic('plugin.cursor.mcp.url', `MCP server ${JSON.stringify(server.name)} requires a URL.`)); - return undefined; - } - const headers = server.headers === undefined - ? undefined - : Object.fromEntries(Object.entries(server.headers).map(([key, value]) => [key, expandCursorToken(value)])); - return { - ...(headers === undefined ? {} : { headers }), - url: expandCursorToken(server.url), - }; -}; - -const emptyCursorHooksDocument = Object.freeze({ hooks: {}, version: 1 }); +const cursorMcpPlanContext = Object.freeze({ codePrefix: 'plugin.cursor', errorDiagnostic }); const plan = (model: NormalizedPlugin): TargetArtifactPlan => { const diagnostics: Diagnostic[] = []; @@ -378,37 +308,34 @@ const plan = (model: NormalizedPlugin): TargetArtifactPlan => { const mcpSourceInputs: string[] = []; for (const server of model.mcpServers) { if (!server.targets.includes(pluginName)) continue; - const lowered = planCursorMcpServer(server, diagnostics); - if (lowered !== undefined) { - cursorServers[server.name] = lowered; + const serverPlan = planCursorMcpServer(server, cursorMcpPlanContext); + diagnostics.push(...serverPlan.diagnostics); + if (serverPlan.value !== undefined) { + cursorServers[server.name] = serverPlan.value; mcpSourceInputs.push(server.provenance.sourcePath); } } const cursorMcp = Object.keys(cursorServers).length === 0 ? undefined : { mcpServers: cursorServers }; - const cursorMcpValid = cursorMcp !== undefined && validateCursorMcp()(cursorMcp); - if (cursorMcp !== undefined) diagnostics.push(...schemaDiagnostics('cursor-mcp', cursorMcpValid, validateCursorMcp().errors)); + const cursorMcpValid = cursorMcp !== undefined && cursorMcpValidator(cursorMcp); + if (cursorMcp !== undefined) diagnostics.push(...schemaDiagnostics('cursor-mcp', cursorMcpValid, cursorMcpValidator.errors)); - if (!cursorNamePattern.test(model.metadata.name) || model.metadata.name.length > 64) { + if (!isValidCursorPluginName(model.metadata.name)) { diagnostics.push(errorDiagnostic( 'plugin.cursor.name', `Plugin name ${JSON.stringify(model.metadata.name)} is not a valid Cursor plugin name (lowercase kebab-case).`, )); } else { const emitCursorHooks = hookDocument !== undefined && hookDocumentValid; - const cursorManifest = { - description: model.metadata.description ?? model.metadata.name, - displayName: model.metadata.name, + const manifest = cursorManifest(model, { ...(emitCursorHooks ? { hooks: `./${cursorPaths.hooks}` } : {}), - ...(cursorMcp !== undefined && cursorMcpValid ? { mcpServers: `./${cursorPaths.mcp}` } : {}), - name: model.metadata.name, + ...(cursorMcp !== undefined && cursorMcpValid ? { mcp: `./${cursorPaths.mcp}` } : {}), ...(model.skills.some((skill) => skill.targets.includes(pluginName)) ? { skills: './skills/' } : {}), - version: model.metadata.version, - }; - const cursorManifestValid = validateCursorPlugin()(cursorManifest); - diagnostics.push(...schemaDiagnostics('cursor-plugin', cursorManifestValid, validateCursorPlugin().errors)); + }); + const cursorManifestValid = cursorPluginValidator(manifest); + diagnostics.push(...schemaDiagnostics('cursor-plugin', cursorManifestValid, cursorPluginValidator.errors)); if (cursorManifestValid) { entries.push({ - content: `${stableJson(cursorManifest)}\n`, + content: `${stableJson(manifest)}\n`, kind: 'write', relativePath: cursorPaths.plugin, sourceInputs: sourceInputs(model.metadata.provenance.sourcePath, ...targetSourceInputs), diff --git a/packages/agent-bundle/src/adapters/registry.ts b/packages/agent-bundle/src/adapters/registry.ts index c28e9ea2f..38bad16ad 100644 --- a/packages/agent-bundle/src/adapters/registry.ts +++ b/packages/agent-bundle/src/adapters/registry.ts @@ -7,6 +7,7 @@ import type { } from '../core/types.ts'; import { claudeAdapter } from './claude.ts'; import { codexAdapter } from './codex.ts'; +import { cursorAdapter } from './cursor.ts'; import { readStandardNativeHookCommands, type TargetHookContract } from './hook-contract.ts'; import { portableAdapter } from './portable.ts'; import { pluginAdapter } from './plugin.ts'; @@ -494,4 +495,5 @@ export const createDefaultRegistry = (): TargetRegistry => .register(portableAdapter, { default: true }) .register(codexAdapter) .register(claudeAdapter) + .register(cursorAdapter) .register(pluginAdapter); diff --git a/packages/agent-bundle/src/services/mcp-runtime.ts b/packages/agent-bundle/src/services/mcp-runtime.ts index 9629cbf7c..86392c8dc 100644 --- a/packages/agent-bundle/src/services/mcp-runtime.ts +++ b/packages/agent-bundle/src/services/mcp-runtime.ts @@ -60,6 +60,13 @@ export interface TargetMcpRuntimeContract { interface CreateTargetMcpRuntimeOptions { readonly manifestPath: string; + /** + * Resolves a server record's transport type. Defaults to reading the + * record's `type` field; targets whose document format is + * shape-discriminated (for example Cursor's typeless entries) supply their + * own resolver. + */ + readonly readServerType?: (server: unknown) => string | undefined; readonly remoteTypes: readonly string[]; readonly validatedButNonModernRemoteTypes?: readonly string[]; readonly resolveStdioArgument?: TargetMcpRuntimeContract['resolveStdioArgument']; @@ -118,23 +125,13 @@ const stdioServer = (value: unknown): ModernMcpStdioServer | undefined => { }); }; -const streamableHttpServer = ( - value: unknown, - remoteTypes: ReadonlySet, -): ModernMcpStreamableHttpServer | undefined => { +const streamableHttpServer = (value: unknown): ModernMcpStreamableHttpServer | undefined => { if (!isPlainDataRecord(value)) return undefined; - const type = ownDataValue(value, 'type'); const headers = ownDataValue(value, 'headers'); const url = ownDataValue(value, 'url'); - if ( - type === undefined || headers === undefined || url === undefined || - !type.found || typeof type.value !== 'string' || !url.found || typeof url.value !== 'string' - ) return undefined; + if (headers === undefined || url === undefined || !url.found || typeof url.value !== 'string') return undefined; const copiedHeaders = headers.found && headers.value !== undefined ? stringRecord(headers.value) : undefined; - if ( - !remoteTypes.has(type.value) || - (headers.found && headers.value !== undefined && copiedHeaders === undefined) - ) return undefined; + if (headers.found && headers.value !== undefined && copiedHeaders === undefined) return undefined; return Object.freeze({ ...(copiedHeaders === undefined ? {} : { headers: copiedHeaders }), kind: 'streamable-http', @@ -142,10 +139,18 @@ const streamableHttpServer = ( }); }; +const typedServerType = (server: unknown): string | undefined => { + if (!isPlainDataRecord(server)) return undefined; + const type = ownDataValue(server, 'type'); + if (type === undefined || !type.found || typeof type.value !== 'string') return undefined; + return type.value; +}; + const readModernMcpServers = ( document: unknown, remoteTypes: ReadonlySet, validatedButNonModernRemoteTypes: ReadonlySet, + readServerType: (server: unknown) => string | undefined, ): ModernMcpServersReadResult => { if (!isPlainDataRecord(document)) return { status: 'invalid' }; const servers = ownDataValue(document, 'mcpServers'); @@ -155,13 +160,15 @@ const readModernMcpServers = ( for (const name of Object.keys(servers.value).sort((left, right) => left.localeCompare(right))) { const value = servers.value[name]; if (!isPlainDataRecord(value)) return { status: 'invalid' }; - const type = ownDataValue(value, 'type'); - if (type === undefined || !type.found || typeof type.value !== 'string') return { status: 'invalid' }; - const server = type.value === 'stdio' + const type = readServerType(value); + if (type === undefined) return { status: 'invalid' }; + const server = type === 'stdio' ? stdioServer(value) - : streamableHttpServer(value, validatedRemoteTypes); + : validatedRemoteTypes.has(type) + ? streamableHttpServer(value) + : undefined; if (server === undefined) return { status: 'invalid' }; - if (type.value !== 'stdio' && validatedButNonModernRemoteTypes.has(type.value)) continue; + if (type !== 'stdio' && validatedButNonModernRemoteTypes.has(type)) continue; entries.push(Object.freeze({ name, server })); } return Object.freeze({ servers: Object.freeze(entries), status: 'found' }); @@ -281,6 +288,7 @@ export const readTargetMcpServer = ( export const createTargetMcpRuntime = ({ manifestPath, + readServerType = typedServerType, remoteTypes, validatedButNonModernRemoteTypes = [], resolveStdioArgument = noRelativeArgumentResolution, @@ -290,7 +298,8 @@ export const createTargetMcpRuntime = ({ const nonModernRemoteTypes = new Set(validatedButNonModernRemoteTypes); return Object.freeze({ manifestPath, - readModernServers: (document: unknown) => readModernMcpServers(document, nativeRemoteTypes, nonModernRemoteTypes), + readModernServers: (document: unknown) => + readModernMcpServers(document, nativeRemoteTypes, nonModernRemoteTypes, readServerType), resolveStdioArgument, resolveValue, }); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 67edbb442..623b7d490 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -127,6 +127,29 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); + expect(registryMetadata(registry, 'cursor')).toEqual({ + adapterRevision: '1.0.0', + capabilityRevision: '2026-08-28', + capabilitySha256: 'c9e916ce4caf1865f57078765c27f47a2d225796ac36c1b65ccadf6a5290c86e', + observedVersion: '2026-08-28', + schemas: [ + { + name: 'hooks', + revision: '2026-08-28', + sha256: '106d76f79c8fa6600e09cd5bcf25ebf8d06015cde249c48c50fe8060d991e21d', + }, + { + name: 'mcp', + revision: '2026-08-28', + sha256: 'ba5379d4dd3f3d7ff291f2a82a9a04b96b4be7c8dd8c106808a186cad3610764', + }, + { + name: 'plugin', + revision: '2026-08-28', + sha256: 'ad5099d50f7f59913a5022b90acaf76e9c50e6d9c5058157a1eed55a842d9d61', + }, + ], + }); }); it('rehashes every declared capability and schema snapshot against its pinned provenance', async () => { @@ -135,6 +158,7 @@ it('rehashes every declared capability and schema snapshot against its pinned pr { capabilityFile: 'portable-1.0.0.json', provenanceFile: 'portable/PROVENANCE.json', target: 'portable', versionKey: 'version' }, { capabilityFile: 'codex-0.147.0.json', provenanceFile: 'codex/PROVENANCE.json', target: 'codex', versionKey: 'observedCliVersion' }, { capabilityFile: 'claude-2.1.250.json', provenanceFile: 'claude/PROVENANCE.json', target: 'claude', versionKey: 'observedCliVersion' }, + { capabilityFile: 'cursor-2026-08-28.json', provenanceFile: 'cursor/PROVENANCE.json', target: 'cursor', versionKey: 'observedCliVersion' }, ] as const; for (const { capabilityFile, provenanceFile, target, versionKey } of targets) { diff --git a/packages/agent-bundle/tests/cursor-adapter.test.ts b/packages/agent-bundle/tests/cursor-adapter.test.ts new file mode 100644 index 000000000..2835831a2 --- /dev/null +++ b/packages/agent-bundle/tests/cursor-adapter.test.ts @@ -0,0 +1,207 @@ +import { expect, it } from '@rstest/core'; + +import { createDefaultRegistry } from '../src/adapters/registry.ts'; +import { cursorAdapter } from '../src/adapters/cursor.ts'; +import { readTargetMcpServers } from '../src/services/mcp-runtime.ts'; +import { pathTokens, type NormalizedPlugin } from '../src/core/types.ts'; + +const configPath = '/workspace/agent-bundle.config.ts'; + +const plugin = (): NormalizedPlugin => ({ + extensions: {}, + hooks: [], + metadata: { + description: 'Review helpers for Cursor.', + id: 'plugin:cursor-review', + name: 'cursor-review', + provenance: { kind: 'config', sourcePath: configPath }, + version: '1.2.3', + }, + mcpServers: [ + { + args: ['--root', `${pathTokens.pluginRoot}/tools/server.mjs`], + command: 'node', + env: { CACHE_DIR: `${pathTokens.workspaceRoot}/cache` }, + id: 'mcp:status', + name: 'status', + provenance: { kind: 'config', sourcePath: configPath }, + targets: ['cursor'], + transport: 'stdio', + }, + { + headers: { Authorization: 'Bearer literal' }, + id: 'mcp:remote', + name: 'remote', + provenance: { kind: 'config', sourcePath: configPath }, + targets: ['cursor'], + transport: 'streamable-http', + url: 'https://mcp.example.test/stream', + }, + ], + runtime: { node: '22.12.0' }, + scripts: [], + skills: [ + { + body: '# Review\n', + description: 'Review code and explain findings.', + dir: '/workspace/skills/review', + frontmatter: { description: 'Review code and explain findings.', name: 'review' }, + id: 'skill:review', + name: 'review', + provenance: { kind: 'conventional', sourcePath: '/workspace/skills/review/SKILL.md' }, + resources: [ + { bytes: 9, relativePath: 'SKILL.md', source: '/workspace/skills/review/SKILL.md' }, + { bytes: 8, relativePath: 'references/guide.md', source: '/workspace/skills/review/references/guide.md' }, + ], + source: '/workspace/skills/review/SKILL.md', + targets: ['cursor'], + }, + ], + targets: [ + { id: 'target:cursor', name: 'cursor', provenance: { kind: 'config', sourcePath: configPath } }, + ], +}); + +const writeContents = (model: NormalizedPlugin): Record => Object.fromEntries( + cursorAdapter.plan(model).entries + .filter((entry): entry is Extract => entry.kind === 'write') + .map((entry) => [entry.relativePath, entry.content]), +); + +it('registers cursor as a first-class target with pinned schema validation', () => { + const registry = createDefaultRegistry(); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); + expect(registry.defaultTargetNames()).toEqual(['portable']); + expect(registry.supports('cursor', 'mcp')).toBe(true); + expect(registry.supports('cursor', 'skills')).toBe(true); + expect(registry.supports('cursor', 'hooks')).toBe(false); + expect(registry.hookContract('cursor')).toBeUndefined(); + expect(registry.artifactValidation('cursor').documents).toEqual([ + { path: '.cursor-plugin/plugin.json', required: true, schema: 'plugin' }, + { path: 'hooks/hooks.json', required: false, schema: 'hooks' }, + { path: 'mcp.json', required: false, schema: 'mcp' }, + ]); +}); + +it('plans a schema-valid Cursor artifact with typeless MCP entries and explicit manifest pointers', () => { + const model = plugin(); + const plan = cursorAdapter.plan(model); + expect(plan.diagnostics).toEqual([]); + expect(plan.hookEntries).toEqual([]); + + const documents = writeContents(model); + expect(Object.keys(documents).sort()).toEqual(['.cursor-plugin/plugin.json', 'mcp.json']); + + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).toEqual({ + description: 'Review helpers for Cursor.', + displayName: 'cursor-review', + mcpServers: './mcp.json', + name: 'cursor-review', + skills: './skills/', + version: '1.2.3', + }); + + const mcp = JSON.parse(documents['mcp.json']!) as { readonly mcpServers: Record> }; + expect(mcp.mcpServers['status']).toEqual({ + args: ['--root', '${CURSOR_PLUGIN_ROOT}/tools/server.mjs'], + command: 'node', + env: { CACHE_DIR: '${workspaceFolder}/cache' }, + }); + expect(mcp.mcpServers['remote']).toEqual({ + headers: { Authorization: 'Bearer literal' }, + url: 'https://mcp.example.test/stream', + }); + expect(mcp.mcpServers['status']).not.toHaveProperty('type'); + expect(mcp.mcpServers['remote']).not.toHaveProperty('type'); + + const skillCopies = plan.entries.filter((entry) => entry.kind === 'copy').map((entry) => entry.relativePath); + expect(skillCopies).toEqual(['skills/review/SKILL.md', 'skills/review/references/guide.md']); +}); + +it('rejects the plugin-data token and omits the failed server from the document', () => { + const model = plugin(); + const plan = cursorAdapter.plan({ + ...model, + mcpServers: [{ + args: [`${pathTokens.pluginData}/state.json`], + command: 'node', + id: 'mcp:data', + name: 'data', + provenance: { kind: 'config', sourcePath: configPath }, + targets: ['cursor'], + transport: 'stdio', + }], + }); + expect(plan.diagnostics).toEqual([ + expect.objectContaining({ code: 'cursor.mcp.token', severity: 'error', target: 'cursor' }), + ]); + const documents = plan.entries.filter((entry) => entry.kind === 'write').map((entry) => entry.relativePath); + expect(documents).toEqual(['.cursor-plugin/plugin.json']); + const manifest = JSON.parse( + (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, + ) as Record; + expect(manifest).not.toHaveProperty('mcpServers'); +}); + +it('never emits hooks and drops hooks scoped to other targets from the plan', () => { + const model = plugin(); + const plan = cursorAdapter.plan({ + ...model, + hooks: [{ + event: 'sessionStart', + id: 'hook:session-start', + name: 'session-start', + provenance: { kind: 'config', sourcePath: configPath }, + source: '/workspace/src/hooks/session-start.ts', + targets: ['claude'], + tools: [], + }], + marketplace: true, + }); + expect(plan.diagnostics).toEqual([]); + expect(plan.hookEntries).toEqual([]); + const paths = plan.entries.map((entry) => entry.relativePath); + expect(paths).not.toContain('hooks/hooks.json'); + expect(paths.some((path) => path.includes('marketplace'))).toBe(false); + const manifest = JSON.parse( + (plan.entries.find((entry) => entry.relativePath === '.cursor-plugin/plugin.json') as { readonly content: string }).content, + ) as Record; + expect(manifest).not.toHaveProperty('hooks'); +}); + +it('reads the emitted shape-discriminated document back through the target MCP runtime', () => { + const model = plugin(); + const document = JSON.parse(writeContents(model)['mcp.json']!) as unknown; + const runtime = cursorAdapter.mcpRuntime!; + expect(runtime.manifestPath).toBe('mcp.json'); + + const result = readTargetMcpServers(runtime, document); + expect(result.status).toBe('found'); + if (result.status !== 'found') throw new Error('unreachable'); + expect(result.servers.map((entry) => [entry.name, entry.server.kind])).toEqual([ + ['remote', 'streamable-http'], + ['status', 'stdio'], + ]); + + expect(readTargetMcpServers(runtime, { + mcpServers: { ambiguous: { command: 'node', url: 'https://mcp.example.test' } }, + })).toEqual({ status: 'invalid' }); + expect(readTargetMcpServers(runtime, { + mcpServers: { untyped: { headers: { Authorization: 'x' } } }, + })).toEqual({ status: 'invalid' }); +}); + +it('resolves Cursor path tokens and diagnoses foreign standard tokens at runtime', () => { + const runtime = cursorAdapter.mcpRuntime!; + const roots = { pluginData: '/data', pluginRoot: '/plugin', workspaceRoot: '/workspace' }; + + const resolved = runtime.resolveValue('args', roots, '${CURSOR_PLUGIN_ROOT}/tools/server.mjs'); + expect(resolved).toEqual({ diagnostics: [], value: '/plugin/tools/server.mjs' }); + const workspace = runtime.resolveValue('env', roots, '${workspaceFolder}/cache'); + expect(workspace).toEqual({ diagnostics: [], value: '/workspace/cache' }); + + const foreign = runtime.resolveValue('args', roots, '${CLAUDE_PLUGIN_ROOT}/tools/server.mjs'); + expect(foreign.diagnostics).toEqual([ + expect.objectContaining({ code: 'mcp.path-token.unsupported.args', severity: 'error', target: 'cursor' }), + ]); +}); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 6543bbb4a..c53c363cd 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -211,7 +211,7 @@ it.each(['codex', 'claude'] as const)('copies project assets selected for %s and it('plans byte-stable native Codex and Claude plugin trees from the same frozen model', async () => { const registry = createDefaultRegistry(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(Object.isFrozen(plugin)).toBe(true); diff --git a/packages/agent-bundle/tests/portable-adapter.test.ts b/packages/agent-bundle/tests/portable-adapter.test.ts index 3fdb7b332..8181b1986 100644 --- a/packages/agent-bundle/tests/portable-adapter.test.ts +++ b/packages/agent-bundle/tests/portable-adapter.test.ts @@ -87,7 +87,7 @@ it('plans a schema-valid skills-only plugin with every discovered resource', () const plan = adapter.plan(plugin()); expect(registry.defaultTargetNames()).toEqual(['portable']); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); expect(plan.diagnostics).toEqual([]); expect(plan.entries).toMatchObject([ { @@ -425,7 +425,7 @@ it('rejects duplicate adapters without exposing mutable registry snapshots', () expect(() => registry.register(portableAdapter)).toThrow('already registered'); expect(() => names.push('other')).toThrow(); expect(() => defaults.push('other')).toThrow(); - expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'plugin']); + expect(registry.names()).toEqual(['portable', 'codex', 'claude', 'cursor', 'plugin']); expect(registry.defaultTargetNames()).toEqual(['portable']); expect(Object.isFrozen(registry.get('portable').capabilities)).toBe(true); expect(new TargetRegistry().has('portable')).toBe(false); diff --git a/packages/workbench/src/mcp/mcp-route-client.ts b/packages/workbench/src/mcp/mcp-route-client.ts index 5552a9f1e..59b959e90 100644 --- a/packages/workbench/src/mcp/mcp-route-client.ts +++ b/packages/workbench/src/mcp/mcp-route-client.ts @@ -9,7 +9,7 @@ import type { } from '../../../agent-bundle/src/contracts/runtime.ts'; import type { JsonObject } from '../../../agent-bundle/src/contracts/runtime.ts'; -export type McpRouteTarget = 'claude' | 'codex' | 'portable'; +export type McpRouteTarget = 'claude' | 'codex' | 'cursor' | 'portable'; export interface McpRouteSessionBinding { readonly epochId: string; @@ -109,7 +109,7 @@ const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null && !Array.isArray(value); const isTarget = (value: unknown): value is McpRouteTarget => - value === 'claude' || value === 'codex' || value === 'portable'; + value === 'claude' || value === 'codex' || value === 'cursor' || value === 'portable'; const detachedJson = (value: unknown, ancestors = new WeakSet()): unknown => { if (value === null || typeof value === 'string' || typeof value === 'boolean') return value; diff --git a/packages/workbench/src/mcp/mcp-session-controller.ts b/packages/workbench/src/mcp/mcp-session-controller.ts index 56d0e5177..0189e2b5c 100644 --- a/packages/workbench/src/mcp/mcp-session-controller.ts +++ b/packages/workbench/src/mcp/mcp-session-controller.ts @@ -257,7 +257,7 @@ const artifactBindingSnapshot = (value: unknown): McpRouteSessionBinding | undef if ( typeof epochId.value !== 'string' || epochId.value.length === 0 || typeof serverName.value !== 'string' || serverName.value.length === 0 || - (target.value !== 'claude' && target.value !== 'codex' && target.value !== 'portable') + (target.value !== 'claude' && target.value !== 'codex' && target.value !== 'cursor' && target.value !== 'portable') ) return undefined; return Object.freeze({ epochId: epochId.value, serverName: serverName.value, target: target.value }); } catch { From e5960ede7e35525dd9ab4ea04066b45ac48493b8 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Fri, 28 Aug 2026 08:12:20 +0000 Subject: [PATCH 2/2] test(release): pin the Cursor-aware package description --- packages/agent-bundle/tests/release-audit.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agent-bundle/tests/release-audit.test.ts b/packages/agent-bundle/tests/release-audit.test.ts index bfbe425f7..0193c7319 100644 --- a/packages/agent-bundle/tests/release-audit.test.ts +++ b/packages/agent-bundle/tests/release-audit.test.ts @@ -99,7 +99,7 @@ it('ships repository and support metadata that matches the verified origin', asy expect(manifest).toMatchObject({ bugs: { url: 'https://github.com/ScriptedAlchemy/agent-bundle/issues' }, - 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.', homepage: 'https://github.com/ScriptedAlchemy/agent-bundle#readme', repository: { type: 'git', url: 'git+https://github.com/ScriptedAlchemy/agent-bundle.git' }, });