Files
MCP/cmd/cmd_test.go
Bo-Yi Wu 75f1adf979 feat!: support protocol 2026-07-28 over HTTP and add --bind (#227)
Adds MCP protocol `2026-07-28` over HTTP through the official Go SDK, and validates the `Origin` header on every request as the spec requires.

Tool and Gitea failures now come back as an ordinary `tools/call` result carrying `result.isError: true`, the way the SDK's own tool wrapper reports them. Malformed requests, unknown tools or methods, and server faults stay JSON-RPC errors.

Adds `-b, --bind` to narrow the listen address. The default still accepts every interface, so this is opt-in hardening. It matters because a request that omits `Authorization` falls back to the server's own token.

**Breaking: the HTTP endpoint no longer keeps a session per client.** What changes for a client:

1. `/mcp` accepts `POST` only, and answers `405` to `GET` or `DELETE`.
2. The server neither sends nor accepts `Mcp-Session-Id`, so there is no session handshake to perform.
3. There is no standalone SSE stream and no `Last-Event-ID` resumption. If a response stream breaks, send the whole request again under a new JSON-RPC id.

Clients that already speak current streamable HTTP need no changes. Anything relying on the session handshake or the standalone SSE stream should stay on the previous release.

---------

Co-authored-by: silverwind <me@silverwind.io>
Reviewed-on: https://gitea.com/gitea/gitea-mcp/pulls/227
Reviewed-by: silverwind <2021+silverwind@noreply.gitea.com>
Co-authored-by: Bo-Yi Wu <appleboy.tw@gmail.com>
2026-08-07 15:14:26 +00:00

98 lines
2.6 KiB
Go

package cmd
import (
"bytes"
"flag"
"maps"
"slices"
"testing"
flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag"
)
func TestInitFlagSetBind(t *testing.T) {
for _, test := range []struct {
name string
args []string
want string
}{
{name: "default is empty, meaning all interfaces", args: []string{}},
{name: "-b sets the address", args: []string{"-b", "127.0.0.1"}, want: "127.0.0.1"},
{name: "-bind sets an IPv6 literal", args: []string{"-bind", "::1"}, want: "::1"},
} {
t.Run(test.name, func(t *testing.T) {
t.Cleanup(func() { flagPkg.Bind = "" })
fs := flag.NewFlagSet("test", flag.ContinueOnError)
initFlagSet(fs, test.args, func(string) string { return "" }, func(string) ([]byte, error) { return nil, nil }, &bytes.Buffer{})
if flagPkg.Bind != test.want {
t.Errorf("Bind = %q, want %q", flagPkg.Bind, test.want)
}
})
}
}
func TestInitFlagSetScopes(t *testing.T) {
tests := []struct {
name string
args []string
env map[string]string
want []string
}{
{
name: "no scope flag or env leaves AllowedScopes unset",
args: []string{},
want: nil,
},
{
name: "-S sets a single scope",
args: []string{"-S", "repository"},
want: []string{"repository"},
},
{
name: "-scope sets a comma-separated list",
args: []string{"-scope", "repository,file"},
want: []string{"file", "repository"},
},
{
name: "GITEA_SCOPES env sets the default",
args: []string{},
env: map[string]string{"GITEA_SCOPES": "issue,pull_request"},
want: []string{"issue", "pull_request"},
},
{
name: "-S flag takes precedence over GITEA_SCOPES env",
args: []string{"-S", "file"},
env: map[string]string{"GITEA_SCOPES": "issue"},
want: []string{"file"},
},
{
name: "normalizes case, whitespace, and hyphens/spaces to underscores",
args: []string{"-S", " Pull Request , pull-request , PULL_REQUEST "},
want: []string{"pull_request"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
origScopes := flagPkg.AllowedScopes
t.Cleanup(func() {
flagPkg.AllowedScopes = origScopes
})
flagPkg.AllowedScopes = nil
getenv := func(key string) string { return tt.env[key] }
readFile := func(string) ([]byte, error) { return nil, nil }
fs := flag.NewFlagSet("test", flag.ContinueOnError)
var stderr bytes.Buffer
initFlagSet(fs, tt.args, getenv, readFile, &stderr)
got := slices.Sorted(maps.Keys(flagPkg.AllowedScopes))
want := slices.Clone(tt.want)
slices.Sort(want)
if !slices.Equal(got, want) {
t.Errorf("AllowedScopes = %v, want %v", got, want)
}
})
}
}