From f992511e75121a6aec668229c16fb42062a8b5b0 Mon Sep 17 00:00:00 2001 From: r3dbars Date: Tue, 7 Jul 2026 15:28:57 -0500 Subject: [PATCH] feat(mcp): add MCP Apps recent-meetings UI widget Expose an interactive "recent meetings" widget from the Transcripted MCP server via the MCP Apps extension (io.modelcontextprotocol/ui, SEP-1865): a card list of the N most recent meetings, each with inline audio playback and a raw-transcript view, rendered inside a rendering-capable agent client. - show_recent_meetings tool returns a self-contained text/html;profile=mcp-app widget as an inline embedded resource + structuredContent + a text fallback, linked via _meta.ui.resourceUri and openai/outputTemplate. - ui://transcripted/recent-meetings.html registered as a readable resource. - Reuses the existing meeting/transcript/audio access; adds a UI layer, not new data plumbing. Audio is base64-inlined (size-capped) and plays from a data: URI under the MCP Apps default sandbox CSP. - Fully local: no network, no CDN. Local audio/transcripts travel with the widget over the local stdio transport; nothing leaves the Mac. - Theme-aware (light/dark). 11 new tests; full MCP suite green (170). Renders inline today in ChatGPT, VS Code, Cursor, and Goose. Claude Desktop/web is announced but unverified on some builds; the Claude Code CLI shows the text fallback (no iframe surface). See docs/mcp-ui-recent-meetings.md. Co-Authored-By: Claude Opus 4.8 --- Tools/TranscriptedMCP/README.md | 12 + .../Sources/TranscriptedMCP/Main.swift | 8 +- .../RecentMeetingsWidget.swift | 361 ++++++++++++++++++ .../RecentMeetingsWidgetBuilder.swift | 228 +++++++++++ .../TranscriptedMCP/ToolHandlers.swift | 20 + .../TranscriptedMCP/UIResourceHandlers.swift | 164 ++++++++ .../RecentMeetingsWidgetTests.swift | 243 ++++++++++++ docs/mcp-ui-recent-meetings.md | 110 ++++++ 8 files changed, 1145 insertions(+), 1 deletion(-) create mode 100644 Tools/TranscriptedMCP/Sources/TranscriptedMCP/RecentMeetingsWidget.swift create mode 100644 Tools/TranscriptedMCP/Sources/TranscriptedMCP/RecentMeetingsWidgetBuilder.swift create mode 100644 Tools/TranscriptedMCP/Sources/TranscriptedMCP/UIResourceHandlers.swift create mode 100644 Tools/TranscriptedMCP/Tests/TranscriptedMCPTests/RecentMeetingsWidgetTests.swift create mode 100644 docs/mcp-ui-recent-meetings.md diff --git a/Tools/TranscriptedMCP/README.md b/Tools/TranscriptedMCP/README.md index c94d0eaa..c0bc785c 100644 --- a/Tools/TranscriptedMCP/README.md +++ b/Tools/TranscriptedMCP/README.md @@ -13,6 +13,18 @@ For Claude Desktop users, the best setup is inside the app: That path installs the bundled helper, updates Claude Desktop's config, and runs a local self-test. +## Interactive UI (MCP Apps) + +The server exposes one interactive widget via the MCP Apps extension +(`io.modelcontextprotocol/ui`, SEP-1865): call `show_recent_meetings` to render a +card list of recent meetings — each with inline audio playback and a raw-transcript +view — that draws inside a rendering-capable client. It is fully self-contained +(no network; local audio/transcripts travel over the local transport), and falls +back to a plain-text list in clients that do not render inline UI (e.g. the Claude +Code CLI). Renders inline today in ChatGPT, VS Code, Cursor, and Goose; see +[`docs/mcp-ui-recent-meetings.md`](../../docs/mcp-ui-recent-meetings.md) for the +per-client support matrix and how to try it. + ## Source Build Use this only when developing the MCP server directly: diff --git a/Tools/TranscriptedMCP/Sources/TranscriptedMCP/Main.swift b/Tools/TranscriptedMCP/Sources/TranscriptedMCP/Main.swift index c9c88f34..2c84a35a 100644 --- a/Tools/TranscriptedMCP/Sources/TranscriptedMCP/Main.swift +++ b/Tools/TranscriptedMCP/Sources/TranscriptedMCP/Main.swift @@ -69,10 +69,16 @@ struct TranscriptedMCP { let server = Server( name: "transcripted", version: serverVersion, - capabilities: .init(tools: .init(listChanged: false)) + capabilities: .init( + resources: .init(subscribe: false, listChanged: false), + tools: .init(listChanged: false) + ) ) await registerToolHandlers(server: server, index: index, directories: directories) + await TranscriptedUIResources.register( + server: server, index: index, directories: directories, serverVersion: serverVersion + ) log("MCP server ready, waiting for connections") diff --git a/Tools/TranscriptedMCP/Sources/TranscriptedMCP/RecentMeetingsWidget.swift b/Tools/TranscriptedMCP/Sources/TranscriptedMCP/RecentMeetingsWidget.swift new file mode 100644 index 00000000..19b06b59 --- /dev/null +++ b/Tools/TranscriptedMCP/Sources/TranscriptedMCP/RecentMeetingsWidget.swift @@ -0,0 +1,361 @@ +import Foundation +import TranscriptedCaptureKit + +// MARK: - Widget data model + +/// One meeting rendered as a card in the recent-meetings widget. This is the +/// contract between the data builder (which reads the local capture library) +/// and the self-contained HTML renderer. It is also the `structuredContent` +/// shape a spec-compliant MCP Apps host receives via `ui/notifications/tool-result`, +/// so a template that reads `window.openai.toolOutput` renders the same view as +/// the data baked into the HTML. +struct WidgetMeeting: Codable, Hashable { + /// Human title (frontmatter title, else a filename-derived label). + let title: String + /// `YYYY-MM-DD` local date. + let date: String + /// ISO-8601 datetime when known (used only for display/sort stability). + let datetime: String + /// Recording length in seconds. + let durationSeconds: Int + /// Speaker display names, in transcript order. + let speakers: [String] + /// Total word count across the transcript. + let wordCount: Int + /// The `read_meeting` filename (stem) — lets an agent drill in for more. + let filename: String + /// Full dialogue text (bounded by the same read cap the read tools use). + let transcript: String + /// Inline audio, when a preferred track exists and fits the size budget. + let audio: WidgetAudio? + /// Absolute local path to the audio directory, for the "open in Finder" + /// fallback when audio is absent or too large to inline. Never leaves the Mac. + let audioDirectory: String? + /// Why inline audio is unavailable, when it is (missing vs. over budget). + let audioNote: String? + + enum CodingKeys: String, CodingKey { + case title, date, datetime + case durationSeconds = "duration_seconds" + case speakers + case wordCount = "word_count" + case filename, transcript, audio + case audioDirectory = "audio_directory" + case audioNote = "audio_note" + } +} + +/// A base64 `data:` audio payload the widget plays with a plain `