You are viewing a plain text version of this content. The canonical link for it is here.
Posted to notifications@skywalking.apache.org by wu...@apache.org on 2026/09/27 03:08:11 UTC

(skywalking-horizon-ui) branch main updated: feat(conversation-view): draw calls to MCP servers on their own lane, with what they did (#175)

This is an automated email from the ASF dual-hosted git repository.

wu-sheng pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/skywalking-horizon-ui.git


The following commit(s) were added to refs/heads/main by this push:
     new 43235adc feat(conversation-view): draw calls to MCP servers on their own lane, with what they did (#175)
43235adc is described below

commit 43235adcc929066f3ef8385f7de58d97abd3a293
Author: 吴晟 Wu Sheng <wu...@foxmail.com>
AuthorDate: Sun Sep 27 11:08:06 2026 +0800

    feat(conversation-view): draw calls to MCP servers on their own lane, with what they did (#175)
    
    * feat(conversation-view): draw calls to MCP servers on their own lane, with what they did
    
    A call to an MCP server is a tool step whose attrs name its server and
    tool, mcp_server and mcp_tool, where the runtime's name split exactly. It
    is now drawn on its own MCP lane of the flow timeline, and its card is
    named by the server and the tool instead of mcp__<server>__<tool>. A
    name that splits two ways names no server and stays on the Tools lane.
    
    The document's tool_executions are joined to their steps by their step,
    as workspace changes are. A call an observer reported wears how the first
    observation ended and the time the runtime measured around it, and the
    inspector gains an Execution tab listing every observation: the observer,
    the server by its configured name and where that came from, the outcome,
    the measured time, when it ended, the size and SHA-256 of what was sent
    and what came back, and where the record was read from. The measured
    time includes any waiting before the call, so the tab says it is not the
    server's own time.
    
    The fixture is the AI Sessionizer's mcp-calls scenario, as the OAP holds
    it.
    
    * fix(conversation-view): write record positions as text, and say only what an execution record says
    
    A record's landed position is written as text in the Execution and the
    Changes tabs, as every other value from the document is. A document is
    data, so a reference is never taken to be a number because the format
    says it is one.
    
    The measured time is the runtime's, taken around the call; the tab and
    the docs no longer say where it starts and ends, which the record does
    not say. An execution record opened in Evidence is labelled as one, not
    as a change record. The docs no longer point at a layer tab this change
    does not add.
    
    * fix(conversation-view): leave the Changes tab as it was
    
    This change is about calls to MCP servers; the Changes tab and its tests
    are back as they are on main.
---
 apps/ui/src/i18n/locales/de.json                   |   17 +-
 apps/ui/src/i18n/locales/en.json                   |   17 +-
 apps/ui/src/i18n/locales/es.json                   |   17 +-
 apps/ui/src/i18n/locales/fr.json                   |   17 +-
 apps/ui/src/i18n/locales/ja.json                   |   17 +-
 apps/ui/src/i18n/locales/ko.json                   |   17 +-
 apps/ui/src/i18n/locales/pt.json                   |   17 +-
 apps/ui/src/i18n/locales/zh-CN.json                |   17 +-
 .../layer/ai-conversation/conversationStrings.ts   |   17 +
 docs/changelog/1.1.0.md                            |    2 +
 docs/operate/ai-agent-conversations.md             |   11 +-
 packages/conversation-view/src/index.ts            |    1 +
 packages/conversation-view/src/model.ts            |   27 +-
 packages/conversation-view/src/strings.ts          |   36 +
 packages/conversation-view/src/styles.css          |    3 +
 packages/conversation-view/src/types.ts            |   51 +
 packages/conversation-view/src/view/context.ts     |    2 +-
 packages/conversation-view/src/view/executions.ts  |   92 ++
 packages/conversation-view/src/view/inspector.ts   |   27 +-
 packages/conversation-view/src/view/timeline.ts    |    5 +-
 packages/conversation-view/src/view/transcript.ts  |   19 +-
 packages/conversation-view/src/vocabulary.ts       |    5 +-
 .../conversation-view/test/fixtures/mcp-calls.json | 1307 ++++++++++++++++++++
 packages/conversation-view/test/mcp.test.ts        |  174 +++
 24 files changed, 1892 insertions(+), 23 deletions(-)

diff --git a/apps/ui/src/i18n/locales/de.json b/apps/ui/src/i18n/locales/de.json
index 508fc61d..cfaafa4c 100644
--- a/apps/ui/src/i18n/locales/de.json
+++ b/apps/ui/src/i18n/locales/de.json
@@ -3419,5 +3419,20 @@
   "Now": "Jetzt",
   "edge metrics queried as": "Kantenmetriken, abgefragt als",
   "No server-side metrics.": "Keine serverseitigen Metriken.",
-  "No client-side metrics.": "Keine clientseitigen Metriken."
+  "No client-side metrics.": "Keine clientseitigen Metriken.",
+  "Execution": "Ausführung",
+  "What the observer around this call saw it do — open the Execution tab": "Was der Beobachter um diesen Aufruf herum gesehen hat — Tab „Ausführung“ öffnen",
+  "{n} more": "{n} weitere",
+  "{n} execution records — open the Execution tab →": "{n} Ausführungsdatensätze — Tab „Ausführung“ öffnen →",
+  "MCP server": "MCP-Server",
+  "MCP tool": "MCP-Werkzeug",
+  "Observed by": "Beobachtet von",
+  "Configured in": "Konfiguriert in",
+  "Measured time": "Gemessene Zeit",
+  "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "Die Zeit, die die Laufzeit um den Aufruf gemessen hat. Sie enthält jedes Warten vor dem Start des Aufrufs und ist daher nicht die eigene Zeit des Servers.",
+  "not reported": "nicht gemeldet",
+  "Sent to the server": "An den Server gesendet",
+  "Came back": "Zurückgekommen",
+  "{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
+  "execution record": "Ausführungsdatensatz"
 }
diff --git a/apps/ui/src/i18n/locales/en.json b/apps/ui/src/i18n/locales/en.json
index dc83a3fb..1eb52063 100644
--- a/apps/ui/src/i18n/locales/en.json
+++ b/apps/ui/src/i18n/locales/en.json
@@ -3419,5 +3419,20 @@
   "Now": "Now",
   "edge metrics queried as": "edge metrics queried as",
   "No server-side metrics.": "No server-side metrics.",
-  "No client-side metrics.": "No client-side metrics."
+  "No client-side metrics.": "No client-side metrics.",
+  "Execution": "Execution",
+  "What the observer around this call saw it do — open the Execution tab": "What the observer around this call saw it do — open the Execution tab",
+  "{n} more": "{n} more",
+  "{n} execution records — open the Execution tab →": "{n} execution records — open the Execution tab →",
+  "MCP server": "MCP server",
+  "MCP tool": "MCP tool",
+  "Observed by": "Observed by",
+  "Configured in": "Configured in",
+  "Measured time": "Measured time",
+  "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.",
+  "not reported": "not reported",
+  "Sent to the server": "Sent to the server",
+  "Came back": "Came back",
+  "{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
+  "execution record": "execution record"
 }
diff --git a/apps/ui/src/i18n/locales/es.json b/apps/ui/src/i18n/locales/es.json
index 22e328ce..53624abc 100644
--- a/apps/ui/src/i18n/locales/es.json
+++ b/apps/ui/src/i18n/locales/es.json
@@ -3419,5 +3419,20 @@
   "Now": "Ahora",
   "edge metrics queried as": "métricas de arista consultadas como",
   "No server-side metrics.": "Sin métricas del lado servidor.",
-  "No client-side metrics.": "Sin métricas del lado cliente."
+  "No client-side metrics.": "Sin métricas del lado cliente.",
+  "Execution": "Ejecución",
+  "What the observer around this call saw it do — open the Execution tab": "Lo que el observador alrededor de esta llamada vio que hizo — abrir la pestaña Ejecución",
+  "{n} more": "{n} más",
+  "{n} execution records — open the Execution tab →": "{n} registros de ejecución — abrir la pestaña Ejecución →",
+  "MCP server": "Servidor MCP",
+  "MCP tool": "Herramienta MCP",
+  "Observed by": "Observado por",
+  "Configured in": "Configurado en",
+  "Measured time": "Tiempo medido",
+  "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "El tiempo que el runtime midió alrededor de la llamada. Incluye cualquier espera antes de que empezara la llamada, así que no es el tiempo propio del servidor.",
+  "not reported": "no informado",
+  "Sent to the server": "Enviado al servidor",
+  "Came back": "Devuelto",
+  "{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
+  "execution record": "registro de ejecución"
 }
diff --git a/apps/ui/src/i18n/locales/fr.json b/apps/ui/src/i18n/locales/fr.json
index f8bce588..9a2c0acb 100644
--- a/apps/ui/src/i18n/locales/fr.json
+++ b/apps/ui/src/i18n/locales/fr.json
@@ -3419,5 +3419,20 @@
   "Now": "Maintenant",
   "edge metrics queried as": "métriques de lien interrogées en tant que",
   "No server-side metrics.": "Aucune métrique côté serveur.",
-  "No client-side metrics.": "Aucune métrique côté client."
+  "No client-side metrics.": "Aucune métrique côté client.",
+  "Execution": "Exécution",
+  "What the observer around this call saw it do — open the Execution tab": "Ce que l’observateur autour de cet appel l’a vu faire — ouvrir l’onglet Exécution",
+  "{n} more": "{n} de plus",
+  "{n} execution records — open the Execution tab →": "{n} enregistrements d’exécution — ouvrir l’onglet Exécution →",
+  "MCP server": "Serveur MCP",
+  "MCP tool": "Outil MCP",
+  "Observed by": "Observé par",
+  "Configured in": "Configuré dans",
+  "Measured time": "Temps mesuré",
+  "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "Le temps que le runtime a mesuré autour de l’appel. Il inclut toute attente avant le début de l’appel, ce n’est donc pas le temps propre du serveur.",
+  "not reported": "non rapporté",
+  "Sent to the server": "Envoyé au serveur",
+  "Came back": "Retour",
+  "{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
+  "execution record": "enregistrement d’exécution"
 }
diff --git a/apps/ui/src/i18n/locales/ja.json b/apps/ui/src/i18n/locales/ja.json
index 36c4e5fe..e34973b7 100644
--- a/apps/ui/src/i18n/locales/ja.json
+++ b/apps/ui/src/i18n/locales/ja.json
@@ -3419,5 +3419,20 @@
   "Now": "現在",
   "edge metrics queried as": "エッジメトリクスの問い合わせ対象:",
   "No server-side metrics.": "サーバー側メトリクスがありません。",
-  "No client-side metrics.": "クライアント側メトリクスがありません。"
+  "No client-side metrics.": "クライアント側メトリクスがありません。",
+  "Execution": "実行",
+  "What the observer around this call saw it do — open the Execution tab": "この呼び出しの周囲の観測者が見た内容 — 「実行」タブを開く",
+  "{n} more": "ほか {n} 件",
+  "{n} execution records — open the Execution tab →": "{n} 件の実行レコード — 「実行」タブを開く →",
+  "MCP server": "MCP サーバー",
+  "MCP tool": "MCP ツール",
+  "Observed by": "観測者",
+  "Configured in": "設定元",
+  "Measured time": "計測時間",
+  "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "ランタイムが呼び出しの前後で測った時間です。呼び出しが始まる前の待ち時間も含むため、サーバー自身の時間ではありません。",
+  "not reported": "報告なし",
+  "Sent to the server": "サーバーへ送信",
+  "Came back": "返ってきた値",
+  "{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
+  "execution record": "実行レコード"
 }
diff --git a/apps/ui/src/i18n/locales/ko.json b/apps/ui/src/i18n/locales/ko.json
index c48cb5a5..41504446 100644
--- a/apps/ui/src/i18n/locales/ko.json
+++ b/apps/ui/src/i18n/locales/ko.json
@@ -3419,5 +3419,20 @@
   "Now": "지금",
   "edge metrics queried as": "간선 메트릭 조회 대상:",
   "No server-side metrics.": "서버 측 메트릭이 없습니다.",
-  "No client-side metrics.": "클라이언트 측 메트릭이 없습니다."
+  "No client-side metrics.": "클라이언트 측 메트릭이 없습니다.",
+  "Execution": "실행",
+  "What the observer around this call saw it do — open the Execution tab": "이 호출 주변의 관찰자가 본 내용 — 실행 탭 열기",
+  "{n} more": "{n}건 더",
+  "{n} execution records — open the Execution tab →": "실행 레코드 {n}건 — 실행 탭 열기 →",
+  "MCP server": "MCP 서버",
+  "MCP tool": "MCP 도구",
+  "Observed by": "관찰자",
+  "Configured in": "설정 위치",
+  "Measured time": "측정 시간",
+  "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "런타임이 호출 전후로 측정한 시간입니다. 호출이 시작되기 전의 대기 시간도 포함하므로 서버 자체의 시간이 아닙니다.",
+  "not reported": "보고되지 않음",
+  "Sent to the server": "서버로 전송",
+  "Came back": "돌아온 값",
+  "{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
+  "execution record": "실행 레코드"
 }
diff --git a/apps/ui/src/i18n/locales/pt.json b/apps/ui/src/i18n/locales/pt.json
index b3b71f9e..24f86d59 100644
--- a/apps/ui/src/i18n/locales/pt.json
+++ b/apps/ui/src/i18n/locales/pt.json
@@ -3419,5 +3419,20 @@
   "Now": "Agora",
   "edge metrics queried as": "métricas de aresta consultadas como",
   "No server-side metrics.": "Nenhuma métrica do lado servidor.",
-  "No client-side metrics.": "Nenhuma métrica do lado cliente."
+  "No client-side metrics.": "Nenhuma métrica do lado cliente.",
+  "Execution": "Execução",
+  "What the observer around this call saw it do — open the Execution tab": "O que o observador em torno desta chamada viu que ela fez — abrir a aba Execução",
+  "{n} more": "mais {n}",
+  "{n} execution records — open the Execution tab →": "{n} registros de execução — abrir a aba Execução →",
+  "MCP server": "Servidor MCP",
+  "MCP tool": "Ferramenta MCP",
+  "Observed by": "Observado por",
+  "Configured in": "Configurado em",
+  "Measured time": "Tempo medido",
+  "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "O tempo que o runtime mediu em torno da chamada. Inclui qualquer espera antes de a chamada começar, portanto não é o tempo do próprio servidor.",
+  "not reported": "não informado",
+  "Sent to the server": "Enviado ao servidor",
+  "Came back": "Retornado",
+  "{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
+  "execution record": "registro de execução"
 }
diff --git a/apps/ui/src/i18n/locales/zh-CN.json b/apps/ui/src/i18n/locales/zh-CN.json
index 1c82760f..4e3fa5e2 100644
--- a/apps/ui/src/i18n/locales/zh-CN.json
+++ b/apps/ui/src/i18n/locales/zh-CN.json
@@ -3419,5 +3419,20 @@
   "Now": "此刻",
   "edge metrics queried as": "连线指标,查询范围为",
   "No server-side metrics.": "尚无服务端指标。",
-  "No client-side metrics.": "尚无客户端指标。"
+  "No client-side metrics.": "尚无客户端指标。",
+  "Execution": "执行",
+  "What the observer around this call saw it do — open the Execution tab": "此调用周围的观测者看到它做了什么 — 打开“执行”标签页",
+  "{n} more": "另外 {n} 条",
+  "{n} execution records — open the Execution tab →": "{n} 条执行记录 — 打开“执行”标签页 →",
+  "MCP server": "MCP 服务器",
+  "MCP tool": "MCP 工具",
+  "Observed by": "观测者",
+  "Configured in": "配置来源",
+  "Measured time": "测得时间",
+  "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.": "运行时在调用前后测得的时间。其中包含调用开始前的等待,因此不是服务器自身的时间。",
+  "not reported": "未报告",
+  "Sent to the server": "发送给服务器",
+  "Came back": "返回",
+  "{bytes} B · SHA-256 {digest}": "{bytes} B · SHA-256 {digest}",
+  "execution record": "执行记录"
 }
diff --git a/apps/ui/src/layer/ai-conversation/conversationStrings.ts b/apps/ui/src/layer/ai-conversation/conversationStrings.ts
index 46d8c4ec..2b64703c 100644
--- a/apps/ui/src/layer/ai-conversation/conversationStrings.ts
+++ b/apps/ui/src/layer/ai-conversation/conversationStrings.ts
@@ -77,6 +77,7 @@ export function conversationStrings(t: Translate): ViewStrings {
     reasoning: t('Reasoning'),
     toolWord: t('Tool'),
     callWord: t('call'),
+    mcpWord: t('MCP'),
     turn: t('turn'),
     input: t('input'),
     result: t('result'),
@@ -113,6 +114,7 @@ export function conversationStrings(t: Translate): ViewStrings {
     laneContext: t('Context put in'),
     laneModel: t('Model calls'),
     laneTools: t('Tools'),
+    laneMcp: t('MCP'),
     laneAgents: t('Agent activity'),
     laneNotices: t('Runtime notices'),
     laneNested: t('Nested streams'),
@@ -228,6 +230,21 @@ export function conversationStrings(t: Translate): ViewStrings {
     close: t('Close'),
     whatDoesMean: t('What does {key} mean?', { key: '{key}' }),
     changes: t('Changes'),
+    execution: t('Execution'),
+    executionPillTitle: t('What the observer around this call saw it do — open the Execution tab'),
+    moreObservations: t('{n} more', { n: '{n}' }),
+    executionRecordsForStep: t('{n} execution records — open the Execution tab →', { n: '{n}' }),
+    executionRecordRef: t('execution record'),
+    mcpServer: t('MCP server'),
+    mcpTool: t('MCP tool'),
+    observedBy: t('Observed by'),
+    configuredIn: t('Configured in'),
+    measuredTime: t('Measured time'),
+    measuredTimeText: t("The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time."),
+    notReported: t('not reported'),
+    sentToServer: t('Sent to the server'),
+    cameBack: t('Came back'),
+    sizeAndDigest: t('{bytes} B · SHA-256 {digest}', { bytes: '{bytes}', digest: '{digest}' }),
     prompt: t('Prompt'),
     promptNotHere: t('The bodies of this call are not among the files this document lists.'),
     promptBothStored: t('The request this call sent and the response it received are stored.'),
diff --git a/docs/changelog/1.1.0.md b/docs/changelog/1.1.0.md
index b2d21e71..906782cf 100644
--- a/docs/changelog/1.1.0.md
+++ b/docs/changelog/1.1.0.md
@@ -28,6 +28,8 @@
 
 **The Prompt tab reads a LangChain or LangGraph agent's calls.** For such an agent the Sessionizer stores what LangChain handed its model, a list of LangChain's own serialized messages, and the tab drew each of those requests as one empty message. It now shows every message in LangChain's words, `system`, `human`, `ai` and `tool`, with the tool calls an `ai` message made, each drawn once with the arguments LangChain parsed even where the provider repeats it in the message content, and ea [...]
 
+**The conversation page shows what each call to an MCP server did.** A call to an MCP server is drawn on its own **MCP** lane of the flow timeline, and its card is named by the server and the tool instead of Claude Code's `mcp__<server>__<tool>`. When the asz Claude Code plugin observed the call, the card wears how it ended and the time the runtime measured around it, `returned · 380 ms`, and the inspector gains an **Execution** tab: the server by its configured name and where that confi [...]
+
 **The inspector says where a step sits in words.** Its Details tab opens with the step's place: the activity window and its number, the agent, the talk by its opening line and its place in the stream, the run's place in the talk, and the step by kind and name, instead of a chain of clipped identifiers; the identifiers stay in the tooltip.
 
 **Tool inputs and results read as fields.** On the conversation page, a tool call's card and its Details tab draw the input the agent recorded as the fields it holds instead of one escaped JSON string: a shell command as the lines that were run, an edit as its path and the text before and after, a result's output and error streams apart. A long text is cut short with a *show all* link that opens it in place, and where the document clipped the text a note says how much of it is there. Eve [...]
diff --git a/docs/operate/ai-agent-conversations.md b/docs/operate/ai-agent-conversations.md
index ff337e8d..6a5b9821 100644
--- a/docs/operate/ai-agent-conversations.md
+++ b/docs/operate/ai-agent-conversations.md
@@ -65,8 +65,8 @@ The page's address is the thing to share. It carries the conversation, its agent
 The page has three parts, and a header with the conversation's title, agent, agent runtime and id, a link back to the list, the theme chip and the language picker (the page follows your Horizon theme, the light one included, and every text of it is translated):
 
 - **Transcript** — the talks of one execution stream in reading order. What came from outside sits on the right; the agent's replies sit on the left; the work between an input and its reply (model calls, reasoning, tool uses, agent launches, context resets) is folded under a *show what the agent did* row so a long run reads as a conversation until you open it. Long pauses are marked. A tool call's input and result are drawn as the fields the runtime recorded rather than as one escaped st [...]
-- **Flow timeline** — the same stream on a time axis, one lane per kind of activity (external input, responses, context put in, model calls, tools, agent activity, runtime notices, nested streams). Busy stretches take the width; a long pause is cut to a marked gap. Selecting a step draws the relations that touch it: a solid line is an exact join, a dashed one was inferred, a faint curve is ownership (the model call that produced a step). A child agent appears as a nested stream you can s [...]
-- **Inspector** — the selected step's **Details**, opening with where the step sits in words a reader knows (the activity window and its place among them, the agent, the talk by its opening line, the run's place in it, and the step by kind and name; the document's own ids sit in the tooltip), then its kind, lane, stream, segment, run, parent, time, token counts, request-to-result interval where the runtime recorded it, and its input and result drawn as fields the way the transcript draws [...]
+- **Flow timeline** — the same stream on a time axis, one lane per kind of activity (external input, responses, context put in, model calls, tools, calls to MCP servers, agent activity, runtime notices, nested streams). Busy stretches take the width; a long pause is cut to a marked gap. Selecting a step draws the relations that touch it: a solid line is an exact join, a dashed one was inferred, a faint curve is ownership (the model call that produced a step). A child agent appears as a n [...]
+- **Inspector** — the selected step's **Details**, opening with where the step sits in words a reader knows (the activity window and its place among them, the agent, the talk by its opening line, the run's place in it, and the step by kind and name; the document's own ids sit in the tooltip), then its kind, lane, stream, segment, run, parent, time, token counts, request-to-result interval where the runtime recorded it, and its input and result drawn as fields the way the transcript draws [...]
 
 ### Changes
 
@@ -82,6 +82,12 @@ The status strip at the top states the document's integrity — *verified* when
 
 What the page shows is exactly what the Sessionizer's own viewer shows for the same conversation: the two draw the same document with the same renderer. The words the runtime uses for things — node kinds such as `message.external`, relation types, join qualities — are shown as the document carries them, in every locale.
 
+### Calls to MCP servers
+
+A call to an MCP server is drawn on its own lane of the flow timeline, **MCP**, apart from the runtime's built-in tools, and its card is named by the server and the tool: the role reads *→ MCP · status* and the title *status · lookup*. These are the runtime's names, split from Claude Code's `mcp__<server>__<tool>`; a name that splits two ways, because it holds a second `__`, is left whole and stays on the Tools lane.
+
+When the asz Claude Code plugin observed the call, the card wears how it ended and the time the runtime measured around it, such as `returned · 380 ms` or `failed · 2.0 s`; clicking it opens the inspector's **Execution** tab. The tab lists each observation of the call: the observer, the MCP server by the name it was configured with and where that configuration came from, the outcome (`returned`, `failed` or `interrupted`), the measured time, when the observation ended, and the size and S [...]
+
 ### Prompts
 
 When the Sessionizer captured the bodies a model call exchanged with its provider, the call's step gains a **Prompt** tab in the inspector: the exact request that was sent, and the exact response that came back, as the agent's runtime wrote them.
@@ -104,6 +110,7 @@ A **LangChain or LangGraph agent** is read in LangChain's own words. What the Se
 
 - **One document, all at once.** OAP sends the whole conversation, and its `viewTimeoutMs` budget (120 s by default, see [Configuration File](../setup/horizon-yaml.md)) covers the wait for the first byte. A conversation OAP cannot assemble within that time reports a timeout; try again, or raise the budget on both sides. Horizon holds the document before the browser starts receiving it, which is how the page knows the size to count against; that hold is a fifth of the document's size per  [...]
 - **Landed records are not on OAP, except the prompts.** The Sessionizer's own viewer can open the raw landed record behind any step; OAP stores the assembled document, so this page shows the text the document carries and says when it was clipped. The one exception is the provider bodies above, which OAP stores as files and serves on request.
+- **Execution records need the asz Claude Code plugin.** They exist from AI Sessionizer 0.6.0, for calls the plugin observed. A call to an MCP server without one still has its lane and its name, and no Execution tab.
 - **Prompts need a Sessionizer that captured them.** Provider bodies exist from AI Sessionizer 0.4.0 for Claude Code, and from 0.5.0 for a LangChain or LangGraph agent, and only for the calls it was configured to capture. A call with none has no Prompt tab; nothing about it is hidden, it was never stored.
 
 ## Troubleshooting
diff --git a/packages/conversation-view/src/index.ts b/packages/conversation-view/src/index.ts
index 9fd0bc1d..27a678d2 100644
--- a/packages/conversation-view/src/index.ts
+++ b/packages/conversation-view/src/index.ts
@@ -140,6 +140,7 @@ function skeleton(s: ViewStrings): string {
           <button class="acv-tab" type="button" role="tab" data-tab="relations" aria-selected="false">${esc(s.relations)}</button>
           <button class="acv-tab" type="button" role="tab" data-tab="evidence" aria-selected="false">${esc(s.evidence)}</button>
           <button class="acv-tab" type="button" role="tab" data-tab="changes" aria-selected="false" hidden>${esc(s.changes)}</button>
+          <button class="acv-tab" type="button" role="tab" data-tab="execution" aria-selected="false" hidden>${esc(s.execution)}</button>
           <button class="acv-tab" type="button" role="tab" data-tab="prompt" aria-selected="false" hidden>${esc(s.prompt)}</button>
         </div>
         <div class="acv-inspector-body" role="tabpanel"></div>
diff --git a/packages/conversation-view/src/model.ts b/packages/conversation-view/src/model.ts
index 1202253c..a10c2785 100644
--- a/packages/conversation-view/src/model.ts
+++ b/packages/conversation-view/src/model.ts
@@ -37,6 +37,7 @@ import type {
   AszViewDocument,
   AszUsage,
   AszDrop,
+  AszToolExecution,
   AszWorkspaceChange,
 } from './types.js';
 import { CONTAINER_KINDS, kindOf, type KindType, type Track } from './vocabulary.js';
@@ -75,6 +76,10 @@ export interface Step {
   edges: AszEdge[];
   /** Whether workspace change records join to this step (the node's `changes`). */
   hasChanges: boolean;
+  /** The MCP server and tool a call addresses, from its `mcp_server` and
+   *  `mcp_tool` attributes: the runtime's names, where its name for the call
+   *  splits exactly. A call to an MCP server is drawn on its own lane. */
+  mcp?: { server: string; tool: string };
   /** Where this step's provider bodies landed, when it has any. */
   providerBodies?: AszProviderBody[];
   /** Position in the flattened document, for ties and for nodes without a ref. */
@@ -259,6 +264,9 @@ export class ConversationModel {
    *  `step`, never its id, which two producers share. */
   private readonly changesByStep = new Map<string, AszWorkspaceChange[]>();
   readonly workspaceChanges: AszWorkspaceChange[];
+  /** Execution records by the step they join to, the record's `step`. */
+  private readonly executionsByStep = new Map<string, AszToolExecution[]>();
+  readonly toolExecutions: AszToolExecution[];
 
   constructor(readonly doc: AszViewDocument) {
     for (const s of doc.streams) {
@@ -268,12 +276,15 @@ export class ConversationModel {
     for (const seg of doc.segments) this.segmentById.set(seg.id, seg);
     this.workspaceChanges = doc.workspace_changes ?? [];
     for (const wc of this.workspaceChanges) if (wc.step) push(this.changesByStep, wc.step, wc);
+    this.toolExecutions = doc.tool_executions ?? [];
+    for (const x of this.toolExecutions) if (x.step) push(this.executionsByStep, x.step, x);
     let order = 0;
     const flatten = (root: AszNode, talkId: string | null, streamFallback: string): void => {
       const walk = (n: AszNode, run: string | null, depth: number): void => {
         if (n.kind === 'run') run = n.id;
         if (!CONTAINER_KINDS.has(n.kind)) {
           const meta = kindOf(n.kind);
+          const mcp = mcpOf(n);
           const step: Step = {
             id: n.id,
             kind: n.kind,
@@ -282,7 +293,7 @@ export class ConversationModel {
             stream: n.stream || streamFallback,
             run,
             talk: talkId,
-            track: meta.track,
+            track: mcp ? 'mcp' : meta.track,
             type: meta.type,
             name: n.name,
             text: n.text,
@@ -305,6 +316,7 @@ export class ConversationModel {
             dropped: n.dropped,
             edges: n.edges ?? [],
             hasChanges: this.changesByStep.has(n.id),
+            mcp,
             order: order++,
             depth,
           };
@@ -387,6 +399,11 @@ export class ConversationModel {
     return this.changesByStep.get(stepId) ?? [];
   }
 
+  /** The execution records joined to a step, in document order. */
+  executionsOf(stepId: string): AszToolExecution[] {
+    return this.executionsByStep.get(stepId) ?? [];
+  }
+
   /** The streams the assembler could tie to the start or the end of a stream
    *  from THIS stream's steps. Several candidates for one call are all kept:
    *  the assembler did not choose, and neither does a view. */
@@ -465,6 +482,14 @@ function push<K, V>(m: Map<K, V[]>, k: K, v: V): void {
   else m.set(k, [v]);
 }
 
+/** The MCP server and tool a tool call's attributes name, when both are there. */
+function mcpOf(n: AszNode): { server: string; tool: string } | undefined {
+  if (n.kind !== 'tool') return undefined;
+  const server = attrString(n.attrs, 'mcp_server');
+  const tool = attrString(n.attrs, 'mcp_tool');
+  return server && tool ? { server, tool } : undefined;
+}
+
 /** A value from a node's `attrs`, as a string. */
 export function attrString(attrs: Record<string, unknown> | undefined, key: string): string {
   const v = attrs?.[key];
diff --git a/packages/conversation-view/src/strings.ts b/packages/conversation-view/src/strings.ts
index 1e9337f0..965ce88f 100644
--- a/packages/conversation-view/src/strings.ts
+++ b/packages/conversation-view/src/strings.ts
@@ -81,6 +81,7 @@ export interface ViewStrings {
   reasoning: string;
   toolWord: string;
   callWord: string;
+  mcpWord: string;
   turn: string;
   input: string;
   result: string;
@@ -119,6 +120,7 @@ export interface ViewStrings {
   laneContext: string;
   laneModel: string;
   laneTools: string;
+  laneMcp: string;
   laneAgents: string;
   laneNotices: string;
   laneNested: string;
@@ -234,6 +236,23 @@ export interface ViewStrings {
   // Workspace changes: the tool card's pill, the inline details, the
   // inspector tab and the conversation-level panel
   changes: string;
+  // Execution records: what an observer around a call to an MCP server saw
+  // it do, on the card's pill and in the inspector tab.
+  execution: string;
+  executionPillTitle: string;
+  moreObservations: string;
+  executionRecordsForStep: string;
+  executionRecordRef: string;
+  mcpServer: string;
+  mcpTool: string;
+  observedBy: string;
+  configuredIn: string;
+  measuredTime: string;
+  measuredTimeText: string;
+  notReported: string;
+  sentToServer: string;
+  cameBack: string;
+  sizeAndDigest: string;
   // The prompt of a call: the bodies it exchanged with the model provider.
   prompt: string;
   promptNotHere: string;
@@ -391,6 +410,7 @@ export const ENGLISH: ViewStrings = {
   reasoning: 'Reasoning',
   toolWord: 'Tool',
   callWord: 'call',
+  mcpWord: 'MCP',
   turn: 'turn',
   input: 'input',
   result: 'result',
@@ -427,6 +447,7 @@ export const ENGLISH: ViewStrings = {
   laneContext: 'Context put in',
   laneModel: 'Model calls',
   laneTools: 'Tools',
+  laneMcp: 'MCP',
   laneAgents: 'Agent activity',
   laneNotices: 'Runtime notices',
   laneNested: 'Nested streams',
@@ -541,6 +562,21 @@ export const ENGLISH: ViewStrings = {
   close: 'Close',
   whatDoesMean: 'What does {key} mean?',
   changes: 'Changes',
+  execution: 'Execution',
+  executionPillTitle: 'What the observer around this call saw it do — open the Execution tab',
+  moreObservations: '{n} more',
+  executionRecordsForStep: '{n} execution records — open the Execution tab →',
+  executionRecordRef: 'execution record',
+  mcpServer: 'MCP server',
+  mcpTool: 'MCP tool',
+  observedBy: 'Observed by',
+  configuredIn: 'Configured in',
+  measuredTime: 'Measured time',
+  measuredTimeText: "The time the runtime measured around the call. It includes any waiting before the call started, so it is not the server's own time.",
+  notReported: 'not reported',
+  sentToServer: 'Sent to the server',
+  cameBack: 'Came back',
+  sizeAndDigest: '{bytes} B · SHA-256 {digest}',
   prompt: 'Prompt',
   promptNotHere: 'The bodies of this call are not among the files this document lists.',
   promptBothStored: 'The request this call sent and the response it received are stored.',
diff --git a/packages/conversation-view/src/styles.css b/packages/conversation-view/src/styles.css
index 12e4478c..781d6742 100644
--- a/packages/conversation-view/src/styles.css
+++ b/packages/conversation-view/src/styles.css
@@ -436,6 +436,9 @@ select.acv-stream-btn { border: 1px solid var(--acv-line); background: var(--acv
 .acv-change-pill.readonly .acv-ico { color: var(--acv-faint); }
 .acv-change-pill.unknown,
 .acv-change-pill.none { border-style: dashed; background: transparent; color: var(--acv-muted); font-weight: 500; }
+.acv-execution-pill { display: inline-flex; align-items: center; margin-left: 2px; padding: 1px 8px; border: 1px solid color-mix(in srgb, var(--acv-kind-tool) 50%, transparent); border-radius: 999px; background: transparent; color: var(--acv-muted); font-size: 10px; font-weight: 600; line-height: 1.6; cursor: pointer; }
+.acv-execution-pill:hover { background: color-mix(in srgb, var(--acv-kind-tool) 14%, transparent); color: var(--acv-text); }
+.acv-execution-pill.failed { border-color: color-mix(in srgb, var(--acv-kind-error) 60%, transparent); color: var(--acv-kind-error); }
 .acv-file-counts { font-family: var(--acv-mono); font-size: 10px; font-weight: 600; font-variant-numeric: tabular-nums; }
 .acv-add { color: var(--sw-ok); }
 .acv-del { color: var(--sw-err); }
diff --git a/packages/conversation-view/src/types.ts b/packages/conversation-view/src/types.ts
index be0d301c..d3b72dbc 100644
--- a/packages/conversation-view/src/types.ts
+++ b/packages/conversation-view/src/types.ts
@@ -102,6 +102,10 @@ export interface AszNode {
    *  `workspace_changes` lists them. Not unique across producers: the join
    *  is `WorkspaceChange.step`, this list only says there are some. */
   changes?: string[];
+  /** Ids of the execution records joined to this step, in the order
+   *  `tool_executions` lists them. The join is `AszToolExecution.step`; one
+   *  call can have several records, one per observation. */
+  executions?: string[];
   /** Where this call's request and response landed, when they were captured.
    *  A call may list one of them, both, or neither. */
   provider_bodies?: AszProviderBody[];
@@ -109,6 +113,50 @@ export interface AszNode {
   edges?: AszEdge[];
 }
 
+/** What an execution record keeps of the value sent to the server or the
+ *  one that came back: its size and its digest, never the value. */
+export interface AszExecutionValue {
+  /** `size_only`. */
+  state: string;
+  bytes: number;
+  sha256?: string;
+}
+
+/**
+ * One `execution/1` record with the step it joins to and where it was read
+ * from, as `tool_executions` lists them: what one observer around a tool call
+ * saw it do. One call can have several records. `step` is the join, `""`
+ * when no step carries the call's tool-use id.
+ */
+export interface AszToolExecution {
+  step: string;
+  ref: AszRef;
+  schema: string;
+  id: string;
+  /** Who observed the call, such as `asz-plugin`. */
+  observed_by: string;
+  /** Where the observer stood, such as `client_hook`: the runtime's hook after the call. */
+  boundary: string;
+  session: string;
+  stream: string;
+  tool: string;
+  tool_name?: string;
+  cwd?: string;
+  /** `mcp`. */
+  protocol: string;
+  /** The server by the name it was configured with, and where that configuration came from. */
+  server?: { name: string; source?: string };
+  /** RFC 3339, when the observation ended. */
+  time: string;
+  /** The time the runtime measured around the call, waiting included; absent when the observer did not say. */
+  duration_ms?: number;
+  /** `returned` | `failed` | `interrupted`. */
+  outcome: string;
+  arguments?: AszExecutionValue;
+  /** Only on a call that returned. */
+  result?: AszExecutionValue;
+}
+
 /** One side of a file before or after a change. `bytes` is null when the
  *  whole file was never seen, as when the runtime reported a patch alone. */
 export interface AszChangeSide {
@@ -318,6 +366,9 @@ export interface AszViewDocument {
   /** Every change record of the session in time order; absent from a
    *  document written before the records existed, `[]` when none. */
   workspace_changes?: AszWorkspaceChange[];
+  /** Every execution record of the session in time order; absent from a
+   *  document written before the records existed, `[]` when none. */
+  tool_executions?: AszToolExecution[];
 }
 
 export const ASZ_VIEW_FORMAT = 'asz.view';
diff --git a/packages/conversation-view/src/view/context.ts b/packages/conversation-view/src/view/context.ts
index 6a765259..7ac93c5d 100644
--- a/packages/conversation-view/src/view/context.ts
+++ b/packages/conversation-view/src/view/context.ts
@@ -22,7 +22,7 @@ import type { AszRef, Glossary, LandedRecord } from '../types.js';
 import type { PromptCache } from '../prompt/cache.js';
 import type { StoredFile } from '../prompt/store.js';
 
-export type InspectorTab = 'details' | 'relations' | 'evidence' | 'changes' | 'prompt';
+export type InspectorTab = 'details' | 'relations' | 'evidence' | 'changes' | 'execution' | 'prompt';
 
 /** What the reader is looking at. The three fields a host round-trips through
  *  its URL are the talk, the selected step and the stream; the rest is local. */
diff --git a/packages/conversation-view/src/view/executions.ts b/packages/conversation-view/src/view/executions.ts
new file mode 100644
index 00000000..97f104f0
--- /dev/null
+++ b/packages/conversation-view/src/view/executions.ts
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+/**
+ * What an observer around a call to an MCP server saw it do, from the
+ * `execution/1` records a call joins to by its tool-use id. A pill on the
+ * call's card says how the first observation ended and how long the runtime
+ * measured it; the inspector's Execution tab lists every observation. One
+ * call can have several, one per observer, and they are listed, never merged.
+ *
+ * The values sent and returned are not in a record, only their size and
+ * digest, so the tab shows those and never the model's input in their place.
+ */
+
+import { esc } from '../dom.js';
+import type { Step } from '../model.js';
+import { fill } from '../strings.js';
+import type { AszExecutionValue } from '../types.js';
+import type { ViewContext } from './context.js';
+
+/** The pill on a call's card: the first observation's outcome and measured
+ *  time, and how many more observations there are. Empty when none joins. */
+export function executionPill(ctx: ViewContext, step: Step): string {
+  const { s, f, model: m } = ctx;
+  const records = m.executionsOf(step.id);
+  if (!records.length) return '';
+  const first = records[0];
+  let text = first.outcome;
+  if (first.duration_ms != null) text += ` · ${f.duration(first.duration_ms)}`;
+  if (records.length > 1) text += ` · ${fill(s.moreObservations, { n: records.length - 1 })}`;
+  return `<button type="button" class="acv-execution-pill${first.outcome === 'returned' ? '' : ' failed'}" data-to-execution="${esc(
+    step.id,
+  )}" title="${esc(s.executionPillTitle)}">${esc(text)}</button>`;
+}
+
+/** The inspector's Execution tab: every observation of the call, in the order the document lists them. */
+export function drawExecutionTab(ctx: ViewContext, body: HTMLElement, step: Step): void {
+  const { s, f, model: m } = ctx;
+  const row = (dt: string, dd: string, mono = false): string => `<dt>${esc(dt)}</dt><dd class="${mono ? 'mono' : ''}">${dd}</dd>`;
+  let html = '';
+  for (const r of m.executionsOf(step.id)) {
+    const ended = Date.parse(r.time);
+    html += `<div class="acv-kicker" style="margin-top:14px">${esc(s.observedBy)} <span class="mono">${esc(r.observed_by)} · ${esc(r.boundary)}</span></div>`;
+    html += `<dl class="acv-definition">`;
+    html += row(s.mcpServer, r.server ? esc(r.server.name) : '—', true);
+    if (r.server?.source) html += row(s.configuredIn, esc(r.server.source), true);
+    html += row(s.outcomeWord, esc(r.outcome), true);
+    html += row(
+      s.measuredTime,
+      r.duration_ms != null ? esc(f.duration(r.duration_ms)) : `<span class="acv-faint">${esc(s.notReported)}</span>`,
+    );
+    html += row(s.observedAt, Number.isFinite(ended) ? esc(f.dateTime(ended)) : esc(r.time));
+    if (r.arguments) html += row(s.sentToServer, value(ctx, r.arguments), true);
+    if (r.result) html += row(s.cameBack, value(ctx, r.result), true);
+    html += row(
+      s.readFrom,
+      `seq ${esc(String(r.ref.seq))} · row ${esc(String(r.ref.row))}${r.ref.block != null ? ` · block ${esc(String(r.ref.block))}` : ''} <button type="button" class="acv-linkish" data-to-evidence data-ref-seq="${esc(String(r.ref.seq))}" data-ref-row="${esc(String(r.ref.row))}" data-ref-block="${esc(String(r.ref.block ?? ''))}">${esc(s.openEvidence)}</button>`,
+      true,
+    );
+    html += `</dl>`;
+  }
+  html += `<div class="acv-warning"><strong>${esc(s.measuredTime)}</strong><br>${esc(s.measuredTimeText)}</div>`;
+  body.innerHTML = html;
+  body.querySelectorAll<HTMLElement>('[data-to-evidence]').forEach(
+    (b) =>
+      (b.onclick = () => {
+        const block = b.dataset.refBlock;
+        ctx.state.rawRef = { seq: Number(b.dataset.refSeq), row: Number(b.dataset.refRow), ...(block ? { block: Number(block) } : {}) };
+        ctx.showTab('evidence');
+      }),
+  );
+}
+
+/** A value's size and digest; the value itself is never in the record. */
+function value(ctx: ViewContext, v: AszExecutionValue): string {
+  const { s, f } = ctx;
+  return esc(fill(s.sizeAndDigest, { bytes: f.number(v.bytes), digest: v.sha256 ? v.sha256.slice(0, 12) : '—' }));
+}
diff --git a/packages/conversation-view/src/view/inspector.ts b/packages/conversation-view/src/view/inspector.ts
index 45d7ab12..629ac5c9 100644
--- a/packages/conversation-view/src/view/inspector.ts
+++ b/packages/conversation-view/src/view/inspector.ts
@@ -29,6 +29,7 @@ import { fill } from '../strings.js';
 import type { AszRef, LandedRecord } from '../types.js';
 import { kindTitle } from '../vocabulary.js';
 import { drawChangesTab } from './changes.js';
+import { drawExecutionTab } from './executions.js';
 import { drawPrompt, hasPrompt } from './prompt.js';
 import { streamName, type InspectorTab, type ViewContext } from './context.js';
 import { clipNote, copyButton, copyField, textBody } from './structured.js';
@@ -53,6 +54,9 @@ export function drawInspector(ctx: ViewContext): void {
   const changesTab = ctx.q<HTMLButtonElement>('[data-tab="changes"]');
   const hasChanges = !!(e && m.changesOf(e.id).length);
   changesTab.hidden = !hasChanges;
+  const executionTab = ctx.q<HTMLButtonElement>('[data-tab="execution"]');
+  const hasExecutions = !!(e && m.executionsOf(e.id).length);
+  executionTab.hidden = !hasExecutions;
   // What the reader chose stays chosen. A step this tab has nothing for is drawn as details, and the
   // choice comes back on the next step that has it, so walking a run does not drop the reader out of
   // the prompt or the relations every time a step in between has none.
@@ -61,6 +65,7 @@ export function drawInspector(ctx: ViewContext): void {
     evidence: true,
     relations: hasRels,
     changes: hasChanges,
+    execution: hasExecutions,
     prompt: prompts,
   };
   const tab: InspectorTab = offered[state.tab] ? state.tab : 'details';
@@ -79,6 +84,7 @@ export function drawInspector(ctx: ViewContext): void {
   if (tab === 'details') drawDetails(ctx, body, e);
   else if (tab === 'relations') drawRelations(ctx, body, e);
   else if (tab === 'changes') drawChangesTab(ctx, body, e);
+  else if (tab === 'execution') drawExecutionTab(ctx, body, e);
   else if (tab === 'prompt') drawPrompt(ctx, body, e);
   else void drawEvidence(ctx, body, e);
 }
@@ -198,6 +204,10 @@ function drawDetails(ctx: ViewContext, body: HTMLElement, e: Step): void {
     html += `<dt></dt><dd><div class="acv-warning" style="margin:0"><strong>${esc(s.requestToResultWhat)}</strong><br>${esc(s.requestToResultText)}</div></dd>`;
   }
   if (e.name) html += field(s.name, esc(e.name));
+  if (e.mcp) {
+    html += field(s.mcpServer, esc(e.mcp.server), true);
+    html += field(s.mcpTool, esc(e.mcp.tool), true);
+  }
   if (e.failed !== undefined) html += field(s.failedField, e.failed ? esc(s.yes) : esc(s.no));
   if (e.state) html += field(s.contentState, esc(e.state));
   if (e.bytes) html += field(s.contentBytes, f.number(e.bytes));
@@ -235,10 +245,15 @@ function drawDetails(ctx: ViewContext, body: HTMLElement, e: Step): void {
   if (changes) {
     html += `<button type="button" class="acv-linkish acv-to-changes" data-to-changes>${esc(fill(s.changeRecordsForStep, { n: changes }))}</button>`;
   }
+  const executions = m.executionsOf(e.id).length;
+  if (executions) {
+    html += `<button type="button" class="acv-linkish acv-to-changes" data-to-execution>${esc(fill(s.executionRecordsForStep, { n: executions }))}</button>`;
+  }
   if (state.navStack.length) html += `<button type="button" class="acv-btn" style="margin-top:12px" data-back-parent>${esc(s.backToParentStream)}</button>`;
   body.innerHTML = html;
   body.querySelector<HTMLElement>('[data-to-relations]')?.addEventListener('click', () => ctx.showTab('relations'));
   body.querySelector<HTMLElement>('[data-to-changes]')?.addEventListener('click', () => ctx.showTab('changes'));
+  body.querySelector<HTMLElement>('[data-to-execution]')?.addEventListener('click', () => ctx.showTab('execution'));
   body.querySelector<HTMLElement>('[data-back-parent]')?.addEventListener('click', () => ctx.goBack());
   body.querySelectorAll<HTMLElement>('[data-copy]').forEach((b) =>
     b.addEventListener('click', (ev) => {
@@ -304,7 +319,7 @@ function detailBlock(ctx: ViewContext, e: Step, text: string, bytes: number | un
  *  The whole record is one more read away, offered only when the host can
  *  make it. */
 async function drawEvidence(ctx: ViewContext, body: HTMLElement, e: Step): Promise<void> {
-  const { s, f, state } = ctx;
+  const { s, f, model: m, state } = ctx;
   const refs: AszRef[] = (e.ref ? [e.ref] : []).concat(e.refs ?? []);
   const seen = new Set<string>();
   const list = refs.filter((r) => {
@@ -313,9 +328,9 @@ async function drawEvidence(ctx: ViewContext, body: HTMLElement, e: Step): Promi
     seen.add(k);
     return true;
   });
-  // A change record's landed position is evidence of this step too, and the
-  // plugin's records sit in a file of their own that none of the step's refs
-  // name: it joins the chips when the reader arrived from one.
+  // A change or execution record's landed position is evidence of this step
+  // too, and the plugin's records sit in files of their own that none of the
+  // step's refs name: it joins the chips when the reader arrived from one.
   const same = (a: AszRef, b: AszRef): boolean => a.seq === b.seq && a.row === b.row && (a.block ?? null) === (b.block ?? null);
   const own = list.length;
   if (state.rawRef && !list.some((r) => same(r, state.rawRef!))) list.push(state.rawRef);
@@ -326,7 +341,9 @@ async function drawEvidence(ctx: ViewContext, body: HTMLElement, e: Step): Promi
   const pick = (state.rawRef && list.find((r) => same(r, state.rawRef!))) ?? list[0]!;
   const role = (i: number): string =>
     i >= own
-      ? s.changeRecordRef
+      ? m.executionsOf(e.id).some((x) => same(x.ref, list[i]!))
+        ? s.executionRecordRef
+        : s.changeRecordRef
       : e.kind === 'tool' || e.kind === 'agent.call'
         ? i === 0
           ? s.request
diff --git a/packages/conversation-view/src/view/timeline.ts b/packages/conversation-view/src/view/timeline.ts
index 30988e70..d4830812 100644
--- a/packages/conversation-view/src/view/timeline.ts
+++ b/packages/conversation-view/src/view/timeline.ts
@@ -457,11 +457,14 @@ export function paintViewport(ctx: ViewContext): void {
     const mark = e.hasChanges
       ? icon(tallyChanges(ctx.model.changesOf(e.id)).observation === 'skipped' ? ICON_READONLY : ICON_CHANGES, 'acv-clip-mark')
       : '';
+    // A call to an MCP server sits on its own lane, named by its server and tool, with how its first observation ended.
+    const observed = ctx.model.executionsOf(e.id)[0];
+    const label = says ? says.says : e.mcp ? `${e.mcp.server} · ${e.mcp.tool}` : e.name || kindTitle(e.kind, s);
     html += `<button type="button" class="acv-clip acv-kind-${e.type}${e.id === state.sel ? ' selected' : ''}${
       p.near && !p.near.has(e.id) ? ' dim' : ''
     }${mark ? ' marked' : ''}" data-node="${esc(e.id)}" data-talk="${esc(e.talk ?? '')}" title="${esc(e.at ? `${f.time(e.at)} · ` : '')}${esc(kindTitle(e.kind, s))}${
       e.name ? ` · ${esc(e.name)}` : ''
-    }${mark ? ` · ${esc(s.changes)}` : ''}" style="left:${q.x}px;top:${q.y}px;width:${q.w}px">${mark}${esc(says ? says.says : e.name || kindTitle(e.kind, s))}</button>`;
+    }${observed ? ` · ${esc(observed.outcome)}` : ''}${mark ? ` · ${esc(s.changes)}` : ''}" style="left:${q.x}px;top:${q.y}px;width:${q.w}px">${mark}${esc(label)}</button>`;
   }
   for (const fo of p.folders) {
     const q = p.fpos.get(fo.key)!;
diff --git a/packages/conversation-view/src/view/transcript.ts b/packages/conversation-view/src/view/transcript.ts
index dea60692..0d9e254c 100644
--- a/packages/conversation-view/src/view/transcript.ts
+++ b/packages/conversation-view/src/view/transcript.ts
@@ -29,6 +29,7 @@ import { fill } from '../strings.js';
 import { injectionSays, kindTitle, QUIET_MS } from '../vocabulary.js';
 import { changePill, inlineChanges, redrawBoth } from './changes.js';
 import { streamName, type ViewContext } from './context.js';
+import { executionPill } from './executions.js';
 import { clipNote, copyButton, copyField, textBody } from './structured.js';
 
 /** Characters past which a card clamps a text with a fade and offers the rest. */
@@ -102,7 +103,10 @@ function present(ctx: ViewContext, e: Step): Presentation {
     case 'thinking':
       return { cls: `acv-activity${child}`, role: `${agent} · ${s.reasoning}`, color: 'model' };
     case 'tool':
-      return { cls: `acv-activity${child}`, role: `${agent} → ${s.toolWord} · ${e.name || s.callWord}`, color: 'tool' };
+      // A call to an MCP server names the server; its tool is the card's title.
+      return e.mcp
+        ? { cls: `acv-activity${child}`, role: `${agent} → ${s.mcpWord} · ${e.mcp.server}`, color: 'tool' }
+        : { cls: `acv-activity${child}`, role: `${agent} → ${s.toolWord} · ${e.name || s.callWord}`, color: 'tool' };
     default:
       return { cls: `acv-activity${child}`, role: `${agent} · ${kindTitle(e.kind, s)}`, color: 'instruction' };
   }
@@ -128,7 +132,9 @@ export function stepCard(ctx: ViewContext, e: Step, prev: Step | undefined, near
     ? `${f.duration(e.durationMs)} ${s.turn}`
     : e.kind === 'context.injection' && injectionSays(e.text)
       ? injectionSays(e.text)!.says
-      : e.name || kindTitle(e.kind, s);
+      : e.mcp
+        ? `${e.mcp.server} · ${e.mcp.tool}`
+        : e.name || kindTitle(e.kind, s);
   // A card is a div, not a button: the change pill and the file rows inside
   // it are buttons of their own, and a button cannot hold buttons. The list's
   // key handler gives it Enter and Space.
@@ -141,7 +147,7 @@ export function stepCard(ctx: ViewContext, e: Step, prev: Step | undefined, near
     <span class="acv-content">
       <span class="acv-role">${esc(p.role)}</span>
       <span class="acv-title acv-kind-${p.color}"><span class="acv-type-mark"></span>${esc(title)}
-        <span class="acv-mini">${e.bytes ? `${f.number(e.bytes)} B` : ''}</span> ${unavailable}${changePill(ctx, e)}</span>
+        <span class="acv-mini">${e.bytes ? `${f.number(e.bytes)} B` : ''}</span> ${unavailable}${changePill(ctx, e)}${executionPill(ctx, e)}</span>
       ${
         e.text
           ? `${e.result ? `<span class="acv-result-label">${esc(s.input)}${e.bytes ? ` · ${f.number(e.bytes)} B` : ''}</span>` : ''}
@@ -320,6 +326,13 @@ export function bindTranscript(ctx: ViewContext): void {
       ctx.showTab('changes');
       return;
     }
+    const toExecution = target.closest<HTMLElement>('[data-to-execution]');
+    if (toExecution) {
+      ev.stopPropagation();
+      ctx.select(toExecution.dataset.toExecution!, true, false);
+      ctx.showTab('execution');
+      return;
+    }
     const card = target.closest<HTMLElement>('[data-card]');
     if (card) {
       ev.stopPropagation();
diff --git a/packages/conversation-view/src/vocabulary.ts b/packages/conversation-view/src/vocabulary.ts
index df1316e4..c17e3f9f 100644
--- a/packages/conversation-view/src/vocabulary.ts
+++ b/packages/conversation-view/src/vocabulary.ts
@@ -24,7 +24,7 @@ import type { ViewStrings } from './strings.js';
  * tokens — and `title` names the key in {@link ViewStrings} for the kind's
  * plain-English name.
  */
-export type Track = 'input' | 'messages' | 'context' | 'model' | 'tools' | 'agents' | 'annotation';
+export type Track = 'input' | 'messages' | 'context' | 'model' | 'tools' | 'mcp' | 'agents' | 'annotation';
 export type KindType = 'user' | 'assistant' | 'subagent-response' | 'model' | 'tool' | 'agent' | 'instruction' | 'annotation' | 'error';
 
 export interface KindMeta {
@@ -70,7 +70,7 @@ export function kindTitle(kind: string, s: ViewStrings): string {
 /** The kinds that are structure, not steps. Everything else is a step. */
 export const CONTAINER_KINDS: ReadonlySet<string> = new Set(['talk', 'run', 'stream', 'segment', 'session', 'epoch']);
 
-export const TRACKS: readonly Track[] = ['input', 'messages', 'context', 'model', 'tools', 'agents', 'annotation'];
+export const TRACKS: readonly Track[] = ['input', 'messages', 'context', 'model', 'tools', 'mcp', 'agents', 'annotation'];
 
 export const TRACK_NAME: Record<Track, keyof ViewStrings> = {
   input: 'laneInput',
@@ -78,6 +78,7 @@ export const TRACK_NAME: Record<Track, keyof ViewStrings> = {
   context: 'laneContext',
   model: 'laneModel',
   tools: 'laneTools',
+  mcp: 'laneMcp',
   agents: 'laneAgents',
   annotation: 'laneNotices',
 };
diff --git a/packages/conversation-view/test/fixtures/mcp-calls.json b/packages/conversation-view/test/fixtures/mcp-calls.json
new file mode 100644
index 00000000..2c953f4f
--- /dev/null
+++ b/packages/conversation-view/test/fixtures/mcp-calls.json
@@ -0,0 +1,1307 @@
+{
+  "format": "asz.view",
+  "version": "1.0",
+  "conversation": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
+  "sessions": [
+    "720d2f9e-dca7-49be-8c5e-89b667b39a01"
+  ],
+  "head": {
+    "round": 1,
+    "digest": "0465bcdcdb729b5e5022b97e10f03c2589937c735debe88956af173df7f612d1"
+  },
+  "parser": "v1",
+  "policy": "v1+idle=10m0s",
+  "summary": {
+    "title": "calls to MCP servers",
+    "state": "verified",
+    "problems": [],
+    "talks": 2,
+    "steps": 25,
+    "streams": 2,
+    "segments": 1,
+    "rounds": 1,
+    "unresolved": 0,
+    "changes": 0,
+    "provider_bodies": 0,
+    "captured_prompts": 0,
+    "from": 1767225600000,
+    "to": 1767225613900,
+    "kinds": {
+      "agent.call": 1,
+      "agent.launch_ack": 1,
+      "agent.output": 1,
+      "epoch": 2,
+      "llm.call": 8,
+      "message.assistant": 7,
+      "message.external": 1,
+      "run": 3,
+      "runtime.notification": 1,
+      "segment": 1,
+      "session": 1,
+      "stream": 2,
+      "talk": 2,
+      "tool": 5
+    },
+    "relation_types": {
+      "ends_with": 1,
+      "in_segment": 2,
+      "reports": 1,
+      "starts": 1
+    },
+    "quality": {
+      "exact_unique": 4,
+      "strong_inference": 1
+    }
+  },
+  "rounds": [
+    {
+      "round": 1,
+      "digest": "0465bcdcdb729b5e5022b97e10f03c2589937c735debe88956af173df7f612d1",
+      "previous": null,
+      "from_seq": 1,
+      "through_seq": 5,
+      "input_digest": "d89701ec46c206724a1881d3571defaab92ce201520b19f84362aa85c9f47627",
+      "from_time": 1767225600000,
+      "through_time": 1767225613900,
+      "verified": true
+    }
+  ],
+  "files": [
+    {
+      "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/main/transcript-20260101T000000.000000000Z-000001.sd",
+      "format": "sd",
+      "kind": "transcript",
+      "seq": 1,
+      "round": null,
+      "stream": "main",
+      "run": null,
+      "lines": 20,
+      "bytes": 6910,
+      "digest": "76e8539b8ad8ea080bd5daec3c187554d68c8a648533974d40cfff535081e0a0",
+      "from_time": 1767225600000,
+      "through_time": 1767225613900
+    },
+    {
+      "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/main/execution-20260101T000000.000000000Z-000002.sd",
+      "format": "sd",
+      "kind": "execution",
+      "seq": 2,
+      "round": null,
+      "stream": "main",
+      "run": null,
+      "lines": 6,
+      "bytes": 3507,
+      "digest": "9a6de86e0baa955694fc14b3f1764820a93b7bb30fedf92854a1ea0cbdae4c94",
+      "from_time": 1767225601500,
+      "through_time": 1767225606200
+    },
+    {
+      "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348/transcript-20260101T000000.000000000Z-000003.sd",
+      "format": "sd",
+      "kind": "transcript",
+      "seq": 3,
+      "round": null,
+      "stream": "a39a255c7ba239348",
+      "run": null,
+      "lines": 7,
+      "bytes": 2147,
+      "digest": "3211096e8b6f85c6b6459be1d3d855a2930d49f4065f4d1d4315a7fc357ee5d4",
+      "from_time": 1767225609500,
+      "through_time": 1767225611900
+    },
+    {
+      "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348/execution-20260101T000000.000000000Z-000004.sd",
+      "format": "sd",
+      "kind": "execution",
+      "seq": 4,
+      "round": null,
+      "stream": "a39a255c7ba239348",
+      "run": null,
+      "lines": 3,
+      "bytes": 1253,
+      "digest": "aea3dc04001736aba88914ff0eacea3731ebbf62915d58941b09110d6099f066",
+      "from_time": 1767225610900,
+      "through_time": 1767225610900
+    },
+    {
+      "file": "720d2f9e-dca7-49be-8c5e-89b667b39a01/streams/a39a255c7ba239348/meta-20260101T000000.000000000Z-000005.sd",
+      "format": "sd",
+      "kind": "agent_meta",
+      "seq": 5,
+      "round": null,
+      "stream": "a39a255c7ba239348",
+      "run": null,
+      "lines": 3,
+      "bytes": 664,
+      "digest": "eec87b3a2a776af6ebcbc9209d820cbe05dff81c067b8ca70f829e59e5e08781",
+      "from_time": null,
+      "through_time": null
+    },
+    {
+      "file": "_conversations/720d2f9e-dca7-49be-8c5e-89b667b39a01/rounds/r000001-0465bcdcdb72.sf",
+      "format": "sf",
+      "kind": "round",
+      "seq": null,
+      "round": 1,
+      "stream": null,
+      "run": null,
+      "lines": 43,
+      "bytes": 10874,
+      "digest": "2ea12f2b3a05408df1eefc01e2273a681e6f26a37ff6ec92bc950b5bd8a7b8a6",
+      "from_time": 1767225600000,
+      "through_time": 1767225613900
+    }
+  ],
+  "streams": [
+    {
+      "id": "stream/main",
+      "name": "main",
+      "role": "main",
+      "label": "",
+      "parent": "",
+      "records": 18,
+      "steps": 19,
+      "talk": "talk/main/s1-cycle",
+      "named_by": "",
+      "opened_by": []
+    },
+    {
+      "id": "stream/a39a255c7ba239348",
+      "name": "a39a255c7ba239348",
+      "role": "child",
+      "label": "auditor",
+      "parent": "main",
+      "records": 6,
+      "steps": 6,
+      "talk": "talk/a39a255c7ba239348",
+      "named_by": "",
+      "opened_by": [
+        {
+          "step": "tool/s6-tool",
+          "stream": "main",
+          "talk": "talk/main/s1-cycle",
+          "quality": "exact_unique"
+        }
+      ]
+    }
+  ],
+  "segments": [
+    {
+      "id": "segment/at_1_2",
+      "state": "open",
+      "committable": false,
+      "talks": 2,
+      "from": 1767225600000,
+      "to": 1767225613900
+    }
+  ],
+  "talks": [
+    {
+      "id": "talk/main/s1-cycle",
+      "kind": "talk",
+      "parent": "epoch/main/0",
+      "stream": "main",
+      "at": 1767225600000,
+      "ref": {
+        "seq": 1,
+        "row": 2
+      },
+      "attrs": {
+        "loops": 2,
+        "runs": 2,
+        "trigger": "external"
+      },
+      "label": "check the checkout service and then the payment service",
+      "reply": "Checkout is healthy and payment is not.",
+      "runs": 2,
+      "steps": 19,
+      "tools": 5,
+      "from": 1767225600000,
+      "to": 1767225613900,
+      "segment": "segment/at_1_2",
+      "children": [
+        {
+          "id": "run/talk_main_s1-cycle/s1-cycle",
+          "kind": "run",
+          "parent": "talk/main/s1-cycle",
+          "stream": "main",
+          "at": 1767225600000,
+          "ref": {
+            "seq": 1,
+            "row": 2
+          },
+          "attrs": {
+            "trigger": "external"
+          },
+          "children": [
+            {
+              "id": "input/1/2",
+              "kind": "message.external",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225600000,
+              "ref": {
+                "seq": 1,
+                "row": 2
+              },
+              "text": "check the checkout service and then the payment service",
+              "state": "available",
+              "bytes": 55,
+              "flags": [
+                "external_input"
+              ]
+            },
+            {
+              "id": "call/s2-call",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225601000,
+              "ref": {
+                "seq": 1,
+                "row": 3
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 3
+                },
+                {
+                  "seq": 1,
+                  "row": 4
+                }
+              ],
+              "attrs": {
+                "fragments": 2,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 4,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "children": [
+                {
+                  "id": "msg/1/3:0",
+                  "kind": "message.assistant",
+                  "parent": "call/s2-call",
+                  "stream": "main",
+                  "at": 1767225601000,
+                  "ref": {
+                    "seq": 1,
+                    "row": 3,
+                    "block": 0
+                  },
+                  "text": "Asking the status server about checkout.",
+                  "state": "available",
+                  "bytes": 40
+                },
+                {
+                  "id": "tool/s2-tool",
+                  "kind": "tool",
+                  "parent": "call/s2-call",
+                  "stream": "main",
+                  "at": 1767225601100,
+                  "ref": {
+                    "seq": 1,
+                    "row": 4,
+                    "block": 0
+                  },
+                  "refs": [
+                    {
+                      "seq": 1,
+                      "row": 4,
+                      "block": 0
+                    },
+                    {
+                      "seq": 1,
+                      "row": 5,
+                      "block": 0
+                    }
+                  ],
+                  "attrs": {
+                    "mcp_server": "status",
+                    "mcp_tool": "lookup",
+                    "name": "mcp__status__lookup",
+                    "result": "available",
+                    "result_join": "exact_unique",
+                    "timing": "unavailable"
+                  },
+                  "text": "{\"service\":\"checkout\"}",
+                  "state": "available",
+                  "bytes": 22,
+                  "flags": [
+                    "finished"
+                  ],
+                  "name": "mcp__status__lookup",
+                  "result": "checkout: 3 of 3 ready",
+                  "result_state": "available",
+                  "result_bytes": 22,
+                  "request_to_result_ms": 400,
+                  "request_to_result_join": "exact_unique",
+                  "executions": [
+                    "s2-tool/client_hook"
+                  ]
+                }
+              ]
+            },
+            {
+              "id": "call/s3-call",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225602500,
+              "ref": {
+                "seq": 1,
+                "row": 6
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 6
+                },
+                {
+                  "seq": 1,
+                  "row": 7
+                }
+              ],
+              "attrs": {
+                "fragments": 2,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 7,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "children": [
+                {
+                  "id": "msg/1/6:0",
+                  "kind": "message.assistant",
+                  "parent": "call/s3-call",
+                  "stream": "main",
+                  "at": 1767225602500,
+                  "ref": {
+                    "seq": 1,
+                    "row": 6,
+                    "block": 0
+                  },
+                  "text": "And about payment.",
+                  "state": "available",
+                  "bytes": 18
+                },
+                {
+                  "id": "tool/s3-tool",
+                  "kind": "tool",
+                  "parent": "call/s3-call",
+                  "stream": "main",
+                  "at": 1767225602600,
+                  "ref": {
+                    "seq": 1,
+                    "row": 7,
+                    "block": 0
+                  },
+                  "refs": [
+                    {
+                      "seq": 1,
+                      "row": 7,
+                      "block": 0
+                    },
+                    {
+                      "seq": 1,
+                      "row": 8,
+                      "block": 0
+                    }
+                  ],
+                  "attrs": {
+                    "mcp_server": "status",
+                    "mcp_tool": "lookup",
+                    "name": "mcp__status__lookup",
+                    "result": "available",
+                    "result_join": "exact_unique",
+                    "timing": "unavailable"
+                  },
+                  "text": "{\"service\":\"payment\"}",
+                  "state": "available",
+                  "bytes": 21,
+                  "flags": [
+                    "finished"
+                  ],
+                  "name": "mcp__status__lookup",
+                  "failed": true,
+                  "result": "the status server could not reach payment",
+                  "result_state": "available",
+                  "result_bytes": 41,
+                  "request_to_result_ms": 2000,
+                  "request_to_result_join": "exact_unique",
+                  "executions": [
+                    "s3-tool/client_hook"
+                  ]
+                }
+              ]
+            },
+            {
+              "id": "call/s4-call",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225605600,
+              "ref": {
+                "seq": 1,
+                "row": 9
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 9
+                },
+                {
+                  "seq": 1,
+                  "row": 10
+                }
+              ],
+              "attrs": {
+                "fragments": 2,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 10,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "children": [
+                {
+                  "id": "msg/1/9:0",
+                  "kind": "message.assistant",
+                  "parent": "call/s4-call",
+                  "stream": "main",
+                  "at": 1767225605600,
+                  "ref": {
+                    "seq": 1,
+                    "row": 9,
+                    "block": 0
+                  },
+                  "text": "Asking again, through a hook that names the region.",
+                  "state": "available",
+                  "bytes": 51
+                },
+                {
+                  "id": "tool/s4-tool",
+                  "kind": "tool",
+                  "parent": "call/s4-call",
+                  "stream": "main",
+                  "at": 1767225605700,
+                  "ref": {
+                    "seq": 1,
+                    "row": 10,
+                    "block": 0
+                  },
+                  "refs": [
+                    {
+                      "seq": 1,
+                      "row": 10,
+                      "block": 0
+                    },
+                    {
+                      "seq": 1,
+                      "row": 11,
+                      "block": 0
+                    }
+                  ],
+                  "attrs": {
+                    "mcp_server": "status",
+                    "mcp_tool": "lookup",
+                    "name": "mcp__status__lookup",
+                    "result": "available",
+                    "result_join": "exact_unique",
+                    "timing": "unavailable"
+                  },
+                  "text": "{\"service\":\"payment\"}",
+                  "state": "available",
+                  "bytes": 21,
+                  "flags": [
+                    "finished"
+                  ],
+                  "name": "mcp__status__lookup",
+                  "result": "payment: 1 of 3 ready",
+                  "result_state": "available",
+                  "result_bytes": 21,
+                  "request_to_result_ms": 500,
+                  "request_to_result_join": "exact_unique",
+                  "executions": [
+                    "s4-tool/client_hook"
+                  ]
+                }
+              ]
+            },
+            {
+              "id": "call/s5-call",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225607200,
+              "ref": {
+                "seq": 1,
+                "row": 12
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 12
+                },
+                {
+                  "seq": 1,
+                  "row": 13
+                }
+              ],
+              "attrs": {
+                "fragments": 2,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 13,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "children": [
+                {
+                  "id": "msg/1/12:0",
+                  "kind": "message.assistant",
+                  "parent": "call/s5-call",
+                  "stream": "main",
+                  "at": 1767225607200,
+                  "ref": {
+                    "seq": 1,
+                    "row": 12,
+                    "block": 0
+                  },
+                  "text": "A tool whose name the runtime cannot split.",
+                  "state": "available",
+                  "bytes": 43
+                },
+                {
+                  "id": "tool/s5-tool",
+                  "kind": "tool",
+                  "parent": "call/s5-call",
+                  "stream": "main",
+                  "at": 1767225607300,
+                  "ref": {
+                    "seq": 1,
+                    "row": 13,
+                    "block": 0
+                  },
+                  "refs": [
+                    {
+                      "seq": 1,
+                      "row": 13,
+                      "block": 0
+                    },
+                    {
+                      "seq": 1,
+                      "row": 14,
+                      "block": 0
+                    }
+                  ],
+                  "attrs": {
+                    "name": "mcp__odd__name__twice",
+                    "result": "available",
+                    "result_join": "exact_unique",
+                    "timing": "unavailable"
+                  },
+                  "text": "{}",
+                  "state": "available",
+                  "bytes": 2,
+                  "flags": [
+                    "finished"
+                  ],
+                  "name": "mcp__odd__name__twice",
+                  "result": "ok",
+                  "result_state": "available",
+                  "result_bytes": 2,
+                  "request_to_result_ms": 100,
+                  "request_to_result_join": "exact_unique"
+                }
+              ]
+            },
+            {
+              "id": "call/s6-call",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225608400,
+              "ref": {
+                "seq": 1,
+                "row": 15
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 15
+                }
+              ],
+              "attrs": {
+                "fragments": 1,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 15,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "children": [
+                {
+                  "id": "tool/s6-tool",
+                  "kind": "agent.call",
+                  "parent": "call/s6-call",
+                  "stream": "main",
+                  "at": 1767225608400,
+                  "ref": {
+                    "seq": 1,
+                    "row": 15,
+                    "block": 0
+                  },
+                  "refs": [
+                    {
+                      "seq": 1,
+                      "row": 15,
+                      "block": 0
+                    },
+                    {
+                      "seq": 1,
+                      "row": 16,
+                      "block": 0
+                    }
+                  ],
+                  "attrs": {
+                    "name": "Agent",
+                    "result": "available",
+                    "result_join": "exact_unique",
+                    "timing": "unavailable"
+                  },
+                  "text": "{\"description\":\"auditor\",\"prompt\":\"read the release notes\"}",
+                  "state": "available",
+                  "bytes": 59,
+                  "flags": [
+                    "finished"
+                  ],
+                  "name": "Agent",
+                  "result": "launched",
+                  "result_state": "available",
+                  "result_bytes": 8,
+                  "request_to_result_ms": 100,
+                  "request_to_result_join": "exact_unique",
+                  "edges": [
+                    {
+                      "type": "starts",
+                      "other": "stream/a39a255c7ba239348",
+                      "dir": "out",
+                      "quality": "exact_unique",
+                      "via": "parent tool result"
+                    }
+                  ]
+                }
+              ]
+            },
+            {
+              "id": "ack/1/16",
+              "kind": "agent.launch_ack",
+              "parent": "run/talk_main_s1-cycle/s1-cycle",
+              "stream": "main",
+              "at": 1767225608500,
+              "ref": {
+                "seq": 1,
+                "row": 16
+              },
+              "text": "launched",
+              "state": "available",
+              "bytes": 8,
+              "flags": [
+                "launch_ack"
+              ]
+            }
+          ]
+        },
+        {
+          "id": "run/talk_main_s1-cycle/s6-cycle-notification",
+          "kind": "run",
+          "parent": "talk/main/s1-cycle",
+          "stream": "main",
+          "at": 1767225612900,
+          "ref": {
+            "seq": 1,
+            "row": 17
+          },
+          "attrs": {
+            "trigger": "notification"
+          },
+          "children": [
+            {
+              "id": "notify/1/17",
+              "kind": "runtime.notification",
+              "parent": "run/talk_main_s1-cycle/s6-cycle-notification",
+              "stream": "main",
+              "at": 1767225612900,
+              "ref": {
+                "seq": 1,
+                "row": 17
+              },
+              "text": "\u003ctask-notification\u003e\n\u003ctask-id\u003ea39a255c7ba239348\u003c/task-id\u003e\n\u003ctool-use-id\u003es6-tool\u003c/tool-use-id\u003e\n\u003cstatus\u003ecompleted\u003c/status\u003e\n\u003c/task-notification\u003e",
+              "state": "available",
+              "bytes": 139,
+              "edges": [
+                {
+                  "type": "reports",
+                  "other": "stream/a39a255c7ba239348",
+                  "dir": "out",
+                  "quality": "exact_unique",
+                  "via": "task id on the notification"
+                }
+              ]
+            },
+            {
+              "id": "call/s7-call",
+              "kind": "llm.call",
+              "parent": "run/talk_main_s1-cycle/s6-cycle-notification",
+              "stream": "main",
+              "at": 1767225613900,
+              "ref": {
+                "seq": 1,
+                "row": 18
+              },
+              "refs": [
+                {
+                  "seq": 1,
+                  "row": 18
+                }
+              ],
+              "attrs": {
+                "fragments": 1,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 18,
+                  "seq": 1
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "children": [
+                {
+                  "id": "msg/1/18:0",
+                  "kind": "message.assistant",
+                  "parent": "call/s7-call",
+                  "stream": "main",
+                  "at": 1767225613900,
+                  "ref": {
+                    "seq": 1,
+                    "row": 18,
+                    "block": 0
+                  },
+                  "text": "Checkout is healthy and payment is not.",
+                  "state": "available",
+                  "bytes": 39,
+                  "flags": [
+                    "finished"
+                  ]
+                }
+              ]
+            }
+          ]
+        }
+      ],
+      "edges": [
+        {
+          "type": "in_segment",
+          "other": "segment/at_1_2",
+          "dir": "out",
+          "quality": "exact_unique",
+          "via": "activity window"
+        }
+      ]
+    },
+    {
+      "id": "talk/a39a255c7ba239348",
+      "kind": "talk",
+      "parent": "epoch/a39a255c7ba239348/0",
+      "stream": "a39a255c7ba239348",
+      "at": 1767225609500,
+      "ref": {
+        "seq": 3,
+        "row": 1
+      },
+      "attrs": {
+        "loops": 1,
+        "runs": 1,
+        "trigger": "unknown"
+      },
+      "reply": "Read.",
+      "runs": 1,
+      "steps": 6,
+      "tools": 1,
+      "from": 1767225609500,
+      "to": 1767225611900,
+      "child": true,
+      "segment": "segment/at_1_2",
+      "children": [
+        {
+          "id": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle",
+          "kind": "run",
+          "parent": "talk/a39a255c7ba239348",
+          "stream": "a39a255c7ba239348",
+          "at": 1767225609500,
+          "ref": {
+            "seq": 3,
+            "row": 1
+          },
+          "attrs": {
+            "trigger": "external"
+          },
+          "children": [
+            {
+              "id": "call/auditor-s1-call",
+              "kind": "llm.call",
+              "parent": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle",
+              "stream": "a39a255c7ba239348",
+              "at": 1767225610500,
+              "ref": {
+                "seq": 3,
+                "row": 2
+              },
+              "refs": [
+                {
+                  "seq": 3,
+                  "row": 2
+                },
+                {
+                  "seq": 3,
+                  "row": 3
+                }
+              ],
+              "attrs": {
+                "fragments": 2,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 3,
+                  "seq": 3
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "children": [
+                {
+                  "id": "msg/3/2:0",
+                  "kind": "message.assistant",
+                  "parent": "call/auditor-s1-call",
+                  "stream": "a39a255c7ba239348",
+                  "at": 1767225610500,
+                  "ref": {
+                    "seq": 3,
+                    "row": 2,
+                    "block": 0
+                  },
+                  "text": "Reading them.",
+                  "state": "available",
+                  "bytes": 13
+                },
+                {
+                  "id": "tool/auditor-s1-tool",
+                  "kind": "tool",
+                  "parent": "call/auditor-s1-call",
+                  "stream": "a39a255c7ba239348",
+                  "at": 1767225610600,
+                  "ref": {
+                    "seq": 3,
+                    "row": 3,
+                    "block": 0
+                  },
+                  "refs": [
+                    {
+                      "seq": 3,
+                      "row": 3,
+                      "block": 0
+                    },
+                    {
+                      "seq": 3,
+                      "row": 4,
+                      "block": 0
+                    }
+                  ],
+                  "attrs": {
+                    "mcp_server": "docs",
+                    "mcp_tool": "read",
+                    "name": "mcp__docs__read",
+                    "result": "available",
+                    "result_join": "exact_unique",
+                    "timing": "unavailable"
+                  },
+                  "text": "{\"page\":\"release-notes\"}",
+                  "state": "available",
+                  "bytes": 24,
+                  "flags": [
+                    "finished"
+                  ],
+                  "name": "mcp__docs__read",
+                  "result": "0.6.0: ...",
+                  "result_state": "available",
+                  "result_bytes": 10,
+                  "request_to_result_ms": 300,
+                  "request_to_result_join": "exact_unique",
+                  "executions": [
+                    "auditor-s1-tool/client_hook"
+                  ]
+                }
+              ]
+            },
+            {
+              "id": "call/auditor-s2-call",
+              "kind": "llm.call",
+              "parent": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle",
+              "stream": "a39a255c7ba239348",
+              "at": 1767225611900,
+              "ref": {
+                "seq": 3,
+                "row": 5
+              },
+              "refs": [
+                {
+                  "seq": 3,
+                  "row": 5
+                }
+              ],
+              "attrs": {
+                "fragments": 1,
+                "usage": "observed_replayable",
+                "usage_at": {
+                  "row": 5,
+                  "seq": 3
+                },
+                "usage_from": "last_fragment_in_line_order"
+              },
+              "usage": {
+                "in": 2,
+                "out": 40,
+                "cache_read": 900,
+                "cache_write": 100
+              },
+              "children": [
+                {
+                  "id": "msg/3/5:0",
+                  "kind": "message.assistant",
+                  "parent": "call/auditor-s2-call",
+                  "stream": "a39a255c7ba239348",
+                  "at": 1767225611900,
+                  "ref": {
+                    "seq": 3,
+                    "row": 5,
+                    "block": 0
+                  },
+                  "text": "Read.",
+                  "state": "available",
+                  "bytes": 5,
+                  "flags": [
+                    "finished"
+                  ]
+                }
+              ]
+            },
+            {
+              "id": "output/a39a255c7ba239348",
+              "kind": "agent.output",
+              "parent": "run/talk_a39a255c7ba239348/a39a255c7ba239348-cycle",
+              "stream": "a39a255c7ba239348",
+              "at": 1767225611900,
+              "ref": {
+                "seq": 3,
+                "row": 5
+              },
+              "refs": [
+                {
+                  "seq": 3,
+                  "row": 5
+                }
+              ],
+              "attrs": {
+                "returned_value": "unavailable"
+              },
+              "text": "Read.",
+              "state": "available",
+              "bytes": 5,
+              "flags": [
+                "finished"
+              ],
+              "edges": [
+                {
+                  "type": "ends_with",
+                  "other": "stream/a39a255c7ba239348",
+                  "dir": "in",
+                  "quality": "exact_unique",
+                  "via": "the last response in the child stream, and what it returned"
+                }
+              ]
+            }
+          ]
+        }
+      ],
+      "edges": [
+        {
+          "type": "in_segment",
+          "other": "segment/at_1_2",
+          "dir": "out",
+          "quality": "strong_inference",
+          "via": "inside the window of the talk that delegated it"
+        }
+      ]
+    }
+  ],
+  "loose": [],
+  "relations": [
+    {
+      "id": "rel/in_segment/talk_main_s1-cycle/segment_at_1_2",
+      "type": "in_segment",
+      "from": "talk/main/s1-cycle",
+      "to": "segment/at_1_2",
+      "quality": "exact_unique",
+      "via": "activity window",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 2
+        }
+      ]
+    },
+    {
+      "id": "rel/starts/tool_s6-tool/stream_a39a255c7ba239348",
+      "type": "starts",
+      "from": "tool/s6-tool",
+      "to": "stream/a39a255c7ba239348",
+      "quality": "exact_unique",
+      "via": "parent tool result",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 16
+        }
+      ]
+    },
+    {
+      "id": "rel/in_segment/talk_a39a255c7ba239348/segment_at_1_2",
+      "type": "in_segment",
+      "from": "talk/a39a255c7ba239348",
+      "to": "segment/at_1_2",
+      "quality": "strong_inference",
+      "via": "inside the window of the talk that delegated it",
+      "evidence": [
+        {
+          "seq": 3,
+          "row": 1
+        }
+      ]
+    },
+    {
+      "id": "rel/ends_with/stream_a39a255c7ba239348/output_a39a255c7ba239348",
+      "type": "ends_with",
+      "from": "stream/a39a255c7ba239348",
+      "to": "output/a39a255c7ba239348",
+      "quality": "exact_unique",
+      "via": "the last response in the child stream, and what it returned",
+      "evidence": [
+        {
+          "seq": 3,
+          "row": 5
+        }
+      ]
+    },
+    {
+      "id": "rel/reports/notify_1_17/stream_a39a255c7ba239348",
+      "type": "reports",
+      "from": "notify/1/17",
+      "to": "stream/a39a255c7ba239348",
+      "quality": "exact_unique",
+      "via": "task id on the notification",
+      "evidence": [
+        {
+          "seq": 1,
+          "row": 17
+        }
+      ]
+    }
+  ],
+  "unresolved": [],
+  "workspace_changes": [],
+  "tool_executions": [
+    {
+      "step": "tool/s2-tool",
+      "ref": {
+        "seq": 2,
+        "row": 1,
+        "block": 0
+      },
+      "schema": "execution/1",
+      "id": "s2-tool/client_hook",
+      "observed_by": "asz-plugin",
+      "boundary": "client_hook",
+      "session": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
+      "stream": "main",
+      "tool": "s2-tool",
+      "tool_name": "mcp__status__lookup",
+      "cwd": "/Users/dev/scenario",
+      "protocol": "mcp",
+      "server": {
+        "name": "status",
+        "source": "user"
+      },
+      "time": "2026-01-01T00:00:01.500Z",
+      "duration_ms": 380,
+      "outcome": "returned",
+      "arguments": {
+        "state": "size_only",
+        "bytes": 22,
+        "sha256": "ea3a64398a82e2ee89a19c80733d2be7a2496e0a0a64050fa307a20f7be8879b"
+      },
+      "result": {
+        "state": "size_only",
+        "bytes": 49,
+        "sha256": "b8af7b751371023596105f146d227c1f580834e3a84903b6d822a166de343be9"
+      }
+    },
+    {
+      "step": "tool/s3-tool",
+      "ref": {
+        "seq": 2,
+        "row": 3,
+        "block": 0
+      },
+      "schema": "execution/1",
+      "id": "s3-tool/client_hook",
+      "observed_by": "asz-plugin",
+      "boundary": "client_hook",
+      "session": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
+      "stream": "main",
+      "tool": "s3-tool",
+      "tool_name": "mcp__status__lookup",
+      "cwd": "/Users/dev/scenario",
+      "protocol": "mcp",
+      "server": {
+        "name": "status",
+        "source": "user"
+      },
+      "time": "2026-01-01T00:00:04.600Z",
+      "duration_ms": 2000,
+      "outcome": "failed",
+      "arguments": {
+        "state": "size_only",
+        "bytes": 21,
+        "sha256": "e0f7631369273f52e3e7c14ca01c6bbf3f8416a36ed125c8d45fc14156596c36"
+      }
+    },
+    {
+      "step": "tool/s4-tool",
+      "ref": {
+        "seq": 2,
+        "row": 4,
+        "block": 0
+      },
+      "schema": "execution/1",
+      "id": "s4-tool/client_hook",
+      "observed_by": "asz-plugin",
+      "boundary": "client_hook",
+      "session": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
+      "stream": "main",
+      "tool": "s4-tool",
+      "tool_name": "mcp__status__lookup",
+      "cwd": "/Users/dev/scenario",
+      "protocol": "mcp",
+      "server": {
+        "name": "status",
+        "source": "user"
+      },
+      "time": "2026-01-01T00:00:06.200Z",
+      "duration_ms": 450,
+      "outcome": "returned",
+      "arguments": {
+        "state": "size_only",
+        "bytes": 42,
+        "sha256": "ea25b35f703111d67a809bd52709c167eda433619f18d87eb668eeab5dcc0851"
+      },
+      "result": {
+        "state": "size_only",
+        "bytes": 48,
+        "sha256": "0397134b1598c5571ad554ef3963b3ad77adaca182426b6850b6b89be698fc32"
+      }
+    },
+    {
+      "step": "tool/auditor-s1-tool",
+      "ref": {
+        "seq": 4,
+        "row": 1,
+        "block": 0
+      },
+      "schema": "execution/1",
+      "id": "auditor-s1-tool/client_hook",
+      "observed_by": "asz-plugin",
+      "boundary": "client_hook",
+      "session": "720d2f9e-dca7-49be-8c5e-89b667b39a01",
+      "stream": "a39a255c7ba239348",
+      "tool": "auditor-s1-tool",
+      "tool_name": "mcp__docs__read",
+      "cwd": "/Users/dev/scenario",
+      "protocol": "mcp",
+      "server": {
+        "name": "docs",
+        "source": "project"
+      },
+      "time": "2026-01-01T00:00:10.900Z",
+      "duration_ms": 280,
+      "outcome": "returned",
+      "arguments": {
+        "state": "size_only",
+        "bytes": 24,
+        "sha256": "5cb51817b71b5f57c1159e9cedfe7a555f4e89d469548438ee4b25324cac71a0"
+      },
+      "result": {
+        "state": "size_only",
+        "bytes": 37,
+        "sha256": "1dc1201945582b1373186efab341595fbbb3ab3316889ea91ea5c2968456e164"
+      }
+    }
+  ]
+}
diff --git a/packages/conversation-view/test/mcp.test.ts b/packages/conversation-view/test/mcp.test.ts
new file mode 100644
index 00000000..fab8ac2f
--- /dev/null
+++ b/packages/conversation-view/test/mcp.test.ts
@@ -0,0 +1,174 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+import { afterEach, describe, expect, it } from 'vitest';
+import { mountConversationView, type AszViewDocument, type ConversationView } from '../src/index.js';
+import { ConversationModel } from '../src/model.js';
+
+/** The Sessionizer's mcp-calls scenario, as the OAP's fixture holds it: three
+ *  calls to one MCP server on the main stream, one failed, a call in a
+ *  subagent to another server, each with its execution record, and a call
+ *  whose name splits two ways and so names no server. */
+const fixture = JSON.parse(readFileSync(resolve('test/fixtures/mcp-calls.json'), 'utf8')) as AszViewDocument;
+/** The original example, from before execution records existed. */
+const plain = JSON.parse(readFileSync(resolve('test/fixtures/asz-view-example.json'), 'utf8')) as AszViewDocument;
+
+const LOOKUP = 'tool/s2-tool';
+const FAILED = 'tool/s3-tool';
+const ODD = 'tool/s5-tool';
+
+let view: ConversationView | null = null;
+let host: HTMLElement | null = null;
+
+function mount(doc: AszViewDocument = fixture): HTMLElement {
+  host = document.createElement('div');
+  document.body.appendChild(host);
+  view = mountConversationView(host, { document: doc });
+  return host;
+}
+
+function openFold(root: HTMLElement): void {
+  root.querySelector<HTMLButtonElement>('.acv-fold[data-work]')!.click();
+}
+
+afterEach(() => {
+  view?.destroy();
+  host?.remove();
+  view = null;
+  host = null;
+});
+
+describe('the model over calls to MCP servers', () => {
+  it('puts a call that names its server on the MCP lane, and one whose name does not split on the tools lane', () => {
+    const m = new ConversationModel(fixture);
+    expect(m.step(LOOKUP)!.mcp).toEqual({ server: 'status', tool: 'lookup' });
+    expect(m.step(LOOKUP)!.track).toBe('mcp');
+    expect(m.step('tool/auditor-s1-tool')!.mcp).toEqual({ server: 'docs', tool: 'read' });
+    expect(m.step(ODD)!.mcp).toBeUndefined();
+    expect(m.step(ODD)!.track).toBe('tools');
+  });
+
+  it('joins execution records to steps by `step`, and keeps the ones no step carries apart', () => {
+    const m = new ConversationModel(fixture);
+    expect(m.toolExecutions).toHaveLength(4);
+    expect(m.executionsOf(LOOKUP).map((x) => x.outcome)).toEqual(['returned']);
+    expect(m.executionsOf(FAILED)[0]!.duration_ms).toBe(2000);
+    expect(m.executionsOf(ODD)).toHaveLength(0);
+    const copy = JSON.parse(JSON.stringify(fixture)) as AszViewDocument;
+    copy.tool_executions = [{ ...copy.tool_executions![0]!, step: '' }];
+    const unjoined = new ConversationModel(copy);
+    expect(unjoined.toolExecutions).toHaveLength(1);
+    expect(unjoined.executionsOf(LOOKUP)).toHaveLength(0);
+  });
+});
+
+describe('an MCP call on the page', () => {
+  it('names its server in the role and its server and tool in the title, and wears how it ended', () => {
+    const root = mount();
+    openFold(root);
+    const card = root.querySelector<HTMLElement>(`[data-card="${LOOKUP}"]`)!;
+    expect(card.querySelector('.acv-role')!.textContent).toContain('→ MCP · status');
+    expect(card.querySelector('.acv-title')!.textContent).toContain('status · lookup');
+    expect(card.querySelector('.acv-execution-pill')!.textContent).toBe('returned · 380 ms');
+    const failed = root.querySelector<HTMLElement>(`[data-card="${FAILED}"] .acv-execution-pill`)!;
+    expect(failed.classList.contains('failed')).toBe(true);
+    expect(failed.textContent).toBe('failed · 2.0 s');
+    // no server, no pill: the call whose name does not split stays a plain tool
+    const odd = root.querySelector<HTMLElement>(`[data-card="${ODD}"]`)!;
+    expect(odd.querySelector('.acv-execution-pill')).toBeNull();
+    expect(odd.querySelector('.acv-role')!.textContent).toContain('Tool');
+  });
+
+  it('opens the Execution tab from its pill, with the server, the outcome, the measured time and the sizes', () => {
+    const root = mount();
+    openFold(root);
+    root.querySelector<HTMLButtonElement>(`[data-card="${LOOKUP}"] .acv-execution-pill`)!.click();
+    const tab = root.querySelector<HTMLButtonElement>('[data-tab="execution"]')!;
+    expect(tab.hidden).toBe(false);
+    expect(tab.getAttribute('aria-selected')).toBe('true');
+    const body = root.querySelector('.acv-inspector-body')!.textContent!;
+    expect(body).toContain('asz-plugin · client_hook');
+    expect(body).toContain('status');
+    expect(body).toContain('user');
+    expect(body).toContain('returned');
+    expect(body).toContain('380 ms');
+    expect(body).toContain('22 B · SHA-256 ea3a64398a82');
+    expect(body).toContain('seq 2 · row 1 · block 0');
+    expect(body).toContain("not the server's own time");
+  });
+
+  it('writes every value of a record as text, the reference numbers included', () => {
+    const copy = JSON.parse(JSON.stringify(fixture)) as AszViewDocument;
+    const record = copy.tool_executions!.find((x) => x.step === LOOKUP)!;
+    record.ref = { ...record.ref, seq: '<img src=x>' as unknown as number };
+    record.server = { name: '<b>status</b>', source: 'user' };
+    const root = mount(copy);
+    openFold(root);
+    root.querySelector<HTMLButtonElement>(`[data-card="${LOOKUP}"] .acv-execution-pill`)!.click();
+    const body = root.querySelector('.acv-inspector-body')!;
+    expect(body.querySelector('img')).toBeNull();
+    expect(body.querySelector('b')).toBeNull();
+    expect(body.textContent).toContain('seq <img src=x>');
+    expect(body.textContent).toContain('<b>status</b>');
+  });
+
+  it('labels the record it opens in Evidence as an execution record', () => {
+    const root = mount();
+    openFold(root);
+    root.querySelector<HTMLButtonElement>(`[data-card="${LOOKUP}"] .acv-execution-pill`)!.click();
+    root.querySelector<HTMLButtonElement>('.acv-inspector-body [data-to-evidence]')!.click();
+    const body = root.querySelector('.acv-inspector-body')!.textContent!;
+    expect(body).toContain('execution record');
+    expect(body).not.toContain('change record');
+  });
+
+  it('says in Details which server and tool the call addressed, and links to its records', () => {
+    const root = mount();
+    openFold(root);
+    root.querySelector<HTMLElement>(`[data-card="${LOOKUP}"]`)!.click();
+    const body = root.querySelector('.acv-inspector-body')!;
+    expect(body.textContent).toContain('MCP server');
+    expect(body.textContent).toContain('MCP tool');
+    body.querySelector<HTMLButtonElement>('[data-to-execution]')!.click();
+    expect(root.querySelector('[data-tab="execution"]')!.getAttribute('aria-selected')).toBe('true');
+  });
+
+  it('draws calls to MCP servers on their own lane, named by server and tool', () => {
+    const root = mount();
+    openFold(root);
+    const lanes = [...root.querySelectorAll('.acv-lane-label')].map((l) => l.textContent);
+    expect(lanes).toContain('MCP');
+    expect(lanes).toContain('Tools');
+    const clip = root.querySelector<HTMLElement>(`.acv-clip[data-node="${LOOKUP}"]`)!;
+    expect(clip.textContent).toBe('status · lookup');
+    expect(clip.title).toContain('returned');
+    const odd = root.querySelector<HTMLElement>(`.acv-clip[data-node="${ODD}"]`)!;
+    expect(odd.textContent).toBe('mcp__odd__name__twice');
+    expect(Number.parseFloat(odd.style.top)).not.toBe(Number.parseFloat(clip.style.top));
+  });
+
+  it('shows no Execution tab and no lane for a document from before execution records', () => {
+    const root = mount(plain);
+    openFold(root);
+    expect(root.querySelector('.acv-execution-pill')).toBeNull();
+    expect([...root.querySelectorAll('.acv-lane-label')].map((l) => l.textContent)).not.toContain('MCP');
+    root.querySelector<HTMLElement>('[data-card]')!.click();
+    expect(root.querySelector<HTMLButtonElement>('[data-tab="execution"]')!.hidden).toBe(true);
+  });
+});