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/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' }, }); 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 {