Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions pkg/github/ui_resources.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
},
},
},
Expand All @@ -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
Expand All @@ -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
Expand Down
41 changes: 41 additions & 0 deletions pkg/http/handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
45 changes: 35 additions & 10 deletions pkg/inventory/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"slices"
"sort"

ghcontext "github.com/github/github-mcp-server/pkg/context"
"github.com/modelcontextprotocol/go-sdk/mcp"
)

Expand Down Expand Up @@ -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)
Expand Down
55 changes: 54 additions & 1 deletion pkg/inventory/registry_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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 {
Expand All @@ -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)
})
}
}
77 changes: 47 additions & 30 deletions ui/src/apps/get-me/App.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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 (
<Box
Expand Down Expand Up @@ -103,7 +116,13 @@ function UserCard({ user }: { user: UserData }) {
{d.blog && (
<>
<Box sx={{ color: "fg.muted" }}><LinkIcon size={16} /></Box>
<Link href={d.blog} target="_blank">{d.blog}</Link>
<Link
href={d.blog}
target="_blank"
onClick={handleClick?.(d.blog)}
>
{d.blog}
</Link>
</>
)}
{d.email && (
Expand Down Expand Up @@ -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 <Text sx={{ color: "danger.fg" }}>Error: {error.message}</Text>;
}

if (!toolResult) {
return (
<Box display="flex" alignItems="center" gap={2}>
<Spinner size="small" />
<Text sx={{ color: "fg.muted" }}>Loading user data...</Text>
</Box>
);
}

// Parse user data from tool result
const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text");
if (!textContent || !("text" in textContent)) {
return <Text sx={{ color: "danger.fg" }}>No user data in response</Text>;
}
const content = (() => {
if (error) {
return <Text sx={{ color: "danger.fg" }}>Error: {error.message}</Text>;
}
if (!toolResult) {
return (
<Box display="flex" alignItems="center" gap={2}>
<Spinner size="small" />
<Text sx={{ color: "fg.muted" }}>Loading user data...</Text>
</Box>
);
}
const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text");
if (!textContent || !("text" in textContent)) {
return <Text sx={{ color: "danger.fg" }}>No user data in response</Text>;
}
try {
const userData = JSON.parse(textContent.text as string) as UserData;
return <UserCard user={userData} onOpenLink={(url) => void openLink(url)} />;
} catch {
return <Text sx={{ color: "danger.fg" }}>Failed to parse user data</Text>;
}
})();

try {
const userData = JSON.parse(textContent.text as string) as UserData;
return <UserCard user={userData} />;
} catch {
return <Text sx={{ color: "danger.fg" }}>Failed to parse user data</Text>;
}
return <AppProvider hostContext={hostContext}>{content}</AppProvider>;
}

createRoot(document.getElementById("root")!).render(
<StrictMode>
<AppProvider>
<GetMeApp />
</AppProvider>
<GetMeApp />
</StrictMode>
);
Loading
Loading