External agents over MCP¶
Agents outside your application, such as a coding assistant, a desktop assistant or another service, can join a workspace over the Model Context Protocol. artifactr.mcp (the mcp extra) serves one: artifacts are resources, commands are tools, and changes arrive as resource-updated notifications. An external agent is a participant like any other, with the same rules and the same attribution (ADR-0012).
Serving it¶
ArtifactrMcp wraps the MCP SDK's server. Mount its Streamable HTTP app in your application and run its lifespan inside the application's:
import contextlib
from collections.abc import AsyncGenerator
from fastapi import FastAPI
from mcp.server.mcpserver import Context
from artifactr.core import ExternalAgentActor, TenantId
from artifactr.mcp import ArtifactrMcp
async def resolve_client(ctx: Context) -> tuple[TenantId, ExternalAgentActor]:
client = await api_keys.verify((ctx.headers or {}).get("authorization")) # your authentication
return client.tenant_id, ExternalAgentActor(client_id=client.id, name=client.name)
mcp = ArtifactrMcp(workspaces, runner, resolve=resolve_client)
@contextlib.asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
async with mcp.lifespan():
yield
app = FastAPI(lifespan=lifespan)
app.mount("/mcp", mcp.http_app(streamable_http_path="/"))
Clients then connect to https://your-host/mcp/ with any MCP client that speaks Streamable HTTP. mcp.server is the underlying MCPServer, if you need to serve it another way.
resolve(ctx)authenticates each request and returns the client's tenant and itsExternalAgentActor.ctx.headersholds the HTTP request's headers. They are the client's own claims, so verify a credential rather than trusting a name.runnercarries out messages, so an external agent can talk to the thread's built-in agent: itspost_messagestarts, steers or answers a run like any other message.nameis the server's name ("artifactr"), andbusis where resource notifications go (in-process by default).
What a client sees¶
The server's instructions tell the client how to behave: read before changing, prefer small edits, pass the version it read, and expect some types to take proposals only.
| Tool | Does |
|---|---|
list_artifacts(workspace_id, kind=None) |
Lists the workspace's artifacts |
read_artifact(workspace_id, artifact_id) |
Reads an artifact's current version, rendered for agents |
create_artifact(workspace_id, kind, data) |
Creates an artifact of a type the workspace accepts |
edit_text(workspace_id, artifact_id, old, new, base_version=None, field="text", summary=None, propose=False, rationale=None) |
Replaces one exact passage of a text field |
edit_artifact(workspace_id, artifact_id, base_version, ops, summary=None, propose=False, rationale=None) |
Applies JSON Patch operations |
archive_artifact(workspace_id, artifact_id) |
Archives an artifact |
list_proposals(workspace_id) |
Lists the proposals awaiting review |
respond_to_proposal(workspace_id, proposal_id, decision, reason=None) |
Accepts or rejects someone else's proposal |
post_message(workspace_id, thread_id, content) |
Posts a message in a thread |
A rejection comes back as a tool error carrying its message, such as a version conflict telling the client to read again.
Each artifact is also a resource at artifactr://{tenant_id}/{workspace_id}/artifacts/{artifact_id}, whose content is the artifact's current version as JSON. A client may read resources of its own tenant only. Once a client has used a workspace, every change to its artifacts is published as a resource-updated notification for that URI, so subscribed clients know to read again. artifact_uri(tenant_id, workspace_id, artifact_id) builds the URI.
Rules for external agents¶
External agents are agents: write policies bind them, so a change to a "propose" type becomes a proposal, exactly as for the built-in agent. Their actions are attributed to their ExternalAgentActor, so people, the built-in agent and other clients see who changed what. They cannot answer their own proposals, and they do not record facts about the built-in agent's runs.
Trying it¶
The MCP SDK's client can connect in-process, which is also how the library's own tests exercise the server:
from mcp import Client
async with Client(mcp.server) as client:
result = await client.call_tool("list_artifacts", {"workspace_id": "launch"})
print(result.content[0].text)
The reference implementation serves MCP at /mcp next to its WebSocket and REST endpoints.