diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index c41d2ac3f1..ab3ebfd163 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -10,6 +10,9 @@ import ( // These are static resources (not templates) that serve HTML content for // MCP App-enabled tools. The HTML is built from React/Primer components // in the ui/ directory using `script/build-ui`. +// +// Resource metadata follows the stable 2026-01-26 MCP Apps spec: +// https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx func RegisterUIResources(s *mcp.Server) { // Register the get_me UI resource s.AddResource( @@ -27,14 +30,14 @@ func RegisterUIResources(s *mcp.Server) { URI: GetMeUIResourceURI, MIMEType: MCPAppMIMEType, Text: html, - // MCP Apps UI metadata - CSP configuration to allow loading GitHub avatars - // See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx Meta: mcp.Meta{ "ui": map[string]any{ + // Allow loading images from GitHub's avatar CDN. "csp": map[string]any{ - // Allow loading images from GitHub's avatar CDN "resourceDomains": []string{"https://avatars.githubusercontent.com"}, }, + // Profile card renders inline within chat without a host border. + "prefersBorder": false, }, }, }, @@ -59,6 +62,14 @@ func RegisterUIResources(s *mcp.Server) { URI: IssueWriteUIResourceURI, MIMEType: MCPAppMIMEType, Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + // No external origins required; documents the secure default. + "csp": map[string]any{}, + // Form surface benefits from a host-provided border. + "prefersBorder": true, + }, + }, }, }, }, nil @@ -81,6 +92,12 @@ func RegisterUIResources(s *mcp.Server) { URI: PullRequestWriteUIResourceURI, MIMEType: MCPAppMIMEType, Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + "csp": map[string]any{}, + "prefersBorder": true, + }, + }, }, }, }, nil diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index a36469133c..4f697ee0cb 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -904,3 +904,44 @@ func TestInsidersRoutePreservesUIMeta(t *testing.T) { require.False(t, plainEnabled, "FF should be off for non-insiders ctx") require.Len(t, plainTools, 1) } + +// TestUIMetaStrippedWhenClientLacksCapability verifies that even on the +// /insiders path (where the feature flag is on), UI metadata is stripped from +// tools/list responses when the client did NOT advertise the +// io.modelcontextprotocol/ui extension capability. Per the 2026-01-26 MCP +// Apps spec, servers SHOULD check client capabilities before exposing +// UI-enabled tools. +func TestUIMetaStrippedWhenClientLacksCapability(t *testing.T) { + const uiURI = "ui://test/widget" + uiTool := mockTool("with_ui", "repos", true) + uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}} + + checker := createHTTPFeatureChecker(nil, false) + build := func() *inventory.Inventory { + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{uiTool}). + WithFeatureChecker(checker). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + return inv + } + + insidersCtx := ghcontext.WithInsidersMode(context.Background(), true) + withoutUICap := ghcontext.WithUISupport(insidersCtx, false) + withUICap := ghcontext.WithUISupport(insidersCtx, true) + + stripped := build().ToolsForRegistration(withoutUICap) + require.Len(t, stripped, 1) + require.Nil(t, stripped[0].Tool.Meta["ui"], "_meta.ui should be stripped when client lacks UI capability") + + preserved := build().ToolsForRegistration(withUICap) + require.Len(t, preserved, 1) + require.NotNil(t, preserved[0].Tool.Meta["ui"], "_meta.ui should be preserved when client advertises UI capability") + require.Equal(t, uiURI, preserved[0].Tool.Meta["ui"].(map[string]any)["resourceUri"]) + + // Unknown capability falls through to the FF gate (insiders ctx → kept). + unknown := build().ToolsForRegistration(insidersCtx) + require.Len(t, unknown, 1) + require.NotNil(t, unknown[0].Tool.Meta["ui"], "_meta.ui should be preserved when capability is unknown and FF is on") +} diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index d147cbfc66..b8a70a3420 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -7,6 +7,7 @@ import ( "slices" "sort" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -169,26 +170,50 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string { // ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as // RegisterTools would expose them: with MCP Apps UI metadata stripped when -// the remote_mcp_ui_apps feature flag is not enabled in ctx. Useful for -// documentation generators and diagnostics that need the same view of the -// tool surface the server would register. +// the client cannot consume it. Useful for documentation generators and +// diagnostics that need the same view of the tool surface the server would +// register. +// +// The strip applies when EITHER of the following is true: +// +// - The remote_mcp_ui_apps feature flag is not enabled in ctx (server-side gate). +// - The client explicitly did not advertise the io.modelcontextprotocol/ui +// extension capability (per the 2026-01-26 MCP Apps spec, servers SHOULD +// check client capabilities before exposing UI-enabled tools). When the +// capability is unknown (e.g. stdio paths that do not populate the +// context flag) the feature-flag gate is the sole source of truth. func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool { tools := r.AvailableTools(ctx) - if !r.checkFeatureFlag(ctx, mcpAppsFeatureFlag) { + if shouldStripMCPAppsMetadata(ctx, r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { tools = stripMCPAppsMetadata(tools) } return tools } +// shouldStripMCPAppsMetadata centralises the strip decision so the same logic +// is exercised by tests and by RegisterTools. +func shouldStripMCPAppsMetadata(ctx context.Context, featureFlagEnabled bool) bool { + if !featureFlagEnabled { + return true + } + // Feature flag is on. Respect the client capability if it is known. + if supported, ok := ghcontext.HasUISupport(ctx); ok && !supported { + return true + } + return false +} + // RegisterTools registers all available tools with the server using the provided dependencies. -// The context is used for feature flag evaluation. +// The context is used for feature flag evaluation and client capability checks. // // MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools -// when the MCP Apps feature flag is not enabled for this request. The strip -// happens here (rather than at Build() time) so the per-request context is -// in scope — HTTP feature checkers that read insiders mode or user identity -// from ctx would otherwise see context.Background() and falsely report the -// flag off, even when the actual request arrived on the /insiders route. +// when either the MCP Apps feature flag is not enabled for this request, or +// the client did not advertise the io.modelcontextprotocol/ui extension. The +// strip happens here (rather than at Build() time) so the per-request +// context is in scope — HTTP feature checkers that read insiders mode or +// user identity from ctx would otherwise see context.Background() and +// falsely report the flag off, even when the actual request arrived on the +// /insiders route. func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) { for _, tool := range r.ToolsForRegistration(ctx) { tool.RegisterFunc(s, deps) diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 372f756023..20b1fb718c 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -6,6 +6,7 @@ import ( "fmt" "testing" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -2211,7 +2212,7 @@ func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) [ toolCopy := tools[i].Tool out = append(out, &toolCopy) } - if !reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag) { + if shouldStripMCPAppsMetadata(ctx, reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { for _, tt := range out { delete(tt.Meta, "ui") if len(tt.Meta) == 0 { @@ -2221,3 +2222,55 @@ func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) [ } return out } + +// TestShouldStripMCPAppsMetadata verifies the spec-conformant strip decision: +// strip when the feature flag is off, OR when the client explicitly does not +// advertise the io.modelcontextprotocol/ui extension. +func TestShouldStripMCPAppsMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupCtx func() context.Context + ffOn bool + want bool + }{ + { + name: "FF off, capability unknown -> strip", + setupCtx: context.Background, + ffOn: false, + want: true, + }, + { + name: "FF off, capability present -> strip (FF wins)", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) }, + ffOn: false, + want: true, + }, + { + name: "FF on, capability unknown -> keep", + setupCtx: context.Background, + ffOn: true, + want: false, + }, + { + name: "FF on, capability present -> keep", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) }, + ffOn: true, + want: false, + }, + { + name: "FF on, capability explicitly absent -> strip", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), false) }, + ffOn: true, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := shouldStripMCPAppsMetadata(tc.setupCtx(), tc.ffOn) + require.Equal(t, tc.want, got) + }) + } +} diff --git a/ui/src/apps/get-me/App.tsx b/ui/src/apps/get-me/App.tsx index a20aae17c5..c181fcab90 100644 --- a/ui/src/apps/get-me/App.tsx +++ b/ui/src/apps/get-me/App.tsx @@ -1,4 +1,5 @@ import { StrictMode, useState } from "react"; +import type React from "react"; import { createRoot } from "react-dom/client"; import { Avatar, Box, Text, Link, Heading, Spinner } from "@primer/react"; import { @@ -62,8 +63,20 @@ function AvatarWithFallback({ src, login, size }: { src?: string; login: string; ); } -function UserCard({ user }: { user: UserData }) { +function UserCard({ + user, + onOpenLink, +}: { + user: UserData; + onOpenLink?: (url: string) => void; +}) { const d = user.details || {}; + const handleClick = + onOpenLink && + ((url: string) => (e: React.MouseEvent) => { + e.preventDefault(); + onOpenLink(url); + }); return ( - {d.blog} + + {d.blog} + )} {d.email && ( @@ -140,41 +159,39 @@ function UserCard({ user }: { user: UserData }) { } function GetMeApp() { - const { error, toolResult } = useMcpApp({ + const { error, toolResult, hostContext, openLink } = useMcpApp({ appName: "github-mcp-server-get-me", }); - if (error) { - return Error: {error.message}; - } - - if (!toolResult) { - return ( - - - Loading user data... - - ); - } - - // Parse user data from tool result - const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text"); - if (!textContent || !("text" in textContent)) { - return No user data in response; - } + const content = (() => { + if (error) { + return Error: {error.message}; + } + if (!toolResult) { + return ( + + + Loading user data... + + ); + } + const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text"); + if (!textContent || !("text" in textContent)) { + return No user data in response; + } + try { + const userData = JSON.parse(textContent.text as string) as UserData; + return void openLink(url)} />; + } catch { + return Failed to parse user data; + } + })(); - try { - const userData = JSON.parse(textContent.text as string) as UserData; - return ; - } catch { - return Failed to parse user data; - } + return {content}; } createRoot(document.getElementById("root")!).render( - - - + ); diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index de72b0a78a..863543fc14 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -121,7 +121,7 @@ function CreateIssueApp() { const [error, setError] = useState(null); const [successIssue, setSuccessIssue] = useState(null); - const { app, error: appError, toolInput, callTool } = useMcpApp({ + const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ appName: "github-mcp-server-issue-write", }); @@ -181,6 +181,19 @@ function CreateIssueApp() { try { const issueData = JSON.parse(textContent.text as string); setSuccessIssue(issueData); + // Per the MCP Apps 2026-01-26 spec, push the created/updated issue + // into the model's context so subsequent agent turns have it. + void setModelContext({ + structuredContent: issueData, + content: [ + { + type: "text", + text: isUpdateMode + ? `Issue #${issueNumber} in ${owner}/${repo} was updated by the user via the issue-write view.` + : `A new issue was created in ${owner}/${repo} by the user via the issue-write view.`, + }, + ], + }); } catch { setSuccessIssue({ title, body }); } @@ -191,8 +204,9 @@ function CreateIssueApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, isUpdateMode, issueNumber, callTool]); + }, [title, body, owner, repo, isUpdateMode, issueNumber, callTool, setModelContext]); + const body_node = (() => { if (appError) { return ( @@ -307,12 +321,13 @@ function CreateIssueApp() { ); + })(); + + return {body_node}; } createRoot(document.getElementById("root")!).render( - - - + ); diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index f5ddbdf29d..bfefdbede0 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -126,7 +126,7 @@ function CreatePRApp() { const [isDraft, setIsDraft] = useState(false); const [maintainerCanModify, setMaintainerCanModify] = useState(true); - const { app, error: appError, toolInput, callTool } = useMcpApp({ + const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ appName: "github-mcp-server-create-pull-request", }); @@ -175,6 +175,17 @@ function CreatePRApp() { if (textContent && textContent.type === "text" && textContent.text) { const prData = JSON.parse(textContent.text); setSuccessPR(prData); + // Push the new PR into the model context so subsequent agent + // turns can reference it (MCP Apps 2026-01-26 ui/update-model-context). + void setModelContext({ + structuredContent: prData, + content: [ + { + type: "text", + text: `A new pull request was created in ${owner}/${repo} by the user via the create-pull-request view.`, + }, + ], + }); } } } catch (e) { @@ -182,11 +193,11 @@ function CreatePRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, callTool]); + }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, callTool, setModelContext]); if (successPR) { return ( - + ); @@ -194,7 +205,7 @@ function CreatePRApp() { if (!app && !appError) { return ( - + @@ -204,14 +215,14 @@ function CreatePRApp() { if (appError) { return ( - + {appError.message} ); } return ( - + { - // Set up theme data attributes for proper Primer theming - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - const colorMode = prefersDark ? "dark" : "light"; + // Prefer the host-supplied theme; fall back to the OS preference. + const colorMode = + hostTheme === "light" || hostTheme === "dark" + ? hostTheme + : window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; document.body.setAttribute("data-color-mode", colorMode); document.body.setAttribute("data-light-theme", "light"); document.body.setAttribute("data-dark-theme", "dark"); - }, []); + }, [hostTheme]); + + // Project the host's standardized CSS variables onto the root so child + // components can consume them via `var(--color-...)`. We rely on Primer's + // own defaults when the host does not supply variables. + const styleVars = useMemo(() => { + if (!hostVariables) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(hostVariables)) { + if (typeof value === "string") out[key] = value; + } + return out as CSSProperties; + }, [hostVariables]); + + const colorMode = + hostTheme === "light" || hostTheme === "dark" ? hostTheme : "auto"; return ( - + - + {children} diff --git a/ui/src/hooks/useMcpApp.ts b/ui/src/hooks/useMcpApp.ts index 54bfa791a7..b060ea6ee2 100644 --- a/ui/src/hooks/useMcpApp.ts +++ b/ui/src/hooks/useMcpApp.ts @@ -1,11 +1,23 @@ import { useApp as useExtApp } from "@modelcontextprotocol/ext-apps/react"; -import type { App } from "@modelcontextprotocol/ext-apps"; +import type { + App, + McpUiDisplayMode, + McpUiHostContext, + McpUiUpdateModelContextRequest, +} from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; interface UseMcpAppOptions { appName: string; appVersion?: string; + /** + * Display modes this view supports. Per the MCP Apps 2026-01-26 spec, a + * view MUST declare every display mode it supports during initialization. + * Defaults to ["inline"] which is the only mode the bundled github-mcp-server + * views currently render. + */ + availableDisplayModes?: McpUiDisplayMode[]; onToolResult?: (result: CallToolResult) => void; onToolInput?: (input: Record) => void; } @@ -15,21 +27,38 @@ interface UseMcpAppReturn { error: Error | null; toolResult: CallToolResult | null; toolInput: Record | null; + hostContext: McpUiHostContext | undefined; callTool: (name: string, args: Record) => Promise; + /** + * Sends `ui/update-model-context` so the agent's next turn sees the + * supplied structured content / blocks. No-op when the app isn't connected. + */ + setModelContext: ( + params: McpUiUpdateModelContextRequest["params"] + ) => Promise; + /** + * Sends `ui/open-link` so the host opens an external URL in the user's + * browser. Falls back to `window.open` when the app isn't connected. + */ + openLink: (url: string) => Promise; } export function useMcpApp({ appName, appVersion = "1.0.0", + availableDisplayModes = ["inline"], onToolResult, onToolInput, }: UseMcpAppOptions): UseMcpAppReturn { const [toolResult, setToolResult] = useState(null); const [toolInput, setToolInput] = useState | null>(null); + const [hostContext, setHostContext] = useState(undefined); + // The SDK's autoResize=true installs a ResizeObserver that emits + // `ui/notifications/size-changed` automatically; no manual wiring needed. const { app, error } = useExtApp({ appInfo: { name: appName, version: appVersion }, - capabilities: {}, + capabilities: { availableDisplayModes }, autoResize: true, strict: import.meta.env.DEV, onAppCreated: (app) => { @@ -42,10 +71,19 @@ export function useMcpApp({ setToolInput(args); onToolInput?.(args); }; + app.onhostcontextchanged = (params) => { + setHostContext((prev) => ({ ...(prev ?? {}), ...params })); + }; app.onerror = console.error; }, }); + useEffect(() => { + if (!app) return; + const initial = app.getHostContext(); + if (initial) setHostContext(initial); + }, [app]); + const callTool = useCallback( async (name: string, args: Record) => { if (!app) throw new Error("App not connected"); @@ -54,5 +92,33 @@ export function useMcpApp({ [app] ); - return { app, error, toolResult, toolInput, callTool }; + const setModelContext = useCallback( + async (params) => { + if (!app) return; + await app.updateModelContext(params); + }, + [app] + ); + + const openLink = useCallback( + async (url) => { + if (!app) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + await app.openLink({ url }); + }, + [app] + ); + + return { + app, + error, + toolResult, + toolInput, + hostContext, + callTool, + setModelContext, + openLink, + }; }