Skip to content

Commit bea9bf9

Browse files
committed
add postmessage logic and richer UI
1 parent 6ed3ac7 commit bea9bf9

File tree

1 file changed

+259
-8
lines changed

1 file changed

+259
-8
lines changed

pkg/github/context_tools.go

Lines changed: 259 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,30 +19,281 @@ import (
1919
const GetMeUIResourceURI = "ui://github-mcp-server/get-me"
2020

2121
// GetMeUIHTML is the HTML content for the get_me tool's MCP App UI.
22-
// This is a simple "Hello World" demo with bold red text.
22+
// This UI dynamically displays user information from the tool result.
23+
//
24+
// How MCP Apps work:
25+
// 1. Server registers this HTML as a resource at ui://github-mcp-server/get-me
26+
// 2. Server links the get_me tool to this resource via _meta.ui.resourceUri
27+
// 3. When host calls get_me, it sees the resourceUri and fetches this HTML
28+
// 4. Host renders HTML in a sandboxed iframe and communicates via postMessage
29+
// 5. After ui/initialize, host sends ui/notifications/tool-result with the data
30+
// 6. This UI parses the tool result and renders the user profile dynamically
2331
const GetMeUIHTML = `<!DOCTYPE html>
2432
<html lang="en">
2533
<head>
2634
<meta charset="UTF-8">
2735
<meta name="viewport" content="width=device-width, initial-scale=1.0">
2836
<title>GitHub MCP Server - Get Me</title>
2937
<style>
38+
* { box-sizing: border-box; }
3039
body {
3140
font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif);
32-
padding: 20px;
41+
padding: 16px;
3342
margin: 0;
3443
background: var(--color-background-primary, #fff);
35-
color: var(--color-text-primary, #333);
44+
color: var(--color-text-primary, #24292f);
45+
font-size: var(--font-text-md-size, 14px);
46+
line-height: var(--font-text-md-line-height, 1.5);
3647
}
37-
.hello-world {
38-
font-weight: bold;
39-
color: red;
40-
font-size: 1.5em;
48+
.loading {
49+
color: var(--color-text-secondary, #656d76);
50+
font-style: italic;
51+
}
52+
.error {
53+
color: var(--color-text-danger, #cf222e);
54+
}
55+
.user-card {
56+
border: 1px solid var(--color-border-primary, #d0d7de);
57+
border-radius: var(--border-radius-lg, 6px);
58+
padding: 16px;
59+
max-width: 400px;
60+
background: var(--color-background-secondary, #f6f8fa);
61+
}
62+
.user-header {
63+
display: flex;
64+
align-items: center;
65+
gap: 12px;
66+
margin-bottom: 12px;
67+
padding-bottom: 12px;
68+
border-bottom: 1px solid var(--color-border-primary, #d0d7de);
69+
}
70+
.avatar {
71+
width: 48px;
72+
height: 48px;
73+
border-radius: 50%;
74+
border: 1px solid var(--color-border-primary, #d0d7de);
75+
}
76+
.user-names {
77+
flex: 1;
78+
}
79+
.display-name {
80+
font-weight: 600;
81+
font-size: var(--font-heading-xs-size, 16px);
82+
color: var(--color-text-primary, #24292f);
83+
margin: 0;
84+
}
85+
.username {
86+
color: var(--color-text-secondary, #656d76);
87+
margin: 0;
88+
}
89+
.bio {
90+
font-style: italic;
91+
color: var(--color-text-secondary, #656d76);
92+
margin: 0 0 12px 0;
93+
padding: 8px;
94+
background: var(--color-background-primary, #fff);
95+
border-radius: var(--border-radius-sm, 4px);
96+
}
97+
.info-grid {
98+
display: grid;
99+
grid-template-columns: auto 1fr;
100+
gap: 4px 12px;
101+
}
102+
.info-label {
103+
color: var(--color-text-secondary, #656d76);
104+
font-weight: 500;
105+
}
106+
.info-value {
107+
color: var(--color-text-primary, #24292f);
108+
}
109+
.info-value a {
110+
color: var(--color-text-info, #0969da);
111+
text-decoration: none;
112+
}
113+
.info-value a:hover {
114+
text-decoration: underline;
115+
}
116+
.stats {
117+
display: flex;
118+
gap: 16px;
119+
margin-top: 12px;
120+
padding-top: 12px;
121+
border-top: 1px solid var(--color-border-primary, #d0d7de);
122+
}
123+
.stat {
124+
text-align: center;
125+
}
126+
.stat-value {
127+
font-weight: 600;
128+
font-size: var(--font-heading-xs-size, 16px);
129+
color: var(--color-text-primary, #24292f);
130+
}
131+
.stat-label {
132+
font-size: var(--font-text-sm-size, 12px);
133+
color: var(--color-text-secondary, #656d76);
41134
}
42135
</style>
43136
</head>
44137
<body>
45-
<p class="hello-world">Hello World</p>
138+
<div id="content">
139+
<p class="loading">Loading user data...</p>
140+
</div>
141+
<script>
142+
// ============================================================
143+
// MCP Apps Protocol Implementation
144+
// ============================================================
145+
// MCP Apps communicate with the host via postMessage using JSON-RPC.
146+
// The host (e.g., VS Code) renders this HTML in a sandboxed iframe.
147+
// After initialization, the host pushes the tool result to us.
148+
149+
const pendingRequests = new Map();
150+
let requestId = 0;
151+
152+
// Send a JSON-RPC request to the host and wait for response
153+
function sendRequest(method, params) {
154+
const id = ++requestId;
155+
return new Promise((resolve) => {
156+
pendingRequests.set(id, resolve);
157+
window.parent.postMessage({ jsonrpc: '2.0', id, method, params }, '*');
158+
});
159+
}
160+
161+
// Handle all messages from the host
162+
function handleMessage(event) {
163+
const message = event.data;
164+
if (!message || typeof message !== 'object') return;
165+
166+
// Handle responses to our requests (e.g., ui/initialize response)
167+
if (message.id !== undefined && pendingRequests.has(message.id)) {
168+
const resolve = pendingRequests.get(message.id);
169+
pendingRequests.delete(message.id);
170+
resolve(message.result);
171+
return;
172+
}
173+
174+
// Handle notifications pushed by the host
175+
// The host sends ui/notifications/tool-result after the tool completes
176+
if (message.method === 'ui/notifications/tool-result') {
177+
handleToolResult(message.params);
178+
}
179+
}
180+
181+
// Process the tool result and render the UI
182+
function handleToolResult(result) {
183+
if (!result || !result.content) {
184+
showError('No content in tool result');
185+
return;
186+
}
187+
188+
// The tool result contains content blocks; find the text one
189+
const textContent = result.content.find(c => c.type === 'text');
190+
if (!textContent || !textContent.text) {
191+
showError('No text content in tool result');
192+
return;
193+
}
194+
195+
try {
196+
// Parse the JSON user data from the tool's text output
197+
const userData = JSON.parse(textContent.text);
198+
renderUserCard(userData);
199+
} catch (e) {
200+
showError('Error parsing user data: ' + e.message);
201+
}
202+
}
203+
204+
function showError(msg) {
205+
document.getElementById('content').innerHTML =
206+
'<p class="error">' + escapeHtml(msg) + '</p>';
207+
notifySize();
208+
}
209+
210+
function renderUserCard(user) {
211+
const d = user.details || {};
212+
const html = ` + "`" + `
213+
<div class="user-card">
214+
<div class="user-header">
215+
${user.avatar_url ? ` + "`" + `<img class="avatar" src="${escapeHtml(user.avatar_url)}" alt="Avatar">` + "`" + ` : ''}
216+
<div class="user-names">
217+
<p class="display-name">${escapeHtml(d.name || user.login || 'Unknown')}</p>
218+
<p class="username">@${escapeHtml(user.login || 'unknown')}</p>
219+
</div>
220+
</div>
221+
<div class="info-grid">
222+
${d.company ? ` + "`" + `
223+
<span class="info-label">🏢 Company</span>
224+
<span class="info-value">${escapeHtml(d.company)}</span>
225+
` + "`" + ` : ''}
226+
${d.location ? ` + "`" + `
227+
<span class="info-label">📍 Location</span>
228+
<span class="info-value">${escapeHtml(d.location)}</span>
229+
` + "`" + ` : ''}
230+
${d.blog ? ` + "`" + `
231+
<span class="info-label">🔗 Website</span>
232+
<span class="info-value"><a href="${escapeHtml(d.blog)}" target="_blank">${escapeHtml(d.blog)}</a></span>
233+
` + "`" + ` : ''}
234+
${d.twitter_username ? ` + "`" + `
235+
<span class="info-label">🐦 Twitter</span>
236+
<span class="info-value"><a href="https://twitter.com/${escapeHtml(d.twitter_username)}" target="_blank">@${escapeHtml(d.twitter_username)}</a></span>
237+
` + "`" + ` : ''}
238+
${d.email ? ` + "`" + `
239+
<span class="info-label">✉️ Email</span>
240+
<span class="info-value"><a href="mailto:${escapeHtml(d.email)}">${escapeHtml(d.email)}</a></span>
241+
` + "`" + ` : ''}
242+
</div>
243+
<div class="stats">
244+
<div class="stat">
245+
<div class="stat-value">${d.public_repos ?? 0}</div>
246+
<div class="stat-label">Repos</div>
247+
</div>
248+
<div class="stat">
249+
<div class="stat-value">${d.followers ?? 0}</div>
250+
<div class="stat-label">Followers</div>
251+
</div>
252+
<div class="stat">
253+
<div class="stat-value">${d.following ?? 0}</div>
254+
<div class="stat-label">Following</div>
255+
</div>
256+
<div class="stat">
257+
<div class="stat-value">${d.public_gists ?? 0}</div>
258+
<div class="stat-label">Gists</div>
259+
</div>
260+
</div>
261+
</div>
262+
` + "`" + `;
263+
264+
document.getElementById('content').innerHTML = html;
265+
notifySize();
266+
}
267+
268+
// Tell the host our rendered size so it can adjust the iframe
269+
function notifySize() {
270+
window.parent.postMessage({
271+
jsonrpc: '2.0',
272+
method: 'ui/notifications/size-changed',
273+
params: { height: document.body.scrollHeight + 20 }
274+
}, '*');
275+
}
276+
277+
function escapeHtml(text) {
278+
if (text == null) return '';
279+
const div = document.createElement('div');
280+
div.textContent = String(text);
281+
return div.innerHTML;
282+
}
283+
284+
// Listen for messages from the host
285+
window.addEventListener('message', handleMessage);
286+
287+
// Initialize the MCP App connection
288+
// After this completes, the host will send us:
289+
// 1. ui/notifications/tool-input (the arguments passed to the tool)
290+
// 2. ui/notifications/tool-result (the tool's output - what we render)
291+
sendRequest('ui/initialize', {
292+
appInfo: { name: 'github-mcp-server-get-me', version: '1.0.0' },
293+
appCapabilities: {},
294+
protocolVersion: '2025-11-21'
295+
});
296+
</script>
46297
</body>
47298
</html>`
48299

0 commit comments

Comments
 (0)