From cafa51136111dd81ec6e71594fa9d5e81a7e04a2 Mon Sep 17 00:00:00 2001 From: ScriptedAlchemy Date: Wed, 2 Sep 2026 06:38:05 +0000 Subject: [PATCH] feat(claude): emit channel declarations bound to plugin MCP servers (#187) --- .changeset/claude-plugin-channels.md | 5 + .../adapters/capabilities/claude-2.1.250.json | 8 + packages/agent-bundle/src/adapters/claude.ts | 154 ++++++++++++++- packages/agent-bundle/src/adapters/plugin.ts | 5 +- .../adapters/schemas/claude/PROVENANCE.json | 6 +- .../schemas/claude/plugin.schema.json | 19 ++ .../tests/adapter-capability-states.test.ts | 21 +++ .../tests/adapter-metadata.test.ts | 6 +- .../tests/host-adapters.native.test.ts | 69 +++++++ .../agent-bundle/tests/host-adapters.test.ts | 176 ++++++++++++++++++ .../agent-bundle/tests/plugin-bundle.test.ts | 47 +++++ 11 files changed, 508 insertions(+), 8 deletions(-) create mode 100644 .changeset/claude-plugin-channels.md diff --git a/.changeset/claude-plugin-channels.md b/.changeset/claude-plugin-channels.md new file mode 100644 index 000000000..887c8356a --- /dev/null +++ b/.changeset/claude-plugin-channels.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +Add validated Claude Code plugin channel declarations bound to emitted MCP servers, including per-channel user configuration and unified plugin bundle emission. diff --git a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json index 46b61a191..ef2984e54 100644 --- a/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json +++ b/packages/agent-bundle/src/adapters/capabilities/claude-2.1.250.json @@ -54,6 +54,10 @@ "enabledOnly": true, "organizationDistributionProhibited": true }, + "channels": { + "bindsToPluginMcpServer": true, + "perChannelUserConfig": true + }, "commands": true, "dependencies": { "autoInstall": true, @@ -214,6 +218,10 @@ "2026-09-01: https://code.claude.com/docs/en/plugins-reference stores non-sensitive options under `pluginConfigs[].options` in user settings and sensitive options in macOS Keychain with credentials-file fallback, or `~/.claude/.credentials.json` without a supported keychain; Keychain storage is shared with OAuth tokens and has an approximately 2 KB total limit. pluginConfigs precedence is managed settings, then `--settings`, then user settings; project and local settings are ignored for pluginConfigs (but not enabledPlugins), while before v2.1.207 they were read.", "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents repeatable `claude plugin install --config key=value` for setting declared userConfig options.", "2026-09-01: Claude Code 2.1.257 `claude plugin validate --strict` accepts an emitted plugin manifest declaring userConfig with a sensitive string option and a bounded number option.", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference lists the component path field as: \"channels | array | Channel declarations for message injection (Telegram, Slack, Discord style). See Channels\".", + "2026-09-01: https://code.claude.com/docs/en/plugins-reference documents `channels` as an array of message-channel declarations for Telegram, Slack, or Discord-style injection; each required `server` \"must match a key in the plugin's mcpServers.\"", + "2026-09-01: The same Channels section states that optional per-channel `userConfig` \"uses the same schema as the top-level field\", so sensitive channel options follow the top-level secure-storage semantics rather than a second option contract.", + "2026-09-01: Local host proof against Claude Code 2.1.257 shows `claude plugin validate --strict` accepts a channel bound to an existing `.mcp.json` server and also accepts a deliberately dangling server name; the CLI validates the declaration shape but does not cross-check the sibling MCP keys, so Agent Bundle's claude.channels.server.unknown plan diagnostic is the binding guard (host-adapters.native.test.ts).", "2026-09-01: https://code.claude.com/docs/en/plugin-dependencies documents `.claude-plugin/plugin.json` dependencies as a union of bare plugin-name strings and closed objects with required `name` plus optional `version` and `marketplace`; omitted marketplace resolves in the declaring plugin's marketplace.", "2026-09-01: https://code.claude.com/docs/en/plugin-dependencies documents npm-style semver ranges, pre-release exclusion unless a range opts in, and git tag resolution through `{name}--v{version}`. Git sources fetch the highest satisfying tag; npm, archive, and command sources are load-checked only, command-source dependencies are never auto-installed, and a dependency's headersHelper is never run automatically.", "2026-09-01: https://code.claude.com/docs/en/plugin-dependencies requires cross-marketplace targets in `allowCrossMarketplaceDependenciesOn` on the root marketplace; only the root allowlist is consulted, trust does not chain, and users may manually install a blocked dependency first.", diff --git a/packages/agent-bundle/src/adapters/claude.ts b/packages/agent-bundle/src/adapters/claude.ts index 7288f672f..4c80a695e 100644 --- a/packages/agent-bundle/src/adapters/claude.ts +++ b/packages/agent-bundle/src/adapters/claude.ts @@ -118,6 +118,12 @@ export interface ClaudeUserConfigOption { readonly type: ClaudeUserConfigOptionType; } +/** One Claude Code message channel bound to an MCP server supplied by this plugin. */ +export interface ClaudeChannelConfig { + readonly server: string; + readonly userConfig?: Readonly>; +} + /** * One Claude Code subagent status line: the command object documented for * `subagentStatusLine`, which renders a custom row body for each subagent in @@ -184,6 +190,8 @@ export interface ClaudeDependencyConfig { export interface ClaudeHostConfig extends AgentBundleHostConfig { /** Project-authored directory copied to the plugin-root `bin/` executable convention. */ readonly bin?: string; + /** Message channels whose server names must resolve in this plugin's emitted `.mcp.json`. */ + readonly channels?: readonly ClaudeChannelConfig[]; /** Whether a newly installed plugin starts enabled when no stronger host state exists. */ readonly defaultEnabled?: boolean; /** @@ -253,7 +261,7 @@ const hookContract = Object.freeze({ wrapperSource: (entry) => nativeHookWrapperSource(entry, 'Claude'), } satisfies TargetHookContract); const metadata = Object.freeze({ - adapterRevision: '1.10.0', + adapterRevision: '1.11.0', observedVersion: capabilityTable.observedCliVersion, schemas: schemaDescriptorsFrom(schemaProvenance, schemaProvenance.observedCliVersion), }); @@ -985,6 +993,17 @@ const noManifestMetadataPlan: ClaudeManifestMetadataPlan = deepFreeze({ sourceInputs: [], }); +interface ClaudeChannelsPlan { + readonly diagnostics: readonly Diagnostic[]; + readonly document?: readonly Record[]; + readonly sourceInputs: readonly string[]; +} + +const noChannelsPlan: ClaudeChannelsPlan = deepFreeze({ + diagnostics: [], + sourceInputs: [], +}); + const manifestMetadataDiagnostic = ( code: string, message: string, @@ -1045,6 +1064,129 @@ const planClaudeManifestMetadata = (model: NormalizedPlugin): ClaudeManifestMeta }; }; +const channelFields: ReadonlySet = new Set(['server', 'userConfig']); + +/** + * Lowers `claude.channels` into plugin manifest declarations after binding + * each channel to a server that survived this target's MCP planning. Duplicate + * declarations are retained in authored order because the host contract does + * not require one channel per server. + */ +export const planClaudeChannels = ( + model: NormalizedPlugin, + pluginMcpServerNames: ReadonlySet, +): ClaudeChannelsPlan => { + const extension = model.extensions[claudeName]; + if (extension === undefined || !isDataRecord(extension.value)) return noChannelsPlan; + const declared = extension.value['channels']; + if (declared === undefined) return noChannelsPlan; + const inputs = sourceInputs(extension.provenance.sourcePath); + if (!Array.isArray(declared) || declared.length === 0) { + return { + diagnostics: [userConfigDiagnostic( + 'claude.channels.declaration.invalid', + 'Claude channels must be a nonempty array of channel declarations.', + 'Declare at least one channel as { server, userConfig? }, then rebuild.', + )], + sourceInputs: inputs, + }; + } + + const diagnostics: Diagnostic[] = []; + const document: Record[] = []; + for (const [index, channel] of declared.entries()) { + if (!isPlainDataRecord(channel)) { + diagnostics.push(userConfigDiagnostic( + 'claude.channels.entry.invalid', + `Claude channel at index ${index} must be a channel declaration object.`, + `Replace channels[${index}] with an object containing server and optional userConfig, then rebuild.`, + )); + continue; + } + for (const field of Object.keys(channel).sort()) { + if (channelFields.has(field)) continue; + diagnostics.push(userConfigDiagnostic( + 'claude.channels.field.unknown', + `Claude channel at index ${index} declares unknown field ${JSON.stringify(field)}.`, + `Remove channels[${index}].${field}; channel declarations support only server and userConfig.`, + )); + } + + const server = channel['server']; + if (typeof server !== 'string' || server.length === 0) { + diagnostics.push(userConfigDiagnostic( + 'claude.channels.server.required', + `Claude channel at index ${index} requires a nonempty server name.`, + `Set channels[${index}].server to a key emitted in this plugin's .mcp.json, then rebuild.`, + )); + } else if (!pluginMcpServerNames.has(server)) { + diagnostics.push(userConfigDiagnostic( + 'claude.channels.server.unknown', + pluginMcpServerNames.size === 0 + ? `Claude channel at index ${index} binds to ${JSON.stringify(server)}, but this target emits no plugin MCP servers.` + : `Claude channel at index ${index} binds to undeclared plugin MCP server ${JSON.stringify(server)}.`, + `Set channels[${index}].server to one of the plugin MCP servers selected for this target, then rebuild.`, + )); + } + + const userConfig = channel['userConfig']; + let plannedUserConfig: Record> | undefined; + if (userConfig !== undefined) { + if (!isPlainDataRecord(userConfig) || Object.keys(userConfig).length === 0) { + diagnostics.push(userConfigDiagnostic( + 'claude.channels.userConfig.invalid', + `Claude channel at index ${index} userConfig must be a nonempty plain record of option key to option declaration.`, + `Set channels[${index}].userConfig to a nonempty option map or remove it, then rebuild.`, + )); + } else { + plannedUserConfig = Object.create(null) as Record>; + const environmentOwners = new Map(); + for (const key of Object.keys(userConfig).sort()) { + if (!userConfigIdentifier.test(key)) { + diagnostics.push(userConfigDiagnostic( + 'claude.channels.key.invalid', + `Claude channel at index ${index} userConfig option key "${key}" must match ^[A-Za-z_][A-Za-z0-9_]*$.`, + `Rename channels[${index}].userConfig option "${key}" to a valid identifier, then rebuild.`, + )); + } + const environmentKey = key.toUpperCase(); + const owner = environmentOwners.get(environmentKey); + if (owner === undefined) { + environmentOwners.set(environmentKey, key); + } else { + diagnostics.push(userConfigDiagnostic( + 'claude.channels.key.collision', + `Claude channel at index ${index} userConfig option keys "${owner}" and "${key}" both export as CLAUDE_PLUGIN_OPTION_${environmentKey}.`, + `Rename one channels[${index}].userConfig option so every key remains unique after uppercasing, then rebuild.`, + )); + } + const optionPlan = planClaudeUserConfigOption(key, userConfig[key]); + diagnostics.push(...optionPlan.diagnostics); + if (optionPlan.value !== undefined) plannedUserConfig[key] = optionPlan.value; + } + } + } + + if ( + typeof server === 'string' && + server.length > 0 && + pluginMcpServerNames.has(server) && + (userConfig === undefined || plannedUserConfig !== undefined) + ) { + document.push({ + server, + ...(plannedUserConfig === undefined ? {} : { userConfig: plannedUserConfig }), + }); + } + } + + return { + diagnostics, + ...(diagnostics.length === 0 ? { document: Object.freeze(document) } : {}), + sourceInputs: inputs, + }; +}; + interface ClaudeBinPlan { readonly diagnostics: readonly Diagnostic[]; readonly entries: readonly TargetArtifactCopy[]; @@ -1299,6 +1441,8 @@ export const planClaudeArtifacts = ( 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 channels = planClaudeChannels(model, new Set(mcpValid ? Object.keys(servers) : [])); + diagnostics.push(...channels.diagnostics); const lsp = planClaudeLsp(model); diagnostics.push(...lsp.diagnostics); const userConfig = planClaudeUserConfig(model); @@ -1324,6 +1468,7 @@ export const planClaudeArtifacts = ( const plugin = { author: { name: model.metadata.name }, ...manifestMetadata.document, + ...(channels.document === undefined ? {} : { channels: channels.document }), ...(dependencies.document === undefined ? {} : { dependencies: dependencies.document }), description: model.metadata.description ?? model.metadata.name, ...(hookDocument === undefined ? {} : { hooks: `./${hookContract.manifestPath}` }), @@ -1365,6 +1510,7 @@ export const planClaudeArtifacts = ( const basePlan = standardPluginArtifactPlan({ additionalPluginSourceInputs: sourceInputs( + ...channels.sourceInputs, ...userConfig.sourceInputs, ...manifestMetadata.sourceInputs, ...dependencies.sourceInputs, @@ -1419,6 +1565,12 @@ export const claudeAdapter: TargetAdapter = Object.freeze({ evidence, 'The pinned Claude plugin contract does not document the plugin-root bin executable surface.', ), + channels: capabilityStateFromSupport( + capabilityTable.plugin.channels.bindsToPluginMcpServer && + capabilityTable.plugin.channels.perChannelUserConfig, + evidence, + 'The pinned Claude plugin contract does not document message channel declarations.', + ), commands: capabilityStateFromSupport( capabilityTable.plugin.commands, evidence, diff --git a/packages/agent-bundle/src/adapters/plugin.ts b/packages/agent-bundle/src/adapters/plugin.ts index a0791ff0c..bb11e08dc 100644 --- a/packages/agent-bundle/src/adapters/plugin.ts +++ b/packages/agent-bundle/src/adapters/plugin.ts @@ -184,7 +184,7 @@ const artifactValidation = deepFreeze({ }); const metadata = Object.freeze({ - adapterRevision: '1.9.0', + adapterRevision: '1.10.0', observedVersion: `${claudeAdapter.metadata.observedVersion}+${codexAdapter.metadata.observedVersion}+${cursorAdapter.metadata.observedVersion}`, // Metadata schemas must exactly match the validation contract: each host's // documents, with one shared Claude-format hook schema (the pinned Codex @@ -567,6 +567,9 @@ export const pluginAdapter: TargetAdapter = Object.freeze({ bin: unavailableCapability( 'The unified bundle emits the Claude-only bin directory, but the pinned Codex and Cursor contracts declare no shared plugin executable surface.', ), + channels: unavailableCapability( + 'The unified bundle emits the Claude-only channels manifest field, but the pinned Codex and Cursor contracts declare no shared message-channel surface.', + ), commands: intersectCapabilityStates( intersectCapabilityStates(claudeAdapter.capabilities.commands!, codexAdapter.capabilities.commands!), cursorAdapter.capabilities.commands!, diff --git a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json index f9be12b9b..5f72c7e1e 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/PROVENANCE.json @@ -2,7 +2,7 @@ "observedCliVersion": "2.1.250", "retrievedAt": "2026-09-01", "schemaSource": "https://docs.anthropic.com/en/docs/claude-code/plugins", - "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.250.json. settings.schema.json was pinned (retrieved 2026-09-01) from the \"Ship default settings with your plugin\" section of https://code.claude.com/docs/en/plugins and the file-locations row of https://code.claude.com/docs/en/plugins-reference, which bound the plugin-root settings.json to the `agent` and `subagentStatusLine` keys, plus https://code.claude.com/docs/en/statusline for the subagentStatusLine command-object shape. Three agent-bundle tightenings over the documented text: the closed schema rejects the unknown keys the host \"silently ignores\", so a requested default never disappears at runtime; minProperties 1 rejects an empty settings.json, which declares no default configuration at all; and subagentStatusLine admits only the two fields its own examples show (`type` and `command`) - statusLine's optional `padding` is documented for the user status line, never for the plugin default, so it stays out of the pinned shape. The plugins-reference placeholder table (\"Which fields substitute them inline depends on the plugin component\") lists Skill and agent content, hook and monitor commands, MCP servers, and LSP servers but not settings.json, so the adapter rejects Agent Bundle path tokens in settings values rather than emitting a placeholder Claude Code never resolves. plugin.json's `userConfig` property and closed `userConfigOption` definition were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle deliberately tightens the documented \"valid identifiers\" requirement to `^[A-Za-z_][A-Za-z0-9_]*$`, rejects option keys that collide after uppercasing because both would map to one `CLAUDE_PLUGIN_OPTION_` environment variable, requires the userConfig record to be nonempty, and rejects `sensitive: true` with `default` because a baked-in secure-storage default would ship a secret in the manifest. plugin.schema.json's `dependencies` property was pinned (retrieved 2026-09-01) from https://code.claude.com/docs/en/plugin-dependencies and the manifest schema in https://code.claude.com/docs/en/plugins-reference: a nonempty array whose entries are nonempty plugin-name strings or closed objects with required name and optional version and marketplace strings. Agent Bundle tightens dependency names to the manifest's existing lowercase kebab-case name pattern, rejects an empty array, and closes object fields so malformed declarations fail before distribution; semver range grammar remains plan-time validation because JSON Schema cannot honestly encode npm range syntax. plugin.schema.json's `displayName`, `metadata`, and `defaultEnabled` properties were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle tightens Claude Code's warning-and-ignore handling for a non-object `metadata` value into build error claude.manifest.metadata.invalid, rejects an empty or whitespace-only `displayName` with claude.manifest.displayName.invalid, and rejects a non-boolean `defaultEnabled` with claude.manifest.defaultEnabled.invalid. The normalized generic model currently carries description but not homepage, repository, license, keywords, or `$schema`, so this slice deliberately emits only the three new Claude host-config fields and does not widen the generic model. Component path fields are deliberately excluded from the emitted schema and config surface: the generator owns the canonical default commands/, skills/, hooks/hooks.json, .mcp.json, .lsp.json, and settings.json layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.250.json.", + "notes": "lsp.schema.json and plugin.json's `lspServers` property were pinned from the Claude Code 2.1.x plugin reference LSP servers section (retrieved 2026-09-01), which documents `.lsp.json` at the plugin root or inline `lspServers` in the manifest, required `command` / `extensionToLanguage`, and the optional `args`, `transport`, `env`, `initializationOptions`, `settings`, `workspaceFolder`, `startupTimeout`, `shutdownTimeout`, `restartOnCrash`, `maxRestarts`, and `diagnostics` fields. `restartOnCrash` and `shutdownTimeout` require Claude Code v2.1.205 or later, which the pinned 2.1.250 revision satisfies. Manifest `lspServers` keeps the documented `string|array|object` union rather than being narrowed to the one emitted form the way `hooks` is; the emitted document itself is `.lsp.json` at the plugin root. Two agent-bundle tightenings over the documented text: a server map and an `extensionToLanguage` map must both be nonempty, because an empty map claims no extension and can never start a server. The current hooks reference at https://code.claude.com/docs/en/hooks supplies the SubagentStart/SubagentStop wire and decision evidence recorded in claude-2.1.250.json. settings.schema.json was pinned (retrieved 2026-09-01) from the \"Ship default settings with your plugin\" section of https://code.claude.com/docs/en/plugins and the file-locations row of https://code.claude.com/docs/en/plugins-reference, which bound the plugin-root settings.json to the `agent` and `subagentStatusLine` keys, plus https://code.claude.com/docs/en/statusline for the subagentStatusLine command-object shape. Three agent-bundle tightenings over the documented text: the closed schema rejects the unknown keys the host \"silently ignores\", so a requested default never disappears at runtime; minProperties 1 rejects an empty settings.json, which declares no default configuration at all; and subagentStatusLine admits only the two fields its own examples show (`type` and `command`) - statusLine's optional `padding` is documented for the user status line, never for the plugin default, so it stays out of the pinned shape. The plugins-reference placeholder table (\"Which fields substitute them inline depends on the plugin component\") lists Skill and agent content, hook and monitor commands, MCP servers, and LSP servers but not settings.json, so the adapter rejects Agent Bundle path tokens in settings values rather than emitting a placeholder Claude Code never resolves. plugin.json's `userConfig` property and closed `userConfigOption` definition were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle deliberately tightens the documented \"valid identifiers\" requirement to `^[A-Za-z_][A-Za-z0-9_]*$`, rejects option keys that collide after uppercasing because both would map to one `CLAUDE_PLUGIN_OPTION_` environment variable, requires the userConfig record to be nonempty, and rejects `sensitive: true` with `default` because a baked-in secure-storage default would ship a secret in the manifest. plugin.schema.json's `dependencies` property was pinned (retrieved 2026-09-01) from https://code.claude.com/docs/en/plugin-dependencies and the manifest schema in https://code.claude.com/docs/en/plugins-reference: a nonempty array whose entries are nonempty plugin-name strings or closed objects with required name and optional version and marketplace strings. Agent Bundle tightens dependency names to the manifest's existing lowercase kebab-case name pattern, rejects an empty array, and closes object fields so malformed declarations fail before distribution; semver range grammar remains plan-time validation because JSON Schema cannot honestly encode npm range syntax. plugin.schema.json's `displayName`, `metadata`, and `defaultEnabled` properties were pinned from https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01). Agent Bundle tightens Claude Code's warning-and-ignore handling for a non-object `metadata` value into build error claude.manifest.metadata.invalid, rejects an empty or whitespace-only `displayName` with claude.manifest.displayName.invalid, and rejects a non-boolean `defaultEnabled` with claude.manifest.defaultEnabled.invalid. The normalized generic model currently carries description but not homepage, repository, license, keywords, or `$schema`, so this slice deliberately emits only the three new Claude host-config fields and does not widen the generic model. Component path fields are deliberately excluded from the emitted schema and config surface: the generator owns the canonical default commands/, skills/, hooks/hooks.json, .mcp.json, .lsp.json, and settings.json layout, while custom replace/add path rules remain documented host-discovery evidence in claude-2.1.250.json. plugin.json's `channels` property was pinned from the Channels section of https://code.claude.com/docs/en/plugins-reference (retrieved 2026-09-01): a nonempty array of closed declarations with required nonempty `server` and optional per-channel `userConfig` reusing the top-level option definition. Agent Bundle tightens the documented contract by rejecting an empty channels array, empty per-channel userConfig, unknown channel fields, invalid or uppercase-colliding option identifiers, and any server name absent from the MCP server map successfully planned for the selected target. Duplicate channel declarations for one server remain allowed and preserve authored order because the reference imposes no uniqueness rule. Per-channel `sensitive: true` follows the top-level secure-storage semantics because the reference says the channel field uses the same schema; the existing prohibition on sensitive defaults therefore applies unchanged. Claude Code 2.1.257 strict validation accepts both valid bindings and deliberately dangling server names, so claude.channels.server.unknown is an intentional compiler tightening and the only pre-enable cross-document binding guard.", "schemas": { "hooks.schema.json": { "bytes": 1108, @@ -25,8 +25,8 @@ "url": "https://docs.anthropic.com/en/docs/claude-code/mcp" }, "plugin.schema.json": { - "bytes": 5721, - "sha256": "cd044b6fcf43f1e2f590059b758759127c0fb86c5cad5fbe80965be896350257", + "bytes": 6271, + "sha256": "3c9943237da86fd08ae82f698cde36dbef2ecf742098c6961cedf481fe435c12", "url": "https://code.claude.com/docs/en/plugins-reference" }, "settings.schema.json": { diff --git a/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json b/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json index 56d540fe3..c281c2f78 100644 --- a/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json +++ b/packages/agent-bundle/src/adapters/schemas/claude/plugin.schema.json @@ -2,6 +2,20 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://agent-bundle.dev/schemas/claude/2.1.250/plugin.schema.json", "$defs": { + "channel": { + "additionalProperties": false, + "properties": { + "server": { "minLength": 1, "type": "string" }, + "userConfig": { + "additionalProperties": { "$ref": "#/$defs/userConfigOption" }, + "minProperties": 1, + "propertyNames": { "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" }, + "type": "object" + } + }, + "required": ["server"], + "type": "object" + }, "dependency": { "additionalProperties": false, "properties": { @@ -115,6 +129,11 @@ "required": ["name"], "type": "object" }, + "channels": { + "items": { "$ref": "#/$defs/channel" }, + "minItems": 1, + "type": "array" + }, "defaultEnabled": { "type": "boolean" }, "description": { "minLength": 1, "type": "string" }, "dependencies": { diff --git a/packages/agent-bundle/tests/adapter-capability-states.test.ts b/packages/agent-bundle/tests/adapter-capability-states.test.ts index ebb9ce932..e84461bc9 100644 --- a/packages/agent-bundle/tests/adapter-capability-states.test.ts +++ b/packages/agent-bundle/tests/adapter-capability-states.test.ts @@ -175,6 +175,27 @@ it('reports Claude userConfig support and honest unavailable composite coverage' expect(registry.supports('plugin', 'userConfig')).toBe(false); }); +it('reports Claude channels support and honest unavailable composite coverage', () => { + const registry = createDefaultRegistry(); + + expect(registry.get('claude').capabilities.channels).toMatchObject({ + evidence: { + observedVersion: '2.1.250', + target: 'claude', + }, + state: 'supported', + }); + expect(registry.get('plugin').capabilities.channels).toEqual({ + reason: 'The unified bundle emits the Claude-only channels manifest field, but the pinned Codex and Cursor contracts declare no shared message-channel surface.', + state: 'unavailable', + }); + for (const target of ['codex', 'cursor', 'portable'] as const) { + expect(registry.get(target).capabilities.channels).toBeUndefined(); + } + expect(registry.supports('claude', 'channels')).toBe(true); + expect(registry.supports('plugin', 'channels')).toBe(false); +}); + it('reports Claude dependency support and honest unavailable composite coverage', () => { const registry = createDefaultRegistry(); diff --git a/packages/agent-bundle/tests/adapter-metadata.test.ts b/packages/agent-bundle/tests/adapter-metadata.test.ts index 134f12b72..c9f91c43d 100644 --- a/packages/agent-bundle/tests/adapter-metadata.test.ts +++ b/packages/agent-bundle/tests/adapter-metadata.test.ts @@ -93,7 +93,7 @@ it('records exact immutable metadata for every built-in target', () => { ], }); expect(registryMetadata(registry, 'claude')).toEqual({ - adapterRevision: '1.10.0', + adapterRevision: '1.11.0', observedVersion: '2.1.250', schemas: [ { @@ -119,7 +119,7 @@ it('records exact immutable metadata for every built-in target', () => { { name: 'plugin', revision: '2.1.250', - sha256: 'cd044b6fcf43f1e2f590059b758759127c0fb86c5cad5fbe80965be896350257', + sha256: '3c9943237da86fd08ae82f698cde36dbef2ecf742098c6961cedf481fe435c12', }, { name: 'settings', @@ -154,7 +154,7 @@ it('records exact immutable metadata for every built-in target', () => { }, ], }); - expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.9.0'); + expect(registryMetadata(registry, 'plugin').adapterRevision).toBe('1.10.0'); }); it('records observed capability versions and rehashes schema snapshots against pinned provenance', async () => { diff --git a/packages/agent-bundle/tests/host-adapters.native.test.ts b/packages/agent-bundle/tests/host-adapters.native.test.ts index f2de1073b..527f797b4 100644 --- a/packages/agent-bundle/tests/host-adapters.native.test.ts +++ b/packages/agent-bundle/tests/host-adapters.native.test.ts @@ -98,6 +98,39 @@ const withClaudeManifestMetadata = ( }, }); +const channelModel: NormalizedPlugin = { + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + target: 'claude', + value: { + channels: [{ + server: 'telegram', + userConfig: { + bot_token: { + description: 'Telegram bot token.', + sensitive: true, + title: 'Bot token', + type: 'string', + }, + }, + }], + }, + }, + }, + mcpServers: [{ + command: 'node', + id: 'mcp:telegram', + name: 'telegram', + provenance: { kind: 'config', sourcePath: '/workspace/agent-bundle.config.ts' }, + targets: ['claude'], + transport: 'stdio', + }], +}; + const writeClaudeArtifact = async ( root: string, planned: NormalizedPlugin, @@ -272,6 +305,42 @@ nativeIt('accepts emitted Claude userConfig under strict native validation', asy } }); +nativeIt('accepts emitted Claude channels bound to a plugin MCP server under strict native validation', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-channels-')); + + try { + const written = await writeClaudeArtifact(root, channelModel); + expect(written).toContain('.mcp.json'); + const validation = await runClaudeValidation(root, root); + + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +nativeIt('records whether strict native validation catches a dangling Claude channel server binding', async () => { + const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-channels-dangling-')); + + try { + await writeClaudeArtifact(root, channelModel); + const manifestPath = join(root, '.claude-plugin', 'plugin.json'); + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as Record; + manifest['channels'] = [{ server: 'missing' }]; + await writeFile(manifestPath, `${JSON.stringify(manifest)}\n`); + const validation = await runClaudeValidation(root, root); + + // Claude Code 2.1.257 validates the channel declaration shape but does + // not cross-check `server` against the sibling .mcp.json keys. + expect(validation.code, validation.output).toBe(0); + expect(validation.output).toContain('Validation passed'); + expect(validation.output).not.toContain('missing'); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + nativeIt('accepts emitted Claude plugin dependencies under strict native validation', async () => { const root = await mkdtemp(join(tmpdir(), 'agent-bundle-claude-dependencies-')); diff --git a/packages/agent-bundle/tests/host-adapters.test.ts b/packages/agent-bundle/tests/host-adapters.test.ts index 5559032ba..61662bed4 100644 --- a/packages/agent-bundle/tests/host-adapters.test.ts +++ b/packages/agent-bundle/tests/host-adapters.test.ts @@ -119,6 +119,23 @@ const withClaudeUserConfig = ( }, }); +const withClaudeChannels = ( + model: NormalizedPlugin, + channels: unknown, + target = 'claude', +): NormalizedPlugin => ({ + ...model, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config', sourcePath: '/workspace/channels.config.ts' }, + target, + value: { channels }, + }, + }, +}); + const withClaudeBin = ( model: NormalizedPlugin, files: NonNullable[number]['files'], @@ -866,6 +883,165 @@ it('pins the closed Claude userConfig manifest schema', async () => { } }); +it('emits Claude channels bound to planned MCP servers with per-channel sensitive userConfig', () => { + const model = withClaudeChannels(plugin, [ + { + server: 'stdio', + userConfig: { + bot_token: { + description: 'Telegram bot token.', + sensitive: true, + title: 'Bot token', + type: 'string', + }, + }, + }, + { server: 'stdio' }, + { server: 'http' }, + ]); + const plan = createDefaultRegistry().get('claude').plan(model); + const manifest = plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json'); + + expect(plan.diagnostics).toEqual([]); + expect(manifest).toMatchObject({ + kind: 'write', + sourceInputs: expect.arrayContaining(['/workspace/channels.config.ts']), + }); + if (manifest?.kind !== 'write') throw new Error('Expected an emitted Claude plugin manifest.'); + expect(JSON.parse(manifest.content).channels).toEqual([ + { + server: 'stdio', + userConfig: { + bot_token: { + description: 'Telegram bot token.', + sensitive: true, + title: 'Bot token', + type: 'string', + }, + }, + }, + { server: 'stdio' }, + { server: 'http' }, + ]); +}); + +it.each([ + { channels: [], code: 'claude.channels.declaration.invalid', label: 'an empty channels declaration' }, + { channels: ['stdio'], code: 'claude.channels.entry.invalid', label: 'a non-object channel entry' }, + { channels: [{ server: 'stdio', typo: true }], code: 'claude.channels.field.unknown', label: 'an unknown channel field' }, + { channels: [{}], code: 'claude.channels.server.required', label: 'a missing channel server' }, + { channels: [{ server: '' }], code: 'claude.channels.server.required', label: 'an empty channel server' }, + { channels: [{ server: 'missing' }], code: 'claude.channels.server.unknown', label: 'a channel bound to an undeclared MCP server' }, + { channels: [{ server: 'stdio', userConfig: {} }], code: 'claude.channels.userConfig.invalid', label: 'an empty per-channel userConfig' }, + { + channels: [{ + server: 'stdio', + userConfig: { 'bot-token': { description: 'Token.', title: 'Token', type: 'string' } }, + }], + code: 'claude.channels.key.invalid', + label: 'an invalid per-channel option key', + }, + { + channels: [{ + server: 'stdio', + userConfig: { + BotToken: { description: 'First.', title: 'First', type: 'string' }, + BOTTOKEN: { description: 'Second.', title: 'Second', type: 'string' }, + }, + }], + code: 'claude.channels.key.collision', + label: 'per-channel environment keys that collide after uppercasing', + }, + { + channels: [{ + server: 'stdio', + userConfig: { bot_token: { description: 'Token.', title: 'Token', type: 'secret' } }, + }], + code: 'claude.userConfig.type.invalid', + label: 'an invalid per-channel option declaration', + }, +])('rejects $label without emitting channels', ({ channels, code }) => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeChannels(plugin, channels)); + const manifest = plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json'); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code, + recovery: expect.any(String), + severity: 'error', + })); + if (manifest?.kind !== 'write') throw new Error('Expected the base Claude plugin manifest.'); + expect(JSON.parse(manifest.content)).not.toHaveProperty('channels'); +}); + +it('rejects channels when no Claude MCP servers are planned', () => { + const plan = createDefaultRegistry().get('claude').plan(withClaudeChannels({ + ...plugin, + mcpServers: [], + }, [{ server: 'stdio' }])); + + expect(plan.diagnostics).toContainEqual(expect.objectContaining({ + code: 'claude.channels.server.unknown', + message: expect.stringContaining('no plugin MCP servers'), + })); +}); + +it('rejects a channel when its MCP server prevents the MCP document from emitting', () => { + const invalidMcpModel = { + ...plugin, + mcpServers: plugin.mcpServers.map((server) => + server.name === 'http' ? { ...server, url: 'not a URL' } : server), + } satisfies NormalizedPlugin; + const plan = createDefaultRegistry().get('claude').plan(withClaudeChannels( + invalidMcpModel, + [{ server: 'http' }], + )); + const manifest = plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json'); + + expect(plan.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([ + 'claude.schema.mcp', + 'claude.channels.server.unknown', + ]); + expect(plan.entries.some((entry) => entry.relativePath === '.mcp.json')).toBe(false); + if (manifest?.kind !== 'write') throw new Error('Expected the base Claude plugin manifest.'); + expect(JSON.parse(manifest.content)).not.toHaveProperty('channels'); +}); + +it('pins the closed Claude channels manifest schema', async () => { + const schema = (await import('../src/adapters/schemas/claude/plugin.schema.json', { + with: { type: 'json' }, + })).default; + const validator = new Ajv2020({ allErrors: true, strict: false }); + installFormats(validator); + const validate = validator.compile(schema); + const manifest = { + author: { name: 'Agent Bundle' }, + description: 'Claude channels schema fixture.', + name: 'claude-channels-fixture', + version: '1.0.0', + }; + const option = { + description: 'Telegram bot token.', + sensitive: true, + title: 'Bot token', + type: 'string', + }; + + expect(validate({ + ...manifest, + channels: [{ server: 'telegram', userConfig: { bot_token: option } }], + })).toBe(true); + for (const channels of [ + [], + [{}], + [{ server: '' }], + [{ server: 'telegram', unknown: true }], + [{ server: 'telegram', userConfig: {} }], + [{ server: 'telegram', userConfig: { 'bot-token': option } }], + ]) { + expect(validate({ ...manifest, channels })).toBe(false); + } +}); + it('plans Claude bin files as byte-faithful prebuilt copies with complete provenance', () => { const model = withClaudeBin(plugin, [ { diff --git a/packages/agent-bundle/tests/plugin-bundle.test.ts b/packages/agent-bundle/tests/plugin-bundle.test.ts index 799eebef4..2dbcccf47 100644 --- a/packages/agent-bundle/tests/plugin-bundle.test.ts +++ b/packages/agent-bundle/tests/plugin-bundle.test.ts @@ -348,6 +348,53 @@ it('emits Claude manifest metadata from the unified target only into the Claude .toContain('/workspace/claude-metadata.config.ts'); }); +it('emits Claude channels from the unified target only into the Claude manifest', () => { + const model = { + ...bundleModel, + extensions: { + claude: { + id: 'extension:claude', + key: 'claude', + provenance: { kind: 'config' as const, sourcePath: '/workspace/channels.config.ts' }, + target: 'claude', + value: { + channels: [{ + server: 'status', + userConfig: { + webhook_secret: { + description: 'Webhook signing secret.', + sensitive: true, + title: 'Webhook secret', + type: 'string', + }, + }, + }], + }, + }, + }, + } satisfies NormalizedPlugin; + const plan = planBundle(model); + const documents = writeContents(model); + const claudeManifest = JSON.parse(documents['.claude-plugin/plugin.json']!) as Record; + + expect(plan.diagnostics).toEqual([]); + expect(claudeManifest.channels).toEqual([{ + server: 'status', + userConfig: { + webhook_secret: { + description: 'Webhook signing secret.', + sensitive: true, + title: 'Webhook secret', + type: 'string', + }, + }, + }]); + expect(JSON.parse(documents['.codex-plugin/plugin.json']!)).not.toHaveProperty('channels'); + expect(JSON.parse(documents['.cursor-plugin/plugin.json']!)).not.toHaveProperty('channels'); + expect(plan.entries.find((entry) => entry.relativePath === '.claude-plugin/plugin.json')?.sourceInputs) + .toContain('/workspace/channels.config.ts'); +}); + it('emits the Claude bin directory from the unified plugin target', () => { const model: NormalizedPlugin = { ...bundleModel,