Skip to content

refactor(contracts): plugin-owned contracts, part 4 (mcp, mcp-apps, feedback, a2a) - #54

Merged
danielgerlag merged 7 commits into
mainfrom
refactor/contracts-split-4
Sep 26, 2026
Merged

danielgerlag merged 7 commits into
mainfrom
refactor/contracts-split-4

Conversation

@danielgerlag

@danielgerlag danielgerlag commented Sep 26, 2026 •

Copy link
Copy Markdown
Owner

Closes #31

This is part 4, the last slice of the contracts split. The remaining plugin commands and events (mcp, mcp-apps, feedback, a2a, embedded content) move to their plugins, and mcp-app-csp.ts moves to plugins/mcp-apps. After this PR the @borg/contracts root defines no plugin command. Its only event is the kernel-guarded borg.channel.inboundMessage.

Commits

  1. refactor(a2a): a2aGetStatus moves to @borg/plugin-a2a/contract. a2aConfigSchema, a2aStatusSchema and their types stay in the root because the kernel's A2AService uses them.
  2. refactor(feedback): feedbackAskInputSchema, feedbackAsk, feedbackRequested and feedbackResolved move to @borg/plugin-feedback/contract. chat and graphs now depend on that contract.
    • feedbackAnswerSchema and FeedbackAnswer stay in the root: InteractionService parses answers with them, and the SDK exposes the type.
    • Kernel tests:
      • The kernel's human-input rule is keyed on the ids borg.feedback and borg.feedback.ask, not on the feedback schemas. So plugin-manager.test.ts now declares local stand-ins with the same ids instead of making the kernel package depend on a plugin. That file is already on the define* allowlist.
      • The duplicate-choice-id assertion moves unchanged to plugins/feedback/test/contract.test.ts.
  3. refactor(mcp): the mcp and mcp-apps contracts move into their plugins.
    • @borg/plugin-mcp/contract gets the borg.mcp.* surface: server status, snapshot, listServers, getStatus, refresh, and appDiscovered with its payload schemas (CSP, permissions, tool, MCP_APP_MAX_MESSAGE_BYTES). It also gets mcpAppSnapshotSchema, which is that payload plus a version, and which mcp's tests parse.
    • @borg/plugin-mcp-apps/contract gets the borg.mcpApps.* surface: invokeTool, cancelTool, toolResponded, and their schemas, plus MCP_APP_RENDERER_ID and MCP_APP_BRIDGE_CHANNEL.
    • mcp-apps depends on mcp's contract, never the reverse.
  4. refactor(mcp-apps): packages/contracts/src/mcp-app-csp.ts and its test move unchanged to plugins/mcp-apps. The file is published as @borg/plugin-mcp-apps/mcp-app-csp, and the desktop's embedded-content policy and protocol import it from there. The root no longer re-exports it.
  5. refactor(chat): embeddedContentRegistered moves to @borg/plugin-chat/contract.
    • mcp-apps can't own it, because chat consumes it and mcp-apps already depends on chat's contract, so that would be a cycle.
    • embeddedContentSnapshotSchema stays in the root because the SDK's renderer API exposes it.
  6. docs: records what stays in the root and why, the one-way contract dependencies, and the full rule set enforced by check-plugin-boundaries. This covers the Split @borg/contracts: plugins own their commands and events #31 criterion "boundary rules are documented".
  7. refactor(contracts): export mcpServerIdSchema from the root: a review follow-up. It includes the one sanctioned snapshot edit, described below.

All moves are verbatim. I checked this by diffing the lines removed from packages/contracts/src/index.ts against the new files; only import lines differ. apps/desktop/test/wire-contract.snapshot.txt keeps all 82 commands and 24 events with the same ids, timeouts and digests. Its only change is the one sanctioned line added in commit 7.

mcpServerIdSchema and the sanctioned snapshot edit

The mcp commands and appDiscovered validate server ids with mcpServerIdSchema. It stays defined in the root, next to the persona MCP server config that uses it. Per review, commit 7 exports it, and the mcp contract imports it from @borg/contracts; the earlier mcpServerConfigSchema.options[0].shape.id reach-in is gone.

Sanctioned wire snapshot edit (approved by SE): exporting the schema adds exactly one line to apps/desktop/test/wire-contract.snapshot.txt:

@@ -209,6 +209,7 @@ schema mcpAppToolArgumentsSchema 76d0081d404a68a8
 schema mcpAppToolResponseSchema e64e89b5b0d2b652
 schema mcpAppToolSchema 1cf6bfbdfd4910c9
 schema mcpServerConfigSchema bc16656f6e102b4c
+schema mcpServerIdSchema 044fca3cfee9272f
 schema mcpServerSnapshotSchema b78f38c1e24ca3bf
 schema mcpServerStatusSchema 1e22a54a18e235cd
 schema modelCompletionRequestSchema d6520f9871b40f2a

No other line or digest changed, and the command and event counts are still 82 and 24. This is the only snapshot edit in the whole split.

What stays in the @borg/contracts root, and why

  • Bus and definition helpers: defineCommand, defineEvent, the envelope, definition types and command errors.
  • Interactions: kind, source, choice, response and pending schemas, plus feedbackAnswerSchema. InteractionService, the desktop IPC and the SDK use them.
  • Execution security and provenance: used by the kernel.
  • Models and gateway: messages, tools, usage, completions, gateway requests, released completions, and the model descriptor. Used by ModelGateway.
  • Usage and cost: CostLedger.
  • Loops: LoopManager.
  • Personas: includes mcpServerConfigSchema and mcpServerIdSchema, because persona config embeds MCP servers. The mcp contract reuses the id schema.
  • Workspaces: WorkspaceService.
  • Prompt scanning: the kernel scan stage.
  • Tool approval and security metadata: ToolService and the SDK.
  • Data classification and channel capacity: the security policy.
  • Inbound messages: channelInboundMessage, KERNEL_ONLY_EVENT_IDS and the attachment handle. The kernel publishes and guards this event.
  • Connector account ids and persisted key formats: the kernel uses oauthGrantKey.
  • embeddedContentSnapshotSchema: the SDK renderer API.
  • A2A config and status: A2AService.

Contracts root size (packages/contracts/src/index.ts)

lines commands events exports
original (before #44) 3210 82 24 410
main (after #53) 1573 7 6 170
this PR 1196 0 1 136

Planted violations

I planted each of these in the new code, ran node scripts/check-plugin-boundaries.mjs, saw the failure, and reverted it:

Plant Failure
mcp's contract imports mcp-apps's contract (dependency declared) plugin dependency cycle: @borg/plugin-mcp -> @borg/plugin-mcp-apps -> @borg/plugin-mcp
kernel plugin-manager.ts imports @borg/plugin-feedback/contract packages/kernel may import no @borg/plugin-* package other than @borg/plugin-sdk
plugin-sdk imports @borg/plugin-chat/contract packages/plugin-sdk may import no @borg/plugin-* package other than @borg/plugin-sdk
contracts root imports @borg/plugin-mcp/contract packages/contracts/src may import only zod and relative files inside that directory
import "./main" in plugins/feedback/src/contract.ts src/contract.ts may import only zod, @borg/contracts, @borg/contracts/<subpath>, or @borg/plugin-<name>/contract
a2a ./contract export removed @borg/plugin-a2a has src/contract.ts but does not export ./contract
defineEvent(...) in plugins/chat/src/main.ts ... calls defineCommand/defineEvent; only packages/contracts/src and plugins/*/src/contract.ts declare bus contracts
chat imports @borg/plugin-mcp-apps/main ... may import @borg/plugin-mcp-apps only as @borg/plugin-mcp-apps/contract
@borg/plugin-feedback dependency removed from graphs plugins/graphs/src/executor.ts imports @borg/plugin-feedback/contract but @borg/plugin-graphs does not list @borg/plugin-feedback in dependencies

#31 acceptance criteria

  • packages/contracts/src/index.ts contains no plugin-specific command or event definitions. Only the kernel-owned borg.channel.inboundMessage event remains.
  • mcp-app-csp.ts lives in plugins/mcp-apps.
  • pnpm check:boundaries passes, and the boundary rules are documented in docs/architecture.md.
  • pnpm typecheck, pnpm test and pnpm test:e2e pass.

Gates (Node 24, Linux), at 17b6454

  • pnpm install --frozen-lockfile: OK.
  • pnpm check:boundaries: pass. The lockfile diff is importer entries only: zod for four plugins, @borg/plugin-feedback for chat and graphs, and @borg/plugin-mcp for mcp-apps.
  • pnpm typecheck: pass.
  • pnpm test: 139 files, 933 tests passed.
  • xvfb-run pnpm test:e2e: 44 passed, 2 skipped, 0 failed.

Out of scope: the #45 items and #48.

Moves a2aGetStatus verbatim from the @borg/contracts root to
plugins/a2a/src/contract.ts, exported as ./contract. a2aConfigSchema,
a2aStatusSchema and their types stay in the root because the kernel's
A2AService uses them; the contract imports a2aStatusSchema from there.
The wire snapshot is untouched.
…back/contract

Moves feedbackAskInputSchema, feedbackAsk, feedbackRequested and
feedbackResolved verbatim from the @borg/contracts root to
plugins/feedback/src/contract.ts. chat and graphs now depend on
@borg/plugin-feedback/contract.

feedbackAnswerSchema and FeedbackAnswer stay in the root: the kernel's
InteractionService parses answers with it and the plugin SDK exposes the
type. The interaction kind, source and choice schemas stay for the same
reason.

The kernel's human-input rule is keyed on the borg.feedback plugin id and
the borg.feedback.ask command id, not on these schemas. The PluginManager
tests used the real definitions only as carriers of those ids, so they now
declare local stand-ins with the same ids (the file is already on the
define* allowlist) instead of making the kernel package depend on a
plugin. The duplicate-choice-id assertion on feedbackAskInputSchema moves
unchanged from the kernel's interaction-service test to
plugins/feedback/test/contract.test.ts. The wire snapshot is untouched.
Moves, verbatim, from the @borg/contracts root:

- to @borg/plugin-mcp/contract: the borg.mcp.* surface. That is
  mcpServerStatusSchema, mcpServerSnapshotSchema, mcpListServers,
  mcpGetStatus and mcpRefresh, plus the borg.mcp.appDiscovered event with
  the schemas its payload uses (mcpAppCspSchema, mcpAppPermissionsSchema,
  mcpAppToolSchema, MCP_APP_MAX_MESSAGE_BYTES). mcpAppSnapshotSchema also
  goes here because it is defined as that payload plus a version, and
  mcp's own tests parse it.
- to @borg/plugin-mcp-apps/contract: the borg.mcpApps.* surface. That is
  mcpAppsInvokeTool, mcpAppsCancelTool, mcpAppToolResponded and their
  request, argument and response schemas, plus MCP_APP_RENDERER_ID and
  MCP_APP_BRIDGE_CHANNEL.

mcp-apps now depends on @borg/plugin-mcp/contract, never the reverse. The
desktop's embedded-content protocol imports the bridge constants from the
two contracts.

mcpServerConfigSchema stays in the root because personaSchema embeds it.
So does its private mcpServerIdSchema. The mcp contract takes that same
instance through mcpServerConfigSchema.options[0].shape.id, so id
validation cannot drift, and no new schema is exported onto the wire
surface. The snapshot digests for mcpGetStatus, mcpRefresh and
appDiscovered confirm that it is identical.

The wire snapshot is untouched.
Moves packages/contracts/src/mcp-app-csp.ts, unchanged, to
plugins/mcp-apps/src/mcp-app-csp.ts, and its test to
plugins/mcp-apps/test/mcp-app-csp.test.ts. The @borg/contracts root
stops re-exporting it. Only mcp-apps and the desktop host use these CSP
and permission helpers, and the kernel does not.

mcp-apps publishes the file as @borg/plugin-mcp-apps/mcp-app-csp, built
to dist/mcp-app-csp.js. The desktop's embedded-content policy and
protocol import it from there, as #31 asks ("mcp-app-csp.ts lives in
plugins/mcp-apps"). It exports functions, not schemas, so the wire
snapshot is untouched.
…n-chat/contract

Moves embeddedContentRegistered verbatim from the @borg/contracts root to
plugins/chat/src/contract.ts. The event attaches embedded content to a
chat session. Chat consumes it, and producers such as mcp-apps already
depend on @borg/plugin-chat/contract.

embeddedContentSnapshotSchema and EmbeddedContentSnapshot stay in the
root because the plugin SDK's embedded-content renderer API exposes the
type. The event keeps its borg.embeddedContent.registered id, so the
wire snapshot is untouched.
… rule set

The Contracts section now lists what stays in the @borg/contracts root
and why (kernel types, and the only root event, the kernel-guarded
borg.channel.inboundMessage). It describes the one-way contract
dependencies (mcp-apps on mcp, embedded content in chat's contract) and
helper subpaths such as @borg/plugin-mcp-apps/mcp-app-csp. The CI list
spells out every rule check-plugin-boundaries enforces.
Per review, the mcp contract now imports mcpServerIdSchema from
@borg/contracts instead of reaching into
mcpServerConfigSchema.options[0].shape.id. The schema stays defined in
the root next to the persona MCP server config that uses it.

Sanctioned wire snapshot edit (approved by SE): exporting the schema
adds exactly one line to apps/desktop/test/wire-contract.snapshot.txt,
"schema mcpServerIdSchema 044fca3cfee9272f". No existing line or digest
changes; the command and event counts stay at 82 and 24.
@danielgerlag
danielgerlag merged commit fa68858 into main Sep 26, 2026
2 checks passed
@danielgerlag
danielgerlag deleted the refactor/contracts-split-4 branch September 26, 2026 13:51
danielgerlag added a commit that referenced this pull request Sep 26, 2026
docs/boundaries.md holds every rule scripts/check-plugin-boundaries.mjs and the other two check:boundaries scripts enforce, read from the scripts on main. It also corrects the ./contract tsconfig rule (the tsconfig named by the build script, with src/contract.ts, src/*.ts or src/**/*.ts in include) and lists the contracts subpaths as they are after #54.

docs/desktop.md holds the desktop setup and feature detail unchanged.

docs/plugin-authoring.md gets the same tsconfig correction and links to docs/boundaries.md. CONTRIBUTING.md links to it and states the /contract exception to the plugin import rule.

Part of #33
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Split @borg/contracts: plugins own their commands and events

1 participant