From 561a4a79515309b45a373cf06d01791f7dc11fd2 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 29 May 2026 16:24:05 +0100 Subject: [PATCH 01/34] Strip _meta.ui when client lacks UI capability Per the MCP Apps 2026-01-26 spec, servers SHOULD check client capabilities before advertising UI-enabled tools. Extend the inventory strip gate to remove _meta.ui not only when the feature flag is off, but also when the request context explicitly reports the client lacks UI support (HasUISupport returns supported=false, ok=true). When the capability is unknown (ok=false, e.g. stdio paths), fall through to the existing feature-flag gate so existing behaviour is preserved. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/http/handler_test.go | 41 +++++++++++++++++++++++++ pkg/inventory/registry.go | 45 +++++++++++++++++++++------- pkg/inventory/registry_test.go | 55 +++++++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 11 deletions(-) diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go index a36469133c..4f697ee0cb 100644 --- a/pkg/http/handler_test.go +++ b/pkg/http/handler_test.go @@ -904,3 +904,44 @@ func TestInsidersRoutePreservesUIMeta(t *testing.T) { require.False(t, plainEnabled, "FF should be off for non-insiders ctx") require.Len(t, plainTools, 1) } + +// TestUIMetaStrippedWhenClientLacksCapability verifies that even on the +// /insiders path (where the feature flag is on), UI metadata is stripped from +// tools/list responses when the client did NOT advertise the +// io.modelcontextprotocol/ui extension capability. Per the 2026-01-26 MCP +// Apps spec, servers SHOULD check client capabilities before exposing +// UI-enabled tools. +func TestUIMetaStrippedWhenClientLacksCapability(t *testing.T) { + const uiURI = "ui://test/widget" + uiTool := mockTool("with_ui", "repos", true) + uiTool.Tool.Meta = mcp.Meta{"ui": map[string]any{"resourceUri": uiURI}} + + checker := createHTTPFeatureChecker(nil, false) + build := func() *inventory.Inventory { + inv, err := inventory.NewBuilder(). + SetTools([]inventory.ServerTool{uiTool}). + WithFeatureChecker(checker). + WithToolsets([]string{"all"}). + Build() + require.NoError(t, err) + return inv + } + + insidersCtx := ghcontext.WithInsidersMode(context.Background(), true) + withoutUICap := ghcontext.WithUISupport(insidersCtx, false) + withUICap := ghcontext.WithUISupport(insidersCtx, true) + + stripped := build().ToolsForRegistration(withoutUICap) + require.Len(t, stripped, 1) + require.Nil(t, stripped[0].Tool.Meta["ui"], "_meta.ui should be stripped when client lacks UI capability") + + preserved := build().ToolsForRegistration(withUICap) + require.Len(t, preserved, 1) + require.NotNil(t, preserved[0].Tool.Meta["ui"], "_meta.ui should be preserved when client advertises UI capability") + require.Equal(t, uiURI, preserved[0].Tool.Meta["ui"].(map[string]any)["resourceUri"]) + + // Unknown capability falls through to the FF gate (insiders ctx → kept). + unknown := build().ToolsForRegistration(insidersCtx) + require.Len(t, unknown, 1) + require.NotNil(t, unknown[0].Tool.Meta["ui"], "_meta.ui should be preserved when capability is unknown and FF is on") +} diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go index d147cbfc66..b8a70a3420 100644 --- a/pkg/inventory/registry.go +++ b/pkg/inventory/registry.go @@ -7,6 +7,7 @@ import ( "slices" "sort" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" ) @@ -169,26 +170,50 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string { // ToolsForRegistration returns AvailableTools(ctx) post-processed exactly as // RegisterTools would expose them: with MCP Apps UI metadata stripped when -// the remote_mcp_ui_apps feature flag is not enabled in ctx. Useful for -// documentation generators and diagnostics that need the same view of the -// tool surface the server would register. +// the client cannot consume it. Useful for documentation generators and +// diagnostics that need the same view of the tool surface the server would +// register. +// +// The strip applies when EITHER of the following is true: +// +// - The remote_mcp_ui_apps feature flag is not enabled in ctx (server-side gate). +// - The client explicitly did not advertise the io.modelcontextprotocol/ui +// extension capability (per the 2026-01-26 MCP Apps spec, servers SHOULD +// check client capabilities before exposing UI-enabled tools). When the +// capability is unknown (e.g. stdio paths that do not populate the +// context flag) the feature-flag gate is the sole source of truth. func (r *Inventory) ToolsForRegistration(ctx context.Context) []ServerTool { tools := r.AvailableTools(ctx) - if !r.checkFeatureFlag(ctx, mcpAppsFeatureFlag) { + if shouldStripMCPAppsMetadata(ctx, r.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { tools = stripMCPAppsMetadata(tools) } return tools } +// shouldStripMCPAppsMetadata centralises the strip decision so the same logic +// is exercised by tests and by RegisterTools. +func shouldStripMCPAppsMetadata(ctx context.Context, featureFlagEnabled bool) bool { + if !featureFlagEnabled { + return true + } + // Feature flag is on. Respect the client capability if it is known. + if supported, ok := ghcontext.HasUISupport(ctx); ok && !supported { + return true + } + return false +} + // RegisterTools registers all available tools with the server using the provided dependencies. -// The context is used for feature flag evaluation. +// The context is used for feature flag evaluation and client capability checks. // // MCP Apps UI metadata (`_meta.ui`) is stripped from the registered tools -// when the MCP Apps feature flag is not enabled for this request. The strip -// happens here (rather than at Build() time) so the per-request context is -// in scope — HTTP feature checkers that read insiders mode or user identity -// from ctx would otherwise see context.Background() and falsely report the -// flag off, even when the actual request arrived on the /insiders route. +// when either the MCP Apps feature flag is not enabled for this request, or +// the client did not advertise the io.modelcontextprotocol/ui extension. The +// strip happens here (rather than at Build() time) so the per-request +// context is in scope — HTTP feature checkers that read insiders mode or +// user identity from ctx would otherwise see context.Background() and +// falsely report the flag off, even when the actual request arrived on the +// /insiders route. func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) { for _, tool := range r.ToolsForRegistration(ctx) { tool.RegisterFunc(s, deps) diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go index 372f756023..20b1fb718c 100644 --- a/pkg/inventory/registry_test.go +++ b/pkg/inventory/registry_test.go @@ -6,6 +6,7 @@ import ( "fmt" "testing" + ghcontext "github.com/github/github-mcp-server/pkg/context" "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/require" ) @@ -2211,7 +2212,7 @@ func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) [ toolCopy := tools[i].Tool out = append(out, &toolCopy) } - if !reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag) { + if shouldStripMCPAppsMetadata(ctx, reg.checkFeatureFlag(ctx, mcpAppsFeatureFlag)) { for _, tt := range out { delete(tt.Meta, "ui") if len(tt.Meta) == 0 { @@ -2221,3 +2222,55 @@ func captureRegisteredTools(ctx context.Context, t *testing.T, reg *Inventory) [ } return out } + +// TestShouldStripMCPAppsMetadata verifies the spec-conformant strip decision: +// strip when the feature flag is off, OR when the client explicitly does not +// advertise the io.modelcontextprotocol/ui extension. +func TestShouldStripMCPAppsMetadata(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupCtx func() context.Context + ffOn bool + want bool + }{ + { + name: "FF off, capability unknown -> strip", + setupCtx: context.Background, + ffOn: false, + want: true, + }, + { + name: "FF off, capability present -> strip (FF wins)", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) }, + ffOn: false, + want: true, + }, + { + name: "FF on, capability unknown -> keep", + setupCtx: context.Background, + ffOn: true, + want: false, + }, + { + name: "FF on, capability present -> keep", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), true) }, + ffOn: true, + want: false, + }, + { + name: "FF on, capability explicitly absent -> strip", + setupCtx: func() context.Context { return ghcontext.WithUISupport(context.Background(), false) }, + ffOn: true, + want: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := shouldStripMCPAppsMetadata(tc.setupCtx(), tc.ffOn) + require.Equal(t, tc.want, got) + }) + } +} From 69f786b87c70e3d06d238e82dc8b149a82d9972d Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 29 May 2026 16:24:11 +0100 Subject: [PATCH 02/34] Align UI resources with MCP Apps 2026-01-26 polish recommendations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Explicitly set prefersBorder on every UI resource — false for the get_me profile card, true for the issue/PR write forms — since hosts' defaults vary. * Declare an empty csp on issue_write_ui and pr_write_ui to document that they need no external origins. * Point spec link comment at the stable 2026-01-26 location. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/github/ui_resources.go | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index c41d2ac3f1..ab3ebfd163 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -10,6 +10,9 @@ import ( // These are static resources (not templates) that serve HTML content for // MCP App-enabled tools. The HTML is built from React/Primer components // in the ui/ directory using `script/build-ui`. +// +// Resource metadata follows the stable 2026-01-26 MCP Apps spec: +// https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx func RegisterUIResources(s *mcp.Server) { // Register the get_me UI resource s.AddResource( @@ -27,14 +30,14 @@ func RegisterUIResources(s *mcp.Server) { URI: GetMeUIResourceURI, MIMEType: MCPAppMIMEType, Text: html, - // MCP Apps UI metadata - CSP configuration to allow loading GitHub avatars - // See: https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/draft/apps.mdx Meta: mcp.Meta{ "ui": map[string]any{ + // Allow loading images from GitHub's avatar CDN. "csp": map[string]any{ - // Allow loading images from GitHub's avatar CDN "resourceDomains": []string{"https://avatars.githubusercontent.com"}, }, + // Profile card renders inline within chat without a host border. + "prefersBorder": false, }, }, }, @@ -59,6 +62,14 @@ func RegisterUIResources(s *mcp.Server) { URI: IssueWriteUIResourceURI, MIMEType: MCPAppMIMEType, Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + // No external origins required; documents the secure default. + "csp": map[string]any{}, + // Form surface benefits from a host-provided border. + "prefersBorder": true, + }, + }, }, }, }, nil @@ -81,6 +92,12 @@ func RegisterUIResources(s *mcp.Server) { URI: PullRequestWriteUIResourceURI, MIMEType: MCPAppMIMEType, Text: html, + Meta: mcp.Meta{ + "ui": map[string]any{ + "csp": map[string]any{}, + "prefersBorder": true, + }, + }, }, }, }, nil From c0dca1f1b8c25dc72d70dc7fb5433fb790324cb5 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Fri, 29 May 2026 16:29:15 +0100 Subject: [PATCH 03/34] Adopt MCP Apps 2026-01-26 view-side capabilities * Declare appCapabilities.availableDisplayModes (defaults to ["inline"]) during initialization, as required by the new spec. * Track McpUiHostContext (and its updates via onhostcontextchanged) and thread it into AppProvider, which now picks up host-supplied theme + CSS style variables and projects them onto the root element so Primer components inherit host theming. * Add setModelContext and openLink helpers to useMcpApp. issue-write and pr-write call setModelContext on a successful submission so the agent has the new entity in its next-turn context; get-me uses openLink for the profile's external blog link. The pinned @modelcontextprotocol/ext-apps ^1.7.2 was already resolved to 1.7.2 in the lockfile, so no dependency bump is required for the new HostContext / openLink / updateModelContext APIs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ui/src/apps/get-me/App.tsx | 77 +++++++++++++++++++------------ ui/src/apps/issue-write/App.tsx | 25 ++++++++-- ui/src/apps/pr-write/App.tsx | 23 ++++++--- ui/src/components/AppProvider.tsx | 42 +++++++++++++---- ui/src/hooks/useMcpApp.ts | 74 +++++++++++++++++++++++++++-- 5 files changed, 187 insertions(+), 54 deletions(-) diff --git a/ui/src/apps/get-me/App.tsx b/ui/src/apps/get-me/App.tsx index a20aae17c5..c181fcab90 100644 --- a/ui/src/apps/get-me/App.tsx +++ b/ui/src/apps/get-me/App.tsx @@ -1,4 +1,5 @@ import { StrictMode, useState } from "react"; +import type React from "react"; import { createRoot } from "react-dom/client"; import { Avatar, Box, Text, Link, Heading, Spinner } from "@primer/react"; import { @@ -62,8 +63,20 @@ function AvatarWithFallback({ src, login, size }: { src?: string; login: string; ); } -function UserCard({ user }: { user: UserData }) { +function UserCard({ + user, + onOpenLink, +}: { + user: UserData; + onOpenLink?: (url: string) => void; +}) { const d = user.details || {}; + const handleClick = + onOpenLink && + ((url: string) => (e: React.MouseEvent) => { + e.preventDefault(); + onOpenLink(url); + }); return ( - {d.blog} + + {d.blog} + )} {d.email && ( @@ -140,41 +159,39 @@ function UserCard({ user }: { user: UserData }) { } function GetMeApp() { - const { error, toolResult } = useMcpApp({ + const { error, toolResult, hostContext, openLink } = useMcpApp({ appName: "github-mcp-server-get-me", }); - if (error) { - return Error: {error.message}; - } - - if (!toolResult) { - return ( - - - Loading user data... - - ); - } - - // Parse user data from tool result - const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text"); - if (!textContent || !("text" in textContent)) { - return No user data in response; - } + const content = (() => { + if (error) { + return Error: {error.message}; + } + if (!toolResult) { + return ( + + + Loading user data... + + ); + } + const textContent = toolResult.content?.find((c: { type: string }) => c.type === "text"); + if (!textContent || !("text" in textContent)) { + return No user data in response; + } + try { + const userData = JSON.parse(textContent.text as string) as UserData; + return void openLink(url)} />; + } catch { + return Failed to parse user data; + } + })(); - try { - const userData = JSON.parse(textContent.text as string) as UserData; - return ; - } catch { - return Failed to parse user data; - } + return {content}; } createRoot(document.getElementById("root")!).render( - - - + ); diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index de72b0a78a..863543fc14 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -121,7 +121,7 @@ function CreateIssueApp() { const [error, setError] = useState(null); const [successIssue, setSuccessIssue] = useState(null); - const { app, error: appError, toolInput, callTool } = useMcpApp({ + const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ appName: "github-mcp-server-issue-write", }); @@ -181,6 +181,19 @@ function CreateIssueApp() { try { const issueData = JSON.parse(textContent.text as string); setSuccessIssue(issueData); + // Per the MCP Apps 2026-01-26 spec, push the created/updated issue + // into the model's context so subsequent agent turns have it. + void setModelContext({ + structuredContent: issueData, + content: [ + { + type: "text", + text: isUpdateMode + ? `Issue #${issueNumber} in ${owner}/${repo} was updated by the user via the issue-write view.` + : `A new issue was created in ${owner}/${repo} by the user via the issue-write view.`, + }, + ], + }); } catch { setSuccessIssue({ title, body }); } @@ -191,8 +204,9 @@ function CreateIssueApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, isUpdateMode, issueNumber, callTool]); + }, [title, body, owner, repo, isUpdateMode, issueNumber, callTool, setModelContext]); + const body_node = (() => { if (appError) { return ( @@ -307,12 +321,13 @@ function CreateIssueApp() { ); + })(); + + return {body_node}; } createRoot(document.getElementById("root")!).render( - - - + ); diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index f5ddbdf29d..bfefdbede0 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -126,7 +126,7 @@ function CreatePRApp() { const [isDraft, setIsDraft] = useState(false); const [maintainerCanModify, setMaintainerCanModify] = useState(true); - const { app, error: appError, toolInput, callTool } = useMcpApp({ + const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ appName: "github-mcp-server-create-pull-request", }); @@ -175,6 +175,17 @@ function CreatePRApp() { if (textContent && textContent.type === "text" && textContent.text) { const prData = JSON.parse(textContent.text); setSuccessPR(prData); + // Push the new PR into the model context so subsequent agent + // turns can reference it (MCP Apps 2026-01-26 ui/update-model-context). + void setModelContext({ + structuredContent: prData, + content: [ + { + type: "text", + text: `A new pull request was created in ${owner}/${repo} by the user via the create-pull-request view.`, + }, + ], + }); } } } catch (e) { @@ -182,11 +193,11 @@ function CreatePRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, callTool]); + }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, callTool, setModelContext]); if (successPR) { return ( - + ); @@ -194,7 +205,7 @@ function CreatePRApp() { if (!app && !appError) { return ( - + @@ -204,14 +215,14 @@ function CreatePRApp() { if (appError) { return ( - + {appError.message} ); } return ( - + { - // Set up theme data attributes for proper Primer theming - const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; - const colorMode = prefersDark ? "dark" : "light"; + // Prefer the host-supplied theme; fall back to the OS preference. + const colorMode = + hostTheme === "light" || hostTheme === "dark" + ? hostTheme + : window.matchMedia("(prefers-color-scheme: dark)").matches + ? "dark" + : "light"; document.body.setAttribute("data-color-mode", colorMode); document.body.setAttribute("data-light-theme", "light"); document.body.setAttribute("data-dark-theme", "dark"); - }, []); + }, [hostTheme]); + + // Project the host's standardized CSS variables onto the root so child + // components can consume them via `var(--color-...)`. We rely on Primer's + // own defaults when the host does not supply variables. + const styleVars = useMemo(() => { + if (!hostVariables) return undefined; + const out: Record = {}; + for (const [key, value] of Object.entries(hostVariables)) { + if (typeof value === "string") out[key] = value; + } + return out as CSSProperties; + }, [hostVariables]); + + const colorMode = + hostTheme === "light" || hostTheme === "dark" ? hostTheme : "auto"; return ( - + - + {children} diff --git a/ui/src/hooks/useMcpApp.ts b/ui/src/hooks/useMcpApp.ts index 54bfa791a7..b060ea6ee2 100644 --- a/ui/src/hooks/useMcpApp.ts +++ b/ui/src/hooks/useMcpApp.ts @@ -1,11 +1,23 @@ import { useApp as useExtApp } from "@modelcontextprotocol/ext-apps/react"; -import type { App } from "@modelcontextprotocol/ext-apps"; +import type { + App, + McpUiDisplayMode, + McpUiHostContext, + McpUiUpdateModelContextRequest, +} from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useEffect } from "react"; interface UseMcpAppOptions { appName: string; appVersion?: string; + /** + * Display modes this view supports. Per the MCP Apps 2026-01-26 spec, a + * view MUST declare every display mode it supports during initialization. + * Defaults to ["inline"] which is the only mode the bundled github-mcp-server + * views currently render. + */ + availableDisplayModes?: McpUiDisplayMode[]; onToolResult?: (result: CallToolResult) => void; onToolInput?: (input: Record) => void; } @@ -15,21 +27,38 @@ interface UseMcpAppReturn { error: Error | null; toolResult: CallToolResult | null; toolInput: Record | null; + hostContext: McpUiHostContext | undefined; callTool: (name: string, args: Record) => Promise; + /** + * Sends `ui/update-model-context` so the agent's next turn sees the + * supplied structured content / blocks. No-op when the app isn't connected. + */ + setModelContext: ( + params: McpUiUpdateModelContextRequest["params"] + ) => Promise; + /** + * Sends `ui/open-link` so the host opens an external URL in the user's + * browser. Falls back to `window.open` when the app isn't connected. + */ + openLink: (url: string) => Promise; } export function useMcpApp({ appName, appVersion = "1.0.0", + availableDisplayModes = ["inline"], onToolResult, onToolInput, }: UseMcpAppOptions): UseMcpAppReturn { const [toolResult, setToolResult] = useState(null); const [toolInput, setToolInput] = useState | null>(null); + const [hostContext, setHostContext] = useState(undefined); + // The SDK's autoResize=true installs a ResizeObserver that emits + // `ui/notifications/size-changed` automatically; no manual wiring needed. const { app, error } = useExtApp({ appInfo: { name: appName, version: appVersion }, - capabilities: {}, + capabilities: { availableDisplayModes }, autoResize: true, strict: import.meta.env.DEV, onAppCreated: (app) => { @@ -42,10 +71,19 @@ export function useMcpApp({ setToolInput(args); onToolInput?.(args); }; + app.onhostcontextchanged = (params) => { + setHostContext((prev) => ({ ...(prev ?? {}), ...params })); + }; app.onerror = console.error; }, }); + useEffect(() => { + if (!app) return; + const initial = app.getHostContext(); + if (initial) setHostContext(initial); + }, [app]); + const callTool = useCallback( async (name: string, args: Record) => { if (!app) throw new Error("App not connected"); @@ -54,5 +92,33 @@ export function useMcpApp({ [app] ); - return { app, error, toolResult, toolInput, callTool }; + const setModelContext = useCallback( + async (params) => { + if (!app) return; + await app.updateModelContext(params); + }, + [app] + ); + + const openLink = useCallback( + async (url) => { + if (!app) { + window.open(url, "_blank", "noopener,noreferrer"); + return; + } + await app.openLink({ url }); + }, + [app] + ); + + return { + app, + error, + toolResult, + toolInput, + hostContext, + callTool, + setModelContext, + openLink, + }; } From 5d47ccc32fbf5358e45539fb4b29791fbc1ef535 Mon Sep 17 00:00:00 2001 From: Sam Morrow Date: Tue, 17 Mar 2026 14:47:57 +0100 Subject: [PATCH 04/34] feat: add create_project and create_iteration_field methods to projects_write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new methods to the consolidated projects_write tool: - create_project: creates a new GitHub ProjectsV2 for a user or org - create_iteration_field: adds an iteration field to an existing project Changes addressing review feedback: - Validate owner_type is exactly 'user' or 'org' in create_project - Use resolveProjectNodeID (GraphQL) instead of getProjectNodeID (REST) to avoid HTTP response body leaks - Add omitempty to Iterations JSON tag - Rename iterations item field startDate to start_date for consistency - Validate iteration elements instead of silently skipping invalid ones - Use explicit response structs with snake_case JSON tags - Add test for auto-detected owner_type in create_iteration_field - Use stubExporters() in test deps for nil-safety Co-authored-by: João Doria de Souza Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 12 +- pkg/github/__toolsnaps__/projects_write.snap | 56 ++- pkg/github/projects.go | 323 ++++++++++++- pkg/github/projects_test.go | 2 +- pkg/github/projects_v2_test.go | 457 +++++++++++++++++++ pkg/github/toolset_instructions.go | 4 + 6 files changed, 832 insertions(+), 22 deletions(-) create mode 100644 pkg/github/projects_v2_test.go diff --git a/README.md b/README.md index 9082a3b642..0cb16df768 100644 --- a/README.md +++ b/README.md @@ -1028,22 +1028,26 @@ The following sets of tools are available: - `project_number`: The project's number. Required for 'list_project_fields', 'list_project_items', and 'list_project_status_updates' methods. (number, optional) - `query`: Filter/query string. For list_projects: filter by title text and state (e.g. "roadmap is:open"). For list_project_items: advanced filtering using GitHub's project filtering syntax. (string, optional) -- **projects_write** - Modify GitHub Project items +- **projects_write** - Manage GitHub Projects - **Required OAuth Scopes**: `project` - `body`: The body of the status update (markdown). Used for 'create_project_status_update' method. (string, optional) + - `field_name`: The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method. (string, optional) - `issue_number`: The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) - `item_id`: The project item ID. Required for 'update_project_item' and 'delete_project_item' methods. (number, optional) - `item_owner`: The owner (user or organization) of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) - `item_repo`: The name of the repository containing the issue or pull request. Required for 'add_project_item' method. (string, optional) - `item_type`: The item's type, either issue or pull_request. Required for 'add_project_item' method. (string, optional) + - `iteration_duration`: Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method. (number, optional) + - `iterations`: Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases. (object[], optional) - `method`: The method to execute (string, required) - `owner`: The project owner (user or organization login). The name is not case sensitive. (string, required) - - `owner_type`: Owner type (user or org). If not provided, will be automatically detected. (string, optional) - - `project_number`: The project's number. (number, required) + - `owner_type`: Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected. (string, optional) + - `project_number`: The project's number. Required for all methods except 'create_project'. (number, optional) - `pull_request_number`: The pull request number (use when item_type is 'pull_request' for 'add_project_item' method). Provide either issue_number or pull_request_number. (number, optional) - - `start_date`: The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) + - `start_date`: Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods. (string, optional) - `status`: The status of the project. Used for 'create_project_status_update' method. (string, optional) - `target_date`: The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method. (string, optional) + - `title`: The project title. Required for 'create_project' method. (string, optional) - `updated_field`: Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {"id": 123456, "value": "New Value"}. Required for 'update_project_item' method. (object, optional) diff --git a/pkg/github/__toolsnaps__/projects_write.snap b/pkg/github/__toolsnaps__/projects_write.snap index f6d3197b84..6c9d349f63 100644 --- a/pkg/github/__toolsnaps__/projects_write.snap +++ b/pkg/github/__toolsnaps__/projects_write.snap @@ -1,15 +1,19 @@ { "annotations": { "destructiveHint": true, - "title": "Modify GitHub Project items" + "title": "Manage GitHub Projects" }, - "description": "Add, update, or delete project items, or create status updates in a GitHub Project.", + "description": "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields.", "inputSchema": { "properties": { "body": { "description": "The body of the status update (markdown). Used for 'create_project_status_update' method.", "type": "string" }, + "field_name": { + "description": "The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method.", + "type": "string" + }, "issue_number": { "description": "The issue number (use when item_type is 'issue' for 'add_project_item' method). Provide either issue_number or pull_request_number.", "type": "number" @@ -34,13 +38,46 @@ ], "type": "string" }, + "iteration_duration": { + "description": "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.", + "type": "number" + }, + "iterations": { + "description": "Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases.", + "items": { + "additionalProperties": false, + "properties": { + "duration": { + "description": "Duration in days", + "type": "number" + }, + "start_date": { + "description": "Start date in YYYY-MM-DD format", + "type": "string" + }, + "title": { + "description": "Iteration title (e.g. 'Sprint 1')", + "type": "string" + } + }, + "required": [ + "title", + "start_date", + "duration" + ], + "type": "object" + }, + "type": "array" + }, "method": { "description": "The method to execute", "enum": [ "add_project_item", "update_project_item", "delete_project_item", - "create_project_status_update" + "create_project_status_update", + "create_project", + "create_iteration_field" ], "type": "string" }, @@ -49,7 +86,7 @@ "type": "string" }, "owner_type": { - "description": "Owner type (user or org). If not provided, will be automatically detected.", + "description": "Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected.", "enum": [ "user", "org" @@ -57,7 +94,7 @@ "type": "string" }, "project_number": { - "description": "The project's number.", + "description": "The project's number. Required for all methods except 'create_project'.", "type": "number" }, "pull_request_number": { @@ -65,7 +102,7 @@ "type": "number" }, "start_date": { - "description": "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + "description": "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.", "type": "string" }, "status": { @@ -83,6 +120,10 @@ "description": "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", "type": "string" }, + "title": { + "description": "The project title. Required for 'create_project' method.", + "type": "string" + }, "updated_field": { "description": "Object consisting of the ID of the project field to update and the new value for the field. To clear the field, set value to null. Example: {\"id\": 123456, \"value\": \"New Value\"}. Required for 'update_project_item' method.", "type": "object" @@ -90,8 +131,7 @@ }, "required": [ "method", - "owner", - "project_number" + "owner" ], "type": "object" }, diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 7c383c1111..9c7310c0ff 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -45,6 +45,8 @@ const ( projectsMethodListProjectStatusUpdates = "list_project_status_updates" projectsMethodGetProjectStatusUpdate = "get_project_status_update" projectsMethodCreateProjectStatusUpdate = "create_project_status_update" + projectsMethodCreateProject = "create_project" + projectsMethodCreateIterationField = "create_iteration_field" ) // GraphQL types for ProjectV2 status updates @@ -403,9 +405,9 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { ToolsetMetadataProjects, mcp.Tool{ Name: "projects_write", - Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Add, update, or delete project items, or create status updates in a GitHub Project."), + Description: t("TOOL_PROJECTS_WRITE_DESCRIPTION", "Create and manage GitHub Projects: create projects, add/update/delete items, create status updates, and add iteration fields."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Modify GitHub Project items"), + Title: t("TOOL_PROJECTS_WRITE_USER_TITLE", "Manage GitHub Projects"), ReadOnlyHint: false, DestructiveHint: jsonschema.Ptr(true), }, @@ -420,11 +422,13 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { projectsMethodUpdateProjectItem, projectsMethodDeleteProjectItem, projectsMethodCreateProjectStatusUpdate, + projectsMethodCreateProject, + projectsMethodCreateIterationField, }, }, "owner_type": { Type: "string", - Description: "Owner type (user or org). If not provided, will be automatically detected.", + Description: "Owner type (user or org). Required for 'create_project' method. If not provided for other methods, will be automatically detected.", Enum: []any{"user", "org"}, }, "owner": { @@ -433,7 +437,11 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "project_number": { Type: "number", - Description: "The project's number.", + Description: "The project's number. Required for all methods except 'create_project'.", + }, + "title": { + Type: "string", + Description: "The project title. Required for 'create_project' method.", }, "item_id": { Type: "number", @@ -475,14 +483,45 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { }, "start_date": { Type: "string", - Description: "The start date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", + Description: "Start date in YYYY-MM-DD format. Used for 'create_project_status_update' and 'create_iteration_field' methods.", }, "target_date": { Type: "string", Description: "The target date of the status update in YYYY-MM-DD format. Used for 'create_project_status_update' method.", }, + "field_name": { + Type: "string", + Description: "The name of the iteration field (e.g. 'Sprint'). Required for 'create_iteration_field' method.", + }, + "iteration_duration": { + Type: "number", + Description: "Duration in days for iterations of the field (e.g. 7 for weekly, 14 for bi-weekly). Required for 'create_iteration_field' method.", + }, + "iterations": { + Type: "array", + Description: "Custom iterations for 'create_iteration_field' method. Only set this when you need iterations with varying durations, breaks between them, or specific titles. Otherwise omit it: GitHub auto-creates three iterations of 'iteration_duration' days starting on 'start_date', which is the right choice for most cases.", + Items: &jsonschema.Schema{ + Type: "object", + AdditionalProperties: &jsonschema.Schema{Not: &jsonschema.Schema{}}, + Properties: map[string]*jsonschema.Schema{ + "title": { + Type: "string", + Description: "Iteration title (e.g. 'Sprint 1')", + }, + "start_date": { + Type: "string", + Description: "Start date in YYYY-MM-DD format", + }, + "duration": { + Type: "number", + Description: "Duration in days", + }, + }, + Required: []string{"title", "start_date", "duration"}, + }, + }, }, - Required: []string{"method", "owner", "project_number"}, + Required: []string{"method", "owner"}, }, }, []scopes.Scope{scopes.Project}, @@ -502,17 +541,22 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } - projectNumber, err := RequiredInt(args, "project_number") + gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - client, err := deps.GetClient(ctx) + // create_project does not require project_number or a REST client + if method == projectsMethodCreateProject { + return createProject(ctx, gqlClient, owner, ownerType, args) + } + + projectNumber, err := RequiredInt(args, "project_number") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - gqlClient, err := deps.GetGQLClient(ctx) + client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -595,6 +639,8 @@ func ProjectsWrite(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultError(err.Error()), nil, nil } return createProjectStatusUpdate(ctx, gqlClient, owner, ownerType, projectNumber, body, status, startDate, targetDate) + case projectsMethodCreateIterationField: + return createIterationField(ctx, gqlClient, owner, ownerType, projectNumber, args) default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -1438,6 +1484,265 @@ func resolvePullRequestNodeID(ctx context.Context, gqlClient *githubv4.Client, o return query.Repository.PullRequest.ID, nil } +// createProject handles the create_project method for ProjectsWrite. +func createProject(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, args map[string]any) (*mcp.CallToolResult, any, error) { + if ownerType == "" { + return utils.NewToolResultError("owner_type is required for create_project"), nil, nil + } + if ownerType != "user" && ownerType != "org" { + return utils.NewToolResultError(fmt.Sprintf("invalid owner_type %q: must be \"user\" or \"org\"", ownerType)), nil, nil + } + + title, err := RequiredParam[string](args, "title") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + ownerID, err := getOwnerNodeID(ctx, gqlClient, owner, ownerType) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get owner ID: %v", err)), nil, nil + } + + var mutation struct { + CreateProjectV2 struct { + ProjectV2 struct { + ID string + Number int + Title string + URL string + } + } `graphql:"createProjectV2(input: $input)"` + } + + input := githubv4.CreateProjectV2Input{ + OwnerID: githubv4.ID(ownerID), + Title: githubv4.String(title), + } + + err = gqlClient.Mutate(ctx, &mutation, input, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create project: %v", err)), nil, nil + } + + result := struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + }{ + ID: mutation.CreateProjectV2.ProjectV2.ID, + Number: mutation.CreateProjectV2.ProjectV2.Number, + Title: mutation.CreateProjectV2.ProjectV2.Title, + URL: mutation.CreateProjectV2.ProjectV2.URL, + } + + return MarshalledTextResult(result), nil, nil +} + +// createIterationField handles the create_iteration_field method for ProjectsWrite. +// +// GitHub's GraphQL API requires two mutations to fully configure an iteration field: +// 1. createProjectV2Field creates the field with DataType=ITERATION (no schedule yet). +// 2. updateProjectV2Field sets the start date, duration, and optional named iterations. +// +// If step 2 fails, the field already exists with default settings and can be reconfigured +// by calling this method again (the create will fail with a duplicate-name error, which +// surfaces clearly) or by deleting the field via the GitHub UI. +func createIterationField(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string, projectNumber int, args map[string]any) (*mcp.CallToolResult, any, error) { + fieldName, err := RequiredParam[string](args, "field_name") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + duration, err := RequiredInt(args, "iteration_duration") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + startDateStr, err := RequiredParam[string](args, "start_date") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + projectID, err := resolveProjectNodeID(ctx, gqlClient, owner, ownerType, projectNumber) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get project ID: %v", err)), nil, nil + } + + // Step 1: Create the iteration field. + var createMutation struct { + CreateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"createProjectV2Field(input: $input)"` + } + + createInput := githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID(projectID), + DataType: githubv4.ProjectV2CustomFieldType("ITERATION"), + Name: githubv4.String(fieldName), + } + + err = gqlClient.Mutate(ctx, &createMutation, createInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to create iteration field: %v", err)), nil, nil + } + + fieldID := createMutation.CreateProjectV2Field.ProjectV2Field.ProjectV2IterationField.ID + + // Step 2: Configure the iteration field with start date and duration. + var updateMutation struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + } + + parsedStartDate, err := time.Parse("2006-01-02", startDateStr) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to parse start_date %s: %v", startDateStr, err)), nil, nil + } + + // GitHub's ProjectV2IterationFieldConfigurationInput requires `iterations` as a + // non-null array, so we always send at least an empty slice. When omitted, GitHub + // generates a default set of iterations from start_date and duration. + iterationsInput := []ProjectV2IterationFieldIterationInput{} + + if rawIterations, ok := args["iterations"].([]any); ok && len(rawIterations) > 0 { + for i, item := range rawIterations { + iterMap, ok := item.(map[string]any) + if !ok { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d] must be an object", i)), nil, nil + } + iterTitle, ok := iterMap["title"].(string) + if !ok || iterTitle == "" { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: title is required and must be a non-empty string", i)), nil, nil + } + iterStartDate, ok := iterMap["start_date"].(string) + if !ok || iterStartDate == "" { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: start_date is required and must be a non-empty string", i)), nil, nil + } + iterDuration, ok := iterMap["duration"].(float64) + if !ok || iterDuration <= 0 { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: duration is required and must be a positive number", i)), nil, nil + } + + parsedIterStartDate, err := time.Parse("2006-01-02", iterStartDate) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("iterations[%d]: failed to parse start_date %q: %v", i, iterStartDate, err)), nil, nil + } + + iterationsInput = append(iterationsInput, ProjectV2IterationFieldIterationInput{ + Title: githubv4.String(iterTitle), + StartDate: githubv4.Date{Time: parsedIterStartDate}, + Duration: githubv4.Int(int32(iterDuration)), //nolint:gosec // Iteration durations are small day counts + }) + } + } + + configInput := ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(int32(duration)), //nolint:gosec // Iteration durations are small day counts + StartDate: githubv4.Date{Time: parsedStartDate}, + Iterations: iterationsInput, + } + + updateInput := UpdateProjectV2FieldInput{ + FieldID: githubv4.ID(fieldID), + IterationConfiguration: &configInput, + } + + err = gqlClient.Mutate(ctx, &updateMutation, updateInput, nil) + if err != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to update iteration configuration: %v", err)), nil, nil + } + + field := updateMutation.UpdateProjectV2Field.ProjectV2Field.ProjectV2IterationField + iterResults := make([]map[string]any, 0, len(field.Configuration.Iterations)) + for _, iter := range field.Configuration.Iterations { + iterResults = append(iterResults, map[string]any{ + "id": iter.ID, + "title": iter.Title, + "start_date": iter.StartDate, + "duration": iter.Duration, + }) + } + + result := map[string]any{ + "id": field.ID, + "name": field.Name, + "configuration": map[string]any{ + "iterations": iterResults, + }, + } + + return MarshalledTextResult(result), nil, nil +} + +// getOwnerNodeID resolves a GitHub user or organization login to its GraphQL node ID. +func getOwnerNodeID(ctx context.Context, gqlClient *githubv4.Client, owner, ownerType string) (string, error) { + if ownerType == "org" { + var query struct { + Organization struct { + ID string + } `graphql:"organization(login: $login)"` + } + variables := map[string]any{ + "login": githubv4.String(owner), + } + err := gqlClient.Query(ctx, &query, variables) + return query.Organization.ID, err + } + + var query struct { + User struct { + ID string + } `graphql:"user(login: $login)"` + } + variables := map[string]any{ + "login": githubv4.String(owner), + } + err := gqlClient.Query(ctx, &query, variables) + return query.User.ID, err +} + +// UpdateProjectV2FieldInput is the GraphQL input for the updateProjectV2Field mutation. +// These types are defined locally because the pinned shurcooL/githubv4 release +// (v0.0.0-20240727222349) does not yet expose them. Upstream master now generates +// equivalent types, so this block can be removed when the dependency is next bumped. +type UpdateProjectV2FieldInput struct { + FieldID githubv4.ID `json:"fieldId"` + IterationConfiguration *ProjectV2IterationFieldConfigurationInput `json:"iterationConfiguration,omitempty"` +} + +// ProjectV2IterationFieldConfigurationInput is the GraphQL input for configuring an iteration field. +// GitHub's schema marks iterations as a required non-null list, so the field is not omitempty. +type ProjectV2IterationFieldConfigurationInput struct { + Duration githubv4.Int `json:"duration"` + StartDate githubv4.Date `json:"startDate"` + Iterations []ProjectV2IterationFieldIterationInput `json:"iterations"` +} + +// ProjectV2IterationFieldIterationInput is the GraphQL input for a single iteration definition. +type ProjectV2IterationFieldIterationInput struct { + StartDate githubv4.Date `json:"startDate"` + Duration githubv4.Int `json:"duration"` + Title githubv4.String `json:"title"` +} + // detectOwnerType attempts to detect the owner type by trying both user and org // Returns the detected type ("user" or "org") and any error encountered func detectOwnerType(ctx context.Context, client *github.Client, owner string, projectNumber int) (string, error) { diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index 306c74b41e..a9787298af 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -569,7 +569,7 @@ func Test_ProjectsWrite(t *testing.T) { assert.Contains(t, inputSchema.Properties, "issue_number") assert.Contains(t, inputSchema.Properties, "pull_request_number") assert.Contains(t, inputSchema.Properties, "updated_field") - assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner", "project_number"}) + assert.ElementsMatch(t, inputSchema.Required, []string{"method", "owner"}) // Verify DestructiveHint is set assert.NotNil(t, toolDef.Tool.Annotations) diff --git a/pkg/github/projects_v2_test.go b/pkg/github/projects_v2_test.go new file mode 100644 index 0000000000..69d4d6395f --- /dev/null +++ b/pkg/github/projects_v2_test.go @@ -0,0 +1,457 @@ +package github + +import ( + "context" + "encoding/json" + "net/http" + "testing" + "time" + + "github.com/github/github-mcp-server/internal/githubv4mock" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/shurcooL/githubv4" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ProjectsWrite_CreateProject(t *testing.T) { + t.Parallel() + + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success user project", func(t *testing.T) { + t.Parallel() + + mockedClient := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + struct { + User struct { + ID string + } `graphql:"user(login: $login)"` + }{}, + map[string]any{ + "login": githubv4.String("octocat"), + }, + githubv4mock.DataResponse(map[string]any{ + "user": map[string]any{ + "id": "U_octocat", + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2 struct { + ProjectV2 struct { + ID string + Number int + Title string + URL string + } + } `graphql:"createProjectV2(input: $input)"` + }{}, + githubv4.CreateProjectV2Input{ + OwnerID: githubv4.ID("U_octocat"), + Title: githubv4.String("New Project"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2": map[string]any{ + "projectV2": map[string]any{ + "id": "PVT_project123", + "number": 1, + "title": "New Project", + "url": "https://github.com/users/octocat/projects/1", + }, + }, + }), + ), + ) + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(mockedClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project", + "owner": "octocat", + "owner_type": "user", + "title": "New Project", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVT_project123", response["id"]) + assert.Equal(t, float64(1), response["number"]) + assert.Equal(t, "New Project", response["title"]) + assert.Equal(t, "https://github.com/users/octocat/projects/1", response["url"]) + }) + + t.Run("missing owner_type returns error", func(t *testing.T) { + t.Parallel() + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project", + "owner": "octocat", + "title": "New Project", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "owner_type is required") + }) + + t.Run("invalid owner_type returns error", func(t *testing.T) { + t.Parallel() + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(githubv4mock.NewMockedHTTPClient()), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_project", + "owner": "octocat", + "owner_type": "invalid", + "title": "New Project", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.True(t, result.IsError) + + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "invalid owner_type") + assert.Contains(t, textContent.Text, "must be") + }) +} + +// resolveProjectNodeIDOrgMatcher returns a GraphQL query matcher for resolving +// an org project node ID via resolveProjectNodeID. +func resolveProjectNodeIDOrgMatcher(owner string, projectNumber int, nodeID string) githubv4mock.Matcher { + return githubv4mock.NewQueryMatcher( + struct { + Organization struct { + ProjectV2 struct { + ID githubv4.ID + } `graphql:"projectV2(number: $projectNumber)"` + } `graphql:"organization(login: $owner)"` + }{}, + map[string]any{ + "owner": githubv4.String(owner), + "projectNumber": githubv4.Int(int32(projectNumber)), //nolint:gosec // test constant + }, + githubv4mock.DataResponse(map[string]any{ + "organization": map[string]any{ + "projectV2": map[string]any{ + "id": nodeID, + }, + }, + }), + ) +} + +func createFieldMatcher() githubv4mock.Matcher { + return githubv4mock.NewMutationMatcher( + struct { + CreateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"createProjectV2Field(input: $input)"` + }{}, + githubv4.CreateProjectV2FieldInput{ + ProjectID: githubv4.ID("PVT_project1"), + DataType: githubv4.ProjectV2CustomFieldType("ITERATION"), + Name: githubv4.String("Sprint"), + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "createProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + }, + }, + }), + ) +} + +func updateFieldIterationResponse() githubv4mock.GQLResponse { + return githubv4mock.DataResponse(map[string]any{ + "updateProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + "configuration": map[string]any{ + "iterations": []any{ + map[string]any{ + "id": "PVTI_iter1", + "title": "Sprint 1", + "startDate": "2025-01-20", + "duration": 7, + }, + }, + }, + }, + }, + }) +} + +func Test_ProjectsWrite_CreateIterationField(t *testing.T) { + t.Parallel() + + toolDef := ProjectsWrite(translations.NullTranslationHelper) + + t.Run("success with iterations", func(t *testing.T) { + t.Parallel() + + mockGQLClient := githubv4mock.NewMockedHTTPClient( + resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), + createFieldMatcher(), + githubv4mock.NewMutationMatcher( + struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + }{}, + UpdateProjectV2FieldInput{ + FieldID: githubv4.ID("PVTIF_field1"), + IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(7), + StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, + Iterations: []ProjectV2IterationFieldIterationInput{ + { + Title: githubv4.String("Sprint 1"), + StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, + Duration: githubv4.Int(7), + }, + }, + }, + }, + nil, + updateFieldIterationResponse(), + ), + ) + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(mockGQLClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_iteration_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_name": "Sprint", + "iteration_duration": float64(7), + "start_date": "2025-01-20", + "iterations": []any{ + map[string]any{ + "title": "Sprint 1", + "start_date": "2025-01-20", + "duration": float64(7), + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTIF_field1", response["id"]) + }) + + t.Run("success without iterations", func(t *testing.T) { + t.Parallel() + + mockGQLClient := githubv4mock.NewMockedHTTPClient( + resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), + createFieldMatcher(), + githubv4mock.NewMutationMatcher( + struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + }{}, + UpdateProjectV2FieldInput{ + FieldID: githubv4.ID("PVTIF_field1"), + IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(7), + StartDate: githubv4.Date{Time: time.Date(2025, 1, 20, 0, 0, 0, 0, time.UTC)}, + Iterations: []ProjectV2IterationFieldIterationInput{}, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + "configuration": map[string]any{ + "iterations": []any{}, + }, + }, + }, + }), + ), + ) + + deps := BaseDeps{ + GQLClient: githubv4.NewClient(mockGQLClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_iteration_field", + "owner": "octo-org", + "owner_type": "org", + "project_number": float64(1), + "field_name": "Sprint", + "iteration_duration": float64(7), + "start_date": "2025-01-20", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTIF_field1", response["id"]) + }) + + t.Run("success with auto-detected owner_type", func(t *testing.T) { + t.Parallel() + + // detectOwnerType uses REST to probe user first, then org + mockRESTClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetUsersProjectsV2ByUsernameByProject: mockResponse(t, http.StatusNotFound, nil), + GetOrgsProjectsV2ByProject: mockResponse(t, http.StatusOK, map[string]any{ + "id": 1, + "node_id": "PVT_project1", + "title": "Org Project", + }), + }) + + mockGQLClient := githubv4mock.NewMockedHTTPClient( + resolveProjectNodeIDOrgMatcher("octo-org", 1, "PVT_project1"), + createFieldMatcher(), + githubv4mock.NewMutationMatcher( + struct { + UpdateProjectV2Field struct { + ProjectV2Field struct { + ProjectV2IterationField struct { + ID string + Name string + Configuration struct { + Iterations []struct { + ID string + Title string + StartDate string + Duration int + } + } + } `graphql:"... on ProjectV2IterationField"` + } + } `graphql:"updateProjectV2Field(input: $input)"` + }{}, + UpdateProjectV2FieldInput{ + FieldID: githubv4.ID("PVTIF_field1"), + IterationConfiguration: &ProjectV2IterationFieldConfigurationInput{ + Duration: githubv4.Int(14), + StartDate: githubv4.Date{Time: time.Date(2025, 2, 1, 0, 0, 0, 0, time.UTC)}, + Iterations: []ProjectV2IterationFieldIterationInput{}, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "updateProjectV2Field": map[string]any{ + "projectV2Field": map[string]any{ + "id": "PVTIF_field1", + "name": "Sprint", + "configuration": map[string]any{ + "iterations": []any{}, + }, + }, + }, + }), + ), + ) + + deps := BaseDeps{ + Client: mustNewGHClient(t, mockRESTClient), + GQLClient: githubv4.NewClient(mockGQLClient), + Obsv: stubExporters(), + } + handler := toolDef.Handler(deps) + request := createMCPRequest(map[string]any{ + "method": "create_iteration_field", + "owner": "octo-org", + "project_number": float64(1), + "field_name": "Sprint", + "iteration_duration": float64(14), + "start_date": "2025-02-01", + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + var response map[string]any + err = json.Unmarshal([]byte(textContent.Text), &response) + require.NoError(t, err) + assert.Equal(t, "PVTIF_field1", response["id"]) + }) +} diff --git a/pkg/github/toolset_instructions.go b/pkg/github/toolset_instructions.go index bc9da4e65c..ba6659612a 100644 --- a/pkg/github/toolset_instructions.go +++ b/pkg/github/toolset_instructions.go @@ -39,6 +39,10 @@ func generateProjectsToolsetInstructions(_ *inventory.Inventory) string { Workflow: 1) list_project_fields (get field IDs), 2) list_project_items (with pagination), 3) optional updates. +Project lifecycle: Use create_project to create a new ProjectsV2 for a user or organization (requires owner_type and title). Returns the new project's id, number, title, and url; pass the returned number as project_number to subsequent project tools. + +Iteration fields: Use create_iteration_field to add a new ITERATION field (e.g. "Sprint") to an existing project. Required: field_name, iteration_duration (days), start_date (YYYY-MM-DD). Only pass the iterations array when iterations need varying durations, breaks between them, or specific titles; otherwise omit it and GitHub creates three default iterations of iteration_duration days starting on start_date. + Status updates: Use list_project_status_updates to read recent project status updates (newest first). Use get_project_status_update with a node ID to get a single update. Use create_project_status_update to create a new status update for a project. Field usage: From 3a4c660033187435c838a46147d9346a35ac5952 Mon Sep 17 00:00:00 2001 From: Omid Mogasemi Date: Fri, 20 Mar 2026 19:02:46 +0000 Subject: [PATCH 05/34] fix: restore thread id in get_review_comments response --- pkg/github/minimal_types.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index ff4149a225..a93d29ead5 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -1547,7 +1547,7 @@ type MinimalReviewComment struct { // MinimalReviewThread is the trimmed output type for PR review thread objects. type MinimalReviewThread struct { - ID string + ID string `json:"id"` IsResolved bool `json:"is_resolved"` IsOutdated bool `json:"is_outdated"` IsCollapsed bool `json:"is_collapsed"` From 29a86780bf104bf91b5cd3ddfa2579564a68eab6 Mon Sep 17 00:00:00 2001 From: John CSA <103165870+jluocsa@users.noreply.github.com> Date: Sat, 23 May 2026 11:43:02 -0700 Subject: [PATCH 06/34] docs: add installation guides for Zed and OpenCode Adds two new installation guides under docs/installation-guides/ covering MCP host applications that are not yet documented: - install-zed.md: covers Zed's 'context_servers' settings key (command + args shape), the official GitHub MCP extension as an easier alternative, remote/local setup, the 'mcp::' permission key format introduced in Zed v0.224.0, and OAuth-vs-PAT trade-offs. - install-opencode.md: covers OpenCode's 'mcp' config block (type-discriminated local/remote, command-as-array, 'environment' instead of 'env'), the 'oauth: false' opt-out needed when using a PAT, the '{env:VAR}' interpolation pattern, and the per-agent tool-gating pattern recommended for token-heavy servers like GitHub. Also adds both hosts to: - docs/installation-guides/README.md installation-guides index and the support-by-host-application table. - README.md 'Install in other MCP hosts' and 'Install in Other MCP Hosts' lists. Closes #2531. --- README.md | 4 + docs/installation-guides/README.md | 4 + docs/installation-guides/install-opencode.md | 154 +++++++++++++++++++ docs/installation-guides/install-zed.md | 103 +++++++++++++ 4 files changed, 265 insertions(+) create mode 100644 docs/installation-guides/install-opencode.md create mode 100644 docs/installation-guides/install-zed.md diff --git a/README.md b/README.md index 0cb16df768..495eb98992 100644 --- a/README.md +++ b/README.md @@ -86,7 +86,9 @@ Alternatively, to manually configure VS Code, choose the appropriate JSON block - **[Claude Applications](/docs/installation-guides/install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI - **[Codex](/docs/installation-guides/install-codex.md)** - Installation guide for OpenAI Codex - **[Cursor](/docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE +- **[OpenCode](/docs/installation-guides/install-opencode.md)** - Installation guide for the OpenCode terminal agent - **[Windsurf](/docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE +- **[Zed](/docs/installation-guides/install-zed.md)** - Installation guide for Zed editor - **[Rovo Dev CLI](/docs/installation-guides/install-rovo-dev-cli.md)** - Installation guide for Rovo Dev CLI > **Note:** Each MCP host application needs to configure a GitHub App or OAuth App to support remote access via OAuth. Any host application that supports remote MCP servers should support the remote GitHub server with PAT authentication. Configuration details and support levels vary by host. Make sure to refer to the host application's documentation for more info. @@ -356,7 +358,9 @@ For other MCP host applications, please refer to our installation guides: - **[Claude Code & Claude Desktop](docs/installation-guides/install-claude.md)** - Installation guide for Claude Code and Claude Desktop - **[Cursor](docs/installation-guides/install-cursor.md)** - Installation guide for Cursor IDE - **[Google Gemini CLI](docs/installation-guides/install-gemini-cli.md)** - Installation guide for Google Gemini CLI +- **[OpenCode](docs/installation-guides/install-opencode.md)** - Installation guide for the OpenCode terminal agent - **[Windsurf](docs/installation-guides/install-windsurf.md)** - Installation guide for Windsurf IDE +- **[Zed](docs/installation-guides/install-zed.md)** - Installation guide for Zed editor For a complete overview of all installation options, see our **[Installation Guides Index](docs/installation-guides)**. diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md index 0c0f7840ef..61ea7eafb1 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -11,9 +11,11 @@ This directory contains detailed installation instructions for the GitHub MCP Se - **[Cursor](install-cursor.md)** - Installation guide for Cursor IDE - **[Google Gemini CLI](install-gemini-cli.md)** - Installation guide for Google Gemini CLI - **[OpenAI Codex](install-codex.md)** - Installation guide for OpenAI Codex +- **[OpenCode](install-opencode.md)** - Installation guide for the OpenCode terminal agent - **[Roo Code](install-roo-code.md)** - Installation guide for Roo Code - **[Windsurf](install-windsurf.md)** - Installation guide for Windsurf IDE - **[Xcode (Codex & Claude Agent)](install-xcode.md)** - Installation guide for Codex and Claude Agent within Xcode +- **[Zed](install-zed.md)** - Installation guide for Zed editor ## Support by Host Application @@ -29,8 +31,10 @@ This directory contains detailed installation instructions for the GitHub MCP Se | Cline | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Cursor | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Google Gemini CLI | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | +| OpenCode | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Roo Code | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Windsurf | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | +| Zed | ✅ | ✅ PAT + ❌ No OAuth | Docker or Go build, GitHub PAT | Easy | | Copilot in Xcode | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Copilot for Xcode 0.41.0+ | Easy | | Copilot in Eclipse | ✅ | ✅ Full (OAuth + PAT) | Local: Docker or Go build, GitHub PAT
Remote: Eclipse Plug-in for Copilot 0.10.0+ | Easy | | Xcode (Codex) | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker (full path required), GitHub PAT
Remote: GitHub PAT via `GITHUB_PAT_TOKEN` env var (`bearer_token_env_var`) | Easy | diff --git a/docs/installation-guides/install-opencode.md b/docs/installation-guides/install-opencode.md new file mode 100644 index 0000000000..10e0e2db2a --- /dev/null +++ b/docs/installation-guides/install-opencode.md @@ -0,0 +1,154 @@ +# Install GitHub MCP Server in OpenCode + +[OpenCode](https://opencode.ai) is a terminal-based AI coding agent that exposes MCP servers under the `mcp` key in `opencode.json` (or `opencode.jsonc`). For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md). + +## Prerequisites + +1. OpenCode installed (`brew install sst/tap/opencode` or see [OpenCode install docs](https://opencode.ai/docs/)) +2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes +3. For local installation: [Docker](https://www.docker.com/) installed and running + +> [!IMPORTANT] +> The OpenCode docs note that the GitHub MCP server can add a lot of tokens to your context. Consider limiting toolsets — for example, by setting `X-MCP-Toolsets` on the remote server or `--toolsets` on the local server — to keep prompts within your model's context window. See the [Server Configuration Guide](../server-configuration.md) and the [main README's toolsets section](../../README.md#available-toolsets). + +## Remote Server (Recommended) + +Uses GitHub's hosted server at `https://api.githubcopilot.com/mcp/`. Edit your [OpenCode config](https://opencode.ai/docs/config/) (typically `~/.config/opencode/opencode.json`, or `opencode.json` in your project root) and add the following under `mcp`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "github": { + "type": "remote", + "url": "https://api.githubcopilot.com/mcp/", + "enabled": true, + "oauth": false, + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` + +Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). The `oauth: false` setting disables OpenCode's automatic OAuth discovery and tells it to use the PAT in `Authorization` instead — without this, OpenCode may try the OAuth flow first. + +### Using an environment variable for the PAT + +OpenCode supports environment-variable interpolation in config values via `{env:VAR_NAME}`. To avoid putting your PAT directly in `opencode.json`: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "github": { + "type": "remote", + "url": "https://api.githubcopilot.com/mcp/", + "enabled": true, + "oauth": false, + "headers": { + "Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}" + } + } + } +} +``` + +Set `GITHUB_PERSONAL_ACCESS_TOKEN` in your shell environment before starting OpenCode. + +## Local Server (Docker) + +The local GitHub MCP server runs via Docker and requires Docker Desktop (or another Docker runtime) to be installed and running. + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "github": { + "type": "local", + "command": [ + "docker", "run", "-i", "--rm", + "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "enabled": true, + "environment": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" + } + } + } +} +``` + +> [!IMPORTANT] +> OpenCode expects `command` as a **single array** combining the executable and its arguments (e.g. `["docker", "run", "-i", ...]`), and the env-var key is `environment` (not `env`). This differs from hosts like Zed and Cursor. + +## Verify Installation + +1. Restart OpenCode (or start a new session). +2. Check that the server is discovered: + ```sh + opencode mcp list + ``` +3. Try a prompt that references the server by name to bias the model toward its tools: + ``` + Use the github tool to list my recently merged pull requests. + ``` + +## Managing the Server + +OpenCode exposes a few useful subcommands for MCP servers: + +| Command | Purpose | +| --- | --- | +| `opencode mcp list` | List configured MCP servers and their auth/connection status. | +| `opencode mcp debug github` | Show auth status, test HTTP connectivity, and walk through OAuth discovery for the `github` server. | +| `opencode mcp auth github` | Trigger an OAuth flow manually (only relevant if `oauth` is not set to `false`). | +| `opencode mcp logout github` | Clear stored OAuth tokens for the server. | + +## Disabling Tools Per-Agent + +Because the GitHub MCP server can register a large number of tools, you may want to **disable them globally** and **re-enable them only for specific agents**. OpenCode uses the `_*` glob pattern to match all tools from a server: + +```json +{ + "$schema": "https://opencode.ai/config.json", + "mcp": { + "github": { + "type": "remote", + "url": "https://api.githubcopilot.com/mcp/", + "enabled": true, + "oauth": false, + "headers": { "Authorization": "Bearer {env:GITHUB_PERSONAL_ACCESS_TOKEN}" } + } + }, + "tools": { + "github_*": false + }, + "agent": { + "github-helper": { + "tools": { "github_*": true } + } + } +} +``` + +This pattern is recommended by the [OpenCode MCP docs](https://opencode.ai/docs/mcp-servers/) for servers with many tools. + +## Troubleshooting + +- **`401 Unauthorized` from the remote server**: confirm your PAT is valid and not expired. If you set `oauth: false`, OpenCode will not attempt an OAuth fallback — the `Authorization` header must be correct. +- **Server marked failed in `opencode mcp list`**: run `opencode mcp debug github` to see the exact connectivity and auth diagnostics. +- **Tools missing from prompts**: check that `enabled: true` is set on the server and that you have not disabled `github_*` in your `tools` block without re-enabling it for the current agent. +- **Context window exceeded**: the GitHub MCP server can register many tools. Use server-side toolset filtering (`X-MCP-Toolsets` header) to register only the toolsets you need. +- **Docker errors on the local server**: ensure Docker Desktop is running and the `ghcr.io/github/github-mcp-server` image has been pulled (`docker pull ghcr.io/github/github-mcp-server`). + +## Important Notes + +- **Configuration key**: OpenCode uses `mcp` (not `mcpServers` or `context_servers`). +- **Type discriminator**: every entry must include `"type": "local"` or `"type": "remote"`. +- **Command shape**: `command` is a single array combining the executable and its arguments. +- **Environment variable key**: `environment` (not `env`). +- **OAuth**: enabled by default for remote servers. Set `"oauth": false` when using PAT-in-`Authorization`, otherwise OpenCode may try OAuth first. +- **Env interpolation**: use `{env:VAR_NAME}` in string values to read from the shell environment instead of hard-coding secrets. diff --git a/docs/installation-guides/install-zed.md b/docs/installation-guides/install-zed.md new file mode 100644 index 0000000000..d0e07b6d8e --- /dev/null +++ b/docs/installation-guides/install-zed.md @@ -0,0 +1,103 @@ +# Install GitHub MCP Server in Zed + +[Zed](https://zed.dev) is a high-performance multiplayer code editor with native MCP support. Zed exposes MCP servers under the `context_servers` settings key. For general setup information (prerequisites, Docker installation, security best practices), see the [Installation Guides README](./README.md). + +## Prerequisites + +1. Zed installed (latest version — Zed v0.224.0+ recommended for the modern `agent.tool_permissions` settings shape) +2. [GitHub Personal Access Token](https://github.com/settings/personal-access-tokens/new) with appropriate scopes +3. For local installation: [Docker](https://www.docker.com/) installed and running + +## Installation Methods + +There are two ways to install the GitHub MCP server in Zed: + +- **Option A — Zed Extension (easiest):** a community-maintained [GitHub MCP extension](https://zed.dev/extensions/mcp-server-github) is available in the Zed extension gallery. Install it from the Agent Panel's top-right menu → "View Server Extensions", or from the command palette via the `zed: extensions` action. After installation, Zed pops up a modal asking for your GitHub Personal Access Token. +- **Option B — Custom Server (recommended for the official remote endpoint):** add the configuration manually to `settings.json` to use either GitHub's hosted remote server or the official Docker image directly. The rest of this guide covers Option B. + +## Remote Server (Recommended) + +Uses GitHub's hosted server at `https://api.githubcopilot.com/mcp/`. Open your Zed [settings file](https://zed.dev/docs/configuring-zed.html#settings-files) (Command Palette → `zed: open settings`) and add the configuration below under `context_servers`. + +```json +{ + "context_servers": { + "github": { + "url": "https://api.githubcopilot.com/mcp/", + "headers": { + "Authorization": "Bearer YOUR_GITHUB_PAT" + } + } + } +} +``` + +Replace `YOUR_GITHUB_PAT` with your [GitHub Personal Access Token](https://github.com/settings/tokens). To customize toolsets, add server-side headers like `X-MCP-Toolsets` or `X-MCP-Readonly` to the `headers` object — see the [Server Configuration Guide](../server-configuration.md). + +> [!NOTE] +> If you omit the `Authorization` header, Zed will attempt the standard MCP OAuth flow on first use. The GitHub MCP server does not currently advertise OAuth for non-Copilot hosts, so a Personal Access Token in the `Authorization` header is the supported path. + +## Local Server (Docker) + +The local GitHub MCP server runs via Docker and requires Docker Desktop (or another Docker runtime) to be installed and running. + +```json +{ + "context_servers": { + "github": { + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", + "ghcr.io/github/github-mcp-server" + ], + "env": { + "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT" + } + } + } +} +``` + +> [!IMPORTANT] +> Zed expects `command` as a **string** plus a separate `args` array, not a single array combining both. This differs from hosts like OpenCode and Claude Desktop. + +## Verify Installation + +1. Open the Agent Panel and click into its Settings view (or run `agent: open settings`). +2. Find `github` in the context servers list. A green indicator dot with the tooltip "Server is active" confirms a working configuration. Other colors and tooltip messages indicate startup or auth errors. +3. Try a prompt that should invoke a tool — for example, `List my recent GitHub pull requests`. Zed will prompt for tool approval before the first call unless your `agent.tool_permissions.default` is set to `"allow"`. + +## Tool Permissions (Optional) + +Zed v0.224.0+ controls tool approval via `agent.tool_permissions`. Approve a specific GitHub MCP tool without per-call prompts by using the `mcp::` key format: + +```json +{ + "agent": { + "tool_permissions": { + "default": "confirm", + "rules": [ + { "tool": "mcp:github:list_pull_requests", "permission": "allow" }, + { "tool": "mcp:github:list_issues", "permission": "allow" } + ] + } + } +} +``` + +See the [Zed tool permissions docs](https://zed.dev/docs/ai/tool-permissions.html) for the full schema. + +## Troubleshooting + +- **Server indicator stays red / "Server is not running"**: check the Agent Panel's settings view for the per-server error string. Most common cause is invalid JSON in `settings.json` — Zed surfaces JSON parse errors in the editor itself. +- **`401 Unauthorized`**: verify your PAT has not expired and includes the scopes for the tools you intend to call. The remote endpoint will reject requests with no `Authorization` header (no anonymous access). +- **Tools missing from prompts**: confirm the Agent profile in use has not disabled the server. If you're using a [custom profile](https://zed.dev/docs/ai/agent-panel.html#custom-profiles), make sure `enable_all_context_servers` is `true` or that `github` is explicitly listed. +- **Docker errors on the local server**: ensure Docker Desktop is running and the `ghcr.io/github/github-mcp-server` image has been pulled at least once. Try `docker pull ghcr.io/github/github-mcp-server` from a terminal. + +## Important Notes + +- **Configuration key**: Zed uses `context_servers` (not `mcpServers`). +- **Command shape**: `command` is a string + separate `args` array. +- **OAuth**: omitting `Authorization` triggers Zed's MCP OAuth flow, but the GitHub MCP server's PAT-based auth is the supported path today. +- **External agents**: MCP servers configured in `context_servers` are forwarded to [external agents](https://zed.dev/docs/ai/external-agents.html) via the Agent Client Protocol. From 03da19109e053b019b8d9555843f532ae132708b Mon Sep 17 00:00:00 2001 From: Emily Chen Date: Fri, 10 Apr 2026 10:05:36 +0000 Subject: [PATCH 07/34] docs: improve Claude installation guide with Windows PowerShell support - Fix README.md: Remove non-existent 'Claude Web' from description - Add Windows PowerShell environment variable example for loading PAT from .env file The previous documentation only showed bash syntax for loading environment variables from .env files, which doesn't work on Windows PowerShell. This adds a PowerShell equivalent to help Windows users set up the GitHub MCP Server correctly. --- docs/installation-guides/README.md | 2 +- docs/installation-guides/install-claude.md | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md index 61ea7eafb1..46581aa77e 100644 --- a/docs/installation-guides/README.md +++ b/docs/installation-guides/README.md @@ -6,7 +6,7 @@ This directory contains detailed installation instructions for the GitHub MCP Se - **[Copilot CLI](install-copilot-cli.md)** - Installation guide for GitHub Copilot CLI - **[GitHub Copilot in other IDEs](install-other-copilot-ides.md)** - Installation for JetBrains, Visual Studio, Eclipse, and Xcode with GitHub Copilot - **[Antigravity](install-antigravity.md)** - Installation for Google Antigravity IDE -- **[Claude Applications](install-claude.md)** - Installation guide for Claude Web, Claude Desktop and Claude Code CLI +- **[Claude Applications](install-claude.md)** - Installation guide for Claude Desktop and Claude Code CLI - **[Cline](install-cline.md)** - Installation guide for Cline - **[Cursor](install-cursor.md)** - Installation guide for Cursor IDE - **[Google Gemini CLI](install-gemini-cli.md)** - Installation guide for Google Gemini CLI diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md index 67003fb69a..c64484977a 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -37,11 +37,17 @@ echo -e ".env\n.mcp.json" >> .gitignore claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' ``` -With an environment variable: +With an environment variable (Linux/macOS): ```bash claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$(grep GITHUB_PAT .env | cut -d '=' -f2)"'"}}' ``` +With an environment variable (Windows PowerShell): +```powershell +$env:GITHUB_PAT = (Get-Content .env | Select-String "^GITHUB_PAT=").ToString().Split("=")[1] +claude mcp add-json github "{`"type`":`"http`",`"url`":`"https://api.githubcopilot.com/mcp`",`"headers`":{`"Authorization`":`"Bearer $env:GITHUB_PAT`"}}" +``` + > **About the `--scope` flag** (optional): Use this to specify where the configuration is stored: > - `local` (default): Available only to you in the current project (was called `project` in older versions) > - `project`: Shared with everyone in the project via `.mcp.json` file From d6b9dc94114496b670797eb7aa8205dedc131162 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 10:59:19 +0200 Subject: [PATCH 08/34] docs: address review feedback on env-var examples - Linux/macOS: actually set GITHUB_PAT instead of inlining via subshell, matching the heading. - PowerShell: use Select-Object -First 1, split with max 2 parts, and trim quotes/whitespace so common .env formats work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/installation-guides/install-claude.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md index c64484977a..d66b34776b 100644 --- a/docs/installation-guides/install-claude.md +++ b/docs/installation-guides/install-claude.md @@ -39,12 +39,14 @@ claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/ With an environment variable (Linux/macOS): ```bash -claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$(grep GITHUB_PAT .env | cut -d '=' -f2)"'"}}' +export GITHUB_PAT="$(grep '^GITHUB_PAT=' .env | cut -d '=' -f2-)" +claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer '"$GITHUB_PAT"'"}}' ``` With an environment variable (Windows PowerShell): ```powershell -$env:GITHUB_PAT = (Get-Content .env | Select-String "^GITHUB_PAT=").ToString().Split("=")[1] +$githubPatLine = Get-Content .env | Select-String "^\s*GITHUB_PAT\s*=" | Select-Object -First 1 +$env:GITHUB_PAT = ($githubPatLine.Line -split "=", 2)[1].Trim().Trim('"').Trim("'") claude mcp add-json github "{`"type`":`"http`",`"url`":`"https://api.githubcopilot.com/mcp`",`"headers`":{`"Authorization`":`"Bearer $env:GITHUB_PAT`"}}" ``` From 830ad2c6132ca6fdf6daa46f19f7102fef7e71a8 Mon Sep 17 00:00:00 2001 From: Dmitry Korobitsin Date: Sun, 15 Mar 2026 17:17:27 +0100 Subject: [PATCH 09/34] docs: clarify that / uses default toolset in remote MCP server --- docs/remote-server.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/remote-server.md b/docs/remote-server.md index 5a82f1c2e1..80699edcb2 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -19,7 +19,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | -| apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| apps
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | | workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | From 07f1d020307d31553918d283fab7ec7da5717375 Mon Sep 17 00:00:00 2001 From: Dmitry Korobitsin Date: Tue, 17 Mar 2026 13:32:03 +0100 Subject: [PATCH 10/34] docs: fix remote default toolset generation --- cmd/github-mcp-server/generate_docs.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index efa8f7c393..0218920571 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -365,9 +365,9 @@ func generateRemoteToolsetsDoc() string { buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") buf.WriteString("| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |\n") - // Add "all" toolset first (special case) - allIcon := octiconImg("apps", "../") - fmt.Fprintf(&buf, "| %s
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", allIcon) + // Add "default" toolset first (special case) + defaultIcon := octiconImg("apps", "../") + fmt.Fprintf(&buf, "| %s
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", defaultIcon) // AvailableToolsets() returns toolsets that have tools, sorted by ID // Exclude context (handled separately) From b0d9854388d6fb51bde7b67aa4394a5081a96988 Mon Sep 17 00:00:00 2001 From: Copilot <223556219+Copilot@users.noreply.github.com> Date: Sat, 30 May 2026 11:07:17 +0200 Subject: [PATCH 11/34] docs: also advertise /x/all meta toolset The default toolset row covers /mcp/ but /x/all is still a real, useful meta toolset that enables every toolset at once. Render both as special rows above the per-toolset list. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/github-mcp-server/generate_docs.go | 8 +++++--- docs/remote-server.md | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/cmd/github-mcp-server/generate_docs.go b/cmd/github-mcp-server/generate_docs.go index 0218920571..78ed8361a8 100644 --- a/cmd/github-mcp-server/generate_docs.go +++ b/cmd/github-mcp-server/generate_docs.go @@ -365,9 +365,11 @@ func generateRemoteToolsetsDoc() string { buf.WriteString("| Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) |\n") buf.WriteString("| ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- |\n") - // Add "default" toolset first (special case) - defaultIcon := octiconImg("apps", "../") - fmt.Fprintf(&buf, "| %s
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", defaultIcon) + // Add "default" and "all" meta toolsets first (special cases). The base + // URL serves the default toolset; /x/all enables every toolset at once. + metaIcon := octiconImg("apps", "../") + fmt.Fprintf(&buf, "| %s
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2F%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Freadonly%%22%%7D) |\n", metaIcon) + fmt.Fprintf(&buf, "| %s
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%22%%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%%7B%%22type%%22%%3A%%20%%22http%%22%%2C%%22url%%22%%3A%%20%%22https%%3A%%2F%%2Fapi.githubcopilot.com%%2Fmcp%%2Fx%%2Fall%%2Freadonly%%22%%7D) |\n", metaIcon) // AvailableToolsets() returns toolsets that have tools, sorted by ID // Exclude context (handled separately) diff --git a/docs/remote-server.md b/docs/remote-server.md index 80699edcb2..aa083d2f29 100644 --- a/docs/remote-server.md +++ b/docs/remote-server.md @@ -20,6 +20,7 @@ Below is a table of available toolsets for the remote GitHub MCP Server. Each to | Name | Description | API URL | 1-Click Install (VS Code) | Read-only Link | 1-Click Read-only Install (VS Code) | | ---- | ----------- | ------- | ------------------------- | -------------- | ----------------------------------- | | apps
`default` | Default toolset | https://api.githubcopilot.com/mcp/ | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) | [read-only](https://api.githubcopilot.com/mcp/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Freadonly%22%7D) | +| apps
`all` | All available GitHub MCP tools | https://api.githubcopilot.com/mcp/x/all | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/all/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-all&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fall%2Freadonly%22%7D) | | workflow
`actions` | GitHub Actions workflows and CI/CD operations | https://api.githubcopilot.com/mcp/x/actions | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/actions/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-actions&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Factions%2Freadonly%22%7D) | | codescan
`code_security` | Code security related tools, such as GitHub Code Scanning | https://api.githubcopilot.com/mcp/x/code_security | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/code_security/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-code_security&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcode_security%2Freadonly%22%7D) | | copilot
`copilot` | Copilot related tools | https://api.githubcopilot.com/mcp/x/copilot | [Install](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%22%7D) | [read-only](https://api.githubcopilot.com/mcp/x/copilot/readonly) | [Install read-only](https://insiders.vscode.dev/redirect/mcp/install?name=gh-copilot&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2Fx%2Fcopilot%2Freadonly%22%7D) | From 92667523b3c56c0e66bf13141357d24eea01f5f5 Mon Sep 17 00:00:00 2001 From: Nelson Joppi Date: Sat, 7 Mar 2026 02:36:51 -0300 Subject: [PATCH 12/34] fix: return project item id usable for updates --- pkg/github/projects.go | 10 +++++++++- pkg/github/projects_test.go | 16 ++++++++++++---- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 9c7310c0ff..53ce510516 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "net/http" + "strconv" "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -1125,7 +1126,8 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne var mutation struct { AddProjectV2ItemByID struct { Item struct { - ID githubv4.ID + ID githubv4.ID + FullDatabaseID string `graphql:"fullDatabaseId"` } } `graphql:"addProjectV2ItemById(input: $input)"` } @@ -1151,6 +1153,12 @@ func addProjectItem(ctx context.Context, gqlClient *githubv4.Client, owner, owne "id": mutation.AddProjectV2ItemByID.Item.ID, "message": fmt.Sprintf("Successfully added %s %s/%s#%d to project %s/%d", itemType, itemOwner, itemRepo, itemNumber, owner, projectNumber), } + if fullDatabaseID := mutation.AddProjectV2ItemByID.Item.FullDatabaseID; fullDatabaseID != "" { + result["full_database_id"] = fullDatabaseID + if itemID, err := strconv.ParseInt(fullDatabaseID, 10, 64); err == nil { + result["item_id"] = itemID + } + } r, err := json.Marshal(result) if err != nil { diff --git a/pkg/github/projects_test.go b/pkg/github/projects_test.go index a9787298af..ad5ce6db86 100644 --- a/pkg/github/projects_test.go +++ b/pkg/github/projects_test.go @@ -630,7 +630,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { struct { AddProjectV2ItemByID struct { Item struct { - ID githubv4.ID + ID githubv4.ID + FullDatabaseID string `graphql:"fullDatabaseId"` } } `graphql:"addProjectV2ItemById(input: $input)"` }{}, @@ -642,7 +643,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "addProjectV2ItemById": map[string]any{ "item": map[string]any{ - "id": "PVTI_item1", + "id": "PVTI_item1", + "fullDatabaseId": "1001", }, }, }), @@ -674,6 +676,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.NotNil(t, response["id"]) + assert.Equal(t, float64(1001), response["item_id"]) + assert.Equal(t, "1001", response["full_database_id"]) assert.Contains(t, response["message"], "Successfully added") }) @@ -727,7 +731,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { struct { AddProjectV2ItemByID struct { Item struct { - ID githubv4.ID + ID githubv4.ID + FullDatabaseID string `graphql:"fullDatabaseId"` } } `graphql:"addProjectV2ItemById(input: $input)"` }{}, @@ -739,7 +744,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { githubv4mock.DataResponse(map[string]any{ "addProjectV2ItemById": map[string]any{ "item": map[string]any{ - "id": "PVTI_item2", + "id": "PVTI_item2", + "fullDatabaseId": "1002", }, }, }), @@ -771,6 +777,8 @@ func Test_ProjectsWrite_AddProjectItem(t *testing.T) { err = json.Unmarshal([]byte(textContent.Text), &response) require.NoError(t, err) assert.NotNil(t, response["id"]) + assert.Equal(t, float64(1002), response["item_id"]) + assert.Equal(t, "1002", response["full_database_id"]) assert.Contains(t, response["message"], "Successfully added") }) From 28846988e997bd604fcb2c283842170f9758a92f Mon Sep 17 00:00:00 2001 From: Roger Garza Date: Fri, 13 Feb 2026 07:37:27 -0600 Subject: [PATCH 13/34] fix: add MCP initialize handshake to mcpcurl mcpcurl was sending tools/list and tools/call requests without first performing the MCP initialize handshake, causing the server to silently reject all requests and discover zero tools. Before: $ mcpcurl --stdio-server-cmd "github-mcp-server stdio" tools --help (no tools listed) After: $ mcpcurl --stdio-server-cmd "github-mcp-server stdio" tools --help Available Commands: add_comment_to_pending_review ... add_issue_comment ... create_branch ... --- cmd/mcpcurl/main.go | 122 +++++++++++++++++++++++++---- cmd/mcpcurl/main_test.go | 161 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 270 insertions(+), 13 deletions(-) create mode 100644 cmd/mcpcurl/main_test.go diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go index f35e6926c3..db8fac65d4 100644 --- a/cmd/mcpcurl/main.go +++ b/cmd/mcpcurl/main.go @@ -1,7 +1,7 @@ package main import ( - "bytes" + "bufio" "crypto/rand" "encoding/json" "fmt" @@ -376,8 +376,8 @@ func buildJSONRPCRequest(method, toolName string, arguments map[string]any) (str return string(jsonData), nil } -// executeServerCommand runs the specified command, sends the JSON request to stdin, -// and returns the response from stdout +// executeServerCommand runs the specified command, performs the MCP initialization +// handshake, sends the JSON request to stdin, and returns the response from stdout. func executeServerCommand(cmdStr, jsonRequest string) (string, error) { // Split the command string into command and arguments cmdParts := strings.Fields(cmdStr) @@ -393,9 +393,14 @@ func executeServerCommand(cmdStr, jsonRequest string) (string, error) { return "", fmt.Errorf("failed to create stdin pipe: %w", err) } - // Setup stdout and stderr pipes - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout + // Setup stdout pipe for line-by-line reading + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return "", fmt.Errorf("failed to create stdout pipe: %w", err) + } + + // Stderr still uses a buffer + var stderr strings.Builder cmd.Stderr = &stderr // Start the command @@ -403,18 +408,109 @@ func executeServerCommand(cmdStr, jsonRequest string) (string, error) { return "", fmt.Errorf("failed to start command: %w", err) } - // Write the JSON request to stdin + // Ensure the child process is cleaned up on any error after Start() + cleanup := func() { + _ = stdin.Close() + _ = cmd.Wait() + } + + // Use a scanner with a large buffer for reading JSON-RPC responses + scanner := bufio.NewScanner(stdoutPipe) + scanner.Buffer(make([]byte, 0, 1024*1024), 1024*1024) // 1MB max line size + + // Step 1: Send MCP initialize request + initReq, err := buildInitializeRequest() + if err != nil { + cleanup() + return "", fmt.Errorf("failed to build initialize request: %w", err) + } + if _, err := io.WriteString(stdin, initReq+"\n"); err != nil { + cleanup() + return "", fmt.Errorf("failed to write initialize request: %w", err) + } + + // Step 2: Read initialize response (skip any server notifications) + if _, err := readJSONRPCResponse(scanner); err != nil { + cleanup() + return "", fmt.Errorf("failed to read initialize response: %w, stderr: %s", err, stderr.String()) + } + + // Step 3: Send initialized notification + if _, err := io.WriteString(stdin, buildInitializedNotification()+"\n"); err != nil { + cleanup() + return "", fmt.Errorf("failed to write initialized notification: %w", err) + } + + // Step 4: Send the actual request if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil { - return "", fmt.Errorf("failed to write to stdin: %w", err) + cleanup() + return "", fmt.Errorf("failed to write request: %w", err) + } + + // Step 5: Read the actual response (skip any server notifications) + response, err := readJSONRPCResponse(scanner) + if err != nil { + cleanup() + return "", fmt.Errorf("failed to read response: %w, stderr: %s", err, stderr.String()) } - _ = stdin.Close() - // Wait for the command to complete - if err := cmd.Wait(); err != nil { - return "", fmt.Errorf("command failed: %w, stderr: %s", err, stderr.String()) + // Close stdin and wait for process to exit. The server will see EOF and + // exit with a non-zero status, which is expected — we already have the response. + cleanup() + + return response, nil +} + +// buildInitializeRequest creates the MCP initialize handshake request. +func buildInitializeRequest() (string, error) { + id, err := rand.Int(rand.Reader, big.NewInt(10000)) + if err != nil { + return "", fmt.Errorf("failed to generate random ID: %w", err) + } + msg := map[string]any{ + "jsonrpc": "2.0", + "id": int(id.Int64()), + "method": "initialize", + "params": map[string]any{ + "protocolVersion": "2024-11-05", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{ + "name": "mcpcurl", + "version": "0.1.0", + }, + }, } + data, err := json.Marshal(msg) + if err != nil { + return "", fmt.Errorf("failed to marshal initialize request: %w", err) + } + return string(data), nil +} + +// buildInitializedNotification creates the MCP initialized notification. +func buildInitializedNotification() string { + return `{"jsonrpc":"2.0","method":"notifications/initialized"}` +} - return stdout.String(), nil +// readJSONRPCResponse reads lines from the scanner, skipping server-initiated +// notifications (messages without an "id" field), and returns the first response. +func readJSONRPCResponse(scanner *bufio.Scanner) (string, error) { + for scanner.Scan() { + line := scanner.Text() + // JSON-RPC responses have an "id" field; notifications do not. + var msg map[string]json.RawMessage + if err := json.Unmarshal([]byte(line), &msg); err != nil { + return "", fmt.Errorf("failed to parse JSON-RPC message: %w", err) + } + if _, hasID := msg["id"]; hasID { + return line, nil + } + // No "id" — this is a notification, skip it + } + if err := scanner.Err(); err != nil { + return "", err + } + return "", fmt.Errorf("unexpected end of output") } func printResponse(response string, prettyPrint bool) error { diff --git a/cmd/mcpcurl/main_test.go b/cmd/mcpcurl/main_test.go new file mode 100644 index 0000000000..c31f95b732 --- /dev/null +++ b/cmd/mcpcurl/main_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "bufio" + "encoding/json" + "strings" + "testing" +) + +func TestReadJSONRPCResponse_DirectResponse(t *testing.T) { + t.Parallel() + input := `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` + "\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + got, err := readJSONRPCResponse(scanner) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != `{"jsonrpc":"2.0","id":1,"result":{"tools":[]}}` { + t.Fatalf("unexpected response: %s", got) + } +} + +func TestReadJSONRPCResponse_SkipsNotifications(t *testing.T) { + t.Parallel() + input := strings.Join([]string{ + `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}`, + `{"jsonrpc":"2.0","method":"notifications/tools/list_changed"}`, + `{"jsonrpc":"2.0","id":42,"result":{"content":[{"type":"text","text":"hello"}]}}`, + }, "\n") + "\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + got, err := readJSONRPCResponse(scanner) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var msg map[string]json.RawMessage + if err := json.Unmarshal([]byte(got), &msg); err != nil { + t.Fatalf("response is not valid JSON: %v", err) + } + // Verify we got the response with id:42, not a notification + var id int + if err := json.Unmarshal(msg["id"], &id); err != nil { + t.Fatalf("failed to parse id: %v", err) + } + if id != 42 { + t.Fatalf("expected id 42, got %d", id) + } +} + +func TestReadJSONRPCResponse_NoResponse(t *testing.T) { + t.Parallel() + // Only notifications, no response + input := `{"jsonrpc":"2.0","method":"notifications/resources/list_changed","params":{}}` + "\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + _, err := readJSONRPCResponse(scanner) + if err == nil { + t.Fatal("expected error for missing response, got nil") + } + if !strings.Contains(err.Error(), "unexpected end of output") { + t.Fatalf("expected 'unexpected end of output' error, got: %v", err) + } +} + +func TestReadJSONRPCResponse_EmptyInput(t *testing.T) { + t.Parallel() + scanner := bufio.NewScanner(strings.NewReader("")) + + _, err := readJSONRPCResponse(scanner) + if err == nil { + t.Fatal("expected error for empty input, got nil") + } +} + +func TestReadJSONRPCResponse_InvalidJSON(t *testing.T) { + t.Parallel() + input := "not valid json\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + _, err := readJSONRPCResponse(scanner) + if err == nil { + t.Fatal("expected error for invalid JSON, got nil") + } + if !strings.Contains(err.Error(), "failed to parse JSON-RPC message") { + t.Fatalf("expected parse error, got: %v", err) + } +} + +func TestBuildInitializeRequest(t *testing.T) { + t.Parallel() + got, err := buildInitializeRequest() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + var msg map[string]json.RawMessage + if err := json.Unmarshal([]byte(got), &msg); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + + // Verify required fields + for _, field := range []string{"jsonrpc", "id", "method", "params"} { + if _, ok := msg[field]; !ok { + t.Errorf("missing required field %q", field) + } + } + + // Verify method + var method string + if err := json.Unmarshal(msg["method"], &method); err != nil { + t.Fatalf("failed to parse method: %v", err) + } + if method != "initialize" { + t.Errorf("expected method 'initialize', got %q", method) + } + + // Verify params contain protocolVersion and clientInfo + var params map[string]json.RawMessage + if err := json.Unmarshal(msg["params"], ¶ms); err != nil { + t.Fatalf("failed to parse params: %v", err) + } + for _, field := range []string{"protocolVersion", "capabilities", "clientInfo"} { + if _, ok := params[field]; !ok { + t.Errorf("missing params field %q", field) + } + } + + var version string + if err := json.Unmarshal(params["protocolVersion"], &version); err != nil { + t.Fatalf("failed to parse protocolVersion: %v", err) + } + if version != "2024-11-05" { + t.Errorf("expected protocolVersion '2024-11-05', got %q", version) + } +} + +func TestBuildInitializedNotification(t *testing.T) { + t.Parallel() + got := buildInitializedNotification() + + var msg map[string]json.RawMessage + if err := json.Unmarshal([]byte(got), &msg); err != nil { + t.Fatalf("result is not valid JSON: %v", err) + } + + // Must have jsonrpc and method + var method string + if err := json.Unmarshal(msg["method"], &method); err != nil { + t.Fatalf("failed to parse method: %v", err) + } + if method != "notifications/initialized" { + t.Errorf("expected method 'notifications/initialized', got %q", method) + } + + // Must NOT have an id (it's a notification) + if _, hasID := msg["id"]; hasID { + t.Error("notification should not have an 'id' field") + } +} From c05e1bb1d8771d6fb2129b3cd685c73694626688 Mon Sep 17 00:00:00 2001 From: Roger Garza Date: Fri, 13 Feb 2026 19:32:18 -0600 Subject: [PATCH 14/34] fix: surface JSON-RPC error responses in mcpcurl readJSONRPCResponse now checks for an "error" field in responses and returns a descriptive error instead of silently passing it through. --- cmd/mcpcurl/main.go | 3 +++ cmd/mcpcurl/main_test.go | 17 +++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go index db8fac65d4..0dad1ea1ac 100644 --- a/cmd/mcpcurl/main.go +++ b/cmd/mcpcurl/main.go @@ -503,6 +503,9 @@ func readJSONRPCResponse(scanner *bufio.Scanner) (string, error) { return "", fmt.Errorf("failed to parse JSON-RPC message: %w", err) } if _, hasID := msg["id"]; hasID { + if errField, hasErr := msg["error"]; hasErr { + return "", fmt.Errorf("server returned error: %s", string(errField)) + } return line, nil } // No "id" — this is a notification, skip it diff --git a/cmd/mcpcurl/main_test.go b/cmd/mcpcurl/main_test.go index c31f95b732..3d0b00d2a5 100644 --- a/cmd/mcpcurl/main_test.go +++ b/cmd/mcpcurl/main_test.go @@ -88,6 +88,23 @@ func TestReadJSONRPCResponse_InvalidJSON(t *testing.T) { } } +func TestReadJSONRPCResponse_ServerError(t *testing.T) { + t.Parallel() + input := `{"jsonrpc":"2.0","id":1,"error":{"code":-32601,"message":"method not found"}}` + "\n" + scanner := bufio.NewScanner(strings.NewReader(input)) + + _, err := readJSONRPCResponse(scanner) + if err == nil { + t.Fatal("expected error for server error response, got nil") + } + if !strings.Contains(err.Error(), "server returned error") { + t.Fatalf("expected 'server returned error', got: %v", err) + } + if !strings.Contains(err.Error(), "method not found") { + t.Fatalf("expected error to contain server message, got: %v", err) + } +} + func TestBuildInitializeRequest(t *testing.T) { t.Parallel() got, err := buildInitializeRequest() From e5f19db688fd56fa4fbbf62061a944ebff61347e Mon Sep 17 00:00:00 2001 From: ra-n-dom <129428390+ra-n-dom@users.noreply.github.com> Date: Sun, 31 May 2026 11:18:56 +0200 Subject: [PATCH 15/34] review: switch cleanup to defer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per @SamMorrowDrums review — replace the manual cleanup() calls before each error return with a single defer right after cmd.Start(). Same behaviour, less code. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cmd/mcpcurl/main.go | 18 +++++------------- 1 file changed, 5 insertions(+), 13 deletions(-) diff --git a/cmd/mcpcurl/main.go b/cmd/mcpcurl/main.go index 0dad1ea1ac..f40e842530 100644 --- a/cmd/mcpcurl/main.go +++ b/cmd/mcpcurl/main.go @@ -408,11 +408,13 @@ func executeServerCommand(cmdStr, jsonRequest string) (string, error) { return "", fmt.Errorf("failed to start command: %w", err) } - // Ensure the child process is cleaned up on any error after Start() - cleanup := func() { + // Ensure the child process is cleaned up on every return path. + // stdin must be closed before Wait so the server sees EOF and exits; + // its non-zero exit status on EOF is expected, so we ignore the error. + defer func() { _ = stdin.Close() _ = cmd.Wait() - } + }() // Use a scanner with a large buffer for reading JSON-RPC responses scanner := bufio.NewScanner(stdoutPipe) @@ -421,43 +423,33 @@ func executeServerCommand(cmdStr, jsonRequest string) (string, error) { // Step 1: Send MCP initialize request initReq, err := buildInitializeRequest() if err != nil { - cleanup() return "", fmt.Errorf("failed to build initialize request: %w", err) } if _, err := io.WriteString(stdin, initReq+"\n"); err != nil { - cleanup() return "", fmt.Errorf("failed to write initialize request: %w", err) } // Step 2: Read initialize response (skip any server notifications) if _, err := readJSONRPCResponse(scanner); err != nil { - cleanup() return "", fmt.Errorf("failed to read initialize response: %w, stderr: %s", err, stderr.String()) } // Step 3: Send initialized notification if _, err := io.WriteString(stdin, buildInitializedNotification()+"\n"); err != nil { - cleanup() return "", fmt.Errorf("failed to write initialized notification: %w", err) } // Step 4: Send the actual request if _, err := io.WriteString(stdin, jsonRequest+"\n"); err != nil { - cleanup() return "", fmt.Errorf("failed to write request: %w", err) } // Step 5: Read the actual response (skip any server notifications) response, err := readJSONRPCResponse(scanner) if err != nil { - cleanup() return "", fmt.Errorf("failed to read response: %w, stderr: %s", err, stderr.String()) } - // Close stdin and wait for process to exit. The server will see EOF and - // exit with a non-zero status, which is expected — we already have the response. - cleanup() - return response, nil } From 2bd162acaf4c234f6aa6e73d948129b06f9b25d1 Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Thu, 21 May 2026 03:46:36 +0800 Subject: [PATCH 16/34] fix: support team pull request reviewers --- README.md | 2 +- .../request_pull_request_reviewers.snap | 4 ++-- .../__toolsnaps__/update_pull_request.snap | 4 ++-- pkg/github/granular_tools_test.go | 7 ++++-- pkg/github/pullrequests.go | 6 +++-- pkg/github/pullrequests_granular.go | 24 +++++++++++++++++-- pkg/github/pullrequests_test.go | 18 ++++++++++++++ 7 files changed, 54 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 495eb98992..dc08311a4b 100644 --- a/README.md +++ b/README.md @@ -1163,7 +1163,7 @@ The following sets of tools are available: - `owner`: Repository owner (string, required) - `pullNumber`: Pull request number to update (number, required) - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames to request reviews from (string[], optional) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], optional) - `state`: New state (string, optional) - `title`: New title (string, optional) diff --git a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap index 67b7014474..7e6d33a274 100644 --- a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap +++ b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap @@ -21,7 +21,7 @@ "type": "string" }, "reviewers": { - "description": "GitHub usernames to request reviews from", + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", "items": { "type": "string" }, @@ -37,4 +37,4 @@ "type": "object" }, "name": "request_pull_request_reviewers" -} \ No newline at end of file +} diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index ef330188ff..640df79702 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -34,7 +34,7 @@ "type": "string" }, "reviewers": { - "description": "GitHub usernames to request reviews from", + "description": "GitHub usernames or ORG/team-slug team reviewers to request reviews from", "items": { "type": "string" }, @@ -61,4 +61,4 @@ "type": "object" }, "name": "update_pull_request" -} \ No newline at end of file +} diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index 90b42b22c5..ae34c1dd42 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -773,7 +773,10 @@ func TestGranularUpdatePullRequestState(t *testing.T) { func TestGranularRequestPullRequestReviewers(t *testing.T) { client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ - PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)}), + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "reviewers": []any{"user1"}, + "team_reviewers": []any{"team1"}, + }).andThen(mockResponse(t, http.StatusOK, &gogithub.PullRequest{Number: gogithub.Ptr(1)})), })) deps := BaseDeps{Client: client} serverTool := GranularRequestPullRequestReviewers(translations.NullTranslationHelper) @@ -783,7 +786,7 @@ func TestGranularRequestPullRequestReviewers(t *testing.T) { "owner": "owner", "repo": "repo", "pullNumber": float64(1), - "reviewers": []string{"user1", "user2"}, + "reviewers": []string{"user1", "owner/team1"}, }) result, err := handler(ContextWithDeps(context.Background(), deps), &request) require.NoError(t, err) diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 3910a96b95..7f1751b970 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -752,7 +752,7 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo }, "reviewers": { Type: "array", - Description: "GitHub usernames to request reviews from", + Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", Items: &jsonschema.Schema{ Type: "string", }, @@ -944,8 +944,10 @@ func UpdatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } + userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) reviewersRequest := github.ReviewersRequest{ - Reviewers: reviewers, + Reviewers: userReviewers, + TeamReviewers: teamReviewers, } _, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, reviewersRequest) diff --git a/pkg/github/pullrequests_granular.go b/pkg/github/pullrequests_granular.go index 30d7f78d62..6bc2b99f36 100644 --- a/pkg/github/pullrequests_granular.go +++ b/pkg/github/pullrequests_granular.go @@ -297,7 +297,7 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i "pullNumber": {Type: "number", Description: "The pull request number", Minimum: jsonschema.Ptr(1.0)}, "reviewers": { Type: "array", - Description: "GitHub usernames to request reviews from", + Description: "GitHub usernames or ORG/team-slug team reviewers to request reviews from", Items: &jsonschema.Schema{Type: "string"}, }, }, @@ -325,13 +325,17 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i if len(reviewers) == 0 { return utils.NewToolResultError("missing required parameter: reviewers"), nil, nil } + userReviewers, teamReviewers := splitPullRequestReviewers(reviewers) client, err := deps.GetClient(ctx) if err != nil { return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } - pr, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, gogithub.ReviewersRequest{Reviewers: reviewers}) + pr, resp, err := client.PullRequests.RequestReviewers(ctx, owner, repo, pullNumber, gogithub.ReviewersRequest{ + Reviewers: userReviewers, + TeamReviewers: teamReviewers, + }) if err != nil { return ghErrors.NewGitHubAPIErrorResponse(ctx, "failed to request reviewers", resp, err), nil, nil } @@ -351,6 +355,22 @@ func GranularRequestPullRequestReviewers(t translations.TranslationHelperFunc) i return st } +func splitPullRequestReviewers(reviewers []string) ([]string, []string) { + userReviewers := make([]string, 0, len(reviewers)) + teamReviewers := make([]string, 0) + + for _, reviewer := range reviewers { + org, team, ok := strings.Cut(reviewer, "/") + if ok && org != "" && team != "" && !strings.Contains(team, "/") { + teamReviewers = append(teamReviewers, team) + continue + } + userReviewers = append(userReviewers, reviewer) + } + + return userReviewers, teamReviewers +} + // GranularCreatePullRequestReview creates a tool to create a PR review. func GranularCreatePullRequestReview(t translations.TranslationHelperFunc) inventory.ServerTool { st := NewTool( diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 097651b66e..0faee23e2b 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -257,6 +257,24 @@ func Test_UpdatePullRequest(t *testing.T) { expectError: false, expectedPR: mockPRWithReviewers, }, + { + name: "successful PR update with user and team reviewers", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposPullsRequestedReviewersByOwnerByRepoByPullNumber: expectRequestBody(t, map[string]any{ + "reviewers": []any{"reviewer1"}, + "team_reviewers": []any{"platform"}, + }).andThen(mockResponse(t, http.StatusOK, mockPRWithReviewers)), + GetReposPullsByOwnerByRepoByPullNumber: mockResponse(t, http.StatusOK, mockPRWithReviewers), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "reviewers": []any{"reviewer1", "owner/platform"}, + }, + expectError: false, + expectedPR: mockPRWithReviewers, + }, { name: "successful PR update (title only)", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ From 04c8dcbc8f7aba67e8671003783f647a784e25ef Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Mon, 1 Jun 2026 17:13:19 +0100 Subject: [PATCH 17/34] Skip MCP App form when issue/PR write carries non-form params (#2589) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Skip MCP App form when issue/PR write carries non-form params When MCP Apps are enabled and the client supports UI, issue_write and create_pull_request route the call to an interactive form. The form only collects a subset of fields and rebuilds the submit payload from scratch, so any parameter it cannot represent was silently dropped — e.g. labels, assignees, milestone, type, state and issue_fields (priority) for issue_write. Skip the form and execute directly whenever the call carries a parameter outside the set the form collects and re-sends. This generalizes the previous state-only guard and is robust to future parameter additions (an unrecognized param now bypasses the form rather than being lost). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Forward original tool params on MCP App form submit The issue-write and pr-write forms rebuilt their submit payload from scratch, so any parameter the form does not render was dropped on submit. Spread the original toolInput first and override only the edited fields, so unsupported params (e.g. issue_fields, labels, state) are preserved when the user submits the form. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/github/issues.go | 82 +++++++++++++-------- pkg/github/issues_test.go | 123 ++++++++++++++++++++++++++++++++ pkg/github/pullrequests.go | 41 +++++++++-- pkg/github/pullrequests_test.go | 43 +++++++++++ ui/src/apps/issue-write/App.tsx | 3 +- ui/src/apps/pr-write/App.tsx | 3 +- 6 files changed, 259 insertions(+), 36 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 0469789812..6e9cdae53b 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1763,6 +1763,36 @@ func searchIssuesHandler(ctx context.Context, deps ToolDependencies, args map[st // IssueWriteUIResourceURI is the URI for the issue_write tool's MCP App UI resource. const IssueWriteUIResourceURI = "ui://github-mcp-server/issue-write" +// issueWriteFormParams are the parameters the issue_write MCP App form collects +// and re-sends on submit. The form only supports title/body editing (plus the +// routing/identity fields), so any other parameter present on a call cannot be +// represented by the form. +var issueWriteFormParams = map[string]struct{}{ + "method": {}, + "owner": {}, + "repo": {}, + "title": {}, + "body": {}, + "issue_number": {}, + "_ui_submitted": {}, +} + +// issueWriteHasNonFormParams reports whether the call carries any parameter the +// issue_write MCP App form cannot represent (anything outside issueWriteFormParams, +// e.g. labels, assignees, issue_fields or a state change). Such calls must bypass +// the UI form and execute directly so the supplied values aren't silently dropped. +func issueWriteHasNonFormParams(args map[string]any) bool { + for key, value := range args { + if value == nil { + continue + } + if _, ok := issueWriteFormParams[key]; !ok { + return true + } + } + return false +} + // IssueWrite is the FeatureFlagIssueFields-enabled variant of issue_write // (with the issue_fields parameter). LegacyIssueWrite is served when the flag // is off. Both register under the tool name "issue_write"; exactly one is @@ -1908,26 +1938,22 @@ Options are: return utils.NewToolResultError(err.Error()), nil, nil } - // When MCP Apps are enabled and the client supports UI, - // check if this is a UI form submission. The UI sends _ui_submitted=true - // to distinguish form submissions from LLM calls. + // When MCP Apps are enabled and the client supports UI, route the + // call to the interactive form unless it is itself a form submission + // (the UI sends _ui_submitted=true) or it carries parameters the form + // cannot represent (e.g. labels, assignees or issue_fields). Those + // must be applied directly so their values aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { if method == "update" { - // Skip the UI form when a state change is requested because - // the form only handles title/body editing and would lose the - // state transition (e.g. closing or reopening the issue). - if _, hasState := args["state"]; !hasState { - issueNumber, numErr := RequiredInt(args, "issue_number") - if numErr != nil { - return utils.NewToolResultError("issue_number is required for update method"), nil, nil - } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + issueNumber, numErr := RequiredInt(args, "issue_number") + if numErr != nil { + return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - } else { - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil } + return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } title, err := OptionalParam[string](args, "title") @@ -2144,26 +2170,22 @@ Options are: return utils.NewToolResultError(err.Error()), nil, nil } - // When MCP Apps are enabled and the client supports UI, - // check if this is a UI form submission. The UI sends _ui_submitted=true - // to distinguish form submissions from LLM calls. + // When MCP Apps are enabled and the client supports UI, route the + // call to the interactive form unless it is itself a form submission + // (the UI sends _ui_submitted=true) or it carries parameters the form + // cannot represent (e.g. labels, assignees or issue_fields). Those + // must be applied directly so their values aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !issueWriteHasNonFormParams(args) { if method == "update" { - // Skip the UI form when a state change is requested because - // the form only handles title/body editing and would lose the - // state transition (e.g. closing or reopening the issue). - if _, hasState := args["state"]; !hasState { - issueNumber, numErr := RequiredInt(args, "issue_number") - if numErr != nil { - return utils.NewToolResultError("issue_number is required for update method"), nil, nil - } - return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil + issueNumber, numErr := RequiredInt(args, "issue_number") + if numErr != nil { + return utils.NewToolResultError("issue_number is required for update method"), nil, nil } - } else { - return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil + return utils.NewToolResultText(fmt.Sprintf("Ready to update issue #%d in %s/%s. IMPORTANT: The issue has NOT been updated yet. Do NOT tell the user the issue was updated. The user MUST click Submit in the form to update it.", issueNumber, owner, repo)), nil, nil } + return utils.NewToolResultText(fmt.Sprintf("Ready to create an issue in %s/%s. IMPORTANT: The issue has NOT been created yet. Do NOT tell the user the issue was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } title, err := OptionalParam[string](args, "title") diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index b04370976e..d794ad1679 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -1700,6 +1700,129 @@ func Test_IssueWrite_MCPAppsFeature_UIGate(t *testing.T) { assert.Contains(t, textContent.Text, "Ready to update issue #1", "update without state should show UI form") }) + + t.Run("UI client with issue_fields skips form and executes directly", func(t *testing.T) { + // The MCP App form does not collect or re-send issue_fields, so a call + // carrying them must bypass the form and apply the values directly. + fieldsClient := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PostReposIssuesByOwnerByRepo: expectRequestBody(t, map[string]any{ + "title": "Issue with fields", + "body": "", + "labels": []any{}, + "assignees": []any{}, + "issue_field_values": []any{ + map[string]any{"field_id": float64(101), "value": "P1"}, + }, + }).andThen( + mockResponse(t, http.StatusCreated, &github.Issue{ + Number: github.Ptr(125), + Title: github.Ptr("Issue with fields"), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/125"), + State: github.Ptr("open"), + }), + ), + })) + fieldsGQLClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + issueFieldWriteMetadataQuery{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issueFields": map[string]any{ + "nodes": []any{ + map[string]any{ + "__typename": "IssueFieldSingleSelect", + "fullDatabaseId": "101", + "name": "Priority", + "dataType": "single_select", + "options": []any{ + map[string]any{"fullDatabaseId": "9001", "name": "P1"}, + }, + }, + }, + }, + }, + }), + ), + )) + + fieldsDeps := BaseDeps{ + Client: fieldsClient, + GQLClient: fieldsGQLClient, + featureChecker: featureCheckerFor(MCPAppsFeatureFlag), + } + fieldsHandler := serverTool.Handler(fieldsDeps) + + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Issue with fields", + "issue_fields": []any{ + map[string]any{"field_name": "Priority", "field_option_name": "P1"}, + }, + }) + result, err := fieldsHandler(ContextWithDeps(context.Background(), fieldsDeps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to create an issue", + "issue_fields should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/125", + "issue_fields call should execute directly and return issue URL") + }) + + t.Run("UI client with labels skips form and executes directly", func(t *testing.T) { + // The form does not collect labels, so a call carrying them must bypass + // the form rather than silently drop them. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "method": "create", + "owner": "owner", + "repo": "repo", + "title": "Test", + "labels": []any{"bug"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to create an issue", + "labels should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/issues/1", + "labels call should execute directly and return issue URL") + }) +} + +func Test_issueWriteHasNonFormParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]any + want bool + }{ + {name: "no params", args: map[string]any{}, want: false}, + {name: "only form params", args: map[string]any{"method": "create", "owner": "o", "repo": "r", "title": "t", "body": "b", "issue_number": float64(1), "_ui_submitted": true}, want: false}, + {name: "labels present", args: map[string]any{"title": "t", "labels": []any{"bug"}}, want: true}, + {name: "assignees present", args: map[string]any{"title": "t", "assignees": []any{"octocat"}}, want: true}, + {name: "milestone present", args: map[string]any{"title": "t", "milestone": float64(2)}, want: true}, + {name: "type present", args: map[string]any{"title": "t", "type": "Bug"}, want: true}, + {name: "issue_fields present", args: map[string]any{"issue_fields": []any{map[string]any{"field_name": "Priority"}}}, want: true}, + {name: "state present", args: map[string]any{"state": "closed"}, want: true}, + {name: "state_reason present", args: map[string]any{"state_reason": "completed"}, want: true}, + {name: "duplicate_of present", args: map[string]any{"duplicate_of": float64(7)}, want: true}, + {name: "nil value is ignored", args: map[string]any{"issue_fields": nil}, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, issueWriteHasNonFormParams(tc.args)) + }) + } } func Test_ListIssues(t *testing.T) { diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 7f1751b970..05028850d7 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -544,6 +544,37 @@ func GetPullRequestReviews(ctx context.Context, client *github.Client, deps Tool // PullRequestWriteUIResourceURI is the URI for the create_pull_request tool's MCP App UI resource. const PullRequestWriteUIResourceURI = "ui://github-mcp-server/pr-write" +// pullRequestWriteFormParams are the parameters the create_pull_request MCP App +// form collects and re-sends on submit. Any other parameter present on a call +// cannot be represented by the form. +var pullRequestWriteFormParams = map[string]struct{}{ + "owner": {}, + "repo": {}, + "title": {}, + "body": {}, + "head": {}, + "base": {}, + "draft": {}, + "maintainer_can_modify": {}, + "_ui_submitted": {}, +} + +// pullRequestWriteHasNonFormParams reports whether the call carries any parameter +// the create_pull_request MCP App form cannot represent (anything outside +// pullRequestWriteFormParams). Such calls must bypass the UI form and execute +// directly so the supplied values aren't silently dropped. +func pullRequestWriteHasNonFormParams(args map[string]any) bool { + for key, value := range args { + if value == nil { + continue + } + if _, ok := pullRequestWriteFormParams[key]; !ok { + return true + } + } + return false +} + // CreatePullRequest creates a tool to create a new pull request. func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( @@ -611,12 +642,14 @@ func CreatePullRequest(t translations.TranslationHelperFunc) inventory.ServerToo return utils.NewToolResultError(err.Error()), nil, nil } - // When MCP Apps are enabled and the client supports UI, - // check if this is a UI form submission. The UI sends _ui_submitted=true - // to distinguish form submissions from LLM calls. + // When MCP Apps are enabled and the client supports UI, route the + // call to the interactive form unless it is itself a form submission + // (the UI sends _ui_submitted=true) or it carries parameters the form + // cannot represent. Those must be applied directly so their values + // aren't silently dropped. uiSubmitted, _ := OptionalParam[bool](args, "_ui_submitted") - if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted { + if deps.IsFeatureEnabled(ctx, MCPAppsFeatureFlag) && clientSupportsUI(ctx, req) && !uiSubmitted && !pullRequestWriteHasNonFormParams(args) { return utils.NewToolResultText(fmt.Sprintf("Ready to create a pull request in %s/%s. IMPORTANT: The PR has NOT been created yet. Do NOT tell the user the PR was created. The user MUST click Submit in the form to create it.", owner, repo)), nil, nil } diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index 0faee23e2b..aff71e4c1a 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -2485,6 +2485,49 @@ func Test_CreatePullRequest_MCPAppsFeature_UIGate(t *testing.T) { assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", "non-UI client should execute directly") }) + + t.Run("UI client with non-form param skips form and executes directly", func(t *testing.T) { + // A parameter the form does not collect must bypass the form rather than + // be silently dropped. + request := createMCPRequestWithSession(t, ClientNameVSCodeInsiders, true, map[string]any{ + "owner": "owner", + "repo": "repo", + "title": "Test PR", + "head": "feature", + "base": "main", + "reviewers": []any{"octocat"}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, "Ready to create a pull request", + "non-form param should skip UI form") + assert.Contains(t, textContent.Text, "https://github.com/owner/repo/pull/42", + "non-form param call should execute directly and return PR URL") + }) +} + +func Test_pullRequestWriteHasNonFormParams(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + args map[string]any + want bool + }{ + {name: "no params", args: map[string]any{}, want: false}, + {name: "only form params", args: map[string]any{"owner": "o", "repo": "r", "title": "t", "body": "b", "head": "h", "base": "b", "draft": true, "maintainer_can_modify": false, "_ui_submitted": true}, want: false}, + {name: "unknown param present", args: map[string]any{"title": "t", "reviewers": []any{"octocat"}}, want: true}, + {name: "nil value is ignored", args: map[string]any{"reviewers": nil}, want: false}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, pullRequestWriteHasNonFormParams(tc.args)) + }) + } } func TestCreateAndSubmitPullRequestReview(t *testing.T) { diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index 863543fc14..fedb7f24f4 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -152,6 +152,7 @@ function CreateIssueApp() { try { const params: Record = { + ...(toolInput as Record | undefined), method: isUpdateMode ? "update" : "create", owner, repo, @@ -204,7 +205,7 @@ function CreateIssueApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, isUpdateMode, issueNumber, callTool, setModelContext]); + }, [title, body, owner, repo, isUpdateMode, issueNumber, toolInput, callTool, setModelContext]); const body_node = (() => { if (appError) { diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index bfefdbede0..abbeacb124 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -156,6 +156,7 @@ function CreatePRApp() { try { const result = await callTool("create_pull_request", { + ...(toolInput as Record | undefined), owner, repo, title: title.trim(), body: body.trim(), @@ -193,7 +194,7 @@ function CreatePRApp() { } finally { setIsSubmitting(false); } - }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, callTool, setModelContext]); + }, [title, body, owner, repo, head, base, isDraft, maintainerCanModify, toolInput, callTool, setModelContext]); if (successPR) { return ( From 7e79ae9aa55b6caf75c1bc327aca7f65de0ef756 Mon Sep 17 00:00:00 2001 From: Timur Date: Mon, 1 Jun 2026 11:45:53 +0200 Subject: [PATCH 18/34] feat: replace include_diff with detail enum on get_commit Replace the get_commit tool's two boolean flags (include_diff, include_patch) with a single detail enum: none / stats / full_patch. Why: - The two-boolean shape had an awkward dependency ("include_patch only applies when include_diff is true") and an impossible state (include_patch=true, include_diff=false) that was silently ignored. - A single discriminator collapses three meaningful response shapes into one orthogonal choice, makes the most expensive option ("full_patch") self-describing, and eliminates the "diff vs patch" naming confusion. Behavior: - Default ("stats") matches the previous default (include_diff=true, include_patch=false): per-file metadata with no patch text. Existing callers using defaults are unaffected. - "none" omits Stats and Files entirely (was include_diff=false). - "full_patch" is the new opt-in level that adds the unified diff to each MinimalCommitFile. Breaking change: callers that previously passed include_diff or include_patch must switch to detail. Callers using the defaults are unaffected. Changes: - Added Patch field to MinimalCommitFile. - Added commitDetail type, parseCommitDetail, and migrated convertToMinimalCommit to take a commitDetail. - Updated get_commit schema, list_commits caller (commitDetailNone), unit tests, toolsnap, and README. Co-authored-by: Sam Morrow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 2 +- pkg/github/__toolsnaps__/get_commit.snap | 13 ++- pkg/github/minimal_types.go | 73 ++++++++++----- pkg/github/repositories.go | 19 ++-- pkg/github/repositories_test.go | 114 +++++++++++++++++++++++ 5 files changed, 188 insertions(+), 33 deletions(-) diff --git a/README.md b/README.md index dc08311a4b..dff62321b8 100644 --- a/README.md +++ b/README.md @@ -1221,7 +1221,7 @@ The following sets of tools are available: - **get_commit** - Get commit details - **Required OAuth Scopes**: `repo` - - `include_diff`: Whether to include file diffs and stats in the response. Default is true. (boolean, optional) + - `detail`: Level of detail to include for changed files. "none" omits stats and files entirely. "stats" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. "full_patch" additionally includes the unified diff content for each file and can be very large. (string, optional) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) diff --git a/pkg/github/__toolsnaps__/get_commit.snap b/pkg/github/__toolsnaps__/get_commit.snap index 9e2346b59d..122e6210b3 100644 --- a/pkg/github/__toolsnaps__/get_commit.snap +++ b/pkg/github/__toolsnaps__/get_commit.snap @@ -6,10 +6,15 @@ "description": "Get details for a commit from a GitHub repository", "inputSchema": { "properties": { - "include_diff": { - "default": true, - "description": "Whether to include file diffs and stats in the response. Default is true.", - "type": "boolean" + "detail": { + "default": "stats", + "description": "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", + "enum": [ + "none", + "stats", + "full_patch" + ], + "type": "string" }, "owner": { "description": "Repository owner", diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index a93d29ead5..5200be297f 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -108,6 +108,7 @@ type MinimalCommitFile struct { Additions int `json:"additions,omitempty"` Deletions int `json:"deletions,omitempty"` Changes int `json:"changes,omitempty"` + Patch string `json:"patch,omitempty"` } // MinimalPRFile represents a file changed in a pull request. @@ -1463,8 +1464,34 @@ func newMinimalCommitFromCore(sha, htmlURL string, commit *github.Commit, author return minimalCommit } -// convertToMinimalCommit converts a GitHub API RepositoryCommit to MinimalCommit -func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) MinimalCommit { +// commitDetail controls how much per-file information convertToMinimalCommit +// includes in its output. +type commitDetail string + +const ( + // commitDetailNone omits Stats and Files entirely. + commitDetailNone commitDetail = "none" + // commitDetailStats includes Stats and Files with metadata only + // (filename, status, additions, deletions, changes) but no patch text. + commitDetailStats commitDetail = "stats" + // commitDetailFullPatch additionally includes the unified diff for each file. + commitDetailFullPatch commitDetail = "full_patch" +) + +// parseCommitDetail validates the user-supplied detail value and returns the +// default (stats) when the value is empty. +func parseCommitDetail(s string) (commitDetail, error) { + switch s { + case "": + return commitDetailStats, nil + case string(commitDetailNone), string(commitDetailStats), string(commitDetailFullPatch): + return commitDetail(s), nil + default: + return "", fmt.Errorf("invalid detail %q: must be one of \"none\", \"stats\", \"full_patch\"", s) + } +} + +func convertToMinimalCommit(commit *github.RepositoryCommit, detail commitDetail) MinimalCommit { minimalCommit := newMinimalCommitFromCore( commit.GetSHA(), commit.GetHTMLURL(), @@ -1473,28 +1500,32 @@ func convertToMinimalCommit(commit *github.RepositoryCommit, includeDiffs bool) commit.Committer, ) - // Only include stats and files if includeDiffs is true - if includeDiffs { - if commit.Stats != nil { - minimalCommit.Stats = &MinimalCommitStats{ - Additions: commit.Stats.GetAdditions(), - Deletions: commit.Stats.GetDeletions(), - Total: commit.Stats.GetTotal(), - } + if detail == commitDetailNone { + return minimalCommit + } + + if commit.Stats != nil { + minimalCommit.Stats = &MinimalCommitStats{ + Additions: commit.Stats.GetAdditions(), + Deletions: commit.Stats.GetDeletions(), + Total: commit.Stats.GetTotal(), } + } - if len(commit.Files) > 0 { - minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files)) - for _, file := range commit.Files { - minimalFile := MinimalCommitFile{ - Filename: file.GetFilename(), - Status: file.GetStatus(), - Additions: file.GetAdditions(), - Deletions: file.GetDeletions(), - Changes: file.GetChanges(), - } - minimalCommit.Files = append(minimalCommit.Files, minimalFile) + if len(commit.Files) > 0 { + minimalCommit.Files = make([]MinimalCommitFile, 0, len(commit.Files)) + for _, file := range commit.Files { + minimalFile := MinimalCommitFile{ + Filename: file.GetFilename(), + Status: file.GetStatus(), + Additions: file.GetAdditions(), + Deletions: file.GetDeletions(), + Changes: file.GetChanges(), + } + if detail == commitDetailFullPatch { + minimalFile.Patch = file.GetPatch() } + minimalCommit.Files = append(minimalCommit.Files, minimalFile) } } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index d682b5c3d7..040a968cf9 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -46,10 +46,11 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { Type: "string", Description: "Commit SHA, branch name, or tag name", }, - "include_diff": { - Type: "boolean", - Description: "Whether to include file diffs and stats in the response. Default is true.", - Default: json.RawMessage(`true`), + "detail": { + Type: "string", + Enum: []any{"none", "stats", "full_patch"}, + Description: "Level of detail to include for changed files. \"none\" omits stats and files entirely. \"stats\" (default) includes per-file metadata: filename, status, and lines-of-code counts (additions, deletions, changes), with no patch content. \"full_patch\" additionally includes the unified diff content for each file and can be very large.", + Default: json.RawMessage(`"stats"`), }, }, Required: []string{"owner", "repo", "sha"}, @@ -69,7 +70,11 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - includeDiff, err := OptionalBoolParamWithDefault(args, "include_diff", true) + detailRaw, err := OptionalParam[string](args, "detail") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + detail, err := parseCommitDetail(detailRaw) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -106,7 +111,7 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { } // Convert to minimal commit - minimalCommit := convertToMinimalCommit(commit, includeDiff) + minimalCommit := convertToMinimalCommit(commit, detail) r, err := json.Marshal(minimalCommit) if err != nil { @@ -252,7 +257,7 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { // Convert to minimal commits minimalCommits := make([]MinimalCommit, len(commits)) for i, commit := range commits { - minimalCommits[i] = convertToMinimalCommit(commit, false) + minimalCommits[i] = convertToMinimalCommit(commit, commitDetailNone) } r, err := json.Marshal(minimalCommits) diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 03535f1d26..e1b7f94f53 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -1028,6 +1028,120 @@ func Test_GetCommit(t *testing.T) { } } +func Test_GetCommit_Detail(t *testing.T) { + mockCommit := &github.RepositoryCommit{ + SHA: github.Ptr("abc123def456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + Commit: &github.Commit{ + Message: github.Ptr("First commit"), + }, + Stats: &github.CommitStats{ + Additions: github.Ptr(10), + Deletions: github.Ptr(2), + Total: github.Ptr(12), + }, + Files: []*github.CommitFile{ + { + Filename: github.Ptr("file1.go"), + Status: github.Ptr("modified"), + Additions: github.Ptr(10), + Deletions: github.Ptr(2), + Changes: github.Ptr(12), + Patch: github.Ptr("@@ -1,2 +1,10 @@\n+new line"), + }, + }, + } + + cases := []struct { + name string + args map[string]any + expectFiles bool + expectStats bool + expectPatch bool + expectError string + }{ + { + name: "default returns stats", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456"}, + expectFiles: true, + expectStats: true, + expectPatch: false, + }, + { + name: "detail=none omits stats and files", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "none"}, + expectFiles: false, + expectStats: false, + expectPatch: false, + }, + { + name: "detail=stats returns metadata without patch", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "stats"}, + expectFiles: true, + expectStats: true, + expectPatch: false, + }, + { + name: "detail=full_patch includes patch text", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "full_patch"}, + expectFiles: true, + expectStats: true, + expectPatch: true, + }, + { + name: "invalid detail value is rejected", + args: map[string]any{"owner": "owner", "repo": "repo", "sha": "abc123def456", "detail": "everything"}, + expectError: `invalid detail "everything"`, + }, + } + + serverTool := GetCommit(translations.NullTranslationHelper) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), + }) + client := mustNewGHClient(t, mockedClient) + deps := BaseDeps{Client: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectError != "" { + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, tc.expectError) + return + } + require.False(t, result.IsError) + + var returned MinimalCommit + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &returned)) + + if tc.expectStats { + require.NotNil(t, returned.Stats) + assert.Equal(t, 12, returned.Stats.Total) + } else { + assert.Nil(t, returned.Stats) + } + + if tc.expectFiles { + require.Len(t, returned.Files, 1) + assert.Equal(t, "file1.go", returned.Files[0].Filename) + if tc.expectPatch { + assert.Equal(t, "@@ -1,2 +1,10 @@\n+new line", returned.Files[0].Patch) + } else { + assert.Empty(t, returned.Files[0].Patch) + } + } else { + assert.Empty(t, returned.Files) + } + }) + } +} + func Test_ListCommits(t *testing.T) { // Verify tool definition once serverTool := ListCommits(translations.NullTranslationHelper) From 2a5d38a2825519e109bcbd3ccfd13a459582a455 Mon Sep 17 00:00:00 2001 From: Matt Holloway Date: Tue, 2 Jun 2026 14:36:43 +0100 Subject: [PATCH 19/34] MCP Apps: Open created issue/PR link via host open-link capability (#2593) * Open created issue/PR link via host open-link capability The success views for issue-write and pr-write rendered a plain anchor to the created/updated issue or PR. MCP Apps run in a sandboxed iframe where target="_blank" navigation may be blocked, so clicking the link did nothing in some hosts. Route the click through the host's ui/open-link capability (already exposed by useMcpApp as openLink), which asks the host to open the URL in the user's browser. The hook now also falls back to window.open when the host denies the request, in addition to the existing no-app fallback. The href is retained so right-click/copy and native fallback still work. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Prevent default anchor navigation before URL check When the success-view link URL was unavailable ("#"), the click handler returned before calling e.preventDefault(), so the anchor's default target="_blank" navigation still ran and could open a stray blank tab. Call preventDefault() first, then no-op when the URL is unavailable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ui/src/apps/issue-write/App.tsx | 13 ++++++++++++- ui/src/apps/pr-write/App.tsx | 14 ++++++++++++-- ui/src/hooks/useMcpApp.ts | 7 ++++++- 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/ui/src/apps/issue-write/App.tsx b/ui/src/apps/issue-write/App.tsx index fedb7f24f4..6c46b8c081 100644 --- a/ui/src/apps/issue-write/App.tsx +++ b/ui/src/apps/issue-write/App.tsx @@ -33,12 +33,14 @@ function SuccessView({ repo, submittedTitle, isUpdate, + openLink, }: { issue: IssueResult; owner: string; repo: string; submittedTitle: string; isUpdate: boolean; + openLink: (url: string) => Promise; }) { const issueUrl = issue.html_url || issue.url || issue.URL || "#"; @@ -87,6 +89,14 @@ function SuccessView({ href={issueUrl} 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 (issueUrl === "#") return; + void openLink(issueUrl); + }} style={{ fontWeight: 600, fontSize: "14px", @@ -121,7 +131,7 @@ function CreateIssueApp() { const [error, setError] = useState(null); const [successIssue, setSuccessIssue] = useState(null); - const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-issue-write", }); @@ -232,6 +242,7 @@ function CreateIssueApp() { repo={repo} submittedTitle={title} isUpdate={isUpdateMode} + openLink={openLink} /> ); } diff --git a/ui/src/apps/pr-write/App.tsx b/ui/src/apps/pr-write/App.tsx index abbeacb124..245753a1bc 100644 --- a/ui/src/apps/pr-write/App.tsx +++ b/ui/src/apps/pr-write/App.tsx @@ -36,11 +36,13 @@ function SuccessView({ owner, repo, submittedTitle, + openLink, }: { pr: PRResult; owner: string; repo: string; submittedTitle: string; + openLink: (url: string) => Promise; }) { const prUrl = pr.html_url || pr.url || pr.URL || "#"; @@ -89,6 +91,14 @@ function SuccessView({ 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", @@ -126,7 +136,7 @@ function CreatePRApp() { const [isDraft, setIsDraft] = useState(false); const [maintainerCanModify, setMaintainerCanModify] = useState(true); - const { app, error: appError, toolInput, callTool, hostContext, setModelContext } = useMcpApp({ + const { app, error: appError, toolInput, callTool, hostContext, setModelContext, openLink } = useMcpApp({ appName: "github-mcp-server-create-pull-request", }); @@ -199,7 +209,7 @@ function CreatePRApp() { if (successPR) { return ( - + ); } diff --git a/ui/src/hooks/useMcpApp.ts b/ui/src/hooks/useMcpApp.ts index b060ea6ee2..cf386520f0 100644 --- a/ui/src/hooks/useMcpApp.ts +++ b/ui/src/hooks/useMcpApp.ts @@ -106,7 +106,12 @@ export function useMcpApp({ window.open(url, "_blank", "noopener,noreferrer"); return; } - await app.openLink({ url }); + 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] ); From 33849e98eb4797107d033b6d29ffbd5f6d9e8a2b Mon Sep 17 00:00:00 2001 From: Ross Tarrant Date: Thu, 4 Jun 2026 10:17:23 +0100 Subject: [PATCH 20/34] fix: Empty assignees array should clear assignees (#2600) * fix: Empty assignees array should clear assignees --- pkg/github/issues.go | 41 ++++++++++++++++++++--- pkg/github/issues_test.go | 68 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+), 5 deletions(-) diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 6e9cdae53b..ef9bbc4305 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1972,12 +1972,16 @@ Options are: if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + assigneesValue, assigneesProvided := args["assignees"] + assigneesProvided = assigneesProvided && assigneesValue != nil // Get labels labels, err := OptionalStringArrayParam(args, "labels") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + labelsValue, labelsProvided := args["labels"] + labelsProvided = labelsProvided && labelsValue != nil // Get optional milestone milestone, err := OptionalIntParam(args, "milestone") @@ -2049,7 +2053,10 @@ Options are: if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf) + result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, issueFieldValues, fieldIDsToDelete, state, stateReason, duplicateOf, UpdateIssueOptions{ + AssigneesProvided: assigneesProvided, + LabelsProvided: labelsProvided, + }) return result, nil, err default: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil @@ -2204,12 +2211,16 @@ Options are: if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + assigneesValue, assigneesProvided := args["assignees"] + assigneesProvided = assigneesProvided && assigneesValue != nil // Get labels labels, err := OptionalStringArrayParam(args, "labels") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } + labelsValue, labelsProvided := args["labels"] + labelsProvided = labelsProvided && labelsValue != nil // Get optional milestone milestone, err := OptionalIntParam(args, "milestone") @@ -2266,7 +2277,10 @@ Options are: if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf) + result, err := UpdateIssue(ctx, client, gqlClient, owner, repo, issueNumber, title, body, assignees, labels, milestoneNum, issueType, nil, nil, state, stateReason, duplicateOf, UpdateIssueOptions{ + AssigneesProvided: assigneesProvided, + LabelsProvided: labelsProvided, + }) return result, nil, err default: return utils.NewToolResultError("invalid method, must be either 'create' or 'update'"), nil, nil @@ -2330,7 +2344,24 @@ func CreateIssue(ctx context.Context, client *github.Client, owner string, repo return utils.NewToolResultText(string(r)), nil } -func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int) (*mcp.CallToolResult, error) { +// UpdateIssueOptions controls which optional fields are included in an issue update request. +type UpdateIssueOptions struct { + // AssigneesProvided sends the assignees field even when the slice is empty. + AssigneesProvided bool + // LabelsProvided sends the labels field even when the slice is empty. + LabelsProvided bool +} + +func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4.Client, owner string, repo string, issueNumber int, title string, body string, assignees []string, labels []string, milestoneNum int, issueType string, issueFieldValues []*github.IssueRequestFieldValue, fieldIDsToDelete []int64, state string, stateReason string, duplicateOf int, opts ...UpdateIssueOptions) (*mcp.CallToolResult, error) { + updateOptions := UpdateIssueOptions{ + AssigneesProvided: len(assignees) > 0, + LabelsProvided: len(labels) > 0, + } + for _, opt := range opts { + updateOptions.AssigneesProvided = updateOptions.AssigneesProvided || opt.AssigneesProvided + updateOptions.LabelsProvided = updateOptions.LabelsProvided || opt.LabelsProvided + } + // Create the issue request with only provided fields issueRequest := &github.IssueRequest{} @@ -2343,11 +2374,11 @@ func UpdateIssue(ctx context.Context, client *github.Client, gqlClient *githubv4 issueRequest.Body = github.Ptr(body) } - if len(labels) > 0 { + if updateOptions.LabelsProvided { issueRequest.Labels = &labels } - if len(assignees) > 0 { + if updateOptions.AssigneesProvided { issueRequest.Assignees = &assignees } diff --git a/pkg/github/issues_test.go b/pkg/github/issues_test.go index d794ad1679..7e47cdb527 100644 --- a/pkg/github/issues_test.go +++ b/pkg/github/issues_test.go @@ -2987,6 +2987,33 @@ func Test_UpdateIssue(t *testing.T) { expectError: false, expectedIssue: mockUpdatedIssue, }, + { + name: "partial update clears labels and assignees", + mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "labels": []any{}, + "assignees": []any{}, + }).andThen( + mockResponse(t, http.StatusOK, &github.Issue{ + Number: github.Ptr(123), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + }), + ), + }), + mockedGQLClient: githubv4mock.NewMockedHTTPClient(), + requestArgs: map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(123), + "labels": []any{}, + "assignees": []any{}, + }, + expectError: false, + expectedIssue: &github.Issue{ + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/123"), + }, + }, { name: "partial update with issue fields reconciled by names", mockedRESTClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -3406,6 +3433,47 @@ func Test_UpdateIssue(t *testing.T) { } } +func Test_LegacyUpdateIssueClearsLabelsAndAssignees(t *testing.T) { + serverTool := LegacyIssueWrite(translations.NullTranslationHelper) + updatedIssue := &github.Issue{ + Number: github.Ptr(8), + HTMLURL: github.Ptr("https://github.com/owner/repo/issues/8"), + } + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ + "labels": []any{}, + "assignees": []any{}, + }).andThen(mockResponse(t, http.StatusOK, updatedIssue)), + })) + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + deps := BaseDeps{ + Client: client, + GQLClient: gqlClient, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "method": "update", + "owner": "owner", + "repo": "repo", + "issue_number": float64(8), + "labels": []any{}, + "assignees": []any{}, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + require.NoError(t, err) + if result.IsError { + t.Fatalf("Unexpected error result: %s", getErrorResult(t, result).Text) + } + textContent := getTextResult(t, result) + + var updateResp MinimalResponse + require.NoError(t, json.Unmarshal([]byte(textContent.Text), &updateResp)) + assert.Equal(t, updatedIssue.GetHTMLURL(), updateResp.URL) +} + func Test_ParseISOTimestamp(t *testing.T) { tests := []struct { name string From 457f59932ac041c9276e03e634b0e0c30f19ba3e Mon Sep 17 00:00:00 2001 From: Alon Dahari Date: Fri, 5 Jun 2026 15:04:22 +0100 Subject: [PATCH 21/34] Add confidence parameter to issue mutation MCP tools (#2605) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an optional confidence integer parameter (0–100) to update_issue_type, update_issue_labels, and set_issue_fields MCP tools. The confidence score is passed through to the REST/GraphQL API on mutation calls. - Rename structs to WithIntent (labelWithIntent, issueTypeWithIntent) - Add confidence schema property (integer, min 0, max 100) with prompt guidance describing what different confidence levels represent - Update tool descriptions to encourage including confidence scores - Pass confidence in the API request body alongside rationale/suggest Closes github/plan-track-agentic-toolkit#219 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/feature-flags.md | 3 +- .../request_pull_request_reviewers.snap | 2 +- .../__toolsnaps__/set_issue_fields.snap | 11 +- .../__toolsnaps__/update_issue_labels.snap | 11 +- .../__toolsnaps__/update_issue_type.snap | 11 +- .../__toolsnaps__/update_pull_request.snap | 2 +- pkg/github/granular_tools_test.go | 321 ++++++++++++++++++ pkg/github/issues_granular.go | 96 ++++-- 8 files changed, 425 insertions(+), 32 deletions(-) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 0b75a61bac..63fb28dc44 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -198,6 +198,7 @@ runtime behavior (such as output formatting) won't appear here. - **update_issue_type** - Update Issue Type - **Required OAuth Scopes**: `repo` + - `confidence`: How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal. (string, optional) - `is_suggestion`: If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API. (boolean, optional) - `issue_number`: The issue number to update (number, required) - `issue_type`: The issue type to set (string, required) @@ -240,7 +241,7 @@ runtime behavior (such as output formatting) won't appear here. - `owner`: Repository owner (username or organization) (string, required) - `pullNumber`: The pull request number (number, required) - `repo`: Repository name (string, required) - - `reviewers`: GitHub usernames to request reviews from (string[], required) + - `reviewers`: GitHub usernames or ORG/team-slug team reviewers to request reviews from (string[], required) - **resolve_review_thread** - Resolve Review Thread - **Required OAuth Scopes**: `repo` diff --git a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap index 7e6d33a274..20f1ab62b6 100644 --- a/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap +++ b/pkg/github/__toolsnaps__/request_pull_request_reviewers.snap @@ -37,4 +37,4 @@ "type": "object" }, "name": "request_pull_request_reviewers" -} +} \ No newline at end of file diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap index 88c88fdc65..e46febeeda 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -4,13 +4,22 @@ "openWorldHint": true, "title": "Set Issue Fields" }, - "description": "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue.", + "description": "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.", "inputSchema": { "properties": { "fields": { "description": "Array of issue field values to set. Each element must have a 'field_id' (string, the GraphQL node ID of the field) and exactly one value field: 'text_value' for text fields, 'number_value' for number fields, 'date_value' (ISO 8601 date string) for date fields, or 'single_select_option_id' (the GraphQL node ID of the option) for single select fields. Set 'delete' to true to remove a field value.", "items": { "properties": { + "confidence": { + "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, "date_value": { "description": "The value to set for a date field (ISO 8601 date string)", "type": "string" diff --git a/pkg/github/__toolsnaps__/update_issue_labels.snap b/pkg/github/__toolsnaps__/update_issue_labels.snap index 3bdbdfc9ef..21f7fea6b6 100644 --- a/pkg/github/__toolsnaps__/update_issue_labels.snap +++ b/pkg/github/__toolsnaps__/update_issue_labels.snap @@ -4,7 +4,7 @@ "openWorldHint": true, "title": "Update Issue Labels" }, - "description": "Update the labels of an existing issue. This replaces the current labels with the provided list.", + "description": "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.", "inputSchema": { "properties": { "issue_number": { @@ -22,6 +22,15 @@ }, { "properties": { + "confidence": { + "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, "is_suggestion": { "description": "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. Whether the label is applied or recorded as a proposal is determined by the API.", "type": "boolean" diff --git a/pkg/github/__toolsnaps__/update_issue_type.snap b/pkg/github/__toolsnaps__/update_issue_type.snap index da749cd466..2f39b2d3b8 100644 --- a/pkg/github/__toolsnaps__/update_issue_type.snap +++ b/pkg/github/__toolsnaps__/update_issue_type.snap @@ -4,9 +4,18 @@ "openWorldHint": true, "title": "Update Issue Type" }, - "description": "Update the type of an existing issue (e.g. 'bug', 'feature').", + "description": "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.", "inputSchema": { "properties": { + "confidence": { + "description": "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + "enum": [ + "low", + "medium", + "high" + ], + "type": "string" + }, "is_suggestion": { "description": "If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. Whether the type is applied or recorded as a proposal is determined by the API.", "type": "boolean" diff --git a/pkg/github/__toolsnaps__/update_pull_request.snap b/pkg/github/__toolsnaps__/update_pull_request.snap index 640df79702..3d87fe75fe 100644 --- a/pkg/github/__toolsnaps__/update_pull_request.snap +++ b/pkg/github/__toolsnaps__/update_pull_request.snap @@ -61,4 +61,4 @@ "type": "object" }, "name": "update_pull_request" -} +} \ No newline at end of file diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index ae34c1dd42..eb688a0b9f 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -461,6 +461,91 @@ func TestGranularUpdateIssueLabelsInvalidRationale(t *testing.T) { } } +func TestGranularUpdateIssueLabelsConfidence(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "label with confidence triggers object form", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "confidence": "high"}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "bug", "confidence": "high"}, + }, + }, + }, + { + name: "label with confidence and rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, + }, + }, + expectedReq: map[string]any{ + "labels": []any{ + map[string]any{"name": "bug", "rationale": "Reports a crash", "confidence": "medium"}, + }, + }, + }, + { + name: "invalid confidence value", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "labels": []any{ + map[string]any{"name": "bug", "confidence": "very_high"}, + }, + }, + expectedReq: nil, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + if tc.expectedReq == nil { + // Error case + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, "confidence must be one of: low, medium, high") + return + } + + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueLabels(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + func TestGranularUpdateIssueMilestone(t *testing.T) { client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, map[string]any{ @@ -642,6 +727,128 @@ func TestGranularUpdateIssueTypeInvalidRationale(t *testing.T) { } } +func TestGranularUpdateIssueTypeConfidence(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedReq map[string]any + }{ + { + name: "type with confidence only", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": "high", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "bug", + "confidence": "high", + }, + }, + }, + { + name: "type with confidence and rationale", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "feature", + "rationale": "Asks for dark mode support", + "confidence": "medium", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "feature", + "rationale": "Asks for dark mode support", + "confidence": "medium", + }, + }, + }, + { + name: "type with low confidence triggers object form", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": "low", + }, + expectedReq: map[string]any{ + "type": map[string]any{ + "value": "bug", + "confidence": "low", + }, + }, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + PatchReposIssuesByOwnerByRepoByIssueNumber: expectRequestBody(t, tc.expectedReq). + andThen(mockResponse(t, http.StatusOK, &gogithub.Issue{Number: gogithub.Ptr(1)})), + })) + deps := BaseDeps{Client: client} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + } +} + +func TestGranularUpdateIssueTypeInvalidConfidence(t *testing.T) { + tests := []struct { + name string + requestArgs map[string]any + expectedErrText string + }{ + { + name: "invalid confidence value", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": "very_high", + }, + expectedErrText: "confidence must be one of: low, medium, high", + }, + { + name: "confidence wrong type", + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(1), + "issue_type": "bug", + "confidence": float64(85), + }, + expectedErrText: "parameter confidence is not of type string", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + deps := BaseDeps{Client: mustNewGHClient(t, MockHTTPClientWithHandlers(nil))} + serverTool := GranularUpdateIssueType(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrText) + }) + } +} + func TestGranularUpdateIssueState(t *testing.T) { tests := []struct { name string @@ -1389,6 +1596,120 @@ func TestGranularSetIssueFields(t *testing.T) { assert.Contains(t, textContent.Text, "field rationale must be 280 characters or less") }) + t.Run("successful set with confidence", func(t *testing.T) { + confidence := "high" + matchers := []githubv4mock.Matcher{ + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + TextValue: githubv4.NewString(githubv4.String("hello")), + Confidence: &confidence, + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "number": 5, + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "confidence": "high", + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError) + }) + + t.Run("invalid confidence value returns error", func(t *testing.T) { + deps := BaseDeps{} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "confidence": "very_high", + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + textContent := getTextResult(t, result) + assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high") + }) + t.Run("successful set with suggest flag", func(t *testing.T) { suggestTrue := githubv4.Boolean(true) matchers := []githubv4mock.Matcher{ diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 73fa75413c..22d26cc47f 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -258,17 +258,18 @@ func GranularUpdateIssueAssignees(t translations.TranslationHelperFunc) inventor ) } -// labelWithRationale represents the object form of a label entry, allowing a -// rationale and/or suggest flag to be sent alongside the label name. -type labelWithRationale struct { - Name string `json:"name"` - Rationale string `json:"rationale,omitempty"` - Suggest bool `json:"suggest,omitempty"` +// labelWithIntent represents the object form of a label entry, allowing a +// rationale, confidence level, and/or suggest flag to be sent alongside the label name. +type labelWithIntent struct { + Name string `json:"name"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + Suggest bool `json:"suggest,omitempty"` } // labelsUpdateRequest is a custom request body for updating an issue's labels // where individual labels may optionally include a rationale. Each element of -// Labels is either a string (label name) or a labelWithRationale object. +// Labels is either a string (label name) or a labelWithIntent object. type labelsUpdateRequest struct { Labels []any `json:"labels"` } @@ -279,7 +280,7 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_labels", - Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list."), + Description: t("TOOL_UPDATE_ISSUE_LABELS_DESCRIPTION", "Update the labels of an existing issue. This replaces the current labels with the provided list. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_ISSUE_LABELS_USER_TITLE", "Update Issue Labels"), ReadOnlyHint: false, @@ -321,6 +322,11 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S "State the concrete signal (e.g. 'Reports a crash when saving' → bug).", MaxLength: jsonschema.Ptr(280), }, + "confidence": { + Type: "string", + Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + Enum: []any{"low", "medium", "high"}, + }, "is_suggestion": { Type: "boolean", Description: "If true, this label is sent to the API as a suggestion (suggest:true) rather than an applied label. " + @@ -387,18 +393,25 @@ func GranularUpdateIssueLabels(t translations.TranslationHelperFunc) inventory.S if len([]rune(rationale)) > 280 { return utils.NewToolResultError("label rationale must be 280 characters or less"), nil, nil } + confidence, err := OptionalParam[string](v, "confidence") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { + return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil + } isSuggestion, err := OptionalParam[bool](v, "is_suggestion") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - if rationale == "" && !isSuggestion { + if rationale == "" && !isSuggestion && confidence == "" { payload = append(payload, name) } else { useObjectForm = true - payload = append(payload, labelWithRationale{Name: name, Rationale: rationale, Suggest: isSuggestion}) + payload = append(payload, labelWithIntent{Name: name, Rationale: rationale, Confidence: confidence, Suggest: isSuggestion}) } default: - return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale' and/or 'is_suggestion'"), nil, nil + return utils.NewToolResultError("each label must be a string or an object with 'name' and optional 'rationale', 'confidence', and/or 'is_suggestion'"), nil, nil } } @@ -470,18 +483,19 @@ func GranularUpdateIssueMilestone(t translations.TranslationHelperFunc) inventor ) } -// issueTypeWithRationale represents the object form of the issue type field, -// allowing a rationale and/or suggest flag to be sent alongside the type name. -type issueTypeWithRationale struct { - Value string `json:"value"` - Rationale string `json:"rationale,omitempty"` - Suggest bool `json:"suggest,omitempty"` +// issueTypeWithIntent represents the object form of the issue type field, +// allowing a rationale, confidence level, and/or suggest flag to be sent alongside the type name. +type issueTypeWithIntent struct { + Value string `json:"value"` + Rationale string `json:"rationale,omitempty"` + Confidence string `json:"confidence,omitempty"` + Suggest bool `json:"suggest,omitempty"` } // issueTypeUpdateRequest is a custom request body for updating an issue type -// with an optional rationale, using the object form that the REST API accepts. +// with optional intent metadata, using the object form that the REST API accepts. type issueTypeUpdateRequest struct { - Type issueTypeWithRationale `json:"type"` + Type issueTypeWithIntent `json:"type"` } // GranularUpdateIssueType creates a tool to update an issue's type. @@ -490,7 +504,7 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser ToolsetMetadataIssues, mcp.Tool{ Name: "update_issue_type", - Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature')."), + Description: t("TOOL_UPDATE_ISSUE_TYPE_DESCRIPTION", "Update the type of an existing issue (e.g. 'bug', 'feature'). When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_UPDATE_ISSUE_TYPE_USER_TITLE", "Update Issue Type"), ReadOnlyHint: false, @@ -523,6 +537,11 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser "State the concrete signal (e.g. 'Reports a crash when saving' → bug, 'Asks for dark mode support' → feature).", MaxLength: jsonschema.Ptr(280), }, + "confidence": { + Type: "string", + Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + Enum: []any{"low", "medium", "high"}, + }, "is_suggestion": { Type: "boolean", Description: "If true, this issue type change is sent to the API as a suggestion (suggest:true) rather than an applied value. " + @@ -558,6 +577,13 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser if len([]rune(rationale)) > 280 { return utils.NewToolResultError("parameter rationale must be 280 characters or less"), nil, nil } + confidence, err := OptionalParam[string](args, "confidence") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { + return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil + } isSuggestion, err := OptionalParam[bool](args, "is_suggestion") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil @@ -569,12 +595,13 @@ func GranularUpdateIssueType(t translations.TranslationHelperFunc) inventory.Ser } var body any - if rationale != "" || isSuggestion { + if rationale != "" || isSuggestion || confidence != "" { body = &issueTypeUpdateRequest{ - Type: issueTypeWithRationale{ - Value: issueType, - Rationale: rationale, - Suggest: isSuggestion, + Type: issueTypeWithIntent{ + Value: issueType, + Rationale: rationale, + Confidence: confidence, + Suggest: isSuggestion, }, } } else { @@ -887,6 +914,7 @@ type IssueFieldCreateOrUpdateInput struct { SingleSelectOptionID *githubv4.ID `json:"singleSelectOptionId,omitempty"` Delete *githubv4.Boolean `json:"delete,omitempty"` Rationale *githubv4.String `json:"rationale,omitempty"` + Confidence *string `json:"confidence,omitempty"` Suggest *githubv4.Boolean `json:"suggest,omitempty"` } @@ -896,7 +924,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv ToolsetMetadataIssues, mcp.Tool{ Name: "set_issue_fields", - Description: t("TOOL_SET_ISSUE_FIELDS_DESCRIPTION", "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue."), + Description: t("TOOL_SET_ISSUE_FIELDS_DESCRIPTION", "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_SET_ISSUE_FIELDS_USER_TITLE", "Set Issue Fields"), ReadOnlyHint: false, @@ -956,6 +984,11 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv "State the concrete signal (e.g. 'Reports a crash when saving' → high priority).", MaxLength: jsonschema.Ptr(280), }, + "confidence": { + Type: "string", + Description: "How confident you are in this choice. Use 'high' for clear signal or explicit user request, 'medium' for reasonable inference with some ambiguity, 'low' for best guess with limited signal.", + Enum: []any{"low", "medium", "high"}, + }, "is_suggestion": { Type: "boolean", Description: "If true, this field value is sent to the API as a suggestion (suggest:true) rather than an applied value. " + @@ -1073,6 +1106,17 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv } } + confidence, err := OptionalParam[string](fieldMap, "confidence") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if confidence != "" && confidence != "low" && confidence != "medium" && confidence != "high" { + return utils.NewToolResultError("confidence must be one of: low, medium, high"), nil, nil + } + if confidence != "" { + input.Confidence = &confidence + } + isSuggestion, err := OptionalParam[bool](fieldMap, "is_suggestion") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil From d42bb3e678b918b8e34a26fd576935d461ccc65d Mon Sep 17 00:00:00 2001 From: Boaz Reicher <44614829+boazreicher@users.noreply.github.com> Date: Wed, 10 Jun 2026 11:39:16 +0300 Subject: [PATCH 22/34] Send update_issue_suggestions feature flag for set_issue_fields mutation (#2638) * Using issues suggestions feature flag * Gate set_issue_fields confidence behind update_issue_confidence flag The GitHub GraphQL API does not yet accept the per-field confidence input on setIssueFieldValue mutations. Hide it from the user-facing schema and drop it from the mutation payload unless the new update_issue_confidence feature flag is enabled so users do not try to use it before the API supports it. * adding back confidence * Update set_issue_fields confidence schema and toolsnap --- .../__toolsnaps__/set_issue_fields.snap | 2 +- pkg/github/granular_tools_test.go | 190 ++++++++++++++++++ pkg/github/issues_granular.go | 8 +- 3 files changed, 197 insertions(+), 3 deletions(-) diff --git a/pkg/github/__toolsnaps__/set_issue_fields.snap b/pkg/github/__toolsnaps__/set_issue_fields.snap index e46febeeda..8f25d09699 100644 --- a/pkg/github/__toolsnaps__/set_issue_fields.snap +++ b/pkg/github/__toolsnaps__/set_issue_fields.snap @@ -4,7 +4,7 @@ "openWorldHint": true, "title": "Set Issue Fields" }, - "description": "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice.", + "description": "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue.", "inputSchema": { "properties": { "fields": { diff --git a/pkg/github/granular_tools_test.go b/pkg/github/granular_tools_test.go index eb688a0b9f..27e8079f97 100644 --- a/pkg/github/granular_tools_test.go +++ b/pkg/github/granular_tools_test.go @@ -8,6 +8,8 @@ import ( "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/http/headers" + transportpkg "github.com/github/github-mcp-server/pkg/http/transport" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/translations" gogithub "github.com/google/go-github/v87/github" @@ -1710,6 +1712,97 @@ func TestGranularSetIssueFields(t *testing.T) { assert.Contains(t, textContent.Text, "confidence must be one of: low, medium, high") }) + t.Run("confidence is sent when supplied", func(t *testing.T) { + confidence := "high" + matchers := []githubv4mock.Matcher{ + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + TextValue: githubv4.NewString(githubv4.String("hello")), + Confidence: &confidence, + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "number": 5, + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + gqlClient := githubv4.NewClient(githubv4mock.NewMockedHTTPClient(matchers...)) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{ + "field_id": "FIELD_1", + "text_value": "hello", + "confidence": "high", + }, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + assert.False(t, result.IsError, getTextResult(t, result).Text) + }) + t.Run("successful set with suggest flag", func(t *testing.T) { suggestTrue := githubv4.Boolean(true) matchers := []githubv4mock.Matcher{ @@ -1802,4 +1895,101 @@ func TestGranularSetIssueFields(t *testing.T) { require.NoError(t, err) assert.False(t, result.IsError) }) + + t.Run("sends GraphQL-Features: update_issue_suggestions header on mutation", func(t *testing.T) { + matchers := []githubv4mock.Matcher{ + githubv4mock.NewQueryMatcher( + struct { + Repository struct { + Issue struct { + ID githubv4.ID + } `graphql:"issue(number: $issueNumber)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + }{}, + map[string]any{ + "owner": githubv4.String("owner"), + "repo": githubv4.String("repo"), + "issueNumber": githubv4.Int(5), + }, + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "issue": map[string]any{"id": "ISSUE_123"}, + }, + }), + ), + githubv4mock.NewMutationMatcher( + struct { + SetIssueFieldValue struct { + Issue struct { + ID githubv4.ID + Number githubv4.Int + URL githubv4.String + } + IssueFieldValues []struct { + TextValue struct { + Value string + } `graphql:"... on IssueFieldTextValue"` + SingleSelectValue struct { + Name string + } `graphql:"... on IssueFieldSingleSelectValue"` + DateValue struct { + Value string + } `graphql:"... on IssueFieldDateValue"` + NumberValue struct { + Value float64 + } `graphql:"... on IssueFieldNumberValue"` + } + } `graphql:"setIssueFieldValue(input: $input)"` + }{}, + SetIssueFieldValueInput{ + IssueID: githubv4.ID("ISSUE_123"), + IssueFields: []IssueFieldCreateOrUpdateInput{ + { + FieldID: githubv4.ID("FIELD_1"), + TextValue: githubv4.NewString(githubv4.String("hello")), + }, + }, + }, + nil, + githubv4mock.DataResponse(map[string]any{ + "setIssueFieldValue": map[string]any{ + "issue": map[string]any{ + "id": "ISSUE_123", + "number": 5, + "url": "https://github.com/owner/repo/issues/5", + }, + }, + }), + ), + } + + // Build a transport chain matching production: GraphQLFeaturesTransport + // wraps a header-capturing spy, which forwards to the mock's RoundTripper. + // This verifies the mutation request sets the update_issue_suggestions + // feature flag so the rationale/suggest input fields are accepted. + mockClient := githubv4mock.NewMockedHTTPClient(matchers...) + spy := &headerCaptureTransport{inner: mockClient.Transport} + httpClient := &http.Client{ + Transport: &transportpkg.GraphQLFeaturesTransport{Transport: spy}, + } + gqlClient := githubv4.NewClient(httpClient) + deps := BaseDeps{GQLClient: gqlClient} + serverTool := GranularSetIssueFields(translations.NullTranslationHelper) + handler := serverTool.Handler(deps) + + request := createMCPRequest(map[string]any{ + "owner": "owner", + "repo": "repo", + "issue_number": float64(5), + "fields": []any{ + map[string]any{"field_id": "FIELD_1", "text_value": "hello"}, + }, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, getTextResult(t, result).Text) + // The last request captured is the mutation; the preceding issue ID + // query does not require the feature flag. + assert.Equal(t, "update_issue_suggestions", spy.captured.Get(headers.GraphQLFeaturesHeader)) + }) } diff --git a/pkg/github/issues_granular.go b/pkg/github/issues_granular.go index 22d26cc47f..3ddfd682f6 100644 --- a/pkg/github/issues_granular.go +++ b/pkg/github/issues_granular.go @@ -7,6 +7,7 @@ import ( "maps" "strings" + ghcontext "github.com/github/github-mcp-server/pkg/context" ghErrors "github.com/github/github-mcp-server/pkg/errors" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" @@ -924,7 +925,7 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv ToolsetMetadataIssues, mcp.Tool{ Name: "set_issue_fields", - Description: t("TOOL_SET_ISSUE_FIELDS_DESCRIPTION", "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue. When setting values, include a confidence level (low, medium, or high) reflecting how certain you are about the choice."), + Description: t("TOOL_SET_ISSUE_FIELDS_DESCRIPTION", "Set issue field values for an issue. Fields are organization-level custom fields (text, number, date, or single select). Use this to create or update field values on an issue."), Annotations: &mcp.ToolAnnotations{ Title: t("TOOL_SET_ISSUE_FIELDS_USER_TITLE", "Set Issue Fields"), ReadOnlyHint: false, @@ -1170,7 +1171,10 @@ func GranularSetIssueFields(t translations.TranslationHelperFunc) inventory.Serv IssueFields: issueFields, } - if err := gqlClient.Mutate(ctx, &mutation, mutationInput, nil); err != nil { + // The rationale and suggest input fields on IssueFieldCreateOrUpdateInput + // are gated behind the update_issue_suggestions GraphQL feature flag. + ctxWithFeatures := ghcontext.WithGraphQLFeatures(ctx, "update_issue_suggestions") + if err := gqlClient.Mutate(ctxWithFeatures, &mutation, mutationInput, nil); err != nil { return ghErrors.NewGitHubGraphQLErrorResponse(ctx, "failed to set issue field values", err), nil, nil } From b2db21cb060cbab026a6a19013f50ec90e1c2ebd Mon Sep 17 00:00:00 2001 From: Moritz Heiber Date: Wed, 10 Jun 2026 12:36:51 +0200 Subject: [PATCH 23/34] Fix GraphQL call using the wrong case for method derivation --- pkg/github/projects.go | 4 ++-- pkg/github/projects_v2_test.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 53ce510516..d20fa3cc3e 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1583,7 +1583,7 @@ func createIterationField(ctx context.Context, gqlClient *githubv4.Client, owner ID string Name string } `graphql:"... on ProjectV2IterationField"` - } + } `graphql:"projectV2Field"` } `graphql:"createProjectV2Field(input: $input)"` } @@ -1616,7 +1616,7 @@ func createIterationField(ctx context.Context, gqlClient *githubv4.Client, owner } } } `graphql:"... on ProjectV2IterationField"` - } + } `graphql:"projectV2Field"` } `graphql:"updateProjectV2Field(input: $input)"` } diff --git a/pkg/github/projects_v2_test.go b/pkg/github/projects_v2_test.go index 69d4d6395f..701e194767 100644 --- a/pkg/github/projects_v2_test.go +++ b/pkg/github/projects_v2_test.go @@ -174,7 +174,7 @@ func createFieldMatcher() githubv4mock.Matcher { ID string Name string } `graphql:"... on ProjectV2IterationField"` - } + } `graphql:"projectV2Field"` } `graphql:"createProjectV2Field(input: $input)"` }{}, githubv4.CreateProjectV2FieldInput{ @@ -242,7 +242,7 @@ func Test_ProjectsWrite_CreateIterationField(t *testing.T) { } } } `graphql:"... on ProjectV2IterationField"` - } + } `graphql:"projectV2Field"` } `graphql:"updateProjectV2Field(input: $input)"` }{}, UpdateProjectV2FieldInput{ @@ -319,7 +319,7 @@ func Test_ProjectsWrite_CreateIterationField(t *testing.T) { } } } `graphql:"... on ProjectV2IterationField"` - } + } `graphql:"projectV2Field"` } `graphql:"updateProjectV2Field(input: $input)"` }{}, UpdateProjectV2FieldInput{ @@ -403,7 +403,7 @@ func Test_ProjectsWrite_CreateIterationField(t *testing.T) { } } } `graphql:"... on ProjectV2IterationField"` - } + } `graphql:"projectV2Field"` } `graphql:"updateProjectV2Field(input: $input)"` }{}, UpdateProjectV2FieldInput{ From 8bbd902b9e459baa40014c9f4ad83b056c4709d4 Mon Sep 17 00:00:00 2001 From: Tim Rogers Date: Wed, 10 Jun 2026 18:55:04 +0100 Subject: [PATCH 24/34] Update title annotations for `issue_write` and `add_issue_comment` tools to reflect that they also work with pull requesta (#2664) * Clarify issue tool titles for PR context Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix issue_write_ff_remote_mcp_issue_fields snap title Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Re-generate docs after upstream merge Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- README.md | 4 ++-- docs/feature-flags.md | 4 ++-- docs/insiders-features.md | 4 ++-- pkg/github/__toolsnaps__/add_issue_comment.snap | 2 +- pkg/github/__toolsnaps__/issue_write.snap | 2 +- .../issue_write_ff_remote_mcp_issue_fields.snap | 2 +- pkg/github/issues.go | 6 +++--- 7 files changed, 12 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index dff62321b8..84f3d3db0e 100644 --- a/README.md +++ b/README.md @@ -826,7 +826,7 @@ The following sets of tools are available: issue-opened Issues -- **add_issue_comment** - Add comment to issue +- **add_issue_comment** - Add comment to issue or pull request - **Required OAuth Scopes**: `repo` - `body`: Comment content (string, required) - `issue_number`: Issue number to comment on (number, required) @@ -854,7 +854,7 @@ The following sets of tools are available: - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository (string, required) -- **issue_write** - Create or update issue +- **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 63fb28dc44..4f98b934ba 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -50,7 +50,7 @@ runtime behavior (such as output formatting) won't appear here. - **MCP App UI**: `ui://github-mcp-server/get-me` - No parameters required -- **issue_write** - Create or update issue +- **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` - **MCP App UI**: `ui://github-mcp-server/issue-write` - `assignees`: Usernames to assign to this issue (string[], optional) @@ -73,7 +73,7 @@ runtime behavior (such as output formatting) won't appear here. ### `remote_mcp_issue_fields` -- **issue_write** - Create or update issue +- **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 881030f020..d5013a6dc2 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -44,7 +44,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - **MCP App UI**: `ui://github-mcp-server/get-me` - No parameters required -- **issue_write** - Create or update issue +- **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` - **MCP App UI**: `ui://github-mcp-server/issue-write` - `assignees`: Usernames to assign to this issue (string[], optional) @@ -67,7 +67,7 @@ The list below is generated from the Go source. It covers tool **inventory and s ### `remote_mcp_issue_fields` -- **issue_write** - Create or update issue +- **issue_write** - Create or update issue/pull request - **Required OAuth Scopes**: `repo` - `assignees`: Usernames to assign to this issue (string[], optional) - `body`: Issue body content (string, optional) diff --git a/pkg/github/__toolsnaps__/add_issue_comment.snap b/pkg/github/__toolsnaps__/add_issue_comment.snap index d273a582d6..5479a16a60 100644 --- a/pkg/github/__toolsnaps__/add_issue_comment.snap +++ b/pkg/github/__toolsnaps__/add_issue_comment.snap @@ -1,6 +1,6 @@ { "annotations": { - "title": "Add comment to issue" + "title": "Add comment to issue or pull request" }, "description": "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments.", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/issue_write.snap b/pkg/github/__toolsnaps__/issue_write.snap index a125864f04..88b01f08f1 100644 --- a/pkg/github/__toolsnaps__/issue_write.snap +++ b/pkg/github/__toolsnaps__/issue_write.snap @@ -9,7 +9,7 @@ } }, "annotations": { - "title": "Create or update issue" + "title": "Create or update issue/pull request" }, "description": "Create a new or update an existing issue in a GitHub repository.", "inputSchema": { diff --git a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap index 6fb00d2490..332a4de3e1 100644 --- a/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap +++ b/pkg/github/__toolsnaps__/issue_write_ff_remote_mcp_issue_fields.snap @@ -9,7 +9,7 @@ } }, "annotations": { - "title": "Create or update issue" + "title": "Create or update issue/pull request" }, "description": "Create a new or update an existing issue in a GitHub repository.", "inputSchema": { diff --git a/pkg/github/issues.go b/pkg/github/issues.go index ef9bbc4305..69b66393aa 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -1144,7 +1144,7 @@ func AddIssueComment(t translations.TranslationHelperFunc) inventory.ServerTool Name: "add_issue_comment", Description: t("TOOL_ADD_ISSUE_COMMENT_DESCRIPTION", "Add a comment to a specific issue in a GitHub repository. Use this tool to add comments to pull requests as well (in this case pass pull request number as issue_number), but only if user is not asking specifically to add review comments."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue"), + Title: t("TOOL_ADD_ISSUE_COMMENT_USER_TITLE", "Add comment to issue or pull request"), ReadOnlyHint: false, }, InputSchema: &jsonschema.Schema{ @@ -1806,7 +1806,7 @@ func IssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool { Name: "issue_write", Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue"), + Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue/pull request"), ReadOnlyHint: false, }, Meta: mcp.Meta{ @@ -2080,7 +2080,7 @@ func LegacyIssueWrite(t translations.TranslationHelperFunc) inventory.ServerTool Name: "issue_write", Description: t("TOOL_ISSUE_WRITE_DESCRIPTION", "Create a new or update an existing issue in a GitHub repository."), Annotations: &mcp.ToolAnnotations{ - Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue"), + Title: t("TOOL_ISSUE_WRITE_USER_TITLE", "Create or update issue/pull request"), ReadOnlyHint: false, }, Meta: mcp.Meta{ From 918a42f05a344be9466b18dfb153d8faf02a4d5d Mon Sep 17 00:00:00 2001 From: Mayowa Fajobi <127399119+MayorFaj@users.noreply.github.com> Date: Wed, 10 Jun 2026 23:09:34 +0100 Subject: [PATCH 25/34] feat: implement cursor pagination for dependabot alerts (#2651) --- README.md | 8 +-- docs/feature-flags.md | 2 +- docs/insiders-features.md | 2 +- .../get_discussion_comments.snap | 2 +- .../__toolsnaps__/list_dependabot_alerts.snap | 9 ++- .../__toolsnaps__/list_discussions.snap | 2 +- pkg/github/__toolsnaps__/list_issues.snap | 2 +- ...ist_issues_ff_remote_mcp_issue_fields.snap | 2 +- pkg/github/dependabot.go | 15 +++-- pkg/github/dependabot_test.go | 61 ++++++++++++++----- pkg/github/params.go | 18 +++++- pkg/github/projects.go | 16 ----- 12 files changed, 86 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 84f3d3db0e..19377b87c1 100644 --- a/README.md +++ b/README.md @@ -717,8 +717,8 @@ The following sets of tools are available: - **list_dependabot_alerts** - List dependabot alerts - **Required OAuth Scopes**: `security_events` - **Accepted OAuth Scopes**: `repo`, `security_events` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `owner`: The owner of the repository. (string, required) - - `page`: Page number for pagination (min 1) (number, optional) - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) - `repo`: The name of the repository. (string, required) - `severity`: Filter dependabot alerts by severity (string, optional) @@ -755,7 +755,7 @@ The following sets of tools are available: - **get_discussion_comments** - Get discussion comments - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `discussionNumber`: Discussion Number (number, required) - `includeReplies`: When true, each top-level comment will include its replies nested within it (up to 100 replies per comment, which is the GitHub API maximum). Defaults to false. (boolean, optional) - `owner`: Repository owner (string, required) @@ -769,7 +769,7 @@ The following sets of tools are available: - **list_discussions** - List discussions - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `category`: Optional filter by discussion category ID. If provided, only discussions with this category are listed. (string, optional) - `direction`: Order direction. (string, optional) - `orderBy`: Order discussions by field. If provided, the 'direction' also needs to be provided. (string, optional) @@ -881,7 +881,7 @@ The following sets of tools are available: - **list_issues** - List issues - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `labels`: Filter by labels (string[], optional) - `orderBy`: Order issues by field. If provided, the 'direction' also needs to be provided. (string, optional) diff --git a/docs/feature-flags.md b/docs/feature-flags.md index 4f98b934ba..bef7283bef 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -102,7 +102,7 @@ runtime behavior (such as output formatting) won't appear here. - **list_issues** - List issues - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) - `labels`: Filter by labels (string[], optional) diff --git a/docs/insiders-features.md b/docs/insiders-features.md index d5013a6dc2..78c8231e3e 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -96,7 +96,7 @@ The list below is generated from the Go source. It covers tool **inventory and s - **list_issues** - List issues - **Required OAuth Scopes**: `repo` - - `after`: Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs. (string, optional) + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) - `direction`: Order direction. If provided, the 'orderBy' also needs to be provided. (string, optional) - `field_filters`: Filter by custom issue field values. Each entry takes a field_name and a value; the server looks up the field and coerces the value to its type (single-select option name, text, number, or YYYY-MM-DD date). (object[], optional) - `labels`: Filter by labels (string[], optional) diff --git a/pkg/github/__toolsnaps__/get_discussion_comments.snap b/pkg/github/__toolsnaps__/get_discussion_comments.snap index 422fc40bf7..0dcd7343e7 100644 --- a/pkg/github/__toolsnaps__/get_discussion_comments.snap +++ b/pkg/github/__toolsnaps__/get_discussion_comments.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "after": { - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "description": "Cursor for pagination. Use the cursor from the previous response.", "type": "string" }, "discussionNumber": { diff --git a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap index 55d5437796..5fdbcd2e6f 100644 --- a/pkg/github/__toolsnaps__/list_dependabot_alerts.snap +++ b/pkg/github/__toolsnaps__/list_dependabot_alerts.snap @@ -6,15 +6,14 @@ "description": "List dependabot alerts in a GitHub repository.", "inputSchema": { "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, "owner": { "description": "The owner of the repository.", "type": "string" }, - "page": { - "description": "Page number for pagination (min 1)", - "minimum": 1, - "type": "number" - }, "perPage": { "description": "Results per page for pagination (min 1, max 100)", "maximum": 100, diff --git a/pkg/github/__toolsnaps__/list_discussions.snap b/pkg/github/__toolsnaps__/list_discussions.snap index 42be769335..fdf5f6d7a3 100644 --- a/pkg/github/__toolsnaps__/list_discussions.snap +++ b/pkg/github/__toolsnaps__/list_discussions.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "after": { - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "description": "Cursor for pagination. Use the cursor from the previous response.", "type": "string" }, "category": { diff --git a/pkg/github/__toolsnaps__/list_issues.snap b/pkg/github/__toolsnaps__/list_issues.snap index a4be59bb0c..8ce261d7cb 100644 --- a/pkg/github/__toolsnaps__/list_issues.snap +++ b/pkg/github/__toolsnaps__/list_issues.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "after": { - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "description": "Cursor for pagination. Use the cursor from the previous response.", "type": "string" }, "direction": { diff --git a/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap index b1d1c7a21d..53a951846a 100644 --- a/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap +++ b/pkg/github/__toolsnaps__/list_issues_ff_remote_mcp_issue_fields.snap @@ -7,7 +7,7 @@ "inputSchema": { "properties": { "after": { - "description": "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + "description": "Cursor for pagination. Use the cursor from the previous response.", "type": "string" }, "direction": { diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index 02023da69f..fbfc62c778 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -120,7 +120,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server }, Required: []string{"owner", "repo"}, } - WithPagination(schema) + WithCursorPagination(schema) return NewTool( ToolsetMetadataDependabot, @@ -152,7 +152,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultError(err.Error()), nil, nil } - pagination, err := OptionalPaginationParams(args) + pagination, err := OptionalCursorPaginationParams(args) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } @@ -165,9 +165,9 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server alerts, resp, err := client.Dependabot.ListRepoAlerts(ctx, owner, repo, &github.ListAlertsOptions{ State: ToStringPtr(state), Severity: ToStringPtr(severity), - ListOptions: github.ListOptions{ - Page: pagination.Page, + ListCursorOptions: github.ListCursorOptions{ PerPage: pagination.PerPage, + After: pagination.After, }, }) if err != nil { @@ -187,7 +187,12 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to list alerts", resp, body), nil, nil } - r, err := json.Marshal(alerts) + response := map[string]any{ + "alerts": alerts, + "pageInfo": buildPageInfo(resp), + } + + r, err := json.Marshal(response) if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, err } diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go index 7811483908..5236c6d349 100644 --- a/pkg/github/dependabot_test.go +++ b/pkg/github/dependabot_test.go @@ -154,19 +154,19 @@ func Test_ListDependabotAlerts(t *testing.T) { } tests := []struct { - name string - mockedClient *http.Client - requestArgs map[string]any - expectError bool - expectedAlerts []*github.DependabotAlert - expectedErrMsg string + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedAlerts []*github.DependabotAlert + expectedNextCursor string + expectedErrMsg string }{ { name: "successful open alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ "state": "open", - "page": "1", "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), @@ -185,7 +185,6 @@ func Test_ListDependabotAlerts(t *testing.T) { mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ "severity": "high", - "page": "1", "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&highSeverityAlert}), @@ -203,7 +202,6 @@ func Test_ListDependabotAlerts(t *testing.T) { name: "successful all alerts listing", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ - "page": "1", "per_page": "30", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}), @@ -217,10 +215,10 @@ func Test_ListDependabotAlerts(t *testing.T) { expectedAlerts: []*github.DependabotAlert{&criticalAlert, &highSeverityAlert}, }, { - name: "successful alerts listing with custom pagination", + name: "successful alerts listing with cursor pagination", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ - "page": "3", + "after": "Y3Vyc29yOnYyOpK5", "per_page": "100", }).andThen( mockResponse(t, http.StatusOK, []*github.DependabotAlert{&criticalAlert}), @@ -229,12 +227,35 @@ func Test_ListDependabotAlerts(t *testing.T) { requestArgs: map[string]any{ "owner": "owner", "repo": "repo", - "page": float64(3), + "after": "Y3Vyc29yOnYyOpK5", "perPage": float64(100), }, expectError: false, expectedAlerts: []*github.DependabotAlert{&criticalAlert}, }, + { + name: "successful alerts listing surfaces next page cursor", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposDependabotAlertsByOwnerByRepo: expectQueryParams(t, map[string]string{ + "per_page": "30", + }).andThen( + func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Link", `; rel="next"`) + w.WriteHeader(http.StatusOK) + b, err := json.Marshal([]*github.DependabotAlert{&criticalAlert}) + require.NoError(t, err) + _, _ = w.Write(b) + }, + ), + }), + requestArgs: map[string]any{ + "owner": "owner", + "repo": "repo", + }, + expectError: false, + expectedAlerts: []*github.DependabotAlert{&criticalAlert}, + expectedNextCursor: "nextcursor123", + }, { name: "alerts listing fails", mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ @@ -291,11 +312,17 @@ func Test_ListDependabotAlerts(t *testing.T) { textContent := getTextResult(t, result) // Unmarshal and verify the result - var returnedAlerts []*github.DependabotAlert - err = json.Unmarshal([]byte(textContent.Text), &returnedAlerts) + var returnedResult struct { + Alerts []*github.DependabotAlert `json:"alerts"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + NextCursor string `json:"nextCursor"` + } `json:"pageInfo"` + } + err = json.Unmarshal([]byte(textContent.Text), &returnedResult) assert.NoError(t, err) - assert.Len(t, returnedAlerts, len(tc.expectedAlerts)) - for i, alert := range returnedAlerts { + assert.Len(t, returnedResult.Alerts, len(tc.expectedAlerts)) + for i, alert := range returnedResult.Alerts { assert.Equal(t, *tc.expectedAlerts[i].Number, *alert.Number) assert.Equal(t, *tc.expectedAlerts[i].HTMLURL, *alert.HTMLURL) assert.Equal(t, *tc.expectedAlerts[i].State, *alert.State) @@ -304,6 +331,8 @@ func Test_ListDependabotAlerts(t *testing.T) { assert.Equal(t, *tc.expectedAlerts[i].SecurityAdvisory.Severity, *alert.SecurityAdvisory.Severity) } } + assert.Equal(t, tc.expectedNextCursor, returnedResult.PageInfo.NextCursor) + assert.Equal(t, tc.expectedNextCursor != "", returnedResult.PageInfo.HasNextPage) }) } } diff --git a/pkg/github/params.go b/pkg/github/params.go index ecdc8c3549..a6b43375ef 100644 --- a/pkg/github/params.go +++ b/pkg/github/params.go @@ -376,7 +376,7 @@ func WithCursorPagination(schema *jsonschema.Schema) *jsonschema.Schema { schema.Properties["after"] = &jsonschema.Schema{ Type: "string", - Description: "Cursor for pagination. Use the endCursor from the previous page's PageInfo for GraphQL APIs.", + Description: "Cursor for pagination. Use the cursor from the previous response.", } return schema @@ -435,6 +435,22 @@ type CursorPaginationParams struct { After string } +type pageInfo struct { + HasNextPage bool `json:"hasNextPage"` + HasPreviousPage bool `json:"hasPreviousPage"` + NextCursor string `json:"nextCursor,omitempty"` + PrevCursor string `json:"prevCursor,omitempty"` +} + +func buildPageInfo(resp *github.Response) pageInfo { + return pageInfo{ + HasNextPage: resp.After != "", + HasPreviousPage: resp.Before != "", + NextCursor: resp.After, + PrevCursor: resp.Before, + } +} + // ToGraphQLParams converts cursor pagination parameters to GraphQL-specific parameters. func (p CursorPaginationParams) ToGraphQLParams() (*GraphQLPaginationParams, error) { if p.PerPage > 100 { diff --git a/pkg/github/projects.go b/pkg/github/projects.go index d20fa3cc3e..5e71bde8f8 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -1350,13 +1350,6 @@ func getProjectStatusUpdate(ctx context.Context, gqlClient *githubv4.Client, sta return utils.NewToolResultText(string(r)), nil, nil } -type pageInfo struct { - HasNextPage bool `json:"hasNextPage"` - HasPreviousPage bool `json:"hasPreviousPage"` - NextCursor string `json:"nextCursor,omitempty"` - PrevCursor string `json:"prevCursor,omitempty"` -} - // validateAndConvertToInt64 ensures the value is a number and converts it to int64. func validateAndConvertToInt64(value any) (int64, error) { switch v := value.(type) { @@ -1407,15 +1400,6 @@ func buildUpdateProjectItem(input map[string]any) (*github.UpdateProjectItemOpti return payload, nil } -func buildPageInfo(resp *github.Response) pageInfo { - return pageInfo{ - HasNextPage: resp.After != "", - HasPreviousPage: resp.Before != "", - NextCursor: resp.After, - PrevCursor: resp.Before, - } -} - func extractPaginationOptionsFromArgs(args map[string]any) (github.ListProjectsPaginationOptions, error) { perPage, err := OptionalIntParamWithDefault(args, "per_page", MaxProjectsPerPage) if err != nil { From fb7cbc8b853fb8e04697f765ee0dd5ef8164fca3 Mon Sep 17 00:00:00 2001 From: JoannaaKL Date: Thu, 11 Jun 2026 13:48:36 +0200 Subject: [PATCH 26/34] Annotate read tools with ifc labels (#2671) * Annotate read tools with ifc labels * Dont automatically enable IFCLabels in insiders mode * ifc: don't label unpublished repo advisories as public Repository security advisory listings can include draft/triage/closed advisories (via the state filter), which are not world-readable even on a public repository. Deriving confidentiality from repo visibility alone under-classified those results as public. LabelRepositorySecurityAdvisory now takes an allPublished flag and only returns a public label when the repo is public AND every returned advisory is published; otherwise it is private. list_repository_security_advisories computes allPublished from the response state; the org-wide listing stays private-untrusted. Adds unit + handler regression tests covering the draft-advisory-on-public-repo case. Addresses PR review feedback. * ifc: fix confidentiality under-classification in releases, collaborators, get_me Audit for the same bug class as the repo-advisory fix (confidentiality derived from a coarse signal that misses access-restricted items) found three more under-classifications: - Releases (list_releases, get_latest_release, get_release_by_tag): draft releases are visible only to push-access users and are not world-readable even on a public repo. New LabelRelease(isPrivate, hasDraft) returns public only for a non-draft release on a public repo; handlers compute hasDraft from the response (Draft flag / per-item scan). - list_repository_collaborators: a collaborator roster requires push access to list, so it is never world-readable, not even on a public repo. New LabelCollaboratorRoster() is always PrivateTrusted (mirrors LabelTeam), replacing the repo-visibility-derived label. - get_me: the result includes private_gists / total_private_repos / owned_private_repos, which are not part of the public profile. LabelGetMe is now PrivateTrusted instead of PublicTrusted. Verified the remaining public-capable labels are sound: Actions logs are world-readable on public repos; branches/tags are public metadata; gist, project, search, and starred-repo labels read per-item visibility and join. Adds ifc unit tests for the new/changed labels and a get_release_by_tag handler regression test (draft on public repo -> private); updates the get_me handler test to assert private. * ifc: document why list results use one joined label, not per-item Explain on LabelSearchIssues (and cross-ref from LabelGistList) that a tool result is delivered as one opaque payload and the IFC engine makes one allow/deny decision per flow at egress, so the only sound bound for a list is the meet of every item's label. Per-item labels would only be load-bearing if the engine could partition a result and route items to different sinks; until then they would invite unsafe declassification of a public item that arrived alongside private data. Doc-only change. --- pkg/github/actions.go | 61 +++++-- pkg/github/code_scanning.go | 15 +- pkg/github/context_tools.go | 21 ++- pkg/github/context_tools_test.go | 4 +- pkg/github/dependabot.go | 15 +- pkg/github/discussions.go | 25 ++- pkg/github/feature_flags.go | 1 - pkg/github/feature_flags_test.go | 4 +- pkg/github/gists.go | 17 +- pkg/github/git.go | 9 +- pkg/github/ifc_labels.go | 154 ++++++++++++++++ pkg/github/issue_fields.go | 13 +- pkg/github/issues.go | 43 ++--- pkg/github/labels.go | 13 +- pkg/github/notifications.go | 9 +- pkg/github/projects.go | 42 ++++- pkg/github/pullrequests.go | 35 +++- pkg/github/repositories.go | 125 +++++++++---- pkg/github/repositories_test.go | 222 +++++++++++++++++++++++ pkg/github/search.go | 57 ++++-- pkg/github/secret_scanning.go | 15 +- pkg/github/security_advisories.go | 49 ++++- pkg/github/security_advisories_test.go | 127 +++++++++++++ pkg/http/server_test.go | 4 +- pkg/ifc/ifc.go | 225 ++++++++++++++++++++++- pkg/ifc/ifc_test.go | 242 +++++++++++++++++++++++++ 26 files changed, 1399 insertions(+), 148 deletions(-) create mode 100644 pkg/github/ifc_labels.go diff --git a/pkg/github/actions.go b/pkg/github/actions.go index a7ce039d83..9dac877736 100644 --- a/pkg/github/actions.go +++ b/pkg/github/actions.go @@ -12,6 +12,7 @@ import ( "github.com/github/github-mcp-server/internal/profiler" buffer "github.com/github/github-mcp-server/pkg/buffer" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -354,6 +355,14 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } + // attachIFC adds the IFC label to a successful Actions result when + // IFC labels are enabled. Workflow definitions, runs, jobs, + // artifacts and logs echo attacker-influenceable run output, so + // integrity is untrusted; confidentiality follows repo visibility. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelActionsResult) + } + var resourceIDInt int64 var parseErr error switch method { @@ -376,13 +385,17 @@ Use this tool to list workflows in a repository, or list workflow runs, jobs, an switch method { case actionsMethodListWorkflows: - return listWorkflows(ctx, client, owner, repo, pagination) + result, payload, err := listWorkflows(ctx, client, owner, repo, pagination) + return attachIFC(result), payload, err case actionsMethodListWorkflowRuns: - return listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + result, payload, err := listWorkflowRuns(ctx, client, args, owner, repo, resourceID, pagination) + return attachIFC(result), payload, err case actionsMethodListWorkflowJobs: - return listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + result, payload, err := listWorkflowJobs(ctx, client, args, owner, repo, resourceIDInt, pagination) + return attachIFC(result), payload, err case actionsMethodListWorkflowArtifacts: - return listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) + result, payload, err := listWorkflowArtifacts(ctx, client, owner, repo, resourceIDInt, pagination) + return attachIFC(result), payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -465,6 +478,14 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) } + // attachIFC adds the IFC label to a successful Actions result when + // IFC labels are enabled. Workflow runs, jobs, artifacts, usage, + // and log URLs reflect attacker-influenceable run output, so + // integrity is untrusted; confidentiality follows repo visibility. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelActionsResult) + } + var resourceIDInt int64 var parseErr error switch method { @@ -480,17 +501,23 @@ Use this tool to get details about individual workflows, workflow runs, jobs, an switch method { case actionsMethodGetWorkflow: - return getWorkflow(ctx, client, owner, repo, resourceID) + result, payload, err := getWorkflow(ctx, client, owner, repo, resourceID) + return attachIFC(result), payload, err case actionsMethodGetWorkflowRun: - return getWorkflowRun(ctx, client, owner, repo, resourceIDInt) + result, payload, err := getWorkflowRun(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err case actionsMethodGetWorkflowJob: - return getWorkflowJob(ctx, client, owner, repo, resourceIDInt) + result, payload, err := getWorkflowJob(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err case actionsMethodDownloadWorkflowArtifact: - return downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt) + result, payload, err := downloadWorkflowArtifact(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err case actionsMethodGetWorkflowRunUsage: - return getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt) + result, payload, err := getWorkflowRunUsage(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err case actionsMethodGetWorkflowRunLogsURL: - return getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt) + result, payload, err := getWorkflowRunLogsURL(ctx, client, owner, repo, resourceIDInt) + return attachIFC(result), payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -719,12 +746,22 @@ For single job logs, provide job_id. For all failed jobs in a run, provide run_i return utils.NewToolResultError("job_id is required when failed_only is false"), nil, nil } + // attachIFC adds the IFC label to a successful result when IFC + // labels are enabled. Job logs echo attacker-influenceable run + // output, so integrity is untrusted; confidentiality follows repo + // visibility. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelActionsResult) + } + if failedOnly && runID > 0 { // Handle failed-only mode: get logs for all failed jobs in the workflow run - return handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + result, payload, err := handleFailedJobLogs(ctx, client, owner, repo, int64(runID), returnContent, tailLines, deps.GetContentWindowSize()) + return attachIFC(result), payload, err } else if jobID > 0 { // Handle single job mode - return handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) + result, payload, err := handleSingleJobLogs(ctx, client, owner, repo, int64(jobID), returnContent, tailLines, deps.GetContentWindowSize()) + return attachIFC(result), payload, err } return utils.NewToolResultError("Either job_id must be provided for single job logs, or run_id with failed_only=true for failed job logs"), nil, nil diff --git a/pkg/github/code_scanning.go b/pkg/github/code_scanning.go index 44307513bb..fb8b7a79c8 100644 --- a/pkg/github/code_scanning.go +++ b/pkg/github/code_scanning.go @@ -7,6 +7,7 @@ import ( "net/http" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -88,7 +89,12 @@ func GetCodeScanningAlert(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultErrorFromErr("failed to marshal alert", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Code scanning alerts are access-restricted regardless of repo + // visibility and embed attacker-influenceable code snippets, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } @@ -208,7 +214,12 @@ func ListCodeScanningAlerts(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Code scanning alerts are access-restricted regardless of repo + // visibility and embed attacker-influenceable code snippets, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go index 4008c2f4aa..b4c7098c56 100644 --- a/pkg/github/context_tools.go +++ b/pkg/github/context_tools.go @@ -106,12 +106,7 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool { } result := MarshalledTextResult(minimalUser) - if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { - if result.Meta == nil { - result.Meta = mcp.Meta{} - } - result.Meta["ifc"] = ifc.LabelGetMe() - } + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGetMe()) return result, nil, nil }, ) @@ -221,7 +216,12 @@ func GetTeams(t translations.TranslationHelperFunc) inventory.ServerTool { organizations = append(organizations, orgTeams) } - return MarshalledTextResult(organizations), nil, nil + result := MarshalledTextResult(organizations) + // Team membership is maintained by GitHub and cannot be forged by + // outside contributors (trusted). Org team rosters are visible only + // to org members, so confidentiality is private. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam()) + return result, nil, nil }, ) } @@ -292,7 +292,12 @@ func GetTeamMembers(t translations.TranslationHelperFunc) inventory.ServerTool { members = append(members, string(member.Login)) } - return MarshalledTextResult(members), nil, nil + result := MarshalledTextResult(members) + // Team membership is maintained by GitHub and cannot be forged by + // outside contributors (trusted). A team's member roster is visible + // only to org members, so confidentiality is private. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelTeam()) + return result, nil, nil }, ) } diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go index ade54aba17..082b467135 100644 --- a/pkg/github/context_tools_test.go +++ b/pkg/github/context_tools_test.go @@ -199,7 +199,9 @@ func Test_GetMe_IFC_FeatureFlag(t *testing.T) { require.NoError(t, err) assert.Equal(t, "trusted", ifcMap["integrity"]) - assert.Equal(t, "public", ifcMap["confidentiality"]) + // get_me returns the caller's private repo/gist counts, which are not + // part of the public profile, so confidentiality is private. + assert.Equal(t, "private", ifcMap["confidentiality"]) }) } diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go index fbfc62c778..1ac6b1b44c 100644 --- a/pkg/github/dependabot.go +++ b/pkg/github/dependabot.go @@ -8,6 +8,7 @@ import ( "net/http" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -89,7 +90,12 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo return utils.NewToolResultErrorFromErr("failed to marshal alert", err), nil, err } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Dependabot alerts are access-restricted regardless of repo + // visibility and embed attacker-influenceable advisory text, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } @@ -197,7 +203,12 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server return utils.NewToolResultErrorFromErr("failed to marshal alerts", err), nil, err } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Dependabot alerts are access-restricted regardless of repo + // visibility and embed attacker-influenceable advisory text, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } diff --git a/pkg/github/discussions.go b/pkg/github/discussions.go index 514a2d030d..1f94597739 100644 --- a/pkg/github/discussions.go +++ b/pkg/github/discussions.go @@ -6,6 +6,7 @@ import ( "fmt" "strings" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -272,7 +273,11 @@ func ListDiscussions(t translations.TranslationHelperFunc) inventory.ServerTool if err != nil { return nil, nil, fmt.Errorf("failed to marshal discussions: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Discussion content is user-authored (untrusted); confidentiality + // follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelListIssues) + return result, nil, nil }, ) } @@ -376,7 +381,11 @@ func GetDiscussion(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal discussion: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Discussion content is user-authored (untrusted); confidentiality + // follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues) + return result, nil, nil }, ) } @@ -580,7 +589,11 @@ func GetDiscussionComments(t translations.TranslationHelperFunc) inventory.Serve return nil, nil, fmt.Errorf("failed to marshal comments: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Discussion comments are user-authored (untrusted); confidentiality + // follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, params.Owner, params.Repo, result, ifc.LabelListIssues) + return result, nil, nil }, ) } @@ -1084,7 +1097,11 @@ func ListDiscussionCategories(t translations.TranslationHelperFunc) inventory.Se if err != nil { return nil, nil, fmt.Errorf("failed to marshal discussion categories: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Discussion categories are repo-defined structural metadata + // (trusted); confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 0f77f6c872..15a78c1f19 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -36,7 +36,6 @@ var AllowedFeatureFlags = []string{ var InsidersFeatureFlags = []string{ MCPAppsFeatureFlag, FeatureFlagCSVOutput, - FeatureFlagIFCLabels, FeatureFlagIssueFields, } diff --git a/pkg/github/feature_flags_test.go b/pkg/github/feature_flags_test.go index 3f9d211953..acb0da1bcd 100644 --- a/pkg/github/feature_flags_test.go +++ b/pkg/github/feature_flags_test.go @@ -162,10 +162,10 @@ func TestResolveFeatureFlags(t *testing.T) { expectedFlags: InsidersFeatureFlags, }, { - name: "insiders mode enables internal-only flags", + name: "insiders mode does not auto-enable ifc labels", enabledFeatures: nil, insidersMode: true, - expectedFlags: []string{FeatureFlagIFCLabels}, + unexpectedFlags: []string{FeatureFlagIFCLabels}, }, { name: "ifc_labels can be directly enabled", diff --git a/pkg/github/gists.go b/pkg/github/gists.go index de577af04d..2eacabe4bc 100644 --- a/pkg/github/gists.go +++ b/pkg/github/gists.go @@ -8,6 +8,7 @@ import ( "net/http" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -99,7 +100,15 @@ func ListGists(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Gist contents are user-authored (untrusted); confidentiality is + // the IFC join of each gist's own public/secret flag. + visibilities := make([]bool, 0, len(gists)) + for _, g := range gists { + visibilities = append(visibilities, g.GetPublic()) + } + result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelGistList) + return result, nil, nil }, ) } @@ -157,7 +166,11 @@ func GetGist(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Gist contents are user-authored (untrusted); confidentiality + // derives from the gist's own public/secret flag. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGist(gist.GetPublic())) + return result, nil, nil }, ) } diff --git a/pkg/github/git.go b/pkg/github/git.go index 515d8b65f8..bf88aad770 100644 --- a/pkg/github/git.go +++ b/pkg/github/git.go @@ -7,6 +7,7 @@ import ( "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -171,7 +172,13 @@ func GetRepositoryTree(t translations.TranslationHelperFunc) inventory.ServerToo return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // The repository tree exposes committed file structure; in public + // repos anyone can land content via a PR (untrusted), in private + // repos only collaborators can (trusted). Confidentiality follows + // repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) + return result, nil, nil }, ) } diff --git a/pkg/github/ifc_labels.go b/pkg/github/ifc_labels.go new file mode 100644 index 0000000000..a1c6fea367 --- /dev/null +++ b/pkg/github/ifc_labels.go @@ -0,0 +1,154 @@ +package github + +import ( + "context" + + "github.com/github/github-mcp-server/pkg/ifc" + "github.com/google/go-github/v87/github" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// setIFCLabel writes the given IFC security label into a tool result's _meta +// under the "ifc" key, allocating the Meta map if necessary. +func setIFCLabel(r *mcp.CallToolResult, label ifc.SecurityLabel) { + if r.Meta == nil { + r.Meta = mcp.Meta{} + } + r.Meta["ifc"] = label +} + +// attachStaticIFCLabel attaches a fixed IFC label to a successful tool result +// when IFC labels are enabled. It is used by tools whose label does not depend +// on any repository visibility lookup (e.g. security alerts, global +// advisories, team membership, notification subjects). +// +// Error results are left untouched, and the label is omitted entirely when the +// IFC feature flag is disabled. +func attachStaticIFCLabel(ctx context.Context, deps ToolDependencies, r *mcp.CallToolResult, label ifc.SecurityLabel) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + setIFCLabel(r, label) + return r +} + +// attachRepoVisibilityIFCLabel attaches an IFC label derived from a single +// repository's visibility to a successful tool result when IFC labels are +// enabled. The concrete label is produced by labelFn, which receives whether +// the repository is private. +// +// The repository visibility is resolved via FetchRepoIsPrivate. Consistent +// with the other IFC-labeled tools, if the visibility lookup fails the label +// is omitted rather than risking a misclassification. Error results and the +// disabled-feature case are left untouched. +func attachRepoVisibilityIFCLabel( + ctx context.Context, + deps ToolDependencies, + client *github.Client, + owner, repo string, + r *mcp.CallToolResult, + labelFn func(isPrivate bool) ifc.SecurityLabel, +) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + setIFCLabel(r, labelFn(isPrivate)) + return r +} + +// ifcSearchPostProcessOption returns a searchOption that attaches IFC labels to +// a multi-repository search result. The feature-flag check is centralized here +// (mirroring the attach* helpers above) rather than in each search tool +// handler: when IFC labels are disabled it returns a no-op option, so callers +// can pass it unconditionally to searchHandler. +func ifcSearchPostProcessOption(ctx context.Context, deps ToolDependencies) searchOption { + if !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return func(*searchConfig) {} + } + return withSearchPostProcess(searchIssuesIFCPostProcess(deps)) +} + +// attachRepoVisibilityIFCLabelLazy is like attachRepoVisibilityIFCLabel but +// resolves the REST client itself, only when IFC labels are enabled. It is used +// by tools whose handler holds a GraphQL client (or no client yet) and would +// otherwise have to acquire a REST client solely to compute the label. The +// feature-flag check is centralized here so callers can invoke it +// unconditionally; if the client cannot be obtained or the visibility lookup +// fails, the label is omitted rather than risking a misclassification. +func attachRepoVisibilityIFCLabelLazy( + ctx context.Context, + deps ToolDependencies, + owner, repo string, + r *mcp.CallToolResult, + labelFn func(isPrivate bool) ifc.SecurityLabel, +) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + client, err := deps.GetClient(ctx) + if err != nil { + return r + } + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, labelFn) +} + +// attachJoinedIFCLabel attaches an IFC label computed by joining a set of +// per-item visibilities (true == private for repositories, true == public for +// gists) when IFC labels are enabled. joinFn is the lattice join for the +// relevant item kind (e.g. ifc.LabelSearchIssues or ifc.LabelGistList). The +// visibility slice is cheap to build from an already-fetched response, so +// callers may construct it unconditionally and let this helper own the +// feature-flag gate. +func attachJoinedIFCLabel( + ctx context.Context, + deps ToolDependencies, + r *mcp.CallToolResult, + visibilities []bool, + joinFn func([]bool) ifc.SecurityLabel, +) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + setIFCLabel(r, joinFn(visibilities)) + return r +} + +// newRepoVisibilityIFCLabeler returns a closure that attaches a repo-visibility +// IFC label to a tool result, for handlers that have several return paths and +// want to label each one. The returned function owns the feature-flag gate (so +// callers invoke it unconditionally) and caches the repository visibility +// lookup across calls, so a handler that returns from many branches only pays +// for one FetchRepoIsPrivate call. A failed visibility lookup is not cached, so +// a later return path can retry; on persistent failure the label is omitted +// rather than risking a misclassification. +func newRepoVisibilityIFCLabeler( + ctx context.Context, + deps ToolDependencies, + client *github.Client, + owner, repo string, + labelFn func(isPrivate bool) ifc.SecurityLabel, +) func(*mcp.CallToolResult) *mcp.CallToolResult { + var ( + known bool + isPrivate bool + ) + return func(r *mcp.CallToolResult) *mcp.CallToolResult { + if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { + return r + } + if !known { + p, err := FetchRepoIsPrivate(ctx, client, owner, repo) + if err != nil { + return r + } + isPrivate = p + known = true + } + setIFCLabel(r, labelFn(isPrivate)) + return r + } +} diff --git a/pkg/github/issue_fields.go b/pkg/github/issue_fields.go index 1eabbc02f3..27a4a09c5d 100644 --- a/pkg/github/issue_fields.go +++ b/pkg/github/issue_fields.go @@ -8,6 +8,7 @@ import ( ghcontext "github.com/github/github-mcp-server/pkg/context" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -155,7 +156,17 @@ func ListIssueFields(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultErrorFromErr("failed to marshal issue fields", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Issue field definitions are repo/org structural metadata + // (trusted). When scoped to a specific repo, confidentiality + // follows that repo's visibility; for an org-level lookup (no + // repo) it is conservatively treated as private. + if repo == "" { + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true)) + } else { + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + } + return result, nil, nil }) st.FeatureFlagEnable = FeatureFlagIssueFields return st diff --git a/pkg/github/issues.go b/pkg/github/issues.go index 69b66393aa..27fc0a4abe 100644 --- a/pkg/github/issues.go +++ b/pkg/github/issues.go @@ -804,20 +804,7 @@ Options are: // attachIFC adds the IFC label to a successful tool result when // IFC labels are enabled. If the visibility lookup fails the // label is omitted rather than misclassifying the result. - attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { - if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { - return r - } - isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) - if err != nil { - return r - } - if r.Meta == nil { - r.Meta = mcp.Meta{} - } - r.Meta["ifc"] = ifc.LabelListIssues(isPrivate) - return r - } + attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelListIssues) switch method { case "get": @@ -1132,7 +1119,13 @@ func ListIssueTypes(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal issue types", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Issue types are org-defined structural metadata (trusted, not + // attacker-authored). They are scoped to an organization rather + // than a single repo, so confidentiality is conservatively treated + // as private (restricted to org members). + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepoMetadata(true)) + return result, nil, nil }) } @@ -1511,11 +1504,7 @@ func SearchIssues(t translations.TranslationHelperFunc) inventory.ServerTool { }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - var options []searchOption - if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { - options = append(options, withSearchPostProcess(searchIssuesIFCPostProcess(deps))) - } - result, err := searchIssuesHandler(ctx, deps, args, options...) + result, err := searchIssuesHandler(ctx, deps, args, ifcSearchPostProcessOption(ctx, deps)) return result, nil, err }) } @@ -2769,12 +2758,7 @@ func ListIssues(t translations.TranslationHelperFunc) inventory.ServerTool { } result := MarshalledTextResult(resp) - if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { - if result.Meta == nil { - result.Meta = mcp.Meta{} - } - result.Meta["ifc"] = ifc.LabelListIssues(isPrivate) - } + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) return result, nil, nil }) st.FeatureFlagEnable = FeatureFlagIssueFields @@ -2972,12 +2956,7 @@ func LegacyListIssues(t translations.TranslationHelperFunc) inventory.ServerTool } result := MarshalledTextResult(resp) - if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { - if result.Meta == nil { - result.Meta = mcp.Meta{} - } - result.Meta["ifc"] = ifc.LabelListIssues(isPrivate) - } + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelListIssues(isPrivate)) return result, nil, nil }) st.FeatureFlagDisable = []string{FeatureFlagIssueFields} diff --git a/pkg/github/labels.go b/pkg/github/labels.go index e8d8102cbf..0e49968496 100644 --- a/pkg/github/labels.go +++ b/pkg/github/labels.go @@ -7,6 +7,7 @@ import ( "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -105,7 +106,11 @@ func GetLabel(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal label: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Labels are structural repo metadata defined by collaborators + // (trusted); confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } @@ -204,7 +209,11 @@ func ListLabels(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal labels: %w", err) } - return utils.NewToolResultText(string(out)), nil, nil + result := utils.NewToolResultText(string(out)) + // Labels are structural repo metadata defined by collaborators + // (trusted); confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabelLazy(ctx, deps, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } diff --git a/pkg/github/notifications.go b/pkg/github/notifications.go index 61d8f40b2e..1504757a7f 100644 --- a/pkg/github/notifications.go +++ b/pkg/github/notifications.go @@ -9,6 +9,7 @@ import ( "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -386,7 +387,13 @@ func GetNotificationDetails(t translations.TranslationHelperFunc) inventory.Serv return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // A notification subject points at an issue, PR, comment, or + // discussion whose content is user-authored (untrusted). It is + // delivered to a specific recipient and may reference private + // repositories, so confidentiality is private. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelNotificationDetails()) + return result, nil, nil }, ) } diff --git a/pkg/github/projects.go b/pkg/github/projects.go index 5e71bde8f8..85774490de 100644 --- a/pkg/github/projects.go +++ b/pkg/github/projects.go @@ -10,6 +10,7 @@ import ( "time" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -227,9 +228,19 @@ Use this tool to list projects for a user or organization, or list project field return utils.NewToolResultError(err.Error()), nil, nil } + // attachIFC adds the IFC label to a successful result when IFC + // labels are enabled. Project titles, item content, field + // definitions, and status updates are user-authored free text + // (untrusted); confidentiality is conservatively private since the + // project's public flag is not available across every sub-result. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false)) + } + switch method { case projectsMethodListProjects: - return listProjects(ctx, client, args, owner, ownerType) + result, payload, err := listProjects(ctx, client, args, owner, ownerType) + return attachIFC(result), payload, err default: // All other methods require project_number and ownerType detection if ownerType == "" { @@ -245,15 +256,18 @@ Use this tool to list projects for a user or organization, or list project field switch method { case projectsMethodListProjectFields: - return listProjectFields(ctx, client, args, owner, ownerType) + result, payload, err := listProjectFields(ctx, client, args, owner, ownerType) + return attachIFC(result), payload, err case projectsMethodListProjectItems: - return listProjectItems(ctx, client, args, owner, ownerType) + result, payload, err := listProjectItems(ctx, client, args, owner, ownerType) + return attachIFC(result), payload, err case projectsMethodListProjectStatusUpdates: gqlClient, err := deps.GetGQLClient(ctx) if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - return listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) + result, payload, err := listProjectStatusUpdates(ctx, gqlClient, args, owner, ownerType) + return attachIFC(result), payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -332,6 +346,14 @@ Use this tool to get details about individual projects, project fields, and proj return utils.NewToolResultError(err.Error()), nil, nil } + // attachIFC adds the IFC label to a successful result when IFC + // labels are enabled. Project data is user-authored free text + // (untrusted); confidentiality is conservatively private since the + // project's public flag is not available across every sub-result. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachStaticIFCLabel(ctx, deps, r, ifc.LabelProject(false)) + } + // Handle get_project_status_update early — it only needs status_update_id if method == projectsMethodGetProjectStatusUpdate { statusUpdateID, err := RequiredParam[string](args, "status_update_id") @@ -342,7 +364,8 @@ Use this tool to get details about individual projects, project fields, and proj if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - return getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) + result, payload, err := getProjectStatusUpdate(ctx, gqlClient, statusUpdateID) + return attachIFC(result), payload, err } owner, err := RequiredParam[string](args, "owner") @@ -375,13 +398,15 @@ Use this tool to get details about individual projects, project fields, and proj switch method { case projectsMethodGetProject: - return getProject(ctx, client, owner, ownerType, projectNumber) + result, payload, err := getProject(ctx, client, owner, ownerType, projectNumber) + return attachIFC(result), payload, err case projectsMethodGetProjectField: fieldID, err := RequiredBigInt(args, "field_id") if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - return getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) + result, payload, err := getProjectField(ctx, client, owner, ownerType, projectNumber, fieldID) + return attachIFC(result), payload, err case projectsMethodGetProjectItem: itemID, err := RequiredBigInt(args, "item_id") if err != nil { @@ -391,7 +416,8 @@ Use this tool to get details about individual projects, project fields, and proj if err != nil { return utils.NewToolResultError(err.Error()), nil, nil } - return getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) + result, payload, err := getProjectItem(ctx, client, owner, ownerType, projectNumber, itemID, fields) + return attachIFC(result), payload, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index 05028850d7..a23b98d3b2 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -14,6 +14,7 @@ import ( "github.com/shurcooL/githubv4" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/octicons" "github.com/github/github-mcp-server/pkg/sanitize" @@ -106,19 +107,29 @@ Possible options: return utils.NewToolResultErrorFromErr("failed to get GitHub client", err), nil, nil } + // attachIFC adds the IFC label to a successful tool result when + // IFC labels are enabled. Pull request content (descriptions, + // diffs, comments, reviews) is user-authored and therefore + // untrusted; confidentiality follows repo visibility. If the + // visibility lookup fails the label is omitted rather than + // misclassifying the result. + attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { + return attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, r, ifc.LabelListIssues) + } + switch method { case "get": result, err := GetPullRequest(ctx, client, deps, owner, repo, pullNumber) - return result, nil, err + return attachIFC(result), nil, err case "get_diff": result, err := GetPullRequestDiff(ctx, client, owner, repo, pullNumber) - return result, nil, err + return attachIFC(result), nil, err case "get_status": result, err := GetPullRequestStatus(ctx, client, owner, repo, pullNumber) - return result, nil, err + return attachIFC(result), nil, err case "get_files": result, err := GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_review_comments": gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -129,16 +140,16 @@ Possible options: return utils.NewToolResultError(err.Error()), nil, nil } result, err := GetPullRequestReviewComments(ctx, gqlClient, deps, owner, repo, pullNumber, cursorPagination) - return result, nil, err + return attachIFC(result), nil, err case "get_reviews": result, err := GetPullRequestReviews(ctx, client, deps, owner, repo, pullNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_comments": result, err := GetIssueComments(ctx, client, deps, owner, repo, pullNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err case "get_check_runs": result, err := GetPullRequestCheckRuns(ctx, client, owner, repo, pullNumber, pagination) - return result, nil, err + return attachIFC(result), nil, err default: return utils.NewToolResultError(fmt.Sprintf("unknown method: %s", method)), nil, nil } @@ -1276,7 +1287,11 @@ func ListPullRequests(t translations.TranslationHelperFunc) inventory.ServerTool return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Pull request titles/bodies are user-authored (untrusted); + // confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelListIssues) + return result, nil, nil }) } @@ -1446,7 +1461,7 @@ func SearchPullRequests(t translations.TranslationHelperFunc) inventory.ServerTo }, []scopes.Scope{scopes.Repo}, func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { - result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests") + result, err := searchHandler(ctx, deps.GetClient, args, "pr", "failed to search pull requests", ifcSearchPostProcessOption(ctx, deps)) return result, nil, err }) } diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index 040a968cf9..b50d5a74e5 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -118,7 +118,13 @@ func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Commit content is reachable from the repo's history; in public + // repos anyone can land it via a PR (untrusted), in private repos + // only collaborators can (trusted). Confidentiality follows repo + // visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) + return result, nil, nil }, ) } @@ -265,7 +271,12 @@ func ListCommits(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Commit content is reachable from the repo's history; integrity + // follows the same public-untrusted / private-trusted rule as file + // contents. Confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelCommitContents) + return result, nil, nil }, ) } @@ -352,7 +363,12 @@ func ListBranches(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Branches are structural repo metadata that only collaborators + // with push access can create, so integrity is trusted. + // Confidentiality follows repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } @@ -752,28 +768,7 @@ func GetFileContents(t translations.TranslationHelperFunc) inventory.ServerTool // each. If the visibility lookup fails we skip the label rather // than misclassify the result; the failure is not cached so a // later return path can retry. - var ( - ifcLabelKnown bool - ifcIsPrivate bool - ) - attachIFC := func(r *mcp.CallToolResult) *mcp.CallToolResult { - if r == nil || r.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { - return r - } - if !ifcLabelKnown { - isPrivate, err := FetchRepoIsPrivate(ctx, client, owner, repo) - if err != nil { - return r - } - ifcIsPrivate = isPrivate - ifcLabelKnown = true - } - if r.Meta == nil { - r.Meta = mcp.Meta{} - } - r.Meta["ifc"] = ifc.LabelGetFileContents(ifcIsPrivate) - return r - } + attachIFC := newRepoVisibilityIFCLabeler(ctx, deps, client, owner, repo, ifc.LabelGetFileContents) rawOpts, fallbackUsed, err := resolveGitReference(ctx, client, owner, repo, ref, sha) if err != nil { @@ -1609,7 +1604,12 @@ func ListTags(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Tags are structural repo metadata created by collaborators with + // push access, so integrity is trusted. Confidentiality follows + // repo visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } @@ -1689,7 +1689,9 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { if err != nil { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil } tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA) @@ -1715,7 +1717,12 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // An annotated tag object is structural repo metadata created by a + // collaborator with push access. Confidentiality follows repo + // visibility. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, ifc.LabelRepoMetadata) + return result, nil, nil }, ) } @@ -1797,7 +1804,24 @@ func ListReleases(t translations.TranslationHelperFunc) inventory.ServerTool { return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Releases are published by collaborators with push access, so + // integrity is trusted. Confidentiality follows repo visibility, + // but draft releases are visible only to push-access users and are + // not world-readable even on a public repo, so the result is only + // public when no returned release is a draft. + hasDraft := false + for _, mr := range minimalReleases { + if mr.Draft { + hasDraft = true + break + } + } + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + func(isPrivate bool) ifc.SecurityLabel { + return ifc.LabelRelease(isPrivate, hasDraft) + }) + return result, nil, nil }, ) } @@ -1863,7 +1887,16 @@ func GetLatestRelease(t translations.TranslationHelperFunc) inventory.ServerTool return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Releases are published by collaborators with push access, so + // integrity is trusted. The "latest release" endpoint never returns + // a draft, but the draft flag is honored defensively: a draft is + // not world-readable even on a public repo. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + func(isPrivate bool) ifc.SecurityLabel { + return ifc.LabelRelease(isPrivate, release.GetDraft()) + }) + return result, nil, nil }, ) } @@ -1940,7 +1973,16 @@ func GetReleaseByTag(t translations.TranslationHelperFunc) inventory.ServerTool return nil, nil, fmt.Errorf("failed to marshal response: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Releases are published by collaborators with push access, so + // integrity is trusted. A release fetched by tag may be a draft, + // which is visible only to push-access users and not world-readable + // even on a public repo, so a draft forces private confidentiality. + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + func(isPrivate bool) ifc.SecurityLabel { + return ifc.LabelRelease(isPrivate, release.GetDraft()) + }) + return result, nil, nil }, ) } @@ -2072,7 +2114,18 @@ func ListStarredRepositories(t translations.TranslationHelperFunc) inventory.Ser return nil, nil, fmt.Errorf("failed to marshal starred repositories: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // A starred-repository listing exposes repository data across many + // repos; reuse the multi-repo join shared with search_repositories + // (untrusted integrity; confidentiality private if any matched repo + // is private). Visibility is read directly from the response, so no + // extra API call is needed. + visibilities := make([]bool, 0, len(minimalRepos)) + for _, mr := range minimalRepos { + visibilities = append(visibilities, mr.Private) + } + result = attachJoinedIFCLabel(ctx, deps, result, visibilities, ifc.LabelSearchIssues) + return result, nil, nil }, ) } @@ -2311,7 +2364,13 @@ func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory "lastPage": resp.LastPage, } - return MarshalledTextResult(response), nil, nil + callResult := MarshalledTextResult(response) + // The collaborator roster is GitHub-maintained membership data + // (trusted, not attacker-authored). Listing collaborators requires + // push access, so the roster is never world-readable — not even on + // a public repo — hence always private confidentiality. + callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.LabelCollaboratorRoster()) + return callResult, nil, nil }, ) } diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index e1b7f94f53..1ca57ee876 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -620,6 +620,127 @@ func Test_GetFileContents_IFC_InsidersMode(t *testing.T) { }) } +// Test_GetCommit_IFC_FeatureFlag verifies that the IFC security label is only +// attached to get_commit results when the ifc_labels feature flag is enabled, +// and that the label content matches the commit-contents rule (untrusted on +// public repos, trusted on private). It also confirms the label is omitted +// when the repository visibility lookup fails, so the result is never +// misclassified. get_commit is representative of every tool wired through the +// shared attachRepoVisibilityIFCLabel helper. +func Test_GetCommit_IFC_FeatureFlag(t *testing.T) { + t.Parallel() + + serverTool := GetCommit(translations.NullTranslationHelper) + + mockCommit := &github.RepositoryCommit{ + SHA: github.Ptr("abc123def456"), + Commit: &github.Commit{Message: github.Ptr("First commit")}, + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + } + + makeMockClient := func(isPrivate bool) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }), + }) + } + + reqParams := map[string]any{ + "owner": "owner", + "repo": "repo", + "sha": "abc123def456", + } + + t.Run("feature flag disabled omits ifc label from result meta", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false)), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + assert.Nil(t, result.Meta, "result meta should be nil when IFC labels are disabled") + }) + + t.Run("feature flag enabled on public repo emits public untrusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "untrusted", ifcMap["integrity"]) + assert.Equal(t, "public", ifcMap["confidentiality"]) + }) + + t.Run("feature flag enabled on private repo emits private trusted label", func(t *testing.T) { + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(true)), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + require.NotNil(t, result.Meta) + ifcLabel, ok := result.Meta["ifc"] + require.True(t, ok, "result meta should contain ifc key") + + ifcJSON, err := json.Marshal(ifcLabel) + require.NoError(t, err) + var ifcMap map[string]any + require.NoError(t, json.Unmarshal(ifcJSON, &ifcMap)) + + assert.Equal(t, "trusted", ifcMap["integrity"]) + assert.Equal(t, "private", ifcMap["confidentiality"]) + }) + + t.Run("feature flag enabled skips ifc label when visibility lookup fails", func(t *testing.T) { + mockedClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposCommitsByOwnerByRepoByRef: mockResponse(t, http.StatusOK, mockCommit), + GetReposByOwnerByRepo: mockResponse(t, http.StatusInternalServerError, "boom"), + }) + deps := BaseDeps{ + Client: mustNewGHClient(t, mockedClient), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError, "tool call should still succeed when visibility lookup fails") + + if result.Meta != nil { + _, hasIFC := result.Meta["ifc"] + assert.False(t, hasIFC, "ifc label should be omitted when visibility lookup fails") + } + }) +} + func Test_ForkRepository(t *testing.T) { // Verify tool definition once serverTool := ForkRepository(translations.NullTranslationHelper) @@ -3717,6 +3838,107 @@ func Test_GetReleaseByTag(t *testing.T) { } } +// Test_GetReleaseByTag_IFC_FeatureFlag verifies the IFC label on +// get_release_by_tag. The label is only present when the ifc_labels flag is +// enabled, and confidentiality is public only for a non-draft release on a +// public repo. A draft release is visible only to push-access users, so even +// on a public repo it must be labeled private. Guards against the same +// under-classification fixed for repository security advisories. +func Test_GetReleaseByTag_IFC_FeatureFlag(t *testing.T) { + t.Parallel() + + serverTool := GetReleaseByTag(translations.NullTranslationHelper) + + makeRelease := func(draft bool) *github.RepositoryRelease { + return &github.RepositoryRelease{ + ID: github.Ptr(int64(1)), + TagName: github.Ptr("v1.0.0"), + Name: github.Ptr("v1.0.0"), + Draft: github.Ptr(draft), + } + } + + makeMockClient := func(isPrivate bool, release *github.RepositoryRelease) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposReleasesTagsByOwnerByRepoByTag: mockResponse(t, http.StatusOK, release), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }), + }) + } + + reqParams := map[string]any{"owner": "owner", "repo": "repo", "tag": "v1.0.0"} + + readIFC := func(t *testing.T, result *mcp.CallToolResult) (map[string]any, bool) { + t.Helper() + if result.Meta == nil { + return nil, false + } + label, ok := result.Meta["ifc"] + if !ok { + return nil, false + } + labelJSON, err := json.Marshal(label) + require.NoError(t, err) + var labelMap map[string]any + require.NoError(t, json.Unmarshal(labelJSON, &labelMap)) + return labelMap, true + } + + t.Run("feature flag disabled omits ifc label", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{Client: mustNewGHClient(t, makeMockClient(false, makeRelease(false)))} + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.Meta) + }) + + t.Run("public repo with published release is public", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, makeRelease(false))), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + label, ok := readIFC(t, result) + require.True(t, ok) + assert.Equal(t, "trusted", label["integrity"]) + assert.Equal(t, "public", label["confidentiality"]) + }) + + t.Run("public repo with draft release is private", func(t *testing.T) { + t.Parallel() + // Reviewer-class scenario: a draft release on a public repo is not + // world-readable, so the label must not be public. + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, makeRelease(true))), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + label, ok := readIFC(t, result) + require.True(t, ok) + assert.Equal(t, "trusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"], "draft release on public repo must be private") + }) +} + func Test_looksLikeSHA(t *testing.T) { tests := []struct { name string diff --git a/pkg/github/search.go b/pkg/github/search.go index 9a8d182887..42ba2896f3 100644 --- a/pkg/github/search.go +++ b/pkg/github/search.go @@ -163,22 +163,22 @@ func SearchRepositories(t translations.TranslationHelperFunc) inventory.ServerTo } callResult := utils.NewToolResultText(string(r)) - if deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { - attachSearchRepositoriesIFCLabel(result.Repositories, callResult) - } + attachSearchRepositoriesIFCLabel(ctx, deps, result.Repositories, callResult) return callResult, nil, nil }, ) } // attachSearchRepositoriesIFCLabel joins per-repository IFC labels across -// every matched repository and attaches the result to callResult. Visibility -// is read directly from the search response — no extra API call. The join -// math is shared with search_issues via ifc.LabelSearchIssues: integrity is -// always untrusted; confidentiality is private if any matched repository is -// private, otherwise public. -func attachSearchRepositoriesIFCLabel(repos []*github.Repository, callResult *mcp.CallToolResult) { - if callResult == nil || callResult.IsError { +// every matched repository and attaches the result to callResult when IFC +// labels are enabled. Visibility is read directly from the search response — +// no extra API call. The join math is shared with search_issues via +// ifc.LabelSearchIssues: integrity is always untrusted; confidentiality is +// private if any matched repository is private, otherwise public. The +// feature-flag check is centralized here (mirroring the attach* helpers in +// ifc_labels.go) so the handler can call this unconditionally. +func attachSearchRepositoriesIFCLabel(ctx context.Context, deps ToolDependencies, repos []*github.Repository, callResult *mcp.CallToolResult) { + if callResult == nil || callResult.IsError || !deps.IsFeatureEnabled(ctx, FeatureFlagIFCLabels) { return } @@ -187,10 +187,7 @@ func attachSearchRepositoriesIFCLabel(repos []*github.Repository, callResult *mc visibilities = append(visibilities, repo.GetPrivate()) } - if callResult.Meta == nil { - callResult.Meta = mcp.Meta{} - } - callResult.Meta["ifc"] = ifc.LabelSearchIssues(visibilities) + setIFCLabel(callResult, ifc.LabelSearchIssues(visibilities)) } // SearchCode creates a tool to search for code across GitHub repositories. @@ -304,7 +301,18 @@ func SearchCode(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + // Code search spans repositories and exposes file contents + // (untrusted). Confidentiality is the IFC join across every matched + // repository's visibility, read directly from the search response. + visibilities := make([]bool, 0, len(result.CodeResults)) + for _, code := range result.CodeResults { + if code.Repository != nil { + visibilities = append(visibilities, code.Repository.GetPrivate()) + } + } + callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues) + return callResult, nil, nil }, ) } @@ -392,7 +400,11 @@ func userOrOrgHandler(ctx context.Context, accountType string, deps ToolDependen if err != nil { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + // User and organization search returns public profile information that is + // authored by the account holders themselves, so it is public-untrusted. + callResult = attachStaticIFCLabel(ctx, deps, callResult, ifc.PublicUntrusted()) + return callResult, nil, nil } // SearchUsers creates a tool to search for GitHub users. @@ -580,7 +592,18 @@ func SearchCommits(t translations.TranslationHelperFunc) inventory.ServerTool { return utils.NewToolResultErrorFromErr("failed to marshal response", err), nil, nil } - return utils.NewToolResultText(string(r)), nil, nil + callResult := utils.NewToolResultText(string(r)) + // Commit search spans repositories and exposes commit content + // (untrusted). Confidentiality is the IFC join across every matched + // repository's visibility, read directly from the search response. + visibilities := make([]bool, 0, len(result.Commits)) + for _, commit := range result.Commits { + if commit.Repository != nil { + visibilities = append(visibilities, commit.Repository.GetPrivate()) + } + } + callResult = attachJoinedIFCLabel(ctx, deps, callResult, visibilities, ifc.LabelSearchIssues) + return callResult, nil, nil }, ) } diff --git a/pkg/github/secret_scanning.go b/pkg/github/secret_scanning.go index e2605274f0..18cfe73771 100644 --- a/pkg/github/secret_scanning.go +++ b/pkg/github/secret_scanning.go @@ -8,6 +8,7 @@ import ( "net/http" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -89,7 +90,12 @@ func GetSecretScanningAlert(t translations.TranslationHelperFunc) inventory.Serv return nil, nil, fmt.Errorf("failed to marshal alert: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Secret scanning alerts are access-restricted regardless of repo + // visibility and surface the matched secret material itself, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } @@ -199,7 +205,12 @@ func ListSecretScanningAlerts(t translations.TranslationHelperFunc) inventory.Se return nil, nil, fmt.Errorf("failed to marshal alerts: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Secret scanning alerts are access-restricted regardless of repo + // visibility and surface the matched secret material itself, so the + // label is always private-untrusted. + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelSecurityAlert()) + return result, nil, nil }, ) } diff --git a/pkg/github/security_advisories.go b/pkg/github/security_advisories.go index ec84e27b15..36e114c1dc 100644 --- a/pkg/github/security_advisories.go +++ b/pkg/github/security_advisories.go @@ -8,6 +8,7 @@ import ( "net/http" ghErrors "github.com/github/github-mcp-server/pkg/errors" + "github.com/github/github-mcp-server/pkg/ifc" "github.com/github/github-mcp-server/pkg/inventory" "github.com/github/github-mcp-server/pkg/scopes" "github.com/github/github-mcp-server/pkg/translations" @@ -203,7 +204,12 @@ func ListGlobalSecurityAdvisories(t translations.TranslationHelperFunc) inventor return nil, nil, fmt.Errorf("failed to marshal advisories: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Global advisories come from the world-readable GitHub Advisory + // Database (public) but contain externally authored prose + // (untrusted). + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory()) + return result, nil, nil }, ) } @@ -307,7 +313,17 @@ func ListRepositorySecurityAdvisories(t translations.TranslationHelperFunc) inve return nil, nil, fmt.Errorf("failed to marshal advisories: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Repository advisories carry externally authored prose (untrusted). + // Confidentiality follows repo visibility, but draft/triage/closed + // advisories are not world-readable even on a public repo, so the + // result is only public when every returned advisory is published. + allPublished := allAdvisoriesPublished(advisories) + result = attachRepoVisibilityIFCLabel(ctx, deps, client, owner, repo, result, + func(isPrivate bool) ifc.SecurityLabel { + return ifc.LabelRepositorySecurityAdvisory(isPrivate, allPublished) + }) + return result, nil, nil }, ) } @@ -364,7 +380,11 @@ func GetGlobalSecurityAdvisory(t translations.TranslationHelperFunc) inventory.S return nil, nil, fmt.Errorf("failed to marshal advisory: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // A global advisory is world-readable (public) but externally + // authored (untrusted). + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelGlobalSecurityAdvisory()) + return result, nil, nil }, ) } @@ -459,7 +479,28 @@ func ListOrgRepositorySecurityAdvisories(t translations.TranslationHelperFunc) i return nil, nil, fmt.Errorf("failed to marshal advisories: %w", err) } - return utils.NewToolResultText(string(r)), nil, nil + result := utils.NewToolResultText(string(r)) + // Org-wide advisory listings span the organization's repositories + // (including private ones) and are restricted to org members, so + // they are conservatively labeled private-untrusted (isPrivate=true, + // which forces private regardless of publication state). + result = attachStaticIFCLabel(ctx, deps, result, ifc.LabelRepositorySecurityAdvisory(true, false)) + return result, nil, nil }, ) } + +// allAdvisoriesPublished reports whether every advisory in the slice is in the +// "published" state. Repository security advisories can also be in draft, +// triage, or closed states, none of which are world-readable even on a public +// repository. An empty slice is treated as published (true) since there is no +// non-public content to protect. Used to decide whether a repository advisory +// listing may carry a public confidentiality label. +func allAdvisoriesPublished(advisories []*github.SecurityAdvisory) bool { + for _, advisory := range advisories { + if advisory.GetState() != "published" { + return false + } + } + return true +} diff --git a/pkg/github/security_advisories_test.go b/pkg/github/security_advisories_test.go index f45c2e4210..d02908610d 100644 --- a/pkg/github/security_advisories_test.go +++ b/pkg/github/security_advisories_test.go @@ -10,6 +10,7 @@ import ( "github.com/github/github-mcp-server/pkg/translations" "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -370,6 +371,132 @@ func Test_ListRepositorySecurityAdvisories(t *testing.T) { } } +// Test_ListRepositorySecurityAdvisories_IFC_FeatureFlag verifies the IFC label +// attached to list_repository_security_advisories. The label is only present +// when the ifc_labels feature flag is enabled, and — critically — confidentiality +// is public only when the repository is public AND every returned advisory is +// published. Draft/triage/closed advisories are not world-readable even on a +// public repo, so a result containing one must be labeled private. This guards +// against the under-classification raised in PR review. +func Test_ListRepositorySecurityAdvisories_IFC_FeatureFlag(t *testing.T) { + t.Parallel() + + toolDef := ListRepositorySecurityAdvisories(translations.NullTranslationHelper) + + publishedAdvisory := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-1111-1111-1111"), + Summary: github.Ptr("Published advisory"), + State: github.Ptr("published"), + } + draftAdvisory := &github.SecurityAdvisory{ + GHSAID: github.Ptr("GHSA-2222-2222-2222"), + Summary: github.Ptr("Draft advisory"), + State: github.Ptr("draft"), + } + + makeMockClient := func(isPrivate bool, advisories []*github.SecurityAdvisory) *http.Client { + return MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposSecurityAdvisoriesByOwnerByRepo: mockResponse(t, http.StatusOK, advisories), + GetReposByOwnerByRepo: mockResponse(t, http.StatusOK, map[string]any{ + "name": "repo", + "private": isPrivate, + }), + }) + } + + reqParams := map[string]any{ + "owner": "owner", + "repo": "repo", + } + + readIFC := func(t *testing.T, result *mcp.CallToolResult) (map[string]any, bool) { + t.Helper() + if result.Meta == nil { + return nil, false + } + label, ok := result.Meta["ifc"] + if !ok { + return nil, false + } + labelJSON, err := json.Marshal(label) + require.NoError(t, err) + var labelMap map[string]any + require.NoError(t, json.Unmarshal(labelJSON, &labelMap)) + return labelMap, true + } + + t.Run("feature flag disabled omits ifc label", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{Client: mustNewGHClient(t, makeMockClient(false, []*github.SecurityAdvisory{publishedAdvisory}))} + handler := toolDef.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + assert.Nil(t, result.Meta, "result meta should be nil when IFC labels are disabled") + }) + + t.Run("public repo with only published advisories is public", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, []*github.SecurityAdvisory{publishedAdvisory})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + label, ok := readIFC(t, result) + require.True(t, ok, "result meta should contain ifc key") + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "public", label["confidentiality"]) + }) + + t.Run("public repo with a draft advisory is private", func(t *testing.T) { + t.Parallel() + // Reviewer scenario: a draft advisory on a public repo is not + // world-readable, so the label must not be public. + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(false, []*github.SecurityAdvisory{publishedAdvisory, draftAdvisory})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + label, ok := readIFC(t, result) + require.True(t, ok, "result meta should contain ifc key") + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"], "draft advisory on public repo must be private") + }) + + t.Run("private repo is private", func(t *testing.T) { + t.Parallel() + deps := BaseDeps{ + Client: mustNewGHClient(t, makeMockClient(true, []*github.SecurityAdvisory{publishedAdvisory})), + featureChecker: featureCheckerFor(FeatureFlagIFCLabels), + } + handler := toolDef.Handler(deps) + + request := createMCPRequest(reqParams) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + require.False(t, result.IsError) + + label, ok := readIFC(t, result) + require.True(t, ok, "result meta should contain ifc key") + assert.Equal(t, "untrusted", label["integrity"]) + assert.Equal(t, "private", label["confidentiality"]) + }) +} + func Test_ListOrgRepositorySecurityAdvisories(t *testing.T) { // Verify tool definition once toolDef := ListOrgRepositorySecurityAdvisories(translations.NullTranslationHelper) diff --git a/pkg/http/server_test.go b/pkg/http/server_test.go index 62511775a9..1804134651 100644 --- a/pkg/http/server_test.go +++ b/pkg/http/server_test.go @@ -94,10 +94,10 @@ func TestCreateHTTPFeatureChecker(t *testing.T) { wantEnabled: true, }, { - name: "insiders mode enables internal-only insiders flags", + name: "insiders mode does not auto-enable ifc labels", flagName: github.FeatureFlagIFCLabels, insidersMode: true, - wantEnabled: true, + wantEnabled: false, }, { name: "insiders mode does not enable granular flags", diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go index e6eeb407bc..fefe542e3d 100644 --- a/pkg/ifc/ifc.go +++ b/pkg/ifc/ifc.go @@ -59,8 +59,18 @@ func PrivateUntrusted() SecurityLabel { } } +// LabelGetMe returns the IFC label for the authenticated user's own profile +// (get_me). +// +// Integrity is trusted: this is GitHub-maintained data about the caller's own +// account, not attacker-authored content. +// +// Confidentiality is private. The result includes fields that are NOT part of +// the user's public profile — private_gists, total_private_repos, and +// owned_private_repos — which are visible only to the authenticated user. The +// result therefore must not be treated as world-readable. func LabelGetMe() SecurityLabel { - return PublicTrusted() + return PrivateTrusted() } // LabelListIssues returns the IFC label for a list_issues result. @@ -98,6 +108,16 @@ func LabelGetFileContents(isPrivate bool) SecurityLabel { // // An empty result set is treated as public-untrusted (no repository data is // leaked). +// +// Why a single joined label rather than one label per item: a tool result is +// delivered as one opaque payload (a single content block) and the IFC engine +// makes one allow/deny decision per flow at egress. Once the items share a +// buffer in the agent's context they can be copied anywhere together, so the +// only sound bound for the whole result is the meet of every item's label. +// Per-item labels would only become load-bearing if the enforcement engine +// could partition a result and route individual items to different sinks; +// until then they would invite unsafe declassification of a "public" item that +// actually arrived alongside private data. func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { for _, isPrivate := range repoVisibilities { if isPrivate { @@ -106,3 +126,206 @@ func LabelSearchIssues(repoVisibilities []bool) SecurityLabel { } return PublicUntrusted() } + +// LabelRepoMetadata returns the IFC label for structural repository metadata +// that only collaborators with write access can define: labels, branches, +// tags, releases, issue types, issue field definitions, discussion +// categories, and the collaborator roster. +// +// Integrity is trusted because, unlike issue/PR/comment bodies, these +// artifacts cannot be authored by arbitrary outsiders — creating a branch, +// tag, release, or label requires push access, so the data reflects decisions +// made by the repository's trusted writers rather than attacker-controllable +// input. +// +// Confidentiality follows repository visibility: public repositories are +// universally readable; private repositories restrict the reader set (the +// opaque "private" marker, resolved client-side at egress time). +func LabelRepoMetadata(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicTrusted() +} + +// LabelRelease returns the IFC label for repository releases (list_releases, +// get_latest_release, get_release_by_tag). +// +// Integrity is trusted: releases are published by collaborators with push +// access, not by arbitrary outsiders. +// +// Confidentiality is public only when the repository is public AND no returned +// release is a draft. Draft releases are visible only to users with push +// access — they are NOT world-readable even on a public repository — so a +// result containing one must be private. hasDraft reflects whether any release +// in the result is a draft; private repositories are always private regardless. +func LabelRelease(isPrivate bool, hasDraft bool) SecurityLabel { + if isPrivate || hasDraft { + return PrivateTrusted() + } + return PublicTrusted() +} + +// LabelCollaboratorRoster returns the IFC label for a repository's collaborator +// list (list_repository_collaborators). +// +// Integrity is trusted: the roster is GitHub-maintained membership data, not +// attacker-authored content. +// +// Confidentiality is always private. Listing collaborators requires push +// access to the repository, so the roster is never world-readable — not even +// for public repositories. This mirrors LabelTeam: membership data is +// restricted regardless of the repository's own visibility. +func LabelCollaboratorRoster() SecurityLabel { + return PrivateTrusted() +} + +// LabelCommitContents returns the IFC label for committed repository content +// reachable from the default branch and its history: commits, commit diffs, +// and the repository file tree. +// +// It shares the reasoning of LabelGetFileContents. In public repositories any +// outsider can land content via a pull request, so the integrity of committed +// content is untrusted. In private repositories only collaborators can push, +// so committed content is trusted. Confidentiality follows repository +// visibility. +func LabelCommitContents(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateTrusted() + } + return PublicUntrusted() +} + +// LabelActionsResult returns the IFC label for GitHub Actions resources: +// workflow definitions, runs, jobs, artifacts, and job logs. +// +// Integrity is untrusted. Workflow logs echo arbitrary text produced during a +// run — including output derived from pull-request branches, dependency +// downloads, and other attacker-influenceable sources — so log and artifact +// content must be treated as low integrity. Workflow definitions are +// themselves editable through pull requests in public repositories. +// +// Confidentiality follows repository visibility. +func LabelActionsResult(isPrivate bool) SecurityLabel { + if isPrivate { + return PrivateUntrusted() + } + return PublicUntrusted() +} + +// LabelSecurityAlert returns the IFC label for security findings: code +// scanning alerts, secret scanning alerts, and Dependabot alerts. +// +// Integrity is untrusted because alert payloads embed attacker-influenceable +// material — the offending code snippet, the matched secret string, or a +// vulnerable dependency's advisory text — none of which the agent should treat +// as a trustworthy instruction source. +// +// Confidentiality is always private. Security alerts are access-restricted by +// GitHub regardless of repository visibility (only users with a security role +// can read them), so the reader set is narrow even for public repositories. +// Secret scanning results additionally surface the secret material itself. +func LabelSecurityAlert() SecurityLabel { + return PrivateUntrusted() +} + +// LabelGlobalSecurityAdvisory returns the IFC label for advisories served from +// the public GitHub Advisory Database (global advisories). +// +// The advisory database is world-readable, so confidentiality is public. +// Integrity is untrusted: advisory descriptions are externally authored prose +// and must not be treated as a trusted instruction source. +func LabelGlobalSecurityAdvisory() SecurityLabel { + return PublicUntrusted() +} + +// LabelRepositorySecurityAdvisory returns the IFC label for repository- or +// organization-scoped security advisories. +// +// Integrity is untrusted (externally authored advisory prose). +// +// Confidentiality is public only when the repository is public AND every +// advisory in the result is in the "published" state. Repository security +// advisories also exist in draft, triage, and closed states; those are visible +// only to maintainers and are NOT world-readable even on a public repository. +// Treating any non-published advisory as private (allPublished == false) +// prevents misclassifying an unpublished advisory from a public repo as +// public-readable. Private repositories are always private regardless of state. +func LabelRepositorySecurityAdvisory(isPrivate bool, allPublished bool) SecurityLabel { + if isPrivate || !allPublished { + return PrivateUntrusted() + } + return PublicUntrusted() +} + +// LabelGist returns the IFC label for gist content. +// +// Integrity is untrusted: gist contents are arbitrary user-authored text. +// Confidentiality derives from the gist's own visibility rather than any +// repository — public gists are universally readable, while secret gists are +// restricted to those who hold the gist URL (modeled with the opaque "private" +// marker). +func LabelGist(isPublic bool) SecurityLabel { + if isPublic { + return PublicUntrusted() + } + return PrivateUntrusted() +} + +// LabelGistList returns the IFC label for a list of gists belonging to a user, +// joining the per-gist confidentiality across the result set. +// +// Integrity is untrusted (user-authored content). Confidentiality follows the +// IFC meet: if any gist in the result is secret the joined label is private; +// otherwise public. An empty result is treated as public-untrusted. +// +// See LabelSearchIssues for why list results carry a single joined label +// rather than one label per item. +func LabelGistList(gistVisibilities []bool) SecurityLabel { + for _, isPublic := range gistVisibilities { + if !isPublic { + return PrivateUntrusted() + } + } + return PublicUntrusted() +} + +// LabelProject returns the IFC label for a GitHub Project (Projects v2) and its +// items, status updates, and field definitions. +// +// Integrity is untrusted: project titles, item content, and status update +// bodies are user-authored free text. Confidentiality derives from the +// project's own public flag — public projects are universally readable, while +// private projects restrict the reader set. +func LabelProject(isPublic bool) SecurityLabel { + if isPublic { + return PublicUntrusted() + } + return PrivateUntrusted() +} + +// LabelTeam returns the IFC label for organization team membership data +// (get_teams, get_team_members). +// +// Integrity is trusted: team membership is maintained by GitHub and cannot be +// forged by outside contributors, so it is not an attacker-controllable +// instruction source. +// +// Confidentiality is private. Organization team rosters and the teams a user +// belongs to are visible only to members of the organization, not to the +// public, so the reader set is restricted (the opaque "private" marker). +func LabelTeam() SecurityLabel { + return PrivateTrusted() +} + +// LabelNotificationDetails returns the IFC label for the subject of a single +// notification. +// +// Integrity is untrusted: a notification subject points at an issue, pull +// request, comment, or discussion whose content is user-authored and may carry +// attacker-controlled text. Confidentiality is private because notifications +// are delivered to a specific recipient and may reference private +// repositories; the result cannot be assumed to be publicly readable. +func LabelNotificationDetails() SecurityLabel { + return PrivateUntrusted() +} diff --git a/pkg/ifc/ifc_test.go b/pkg/ifc/ifc_test.go index 669f5ff0cc..90788a8cb7 100644 --- a/pkg/ifc/ifc_test.go +++ b/pkg/ifc/ifc_test.go @@ -49,3 +49,245 @@ func TestLabelSearchIssues(t *testing.T) { }) } } + +func TestLabelRepoMetadata(t *testing.T) { + t.Parallel() + + t.Run("public repo metadata is trusted and public", func(t *testing.T) { + t.Parallel() + label := LabelRepoMetadata(false) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo metadata is trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelRepoMetadata(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelGetMe(t *testing.T) { + t.Parallel() + + // get_me exposes private_gists/total_private_repos/owned_private_repos, + // which are not part of the public profile, so the result is trusted but + // private — never public. + label := LabelGetMe() + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) +} + +func TestLabelRelease(t *testing.T) { + t.Parallel() + + t.Run("public repo with no draft is trusted and public", func(t *testing.T) { + t.Parallel() + label := LabelRelease(false, false) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("public repo with a draft release is private", func(t *testing.T) { + t.Parallel() + // Draft releases are visible only to push-access users, so a draft on + // a public repo must not be labeled public. + label := LabelRelease(false, true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) + + t.Run("private repo is private regardless of draft", func(t *testing.T) { + t.Parallel() + for _, hasDraft := range []bool{false, true} { + label := LabelRelease(true, hasDraft) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + } + }) +} + +func TestLabelCollaboratorRoster(t *testing.T) { + t.Parallel() + + // A collaborator roster requires push access to list, so it is never + // world-readable — always trusted and private. + label := LabelCollaboratorRoster() + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) +} + +func TestLabelCommitContents(t *testing.T) { + t.Parallel() + + t.Run("public repo commit content is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelCommitContents(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo commit content is trusted and private", func(t *testing.T) { + t.Parallel() + label := LabelCommitContents(true) + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelActionsResult(t *testing.T) { + t.Parallel() + + t.Run("public repo actions result is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelActionsResult(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private repo actions result is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelActionsResult(true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelSecurityAlert(t *testing.T) { + t.Parallel() + label := LabelSecurityAlert() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality, + "security alerts are access-restricted regardless of repo visibility") +} + +func TestLabelGlobalSecurityAdvisory(t *testing.T) { + t.Parallel() + label := LabelGlobalSecurityAdvisory() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) +} + +func TestLabelRepositorySecurityAdvisory(t *testing.T) { + t.Parallel() + + t.Run("public repo with all published advisories is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelRepositorySecurityAdvisory(false, true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("public repo with an unpublished advisory is untrusted and private", func(t *testing.T) { + t.Parallel() + // draft/triage/closed advisories are not world-readable even on a + // public repo, so confidentiality must be private. + label := LabelRepositorySecurityAdvisory(false, false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) + + t.Run("private repo advisory is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelRepositorySecurityAdvisory(true, true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) + + t.Run("private repo with unpublished advisory is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelRepositorySecurityAdvisory(true, false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelGist(t *testing.T) { + t.Parallel() + + t.Run("public gist is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelGist(true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("secret gist is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelGist(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelGistList(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + visibilities []bool // true == public + wantConfidential Confidentiality + }{ + { + name: "empty result is treated as public", + wantConfidential: ConfidentialityPublic, + }, + { + name: "all public gists stay public", + visibilities: []bool{true, true}, + wantConfidential: ConfidentialityPublic, + }, + { + name: "any secret gist flips to private", + visibilities: []bool{true, false, true}, + wantConfidential: ConfidentialityPrivate, + }, + { + name: "all secret gists stay private", + visibilities: []bool{false, false}, + wantConfidential: ConfidentialityPrivate, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + label := LabelGistList(tc.visibilities) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, tc.wantConfidential, label.Confidentiality) + }) + } +} + +func TestLabelProject(t *testing.T) { + t.Parallel() + + t.Run("public project is untrusted and public", func(t *testing.T) { + t.Parallel() + label := LabelProject(true) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPublic, label.Confidentiality) + }) + + t.Run("private project is untrusted and private", func(t *testing.T) { + t.Parallel() + label := LabelProject(false) + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) + }) +} + +func TestLabelTeam(t *testing.T) { + t.Parallel() + label := LabelTeam() + assert.Equal(t, IntegrityTrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) +} + +func TestLabelNotificationDetails(t *testing.T) { + t.Parallel() + label := LabelNotificationDetails() + assert.Equal(t, IntegrityUntrusted, label.Integrity) + assert.Equal(t, ConfidentialityPrivate, label.Confidentiality) +} From 0e4b22a610d9e5bad9154e423e20ca8a54f97fa7 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:00:23 +0200 Subject: [PATCH 27/34] build(deps): bump hono (#2606) Bumps the npm_and_yarn group with 1 update in the /ui directory: [hono](https://github.com/honojs/hono). Updates `hono` from 4.12.19 to 4.12.23 - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.19...v4.12.23) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.23 dependency-type: indirect dependency-group: npm_and_yarn ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sam Morrow --- ui/package-lock.json | 428 +------------------------------------------ 1 file changed, 3 insertions(+), 425 deletions(-) diff --git a/ui/package-lock.json b/ui/package-lock.json index 13d78a25a8..4046bc28f9 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -1694,381 +1694,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "peer": true - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "peer": true - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -3375,9 +3000,9 @@ "peer": true }, "node_modules/hono": { - "version": "4.12.19", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.19.tgz", - "integrity": "sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==", + "version": "4.12.23", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", + "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", "license": "MIT", "peer": true, "engines": { @@ -5417,53 +5042,6 @@ "@rolldown/binding-win32-x64-msvc": "1.0.1" } }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", From 2cff183ca056a13851d46b152f5892ff37c6f8ff Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:00:30 +0200 Subject: [PATCH 28/34] build(deps): bump golang from 1.25.10-alpine to 1.25.11-alpine (#2597) Bumps golang from 1.25.10-alpine to 1.25.11-alpine. --- updated-dependencies: - dependency-name: golang dependency-version: 1.25.11-alpine dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sam Morrow --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 65d0f9e1ca..90a2cf0af2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ COPY ui/ ./ui/ RUN mkdir -p ./pkg/github/ui_dist && \ cd ui && npm run build -FROM golang:1.25.10-alpine@sha256:8d22e29d960bc50cd025d93d5b7c7d220b1ee9aa7a239b3c8f55a57e987e8d45 AS build +FROM golang:1.25.11-alpine@sha256:cd2fb3559df6e13bc93b7f0734a4eabe1d21e7b64eec211ed90784f00a17a56a AS build ARG VERSION="dev" # Set the working directory From 1654d32ad8c95041c0dc2d27df57afad62bbf87d Mon Sep 17 00:00:00 2001 From: Dan Moseley <6385855+danmoseley@users.noreply.github.com> Date: Thu, 11 Jun 2026 06:16:33 -0600 Subject: [PATCH 29/34] errors: improve rate limit error messages for AI agents (#2386) * errors: improve rate limit error messages for AI agents When the GitHub API returns a rate limit error, replace the raw Go HTTP error string with a clean, actionable message so agents know exactly how long to wait before retrying. Before: search code: GET https://api.github.com/search/code: 403 API rate limit exceeded for user ID 12345. [rate reset in 2m59s] After: search code: GitHub API rate limit exceeded. Retry after 2m59s. create issue: GitHub secondary rate limit exceeded. Retry after 47s. create issue: GitHub secondary rate limit exceeded. Wait before retrying. Edge cases: expired/zero reset time, nil RetryAfter, and errors wrapped with errors.As all produce "Wait before retrying." rather than a negative or confusing duration. The original error is stored in context via addGitHubAPIErrorToContext before the rate-limit check, so middleware is unaffected. Fixes #2385. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * errors: fix flaky rate limit tests Compute expectedRetryIn before calling the function under test, and use larger reset time offsets (20-30 min), so a 1s boundary during time.Duration.Round cannot cause spurious mismatches. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * errors: extract requireErrorText and assertContextHasError test helpers Reduces repetition in TestNewGitHubAPIErrorResponse_RateLimits subtests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Fix edge cases: sub-second rate limit durations and UTF-8 BOM - Primary rate limit: compute time.Until(resetTime) once and check the rounded result is >0 before showing 'Retry after X'. This avoids a TOCTOU race between the After(time.Now()) guard and the subsequent time.Until call, and prevents showing 'Retry after 0s.' when the reset time is imminent. - Secondary rate limit: round RetryAfter first, then check >0. Previously, a RetryAfter of e.g. 200ms would pass the >0 guard but format as 'Retry after 0s.' after rounding. - Add tests for both sub-second edge cases. - Remove UTF-8 BOM accidentally introduced in error_test.go by .NET WriteAllText with the default UTF8 encoding. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- pkg/errors/error.go | 31 ++++++ pkg/errors/error_test.go | 230 ++++++++++++++++++++++++++++++++++++++- 2 files changed, 260 insertions(+), 1 deletion(-) diff --git a/pkg/errors/error.go b/pkg/errors/error.go index 7c1f28e660..a1b35d697d 100644 --- a/pkg/errors/error.go +++ b/pkg/errors/error.go @@ -2,8 +2,10 @@ package errors import ( "context" + stderrors "errors" "fmt" "net/http" + "time" "github.com/github/github-mcp-server/pkg/utils" "github.com/google/go-github/v87/github" @@ -159,6 +161,35 @@ func NewGitHubAPIErrorResponse(ctx context.Context, message string, resp *github if ctx != nil { _, _ = addGitHubAPIErrorToContext(ctx, apiErr) // Explicitly ignore error for graceful handling } + + var rateLimitErr *github.RateLimitError + if stderrors.As(err, &rateLimitErr) { + resetTime := rateLimitErr.Rate.Reset.Time + if !resetTime.IsZero() { + retryIn := time.Until(resetTime).Round(time.Second) + if retryIn > 0 { + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub API rate limit exceeded. Retry after %v.", message, retryIn)) + } + } + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub API rate limit exceeded. Wait before retrying.", message)) + } + + var abuseErr *github.AbuseRateLimitError + if stderrors.As(err, &abuseErr) { + if abuseErr.RetryAfter != nil { + retryAfter := abuseErr.RetryAfter.Round(time.Second) + if retryAfter > 0 { + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub secondary rate limit exceeded. Retry after %v.", + message, retryAfter)) + } + } + return utils.NewToolResultError(fmt.Sprintf( + "%s: GitHub secondary rate limit exceeded. Wait before retrying.", message)) + } + return utils.NewToolResultErrorFromErr(message, err) } diff --git a/pkg/errors/error_test.go b/pkg/errors/error_test.go index 7459569f2a..77ceb21375 100644 --- a/pkg/errors/error_test.go +++ b/pkg/errors/error_test.go @@ -5,8 +5,9 @@ import ( "fmt" "net/http" "testing" - + "time" "github.com/google/go-github/v87/github" + "github.com/modelcontextprotocol/go-sdk/mcp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -460,3 +461,230 @@ func TestMiddlewareScenario(t *testing.T) { assert.Contains(t, gqlMessages, "mutation failed") }) } + +// requireErrorText asserts that result is a non-nil MCP tool error and returns its text content. +func requireErrorText(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + require.NotNil(t, result) + require.True(t, result.IsError) + require.NotEmpty(t, result.Content) + text, ok := result.Content[0].(*mcp.TextContent) + require.True(t, ok, "expected *mcp.TextContent, got %T", result.Content[0]) + return text.Text +} + +// assertContextHasError asserts that exactly one error is stored in ctx and it matches expectedErr. +// +//nolint:revive // t must be first for test helpers; context-as-argument doesn't apply here +func assertContextHasError(t *testing.T, ctx context.Context, expectedErr error) { + t.Helper() + apiErrors, err := GetGitHubAPIErrors(ctx) + require.NoError(t, err) + require.Len(t, apiErrors, 1) + assert.Equal(t, expectedErr, apiErrors[0].Err) +} + +func TestNewGitHubAPIErrorResponse_RateLimits(t *testing.T) { + t.Run("RateLimitError produces clean message with retry time", func(t *testing.T) { + // Given a context with GitHub error tracking enabled + ctx := ContextWithGitHubErrors(context.Background()) + + resetTime := time.Now().Add(30 * time.Minute) + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}}, + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + resp := &github.Response{Response: rateLimitErr.Response} + + // Capture expected duration before the call so both use the same time.Until snapshot + expectedRetryIn := time.Until(resetTime).Round(time.Second) + + // When we create an API error response for a rate limit error + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr) + + // Then the message should be clean and actionable (no raw URLs or status codes) + text := requireErrorText(t, result) + assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn)) + assert.NotContains(t, text, "https://") + assert.NotContains(t, text, "403") + + // And the original error should still be stored in context for middleware + assertContextHasError(t, ctx, rateLimitErr) + }) + + t.Run("AbuseRateLimitError with RetryAfter produces clean message with wait time", func(t *testing.T) { + // Given a context with GitHub error tracking enabled + ctx := ContextWithGitHubErrors(context.Background()) + + retryAfter := 47 * time.Second + abuseErr := &github.AbuseRateLimitError{ + Response: &http.Response{StatusCode: 403}, + Message: "You have exceeded a secondary rate limit.", + RetryAfter: &retryAfter, + } + resp := &github.Response{Response: abuseErr.Response} + + // When we create an API error response for a secondary rate limit error + result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr) + + // And the message should include the specific retry duration + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 47s.") + assert.NotContains(t, text, "https://") + assert.NotContains(t, text, "403") + + // And the original error should still be stored in context for middleware + assertContextHasError(t, ctx, abuseErr) + }) + + t.Run("AbuseRateLimitError without RetryAfter produces clean message without wait time", func(t *testing.T) { + // Given a context with GitHub error tracking enabled + ctx := ContextWithGitHubErrors(context.Background()) + + abuseErr := &github.AbuseRateLimitError{ + Response: &http.Response{StatusCode: 403}, + Message: "You have exceeded a secondary rate limit.", + RetryAfter: nil, + } + resp := &github.Response{Response: abuseErr.Response} + + // When we create an API error response for a secondary rate limit error without retry info + result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr) + + // And the message should be clean and actionable + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.") + assert.NotContains(t, text, "https://") + assert.NotContains(t, text, "403") + + // And the original error should still be stored in context for middleware + assertContextHasError(t, ctx, abuseErr) + }) + + t.Run("AbuseRateLimitError with sub-second RetryAfter falls back to wait message", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + // 200ms rounds to 0s, so should fall back to the generic wait message + retryAfter := 200 * time.Millisecond + abuseErr := &github.AbuseRateLimitError{ + Response: &http.Response{StatusCode: 403}, + Message: "You have exceeded a secondary rate limit.", + RetryAfter: &retryAfter, + } + resp := &github.Response{Response: abuseErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, abuseErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub secondary rate limit exceeded. Wait before retrying.") + }) + + t.Run("RateLimitError with reset time in the past falls back to wait message", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + resetTime := time.Now().Add(-5 * time.Second) // already passed + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}}, + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + resp := &github.Response{Response: rateLimitErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.") + }) + + t.Run("RateLimitError with sub-second reset time falls back to wait message", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + // 250ms in the future: still positive, but rounds to 0s, so should fall back + resetTime := time.Now().Add(250 * time.Millisecond) + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}}, + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + resp := &github.Response{Response: rateLimitErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.") + }) + + t.Run("RateLimitError with zero reset time falls back to wait message", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{}, // zero Reset time + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + resp := &github.Response{Response: rateLimitErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, rateLimitErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub API rate limit exceeded. Wait before retrying.") + }) + + t.Run("wrapped RateLimitError is handled via errors.As", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + resetTime := time.Now().Add(20 * time.Minute) + rateLimitErr := &github.RateLimitError{ + Rate: github.Rate{Reset: github.Timestamp{Time: resetTime}}, + Response: &http.Response{StatusCode: 403}, + Message: "API rate limit exceeded", + } + wrappedErr := fmt.Errorf("transport layer: %w", rateLimitErr) + resp := &github.Response{Response: rateLimitErr.Response} + + // Capture expected duration before the call so both use the same time.Until snapshot + expectedRetryIn := time.Until(resetTime).Round(time.Second) + + result := NewGitHubAPIErrorResponse(ctx, "search code", resp, wrappedErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, fmt.Sprintf("GitHub API rate limit exceeded. Retry after %v.", expectedRetryIn)) + assert.NotContains(t, text, "https://") + }) + + t.Run("wrapped AbuseRateLimitError is handled via errors.As", func(t *testing.T) { + ctx := ContextWithGitHubErrors(context.Background()) + + retryAfter := 30 * time.Second + abuseErr := &github.AbuseRateLimitError{ + Response: &http.Response{StatusCode: 403}, + Message: "secondary rate limit", + RetryAfter: &retryAfter, + } + wrappedErr := fmt.Errorf("transport layer: %w", abuseErr) + resp := &github.Response{Response: abuseErr.Response} + + result := NewGitHubAPIErrorResponse(ctx, "create issue", resp, wrappedErr) + + text := requireErrorText(t, result) + assert.Contains(t, text, "GitHub secondary rate limit exceeded. Retry after 30s.") + assert.NotContains(t, text, "https://") + }) + + t.Run("non-rate-limit GitHub API error passes through the original error message", func(t *testing.T) { + // Given a context with GitHub error tracking enabled + ctx := ContextWithGitHubErrors(context.Background()) + + resp := &github.Response{Response: &http.Response{StatusCode: 422}} + originalErr := fmt.Errorf("validation failed") + + // When we create an API error response for a non-rate-limit error + result := NewGitHubAPIErrorResponse(ctx, "API call failed", resp, originalErr) + + // Then the message should contain the original error text unchanged + text := requireErrorText(t, result) + assert.Contains(t, text, "validation failed") + }) +} + From 35acc92c4f567a5d96ded95be4987ad6033971d4 Mon Sep 17 00:00:00 2001 From: Ross Tarrant Date: Thu, 11 Jun 2026 13:52:22 +0100 Subject: [PATCH 30/34] feat: Add get_commits method to pull_request_read (#2608) * feat: Add get_commits method to pull_request_read * Add nil check and additional test case --------- Co-authored-by: Sam Morrow --- README.md | 9 +- .../__toolsnaps__/pull_request_read.snap | 3 +- pkg/github/helper_test.go | 1 + pkg/github/minimal_types.go | 46 +++++ pkg/github/pullrequests.go | 42 +++- pkg/github/pullrequests_test.go | 179 ++++++++++++++++++ 6 files changed, 270 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 19377b87c1..07169c4c16 100644 --- a/README.md +++ b/README.md @@ -1122,10 +1122,11 @@ The following sets of tools are available: 2. get_diff - Get the diff of a pull request. 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. - 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. - 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. - 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. - 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. + 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned. + 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. + 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. + 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. + 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. (string, required) - `owner`: Repository owner (string, required) - `page`: Page number for pagination (min 1) (number, optional) diff --git a/pkg/github/__toolsnaps__/pull_request_read.snap b/pkg/github/__toolsnaps__/pull_request_read.snap index d70f77e1e0..f1bb855d51 100644 --- a/pkg/github/__toolsnaps__/pull_request_read.snap +++ b/pkg/github/__toolsnaps__/pull_request_read.snap @@ -11,12 +11,13 @@ "type": "string" }, "method": { - "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", + "description": "Action to specify what pull request data needs to be retrieved from GitHub. \nPossible options: \n 1. get - Get details of a specific pull request.\n 2. get_diff - Get the diff of a pull request.\n 3. get_status - Get combined commit status of a head commit in a pull request.\n 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned.\n 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned.\n 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results.\n 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned.\n 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned.\n 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR.\n", "enum": [ "get", "get_diff", "get_status", "get_files", + "get_commits", "get_review_comments", "get_reviews", "get_comments", diff --git a/pkg/github/helper_test.go b/pkg/github/helper_test.go index fdac78ce3f..7f86c8b989 100644 --- a/pkg/github/helper_test.go +++ b/pkg/github/helper_test.go @@ -69,6 +69,7 @@ const ( // Pull request endpoints GetReposPullsByOwnerByRepo = "GET /repos/{owner}/{repo}/pulls" GetReposPullsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}" + GetReposPullsCommitsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/commits" GetReposPullsFilesByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/files" GetReposPullsReviewsByOwnerByRepoByPullNumber = "GET /repos/{owner}/{repo}/pulls/{pull_number}/reviews" PostReposPullsByOwnerByRepo = "POST /repos/{owner}/{repo}/pulls" diff --git a/pkg/github/minimal_types.go b/pkg/github/minimal_types.go index 5200be297f..eff6edc133 100644 --- a/pkg/github/minimal_types.go +++ b/pkg/github/minimal_types.go @@ -123,6 +123,14 @@ type MinimalPRFile struct { PreviousFilename string `json:"previous_filename,omitempty"` } +// MinimalPullRequestCommit is the trimmed output type for commits listed on a pull request. +type MinimalPullRequestCommit struct { + SHA string `json:"sha"` + HTMLURL string `json:"html_url,omitempty"` + Message string `json:"message,omitempty"` + Author *MinimalCommitAuthor `json:"author,omitempty"` +} + // MinimalCommit is the trimmed output type for commit objects. type MinimalCommit struct { SHA string `json:"sha"` @@ -1609,6 +1617,44 @@ func convertToMinimalPRFiles(files []*github.CommitFile) []MinimalPRFile { return result } +func convertToMinimalPullRequestCommits(commits []*github.RepositoryCommit) []MinimalPullRequestCommit { + result := make([]MinimalPullRequestCommit, 0, len(commits)) + for _, commit := range commits { + if commit == nil { + continue + } + + minimalCommit := MinimalPullRequestCommit{ + SHA: commit.GetSHA(), + HTMLURL: commit.GetHTMLURL(), + } + + if commit.Commit != nil { + minimalCommit.Message = commit.Commit.GetMessage() + minimalCommit.Author = convertToMinimalCommitAuthor(commit.Commit.Author) + } + + result = append(result, minimalCommit) + } + return result +} + +func convertToMinimalCommitAuthor(author *github.CommitAuthor) *MinimalCommitAuthor { + if author == nil { + return nil + } + + minimalAuthor := &MinimalCommitAuthor{ + Name: author.GetName(), + Email: author.GetEmail(), + } + if author.Date != nil { + minimalAuthor.Date = author.Date.Format(time.RFC3339) + } + + return minimalAuthor +} + // convertToMinimalBranch converts a GitHub API Branch to MinimalBranch func convertToMinimalBranch(branch *github.Branch) MinimalBranch { return MinimalBranch{ diff --git a/pkg/github/pullrequests.go b/pkg/github/pullrequests.go index a23b98d3b2..ae7d04331d 100644 --- a/pkg/github/pullrequests.go +++ b/pkg/github/pullrequests.go @@ -36,12 +36,13 @@ Possible options: 2. get_diff - Get the diff of a pull request. 3. get_status - Get combined commit status of a head commit in a pull request. 4. get_files - Get the list of files changed in a pull request. Use with pagination parameters to control the number of results returned. - 5. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. - 6. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. - 7. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. - 8. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. + 5. get_commits - Get the list of commits on a pull request. Use with pagination parameters to control the number of results returned. + 6. get_review_comments - Get review threads on a pull request. Each thread contains logically grouped review comments made on the same code location during pull request reviews. Returns threads with metadata (isResolved, isOutdated, isCollapsed) and their associated comments. Use cursor-based pagination (perPage, after) to control results. + 7. get_reviews - Get the reviews on a pull request. When asked for review comments, use get_review_comments method. Use with pagination parameters to control the number of results returned. + 8. get_comments - Get comments on a pull request. Use this if user doesn't specifically want review comments. Use with pagination parameters to control the number of results returned. + 9. get_check_runs - Get check runs for the head commit of a pull request. Check runs are the individual CI/CD jobs and checks that run on the PR. `, - Enum: []any{"get", "get_diff", "get_status", "get_files", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"}, + Enum: []any{"get", "get_diff", "get_status", "get_files", "get_commits", "get_review_comments", "get_reviews", "get_comments", "get_check_runs"}, }, "owner": { Type: "string", @@ -130,6 +131,9 @@ Possible options: case "get_files": result, err := GetPullRequestFiles(ctx, client, owner, repo, pullNumber, pagination) return attachIFC(result), nil, err + case "get_commits": + result, err := GetPullRequestCommits(ctx, client, owner, repo, pullNumber, pagination) + return attachIFC(result), nil, err case "get_review_comments": gqlClient, err := deps.GetGQLClient(ctx) if err != nil { @@ -382,6 +386,34 @@ func GetPullRequestFiles(ctx context.Context, client *github.Client, owner, repo return MarshalledTextResult(minimalFiles), nil } +func GetPullRequestCommits(ctx context.Context, client *github.Client, owner, repo string, pullNumber int, pagination PaginationParams) (*mcp.CallToolResult, error) { + opts := &github.ListOptions{ + PerPage: pagination.PerPage, + Page: pagination.Page, + } + commits, resp, err := client.PullRequests.ListCommits(ctx, owner, repo, pullNumber, opts) + if err != nil { + return ghErrors.NewGitHubAPIErrorResponse(ctx, + "failed to get pull request commits", + resp, + err, + ), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get pull request commits", resp, body), nil + } + + minimalCommits := convertToMinimalPullRequestCommits(commits) + + return MarshalledTextResult(minimalCommits), nil +} + // GraphQL types for review threads query type reviewThreadsQuery struct { Repository struct { diff --git a/pkg/github/pullrequests_test.go b/pkg/github/pullrequests_test.go index aff71e4c1a..2b911636a9 100644 --- a/pkg/github/pullrequests_test.go +++ b/pkg/github/pullrequests_test.go @@ -1260,6 +1260,185 @@ func Test_GetPullRequestFiles(t *testing.T) { } } +func Test_GetPullRequestCommits(t *testing.T) { + // Verify tool definition once + serverTool := PullRequestRead(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "pull_request_read", tool.Name) + assert.NotEmpty(t, tool.Description) + schema := tool.InputSchema.(*jsonschema.Schema) + assert.Contains(t, schema.Properties, "method") + assert.Contains(t, schema.Properties, "owner") + assert.Contains(t, schema.Properties, "repo") + assert.Contains(t, schema.Properties, "pullNumber") + assert.Contains(t, schema.Properties, "page") + assert.Contains(t, schema.Properties, "perPage") + assert.ElementsMatch(t, schema.Required, []string{"method", "owner", "repo", "pullNumber"}) + + authorDate := time.Date(2026, 4, 1, 12, 0, 0, 0, time.UTC) + mockCommits := []*github.RepositoryCommit{ + { + SHA: github.Ptr("abc123def456"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/abc123def456"), + Commit: &github.Commit{ + Message: github.Ptr("feat: add commit listing"), + Author: &github.CommitAuthor{ + Name: github.Ptr("Test User"), + Email: github.Ptr("test@example.com"), + Date: &github.Timestamp{Time: authorDate}, + }, + Committer: &github.CommitAuthor{ + Name: github.Ptr("Merge Bot"), + Email: github.Ptr("merge@example.com"), + Date: &github.Timestamp{Time: authorDate.Add(30 * time.Minute)}, + }, + }, + Author: &github.User{ + Login: github.Ptr("test-user"), + ID: github.Ptr(int64(12345)), + HTMLURL: github.Ptr("https://github.com/test-user"), + AvatarURL: github.Ptr("https://github.com/test-user.png"), + }, + Committer: &github.User{ + Login: github.Ptr("merge-bot"), + ID: github.Ptr(int64(67890)), + HTMLURL: github.Ptr("https://github.com/merge-bot"), + AvatarURL: github.Ptr("https://github.com/merge-bot.png"), + }, + }, + { + SHA: github.Ptr("def456abc789"), + HTMLURL: github.Ptr("https://github.com/owner/repo/commit/def456abc789"), + Commit: &github.Commit{ + Message: github.Ptr("fix: handle pagination"), + }, + }, + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedCommits []*github.RepositoryCommit + expectedErrMsg string + }{ + { + name: "successful commits fetch", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "method": "get_commits", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "successful commits fetch with pagination", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "2", + "per_page": "10", + }).andThen( + mockResponse(t, http.StatusOK, mockCommits), + ), + }), + requestArgs: map[string]any{ + "method": "get_commits", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(42), + "page": float64(2), + "perPage": float64(10), + }, + expectError: false, + expectedCommits: mockCommits, + }, + { + name: "commits fetch fails", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposPullsCommitsByOwnerByRepoByPullNumber: expectQueryParams(t, map[string]string{ + "page": "1", + "per_page": "30", + }).andThen( + http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + }), + ), + }), + requestArgs: map[string]any{ + "method": "get_commits", + "owner": "owner", + "repo": "repo", + "pullNumber": float64(999), + }, + expectError: true, + expectedErrMsg: "failed to get pull request commits", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := mustNewGHClient(t, tc.mockedClient) + serverTool := PullRequestRead(translations.NullTranslationHelper) + deps := BaseDeps{ + Client: client, + RepoAccessCache: stubRepoAccessCache(nil, 5*time.Minute), + Flags: stubFeatureFlags(map[string]bool{"lockdown-mode": false}), + } + handler := serverTool.Handler(deps) + request := createMCPRequest(tc.requestArgs) + + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + + textContent := getTextResult(t, result) + assert.NotContains(t, textContent.Text, `"committer"`) + assert.NotContains(t, textContent.Text, `"profile_url"`) + + var returnedCommits []MinimalPullRequestCommit + err = json.Unmarshal([]byte(textContent.Text), &returnedCommits) + require.NoError(t, err) + assert.Len(t, returnedCommits, len(tc.expectedCommits)) + for i, commit := range returnedCommits { + assert.Equal(t, tc.expectedCommits[i].GetSHA(), commit.SHA) + assert.Equal(t, tc.expectedCommits[i].GetHTMLURL(), commit.HTMLURL) + assert.Equal(t, tc.expectedCommits[i].GetCommit().GetMessage(), commit.Message) + } + + assert.Equal(t, authorDate.Format(time.RFC3339), returnedCommits[0].Author.Date) + }) + } +} + +func Test_ConvertToMinimalPullRequestCommitsSkipsNilCommit(t *testing.T) { + commits := convertToMinimalPullRequestCommits([]*github.RepositoryCommit{nil}) + + require.Empty(t, commits) +} + func Test_GetPullRequestStatus(t *testing.T) { // Verify tool definition once serverTool := PullRequestRead(translations.NullTranslationHelper) From f2219746b5f2b78e24ca83b1161847ae4fd3ff60 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:52:53 +0200 Subject: [PATCH 31/34] build(deps): bump node from `7c6af15` to `144769e` (#2598) Bumps node from `7c6af15` to `144769e`. --- updated-dependencies: - dependency-name: node dependency-version: 26-alpine dependency-type: direct:production ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Sam Morrow --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 90a2cf0af2..a4ea1d03b8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:26-alpine@sha256:7c6af15abe4e3de859690e7db171d0d711bf37d27528eddfe625b2fe89e097f8 AS ui-build +FROM node:26-alpine@sha256:144769ec3f32e8ee36b3cfde91e82bee25d9367b20f31a151f3f7eea3a2a8541 AS ui-build WORKDIR /app COPY ui/package*.json ./ui/ RUN cd ui && npm ci From e0fba89e4c1ef1bbb2e0e7b062eba92b17f39e2f Mon Sep 17 00:00:00 2001 From: Yufeng He <40085740+he-yufeng@users.noreply.github.com> Date: Thu, 11 Jun 2026 20:57:49 +0800 Subject: [PATCH 32/34] fix: hide write UI resources in read-only mode (#2612) Co-authored-by: Sam Morrow --- pkg/github/server.go | 2 +- pkg/github/ui_resources.go | 6 ++++- pkg/github/ui_resources_test.go | 46 ++++++++++++++++++++++++++++++++- 3 files changed, 51 insertions(+), 3 deletions(-) diff --git a/pkg/github/server.go b/pkg/github/server.go index f56ac7d3a8..7ec5837c3a 100644 --- a/pkg/github/server.go +++ b/pkg/github/server.go @@ -111,7 +111,7 @@ func NewMCPServer(ctx context.Context, cfg *MCPServerConfig, deps ToolDependenci // remote/HTTP server also serves them, fixing the "-32002 Resource not // found" error clients hit after the tool returns a ui:// URI. if UIAssetsAvailable() { - RegisterUIResources(ghServer) + RegisterUIResources(ghServer, cfg.ReadOnly) } return ghServer, nil diff --git a/pkg/github/ui_resources.go b/pkg/github/ui_resources.go index ab3ebfd163..28051c0c4a 100644 --- a/pkg/github/ui_resources.go +++ b/pkg/github/ui_resources.go @@ -13,7 +13,7 @@ import ( // // Resource metadata follows the stable 2026-01-26 MCP Apps spec: // https://github.com/modelcontextprotocol/ext-apps/blob/main/specification/2026-01-26/apps.mdx -func RegisterUIResources(s *mcp.Server) { +func RegisterUIResources(s *mcp.Server, readOnly bool) { // Register the get_me UI resource s.AddResource( &mcp.Resource{ @@ -46,6 +46,10 @@ func RegisterUIResources(s *mcp.Server) { }, ) + if readOnly { + return + } + // Register the issue_write UI resource s.AddResource( &mcp.Resource{ diff --git a/pkg/github/ui_resources_test.go b/pkg/github/ui_resources_test.go index 928950ac73..7e67d5faed 100644 --- a/pkg/github/ui_resources_test.go +++ b/pkg/github/ui_resources_test.go @@ -2,6 +2,7 @@ package github import ( "context" + "slices" "testing" "github.com/github/github-mcp-server/pkg/inventory" @@ -26,7 +27,7 @@ func TestRegisterUIResources_ReadableViaClient(t *testing.T) { } srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) - RegisterUIResources(srv) + RegisterUIResources(srv, false) // Connect an in-memory client/server pair and read each advertised URI. st, ct := mcp.NewInMemoryTransports() @@ -113,6 +114,49 @@ func TestNewMCPServer_RegistersUIResources(t *testing.T) { assert.Equal(t, MCPAppMIMEType, res.Contents[0].MIMEType) } +func TestRegisterUIResources_ReadOnlySkipsWriteResources(t *testing.T) { + t.Parallel() + + srv := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + RegisterUIResources(srv, true) + + st, ct := mcp.NewInMemoryTransports() + + type clientResult struct { + res *mcp.ListResourcesResult + err error + } + clientCh := make(chan clientResult, 1) + go func() { + client := mcp.NewClient(&mcp.Implementation{Name: "test-client"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + if err != nil { + clientCh <- clientResult{err: err} + return + } + defer func() { _ = cs.Close() }() + + res, err := cs.ListResources(context.Background(), nil) + clientCh <- clientResult{res: res, err: err} + }() + + ss, err := srv.Connect(context.Background(), st, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = ss.Close() }) + + got := <-clientCh + require.NoError(t, got.err) + require.NotNil(t, got.res) + + names := make([]string, 0, len(got.res.Resources)) + for _, res := range got.res.Resources { + names = append(names, res.Name) + } + slices.Sort(names) + + assert.Equal(t, []string{"get_me_ui"}, names) +} + // mustEmptyInventory builds an empty inventory for tests that only care about // resources/prompts registered outside the inventory (such as the UI resources). func mustEmptyInventory(t *testing.T) *inventory.Inventory { From 1209da8eba41fdcbabf4ef0875a405743799c2e3 Mon Sep 17 00:00:00 2001 From: Mayowa Fajobi <127399119+MayorFaj@users.noreply.github.com> Date: Thu, 11 Jun 2026 14:51:09 +0100 Subject: [PATCH 33/34] feat: add get_file_blame tool for retrieving git blame information (#1538) * feat: add get_file_blame tool * feat: implement cursor-based pagination for get_file_blame tool * resolve annotated tags to their target commit in get_file_blame * Regenerate get_file_blame toolsnap and docs after merge with main The cursor-pagination parameter description changed on main; regenerate the toolsnap and README so docs-check and toolsnap tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * feat: gate get_file_blame behind file_blame feature flag The git blame tool adds a new tool to the inventory, which carries a context-footprint cost for every client. Gate it behind a new file_blame feature flag (user opt-in via --features / X-MCP-Features) that is also auto-enabled in insiders mode, so it is not advertised by default. Regenerated README, feature-flags.md and insiders-features.md docs. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Sam Morrow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/feature-flags.md | 13 + docs/insiders-features.md | 13 + pkg/github/__toolsnaps__/get_file_blame.snap | 54 ++ pkg/github/feature_flags.go | 7 + pkg/github/repositories.go | 422 ++++++++++++ pkg/github/repositories_test.go | 667 ++++++++++++++++++- pkg/github/tools.go | 1 + 7 files changed, 1176 insertions(+), 1 deletion(-) create mode 100644 pkg/github/__toolsnaps__/get_file_blame.snap diff --git a/docs/feature-flags.md b/docs/feature-flags.md index bef7283bef..cb02463a10 100644 --- a/docs/feature-flags.md +++ b/docs/feature-flags.md @@ -287,4 +287,17 @@ runtime behavior (such as output formatting) won't appear here. - `repo`: Repository name (string, required) - `title`: The new title for the pull request (string, required) +### `file_blame` + +- **get_file_blame** - Get file blame information + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) + - `end_line`: Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided. (number, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `path`: Path to the file in the repository, relative to the repository root (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD). (string, optional) + - `repo`: Repository name (string, required) + - `start_line`: Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window. (number, optional) + diff --git a/docs/insiders-features.md b/docs/insiders-features.md index 78c8231e3e..2277f0c8e2 100644 --- a/docs/insiders-features.md +++ b/docs/insiders-features.md @@ -107,6 +107,19 @@ The list below is generated from the Go source. It covers tool **inventory and s - `since`: Filter by date (ISO 8601 timestamp) (string, optional) - `state`: Filter by state, by default both open and closed issues are returned when not provided (string, optional) +### `file_blame` + +- **get_file_blame** - Get file blame information + - **Required OAuth Scopes**: `repo` + - `after`: Cursor for pagination. Use the cursor from the previous response. (string, optional) + - `end_line`: Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided. (number, optional) + - `owner`: Repository owner (username or organization) (string, required) + - `path`: Path to the file in the repository, relative to the repository root (string, required) + - `perPage`: Results per page for pagination (min 1, max 100) (number, optional) + - `ref`: Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD). (string, optional) + - `repo`: Repository name (string, required) + - `start_line`: Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window. (number, optional) + --- diff --git a/pkg/github/__toolsnaps__/get_file_blame.snap b/pkg/github/__toolsnaps__/get_file_blame.snap new file mode 100644 index 0000000000..83d09f265c --- /dev/null +++ b/pkg/github/__toolsnaps__/get_file_blame.snap @@ -0,0 +1,54 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Get file blame information" + }, + "description": "Get git blame information for a file, showing the commit that last modified each line. Ranges share commit metadata via the top-level 'commits' map keyed by SHA. Use 'start_line'/'end_line' to restrict the result to a window of the file, and 'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at 1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.", + "inputSchema": { + "properties": { + "after": { + "description": "Cursor for pagination. Use the cursor from the previous response.", + "type": "string" + }, + "end_line": { + "description": "Optional 1-based ending line of the window of interest. Must be \u003e= start_line when both are provided.", + "minimum": 1, + "type": "number" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "path": { + "description": "Path to the file in the repository, relative to the repository root", + "type": "string" + }, + "perPage": { + "description": "Results per page for pagination (min 1, max 100)", + "maximum": 100, + "minimum": 1, + "type": "number" + }, + "ref": { + "description": "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD).", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + }, + "start_line": { + "description": "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.", + "minimum": 1, + "type": "number" + } + }, + "required": [ + "owner", + "repo", + "path" + ], + "type": "object" + }, + "name": "get_file_blame" +} \ No newline at end of file diff --git a/pkg/github/feature_flags.go b/pkg/github/feature_flags.go index 15a78c1f19..8351795327 100644 --- a/pkg/github/feature_flags.go +++ b/pkg/github/feature_flags.go @@ -16,6 +16,11 @@ const FeatureFlagIFCLabels = "ifc_labels" // and field_values enrichment in list_issues / search_issues output. const FeatureFlagIssueFields = "remote_mcp_issue_fields" +// FeatureFlagFileBlame is the feature flag name for the get_file_blame tool, +// which exposes git blame information for a file. It is gated so the extra tool +// is not advertised by default, keeping the tool surface small unless opted in. +const FeatureFlagFileBlame = "file_blame" + // AllowedFeatureFlags is the allowlist of feature flags that can be enabled // by users via --features CLI flag or X-MCP-Features HTTP header. // Only flags in this list are accepted; unknown flags are silently ignored. @@ -27,6 +32,7 @@ var AllowedFeatureFlags = []string{ FeatureFlagIssueFields, FeatureFlagIssuesGranular, FeatureFlagPullRequestsGranular, + FeatureFlagFileBlame, } // InsidersFeatureFlags is the list of feature flags that insiders mode enables. @@ -37,6 +43,7 @@ var InsidersFeatureFlags = []string{ MCPAppsFeatureFlag, FeatureFlagCSVOutput, FeatureFlagIssueFields, + FeatureFlagFileBlame, } // FeatureFlags defines runtime feature toggles that adjust tool behavior. diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go index b50d5a74e5..60bb45c44f 100644 --- a/pkg/github/repositories.go +++ b/pkg/github/repositories.go @@ -7,6 +7,8 @@ import ( "fmt" "io" "net/http" + "slices" + "strconv" "strings" ghErrors "github.com/github/github-mcp-server/pkg/errors" @@ -19,6 +21,7 @@ import ( "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" ) func GetCommit(t translations.TranslationHelperFunc) inventory.ServerTool { @@ -2261,6 +2264,425 @@ func UnstarRepository(t translations.TranslationHelperFunc) inventory.ServerTool ) } +// maxBlameRanges caps the number of matching blame ranges considered for one response. +const maxBlameRanges = 1000 + +const blameCursorPrefix = "blame-range:" + +func encodeBlameCursor(offset int) string { + return base64.RawURLEncoding.EncodeToString(fmt.Appendf(nil, "%s%d", blameCursorPrefix, offset)) +} + +func decodeBlameCursor(cursor string) (int, error) { + if cursor == "" { + return 0, nil + } + + decoded, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return 0, fmt.Errorf("after cursor is invalid") + } + + value := string(decoded) + if !strings.HasPrefix(value, blameCursorPrefix) { + return 0, fmt.Errorf("after cursor is invalid") + } + + offset, err := strconv.Atoi(strings.TrimPrefix(value, blameCursorPrefix)) + if err != nil || offset < 0 { + return 0, fmt.Errorf("after cursor is invalid") + } + + return offset, nil +} + +// BlameAuthor describes the author of a commit referenced by a BlameRange. +type BlameAuthor struct { + Name string `json:"name"` + Email string `json:"email"` + Login *string `json:"login,omitempty"` + URL *string `json:"url,omitempty"` +} + +// BlameCommit holds commit metadata shared by one or more blame ranges. +type BlameCommit struct { + SHA string `json:"sha"` + MessageHeadline string `json:"message_headline"` + CommittedDate string `json:"committed_date"` + Author BlameAuthor `json:"author"` +} + +// BlameRange is a contiguous run of lines attributed to a single commit. +// +// Age is the relative position of this range's commit among distinct commits +// touching the file (0 = newest), not an absolute time delta. See: +// https://docs.github.com/en/graphql/reference/objects#blamerange +type BlameRange struct { + StartingLine int `json:"starting_line"` + EndingLine int `json:"ending_line"` + Age int `json:"age"` + CommitSHA string `json:"commit_sha"` +} + +// BlameResult is the response payload returned by the get_file_blame tool. +// +// Commits is keyed by SHA. TotalRanges counts matching ranges before cursor +// pagination or truncation. Truncated reports whether maxBlameRanges was hit. +type BlameResult struct { + Repository string `json:"repository"` + Path string `json:"path"` + Ref string `json:"ref"` + Ranges []BlameRange `json:"ranges"` + Commits map[string]BlameCommit `json:"commits"` + PageInfo MinimalPageInfo `json:"pageInfo"` + TotalRanges int `json:"total_ranges"` + Truncated bool `json:"truncated,omitempty"` +} + +// blameCommitFragment is the GraphQL selection for a Commit's blame data. +type blameCommitFragment struct { + Blame struct { + Ranges []struct { + StartingLine githubv4.Int + EndingLine githubv4.Int + Age githubv4.Int + Commit struct { + OID githubv4.String + Message githubv4.String + CommittedDate githubv4.DateTime + Author struct { + Name githubv4.String + Email githubv4.String + User *struct { + Login githubv4.String + URL githubv4.String + } + } + } + } + } `graphql:"blame(path: $path)"` +} + +// validateBlamePath rejects empty, leading-slash, traversal-laden, or +// control-character paths before any network call is made. +func validateBlamePath(p string) error { + if strings.TrimSpace(p) == "" { + return fmt.Errorf("path must not be empty") + } + if strings.HasPrefix(p, "/") { + return fmt.Errorf("path must be relative to the repository root (no leading '/')") + } + if slices.Contains(strings.Split(p, "/"), "..") { + return fmt.Errorf("path must not contain '..' segments") + } + for _, r := range p { + if r < 0x20 || r == 0x7f { + return fmt.Errorf("path must not contain control characters") + } + } + return nil +} + +func GetFileBlame(t translations.TranslationHelperFunc) inventory.ServerTool { + st := NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "get_file_blame", + Description: t("TOOL_GET_FILE_BLAME_DESCRIPTION", + "Get git blame information for a file, showing the commit that last modified each line. "+ + "Ranges share commit metadata via the top-level 'commits' map keyed by SHA. "+ + "Use 'start_line'/'end_line' to restrict the result to a window of the file, and "+ + "'perPage'/'after' to cursor-page through returned ranges. Matching ranges are capped at "+ + "1000; when the cap is hit 'truncated' is set to true and 'total_ranges' reports the pre-cap match count.", + ), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_GET_FILE_BLAME_USER_TITLE", "Get file blame information"), + ReadOnlyHint: true, + }, + InputSchema: WithCursorPagination(&jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "path": { + Type: "string", + Description: "Path to the file in the repository, relative to the repository root", + }, + "ref": { + Type: "string", + Description: "Git reference (branch, tag, or commit SHA). Defaults to the repository's default branch (HEAD).", + }, + "start_line": { + Type: "number", + Description: "Optional 1-based starting line of the window of interest. Only ranges overlapping [start_line, end_line] are returned, clamped to the window.", + Minimum: jsonschema.Ptr(1.0), + }, + "end_line": { + Type: "number", + Description: "Optional 1-based ending line of the window of interest. Must be >= start_line when both are provided.", + Minimum: jsonschema.Ptr(1.0), + }, + }, + Required: []string{"owner", "repo", "path"}, + }), + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + path, err := RequiredParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if err := validateBlamePath(path); err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + ref, err := OptionalParam[string](args, "ref") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + _, hasStartLine := args["start_line"] + startLine, err := OptionalIntParam(args, "start_line") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if hasStartLine && startLine < 1 { + return utils.NewToolResultError("start_line must be omitted or >= 1"), nil, nil + } + _, hasEndLine := args["end_line"] + endLine, err := OptionalIntParam(args, "end_line") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if hasEndLine && endLine < 1 { + return utils.NewToolResultError("end_line must be omitted or >= 1"), nil, nil + } + if hasStartLine && hasEndLine && endLine < startLine { + return utils.NewToolResultError("end_line must be >= start_line when both are provided"), nil, nil + } + if _, hasPage := args["page"]; hasPage { + return utils.NewToolResultError("This tool uses cursor-based pagination. Use the 'after' parameter with the 'endCursor' value from the previous response instead of 'page'."), nil, nil + } + pagination, err := OptionalCursorPaginationParams(args) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if _, hasPerPage := args["perPage"]; hasPerPage { + perPage, err := OptionalIntParam(args, "perPage") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + if perPage < 1 || perPage > 100 { + return utils.NewToolResultError("perPage must be between 1 and 100 when provided"), nil, nil + } + pagination.PerPage = perPage + } + afterOffset, err := decodeBlameCursor(pagination.After) + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetGQLClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub GraphQL client: %w", err) + } + + // Default to HEAD and fetch defaultBranchRef.name in the same query + // so the response can echo a readable ref. + refExpression := ref + if refExpression == "" { + refExpression = "HEAD" + } + + var blameQuery struct { + Repository struct { + DefaultBranchRef struct { + Name githubv4.String + } + Object struct { + Typename githubv4.String `graphql:"__typename"` + Commit blameCommitFragment `graphql:"... on Commit"` + // Annotated tag targets are followed one level. Tag-of-tag + // chains are not followed and will return an error. + Tag struct { + Target struct { + Typename githubv4.String `graphql:"__typename"` + Commit blameCommitFragment `graphql:"... on Commit"` + } + } `graphql:"... on Tag"` + } `graphql:"object(expression: $ref)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + vars := map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "ref": githubv4.String(refExpression), + "path": githubv4.String(path), + } + + if err := client.Query(ctx, &blameQuery, vars); err != nil { + return ghErrors.NewGitHubGraphQLErrorResponse(ctx, + fmt.Sprintf("failed to get blame for file: %s", path), + err, + ), nil, nil + } + + // GitHub's Commit.blame field accepts only path, and Blame.ranges is + // not a connection, so cursor pagination is applied locally below. + // The ref must resolve to a commit, either directly or via an annotated tag. + objectTypename := string(blameQuery.Repository.Object.Typename) + if objectTypename == "" { + return utils.NewToolResultError( + fmt.Sprintf("ref %q was not found in %s/%s", refExpression, owner, repo), + ), nil, nil + } + blameCommit := &blameQuery.Repository.Object.Commit + if objectTypename == "Tag" { + targetTypename := string(blameQuery.Repository.Object.Tag.Target.Typename) + if targetTypename != "Commit" { + if targetTypename == "" { + targetTypename = "unknown" + } + return utils.NewToolResultError( + fmt.Sprintf("ref %q resolved to a tag in %s/%s, but the tag target did not resolve to a commit (resolved to %s)", + refExpression, owner, repo, targetTypename), + ), nil, nil + } + blameCommit = &blameQuery.Repository.Object.Tag.Target.Commit + } else if objectTypename != "Commit" { + return utils.NewToolResultError( + fmt.Sprintf("ref %q did not resolve to a commit in %s/%s (resolved to %s)", + refExpression, owner, repo, objectTypename), + ), nil, nil + } + + // Echo the caller's ref, otherwise prefer the default branch name. + responseRef := ref + if responseRef == "" { + if name := string(blameQuery.Repository.DefaultBranchRef.Name); name != "" { + responseRef = name + } else { + responseRef = refExpression + } + } + + rawRanges := blameCommit.Blame.Ranges + pageRanges := make([]BlameRange, 0, pagination.PerPage) + commits := make(map[string]BlameCommit) + totalRanges := 0 + truncated := false + + for _, r := range rawRanges { + start := int(r.StartingLine) + end := int(r.EndingLine) + if startLine > 0 && end < startLine { + continue + } + if endLine > 0 && start > endLine { + continue + } + if startLine > 0 && start < startLine { + start = startLine + } + if endLine > 0 && end > endLine { + end = endLine + } + + matchIndex := totalRanges + totalRanges++ + if matchIndex >= maxBlameRanges { + truncated = true + continue + } + if matchIndex < afterOffset || len(pageRanges) >= pagination.PerPage { + continue + } + + blameRange := BlameRange{ + StartingLine: start, + EndingLine: end, + Age: int(r.Age), + CommitSHA: string(r.Commit.OID), + } + pageRanges = append(pageRanges, blameRange) + + sha := string(r.Commit.OID) + if _, seen := commits[sha]; seen { + continue + } + headline := string(r.Commit.Message) + if idx := strings.IndexByte(headline, '\n'); idx >= 0 { + headline = headline[:idx] + } + headline = strings.TrimRight(headline, " \t\r") + bc := BlameCommit{ + SHA: sha, + MessageHeadline: headline, + CommittedDate: r.Commit.CommittedDate.Format("2006-01-02T15:04:05Z"), + Author: BlameAuthor{ + Name: string(r.Commit.Author.Name), + Email: string(r.Commit.Author.Email), + }, + } + if r.Commit.Author.User != nil { + login := string(r.Commit.Author.User.Login) + url := string(r.Commit.Author.User.URL) + bc.Author.Login = &login + bc.Author.URL = &url + } + commits[sha] = bc + } + + cappedRanges := min(totalRanges, maxBlameRanges) + consumedRanges := min(afterOffset+len(pageRanges), cappedRanges) + pageInfo := MinimalPageInfo{ + HasNextPage: consumedRanges < cappedRanges, + HasPreviousPage: afterOffset > 0, + } + if len(pageRanges) > 0 { + pageInfo.StartCursor = encodeBlameCursor(afterOffset) + pageInfo.EndCursor = encodeBlameCursor(consumedRanges) + } + + result := BlameResult{ + Repository: fmt.Sprintf("%s/%s", owner, repo), + Path: path, + Ref: responseRef, + Ranges: pageRanges, + Commits: commits, + PageInfo: pageInfo, + TotalRanges: totalRanges, + Truncated: truncated, + } + if result.Ranges == nil { + result.Ranges = []BlameRange{} + } + + payload, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal response: %w", err) + } + + return utils.NewToolResultText(string(payload)), nil, nil + }, + ) + st.FeatureFlagEnable = FeatureFlagFileBlame + return st +} + // ListRepositoryCollaborators creates a tool to list collaborators of a GitHub repository. func ListRepositoryCollaborators(t translations.TranslationHelperFunc) inventory.ServerTool { return NewTool( diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go index 1ca57ee876..8b0b196a63 100644 --- a/pkg/github/repositories_test.go +++ b/pkg/github/repositories_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/github/github-mcp-server/internal/githubv4mock" "github.com/github/github-mcp-server/internal/toolsnaps" "github.com/github/github-mcp-server/pkg/raw" "github.com/github/github-mcp-server/pkg/translations" @@ -17,6 +18,7 @@ import ( "github.com/google/go-github/v87/github" "github.com/google/jsonschema-go/jsonschema" "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/shurcooL/githubv4" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -4704,6 +4706,670 @@ func Test_UnstarRepository(t *testing.T) { }) } } +func Test_GetFileBlame(t *testing.T) { + // Verify tool definition once + serverTool := GetFileBlame(translations.NullTranslationHelper) + tool := serverTool.Tool + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + // get_file_blame is gated so it is not advertised unless the feature flag + // (or insiders mode) opts it in. + assert.Equal(t, FeatureFlagFileBlame, serverTool.FeatureFlagEnable, "get_file_blame must be gated behind the file_blame feature flag") + + schema, ok := tool.InputSchema.(*jsonschema.Schema) + require.True(t, ok, "InputSchema should be *jsonschema.Schema") + assert.Equal(t, "get_file_blame", tool.Name) + assert.NotEmpty(t, tool.Description) + for _, key := range []string{"owner", "repo", "path", "ref", "start_line", "end_line", "perPage", "after"} { + assert.Contains(t, schema.Properties, key, "schema missing property %q", key) + } + assert.NotContains(t, schema.Properties, "page") + assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "path"}) + require.NotNil(t, tool.Annotations) + assert.True(t, tool.Annotations.ReadOnlyHint, "blame is read-only") + + // blameQueryShape is the GraphQL query shape used by all + // network-touching subtests below. Defined once so changes to the wire + // schema are made in a single place. + type blameQueryShape = struct { + Repository struct { + DefaultBranchRef struct { + Name githubv4.String + } + Object struct { + Typename githubv4.String `graphql:"__typename"` + Commit blameCommitFragment `graphql:"... on Commit"` + Tag struct { + Target struct { + Typename githubv4.String `graphql:"__typename"` + Commit blameCommitFragment `graphql:"... on Commit"` + } + } `graphql:"... on Tag"` + } `graphql:"object(expression: $ref)"` + } `graphql:"repository(owner: $owner, name: $repo)"` + } + + makeBlameVars := func(owner, repo, ref, path string) map[string]any { + return map[string]any{ + "owner": githubv4.String(owner), + "repo": githubv4.String(repo), + "ref": githubv4.String(ref), + "path": githubv4.String(path), + } + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]any + expectError bool + expectedErrMsg string + validateResponse func(t *testing.T, result string) + }{ + { + name: "successful blame using default branch (HEAD)", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "HEAD", "README.md"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 5, "age": 2, + "commit": map[string]any{ + "oid": "abc123def456", + "message": "Initial commit\n\nLong body that should not appear in the response.", + "committedDate": "2024-01-01T12:00:00Z", + "author": map[string]any{ + "name": "John Doe", "email": "john@example.com", + "user": map[string]any{"login": "johndoe", "url": "https://github.com/johndoe"}, + }, + }, + }, + { + // Same commit as the first range -> must be deduplicated. + "startingLine": 6, "endingLine": 7, "age": 2, + "commit": map[string]any{ + "oid": "abc123def456", + "message": "Initial commit\n\nLong body that should not appear in the response.", + "committedDate": "2024-01-01T12:00:00Z", + "author": map[string]any{ + "name": "John Doe", "email": "john@example.com", + "user": map[string]any{"login": "johndoe", "url": "https://github.com/johndoe"}, + }, + }, + }, + { + "startingLine": 8, "endingLine": 10, "age": 1, + "commit": map[string]any{ + "oid": "def456ghi789", + "message": "Update README", + "committedDate": "2024-01-02T15:30:00Z", + "author": map[string]any{ + "name": "Jane Smith", "email": "jane@example.com", + "user": map[string]any{"login": "janesmith", "url": "https://github.com/janesmith"}, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "README.md", + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, "testowner/testrepo", br.Repository) + assert.Equal(t, "README.md", br.Path) + assert.Equal(t, "main", br.Ref, "ref should resolve to default branch name") + assert.False(t, br.Truncated) + assert.Equal(t, 3, br.TotalRanges) + assert.False(t, br.PageInfo.HasNextPage) + assert.False(t, br.PageInfo.HasPreviousPage) + assert.NotEmpty(t, br.PageInfo.StartCursor) + assert.NotEmpty(t, br.PageInfo.EndCursor) + require.Len(t, br.Ranges, 3) + // Commits map is deduplicated. + require.Len(t, br.Commits, 2) + require.Contains(t, br.Commits, "abc123def456") + require.Contains(t, br.Commits, "def456ghi789") + // Multi-line message must be reduced to its headline. + assert.Equal(t, "Initial commit", br.Commits["abc123def456"].MessageHeadline) + assert.NotContains(t, result, "Long body that should not appear") + // Login/URL pointers populated. + require.NotNil(t, br.Commits["abc123def456"].Author.Login) + assert.Equal(t, "johndoe", *br.Commits["abc123def456"].Author.Login) + }, + }, + { + name: "successful blame with explicit ref", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "feature-branch", "src/main.go"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 3, "age": 1, + "commit": map[string]any{ + "oid": "xyz789abc123", + "message": "Add main function", + "committedDate": "2024-01-03T10:00:00Z", + "author": map[string]any{ + "name": "Bob Developer", "email": "bob@example.com", + "user": nil, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "src/main.go", + "ref": "feature-branch", + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, "feature-branch", br.Ref, "explicit ref echoed back") + require.Len(t, br.Ranges, 1) + require.Contains(t, br.Commits, "xyz789abc123") + assert.Nil(t, br.Commits["xyz789abc123"].Author.Login, "anonymous author has no login") + }, + }, + { + name: "successful blame with annotated tag ref", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "v1.0.0", "src/tagged.go"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Tag", + "target": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 2, "age": 1, + "commit": map[string]any{ + "oid": "taggedcommit123", + "message": "Tagged release commit", + "committedDate": "2024-01-04T10:00:00Z", + "author": map[string]any{"name": "Tag Author", "email": "tag@example.com", "user": nil}, + }, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "src/tagged.go", + "ref": "v1.0.0", + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, "v1.0.0", br.Ref, "explicit annotated tag ref echoed back") + require.Len(t, br.Ranges, 1) + assert.Equal(t, "taggedcommit123", br.Ranges[0].CommitSHA) + require.Contains(t, br.Commits, "taggedcommit123") + assert.Equal(t, "Tagged release commit", br.Commits["taggedcommit123"].MessageHeadline, + "commit metadata threads through the Tag.Target.Commit path") + assert.Equal(t, "Tag Author", br.Commits["taggedcommit123"].Author.Name) + }, + }, + { + name: "empty blame ranges", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "HEAD", "EMPTY.md"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{"ranges": []map[string]any{}}, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "EMPTY.md", + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, 0, br.TotalRanges) + assert.Empty(t, br.Ranges) + assert.Empty(t, br.Commits) + assert.False(t, br.PageInfo.HasNextPage) + assert.False(t, br.PageInfo.HasPreviousPage) + assert.False(t, br.Truncated) + // Ranges should marshal as an empty array, not null. + assert.Contains(t, result, `"ranges":[]`) + }, + }, + { + name: "ref resolves to non-commit object", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "main", "docs"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Tree", + "blame": map[string]any{"ranges": []map[string]any{}}, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "docs", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "did not resolve to a commit", + }, + { + name: "ref not found", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "no-such-ref", "README.md"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": nil, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "README.md", + "ref": "no-such-ref", + }, + expectError: true, + expectedErrMsg: "was not found", + }, + { + name: "annotated tag target is not commit", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "tree-tag", "README.md"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Tag", + "target": map[string]any{"__typename": "Tree"}, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "README.md", + "ref": "tree-tag", + }, + expectError: true, + expectedErrMsg: "tag target did not resolve to a commit", + }, + { + name: "line-range filter clamps and drops ranges", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "HEAD", "src/big.go"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 5, "age": 1, + "commit": map[string]any{ + "oid": "sha-A", "message": "A", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "a", "email": "a@x", "user": nil}, + }, + }, + { + "startingLine": 6, "endingLine": 12, "age": 1, + "commit": map[string]any{ + "oid": "sha-B", "message": "B", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "b", "email": "b@x", "user": nil}, + }, + }, + { + "startingLine": 13, "endingLine": 20, "age": 1, + "commit": map[string]any{ + "oid": "sha-C", "message": "C", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "c", "email": "c@x", "user": nil}, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "src/big.go", + "start_line": float64(8), + "end_line": float64(15), + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + // First range (1-5) is dropped; middle clamped to 8-12; + // last clamped to 13-15. + require.Len(t, br.Ranges, 2) + assert.Equal(t, 8, br.Ranges[0].StartingLine) + assert.Equal(t, 12, br.Ranges[0].EndingLine) + assert.Equal(t, "sha-B", br.Ranges[0].CommitSHA) + assert.Equal(t, 13, br.Ranges[1].StartingLine) + assert.Equal(t, 15, br.Ranges[1].EndingLine) + assert.Equal(t, "sha-C", br.Ranges[1].CommitSHA) + assert.NotContains(t, br.Commits, "sha-A", "filtered-out commit must not appear") + }, + }, + { + name: "cursor pagination returns requested page", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "HEAD", "src/paged.go"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{ + "ranges": []map[string]any{ + { + "startingLine": 1, "endingLine": 1, "age": 1, + "commit": map[string]any{ + "oid": "sha-A", "message": "A", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "a", "email": "a@x", "user": nil}, + }, + }, + { + "startingLine": 2, "endingLine": 2, "age": 1, + "commit": map[string]any{ + "oid": "sha-B", "message": "B", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "b", "email": "b@x", "user": nil}, + }, + }, + { + "startingLine": 3, "endingLine": 3, "age": 1, + "commit": map[string]any{ + "oid": "sha-C", "message": "C", "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "c", "email": "c@x", "user": nil}, + }, + }, + }, + }, + }, + }, + }), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "src/paged.go", + "perPage": float64(1), + "after": encodeBlameCursor(1), + }, + validateResponse: func(t *testing.T, result string) { + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(result), &br)) + assert.Equal(t, 3, br.TotalRanges) + require.Len(t, br.Ranges, 1) + assert.Equal(t, "sha-B", br.Ranges[0].CommitSHA) + require.Len(t, br.Commits, 1) + require.Contains(t, br.Commits, "sha-B") + assert.True(t, br.PageInfo.HasNextPage) + assert.True(t, br.PageInfo.HasPreviousPage) + assert.Equal(t, encodeBlameCursor(1), br.PageInfo.StartCursor) + assert.Equal(t, encodeBlameCursor(2), br.PageInfo.EndCursor) + }, + }, + { + name: "GraphQL error is surfaced", + mockedClient: githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("testowner", "testrepo", "main", "nonexistent.txt"), + githubv4mock.ErrorResponse("file not found"), + ), + ), + requestArgs: map[string]any{ + "owner": "testowner", + "repo": "testrepo", + "path": "nonexistent.txt", + "ref": "main", + }, + expectError: true, + expectedErrMsg: "file not found", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := githubv4.NewClient(tc.mockedClient) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + + if tc.expectError { + require.NoError(t, err) + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.NoError(t, err) + require.False(t, result.IsError) + textContent := getTextResult(t, result) + if tc.validateResponse != nil { + tc.validateResponse(t, textContent.Text) + } + }) + } + + // Path validation must short-circuit before any network call. We supply + // a client with no matchers so any HTTP attempt would fail loudly. + t.Run("path validation rejects bad inputs", func(t *testing.T) { + client := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + + cases := []struct { + name string + path string + want string + }{ + {"empty", " ", "must not be empty"}, + {"absolute", "/etc/passwd", "must be relative"}, + {"traversal", "src/../../../etc/passwd", "must not contain '..'"}, + {"control char", "src/\x00bad.go", "control characters"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := createMCPRequest(map[string]any{ + "owner": "o", "repo": "r", "path": c.path, + }) + result, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError, "expected validation error for %q", c.path) + assert.Contains(t, getErrorResult(t, result).Text, c.want) + }) + } + }) + + // Line-window and cursor pagination validation also short-circuits. + t.Run("line-range argument validation", func(t *testing.T) { + client := githubv4.NewClient(githubv4mock.NewMockedHTTPClient()) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + + cases := []struct { + name string + args map[string]any + want string + }{ + { + "end before start", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "start_line": float64(10), "end_line": float64(5)}, + "end_line must be >= start_line when both are provided", + }, + { + "start line zero", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "start_line": float64(0)}, + "start_line must be omitted or >= 1", + }, + { + "end line zero", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "end_line": float64(0)}, + "end_line must be omitted or >= 1", + }, + { + "page not supported", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "page": float64(1)}, + "cursor-based pagination", + }, + { + "invalid after cursor", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "after": "not-a-cursor"}, + "after cursor is invalid", + }, + { + "perPage too large", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "perPage": float64(101)}, + "perPage must be between 1 and 100 when provided", + }, + { + "perPage zero", + map[string]any{"owner": "o", "repo": "r", "path": "f.go", "perPage": float64(0)}, + "perPage must be between 1 and 100 when provided", + }, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := createMCPRequest(c.args) + result, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.True(t, result.IsError) + assert.Contains(t, getErrorResult(t, result).Text, c.want) + }) + } + }) + + // Truncation: hand-build a response with > maxBlameRanges to verify + // the cap is applied and surfaced. + t.Run("truncation at maxBlameRanges", func(t *testing.T) { + ranges := make([]map[string]any, 0, maxBlameRanges+5) + for i := range maxBlameRanges + 5 { + ranges = append(ranges, map[string]any{ + "startingLine": i + 1, "endingLine": i + 1, "age": 0, + "commit": map[string]any{ + "oid": "sha-shared", + "message": "shared", + "committedDate": "2024-01-01T00:00:00Z", + "author": map[string]any{"name": "n", "email": "e@x", "user": nil}, + }, + }) + } + mocked := githubv4mock.NewMockedHTTPClient( + githubv4mock.NewQueryMatcher( + blameQueryShape{}, + makeBlameVars("o", "r", "HEAD", "huge.txt"), + githubv4mock.DataResponse(map[string]any{ + "repository": map[string]any{ + "defaultBranchRef": map[string]any{"name": "main"}, + "object": map[string]any{ + "__typename": "Commit", + "blame": map[string]any{"ranges": ranges}, + }, + }, + }), + ), + ) + // Use a large perPage so the truncated set is observable on a + // single page. + req := createMCPRequest(map[string]any{ + "owner": "o", "repo": "r", "path": "huge.txt", "perPage": float64(100), + }) + client := githubv4.NewClient(mocked) + deps := BaseDeps{GQLClient: client} + handler := serverTool.Handler(deps) + result, err := handler(ContextWithDeps(context.Background(), deps), &req) + require.NoError(t, err) + require.False(t, result.IsError) + + var br BlameResult + require.NoError(t, json.Unmarshal([]byte(getTextResult(t, result).Text), &br)) + assert.True(t, br.Truncated, "truncation flag must be set") + assert.Equal(t, maxBlameRanges+5, br.TotalRanges) + assert.Len(t, br.Ranges, 100, "perPage limits the page size") + assert.True(t, br.PageInfo.HasNextPage) + assert.NotEmpty(t, br.PageInfo.EndCursor) + }) +} func Test_ListRepositoryCollaborators(t *testing.T) { // Verify tool definition once @@ -4741,7 +5407,6 @@ func Test_ListRepositoryCollaborators(t *testing.T) { name string args map[string]any mockResponses []MockBackendOption - wantErr bool errContains string }{ { diff --git a/pkg/github/tools.go b/pkg/github/tools.go index d1d585b3fa..906fa777d7 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -180,6 +180,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { SearchCode(t), SearchCommits(t), GetCommit(t), + GetFileBlame(t), ListBranches(t), ListTags(t), GetTag(t), From 34227037fc48771baea9af7163e28cb6556ef287 Mon Sep 17 00:00:00 2001 From: Praveen Sethuraman Date: Thu, 11 Jun 2026 06:56:00 -0700 Subject: [PATCH 34/34] Add Visual Studio install badges for MCP server (#2085) Add one-click install badges for Visual Studio alongside the existing VS Code and VS Code Insiders badges in both the Remote and Local server sections. Uses the aka.ms/mcpinstall redirect URL with the vsweb+mcp protocol handler, matching the badge styling from the Visual Studio blog. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Sam Morrow --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 07169c4c16..dc063f22ce 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Built for developers who want to connect their AI tools to GitHub context and ca ## Remote GitHub MCP Server -[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D&quality=insiders) +[![Install in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) [![Install in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&config=%7B%22type%22%3A%20%22http%22%2C%22url%22%3A%20%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D&quality=insiders) [![Install in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22gallery%22%3Atrue%2C%22url%22%3A%22https%3A%2F%2Fapi.githubcopilot.com%2Fmcp%2F%22%7D) The remote GitHub MCP Server is hosted by GitHub and provides the easiest method for getting up and running. If your MCP host does not support remote MCP servers, don't worry! You can use the [local version of the GitHub MCP Server](https://github.com/github/github-mcp-server?tab=readme-ov-file#local-github-mcp-server) instead. @@ -176,7 +176,7 @@ GitHub Enterprise Server does not support remote server hosting. Please refer to ## Local GitHub MCP Server -[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) +[![Install with Docker in VS Code](https://img.shields.io/badge/VS_Code-Install_Server-0098FF?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D) [![Install with Docker in VS Code Insiders](https://img.shields.io/badge/VS_Code_Insiders-Install_Server-24bfa5?style=flat-square&logo=visualstudiocode&logoColor=white)](https://insiders.vscode.dev/redirect/mcp/install?name=github&inputs=%5B%7B%22id%22%3A%22github_token%22%2C%22type%22%3A%22promptString%22%2C%22description%22%3A%22GitHub%20Personal%20Access%20Token%22%2C%22password%22%3Atrue%7D%5D&config=%7B%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%2C%22env%22%3A%7B%22GITHUB_PERSONAL_ACCESS_TOKEN%22%3A%22%24%7Binput%3Agithub_token%7D%22%7D%7D&quality=insiders) [![Install with Docker in Visual Studio](https://img.shields.io/badge/Visual_Studio-Install_Server-C16FDE?style=flat-square&logo=visualstudio&logoColor=white)](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22github%22%2C%22command%22%3A%22docker%22%2C%22args%22%3A%5B%22run%22%2C%22-i%22%2C%22--rm%22%2C%22-e%22%2C%22GITHUB_PERSONAL_ACCESS_TOKEN%22%2C%22ghcr.io%2Fgithub%2Fgithub-mcp-server%22%5D%7D) ### Prerequisites