MCP Apps Should Work in the Terminal Too

Open LLM-readable version of this post Open translated version of this post

A proposal for native terminal views over MCP, with a working plane seat picker in HTML, a TUI, and elicitation forms.

MCP Apps Should Work in the Terminal Too

Back in May 2025, I wrote about LLMs generating UIs during a conversation. I had a prototype where the model generated JSON and the client turned it into forms, buttons, and other UI components. That was before elicitation was added to MCP and before MCP Apps was proposed.

The post got a lot of attention and discussion on Hacker News and social media. I still think the idea makes sense. Sometimes I want the agent to show me an interface so I can make the choice myself.

Since then, I’ve been building MCP Apps for Spacefast, and I like where this is going.

But I also spend a lot of time in CLI harnesses. Codex CLI, Claude Code, Copilot CLI. I want these apps there too.

I mean inside the terminal. A seat map, a split view, a colorized diff, a file tree with a preview next to it. Something I can use with my keyboard while staying in the same conversation.

I built a seat picker with an HTML view and a terminal view to show what I mean.

Let me choose my seat

Imagine asking an agent:

Show me the available seats for my flight to London. I’d like a window seat with more legroom.

The agent can return a list. It can also recommend 14A. But I might want to see where 14A actually is. Is it near the front? Where is the aisle? What is available around it?

An HTML MCP App is a good fit for this.

HTML MCP App showing a cabin map with seat 14A selected, a window seat with extra legroom for €24. Selecting 14A in the HTML app updates the seat details through MCP.

The HTML experience has space for a cabin shape, seat buttons, flight details, and a confirmation card. That makes sense in a graphical host.

The current MCP Apps specification connects tools to UI resources through _meta.ui.resourceUri. The resource uses text/html;profile=mcp-app, and the app communicates with the host through a bridge. The current specification defines HTML rendering; other content types are left for future extensions.

That is the gap I want to explore.

Elicitation gets us part of the way

MCP already has a way to ask the user for structured input: elicitation.

For this example, the server can send a form request with a seat field. Here is a shortened version with two choices:

{
  "jsonrpc": "2.0",
  "id": 7,
  "method": "elicitation/create",
  "params": {
    "mode": "form",
    "message": "Choose a seat for DEMO 204.",
    "requestedSchema": {
      "type": "object",
      "properties": {
        "seatId": {
          "type": "string",
          "title": "Available seats",
          "oneOf": [
            { "const": "14A", "title": "14A · Window · Extra legroom · €24" },
            { "const": "14C", "title": "14C · Aisle · Extra legroom · €24" }
          ]
        }
      },
      "required": ["seatId"]
    }
  }
}

The host decides how to render that input. My demo renders it as a keyboard-controlled list, then asks for confirmation in a second form.

MCP elicitation rendered as a terminal list of available seats, with seat 14A highlighted. The same seat selection as an elicitation form.

Elicitation can produce useful forms through the host. Its schema describes the input you need, but it doesn’t define a seat grid, linked panes, or a diff viewer.

For “pick one of these options,” this is useful. I just don’t want every app to become a sequence of questions.

A terminal can show more than a list

Here is the same example with a terminal-specific view:

Native terminal seat picker with a cabin grid on the left and seat 14A, window position, extra legroom, and €24 price in a right-hand details pane. The terminal view: a seat grid on the left and details on the right.

Arrow keys move the cursor. Space selects a seat. Enter confirms the selection. W dims non-window seats and limits cursor movement to window seats. Occupied seats stay visible because their position still matters.

The terminal experience can have its own behavior. It doesn’t need to reproduce the HTML cabin shape. It needs to preserve the information that helps me choose.

The same idea works for development tools. A deployment app could show versions on the left and changes on the right. A review app could show a file tree and a colorized diff. A log viewer could keep filters visible while the output updates.

My proposal: one app, separate views

I would keep MCP as the transport and let an app offer more than one representation.

The HTML resource stays as it is. A second resource describes the terminal view as JSON. The host advertises which terminal catalogs it can render, and the server offers a compatible view. A catalog defines the components and properties the host understands.

I’d keep the format small: a view, a set of components, state bindings, and named actions. The host would turn those components into native terminal controls.

For this proposal, the seat picker needs just three components: SplitPane, SeatGrid, and SeatDetails.

A tool could advertise both resources:

{
  "name": "show_seats",
  "inputSchema": { "type": "object", "properties": {} },
  "_meta": {
    "ui": {
      "resourceUri": "ui://seat-demo/seat-map.html"
    },
    "dev.fka/terminal-ui": {
      "resourceUri": "ui://seat-demo/seat-map.json",
      "mimeType": "application/vnd.fka.terminal-ui+json",
      "catalogId": "dev.fka:terminal-seat-demo/0.1"
    }
  }
}

A terminal host opts in during MCP initialization:

{
  "capabilities": {
    "extensions": {
      "dev.fka/terminal-ui": {
        "catalogs": ["dev.fka:terminal-seat-demo/0.1"]
      }
    }
  }
}

The host and server agree on which components can be rendered. A host that doesn’t understand this catalog can use another representation.

Here is one possible shape for the terminal resource. The first message creates the view, and the second describes its components:

[
  {
    "version": "0.1",
    "createSurface": {
      "surfaceId": "seat-picker",
      "catalogId": "dev.fka:terminal-seat-demo/0.1"
    }
  },
  {
    "version": "0.1",
    "updateComponents": {
      "surfaceId": "seat-picker",
      "components": [
        {
          "id": "root",
          "component": "SplitPane",
          "children": ["cabin", "details"],
          "ratio": 0.6
        },
        {
          "id": "cabin",
          "component": "SeatGrid",
          "seats": { "path": "/seats" },
          "selected": { "path": "/selected" },
          "onSelect": "select_seat"
        },
        {
          "id": "details",
          "component": "SeatDetails",
          "selected": { "path": "/selected" },
          "onConfirm": "confirm_seat"
        }
      ]
    }
  }
]

The seat inventory comes separately in updateDataModel messages, carried by embedded MCP resources with the same application/vnd.fka.terminal-ui+json MIME type. Both components read the same selected-seat value. Choosing another seat changes the data; it doesn’t need to rebuild the entire interface or lose keyboard focus.

In this example, each action returns a complete data snapshot.

The actions still go through MCP

When I confirm 14A, the renderer calls the seat_action tool with an action payload:

{
  "name": "seat_action",
  "arguments": {
    "name": "confirm_seat",
    "surfaceId": "seat-picker",
    "sourceComponentId": "details",
    "timestamp": "2026-09-09T10:00:00.000Z",
    "context": {
      "seatId": "14A",
      "expectedRevision": 1,
      "price": 24
    }
  }
}

This is the params object of a tools/call request. The action names and context fields belong to this app.

The server checks the seat and price against its own state. It rejects occupied seats, old revisions, and confirmation of a different price. Selection doesn’t confirm anything. Both renderers use the same checks.

The host handles focus, key bindings, resizing, and tool calls. Each component name maps to a native renderer in the host.

For a shared terminal catalog, I would start with rows, columns, text, lists, tables, inputs, tabs, and split panes. Then add explicit contracts for things like a file tree and a diff viewer. Labels and symbols can distinguish selected, occupied, added, and removed states alongside colors.

Each host would implement the shared components once, so different apps could compose them into their own views.

What would Codex, Claude Code, and Copilot need?

For this to work inside Codex CLI, Claude Code, or Copilot CLI, the harness needs to support the format.

Each harness would need to negotiate the capability, load the JSON resource, validate its catalog, mount a native view, and route user actions back through MCP. It would also need a clean way to close the view and return a short result to the conversation.

The wire format and behavior tests can be shared. The native rendering code might differ. I don’t expect every harness to use the same UI library.

Until a host supports the terminal view, the app can fall back to HTML where supported, then elicitation where appropriate, then a text result.

I want to ask an agent to show me something and then use the right interface for the place I’m already working. Sometimes that is HTML. Sometimes it is a seat map in my terminal. I think MCP Apps should have room for both.

Cookies