diff --git a/docs/installation-guides/README.md b/docs/installation-guides/README.md
index ab3aede36e..aadfa6a04f 100644
--- a/docs/installation-guides/README.md
+++ b/docs/installation-guides/README.md
@@ -13,6 +13,7 @@ This directory contains detailed installation instructions for the GitHub MCP Se
- **[OpenAI Codex](install-codex.md)** - Installation guide for OpenAI Codex
- **[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
## Support by Host Application
@@ -32,6 +33,8 @@ This directory contains detailed installation instructions for the GitHub MCP Se
| Windsurf | ✅ | ✅ 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 |
+| Xcode (Claude Agent) | ✅ | ✅ PAT + ❌ No OAuth | Local: Docker (full path required), GitHub PAT
Remote: GitHub PAT | Easy |
**Legend:**
- ✅ = Fully supported
diff --git a/docs/installation-guides/install-claude.md b/docs/installation-guides/install-claude.md
index 05e3c3739d..67003fb69a 100644
--- a/docs/installation-guides/install-claude.md
+++ b/docs/installation-guides/install-claude.md
@@ -164,7 +164,75 @@ Add this codeblock to your `claude_desktop_config.json`:
---
-## Troubleshooting
+## Xcode (Claude Agent)
+
+Xcode's Claude Agent uses the same `.claude.json` configuration format as the Claude Code CLI, but reads it from an Xcode-specific directory rather than the global config location.
+
+### Configuration File Location
+
+```
+~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json
+```
+
+> Configurations placed here only affect Claude Agent when launched from Xcode. See [Apple's documentation](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence#Customize-the-Claude-Agent-and-Codex-environments) for more details.
+
+### Remote Server Setup (Recommended)
+
+Run the following command in Terminal to add the remote GitHub MCP server:
+
+```bash
+claude mcp add-json github '{"type":"http","url":"https://api.githubcopilot.com/mcp/","headers":{"Authorization":"Bearer YOUR_GITHUB_PAT"}}' --config ~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json
+```
+
+Or open the file in a text editor and add the `mcpServers` block manually:
+
+```json
+{
+ "mcpServers": {
+ "github": {
+ "type": "http",
+ "url": "https://api.githubcopilot.com/mcp/",
+ "headers": {
+ "Authorization": "Bearer YOUR_GITHUB_PAT"
+ }
+ }
+ }
+}
+```
+
+### Local Server Setup (Docker)
+
+> **macOS note**: Xcode runs with a minimal `PATH` that typically excludes `/usr/local/bin` (Intel) and `/opt/homebrew/bin` (Apple Silicon). Use the full path to `docker` to ensure it can be found. Run `which docker` in Terminal to find the correct path on your system.
+
+```json
+{
+ "mcpServers": {
+ "github": {
+ "command": "/usr/local/bin/docker",
+ "args": [
+ "run",
+ "-i",
+ "--rm",
+ "-e",
+ "GITHUB_PERSONAL_ACCESS_TOKEN",
+ "ghcr.io/github/github-mcp-server"
+ ],
+ "env": {
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "YOUR_GITHUB_PAT"
+ }
+ }
+ }
+}
+```
+
+### Setup Steps
+1. Create or open `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/.claude.json`
+2. Add the configuration block above
+3. Replace `YOUR_GITHUB_PAT` with your actual token
+4. Restart Xcode
+
+---
+
**Authentication Failed:**
- Verify PAT has `repo` scope
diff --git a/docs/installation-guides/install-xcode.md b/docs/installation-guides/install-xcode.md
new file mode 100644
index 0000000000..15bcfde34f
--- /dev/null
+++ b/docs/installation-guides/install-xcode.md
@@ -0,0 +1,45 @@
+# Install GitHub MCP Server in Xcode
+
+Xcode currently supports two built-in coding agents: **Codex** (powered by OpenAI) and **Claude Agent** (powered by Anthropic). Follow the standard installation guide for each agent, with one important difference: Xcode uses its own isolated configuration directories for each agent, separate from your global config.
+
+> Configurations placed in these directories only affect agents when launched from Xcode. See [Apple's documentation](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence#Customize-the-Claude-Agent-and-Codex-environments) for more details.
+
+## Configuration Directories
+
+| Agent | Configuration Directory |
+|-------|------------------------|
+| Codex | `~/Library/Developer/Xcode/CodingAssistant/codex/` |
+| Claude Agent | `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/` |
+
+Place your MCP server configuration in the relevant directory above rather than the default location used by the standalone CLI.
+
+## Setup Guides
+
+- **[Codex](install-codex.md)** — configure `config.toml` inside `~/Library/Developer/Xcode/CodingAssistant/codex/`
+- **[Claude Agent](install-claude.md#xcode-claude-agent)** — configure `.claude.json` inside `~/Library/Developer/Xcode/CodingAssistant/ClaudeAgentConfig/`
+
+## macOS Path Note
+
+Xcode runs with a minimal `PATH` that typically excludes common binary locations. If you are using a local STDIO server (e.g. Docker or a pre-built binary), use the **full path** to the command in your config. Run `which docker` (or `which github-mcp-server`) in Terminal to find the correct path on your system. Common locations:
+
+| Installation | Typical path |
+|---|---|
+| Docker (Intel Mac) | `/usr/local/bin/docker` |
+| Docker (Apple Silicon) | `/usr/local/bin/docker` |
+| Homebrew (Intel Mac) | `/usr/local/bin/` |
+| Homebrew (Apple Silicon) | `/opt/homebrew/bin/` |
+
+## Troubleshooting
+
+| Issue | Possible Cause | Fix |
+|-------|----------------|-----|
+| Tools not loading | Config placed in wrong directory | Ensure config is in the Xcode-specific path above, not `~/.codex/` or `~/.claude.json` |
+| Command not found (STDIO) | Xcode's PATH excludes binary location | Use the full path (e.g. `/usr/local/bin/docker` or `/opt/homebrew/bin/docker`); run `which docker` in Terminal to confirm |
+| Docker not found | Docker not running | Start Docker Desktop and restart Xcode |
+| Authentication failed | Invalid or expired PAT | Regenerate PAT and update config |
+
+## References
+
+- [Apple Developer Documentation — Setting up coding intelligence](https://developer.apple.com/documentation/xcode/setting-up-coding-intelligence#Customize-the-Claude-Agent-and-Codex-environments)
+- [Codex MCP documentation](https://developers.openai.com/codex/mcp)
+- Main project README: [Advanced configuration options](../../README.md)
diff --git a/pkg/github/__toolsnaps__/actions_run_trigger.snap b/pkg/github/__toolsnaps__/actions_run_trigger.snap
index c51501c176..41a6439929 100644
--- a/pkg/github/__toolsnaps__/actions_run_trigger.snap
+++ b/pkg/github/__toolsnaps__/actions_run_trigger.snap
@@ -8,6 +8,7 @@
"properties": {
"inputs": {
"description": "Inputs the workflow accepts. Only used for 'run_workflow' method.",
+ "properties": {},
"type": "object"
},
"method": {
diff --git a/pkg/github/actions.go b/pkg/github/actions.go
index c3b5bb8c71..85afed6e1b 100644
--- a/pkg/github/actions.go
+++ b/pkg/github/actions.go
@@ -544,6 +544,7 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo
"inputs": {
Type: "object",
Description: "Inputs the workflow accepts. Only used for 'run_workflow' method.",
+ Properties: map[string]*jsonschema.Schema{},
},
"run_id": {
Type: "number",
@@ -574,11 +575,9 @@ func ActionsRunTrigger(t translations.TranslationHelperFunc) inventory.ServerToo
runID, _ := OptionalIntParam(args, "run_id")
// Get optional inputs parameter
- var inputs map[string]any
- if requestInputs, ok := args["inputs"]; ok {
- if inputsMap, ok := requestInputs.(map[string]any); ok {
- inputs = inputsMap
- }
+ inputs, err := OptionalParam[map[string]any](args, "inputs")
+ if err != nil {
+ return utils.NewToolResultError(err.Error()), nil, nil
}
// Validate required parameters based on action type
diff --git a/pkg/github/actions_test.go b/pkg/github/actions_test.go
index fe960ed924..6eba71b8b3 100644
--- a/pkg/github/actions_test.go
+++ b/pkg/github/actions_test.go
@@ -377,6 +377,37 @@ func Test_ActionsRunTrigger_RunWorkflow(t *testing.T) {
expectError: true,
expectedErrMsg: "ref is required for run_workflow action",
},
+ {
+ name: "successful workflow run with inputs",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ PostReposActionsWorkflowsDispatchesByOwnerByRepoByWorkflowID: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusNoContent)
+ }),
+ }),
+ requestArgs: map[string]any{
+ "method": "run_workflow",
+ "owner": "owner",
+ "repo": "repo",
+ "workflow_id": "12345",
+ "ref": "main",
+ "inputs": map[string]any{"FIELD1": "value1", "FIELD2": "value2"},
+ },
+ expectError: false,
+ },
+ {
+ name: "invalid inputs type returns error",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}),
+ requestArgs: map[string]any{
+ "method": "run_workflow",
+ "owner": "owner",
+ "repo": "repo",
+ "workflow_id": "12345",
+ "ref": "main",
+ "inputs": "not a map",
+ },
+ expectError: true,
+ expectedErrMsg: "parameter inputs is not of type map[string]interface {}, is string",
+ },
}
for _, tc := range tests {
diff --git a/pkg/github/context_tools.go b/pkg/github/context_tools.go
index 902734481a..9f84c02118 100644
--- a/pkg/github/context_tools.go
+++ b/pkg/github/context_tools.go
@@ -6,6 +6,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"
@@ -103,7 +104,14 @@ func GetMe(t translations.TranslationHelperFunc) inventory.ServerTool {
},
}
- return MarshalledTextResult(minimalUser), nil, nil
+ result := MarshalledTextResult(minimalUser)
+ if deps.GetFlags(ctx).InsidersMode {
+ if result.Meta == nil {
+ result.Meta = mcp.Meta{}
+ }
+ result.Meta["ifc"] = ifc.LabelGetMe()
+ }
+ return result, nil, nil
},
)
}
diff --git a/pkg/github/context_tools_test.go b/pkg/github/context_tools_test.go
index 39f2058bec..365a019ab6 100644
--- a/pkg/github/context_tools_test.go
+++ b/pkg/github/context_tools_test.go
@@ -139,6 +139,66 @@ func Test_GetMe(t *testing.T) {
}
}
+func Test_GetMe_IFC_InsidersMode(t *testing.T) {
+ t.Parallel()
+
+ serverTool := GetMe(translations.NullTranslationHelper)
+
+ mockUser := &github.User{
+ Login: github.Ptr("testuser"),
+ HTMLURL: github.Ptr("https://github.com/testuser"),
+ CreatedAt: &github.Timestamp{Time: time.Now()},
+ }
+ mockedHTTPClient := MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetUser: mockResponse(t, http.StatusOK, mockUser),
+ })
+
+ t.Run("insiders mode disabled omits ifc label from result meta", func(t *testing.T) {
+ deps := BaseDeps{
+ Client: github.NewClient(mockedHTTPClient),
+ Flags: FeatureFlags{InsidersMode: false},
+ }
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(map[string]any{})
+ 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 insiders mode is disabled")
+ })
+
+ t.Run("insiders mode enabled includes ifc label in result meta", func(t *testing.T) {
+ deps := BaseDeps{
+ Client: github.NewClient(mockedHTTPClient),
+ Flags: FeatureFlags{InsidersMode: true},
+ }
+ handler := serverTool.Handler(deps)
+
+ request := createMCPRequest(map[string]any{})
+ result, err := handler(ContextWithDeps(context.Background(), deps), &request)
+ require.NoError(t, err)
+ require.False(t, result.IsError)
+
+ require.NotNil(t, result.Meta, "result meta should be set when insiders mode is enabled")
+ 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
+ err = json.Unmarshal(ifcJSON, &ifcMap)
+ require.NoError(t, err)
+
+ assert.Equal(t, "trusted", ifcMap["integrity"])
+ confList, ok := ifcMap["confidentiality"].([]any)
+ require.True(t, ok, "confidentiality should be a list")
+ require.Len(t, confList, 1)
+ assert.Equal(t, "public", confList[0])
+ })
+}
+
func Test_GetTeams(t *testing.T) {
t.Parallel()
diff --git a/pkg/github/dependabot.go b/pkg/github/dependabot.go
index 6f0da1b208..541cc5c1e7 100644
--- a/pkg/github/dependabot.go
+++ b/pkg/github/dependabot.go
@@ -69,7 +69,7 @@ func GetDependabotAlert(t translations.TranslationHelperFunc) inventory.ServerTo
alert, resp, err := client.Dependabot.GetRepoAlert(ctx, owner, repo, alertNumber)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
- fmt.Sprintf("failed to get alert with number '%d'", alertNumber),
+ dependabotErrMsg(fmt.Sprintf("failed to get alert with number '%d'", alertNumber), owner, repo, resp),
resp,
err,
), nil, nil
@@ -160,7 +160,7 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server
})
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
- fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo),
+ dependabotErrMsg(fmt.Sprintf("failed to list alerts for repository '%s/%s'", owner, repo), owner, repo, resp),
resp,
err,
), nil, nil
@@ -184,3 +184,16 @@ func ListDependabotAlerts(t translations.TranslationHelperFunc) inventory.Server
},
)
}
+
+// dependabotErrMsg enhances error messages for dependabot API failures by
+// appending a hint about token permissions when the response indicates
+// the token may lack access to the repository (403 or 404).
+func dependabotErrMsg(base, owner, repo string, resp *github.Response) string {
+ if resp != nil && (resp.StatusCode == http.StatusForbidden || resp.StatusCode == http.StatusNotFound) {
+ return fmt.Sprintf("%s. Your token may not have access to Dependabot alerts on %s/%s. "+
+ "To access Dependabot alerts, the token needs the 'security_events' scope or, for fine-grained tokens, "+
+ "Dependabot alerts read permission for this specific repository.",
+ base, owner, repo)
+ }
+ return base
+}
diff --git a/pkg/github/dependabot_test.go b/pkg/github/dependabot_test.go
index e20d2668ff..6c9b95ca36 100644
--- a/pkg/github/dependabot_test.go
+++ b/pkg/github/dependabot_test.go
@@ -66,7 +66,23 @@ func Test_GetDependabotAlert(t *testing.T) {
"alertNumber": float64(9999),
},
expectError: true,
- expectedErrMsg: "failed to get alert",
+ expectedErrMsg: "Your token may not have access to Dependabot alerts on owner/repo",
+ },
+ {
+ name: "alert fetch forbidden",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposDependabotAlertsByOwnerByRepoByAlertNumber: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"message": "Resource not accessible by integration"}`))
+ }),
+ }),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "alertNumber": float64(42),
+ },
+ expectError: true,
+ expectedErrMsg: "Your token may not have access to Dependabot alerts on owner/repo",
},
}
@@ -208,6 +224,21 @@ func Test_ListDependabotAlerts(t *testing.T) {
expectError: true,
expectedErrMsg: "failed to list alerts",
},
+ {
+ name: "alerts listing forbidden includes token hint",
+ mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{
+ GetReposDependabotAlertsByOwnerByRepo: http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
+ w.WriteHeader(http.StatusForbidden)
+ _, _ = w.Write([]byte(`{"message": "Resource not accessible by integration"}`))
+ }),
+ }),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ },
+ expectError: true,
+ expectedErrMsg: "Your token may not have access to Dependabot alerts on owner/repo",
+ },
}
for _, tc := range tests {
diff --git a/pkg/github/repositories.go b/pkg/github/repositories.go
index 9577b37b69..0ebacc6668 100644
--- a/pkg/github/repositories.go
+++ b/pkg/github/repositories.go
@@ -1632,7 +1632,15 @@ func GetTag(t translations.TranslationHelperFunc) inventory.ServerTool {
return ghErrors.NewGitHubAPIStatusErrorResponse(ctx, "failed to get tag reference", resp, body), nil, nil
}
- // Then get the tag object
+ // Differentiate between lightweight and annotated tags since lightweight ones don't have a fetchable object
+ if ref.Object.GetType() == "commit" {
+ r, err := json.Marshal(ref)
+ if err != nil {
+ return nil, nil, fmt.Errorf("failed to marshal response: %w", err)
+ }
+ return utils.NewToolResultText(string(r)), nil, nil
+ }
+
tagObj, resp, err := client.Git.GetTag(ctx, owner, repo, *ref.Object.SHA)
if err != nil {
return ghErrors.NewGitHubAPIErrorResponse(ctx,
diff --git a/pkg/github/repositories_test.go b/pkg/github/repositories_test.go
index d7bb487382..c21709dad4 100644
--- a/pkg/github/repositories_test.go
+++ b/pkg/github/repositories_test.go
@@ -2914,10 +2914,19 @@ func Test_GetTag(t *testing.T) {
assert.Contains(t, schema.Properties, "tag")
assert.ElementsMatch(t, schema.Required, []string{"owner", "repo", "tag"})
- mockTagRef := &github.Reference{
+ mockAnnotatedTagRef := &github.Reference{
Ref: github.Ptr("refs/tags/v1.0.0"),
Object: &github.GitObject{
- SHA: github.Ptr("v1.0.0-tag-sha"),
+ Type: github.Ptr("tag"),
+ SHA: github.Ptr("v1.0.0-tag-sha"),
+ },
+ }
+
+ mockLightweightTagRef := &github.Reference{
+ Ref: github.Ptr("refs/tags/v1.0.1"),
+ Object: &github.GitObject{
+ Type: github.Ptr("commit"),
+ SHA: github.Ptr("abc123"),
},
}
@@ -2937,6 +2946,7 @@ func Test_GetTag(t *testing.T) {
requestArgs map[string]any
expectError bool
expectedTag *github.Tag
+ expectedRef *github.Reference
expectedErrMsg string
}{
{
@@ -2948,7 +2958,7 @@ func Test_GetTag(t *testing.T) {
t,
"/repos/owner/repo/git/ref/tags/v1.0.0",
).andThen(
- mockResponse(t, http.StatusOK, mockTagRef),
+ mockResponse(t, http.StatusOK, mockAnnotatedTagRef),
),
),
WithRequestMatchHandler(
@@ -2993,7 +3003,7 @@ func Test_GetTag(t *testing.T) {
mockedClient: NewMockedHTTPClient(
WithRequestMatch(
GetReposGitRefByOwnerByRepoByRef,
- mockTagRef,
+ mockAnnotatedTagRef,
),
WithRequestMatchHandler(
GetReposGitTagsByOwnerByRepoByTagSHA,
@@ -3011,6 +3021,27 @@ func Test_GetTag(t *testing.T) {
expectError: true,
expectedErrMsg: "failed to get tag object",
},
+ {
+ name: "successful lightweight tag retrieval",
+ mockedClient: NewMockedHTTPClient(
+ WithRequestMatchHandler(
+ GetReposGitRefByOwnerByRepoByRef,
+ expectPath(
+ t,
+ "/repos/owner/repo/git/ref/tags/v1.0.1",
+ ).andThen(
+ mockResponse(t, http.StatusOK, mockLightweightTagRef),
+ ),
+ ),
+ ),
+ requestArgs: map[string]any{
+ "owner": "owner",
+ "repo": "repo",
+ "tag": "v1.0.1",
+ },
+ expectError: false,
+ expectedRef: mockLightweightTagRef,
+ },
}
for _, tc := range tests {
@@ -3043,16 +3074,29 @@ func Test_GetTag(t *testing.T) {
// Parse the result and get the text content if no error
textContent := getTextResult(t, result)
- // Parse and verify the result
- var returnedTag github.Tag
- err = json.Unmarshal([]byte(textContent.Text), &returnedTag)
- require.NoError(t, err)
+ // Parse and verify the result - annotated tag (full tag object)
+ if tc.expectedTag != nil {
+ var returnedTag github.Tag
+ err = json.Unmarshal([]byte(textContent.Text), &returnedTag)
+ require.NoError(t, err)
+
+ assert.Equal(t, tc.expectedTag.GetSHA(), returnedTag.GetSHA())
+ assert.Equal(t, tc.expectedTag.GetTag(), returnedTag.GetTag())
+ assert.Equal(t, tc.expectedTag.GetMessage(), returnedTag.GetMessage())
+ assert.Equal(t, tc.expectedTag.Object.GetType(), returnedTag.Object.GetType())
+ assert.Equal(t, tc.expectedTag.Object.GetSHA(), returnedTag.Object.GetSHA())
+ }
- assert.Equal(t, *tc.expectedTag.SHA, *returnedTag.SHA)
- assert.Equal(t, *tc.expectedTag.Tag, *returnedTag.Tag)
- assert.Equal(t, *tc.expectedTag.Message, *returnedTag.Message)
- assert.Equal(t, *tc.expectedTag.Object.Type, *returnedTag.Object.Type)
- assert.Equal(t, *tc.expectedTag.Object.SHA, *returnedTag.Object.SHA)
+ // Parse and verify the result - lightweight tag (reference only)
+ if tc.expectedRef != nil {
+ var returnedRef github.Reference
+ err = json.Unmarshal([]byte(textContent.Text), &returnedRef)
+ require.NoError(t, err)
+
+ assert.Equal(t, tc.expectedRef.GetRef(), returnedRef.GetRef())
+ assert.Equal(t, tc.expectedRef.Object.GetType(), returnedRef.Object.GetType())
+ assert.Equal(t, tc.expectedRef.Object.GetSHA(), returnedRef.Object.GetSHA())
+ }
})
}
}
diff --git a/pkg/http/handler_test.go b/pkg/http/handler_test.go
index 002266ba15..9887ff1f3b 100644
--- a/pkg/http/handler_test.go
+++ b/pkg/http/handler_test.go
@@ -825,3 +825,56 @@ func TestCrossOriginProtection(t *testing.T) {
})
}
}
+
+// TestInsidersRoutePreservesUIMeta is a regression test for the bug where
+// _meta.ui was stripped from tools/list responses on the HTTP /insiders route.
+//
+// Before the fix:
+// - buildStaticInventory called Build() on a builder configured with the
+// HTTP feature checker (which reads insiders mode from the request ctx).
+// - Build() invoked checkFeatureFlag(context.Background()) — bg ctx has no
+// insiders mode, so the FF reported MCP Apps off, and stripMCPAppsMetadata
+// ran eagerly against the static tool slice at server startup.
+// - Per-request inventory factories then served pre-stripped tools regardless
+// of whether the request actually came in via /insiders.
+//
+// After the fix:
+// - Build() no longer touches MCP Apps metadata.
+// - RegisterTools applies the strip per-request, using the request context
+// where the HTTP feature checker correctly observes insiders mode.
+func TestInsidersRoutePreservesUIMeta(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()
+ build := func() *inventory.Inventory {
+ inv, err := inventory.NewBuilder().
+ SetTools([]inventory.ServerTool{uiTool}).
+ WithFeatureChecker(checker).
+ WithToolsets([]string{"all"}).
+ Build()
+ require.NoError(t, err)
+ return inv
+ }
+
+ // Simulate a /insiders request: ctx has insiders mode set.
+ insidersCtx := ghcontext.WithInsidersMode(context.Background(), true)
+
+ // AvailableTools no longer strips _meta.ui (post-fix), regardless of ctx.
+ // The strip lives in RegisterTools, gated on the per-request FF check.
+ insidersTools := build().AvailableTools(insidersCtx)
+ plainTools := build().AvailableTools(context.Background())
+
+ // On the /insiders path, the FF check returns true → no strip → _meta preserved.
+ enabled, _ := checker(insidersCtx, "remote_mcp_ui_apps")
+ require.True(t, enabled, "FF should be on for /insiders ctx")
+ require.Len(t, insidersTools, 1)
+ require.NotNil(t, insidersTools[0].Tool.Meta, "_meta should be present on /insiders")
+ require.Equal(t, uiURI, insidersTools[0].Tool.Meta["ui"].(map[string]any)["resourceUri"])
+
+ // On the non-insiders path, RegisterTools strips _meta.ui.
+ plainEnabled, _ := checker(context.Background(), "remote_mcp_ui_apps")
+ require.False(t, plainEnabled, "FF should be off for non-insiders ctx")
+ require.Len(t, plainTools, 1)
+}
diff --git a/pkg/ifc/ifc.go b/pkg/ifc/ifc.go
new file mode 100644
index 0000000000..cf0d72114f
--- /dev/null
+++ b/pkg/ifc/ifc.go
@@ -0,0 +1,29 @@
+// Package ifc provides Information Flow Control labels for annotating MCP tool outputs.
+// The actual IFC enforcement engine lives in a separate service; this package only
+// defines the label schema used for annotations.
+package ifc
+
+type Integrity string
+
+const (
+ IntegrityTrusted Integrity = "trusted"
+ IntegrityUntrusted Integrity = "untrusted"
+)
+
+type Confidentiality string
+
+const (
+ ConfidentialityPublic Confidentiality = "public"
+)
+
+type SecurityLabel struct {
+ Integrity Integrity `json:"integrity"`
+ Confidentiality []Confidentiality `json:"confidentiality"`
+}
+
+func LabelGetMe() SecurityLabel {
+ return SecurityLabel{
+ Integrity: IntegrityTrusted,
+ Confidentiality: []Confidentiality{ConfidentialityPublic},
+ }
+}
diff --git a/pkg/inventory/builder.go b/pkg/inventory/builder.go
index b9a0d8548b..d656359bb6 100644
--- a/pkg/inventory/builder.go
+++ b/pkg/inventory/builder.go
@@ -190,19 +190,6 @@ func cleanTools(tools []string) []string {
return cleaned
}
-// checkFeatureFlag checks a feature flag at build time using the builder's feature checker.
-// Returns false if no checker is configured or the flag is not enabled.
-func (b *Builder) checkFeatureFlag(flag string) bool {
- if b.featureChecker == nil {
- return false
- }
- enabled, err := b.featureChecker(context.Background(), flag)
- if err != nil {
- return false
- }
- return enabled
-}
-
// Build creates the final Inventory with all configuration applied.
// This processes toolset filtering, tool name resolution, and sets up
// the inventory for use. The returned Inventory is ready for use with
@@ -214,13 +201,6 @@ func (b *Builder) checkFeatureFlag(flag string) bool {
func (b *Builder) Build() (*Inventory, error) {
tools := b.tools
- // When MCP Apps feature flag is not enabled, strip UI metadata from tools
- // so clients won't attempt to load UI resources.
- // The feature checker is the single source of truth for flag evaluation.
- if !b.checkFeatureFlag(mcpAppsFeatureFlag) {
- tools = stripMCPAppsMetadata(tools)
- }
-
r := &Inventory{
tools: tools,
resourceTemplates: b.resourceTemplates,
diff --git a/pkg/inventory/registry.go b/pkg/inventory/registry.go
index e2cd3a9e67..a0bbc7a550 100644
--- a/pkg/inventory/registry.go
+++ b/pkg/inventory/registry.go
@@ -170,8 +170,19 @@ func (r *Inventory) ToolsetDescriptions() map[ToolsetID]string {
// RegisterTools registers all available tools with the server using the provided dependencies.
// The context is used for feature flag evaluation.
+//
+// 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.
func (r *Inventory) RegisterTools(ctx context.Context, s *mcp.Server, deps any) {
- for _, tool := range r.AvailableTools(ctx) {
+ tools := r.AvailableTools(ctx)
+ if !r.checkFeatureFlag(ctx, mcpAppsFeatureFlag) {
+ tools = stripMCPAppsMetadata(tools)
+ }
+ for _, tool := range tools {
tool.RegisterFunc(s, deps)
}
}
diff --git a/pkg/inventory/registry_test.go b/pkg/inventory/registry_test.go
index e6c9e450cb..77c3bb57e5 100644
--- a/pkg/inventory/registry_test.go
+++ b/pkg/inventory/registry_test.go
@@ -1863,18 +1863,16 @@ func TestWithMCPApps_DisabledStripsUIMetadata(t *testing.T) {
"description": "kept",
})
- // Default: MCP Apps is disabled - UI meta should be stripped
+ // Default: MCP Apps is disabled - UI meta should be stripped on registration.
reg := mustBuild(t, NewBuilder().SetTools([]ServerTool{toolWithUI}).WithToolsets([]string{"all"}))
- available := reg.AvailableTools(context.Background())
+ registered := captureRegisteredTools(context.Background(), t, reg)
- require.Len(t, available, 1)
- // UI metadata should be stripped
- if available[0].Tool.Meta["ui"] != nil {
+ require.Len(t, registered, 1)
+ if registered[0].Meta["ui"] != nil {
t.Errorf("Expected 'ui' meta to be stripped, but it was present")
}
- // Other metadata should be preserved
- if available[0].Tool.Meta["description"] != "kept" {
- t.Errorf("Expected 'description' meta to be preserved, got %v", available[0].Tool.Meta["description"])
+ if registered[0].Meta["description"] != "kept" {
+ t.Errorf("Expected 'description' meta to be preserved, got %v", registered[0].Meta["description"])
}
}
@@ -1947,7 +1945,6 @@ func TestWithMCPApps_ToolsWithoutUIMetaUnaffected(t *testing.T) {
}
func TestWithMCPApps_UIOnlyMetaBecomesNil(t *testing.T) {
- // Tool with ONLY ui metadata - should become nil after stripping when MCP Apps is disabled
toolUIOnly := mockToolWithMeta("tool_ui_only", "toolset1", map[string]any{
"ui": map[string]any{"html": "