Skip to content

Commit c08ff04

Browse files
committed
added ui/no-ui support
1 parent 8103657 commit c08ff04

File tree

6 files changed

+166
-9
lines changed

6 files changed

+166
-9
lines changed

pkg/github/issues.go

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1086,7 +1086,7 @@ Options are:
10861086
},
10871087
},
10881088
[]scopes.Scope{scopes.Repo},
1089-
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
1089+
func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
10901090
method, err := RequiredParam[string](args, "method")
10911091
if err != nil {
10921092
return utils.NewToolResultError(err.Error()), nil, nil
@@ -1101,12 +1101,14 @@ Options are:
11011101
return utils.NewToolResultError(err.Error()), nil, nil
11021102
}
11031103

1104-
// When insiders mode is enabled, check if this is a UI form submission.
1105-
// The UI sends _ui_submitted=true to distinguish form submissions from LLM calls.
1106-
// Without this flag, always show the UI so the user can review/edit before submitting.
1104+
// When insiders mode is enabled and the client supports MCP Apps UI,
1105+
// check if this is a UI form submission. The UI sends _ui_submitted=true
1106+
// to distinguish form submissions from LLM calls. Without this flag,
1107+
// show the UI so the user can review/edit before submitting.
1108+
// Clients without MCP Apps support skip this gate and execute directly.
11071109
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")
11081110

1109-
if deps.GetFlags(ctx).InsidersMode && !uiSubmitted {
1111+
if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(req) && !uiSubmitted {
11101112
if method == "update" {
11111113
issueNumber, numErr := RequiredInt(args, "issue_number")
11121114
if numErr != nil {

pkg/github/issues_test.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -933,6 +933,50 @@ func Test_CreateIssue(t *testing.T) {
933933
}
934934
}
935935

936+
// Test_IssueWrite_InsidersMode_NoUISupport verifies that when insiders mode is
937+
// enabled but the client does not support MCP Apps UI (no session or no UI
938+
// capability), the tool executes directly instead of returning a "Ready to..."
939+
// form message.
940+
func Test_IssueWrite_InsidersMode_NoUISupport(t *testing.T) {
941+
t.Parallel()
942+
943+
mockIssue := &github.Issue{
944+
Number: github.Ptr(1),
945+
Title: github.Ptr("Test"),
946+
HTMLURL: github.Ptr("https://github.com/owner/repo/issues/1"),
947+
}
948+
949+
serverTool := IssueWrite(translations.NullTranslationHelper)
950+
951+
client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
952+
PostReposIssuesByOwnerByRepo: mockResponse(t, http.StatusCreated, mockIssue),
953+
}))
954+
955+
deps := BaseDeps{
956+
Client: client,
957+
GQLClient: githubv4.NewClient(nil),
958+
Flags: FeatureFlags{InsidersMode: true},
959+
}
960+
handler := serverTool.Handler(deps)
961+
962+
// Request has no session — simulates a client without MCP Apps support
963+
request := createMCPRequest(map[string]interface{}{
964+
"method": "create",
965+
"owner": "owner",
966+
"repo": "repo",
967+
"title": "Test",
968+
})
969+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
970+
require.NoError(t, err)
971+
require.NotNil(t, result)
972+
973+
textContent := getTextResult(t, result)
974+
assert.NotContains(t, textContent.Text, "interactive form will be displayed",
975+
"tool should execute directly when client has no MCP Apps support")
976+
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1",
977+
"tool should return the created issue URL")
978+
}
979+
936980
func Test_ListIssues(t *testing.T) {
937981
// Verify tool definition
938982
serverTool := ListIssues(translations.NullTranslationHelper)

pkg/github/pullrequests.go

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,7 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
545545
},
546546
},
547547
[]scopes.Scope{scopes.Repo},
548-
func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
548+
func(ctx context.Context, deps ToolDependencies, req *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) {
549549
owner, err := RequiredParam[string](args, "owner")
550550
if err != nil {
551551
return utils.NewToolResultError(err.Error()), nil, nil
@@ -555,11 +555,13 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo
555555
return utils.NewToolResultError(err.Error()), nil, nil
556556
}
557557

558-
// When insiders mode is enabled, check if this is a UI form submission.
559-
// The UI sends _ui_submitted=true to distinguish form submissions from LLM calls.
558+
// When insiders mode is enabled and the client supports MCP Apps UI,
559+
// check if this is a UI form submission. The UI sends _ui_submitted=true
560+
// to distinguish form submissions from LLM calls.
561+
// Clients without MCP Apps support skip this gate and execute directly.
560562
uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted")
561563

562-
if deps.GetFlags(ctx).InsidersMode && !uiSubmitted {
564+
if deps.GetFlags(ctx).InsidersMode && clientSupportsUI(req) && !uiSubmitted {
563565
return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. The interactive form will be displayed.", owner, repo)), nil, nil
564566
}
565567

pkg/github/pullrequests_test.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2172,6 +2172,53 @@ func Test_CreatePullRequest(t *testing.T) {
21722172
}
21732173
}
21742174

2175+
// Test_CreatePullRequest_InsidersMode_NoUISupport verifies that when insiders
2176+
// mode is enabled but the client does not support MCP Apps UI, the tool
2177+
// executes directly instead of returning a "Ready to..." form message.
2178+
func Test_CreatePullRequest_InsidersMode_NoUISupport(t *testing.T) {
2179+
t.Parallel()
2180+
2181+
mockPR := &github.PullRequest{
2182+
Number: github.Ptr(42),
2183+
Title: github.Ptr("Test PR"),
2184+
HTMLURL: github.Ptr("https://github.com/owner/repo/pull/42"),
2185+
Head: &github.PullRequestBranch{SHA: github.Ptr("abc"), Ref: github.Ptr("feature")},
2186+
Base: &github.PullRequestBranch{SHA: github.Ptr("def"), Ref: github.Ptr("main")},
2187+
User: &github.User{Login: github.Ptr("testuser")},
2188+
}
2189+
2190+
serverTool := CreatePullRequest(translations.NullTranslationHelper)
2191+
2192+
client := github.NewClient(MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
2193+
PostReposPullsByOwnerByRepo: mockResponse(t, http.StatusCreated, mockPR),
2194+
}))
2195+
2196+
deps := BaseDeps{
2197+
Client: client,
2198+
GQLClient: githubv4.NewClient(nil),
2199+
Flags: FeatureFlags{InsidersMode: true},
2200+
}
2201+
handler := serverTool.Handler(deps)
2202+
2203+
// Request has no session — simulates a client without MCP Apps support
2204+
request := createMCPRequest(map[string]interface{}{
2205+
"owner": "owner",
2206+
"repo": "repo",
2207+
"title": "Test PR",
2208+
"head": "feature",
2209+
"base": "main",
2210+
})
2211+
result, err := handler(ContextWithDeps(context.Background(), deps), &request)
2212+
require.NoError(t, err)
2213+
require.NotNil(t, result)
2214+
2215+
textContent := getTextResult(t, result)
2216+
assert.NotContains(t, textContent.Text, "interactive form will be displayed",
2217+
"tool should execute directly when client has no MCP Apps support")
2218+
assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42",
2219+
"tool should return the created PR URL")
2220+
}
2221+
21752222
func TestCreateAndSubmitPullRequestReview(t *testing.T) {
21762223
t.Parallel()
21772224

pkg/github/ui_capability.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package github
2+
3+
import "github.com/modelcontextprotocol/go-sdk/mcp"
4+
5+
// UIExtensionID is the MCP Apps extension identifier used for capability negotiation.
6+
// Clients advertise MCP Apps support by including this key in their capabilities.
7+
// See: https://github.com/modelcontextprotocol/ext-apps
8+
const UIExtensionID = "io.modelcontextprotocol/ui"
9+
10+
// clientSupportsUI checks whether the client that sent this request supports
11+
// MCP Apps UI rendering. It inspects the client's experimental capabilities
12+
// for the MCP Apps extension identifier.
13+
//
14+
// When the client does not support MCP Apps, tools should skip any UI-gated
15+
// flow (e.g., interactive forms) and execute the action directly.
16+
func clientSupportsUI(req *mcp.CallToolRequest) bool {
17+
if req == nil || req.Session == nil {
18+
return false
19+
}
20+
params := req.Session.InitializeParams()
21+
if params == nil || params.Capabilities == nil {
22+
return false
23+
}
24+
_, ok := params.Capabilities.Experimental[UIExtensionID]
25+
return ok
26+
}

pkg/github/ui_capability_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
package github
2+
3+
import (
4+
"testing"
5+
6+
"github.com/modelcontextprotocol/go-sdk/mcp"
7+
"github.com/stretchr/testify/assert"
8+
)
9+
10+
func TestClientSupportsUI(t *testing.T) {
11+
t.Parallel()
12+
13+
tests := []struct {
14+
name string
15+
req *mcp.CallToolRequest
16+
expected bool
17+
}{
18+
{
19+
name: "nil request",
20+
req: nil,
21+
expected: false,
22+
},
23+
{
24+
name: "nil session",
25+
req: &mcp.CallToolRequest{},
26+
expected: false,
27+
},
28+
}
29+
30+
for _, tt := range tests {
31+
t.Run(tt.name, func(t *testing.T) {
32+
t.Parallel()
33+
assert.Equal(t, tt.expected, clientSupportsUI(tt.req))
34+
})
35+
}
36+
}

0 commit comments

Comments
 (0)