chore: import upstream snapshot with attribution

This commit is contained in:
wehub-resource-sync
2026-07-13 12:37:57 +08:00
commit e30f8ba47c
533 changed files with 115926 additions and 0 deletions
+5914
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "@github/mcp-server-ui",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "MCP App UIs for github-mcp-server using Primer React",
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"build": "node scripts/build.mjs",
"dev": "npm run build",
"typecheck": "tsc --noEmit",
"clean": "rm -rf dist"
},
"dependencies": {
"@github/markdown-toolbar-element": "^2.2.3",
"@modelcontextprotocol/ext-apps": "^1.7.2",
"@primer/octicons-react": "^19.0.0",
"@primer/react": "^36.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1"
},
"devDependencies": {
"@types/node": "^25.2.0",
"@types/react": "^18.0.0",
"@types/react-dom": "^18.0.0",
"@vitejs/plugin-react": "^6.0.2",
"typescript": "^5.7.0",
"vite": "^8.0.16",
"vite-plugin-singlefile": "^2.3.3"
}
}
+14
View File
@@ -0,0 +1,14 @@
// Build all UI apps in a single Node process.
//
// Replaces serial `cross-env APP=<app> vite build` invocations: doing it
// in one process avoids paying Vite/plugin startup cost for each app and is
// portable without `cross-env`.
import { build } from "vite";
const apps = ["get-me", "issue-write", "pr-write", "pr-edit"];
for (const app of apps) {
process.env.APP = app;
await build();
}
+197
View File
@@ -0,0 +1,197 @@
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 {
OrganizationIcon,
LocationIcon,
LinkIcon,
MailIcon,
PeopleIcon,
RepoIcon,
PersonIcon,
} from "@primer/octicons-react";
import { AppProvider } from "../../components/AppProvider";
import { useMcpApp } from "../../hooks/useMcpApp";
interface UserData {
login: string;
avatar_url?: string;
details?: {
name?: string;
company?: string;
location?: string;
blog?: string;
email?: string;
twitter_username?: string;
public_repos?: number;
followers?: number;
following?: number;
};
}
function AvatarWithFallback({ src, login, size }: { src?: string; login: string; size: number }) {
const [imgError, setImgError] = useState(false);
if (!src || imgError) {
return (
<Box
sx={{
width: size,
height: size,
borderRadius: "50%",
bg: "accent.subtle",
display: "flex",
alignItems: "center",
justifyContent: "center",
mr: 3,
flexShrink: 0,
}}
>
<PersonIcon size={size * 0.6} />
</Box>
);
}
return (
<Avatar
src={src}
size={size}
sx={{ mr: 3 }}
onError={() => setImgError(true)}
/>
);
}
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
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
bg="canvas.subtle"
p={3}
maxWidth={400}
>
{/* Header with avatar and name */}
<Box display="flex" alignItems="center" mb={3} pb={3} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
<AvatarWithFallback src={user.avatar_url} login={user.login} size={48} />
<Box>
<Heading as="h2" sx={{ fontSize: 2, mb: 0 }}>
{d.name || user.login}
</Heading>
<Text sx={{ color: "fg.muted", fontSize: 1 }}>@{user.login}</Text>
</Box>
</Box>
{/* Info grid */}
<Box display="grid" sx={{ gridTemplateColumns: "auto 1fr", gap: 2, fontSize: 1 }}>
{d.company && (
<>
<Box sx={{ color: "fg.muted" }}><OrganizationIcon size={16} /></Box>
<Text>{d.company}</Text>
</>
)}
{d.location && (
<>
<Box sx={{ color: "fg.muted" }}><LocationIcon size={16} /></Box>
<Text>{d.location}</Text>
</>
)}
{d.blog && (
<>
<Box sx={{ color: "fg.muted" }}><LinkIcon size={16} /></Box>
<Link
href={d.blog}
target="_blank"
onClick={handleClick?.(d.blog)}
>
{d.blog}
</Link>
</>
)}
{d.email && (
<>
<Box sx={{ color: "fg.muted" }}><MailIcon size={16} /></Box>
<Link href={`mailto:${d.email}`}>{d.email}</Link>
</>
)}
</Box>
{/* Stats */}
<Box display="flex" justifyContent="space-around" mt={3} pt={3} borderTopWidth={1} borderTopStyle="solid" borderTopColor="border.default">
<Box sx={{ textAlign: "center" }}>
<Text sx={{ fontWeight: "bold", fontSize: 2, display: "block" }}>
<RepoIcon size={16} /> {d.public_repos ?? 0}
</Text>
<Text sx={{ color: "fg.muted", fontSize: 0 }}>Repos</Text>
</Box>
<Box sx={{ textAlign: "center" }}>
<Text sx={{ fontWeight: "bold", fontSize: 2, display: "block" }}>
<PeopleIcon size={16} /> {d.followers ?? 0}
</Text>
<Text sx={{ color: "fg.muted", fontSize: 0 }}>Followers</Text>
</Box>
<Box sx={{ textAlign: "center" }}>
<Text sx={{ fontWeight: "bold", fontSize: 2, display: "block" }}>
{d.following ?? 0}
</Text>
<Text sx={{ color: "fg.muted", fontSize: 0 }}>Following</Text>
</Box>
</Box>
</Box>
);
}
function GetMeApp() {
const { error, toolResult, hostContext, openLink } = useMcpApp({
appName: "github-mcp-server-get-me",
});
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>;
}
})();
return <AppProvider hostContext={hostContext}>{content}</AppProvider>;
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<GetMeApp />
</StrictMode>
);
+13
View File
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Security-Policy" content="default-src 'self' 'unsafe-inline' 'unsafe-eval' data:; img-src 'self' data: https://avatars.githubusercontent.com https://*.githubusercontent.com; connect-src *;" />
<title>GitHub User Profile</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Create GitHub Issue</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+783
View File
@@ -0,0 +1,783 @@
import { StrictMode, useState, useCallback, useEffect, useMemo } from "react";
import { createRoot } from "react-dom/client";
import {
Box,
Text,
TextInput,
Button,
Flash,
Spinner,
FormControl,
ActionMenu,
ActionList,
Checkbox,
ButtonGroup,
CounterLabel,
Label,
} from "@primer/react";
import {
GitPullRequestIcon,
CheckCircleIcon,
GitBranchIcon,
LockIcon,
PersonIcon,
PeopleIcon,
} from "@primer/octicons-react";
import { AppProvider } from "../../components/AppProvider";
import { useMcpApp } from "../../hooks/useMcpApp";
import { completedToolResult } from "../../lib/toolResult";
import { MarkdownEditor } from "../../components/MarkdownEditor";
interface PRResult {
ID?: string;
number?: number;
title?: string;
url?: string;
html_url?: string;
URL?: string;
}
interface BranchItem {
name: string;
protected: boolean;
}
type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string };
type PRState = "open" | "closed";
interface InitialPRState {
title: string;
body: string;
state: PRState;
base: string;
draft: boolean;
maintainerCanModify: boolean;
reviewers: string[];
}
function asRecord(value: unknown): Record<string, unknown> | null {
return typeof value === "object" && value !== null ? (value as Record<string, unknown>) : null;
}
function asString(value: unknown): string | undefined {
return typeof value === "string" ? value : undefined;
}
function asBoolean(value: unknown): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
function asNumber(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string") {
const parsed = Number(value);
if (Number.isFinite(parsed)) return parsed;
}
return undefined;
}
function reviewerFromValue(value: string): ReviewerItem {
if (value.includes("/")) {
const [org, slug] = value.split("/", 2);
return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org };
}
return { kind: "user", id: value, text: value };
}
function reviewerValue(reviewer: ReviewerItem): string {
return reviewer.kind === "team" ? reviewer.id : reviewer.text;
}
function sameReviewerValues(a: string[], b: string[]): boolean {
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((value, index) => value === sortedB[index]);
}
function parseUserReviewer(value: unknown): ReviewerItem | null {
if (typeof value === "string") return reviewerFromValue(value);
const record = asRecord(value);
const login = asString(record?.login);
if (!login) return null;
return { kind: "user", id: login, text: login, avatar: asString(record?.avatar_url) };
}
function parseTeamReviewer(value: unknown, fallbackOrg: string): ReviewerItem | null {
if (typeof value === "string") {
if (value.includes("/")) return reviewerFromValue(value);
return { kind: "team", id: `${fallbackOrg}/${value}`, text: `${fallbackOrg}/${value}`, org: fallbackOrg };
}
const record = asRecord(value);
const slug = asString(record?.slug) || asString(record?.name);
if (!slug) return null;
const organization = asRecord(record?.organization);
const org = asString(record?.org) || asString(organization?.login) || fallbackOrg;
const id = org ? `${org}/${slug}` : slug;
return { kind: "team", id, text: id, org };
}
function reviewersFromValues(values: unknown): ReviewerItem[] | undefined {
if (!Array.isArray(values)) return undefined;
return values
.map((value) => (typeof value === "string" ? reviewerFromValue(value) : null))
.filter((value): value is ReviewerItem => value !== null);
}
function extractRequestedReviewers(prData: Record<string, unknown>, owner: string): ReviewerItem[] {
const requestedReviewers = Array.isArray(prData.requested_reviewers) ? prData.requested_reviewers : [];
const requestedTeams = Array.isArray(prData.requested_teams) ? prData.requested_teams : [];
return [
...requestedReviewers.map(parseUserReviewer).filter((value): value is ReviewerItem => value !== null),
...requestedTeams.map((team) => parseTeamReviewer(team, owner)).filter((value): value is ReviewerItem => value !== null),
];
}
function parsePRState(value: unknown): PRState {
return value === "closed" ? "closed" : "open";
}
function buildInitialState(prData: Record<string, unknown>, owner: string): InitialPRState {
const base = asRecord(prData.base);
const requestedReviewers = extractRequestedReviewers(prData, owner);
return {
title: asString(prData.title) || "",
body: asString(prData.body) || "",
state: parsePRState(prData.state),
base: asString(base?.ref) || "",
draft: asBoolean(prData.draft) || false,
maintainerCanModify: asBoolean(prData.maintainer_can_modify) || false,
reviewers: requestedReviewers.map(reviewerValue),
};
}
function SuccessView({
pr,
owner,
repo,
submittedTitle,
openLink,
}: {
pr: PRResult;
owner: string;
repo: string;
submittedTitle: string;
openLink: (url: string) => Promise<void>;
}) {
const prUrl = pr.html_url || pr.url || pr.URL || "#";
return (
<Box
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
bg="canvas.subtle"
p={3}
>
<Box
display="flex"
alignItems="center"
mb={3}
pb={3}
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
>
<Box sx={{ color: "success.fg", flexShrink: 0, mr: 2 }}>
<CheckCircleIcon size={16} />
</Box>
<Text sx={{ fontWeight: "semibold" }}>
Pull request updated successfully
</Text>
</Box>
<Box
display="flex"
alignItems="flex-start"
gap={2}
p={3}
bg="canvas.subtle"
borderRadius={2}
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
>
<Box sx={{ color: "open.fg", flexShrink: 0, mt: "2px", mr: 1 }}>
<GitPullRequestIcon size={16} />
</Box>
<Box sx={{ minWidth: 0 }}>
<a
href={prUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
e.preventDefault();
if (prUrl === "#") return;
void openLink(prUrl);
}}
style={{
fontWeight: 600,
fontSize: "14px",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--fgColor-accent, var(--color-accent-fg))",
textDecoration: "none",
}}
>
{pr.title || submittedTitle}
{pr.number && (
<Text sx={{ color: "fg.muted", fontWeight: "normal", ml: 1 }}>
#{pr.number}
</Text>
)}
</a>
<Text sx={{ color: "fg.muted", fontSize: 0 }}>
{owner}/{repo}
</Text>
</Box>
</Box>
</Box>
);
}
function EditPRApp() {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [prState, setPRState] = useState<PRState>("open");
const [isDraft, setIsDraft] = useState(false);
const [maintainerCanModify, setMaintainerCanModify] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successPR, setSuccessPR] = useState<PRResult | null>(null);
const [initialValues, setInitialValues] = useState<InitialPRState | null>(null);
const [isLoadingPR, setIsLoadingPR] = useState(false);
const [submittedTitle, setSubmittedTitle] = useState("");
const [availableBranches, setAvailableBranches] = useState<BranchItem[]>([]);
const [baseBranch, setBaseBranch] = useState<string>("");
const [branchesLoading, setBranchesLoading] = useState(false);
const [baseFilter, setBaseFilter] = useState("");
const [availableReviewers, setAvailableReviewers] = useState<ReviewerItem[]>([]);
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerItem[]>([]);
const [reviewersLoading, setReviewersLoading] = useState(false);
const [reviewersFilter, setReviewersFilter] = useState("");
const { app, error: appError, toolInput, toolResult, callTool, hostContext, setModelContext, openLink } = useMcpApp({
appName: "github-mcp-server-edit-pull-request",
});
const owner = (toolInput?.owner as string) || "";
const repo = (toolInput?.repo as string) || "";
const pullNumber = asNumber(toolInput?.pullNumber);
// When the server updated the PR up-front instead of deferring to this form,
// the host still renders this View and delivers the updated PR via
// tool-result. Render that completed result as success so we never show an
// edit form for changes already applied. The deferral sentinel
// (awaiting_user_submission) returns null here, keeping the form for the
// normal deferred flow. See github/copilot-mcp-core#1864.
const resultPR = useMemo(() => completedToolResult<PRResult>(toolResult), [toolResult]);
const shownPR = successPR ?? resultPR;
useEffect(() => {
setTitle("");
setBody("");
setPRState("open");
setIsDraft(false);
setMaintainerCanModify(false);
setBaseBranch("");
setSelectedReviewers([]);
setAvailableBranches([]);
setAvailableReviewers([]);
setBaseFilter("");
setReviewersFilter("");
setInitialValues(null);
setSuccessPR(null);
setError(null);
setSubmittedTitle("");
}, [toolInput]);
useEffect(() => {
if (!app || !owner || !repo || !pullNumber) return;
let cancelled = false;
const loadPullRequest = async () => {
setIsLoadingPR(true);
try {
const result = await callTool("pull_request_read", { method: "get", owner, repo, pullNumber });
if (cancelled) return;
if (result.isError) {
const textContent = result.content?.find((c) => c.type === "text");
const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to load pull request";
setError(errorMessage);
return;
}
const textContent = result.content?.find((c) => c.type === "text");
if (!textContent || textContent.type !== "text" || !textContent.text) {
setError("Pull request details were not returned");
return;
}
const prData = JSON.parse(textContent.text) as Record<string, unknown>;
const initialState = buildInitialState(prData, owner);
const toolInputReviewers = reviewersFromValues(toolInput?.reviewers);
setInitialValues(initialState);
setTitle(asString(toolInput?.title) ?? initialState.title);
setBody(asString(toolInput?.body) ?? initialState.body);
setPRState(parsePRState(asString(toolInput?.state) ?? initialState.state));
setIsDraft(asBoolean(toolInput?.draft) ?? initialState.draft);
setBaseBranch(asString(toolInput?.base) ?? initialState.base);
setMaintainerCanModify(asBoolean(toolInput?.maintainer_can_modify) ?? initialState.maintainerCanModify);
setSelectedReviewers(toolInputReviewers ?? extractRequestedReviewers(prData, owner));
} catch (e) {
if (!cancelled) {
setError(e instanceof Error ? e.message : "Failed to load pull request");
}
} finally {
if (!cancelled) setIsLoadingPR(false);
}
};
loadPullRequest();
return () => {
cancelled = true;
};
}, [app, callTool, owner, repo, pullNumber, toolInput]);
useEffect(() => {
if (!owner || !repo || !app) return;
let cancelled = false;
const loadBranches = async () => {
setBranchesLoading(true);
try {
const result = await callTool("ui_get", { method: "branches", owner, repo });
if (cancelled) return;
if (result && !result.isError && result.content) {
const textContent = result.content.find((c: { type: string }) => c.type === "text");
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const branches = (data.branches || data || []).map(
(b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false })
);
setAvailableBranches(branches);
const defaultBranch = branches.find((b: BranchItem) => b.name === "main" || b.name === "master");
if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name);
}
}
} catch (e) {
console.error("Failed to load branches:", e);
} finally {
if (!cancelled) setBranchesLoading(false);
}
};
const loadReviewers = async () => {
setReviewersLoading(true);
try {
const result = await callTool("ui_get", { method: "reviewers", owner, repo });
if (cancelled) return;
if (result && !result.isError && result.content) {
const textContent = result.content.find((c: { type: string }) => c.type === "text");
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const users = (data.users || []).map(
(u: { login: string; avatar_url?: string }) => ({
kind: "user" as const,
id: u.login,
text: u.login,
avatar: u.avatar_url,
})
);
const teams = (data.teams || []).map(
(t: { slug: string; name?: string; org: string }) => ({
kind: "team" as const,
id: `${t.org}/${t.slug}`,
text: `${t.org}/${t.slug}`,
org: t.org,
})
);
setAvailableReviewers([...users, ...teams]);
}
}
} catch (e) {
console.error("Failed to load reviewers:", e);
} finally {
if (!cancelled) setReviewersLoading(false);
}
};
loadBranches();
loadReviewers();
return () => {
cancelled = true;
};
}, [owner, repo, app, callTool]);
useEffect(() => {
if (availableReviewers.length === 0) return;
setSelectedReviewers((prev) =>
prev.map((reviewer) =>
availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer
)
);
}, [availableReviewers]);
const filteredBaseBranches = useMemo(() => {
if (!baseFilter.trim()) return availableBranches;
return availableBranches.filter((branch) => branch.name.toLowerCase().includes(baseFilter.toLowerCase()));
}, [availableBranches, baseFilter]);
const filteredReviewers = useMemo(() => {
if (!reviewersFilter.trim()) return availableReviewers;
const lowerFilter = reviewersFilter.toLowerCase();
return availableReviewers.filter((reviewer) =>
reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter)
);
}, [availableReviewers, reviewersFilter]);
const handleSubmit = useCallback(async () => {
if (!title.trim()) { setError("Title is required"); return; }
if (!owner || !repo || !pullNumber) { setError("Pull request information not available"); return; }
if (!baseBranch) { setError("Base branch is required"); return; }
if (!initialValues) { setError("Pull request details are still loading"); return; }
const selectedReviewerValues = selectedReviewers.map(reviewerValue);
const params: Record<string, unknown> = { owner, repo, pullNumber, _ui_submitted: true };
if (title.trim() !== initialValues.title) params.title = title.trim();
if (body !== initialValues.body) params.body = body;
if (prState !== initialValues.state) params.state = prState;
if (baseBranch !== initialValues.base) params.base = baseBranch;
if (isDraft !== initialValues.draft) params.draft = isDraft;
if (maintainerCanModify !== initialValues.maintainerCanModify) params.maintainer_can_modify = maintainerCanModify;
if (!sameReviewerValues(selectedReviewerValues, initialValues.reviewers)) params.reviewers = selectedReviewerValues;
const hasChanges = Object.keys(params).some((key) => !["owner", "repo", "pullNumber", "_ui_submitted"].includes(key));
if (!hasChanges) {
setError("No changes to update");
return;
}
setIsSubmitting(true);
setError(null);
setSubmittedTitle(title);
try {
const result = await callTool("update_pull_request", params);
if (result.isError) {
const textContent = result.content?.find((c) => c.type === "text");
const errorMessage = textContent && textContent.type === "text" ? textContent.text : "Failed to update pull request";
setError(errorMessage);
} else {
const textContent = result.content?.find((c) => c.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
const prData = JSON.parse(textContent.text);
setSuccessPR(prData);
void setModelContext({
structuredContent: prData,
content: [
{
type: "text",
text: `Pull request #${pullNumber} in ${owner}/${repo} was updated by the user via the edit-pull-request view.`,
},
],
});
}
}
} catch (e) {
setError(e instanceof Error ? e.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}, [title, body, owner, repo, pullNumber, baseBranch, initialValues, selectedReviewers, prState, isDraft, maintainerCanModify, callTool, setModelContext]);
if (shownPR) {
return (
<AppProvider hostContext={hostContext}>
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
</AppProvider>
);
}
if (!app && !appError) {
return (
<AppProvider hostContext={hostContext}>
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
</Box>
</AppProvider>
);
}
if (appError) {
return (
<AppProvider hostContext={hostContext}>
<Flash variant="danger">{appError.message}</Flash>
</AppProvider>
);
}
if (toolInput === null) {
return (
<AppProvider hostContext={hostContext}>
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
</Box>
</AppProvider>
);
}
if (!owner || !repo || !pullNumber) {
return (
<AppProvider hostContext={hostContext}>
<Flash variant="danger">Pull request owner, repo, and pullNumber are required.</Flash>
</AppProvider>
);
}
return (
<AppProvider hostContext={hostContext}>
<Box
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
bg="canvas.subtle"
p={3}
>
<Box
display="flex"
alignItems="center"
gap={2}
mb={3}
pb={2}
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
sx={{ minWidth: 0, overflow: "hidden" }}
>
<Box sx={{ color: "open.fg", flexShrink: 0 }}>
<GitPullRequestIcon size={16} />
</Box>
<Box sx={{ minWidth: 0 }}>
<Text sx={{ fontWeight: "semibold" }}>#{pullNumber} · {owner}/{repo}</Text>
{title && (
<Text as="div" sx={{ color: "fg.muted", fontSize: 0, mt: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{title}
</Text>
)}
</Box>
</Box>
{error && <Flash variant="danger" sx={{ mb: 3 }}>{error}</Flash>}
{isLoadingPR && !initialValues ? (
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
<Text sx={{ color: "fg.muted", ml: 2 }}>Loading pull request...</Text>
</Box>
) : (
<>
<FormControl sx={{ mb: 3 }}>
<FormControl.Label sx={{ fontWeight: "semibold" }}>Title</FormControl.Label>
<TextInput
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
block
contrast
/>
</FormControl>
<Box sx={{ mb: 3 }}>
<Text as="label" sx={{ fontWeight: "semibold", fontSize: 1, display: "block", mb: 2 }}>
Description
</Text>
<MarkdownEditor value={body} onChange={setBody} placeholder="Add a description..." />
</Box>
<Box sx={{ mb: 3, flex: "1 1 240px", minWidth: 0 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>base</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
{baseBranch || "Select base"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box p={2}>
<TextInput
placeholder="Filter branches..."
value={baseFilter}
onChange={(e) => setBaseFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList.Divider />
{branchesLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredBaseBranches.length === 0 ? (
<ActionList.Item disabled>No branches found</ActionList.Item>
) : (
filteredBaseBranches.map((branch) => (
<ActionList.Item
key={branch.name}
selected={baseBranch === branch.name}
onSelect={() => { setBaseBranch(branch.name); setBaseFilter(""); }}
>
{branch.name}
{branch.protected && <ActionList.TrailingVisual><LockIcon size={12} /></ActionList.TrailingVisual>}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3} mb={3}>
<Box>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>state</Text>
<ButtonGroup>
<Button size="small" variant={prState === "open" ? "primary" : "default"} onClick={() => setPRState("open")}>
Open
</Button>
<Button size="small" variant={prState === "closed" ? "primary" : "default"} onClick={() => setPRState("closed")}>
Closed
</Button>
</ButtonGroup>
</Box>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mb: "6px" }}>
<Checkbox checked={isDraft} onChange={(e) => setIsDraft(e.target.checked)} />
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Mark as draft</Text>
</Box>
</Box>
<Box sx={{ mb: 3 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>reviewers</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={PersonIcon} sx={{ minWidth: 160 }}>
{selectedReviewers.length === 0 ? (
"No reviewers"
) : (
<>
Reviewers
<CounterLabel sx={{ ml: 1 }}>{selectedReviewers.length}</CounterLabel>
</>
)}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
<TextInput
placeholder="Search reviewers"
value={reviewersFilter}
onChange={(e) => setReviewersFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList selectionVariant="multiple">
{reviewersLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredReviewers.length === 0 ? (
<ActionList.Item disabled>No reviewers available</ActionList.Item>
) : (
filteredReviewers.map((reviewer) => (
<ActionList.Item
key={reviewer.id}
selected={selectedReviewers.some((r) => r.id === reviewer.id)}
onSelect={() => {
setSelectedReviewers((prev) =>
prev.some((r) => r.id === reviewer.id)
? prev.filter((r) => r.id !== reviewer.id)
: [...prev, reviewer]
);
}}
>
<ActionList.LeadingVisual>
{reviewer.kind === "user" ? (
reviewer.avatar ? (
<img
src={reviewer.avatar}
alt=""
width={16}
height={16}
style={{ borderRadius: "50%", display: "block" }}
/>
) : (
<PersonIcon />
)
) : (
<PeopleIcon />
)}
</ActionList.LeadingVisual>
{reviewer.text}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
{selectedReviewers.length > 0 && (
<Box display="flex" gap={1} mt={2} flexWrap="wrap">
{selectedReviewers.map((reviewer) => (
<Label
key={reviewer.id}
sx={{
backgroundColor: "canvas.inset",
color: "fg.muted",
borderColor: "border.default",
}}
>
{reviewer.text}
</Label>
))}
</Box>
)}
</Box>
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3}>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mt: 1 }}>
<Checkbox checked={maintainerCanModify} onChange={(e) => setMaintainerCanModify(e.target.checked)} />
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Allow maintainer edits</Text>
</Box>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isSubmitting || !initialValues || !title.trim() || !baseBranch}
>
{isSubmitting ? (
<><Spinner size="small" sx={{ mr: 1 }} />Updating...</>
) : (
"Update pull request"
)}
</Button>
</Box>
</>
)}
</Box>
</AppProvider>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<EditPRApp />
</StrictMode>
);
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Edit pull request</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+823
View File
@@ -0,0 +1,823 @@
import { StrictMode, useState, useCallback, useEffect, useMemo } from "react";
import { createRoot } from "react-dom/client";
import {
Box,
Text,
TextInput,
Button,
Flash,
Spinner,
FormControl,
ActionMenu,
ActionList,
Checkbox,
ButtonGroup,
CounterLabel,
Label,
} from "@primer/react";
import {
GitPullRequestIcon,
CheckCircleIcon,
RepoIcon,
LockIcon,
GitBranchIcon,
TriangleDownIcon,
PersonIcon,
PeopleIcon,
} from "@primer/octicons-react";
import { AppProvider } from "../../components/AppProvider";
import { useMcpApp } from "../../hooks/useMcpApp";
import { completedToolResult } from "../../lib/toolResult";
import { MarkdownEditor } from "../../components/MarkdownEditor";
interface PRResult {
ID?: string;
number?: number;
title?: string;
url?: string;
html_url?: string;
URL?: string;
}
interface RepositoryItem {
id: string;
owner: string;
name: string;
fullName: string;
isPrivate: boolean;
}
interface BranchItem {
name: string;
protected: boolean;
}
type ReviewerItem = { kind: "user" | "team"; id: string; text: string; avatar?: string; org?: string };
function reviewerFromValue(value: string): ReviewerItem {
if (value.includes("/")) {
const [org, slug] = value.split("/", 2);
return { kind: "team", id: `${org}/${slug}`, text: `${org}/${slug}`, org };
}
return { kind: "user", id: value, text: value };
}
function reviewerValue(reviewer: ReviewerItem): string {
return reviewer.kind === "team" ? reviewer.id : reviewer.text;
}
function SuccessView({
pr,
owner,
repo,
submittedTitle,
openLink,
}: {
pr: PRResult;
owner: string;
repo: string;
submittedTitle: string;
openLink: (url: string) => Promise<void>;
}) {
const prUrl = pr.html_url || pr.url || pr.URL || "#";
return (
<Box
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
bg="canvas.subtle"
p={3}
>
<Box
display="flex"
alignItems="center"
mb={3}
pb={3}
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
>
<Box sx={{ color: "success.fg", flexShrink: 0, mr: 2 }}>
<CheckCircleIcon size={16} />
</Box>
<Text sx={{ fontWeight: "semibold" }}>
Pull request created successfully
</Text>
</Box>
<Box
display="flex"
alignItems="flex-start"
gap={2}
p={3}
bg="canvas.subtle"
borderRadius={2}
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
>
<Box sx={{ color: "open.fg", flexShrink: 0, mt: "2px", mr: 1 }}>
<GitPullRequestIcon size={16} />
</Box>
<Box sx={{ minWidth: 0 }}>
<a
href={prUrl}
target="_blank"
rel="noopener noreferrer"
onClick={(e) => {
// MCP Apps run in a sandboxed iframe where a plain anchor may be
// blocked, so route the click through the host's open-link
// capability (falls back to window.open).
e.preventDefault();
if (prUrl === "#") return;
void openLink(prUrl);
}}
style={{
fontWeight: 600,
fontSize: "14px",
display: "block",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
color: "var(--fgColor-accent, var(--color-accent-fg))",
textDecoration: "none",
}}
>
{pr.title || submittedTitle}
{pr.number && (
<Text sx={{ color: "fg.muted", fontWeight: "normal", ml: 1 }}>
#{pr.number}
</Text>
)}
</a>
<Text sx={{ color: "fg.muted", fontSize: 0 }}>
{owner}/{repo}
</Text>
</Box>
</Box>
</Box>
);
}
function CreatePRApp() {
const [title, setTitle] = useState("");
const [body, setBody] = useState("");
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successPR, setSuccessPR] = useState<PRResult | null>(null);
// Branch state
const [availableBranches, setAvailableBranches] = useState<BranchItem[]>([]);
const [baseBranch, setBaseBranch] = useState<string>("");
const [headBranch, setHeadBranch] = useState<string>("");
const [branchesLoading, setBranchesLoading] = useState(false);
const [baseFilter, setBaseFilter] = useState("");
const [headFilter, setHeadFilter] = useState("");
// Options
const [isDraft, setIsDraft] = useState(false);
const [maintainerCanModify, setMaintainerCanModify] = useState(true);
const [availableReviewers, setAvailableReviewers] = useState<ReviewerItem[]>([]);
const [selectedReviewers, setSelectedReviewers] = useState<ReviewerItem[]>([]);
const [reviewersLoading, setReviewersLoading] = useState(false);
const [reviewersFilter, setReviewersFilter] = useState("");
// Repository state
const [selectedRepo, setSelectedRepo] = useState<RepositoryItem | null>(null);
const [repoSearchResults, setRepoSearchResults] = useState<RepositoryItem[]>([]);
const [repoSearchLoading, setRepoSearchLoading] = useState(false);
const [repoFilter, setRepoFilter] = useState("");
const { app, error: appError, toolInput, toolResult, callTool, hostContext, setModelContext, openLink } = useMcpApp({
appName: "github-mcp-server-create-pull-request",
});
const owner = selectedRepo?.owner || (toolInput?.owner as string) || "";
const repo = selectedRepo?.name || (toolInput?.repo as string) || "";
const [submittedTitle, setSubmittedTitle] = useState("");
// When the server executed up-front instead of deferring to this form (e.g.
// the agent passed show_ui=false or parameters the form can't represent), the
// host still renders this View and delivers the created PR via tool-result.
// Treat that completed result as a success so we never show a "Create pull
// request" form for a PR that already exists. The deferral sentinel
// (awaiting_user_submission) returns null here, keeping the form for the
// normal deferred flow. See github/copilot-mcp-core#1864.
const resultPR = useMemo(() => completedToolResult<PRResult>(toolResult), [toolResult]);
const shownPR = successPR ?? resultPR;
// Reset all transient form/result state when toolInput changes (new invocation).
// Without this, the SuccessView from a previous submit stays visible and stale
// form values bleed through because the prefill effect below only sets when
// toolInput has truthy values and never clears. The repo is re-initialized from
// the new invocation here (rather than in a separate effect) so it isn't wiped
// by this reset.
useEffect(() => {
setTitle("");
setBody("");
setHeadBranch("");
setBaseBranch("");
setIsDraft(false);
setMaintainerCanModify(true);
setSuccessPR(null);
setError(null);
setSubmittedTitle("");
// Clear branch list and filters so a new invocation doesn't briefly show stale
// branches from the previous repo (or allow selecting invalid options) before the
// new repo's ui_get branches call resolves.
setAvailableBranches([]);
setBaseFilter("");
setHeadFilter("");
setAvailableReviewers([]);
setSelectedReviewers([]);
setReviewersFilter("");
if (toolInput?.owner && toolInput?.repo) {
setSelectedRepo({
id: `${toolInput.owner}/${toolInput.repo}`,
owner: toolInput.owner as string,
name: toolInput.repo as string,
fullName: `${toolInput.owner}/${toolInput.repo}`,
isPrivate: false,
});
} else {
setSelectedRepo(null);
}
}, [toolInput]);
// Pre-fill from toolInput
useEffect(() => {
if (toolInput?.title) setTitle(toolInput.title as string);
if (toolInput?.body) setBody(toolInput.body as string);
if (toolInput?.head) setHeadBranch(toolInput.head as string);
if (toolInput?.base) setBaseBranch(toolInput.base as string);
if (toolInput?.draft) setIsDraft(toolInput.draft as boolean);
if (toolInput?.maintainer_can_modify !== undefined) {
setMaintainerCanModify(toolInput.maintainer_can_modify as boolean);
}
if (Array.isArray(toolInput?.reviewers)) {
setSelectedReviewers((toolInput.reviewers as string[]).map(reviewerFromValue));
}
}, [toolInput]);
// Search repositories
useEffect(() => {
if (!app || !repoFilter.trim()) {
setRepoSearchResults([]);
return;
}
const searchRepos = async () => {
setRepoSearchLoading(true);
try {
const result = await callTool("search_repositories", { query: repoFilter, perPage: 10 });
if (result && !result.isError && result.content) {
const textContent = result.content.find((c) => c.type === "text");
if (textContent && textContent.type === "text" && textContent.text) {
const data = JSON.parse(textContent.text);
const repos = (data.repositories || data.items || []).map(
(r: { id?: number; owner?: { login?: string } | string; name?: string; full_name?: string; private?: boolean }) => ({
id: String(r.id || r.full_name),
owner: typeof r.owner === 'string' ? r.owner : r.owner?.login || r.full_name?.split('/')[0] || '',
name: r.name || '',
fullName: r.full_name || '',
isPrivate: r.private || false,
})
);
setRepoSearchResults(repos);
}
}
} catch (e) {
console.error("Failed to search repositories:", e);
} finally {
setRepoSearchLoading(false);
}
};
const debounce = setTimeout(searchRepos, 300);
return () => clearTimeout(debounce);
}, [app, callTool, repoFilter]);
// Load branches and reviewers when repo is selected
useEffect(() => {
if (!owner || !repo || !app) return;
const loadBranches = async () => {
setBranchesLoading(true);
try {
const result = await callTool("ui_get", { method: "branches", owner, repo });
if (result && !result.isError && result.content) {
const textContent = result.content.find((c: { type: string }) => c.type === "text");
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const branches = (data.branches || data || []).map(
(b: { name: string; protected?: boolean }) => ({ name: b.name, protected: b.protected || false })
);
setAvailableBranches(branches);
if (branches.length > 0) {
const defaultBranch = branches.find((b: BranchItem) => b.name === 'main' || b.name === 'master');
// Functional update so a base branch already prefilled from
// toolInput.base (or chosen by the user) isn't overwritten by a
// stale closure value captured before the request resolved.
if (defaultBranch) setBaseBranch((prev) => prev || defaultBranch.name);
}
}
}
} catch (e) {
console.error("Failed to load branches:", e);
} finally {
setBranchesLoading(false);
}
};
const loadReviewers = async () => {
setReviewersLoading(true);
try {
const result = await callTool("ui_get", { method: "reviewers", owner, repo });
if (result && !result.isError && result.content) {
const textContent = result.content.find((c: { type: string }) => c.type === "text");
if (textContent && "text" in textContent) {
const data = JSON.parse(textContent.text as string);
const users = (data.users || []).map(
(u: { login: string; avatar_url?: string }) => ({
kind: "user" as const,
id: u.login,
text: u.login,
avatar: u.avatar_url,
})
);
const teams = (data.teams || []).map(
(t: { slug: string; name?: string; org: string }) => ({
kind: "team" as const,
id: `${t.org}/${t.slug}`,
text: `${t.org}/${t.slug}`,
org: t.org,
})
);
setAvailableReviewers([...users, ...teams]);
}
}
} catch (e) {
console.error("Failed to load reviewers:", e);
} finally {
setReviewersLoading(false);
}
};
loadBranches();
loadReviewers();
}, [owner, repo, app, callTool]);
useEffect(() => {
if (availableReviewers.length === 0) return;
setSelectedReviewers((prev) =>
prev.map((reviewer) =>
availableReviewers.find((available) => available.id === reviewer.id || available.text === reviewer.text) || reviewer
)
);
}, [availableReviewers]);
// Filters
const filteredBaseBranches = useMemo(() => {
if (!baseFilter.trim()) return availableBranches;
return availableBranches.filter((b) => b.name.toLowerCase().includes(baseFilter.toLowerCase()));
}, [availableBranches, baseFilter]);
const filteredHeadBranches = useMemo(() => {
if (!headFilter.trim()) return availableBranches;
return availableBranches.filter((b) => b.name.toLowerCase().includes(headFilter.toLowerCase()));
}, [availableBranches, headFilter]);
const filteredReviewers = useMemo(() => {
if (!reviewersFilter.trim()) return availableReviewers;
const lowerFilter = reviewersFilter.toLowerCase();
return availableReviewers.filter((reviewer) =>
reviewer.text.toLowerCase().includes(lowerFilter) || reviewer.id.toLowerCase().includes(lowerFilter)
);
}, [availableReviewers, reviewersFilter]);
const handleSubmit = useCallback(async () => {
if (!title.trim()) { setError("Title is required"); return; }
if (!owner || !repo) { setError("Repository information not available"); return; }
if (!baseBranch) { setError("Base branch is required"); return; }
if (!headBranch) { setError("Head branch is required"); return; }
if (baseBranch === headBranch) { setError("Base and head branches cannot be the same"); return; }
setIsSubmitting(true);
setError(null);
setSubmittedTitle(title);
try {
const result = await callTool("create_pull_request", {
...(toolInput as Record<string, unknown> | undefined),
owner, repo,
title: title.trim(),
body: body.trim(),
head: headBranch,
base: baseBranch,
draft: isDraft,
maintainer_can_modify: maintainerCanModify,
reviewers: selectedReviewers.map(reviewerValue),
_ui_submitted: true
});
if (result.isError) {
const errorText = result.content?.find((c) => c.type === "text");
const errorMessage = errorText && errorText.type === "text" ? errorText.text : "Failed to create pull request";
setError(errorMessage);
} else {
const textContent = result.content?.find((c) => c.type === "text");
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) {
setError(e instanceof Error ? e.message : "An error occurred");
} finally {
setIsSubmitting(false);
}
}, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]);
if (shownPR) {
return (
<AppProvider hostContext={hostContext}>
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
</AppProvider>
);
}
if (!app && !appError) {
return (
<AppProvider hostContext={hostContext}>
<Box display="flex" alignItems="center" justifyContent="center" p={4}>
<Spinner size="medium" />
</Box>
</AppProvider>
);
}
if (appError) {
return (
<AppProvider hostContext={hostContext}>
<Flash variant="danger">{appError.message}</Flash>
</AppProvider>
);
}
return (
<AppProvider hostContext={hostContext}>
<Box
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
bg="canvas.subtle"
p={3}
>
{/* Repository picker */}
<Box
display="flex"
alignItems="center"
gap={2}
mb={3}
pb={2}
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
sx={{ minWidth: 0, overflow: "hidden" }}
>
<Box sx={{ minWidth: 0, maxWidth: "100%" }}>
<ActionMenu>
<ActionMenu.Button
size="small"
leadingVisual={selectedRepo?.isPrivate ? LockIcon : RepoIcon}
sx={{ maxWidth: "100%", overflow: "hidden", "& > span:last-child": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}
>
{selectedRepo ? selectedRepo.fullName : "Select repository"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box px={3} py={2}>
<TextInput
placeholder="Search repositories..."
value={repoFilter}
onChange={(e) => setRepoFilter(e.target.value)}
sx={{ width: "100%" }}
size="small"
autoFocus
/>
</Box>
<ActionList.Divider />
{repoSearchLoading ? (
<Box display="flex" justifyContent="center" p={3}>
<Spinner size="small" />
</Box>
) : repoSearchResults.length > 0 ? (
repoSearchResults.map((r) => (
<ActionList.Item
key={r.id}
selected={selectedRepo?.id === r.id}
onSelect={() => {
setSelectedRepo(r);
setRepoFilter("");
setAvailableBranches([]);
setBaseBranch("");
setHeadBranch("");
setAvailableReviewers([]);
setSelectedReviewers([]);
setReviewersFilter("");
}}
>
<ActionList.LeadingVisual>
{r.isPrivate ? <LockIcon /> : <RepoIcon />}
</ActionList.LeadingVisual>
{r.fullName}
</ActionList.Item>
))
) : selectedRepo ? (
<ActionList.Item key={selectedRepo.id} selected onSelect={() => setRepoFilter("")}>
<ActionList.LeadingVisual>
{selectedRepo.isPrivate ? <LockIcon /> : <RepoIcon />}
</ActionList.LeadingVisual>
{selectedRepo.fullName}
</ActionList.Item>
) : (
<Box px={3} py={2}>
<Text sx={{ color: "fg.muted", fontSize: 1 }}>Type to search repositories...</Text>
</Box>
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
</Box>
{/* Branch selectors */}
<Box display="flex" gap={2} mb={3} alignItems="flex-end" sx={{ minWidth: 0, flexWrap: "wrap" }}>
<Box sx={{ flex: "1 1 120px", minWidth: 0 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>base</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
{baseBranch || "Select base"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box p={2}>
<TextInput
placeholder="Filter branches..."
value={baseFilter}
onChange={(e) => setBaseFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList.Divider />
{branchesLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredBaseBranches.length === 0 ? (
<ActionList.Item disabled>No branches found</ActionList.Item>
) : (
filteredBaseBranches.map((branch) => (
<ActionList.Item
key={branch.name}
selected={baseBranch === branch.name}
onSelect={() => { setBaseBranch(branch.name); setBaseFilter(""); }}
>
{branch.name}
{branch.protected && <ActionList.TrailingVisual><LockIcon size={12} /></ActionList.TrailingVisual>}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
<Text sx={{ color: "fg.muted", pb: 1, px: 1, flexShrink: 0 }}></Text>
<Box sx={{ flex: "1 1 120px", minWidth: 0 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>compare</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={GitBranchIcon} sx={{ width: "100%", "& > span": { overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" } }}>
{headBranch || "Select head"}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<Box p={2}>
<TextInput
placeholder="Filter branches..."
value={headFilter}
onChange={(e) => setHeadFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList.Divider />
{branchesLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredHeadBranches.length === 0 ? (
<ActionList.Item disabled>No branches found</ActionList.Item>
) : (
filteredHeadBranches.map((branch) => (
<ActionList.Item
key={branch.name}
selected={headBranch === branch.name}
onSelect={() => { setHeadBranch(branch.name); setHeadFilter(""); }}
>
{branch.name}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</Box>
</Box>
{/* Error banner */}
{error && <Flash variant="danger" sx={{ mb: 3 }}>{error}</Flash>}
{/* Title */}
<FormControl sx={{ mb: 3 }}>
<FormControl.Label sx={{ fontWeight: "semibold" }}>Title</FormControl.Label>
<TextInput
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Title"
block
contrast
/>
</FormControl>
{/* Description */}
<Box sx={{ mb: 3 }}>
<Text as="label" sx={{ fontWeight: "semibold", fontSize: 1, display: "block", mb: 2 }}>
Description
</Text>
<MarkdownEditor value={body} onChange={setBody} placeholder="Add a description..." />
</Box>
{/* Reviewers */}
<Box sx={{ mb: 3 }}>
<Text sx={{ fontSize: 0, color: "fg.muted", mb: 1, display: "block" }}>reviewers</Text>
<ActionMenu>
<ActionMenu.Button size="small" leadingVisual={PersonIcon} sx={{ minWidth: 160 }}>
{selectedReviewers.length === 0 ? (
"No reviewers"
) : (
<>
Reviewers
<CounterLabel sx={{ ml: 1 }}>{selectedReviewers.length}</CounterLabel>
</>
)}
</ActionMenu.Button>
<ActionMenu.Overlay width="medium">
<Box p={2} borderBottomWidth={1} borderBottomStyle="solid" borderBottomColor="border.default">
<TextInput
placeholder="Search reviewers"
value={reviewersFilter}
onChange={(e) => setReviewersFilter(e.target.value)}
size="small"
block
/>
</Box>
<ActionList selectionVariant="multiple">
{reviewersLoading ? (
<ActionList.Item disabled><Spinner size="small" /> Loading...</ActionList.Item>
) : filteredReviewers.length === 0 ? (
<ActionList.Item disabled>No reviewers available</ActionList.Item>
) : (
filteredReviewers.map((reviewer) => (
<ActionList.Item
key={reviewer.id}
selected={selectedReviewers.some((r) => r.id === reviewer.id)}
onSelect={() => {
setSelectedReviewers((prev) =>
prev.some((r) => r.id === reviewer.id)
? prev.filter((r) => r.id !== reviewer.id)
: [...prev, reviewer]
);
}}
>
<ActionList.LeadingVisual>
{reviewer.kind === "user" ? (
reviewer.avatar ? (
<img
src={reviewer.avatar}
alt=""
width={16}
height={16}
style={{ borderRadius: "50%", display: "block" }}
/>
) : (
<PersonIcon />
)
) : (
<PeopleIcon />
)}
</ActionList.LeadingVisual>
{reviewer.text}
</ActionList.Item>
))
)}
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
{selectedReviewers.length > 0 && (
<Box display="flex" gap={1} mt={2} flexWrap="wrap">
{selectedReviewers.map((reviewer) => (
<Label
key={reviewer.id}
sx={{
backgroundColor: "canvas.inset",
color: "fg.muted",
borderColor: "border.default",
}}
>
{reviewer.text}
</Label>
))}
</Box>
)}
</Box>
{/* Options and Submit */}
<Box display="flex" justifyContent="space-between" alignItems="flex-end" flexWrap="wrap" gap={3}>
<Box as="label" display="flex" alignItems="center" sx={{ cursor: "pointer", gap: 2, mt: 1 }}>
<Checkbox checked={maintainerCanModify} onChange={(e) => setMaintainerCanModify(e.target.checked)} />
<Text sx={{ fontSize: 1, color: "fg.muted" }}>Allow maintainer edits</Text>
</Box>
<ButtonGroup>
<Button
variant="primary"
onClick={handleSubmit}
disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch}
>
{isSubmitting ? (
<><Spinner size="small" sx={{ mr: 1 }} />Creating...</>
) : isDraft ? (
"Draft pull request"
) : (
"Create pull request"
)}
</Button>
<ActionMenu>
<ActionMenu.Anchor>
<Button
variant="primary"
disabled={isSubmitting || !owner || !repo || !baseBranch || !headBranch}
sx={{ px: 2 }}
aria-label="Select pull request type"
>
<TriangleDownIcon />
</Button>
</ActionMenu.Anchor>
<ActionMenu.Overlay width="medium">
<ActionList selectionVariant="single">
<ActionList.Item selected={!isDraft} onSelect={() => setIsDraft(false)}>
<ActionList.LeadingVisual>
<GitPullRequestIcon />
</ActionList.LeadingVisual>
Create pull request
<ActionList.Description variant="block">
Open a pull request that is ready for review
</ActionList.Description>
</ActionList.Item>
<ActionList.Item selected={isDraft} onSelect={() => setIsDraft(true)}>
<ActionList.LeadingVisual>
<GitPullRequestIcon />
</ActionList.LeadingVisual>
Create draft pull request
<ActionList.Description variant="block">
Cannot be merged until marked ready for review
</ActionList.Description>
</ActionList.Item>
</ActionList>
</ActionMenu.Overlay>
</ActionMenu>
</ButtonGroup>
</Box>
</Box>
</AppProvider>
);
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<CreatePRApp />
</StrictMode>
);
+12
View File
@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Create Pull Request</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="./App.tsx"></script>
</body>
</html>
+54
View File
@@ -0,0 +1,54 @@
import { ThemeProvider, BaseStyles, Box } from "@primer/react";
import type { ReactNode, CSSProperties } from "react";
import { useEffect, useMemo } from "react";
import type { McpUiHostContext } from "@modelcontextprotocol/ext-apps";
import { FeedbackFooter } from "./FeedbackFooter";
interface AppProviderProps {
children: ReactNode;
hostContext?: McpUiHostContext;
}
export function AppProvider({ children, hostContext }: AppProviderProps) {
const hostTheme = hostContext?.theme;
const hostVariables = hostContext?.styles?.variables;
useEffect(() => {
// 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<CSSProperties | undefined>(() => {
if (!hostVariables) return undefined;
const out: Record<string, string> = {};
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 (
<ThemeProvider colorMode={colorMode}>
<BaseStyles>
<Box p={3} style={styleVars}>
{children}
<FeedbackFooter />
</Box>
</BaseStyles>
</ThemeProvider>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { Box, Text } from "@primer/react";
export function FeedbackFooter() {
return (
<Box
display="flex"
justifyContent="center"
mt={2}
>
<Text sx={{ color: "fg.subtle", fontSize: 0, textAlign: "center" }}>
Help us improve MCP Apps support in the GitHub MCP Server
<br />
github.com/github/github-mcp-server/issues/new?template=insiders-feedback.md
</Text>
</Box>
);
}
+447
View File
@@ -0,0 +1,447 @@
/**
* MarkdownEditor component using GitHub's official @github/markdown-toolbar-element
* with Primer React styling. This provides the same markdown editing experience
* used on github.com.
*
* @see https://github.com/github/markdown-toolbar-element
*/
import { useId, useRef, useState, useEffect } from "react";
import { Box, Text, Button, IconButton, useTheme } from "@primer/react";
import {
BoldIcon,
ItalicIcon,
QuoteIcon,
CodeIcon,
LinkIcon,
ListUnorderedIcon,
ListOrderedIcon,
TasklistIcon,
MarkdownIcon,
} from "@primer/octicons-react";
import Markdown from "react-markdown";
import remarkGfm from "remark-gfm";
// Import and register the web component
import "@github/markdown-toolbar-element";
// Declare types for the web component elements
declare global {
namespace JSX {
interface IntrinsicElements {
"markdown-toolbar": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement> & { for: string },
HTMLElement
>;
"md-bold": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
"md-italic": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
"md-quote": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
"md-code": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
"md-link": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
"md-unordered-list": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
"md-ordered-list": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
"md-task-list": React.DetailedHTMLProps<
React.HTMLAttributes<HTMLElement>,
HTMLElement
>;
}
}
}
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
placeholder?: string;
minHeight?: number;
}
export function MarkdownEditor({
value,
onChange,
placeholder = "Add a description...",
minHeight = 150,
}: MarkdownEditorProps) {
const textareaId = useId();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [viewMode, setViewMode] = useState<"write" | "preview">("write");
const { colorScheme } = useTheme();
const isDark = colorScheme === "dark" || colorScheme === "dark_dimmed";
// Sync external value changes to textarea
useEffect(() => {
if (textareaRef.current && textareaRef.current.value !== value) {
textareaRef.current.value = value;
}
}, [value]);
// Handle Enter key for list continuation
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key !== "Enter" || e.shiftKey) return;
const textarea = textareaRef.current;
if (!textarea) return;
const { selectionStart, value: currentValue } = textarea;
// Get the current line
const beforeCursor = currentValue.substring(0, selectionStart);
const lastNewline = beforeCursor.lastIndexOf("\n");
const currentLine = beforeCursor.substring(lastNewline + 1);
// Match different list patterns
const unorderedMatch = currentLine.match(/^(\s*)([-*])\s/);
const orderedMatch = currentLine.match(/^(\s*)(\d+)\.\s/);
const taskMatch = currentLine.match(/^(\s*)([-*])\s\[[ x]\]\s/);
let prefix = "";
let isEmpty = false;
if (taskMatch) {
const indent = taskMatch[1];
const marker = taskMatch[2];
// Check if the line only has the list marker with no content
isEmpty = currentLine.trim() === `${marker} [ ]` || currentLine.trim() === `${marker} [x]`;
prefix = `${indent}${marker} [ ] `;
} else if (orderedMatch) {
const indent = orderedMatch[1];
const num = parseInt(orderedMatch[2], 10);
// Check if the line only has the list marker
isEmpty = currentLine.trim() === `${num}.`;
prefix = `${indent}${num + 1}. `;
} else if (unorderedMatch) {
const indent = unorderedMatch[1];
const marker = unorderedMatch[2];
// Check if the line only has the list marker
isEmpty = currentLine.trim() === marker;
prefix = `${indent}${marker} `;
}
if (prefix) {
e.preventDefault();
if (isEmpty) {
// If just the list marker, remove it and exit list
const newValue = currentValue.substring(0, lastNewline + 1) + currentValue.substring(selectionStart);
onChange(newValue);
// Set cursor position after React updates
requestAnimationFrame(() => {
if (textarea) {
textarea.selectionStart = textarea.selectionEnd = lastNewline + 1;
textarea.focus();
}
});
} else {
// Continue the list on the next line
const afterCursor = currentValue.substring(selectionStart);
const newValue = beforeCursor + "\n" + prefix + afterCursor;
onChange(newValue);
// Set cursor position after the prefix
const newCursorPos = selectionStart + 1 + prefix.length;
requestAnimationFrame(() => {
if (textarea) {
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
textarea.focus();
}
});
}
}
};
return (
<Box
borderWidth={1}
borderStyle="solid"
borderColor="border.default"
borderRadius={2}
overflow="hidden"
>
{/* Header with tabs and toolbar */}
<Box
display="flex"
alignItems="center"
justifyContent="space-between"
px={2}
py={1}
bg="canvas.subtle"
borderBottomWidth={1}
borderBottomStyle="solid"
borderBottomColor="border.default"
overflow="hidden"
>
{/* Write/Preview tabs */}
<Box display="flex" flexShrink={0} gap={0}>
<Button
size="small"
variant="invisible"
onClick={() => setViewMode("write")}
sx={{
fontWeight: viewMode === "write" ? "semibold" : "normal",
color: viewMode === "write" ? "fg.default" : "fg.muted",
bg: viewMode === "write" ? "actionListItem.default.hoverBg" : "transparent",
borderRadius: 2,
"&:hover": {
color: "fg.default",
},
}}
>
Write
</Button>
<Button
size="small"
variant="invisible"
onClick={() => setViewMode("preview")}
sx={{
fontWeight: viewMode === "preview" ? "semibold" : "normal",
color: viewMode === "preview" ? "fg.default" : "fg.muted",
bg: viewMode === "preview" ? "actionListItem.default.hoverBg" : "transparent",
borderRadius: 2,
"&:hover": {
color: "fg.default",
},
}}
>
Preview
</Button>
</Box>
{/* Toolbar - uses GitHub's official markdown-toolbar-element */}
{viewMode === "write" && (
<markdown-toolbar for={textareaId} style={{ display: "flex", overflow: "hidden", minWidth: 0, flexShrink: 1 }}>
<Box display="flex" gap={0} alignItems="center" sx={{ overflowX: "auto" }}>
<md-bold>
<IconButton
icon={BoldIcon}
aria-label="Add bold text"
size="small"
variant="invisible"
/>
</md-bold>
<md-italic>
<IconButton
icon={ItalicIcon}
aria-label="Add italic text"
size="small"
variant="invisible"
/>
</md-italic>
<md-quote>
<IconButton
icon={QuoteIcon}
aria-label="Add a quote"
size="small"
variant="invisible"
/>
</md-quote>
<md-code>
<IconButton
icon={CodeIcon}
aria-label="Add code"
size="small"
variant="invisible"
/>
</md-code>
<md-link>
<IconButton
icon={LinkIcon}
aria-label="Add a link"
size="small"
variant="invisible"
/>
</md-link>
<Box
sx={{
width: "1px",
height: 16,
bg: "border.default",
mx: 1,
}}
/>
<md-unordered-list>
<IconButton
icon={ListUnorderedIcon}
aria-label="Add a bulleted list"
size="small"
variant="invisible"
/>
</md-unordered-list>
<md-ordered-list>
<IconButton
icon={ListOrderedIcon}
aria-label="Add a numbered list"
size="small"
variant="invisible"
/>
</md-ordered-list>
<md-task-list>
<IconButton
icon={TasklistIcon}
aria-label="Add a task list"
size="small"
variant="invisible"
/>
</md-task-list>
</Box>
</markdown-toolbar>
)}
</Box>
{/* Content area */}
{viewMode === "write" ? (
<textarea
ref={textareaRef}
id={textareaId}
defaultValue={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
style={{
width: "100%",
minHeight,
padding: "12px",
border: "none",
resize: "vertical",
fontFamily:
'-apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif',
fontSize: "14px",
lineHeight: 1.5,
outline: "none",
boxSizing: "border-box",
backgroundColor: isDark ? "#0d1117" : "#ffffff",
color: isDark ? "#e6edf3" : "#1f2328",
}}
/>
) : (
<Box
bg="canvas.default"
sx={{
padding: "12px",
minHeight,
fontSize: 1,
lineHeight: 1.5,
color: "fg.default",
// Remove top margin from first element so text aligns with write mode
"& > :first-child": { mt: 0 },
// GitHub Flavored Markdown styles
"& h1, & h2, & h3, & h4, & h5, & h6": {
mt: 3,
mb: 2,
fontWeight: "semibold",
lineHeight: 1.25,
},
"& h1": { fontSize: 4, borderBottom: "1px solid", borderColor: "border.default", pb: 2 },
"& h2": { fontSize: 3, borderBottom: "1px solid", borderColor: "border.default", pb: 2 },
"& h3": { fontSize: 2 },
"& p": { my: 2 },
"& ul, & ol": { pl: 4, my: 2 },
"& li": { my: 1 },
"& code": {
bg: "neutral.muted",
px: 1,
py: "2px",
borderRadius: 1,
fontFamily: "mono",
fontSize: "85%",
},
"& pre": {
bg: "neutral.muted",
p: 3,
borderRadius: 2,
overflow: "auto",
my: 2,
},
"& pre code": {
bg: "transparent",
p: 0,
},
"& blockquote": {
borderLeft: "4px solid",
borderColor: "border.default",
pl: 3,
ml: 0,
mr: 0,
my: 2,
color: "fg.muted",
bg: "canvas.subtle",
},
"& a": {
color: "accent.fg",
textDecoration: "none",
"&:hover": { textDecoration: "underline" },
},
"& table": {
borderCollapse: "collapse",
width: "100%",
my: 2,
},
"& th, & td": {
border: "1px solid",
borderColor: "border.default",
p: 2,
},
"& th": {
bg: "canvas.subtle",
fontWeight: "semibold",
},
"& input[type='checkbox']": {
mr: 2,
},
"& hr": {
border: "none",
borderTop: "1px solid",
borderColor: "border.default",
my: 3,
},
}}>
{value ? (
<Markdown remarkPlugins={[remarkGfm]}>{value}</Markdown>
) : (
<Text sx={{ color: "fg.muted", fontStyle: "italic" }}>
Nothing to preview
</Text>
)}
</Box>
)}
{/* Footer */}
<Box
display="flex"
alignItems="center"
gap={1}
px={2}
py={1}
bg="canvas.subtle"
borderTopWidth={1}
borderTopStyle="solid"
borderTopColor="border.default"
>
<MarkdownIcon size={16} />
<Text sx={{ fontSize: 0, color: "fg.muted" }}>
Markdown is supported
</Text>
</Box>
</Box>
);
}
+136
View File
@@ -0,0 +1,136 @@
import { useApp as useExtApp } from "@modelcontextprotocol/ext-apps/react";
import type {
App,
McpUiDisplayMode,
McpUiHostContext,
McpUiUpdateModelContextRequest,
} from "@modelcontextprotocol/ext-apps";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
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<string, unknown>) => void;
}
interface UseMcpAppReturn {
app: App | null;
error: Error | null;
toolResult: CallToolResult | null;
toolInput: Record<string, unknown> | null;
hostContext: McpUiHostContext | undefined;
callTool: (name: string, args: Record<string, unknown>) => Promise<CallToolResult>;
/**
* 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<void>;
/**
* 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<void>;
}
export function useMcpApp({
appName,
appVersion = "1.0.0",
availableDisplayModes = ["inline"],
onToolResult,
onToolInput,
}: UseMcpAppOptions): UseMcpAppReturn {
const [toolResult, setToolResult] = useState<CallToolResult | null>(null);
const [toolInput, setToolInput] = useState<Record<string, unknown> | null>(null);
const [hostContext, setHostContext] = useState<McpUiHostContext | undefined>(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: { availableDisplayModes },
autoResize: true,
strict: import.meta.env.DEV,
onAppCreated: (app) => {
app.ontoolresult = async (result) => {
setToolResult(result);
onToolResult?.(result);
};
app.ontoolinput = async (input) => {
const args = (input.arguments ?? {}) as Record<string, unknown>;
setToolInput(args);
// A tool-input notification marks a new invocation, and the spec
// guarantees it is delivered before that invocation's tool-result.
// Drop any prior result so a completed result from a previous
// invocation can't leak into the new render (e.g. a stale success card
// showing over a fresh, still-deferred form). The current invocation's
// result, if any, arrives next via ontoolresult.
setToolResult(null);
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<string, unknown>) => {
if (!app) throw new Error("App not connected");
return app.callServerTool({ name, arguments: args });
},
[app]
);
const setModelContext = useCallback<UseMcpAppReturn["setModelContext"]>(
async (params) => {
if (!app) return;
await app.updateModelContext(params);
},
[app]
);
const openLink = useCallback<UseMcpAppReturn["openLink"]>(
async (url) => {
if (!app) {
window.open(url, "_blank", "noopener,noreferrer");
return;
}
const result = await app.openLink({ url });
// The host may deny the request (e.g. blocked domain or user cancelled).
// Fall back to a direct window.open so the link still works.
if (result?.isError) {
window.open(url, "_blank", "noopener,noreferrer");
}
},
[app]
);
return {
app,
error,
toolResult,
toolInput,
hostContext,
callTool,
setModelContext,
openLink,
};
}
+37
View File
@@ -0,0 +1,37 @@
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
/**
* Returns the parsed JSON payload of a *completed* tool result, or `null` when
* the result is absent, an error, or the deferral sentinel (structured
* `status` of `"awaiting_user_submission"`, set by the server's
* `NewToolResultAwaitingFormSubmission`).
*
* Form-backed write Views (create_pull_request, issue_write,
* update_pull_request) use this to tell apart "the server deferred and is
* waiting for my form submission" from "the server already executed". Without
* it, a View renders its input form off in-app state alone and will show e.g. a
* "Create pull request" form for a PR that was already created up-front (when
* the agent passed `show_ui=false` or parameters the form can't represent).
*
* See github/copilot-mcp-core#1864 for the full show/defer state machine.
*/
export function completedToolResult<T = Record<string, unknown>>(
result: CallToolResult | null,
): T | null {
if (!result || result.isError) return null;
const status = (result.structuredContent as { status?: string } | undefined)
?.status;
if (status === "awaiting_user_submission") return null;
const textContent = result.content?.find((c) => c.type === "text");
if (!textContent || textContent.type !== "text" || !textContent.text) {
return null;
}
try {
return JSON.parse(textContent.text) as T;
} catch {
return null;
}
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"isolatedModules": true,
"types": ["node"]
},
"include": ["src", "vite.config.ts"]
}
+47
View File
@@ -0,0 +1,47 @@
import { defineConfig, Plugin } from "vite";
import react from "@vitejs/plugin-react";
import { viteSingleFile } from "vite-plugin-singlefile";
import { existsSync, renameSync, rmSync } from "fs";
import { resolve } from "path";
const app = process.env.APP;
if (!app) {
throw new Error("APP environment variable must be set");
}
const outDir = resolve(__dirname, "../pkg/github/ui_dist");
// vite-plugin-singlefile inlines all JS/CSS into the HTML, but Vite preserves
// the input file's relative path in the output (src/apps/<app>/index.html).
// After the bundle is written, hoist that file to <outDir>/<app>.html and
// remove the now-empty nested directories. Done in closeBundle (post-write)
// because Rolldown disallows mutating the in-memory bundle in generateBundle.
function flattenOutput(): Plugin {
return {
name: "flatten-output",
enforce: "post",
closeBundle() {
const nested = resolve(outDir, `src/apps/${app}/index.html`);
const flat = resolve(outDir, `${app}.html`);
if (!existsSync(nested)) {
throw new Error(
`flatten-output: expected built HTML at ${nested} for app "${app}" but it was not emitted`,
);
}
renameSync(nested, flat);
rmSync(resolve(outDir, "src"), { recursive: true, force: true });
},
};
}
export default defineConfig({
plugins: [react(), viteSingleFile(), flattenOutput()],
build: {
outDir,
emptyOutDir: false,
rollupOptions: {
input: resolve(__dirname, `src/apps/${app}/index.html`),
},
},
});