Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions .github/copilot-instructions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
This is the **GitHub MCP Server**, a Model Context Protocol (MCP) server that connects AI tools to GitHub's platform. It enables AI agents to manage repositories, issues, pull requests, workflows, and more through natural language.

**Key Details:**
- **Language:** Go 1.24+ (~38k lines of code)
- **Language:** Go (minimum version specified in `go.mod`)
- **Type:** MCP server application with CLI interface
- **Primary Package:** github-mcp-server (stdio MCP server - **this is the main focus**)
- **Secondary Package:** mcpcurl (testing utility - don't break it, but not the priority)
Expand Down Expand Up @@ -92,7 +92,7 @@ go test ./pkg/github -run TestGetMe

### Key Configuration Files

- **go.mod / go.sum:** Go module dependencies (Go 1.24.0+)
- **go.mod / go.sum:** Go module dependencies and minimum supported Go version
- **.golangci.yml:** Linter configuration (v2 format, ~15 linters enabled)
- **Dockerfile:** Multi-stage build (golang:1.25.8-alpine → distroless)
- **server.json:** MCP server metadata for registry
Expand All @@ -112,18 +112,18 @@ go test ./pkg/github -run TestGetMe

## GitHub Workflows (CI/CD)

All workflows run on push/PR unless noted. Located in `.github/workflows/`:
Workflow triggers vary; consult the files in `.github/workflows/`:

1. **go.yml** - Build and test on ubuntu/windows/macos. Runs `script/test` and builds binary
2. **lint.yml** - Runs golangci-lint-action v2.5 (GitHub Action) with actions/setup-go stable
2. **lint.yml** - Runs golangci-lint-action v9 with Go 1.25 and golangci-lint v2.9.0, matching the version enforced by `script/lint`
3. **docs-check.yml** - Verifies README.md is up-to-date by running generate-docs and checking git diff
4. **code-scanning.yml** - CodeQL security analysis for Go and GitHub Actions
5. **license-check.yml** - Runs `script/licenses-check` to validate compliance
6. **docker-publish.yml** - Publishes container image to ghcr.io
7. **goreleaser.yml** - Creates releases (main branch only)
8. **registry-releaser.yml** - Updates MCP registry

**All of these must pass for PR merge.** If docs-check fails, run `script/generate-docs` and commit changes.
**Applicable PR checks must pass for merge.** Publishing workflows run on their configured release events, not on every PR. CodeQL may be intentionally skipped on forks. If docs-check fails, run `script/generate-docs` and commit changes.

## Testing Guidelines

Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,4 @@ jobs:
uses: golangci/golangci-lint-action@v9
with:
# sync with script/lint
version: v2.9
version: v2.9.0
7 changes: 3 additions & 4 deletions docs/installation-guides/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,8 @@ The GitHub MCP Server can be installed using several methods. **Docker is the mo
### 🔨 Build from Source (Advanced Users)
- **Pros**: Latest features, full customization, no external dependencies
- **Cons**: Requires Go development environment, more complex setup
- **Prerequisites**: [Go 1.24+](https://go.dev/doc/install)
- **Build command**: `go build -o github-mcp-server cmd/github-mcp-server/main.go`
- **Prerequisites**: [Go](https://go.dev/doc/install), at least the version specified in [`go.mod`](../../go.mod)
- **Build command**: `go build -o github-mcp-server ./cmd/github-mcp-server`
- **Best for**: Developers who want the latest features or need custom modifications

### Important Notes on the GitHub MCP Server
Expand All @@ -82,7 +82,7 @@ All installations with Personal Access Tokens (PAT) require:

Optional (depending on installation method):
- **Docker** (for Docker-based installations): [Download Docker](https://www.docker.com/)
- **Go 1.24+** (for building from source): [Install Go](https://go.dev/doc/install)
- **Go** (for building from source, at least the version in [`go.mod`](../../go.mod)): [Install Go](https://go.dev/doc/install)

## Security Best Practices

Expand All @@ -109,4 +109,3 @@ After installation, you may want to explore:
- **Toolsets**: Enable/disable specific GitHub API capabilities
- **Read-Only Mode**: Restrict to read-only operations
- **Lockdown Mode**: Hide public issue details created by users without push access

11 changes: 11 additions & 0 deletions pkg/context/mcp_info.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,22 @@ type MCPMethodInfo struct {
ItemName string
// RawArguments contains the unmaterialized tool arguments for tools/call requests.
RawArguments json.RawMessage
// Deprecated: Owner is retained for source compatibility and is not populated
// by the parse middleware. Use DecodeArguments for call-specific values.
Owner string
// Deprecated: Repo is retained for source compatibility and is not populated
// by the parse middleware. Use DecodeArguments for call-specific values.
Repo string
// Deprecated: Arguments is retained for source compatibility and is not
// populated by the parse middleware. Use DecodeArguments instead.
Arguments map[string]any
}

// DecodeArguments materializes tool arguments when request middleware needs
// call-specific values. Invalid argument shapes are returned to the caller so
// the request can continue to the tool handler's normal validation path.
// Each call decodes RawArguments anew; decoded maps are not cached and the
// deprecated fields are neither read nor populated.
func (info *MCPMethodInfo) DecodeArguments() (map[string]any, error) {
if len(info.RawArguments) == 0 {
return nil, nil
Expand Down
137 changes: 137 additions & 0 deletions pkg/context/mcp_info_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
package context

import (
"encoding/json"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func TestMCPMethodInfoDecodeArguments(t *testing.T) {
tests := []struct {
name string
raw string
want map[string]any
wantErr bool
}{
{name: "missing"},
{name: "null", raw: `null`},
{name: "empty object", raw: `{}`, want: map[string]any{}},
{name: "owner only", raw: `{"owner":"github"}`, want: map[string]any{"owner": "github"}},
{name: "repo only", raw: `{"repo":"server"}`, want: map[string]any{"repo": "server"}},
{
name: "exact and differently cased keys remain distinct",
raw: `{"owner":"github","Owner":"other","repo":"server","REPO":"other"}`,
want: map[string]any{"owner": "github", "Owner": "other", "repo": "server", "REPO": "other"},
},
{
name: "differently cased keys do not introduce lowercase keys",
raw: `{"Owner":"other","REPO":"other"}`,
want: map[string]any{"Owner": "other", "REPO": "other"},
},
{
name: "escaped keys and values",
raw: `{"ow\u006eer":"git\u0068ub","re\u0070o":"server","\u004fwner":null}`,
want: map[string]any{"owner": "github", "repo": "server", "Owner": nil},
},
{
name: "last duplicate wins including null",
raw: `{"owner":123,"owner":"github","repo":"server","repo":null}`,
want: map[string]any{"owner": "github", "repo": nil},
},
{
name: "escaped duplicate replaces exact key",
raw: `{"owner":"github","ow\u006eer":null,"repo":[],"repo":"server"}`,
want: map[string]any{"owner": nil, "repo": "server"},
},
{
name: "duplicate becomes object",
raw: `{"owner":"github","owner":{},"repo":"server"}`,
want: map[string]any{"owner": map[string]any{}, "repo": "server"},
},
{
name: "wrongly typed owner does not discard repo",
raw: `{"owner":123,"repo":"server"}`,
want: map[string]any{"owner": float64(123), "repo": "server"},
},
{
name: "wrongly typed repo does not discard owner",
raw: `{"owner":"github","repo":false}`,
want: map[string]any{"owner": "github", "repo": false},
},
{
name: "nested policy arguments are retained",
raw: `{"files":[{"path":".github/workflows/ci.yml"}]}`,
want: map[string]any{"files": []any{map[string]any{"path": ".github/workflows/ci.yml"}}},
},
{name: "array shape", raw: `[]`, wantErr: true},
{name: "string shape", raw: `"not an object"`, wantErr: true},
{name: "number shape", raw: `123`, wantErr: true},
{name: "boolean shape", raw: `true`, wantErr: true},
{name: "malformed JSON", raw: `{"owner":"github","repo":}`, wantErr: true},
{name: "trailing JSON", raw: `{"owner":"github"} {}`, wantErr: true},
{name: "unrepresentable number", raw: `{"owner":"github","extra":1e1000}`, wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
info := MCPMethodInfo{RawArguments: json.RawMessage(tt.raw)}
arguments, err := info.DecodeArguments()
if tt.wantErr {
require.Error(t, err)
assert.Nil(t, arguments, "a failed decode must not expose a partial map")
} else {
require.NoError(t, err)
assert.Equal(t, tt.want, arguments)
}
assert.Equal(t, MCPMethodInfo{RawArguments: json.RawMessage(tt.raw)}, info,
"decoding must not mutate raw arguments or populate legacy fields")
})
}
}

func TestMCPMethodInfoDecodeArgumentsReturnsIndependentMaps(t *testing.T) {
const raw = `{"owner":"github","files":[{"path":"README.md"}]}`
info := MCPMethodInfo{RawArguments: json.RawMessage(raw)}

first, err := info.DecodeArguments()
require.NoError(t, err)
first["owner"] = "changed"
files, ok := first["files"].([]any)
require.True(t, ok)
file, ok := files[0].(map[string]any)
require.True(t, ok)
file["path"] = "changed"

second, err := info.DecodeArguments()
require.NoError(t, err)
assert.Equal(t, map[string]any{
"owner": "github",
"files": []any{map[string]any{"path": "README.md"}},
}, second)
assert.Equal(t, MCPMethodInfo{RawArguments: json.RawMessage(raw)}, info)
}

func TestMCPMethodInfoDecodeArgumentsIgnoresLegacyFields(t *testing.T) {
for _, raw := range []string{"", `{"owner":"current"}`} {
t.Run(raw, func(t *testing.T) {
info := MCPMethodInfo{
RawArguments: json.RawMessage(raw),
Owner: "legacy-owner",
Repo: "legacy-repo",
Arguments: map[string]any{"owner": "legacy-argument"},
}
arguments, err := info.DecodeArguments()
require.NoError(t, err)
if raw == "" {
assert.Nil(t, arguments)
} else {
assert.Equal(t, map[string]any{"owner": "current"}, arguments)
}
assert.Equal(t, "legacy-owner", info.Owner)
assert.Equal(t, "legacy-repo", info.Repo)
assert.Equal(t, map[string]any{"owner": "legacy-argument"}, info.Arguments)
})
}
}
Loading
Loading