Skip to content
Open
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
6 changes: 6 additions & 0 deletions pkg/github/pullrequests_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2776,6 +2776,12 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) {
textContent := getTextResult(t, result)
assert.Contains(t, textContent.Text, "interactive form has been shown to the user for creating a new pull request")
assert.True(t, result.IsError, "form-routing stub should be marked IsError so agents don't claim success")

// github/github-mcp-server#2965: remount persistence keys off _meta.viewUUID.
require.NotNil(t, result.Meta, "deferral must include Meta for viewUUID")
viewUUID, ok := result.Meta["viewUUID"].(string)
require.True(t, ok, "viewUUID should be a string")
assert.NotEmpty(t, viewUUID)
})

t.Run("UI client with _ui_submitted executes directly", func(t *testing.T) {
Expand Down
30 changes: 29 additions & 1 deletion pkg/utils/result.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
package utils //nolint:revive //TODO: figure out a better name for this package

import "github.com/modelcontextprotocol/go-sdk/mcp"
import (
"crypto/rand"
"fmt"
"time"

"github.com/modelcontextprotocol/go-sdk/mcp"
)

func NewToolResultText(message string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Expand Down Expand Up @@ -69,6 +75,10 @@ func NewToolResultResourceLink(message string, link *mcp.ResourceLink) *mcp.Call
// result. The MCP App form will submit the operation directly when the user
// clicks submit, after which a ui/update-model-context call delivers the real
// outcome to the agent.
//
// A stable Meta.viewUUID is included so MCP Apps can persist view state (e.g.
// a completed success card) across host remounts via localStorage, per the
// MCP Apps "Persisting view state" pattern.
func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult {
return &mcp.CallToolResult{
Content: []mcp.Content{
Expand All @@ -81,5 +91,23 @@ func NewToolResultAwaitingFormSubmission(message string) *mcp.CallToolResult {
"reason": "An interactive form is being shown to the user. The operation has not been performed.",
},
IsError: true,
Meta: mcp.Meta{
"viewUUID": newViewUUID(),
},
}
}

// newViewUUID returns a random UUID v4 string without pulling in an extra
// dependency. Used as the MCP Apps view persistence key.
func newViewUUID() string {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
// Extremely unlikely. Do not return a constant key (shared storage key →
// cross-view leakage); make it unique enough for a view persistence key.
return fmt.Sprintf("view-%d", time.Now().UnixNano())
}
// UUID version 4 + RFC 4122 variant bits.
b[6] = (b[6] & 0x0f) | 0x40
b[8] = (b[8] & 0x3f) | 0x80
return fmt.Sprintf("%x-%x-%x-%x-%x", b[0:4], b[4:6], b[6:8], b[8:10], b[10:])
}
63 changes: 63 additions & 0 deletions pkg/utils/result_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package utils

import (
"encoding/json"
"testing"

"github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestNewToolResultAwaitingFormSubmission_IncludesViewUUID(t *testing.T) {
t.Parallel()

result := NewToolResultAwaitingFormSubmission("showing form")
require.NotNil(t, result)
assert.True(t, result.IsError)
require.NotNil(t, result.Meta)

viewUUID, ok := result.Meta["viewUUID"].(string)
require.True(t, ok, "viewUUID should be a string")
assert.NotEmpty(t, viewUUID)
assert.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, viewUUID)

status, ok := result.StructuredContent.(map[string]any)["status"]
require.True(t, ok)
assert.Equal(t, "awaiting_user_submission", status)
}

func TestNewToolResultAwaitingFormSubmission_UniqueViewUUIDs(t *testing.T) {
t.Parallel()

a := NewToolResultAwaitingFormSubmission("a")
b := NewToolResultAwaitingFormSubmission("b")
require.NotEqual(t, a.Meta["viewUUID"], b.Meta["viewUUID"])
}

// TestNewToolResultAwaitingFormSubmission_WireFormatMeta verifies the host-facing
// JSON shape: MCP Apps read result._meta.viewUUID after the host re-delivers the
// original deferral on remount (github/github-mcp-server#2965).
func TestNewToolResultAwaitingFormSubmission_WireFormatMeta(t *testing.T) {
t.Parallel()

result := NewToolResultAwaitingFormSubmission("showing form")
raw, err := json.Marshal(result)
require.NoError(t, err)

var payload map[string]any
require.NoError(t, json.Unmarshal(raw, &payload))

meta, ok := payload["_meta"].(map[string]any)
require.True(t, ok, "wire JSON must include _meta; got: %s", string(raw))
viewUUID, ok := meta["viewUUID"].(string)
require.True(t, ok, "_meta.viewUUID must be a string; got: %s", string(raw))
assert.NotEmpty(t, viewUUID)
assert.Equal(t, true, payload["isError"])

// Round-trip: host re-sends the same JSON → UI must recover the same UUID.
var decoded mcp.CallToolResult
require.NoError(t, json.Unmarshal(raw, &decoded))
require.NotNil(t, decoded.Meta)
assert.Equal(t, viewUUID, decoded.Meta["viewUUID"])
}
1 change: 1 addition & 0 deletions ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"build": "node scripts/build.mjs",
"dev": "npm run build",
"typecheck": "tsc --noEmit",
"test": "node --experimental-strip-types --test src/lib/viewState.test.ts",
"clean": "rm -rf dist"
},
"dependencies": {
Expand Down
39 changes: 35 additions & 4 deletions ui/src/apps/issue-write/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import { AppProvider } from "../../components/AppProvider";
import { useMcpApp } from "../../hooks/useMcpApp";
import { completedToolResult } from "../../lib/toolResult";
import { getViewUUID, loadViewState, saveViewState } from "../../lib/viewState";
import { MarkdownEditor } from "../../components/MarkdownEditor";

interface IssueResult {
Expand Down Expand Up @@ -449,7 +450,17 @@ function CreateIssueApp() {
// null here, keeping the form for the normal deferred flow.
// See github/copilot-mcp-core#1864.
const resultIssue = useMemo(() => completedToolResult<IssueResult>(toolResult), [toolResult]);
const shownIssue = successIssue ?? resultIssue;
// Restore synchronously during render so a remount never paints the
// create/update form for a completed invocation (github/github-mcp-server#2965).
const persistedIssue = useMemo(() => {
if (successIssue || resultIssue) return null;
const viewUUID = getViewUUID(toolResult);
if (!viewUUID) return null;
return loadViewState<IssueResult, { submittedLabels?: LabelItem[] }>(viewUUID);
}, [successIssue, resultIssue, toolResult]);
const shownIssue = successIssue ?? resultIssue ?? persistedIssue?.result ?? null;
const shownSubmittedTitle = persistedIssue?.submittedTitle || title;
const shownSubmittedLabels = persistedIssue?.extras?.submittedLabels || selectedLabels;

// Get method and issue_number from toolInput
const method = (toolInput?.method as string) || "create";
Expand Down Expand Up @@ -1083,6 +1094,15 @@ function CreateIssueApp() {
try {
const issueData = JSON.parse(textContent.text as string);
setSuccessIssue(issueData);
const viewUUID = getViewUUID(toolResult);
if (viewUUID) {
saveViewState(viewUUID, {
status: "completed",
result: issueData,
submittedTitle: title.trim(),
extras: { submittedLabels: selectedLabels },
});
}
// 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({
Expand All @@ -1097,7 +1117,17 @@ function CreateIssueApp() {
],
});
} catch {
setSuccessIssue({ title, body });
const fallback = { title, body };
setSuccessIssue(fallback);
const viewUUID = getViewUUID(toolResult);
if (viewUUID) {
saveViewState(viewUUID, {
status: "completed",
result: fallback,
submittedTitle: title.trim(),
extras: { submittedLabels: selectedLabels },
});
}
}
}
}
Expand All @@ -1123,6 +1153,7 @@ function CreateIssueApp() {
fieldValues,
issueFieldsByName,
toolInput,
toolResult,
callTool,
setModelContext,
]);
Expand Down Expand Up @@ -1228,8 +1259,8 @@ function CreateIssueApp() {
issue={shownIssue}
owner={owner}
repo={repo}
submittedTitle={title}
submittedLabels={selectedLabels}
submittedTitle={shownSubmittedTitle}
submittedLabels={shownSubmittedLabels}
isUpdate={isUpdateMode}
openLink={openLink}
/>
Expand Down
24 changes: 21 additions & 3 deletions ui/src/apps/pr-edit/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { AppProvider } from "../../components/AppProvider";
import { useMcpApp } from "../../hooks/useMcpApp";
import { completedToolResult } from "../../lib/toolResult";
import { getViewUUID, loadViewState, saveViewState } from "../../lib/viewState";
import { MarkdownEditor } from "../../components/MarkdownEditor";

interface PRResult {
Expand Down Expand Up @@ -283,7 +284,16 @@ function EditPRApp() {
// (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;
// Restore synchronously during render so a remount never paints the edit form
// for a completed invocation (github/github-mcp-server#2965).
const persistedPR = useMemo(() => {
if (successPR || resultPR) return null;
const viewUUID = getViewUUID(toolResult);
if (!viewUUID) return null;
return loadViewState<PRResult>(viewUUID);
}, [successPR, resultPR, toolResult]);
const shownPR = successPR ?? resultPR ?? persistedPR?.result ?? null;
const shownSubmittedTitle = submittedTitle || persistedPR?.submittedTitle || "";

useEffect(() => {
setTitle("");
Expand Down Expand Up @@ -487,6 +497,14 @@ function EditPRApp() {
if (textContent && textContent.type === "text" && textContent.text) {
const prData = JSON.parse(textContent.text);
setSuccessPR(prData);
const viewUUID = getViewUUID(toolResult);
if (viewUUID) {
saveViewState(viewUUID, {
status: "completed",
result: prData,
submittedTitle: title.trim(),
});
}
void setModelContext({
structuredContent: prData,
content: [
Expand All @@ -503,12 +521,12 @@ function EditPRApp() {
} finally {
setIsSubmitting(false);
}
}, [title, body, owner, repo, pullNumber, baseBranch, initialValues, selectedReviewers, prState, isDraft, maintainerCanModify, callTool, setModelContext]);
}, [title, body, owner, repo, pullNumber, baseBranch, initialValues, selectedReviewers, prState, isDraft, maintainerCanModify, toolResult, callTool, setModelContext]);

if (shownPR) {
return (
<AppProvider hostContext={hostContext}>
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={shownSubmittedTitle} openLink={openLink} />
</AppProvider>
);
}
Expand Down
25 changes: 22 additions & 3 deletions ui/src/apps/pr-write/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
import { AppProvider } from "../../components/AppProvider";
import { useMcpApp } from "../../hooks/useMcpApp";
import { completedToolResult } from "../../lib/toolResult";
import { getViewUUID, loadViewState, saveViewState } from "../../lib/viewState";
import { MarkdownEditor } from "../../components/MarkdownEditor";

interface PRResult {
Expand Down Expand Up @@ -206,7 +207,17 @@ function CreatePRApp() {
// (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;
// Restore synchronously during render so a remount never paints the create
// form for a completed invocation (github/github-mcp-server#2965). React
// success state and up-front (non-deferred) results take precedence.
const persistedPR = useMemo(() => {
if (successPR || resultPR) return null;
const viewUUID = getViewUUID(toolResult);
if (!viewUUID) return null;
return loadViewState<PRResult>(viewUUID);
}, [successPR, resultPR, toolResult]);
const shownPR = successPR ?? resultPR ?? persistedPR?.result ?? null;
const shownSubmittedTitle = submittedTitle || persistedPR?.submittedTitle || "";

// Reset all transient form/result state when toolInput changes (new invocation).
// Without this, the SuccessView from a previous submit stays visible and stale
Expand Down Expand Up @@ -431,6 +442,14 @@ function CreatePRApp() {
if (textContent && textContent.type === "text" && textContent.text) {
const prData = JSON.parse(textContent.text);
setSuccessPR(prData);
const viewUUID = getViewUUID(toolResult);
if (viewUUID) {
saveViewState(viewUUID, {
status: "completed",
result: prData,
submittedTitle: title.trim(),
});
}
// 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({
Expand All @@ -449,12 +468,12 @@ function CreatePRApp() {
} finally {
setIsSubmitting(false);
}
}, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, callTool, setModelContext]);
}, [title, body, owner, repo, baseBranch, headBranch, isDraft, maintainerCanModify, selectedReviewers, toolInput, toolResult, callTool, setModelContext]);

if (shownPR) {
return (
<AppProvider hostContext={hostContext}>
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={submittedTitle} openLink={openLink} />
<SuccessView pr={shownPR} owner={owner} repo={repo} submittedTitle={shownSubmittedTitle} openLink={openLink} />
</AppProvider>
);
}
Expand Down
Loading