Skip to content

Commit d927b47

Browse files
olaservoclaude
andcommitted
Add PR viewer UI app with diff and details tabs
Integrates the diff viewer UI into the MCP server as a general-purpose PR viewer attached to the pull_request_read tool. Supports two views: - Diff: unified/split diff rendering with file collapsing - Details: PR metadata, state, labels, branches, and stats Uses method-driven tab selection with the ability to switch between views via tab navigation, fetching data through callTool. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bf64678 commit d927b47

File tree

11 files changed

+1454
-2
lines changed

11 files changed

+1454
-2
lines changed

pkg/github/__toolsnaps__/pull_request_read.snap

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,9 @@
11
{
2+
"_meta": {
3+
"ui": {
4+
"resourceUri": "ui://github-mcp-server/pr-read"
5+
}
6+
},
27
"annotations": {
38
"readOnlyHint": true,
49
"title": "Get details for a single pull request"

pkg/github/pullrequests.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ import (
2222
"github.com/github/github-mcp-server/pkg/utils"
2323
)
2424

25+
// PullRequestReadUIResourceURI is the URI for the pull_request_read tool's MCP App UI resource.
26+
const PullRequestReadUIResourceURI = "ui://github-mcp-server/pr-read"
27+
2528
// PullRequestRead creates a tool to get details of a specific pull request.
2629
func PullRequestRead(t translations.TranslationHelperFunc) inventory.ServerTool {
2730
schema := &jsonschema.Schema{
@@ -69,6 +72,11 @@ Possible options:
6972
ReadOnlyHint: true,
7073
},
7174
InputSchema: schema,
75+
Meta: mcp.Meta{
76+
"ui": map[string]any{
77+
"resourceUri": PullRequestReadUIResourceURI,
78+
},
79+
},
7280
},
7381
[]scopes.Scope{scopes.Repo},
7482
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {

pkg/github/ui_resources.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,4 +86,26 @@ func RegisterUIResources(s *mcp.Server) {
8686
}, nil
8787
},
8888
)
89+
90+
// Register the pull_request_read UI resource
91+
s.AddResource(
92+
&mcp.Resource{
93+
URI: PullRequestReadUIResourceURI,
94+
Name: "pr_read_ui",
95+
Description: "MCP App UI for viewing pull request details and diffs",
96+
MIMEType: MCPAppMIMEType,
97+
},
98+
func(_ context.Context, _ *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) {
99+
html := MustGetUIAsset("pr-read.html")
100+
return &mcp.ReadResourceResult{
101+
Contents: []*mcp.ResourceContents{
102+
{
103+
URI: PullRequestReadUIResourceURI,
104+
MIMEType: MCPAppMIMEType,
105+
Text: html,
106+
},
107+
},
108+
}, nil
109+
},
110+
)
89111
}

ui/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55
"type": "module",
66
"description": "MCP App UIs for github-mcp-server using Primer React",
77
"scripts": {
8-
"build": "npm run build:get-me && npm run build:issue-write && npm run build:pr-write",
8+
"build": "npm run build:get-me && npm run build:issue-write && npm run build:pr-write && npm run build:pr-read",
99
"build:get-me": "cross-env APP=get-me vite build",
1010
"build:issue-write": "cross-env APP=issue-write vite build",
1111
"build:pr-write": "cross-env APP=pr-write vite build",
12+
"build:pr-read": "cross-env APP=pr-read vite build",
1213
"dev": "npm run build",
1314
"typecheck": "tsc --noEmit",
1415
"clean": "rm -rf dist"

ui/src/apps/pr-read/App.ts

Lines changed: 314 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,314 @@
1+
import {
2+
App,
3+
applyDocumentTheme,
4+
applyHostStyleVariables,
5+
applyHostFonts,
6+
} from "@modelcontextprotocol/ext-apps";
7+
import type { CallToolResult } from "@modelcontextprotocol/ext-apps";
8+
import { parseDiff } from "./diff-parser";
9+
import { renderDiff, setViewMode, getViewMode } from "./diff-renderer";
10+
import { renderPRDetails } from "./pr-details-renderer";
11+
import "./styles.css";
12+
13+
type Tab = "details" | "diff";
14+
15+
let app: App | null = null;
16+
let activeTab: Tab = "details";
17+
18+
// Stored params for making subsequent tool calls when switching tabs
19+
let prOwner = "";
20+
let prRepo = "";
21+
let prPullNumber = 0;
22+
23+
// Cache fetched data to avoid re-fetching on tab switch
24+
let cachedDetails: Record<string, unknown> | null = null;
25+
let cachedDiff: string | null = null;
26+
27+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
28+
function handleHostContextChanged(ctx: any): void {
29+
if (ctx.theme) {
30+
applyDocumentTheme(ctx.theme);
31+
}
32+
if (ctx.styles?.variables) {
33+
applyHostStyleVariables(ctx.styles.variables);
34+
}
35+
if (ctx.styles?.css?.fonts) {
36+
applyHostFonts(ctx.styles.css.fonts);
37+
}
38+
39+
// Apply safe area insets
40+
if (ctx.safeAreaInsets) {
41+
document.body.style.paddingTop = `${ctx.safeAreaInsets.top}px`;
42+
document.body.style.paddingBottom = `${ctx.safeAreaInsets.bottom}px`;
43+
document.body.style.paddingLeft = `${ctx.safeAreaInsets.left}px`;
44+
document.body.style.paddingRight = `${ctx.safeAreaInsets.right}px`;
45+
}
46+
47+
// Update fullscreen button visibility and state
48+
const fullscreenBtn = document.getElementById("fullscreen-btn");
49+
if (fullscreenBtn) {
50+
if (ctx.availableDisplayModes) {
51+
const canFullscreen = ctx.availableDisplayModes.includes("fullscreen");
52+
fullscreenBtn.style.display = canFullscreen ? "flex" : "none";
53+
}
54+
if (ctx.displayMode) {
55+
const isFullscreen = ctx.displayMode === "fullscreen";
56+
fullscreenBtn.textContent = isFullscreen ? "✕" : "⛶";
57+
fullscreenBtn.title = isFullscreen ? "Exit fullscreen" : "Fullscreen";
58+
document.body.classList.toggle("fullscreen", isFullscreen);
59+
}
60+
}
61+
}
62+
63+
async function toggleFullscreen(): Promise<void> {
64+
if (!app) return;
65+
const ctx = app.getHostContext();
66+
const currentMode = ctx?.displayMode || "inline";
67+
const newMode = currentMode === "fullscreen" ? "inline" : "fullscreen";
68+
if (ctx?.availableDisplayModes?.includes(newMode)) {
69+
await app.requestDisplayMode({ mode: newMode });
70+
}
71+
}
72+
73+
function toggleViewMode(): void {
74+
const currentMode = getViewMode();
75+
const newMode = currentMode === "unified" ? "split" : "unified";
76+
setViewMode(newMode);
77+
updateViewModeButton();
78+
}
79+
80+
function updateViewModeButton(): void {
81+
const btn = document.getElementById("view-mode-btn");
82+
if (btn) {
83+
const mode = getViewMode();
84+
btn.textContent = mode === "unified" ? "Split" : "Unified";
85+
btn.title = mode === "unified" ? "Switch to split view" : "Switch to unified view";
86+
}
87+
}
88+
89+
function updateTitle(owner: string, repo: string, pullNumber: number): void {
90+
const title = document.getElementById("title");
91+
if (title) {
92+
const prUrl = `https://github.com/${owner}/${repo}/pull/${pullNumber}`;
93+
title.innerHTML = `<span class="pr-link">${escapeHtml(owner)}/${escapeHtml(repo)} #${pullNumber}</span>`;
94+
const link = title.querySelector(".pr-link");
95+
if (link) {
96+
link.addEventListener("click", () => {
97+
app?.openLink({ url: prUrl });
98+
});
99+
}
100+
}
101+
}
102+
103+
function escapeHtml(text: string): string {
104+
const div = document.createElement("div");
105+
div.textContent = text;
106+
return div.innerHTML;
107+
}
108+
109+
function switchTab(tab: Tab): void {
110+
if (tab === activeTab) return;
111+
activeTab = tab;
112+
113+
// Update tab bar
114+
document.querySelectorAll(".tab").forEach((el) => {
115+
el.classList.toggle("active", (el as HTMLElement).dataset.tab === tab);
116+
});
117+
118+
// Toggle content visibility
119+
const contentArea = document.getElementById("content-area");
120+
const diffContainer = document.getElementById("diff-container");
121+
const viewModeBtn = document.getElementById("view-mode-btn");
122+
123+
if (contentArea) contentArea.style.display = tab === "details" ? "block" : "none";
124+
if (diffContainer) diffContainer.style.display = tab === "diff" ? "flex" : "none";
125+
if (viewModeBtn) viewModeBtn.style.display = tab === "diff" ? "flex" : "none";
126+
127+
// Fetch data if not cached
128+
if (tab === "diff" && cachedDiff === null) {
129+
fetchDiff();
130+
} else if (tab === "details" && cachedDetails === null) {
131+
fetchDetails();
132+
}
133+
}
134+
135+
function parseToolResultText(result: CallToolResult): string | null {
136+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
137+
const content = result.content as any[];
138+
if (!content || content.length === 0) return null;
139+
const textBlock = content.find((c) => c.type === "text");
140+
return textBlock?.text ?? null;
141+
}
142+
143+
async function fetchDiff(): Promise<void> {
144+
if (!app || !prOwner || !prRepo || !prPullNumber) return;
145+
146+
const diffContainer = document.getElementById("diff-container");
147+
if (diffContainer) diffContainer.innerHTML = '<div class="loading">Loading diff...</div>';
148+
149+
const result = await app.callTool("pull_request_read", {
150+
method: "get_diff",
151+
owner: prOwner,
152+
repo: prRepo,
153+
pullNumber: prPullNumber,
154+
});
155+
156+
const text = parseToolResultText(result);
157+
if (text) {
158+
cachedDiff = text;
159+
const parsed = parseDiff(text);
160+
renderDiff(parsed);
161+
}
162+
}
163+
164+
async function fetchDetails(): Promise<void> {
165+
if (!app || !prOwner || !prRepo || !prPullNumber) return;
166+
167+
const contentArea = document.getElementById("content-area");
168+
if (contentArea) contentArea.innerHTML = '<div class="loading">Loading details...</div>';
169+
170+
const result = await app.callTool("pull_request_read", {
171+
method: "get",
172+
owner: prOwner,
173+
repo: prRepo,
174+
pullNumber: prPullNumber,
175+
});
176+
177+
const text = parseToolResultText(result);
178+
if (text) {
179+
try {
180+
cachedDetails = JSON.parse(text);
181+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
182+
renderPRDetails(cachedDetails as any);
183+
} catch {
184+
if (contentArea) contentArea.innerHTML = `<div class="empty-state">Failed to parse PR details</div>`;
185+
}
186+
}
187+
}
188+
189+
function handleInitialResult(result: CallToolResult, method: string): void {
190+
const text = parseToolResultText(result);
191+
if (!text) return;
192+
193+
if (method === "get_diff") {
194+
cachedDiff = text;
195+
const parsed = parseDiff(text);
196+
renderDiff(parsed);
197+
switchTab("diff");
198+
} else if (method === "get") {
199+
try {
200+
cachedDetails = JSON.parse(text);
201+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
202+
renderPRDetails(cachedDetails as any);
203+
switchTab("details");
204+
} catch {
205+
// fall through
206+
}
207+
}
208+
}
209+
210+
function init(): void {
211+
app = new App({ name: "github-mcp-server-pr-read", version: "1.0.0" });
212+
213+
// Handle tool input to extract params and determine initial tab
214+
app.ontoolinput = (input: Record<string, unknown>) => {
215+
const owner = input.owner as string;
216+
const repo = input.repo as string;
217+
const pullNumber = input.pullNumber as number;
218+
const method = (input.method as string) || "get";
219+
220+
if (owner) prOwner = owner;
221+
if (repo) prRepo = repo;
222+
if (pullNumber) prPullNumber = pullNumber;
223+
224+
if (prOwner && prRepo && prPullNumber) {
225+
updateTitle(prOwner, prRepo, prPullNumber);
226+
}
227+
228+
// Set initial tab based on method
229+
if (method === "get_diff") {
230+
switchTab("diff");
231+
} else {
232+
switchTab("details");
233+
}
234+
};
235+
236+
// Handle tool results
237+
app.ontoolresult = (result: CallToolResult) => {
238+
// Determine which method this result is for based on active tab / cached state
239+
// If we don't have either cached, this is the initial result
240+
if (cachedDetails === null && cachedDiff === null) {
241+
// Peek at the content to determine the type
242+
const text = parseToolResultText(result);
243+
if (text) {
244+
// If it looks like a unified diff, it's a diff result
245+
if (text.startsWith("diff --git") || text.includes("\n---\n")) {
246+
cachedDiff = text;
247+
const parsed = parseDiff(text);
248+
renderDiff(parsed);
249+
if (activeTab === "diff") {
250+
// Already on diff tab, just render
251+
}
252+
} else {
253+
// Try to parse as JSON (PR details)
254+
try {
255+
cachedDetails = JSON.parse(text);
256+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
257+
renderPRDetails(cachedDetails as any);
258+
} catch {
259+
// Unknown format - show as text
260+
const contentArea = document.getElementById("content-area");
261+
if (contentArea) contentArea.textContent = text;
262+
}
263+
}
264+
}
265+
}
266+
};
267+
268+
// Handle streaming partial input for progressive diff rendering
269+
app.ontoolinputpartial = (input: Record<string, unknown>) => {
270+
const diff = input.diff as string | undefined;
271+
if (diff && activeTab === "diff") {
272+
const parsed = parseDiff(diff);
273+
renderDiff(parsed);
274+
}
275+
};
276+
277+
// Handle host context changes (theme, etc.)
278+
app.onhostcontextchanged = handleHostContextChanged;
279+
280+
// Set up tab bar
281+
document.querySelectorAll(".tab").forEach((tab) => {
282+
tab.addEventListener("click", () => {
283+
const tabName = (tab as HTMLElement).dataset.tab as Tab;
284+
if (tabName) switchTab(tabName);
285+
});
286+
});
287+
288+
// Set up view mode toggle button
289+
const viewModeBtn = document.getElementById("view-mode-btn");
290+
if (viewModeBtn) {
291+
viewModeBtn.addEventListener("click", toggleViewMode);
292+
}
293+
294+
// Set up fullscreen button
295+
const fullscreenBtn = document.getElementById("fullscreen-btn");
296+
if (fullscreenBtn) {
297+
fullscreenBtn.addEventListener("click", toggleFullscreen);
298+
}
299+
300+
// Connect to host
301+
app.connect().then(() => {
302+
const ctx = app?.getHostContext();
303+
if (ctx) {
304+
handleHostContextChanged(ctx);
305+
}
306+
});
307+
}
308+
309+
// Initialize when DOM is ready
310+
if (document.readyState === "loading") {
311+
document.addEventListener("DOMContentLoaded", init);
312+
} else {
313+
init();
314+
}

0 commit comments

Comments
 (0)