SmolForgeField Notes

Notes on systems,
code, and craft.

All notes

Product architecture

Forge Agents Now Speak MCP

The hard part was adding the protocol without creating a second agent authority.

Forge shipped a stateless MCP adapter without creating a second thread model, runtime, or repository authorization boundary.

Forge's repository agents now speak MCP through four stateless tools at POST /mcp/agents. Compatible hosts can create or continue an Instant run, read its durable thread, list visible threads, and resume events from a cursor. The tools return the same Forge thread and run identifiers used by the web app and REST API.

The hard part was not registering tools with the MCP SDK. It was adding a new protocol without creating a second agent authority. Forge already owned durable repository-agent threads, exact-source runs, event cursors, budgets, approvals, and authorization records. MCP had to reach that system without copying it.

The easiest demo would have made the MCP session itself the agent: connect, create protocol-owned conversation state, and let that connection decide what survives. Reconnecting through another client could then change the identity, cursor, or repository scope of work Forge had already accepted. Instead, REST and MCP construct the same RepositoryAgentService. The first MCP slice is deliberately small—four Instant-only tools, Forge bearer credentials, and polling instead of MCP Tasks—but it establishes the boundary every later tool must preserve.

The hard part was avoiding a second agent

A repository agent has state that outlives one protocol request. Forge records a durable thread, one message and run per accepted turn, the exact source ref and SHA, an ordered event cursor, actor attribution, budget, receipt, and any verified artifacts. Database constraints keep the repository ID attached across those records.

MCP does not need to recreate that model. It needs to let a compatible host invoke it.

This distinction avoids two bad outcomes. If MCP owned a parallel thread model, REST and the Forge web app could disagree with external hosts about run state. If MCP were the only public API, ordinary application operations such as pagination, event recovery, or typed resource reads would have to hide inside Forge-specific tools. In both cases the protocol would leak into the domain.

The design rule is narrower: MCP carries a request; Forge decides what that request means and whether it is allowed.

One service, two transports

The decisive implementation change was not the /mcp/agents route. It was extracting RepositoryAgentService from the REST handlers in commit cddbd6a.

The service is constructed only after Forge has resolved a repository and an actor. Its methods create and read threads, accept messages, list cursorable events, cancel or resume runs, resolve approvals, and archive threads. It also freezes the selected source before recording a run and hands accepted work to the dispatcher. Neither transport writes agent tables directly.

Forge Agents speak MCP through one authority. The new transport broadens compatibility while repository grants and durable state remain inside Forge.

The REST refactor matters more than shared helper functions. It creates one place for invariants. A future Slack adapter, for example, should not invent how a source ref becomes an exact SHA or when accepted work enters the dispatcher. It should receive an authorized scope and call this service.

Phase 1 exposes four tools

The MCP handler uses the official TypeScript server SDK and its stateless compatibility mode. It registers four tools:

forge_agent_message       create or continue a thread; accept one Instant run
forge_agent_get_thread    read messages and runs for one thread
forge_agent_list_threads  list recent threads visible to the caller
forge_agent_list_events   read durable events after a cursor

forge_agent_message accepts an owner, repository, message, optional agent_thread_... ID, and optional canonical refs/heads/... source ref. When the client omits a thread ID, Forge assigns one. The tool returns the accepted message and run plus the arguments for a follow-up forge_agent_get_thread call.

The response uses the same identifiers as REST because there is no MCP-specific persistence layer. A thread is still agent_thread_...; a run is still agent_run_...; the source SHA and later receipt still belong to that run. The tool result is a projection of Forge records, not a protocol-owned copy.

Authorization precedes every domain call

Stateless does not mean unauthenticated. The handler applies several checks before it creates RepositoryAgentService:

  1. The endpoint accepts only POST requests. A browser request with a foreign Origin is rejected.
  2. Forge authenticates a bearer credential and retains the user or service-principal identity for attribution.
  3. The requested tool must match an agent:read or agent:message scope.
  4. Forge resolves the named owner and repository.
  5. A repository-bound personal access token must name its repository. A service principal must hold the required explicit grant for that repository.
  6. Only then does Forge construct a service scoped to that repository ID, actor, and default branch.

Tool arguments cannot supply a tenant ID, provider credential, model endpoint, or compute binding. An agent_thread_... handle is also not authority. The repository remains part of every service call, so copying a valid-looking handle into a request for another repository cannot widen access.

The MCP tests make this failure case explicit. A repo-bound credential pointed at a different repository receives repository_not_found, and the mocked service never reads the thread. The same suite checks missing credentials, cross-origin browser requests, tool discovery, and durable message acceptance. The current MCP, REST-route, and agent-contract test selection passes 15 tests across three files.

Acceptance is asynchronous

The message tool does not wait for an answer. It normalizes the input, resolves an Instant-only execution ceiling, asks the shared service to accept the message, and returns once Forge has durable IDs for the message and run. Dispatch continues after the request.

This is an honest fit for the implementation. Repository-agent work can outlive an HTTP request, and Forge already has a durable event log with an after cursor. A client can read the thread for authoritative state or request events after its last observed cursor. Reconnecting does not depend on a sticky server instance.

It also avoids pretending that synchronous MCP tool execution is a durable job protocol. The server description tells clients to poll forge_agent_get_thread or forge_agent_list_events; the message result includes the next thread lookup. That fallback is less elegant than native asynchronous Tasks, but its recovery semantics are already backed by Forge's event records.

What has not shipped

The narrow surface leaves real gaps:

  • MCP Tasks are not mapped to Forge runs.
  • OAuth protected-resource discovery is not present; clients use a Forge personal access token or service-principal key.
  • Resource templates and change subscriptions are not exposed.
  • MCP has no cancellation, resume, approval, archive, or artifact tools yet, although the shared service and REST API contain several of those operations.
  • forge_agent_message hard-codes the Instant read-only profile. Workspace Alpha is not reachable through MCP.

These are limitations, not hidden compatibility claims. The endpoint advertises only the operations it implements, and older Streamable HTTP clients receive the SDK's stateless fallback. A host must discover the available tools instead of assuming support for a protocol extension or a Forge compute tier.

The implementation order should continue from the same rule: add a domain operation to the shared authority first, then expose the smallest faithful projection in each transport. Tasks become useful when their lifecycle maps cleanly to a Forge run. OAuth becomes useful when it preserves Forge's repository and service-principal grants rather than bypassing them.

The next proof is cross-protocol

Phase 1 proves the structural boundary in source and tests: both transports converge on one repository-scoped service, and the MCP adapter cannot use a repo-bound credential to cross that scope. It does not yet prove broad host compatibility or lifecycle parity.

The next useful test is an end-to-end run from an external MCP host paired with a REST read of the same thread. Both paths should observe the same thread and run IDs, exact source SHA, terminal events, and receipt. A disconnect during execution should recover from the Forge event cursor without creating a second run.

Forge Agents now speak MCP because interoperability stays at the edge and authority stays underneath it. The protocol makes the agents easier to reach without creating another place where repository scope, execution history, or approval truth can drift.