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,
+ };
}