Skip to content

Build views and the interrupt gate on v2's own extensions #166

Open

Description

@ciaransweet

#165 ports the package to mcp 2 by keeping both of these exactly as they were. That was the right call for a migration — nothing about them broke — but v2 has first-class primitives for both, and both are ours to maintain until we move.

The two halves are independent; either can be done without the other.

1. views.py on the Apps extension

mcp_runtime.views hand-rolls the MCP Apps wire format: it stamps _meta.ui.resourceUri on the tool, registers the HTML as a ui://<toolset>/<view> resource, and serves it as text/html;profile=mcp-app. v2 ships that as mcp.server.apps, and our constants already agree with it exactly — APP_MIME_TYPE is the same string, EXTENSION_ID is io.modelcontextprotocol/ui.

So this is not a bug to fix. What the extension adds over the hand-roll:

  • Capability negotiation. An Apps extension advertises support, and client_supports_apps(ctx) lets a tool branch on whether the caller negotiated it. We advertise nothing, so a host cannot tell that our servers speak Apps, and a tool cannot degrade deliberately — it just returns the same thing either way and hopes.
  • Resource-level CSP, permissions and visibility (ResourceCsp, ResourcePermissions, Visibility), none of which we set today. Our views are served with no declared CSP at all — worth a look on its own terms, given a view renders third-party tiles (ecmwf/dss-agentic-ai-services#194).
  • ToolBinding / ResourceBinding in place of our model_copy(update={"meta": ...}) stamping and bare add_resource.
  • It tracks the spec. Apps is a draft extension; the wire format can still move. Following the SDK's implementation means it moves under us rather than through us.

The scaffold (mcp_toolset) and js/mcp-view would want to follow, and docs/VIEWS.md with them.

2. The interrupt gate and elicitation

These are not the same mechanism, and the issue is the seam between them rather than a duplicate to delete:

  • mcp_agent.interrupt_gate is agent-side and model-initiated — the model calls the interrupt tool to put a question to the user, and the value it raises is already in AG-UI's terms.
  • v2's Elicit is server-side and tool-initiated — a tool needs input mid-call and returns an InputRequiredResult, expecting the call to be retried with answers. At 2026-07-28 a server can no longer call the client, so this is the supported way for a tool to ask a question.

langchain.mcp.elicitation already drives that loop, and it answers each round with a LangGraph interrupt() — the same mechanism the gate uses. Since #165 wires MCPAdapter, which arms elicitation by default, a toolset tool that elicits would already raise an interrupt through our stack today.

The gap is the round trip. That interrupt's value is {"type": ..., "tool_name": ..., "requests": [...]} and the resume it waits for is {"responses": ...}. mcp_agent_api.events degrades correctly on the way out — a value it does not recognise goes out as input_required with the raw value under metadata — but nothing maps a client's answer back into the shape langchain.mcp expects, so resuming one would fail in _build_responses. Worth a test either way, because right now nothing exercises it.

What this would unlock is worth stating plainly: a tool that cannot proceed can ask, rather than returning a refusal and relying on the model to turn it into a question. The constraint refusals in the dss toolsets are the obvious candidate.

Order

Either half first. The views half is self-contained. The elicitation half should start with a failing test that a tool's elicitation survives the whole round trip, since that decides how much of the gate's plumbing is reusable.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions