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>
This commit is contained in:
Bo-Yi Wu
2026-08-07 15:14:26 +00:00
committed by silverwind
parent efcbdbb17f
commit 75f1adf979
13 changed files with 806 additions and 223 deletions
+7 -1
View File
@@ -20,6 +20,12 @@ make install
Pass the Gitea host and access token as command-line flags or environment variables, flags take precedence. Run `gitea-mcp --help` for the full list of flags and environment variables. Logs are written to `$HOME/.gitea-mcp/gitea-mcp.log`, add `-d` for debug logging. Pass the Gitea host and access token as command-line flags or environment variables, flags take precedence. Run `gitea-mcp --help` for the full list of flags and environment variables. Logs are written to `$HOME/.gitea-mcp/gitea-mcp.log`, add `-d` for debug logging.
### MCP protocol and HTTP transport
The server supports MCP up to `2026-07-28` and negotiates down to the client's version, advertising only the `tools` capability. Tool and Gitea failures return a `tools/call` result with `result.isError: true`, while malformed requests and server faults stay JSON-RPC errors.
HTTP is always stateless: `/mcp` accepts POST only, without `Mcp-Session-Id`, standalone SSE or `Last-Event-ID` resumability. Origins are validated, and reverse proxies must forward `Mcp-Protocol-Version`, `Mcp-Method` and `Mcp-Name` unchanged. `Authorization: Bearer <token>` and `Authorization: token <token>` pass a Gitea credential per request, which is credential passthrough rather than MCP OAuth.
### Claude Code ### Claude Code
Runs the server through `go run` and requires [Go](https://go.dev): Runs the server through `go run` and requires [Go](https://go.dev):
@@ -130,7 +136,7 @@ Once configured, try `list all my repositories` in the chat box.
## Available Tools ## Available Tools
| Tool | Scope | Access | Description | | Tool | Scope | Access | Description |
| :--------------------------- | :----------- | :----- | :----------------------------------------------------------------------------------------- | | :--------------------------- | :----------- | :----- | :---------- |
| get_gitea_mcp_server_version | version | Read | Get the Gitea MCP server version | | get_gitea_mcp_server_version | version | Read | Get the Gitea MCP server version |
| get_me | user | Read | Get the current authenticated user | | get_me | user | Read | Get the current authenticated user |
| get_user_orgs | user | Read | List the current user's organizations | | get_user_orgs | user | Read | List the current user's organizations |
+7 -1
View File
@@ -20,6 +20,12 @@ make install
Gitea 主机和访问令牌可通过命令行参数或环境变量提供,命令行参数优先。运行 `gitea-mcp --help` 可查看完整的参数与环境变量列表。日志写入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可启用调试日志。 Gitea 主机和访问令牌可通过命令行参数或环境变量提供,命令行参数优先。运行 `gitea-mcp --help` 可查看完整的参数与环境变量列表。日志写入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可启用调试日志。
### MCP 协议与 HTTP 传输
服务器支持最高至 `2026-07-28` 的 MCP 协议,并向下协商到客户端的版本,仅声明 `tools` 能力。工具和 Gitea 执行失败会在 `tools/call` 结果中返回并设置 `result.isError: true`,格式错误的请求和服务器故障仍返回 JSON-RPC 错误。
HTTP 传输固定为无状态:`/mcp` 仅接受 POST,没有 `Mcp-Session-Id`、独立 SSE 和 `Last-Event-ID` 断点续传。服务器会验证来源,反向代理必须原样转发 `Mcp-Protocol-Version``Mcp-Method``Mcp-Name``Authorization: Bearer <令牌>``Authorization: token <令牌>` 会在每个请求中传递 Gitea 凭据,这是凭据透传,而不是 MCP OAuth。
### Claude Code ### Claude Code
通过 `go run` 运行服务器,需要安装 [Go](https://go.dev) 通过 `go run` 运行服务器,需要安装 [Go](https://go.dev)
@@ -130,7 +136,7 @@ Cursor 等客户端可使用 stdio 命令:
## 可用工具 ## 可用工具
| 工具 | 范围 | 访问 | 描述 | | 工具 | 范围 | 访问 | 描述 |
| :--------------------------- | :----------- | :- | :--------------------------------- | | :--------------------------- | :----------- | :--- | :--- |
| get_gitea_mcp_server_version | version | 读取 | 获取 Gitea MCP 服务器版本 | | get_gitea_mcp_server_version | version | 读取 | 获取 Gitea MCP 服务器版本 |
| get_me | user | 读取 | 获取当前已认证用户 | | get_me | user | 读取 | 获取当前已认证用户 |
| get_user_orgs | user | 读取 | 列出当前用户的组织 | | get_user_orgs | user | 读取 | 列出当前用户的组织 |
+7 -1
View File
@@ -20,6 +20,12 @@ make install
Gitea 主機與存取令牌可透過命令列參數或環境變數提供,命令列參數優先。執行 `gitea-mcp --help` 可查看完整的參數與環境變數列表。日誌寫入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可啟用除錯日誌。 Gitea 主機與存取令牌可透過命令列參數或環境變數提供,命令列參數優先。執行 `gitea-mcp --help` 可查看完整的參數與環境變數列表。日誌寫入 `$HOME/.gitea-mcp/gitea-mcp.log`,加上 `-d` 可啟用除錯日誌。
### MCP 協定與 HTTP 傳輸
伺服器支援最高至 `2026-07-28` 的 MCP 協定,並向下協商到客戶端的版本,僅宣告 `tools` 能力。工具與 Gitea 執行失敗會在 `tools/call` 結果中回傳並設定 `result.isError: true`,格式錯誤的請求與伺服器故障仍回傳 JSON-RPC 錯誤。
HTTP 傳輸固定為無狀態:`/mcp` 只接受 POST,沒有 `Mcp-Session-Id`、獨立 SSE 與 `Last-Event-ID` 斷點續傳。伺服器會驗證來源,反向代理必須原樣轉發 `Mcp-Protocol-Version``Mcp-Method``Mcp-Name``Authorization: Bearer <令牌>``Authorization: token <令牌>` 會在每次請求中傳遞 Gitea 憑證,這是憑證透傳,而不是 MCP OAuth。
### Claude Code ### Claude Code
透過 `go run` 執行伺服器,需要安裝 [Go](https://go.dev) 透過 `go run` 執行伺服器,需要安裝 [Go](https://go.dev)
@@ -130,7 +136,7 @@ Cursor 等客戶端可使用 stdio 命令:
## 可用工具 ## 可用工具
| 工具 | 範圍 | 存取 | 描述 | | 工具 | 範圍 | 存取 | 描述 |
| :--------------------------- | :----------- | :- | :--------------------------------- | | :--------------------------- | :----------- | :--- | :--- |
| get_gitea_mcp_server_version | version | 讀取 | 取得 Gitea MCP 伺服器版本 | | get_gitea_mcp_server_version | version | 讀取 | 取得 Gitea MCP 伺服器版本 |
| get_me | user | 讀取 | 取得目前已認證用戶 | | get_me | user | 讀取 | 取得目前已認證用戶 |
| get_user_orgs | user | 讀取 | 列出目前用戶的組織 | | get_user_orgs | user | 讀取 | 列出目前用戶的組織 |
+5
View File
@@ -17,6 +17,7 @@ import (
var ( var (
host string host string
bind string
port int port int
token string token string
tools string tools string
@@ -32,6 +33,8 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re
fs.StringVar(&flagPkg.Mode, "transport", "stdio", "") fs.StringVar(&flagPkg.Mode, "transport", "stdio", "")
fs.StringVar(&host, "H", getenv("GITEA_HOST"), "") fs.StringVar(&host, "H", getenv("GITEA_HOST"), "")
fs.StringVar(&host, "host", getenv("GITEA_HOST"), "") fs.StringVar(&host, "host", getenv("GITEA_HOST"), "")
fs.StringVar(&bind, "b", "", "")
fs.StringVar(&bind, "bind", "", "")
fs.IntVar(&port, "p", 8080, "") fs.IntVar(&port, "p", 8080, "")
fs.IntVar(&port, "port", 8080, "") fs.IntVar(&port, "port", 8080, "")
fs.StringVar(&token, "T", "", "") fs.StringVar(&token, "T", "", "")
@@ -68,6 +71,7 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re
fmt.Fprintln(stderr, "Options:") fmt.Fprintln(stderr, "Options:")
fmt.Fprintf(w, " -t, -transport <type>\tTransport type: stdio or http (default: stdio)\n") fmt.Fprintf(w, " -t, -transport <type>\tTransport type: stdio or http (default: stdio)\n")
fmt.Fprintf(w, " -H, -host <url>\tGitea host URL (default: https://gitea.com)\n") fmt.Fprintf(w, " -H, -host <url>\tGitea host URL (default: https://gitea.com)\n")
fmt.Fprintf(w, " -b, -bind <address>\tHTTP listen address, e.g. 127.0.0.1 (default: all interfaces)\n")
fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n") fmt.Fprintf(w, " -p, -port <number>\tHTTP server port (default: 8080)\n")
fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n") fmt.Fprintf(w, " -T, -token <token>\tPersonal access token\n")
fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n") fmt.Fprintf(w, " -r, -read-only\tExpose only read-only tools\n")
@@ -99,6 +103,7 @@ func initFlagSet(fs *flag.FlagSet, args []string, getenv func(string) string, re
flagPkg.Host = "https://gitea.com" flagPkg.Host = "https://gitea.com"
} }
flagPkg.Bind = bind
flagPkg.Port = port flagPkg.Port = port
flagPkg.MaxInlineAttachmentBytes = maxInlineAttachmentBytes flagPkg.MaxInlineAttachmentBytes = maxInlineAttachmentBytes
+21
View File
@@ -10,6 +10,27 @@ import (
flagPkg "gitea.com/gitea/gitea-mcp/pkg/flag" 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) { func TestInitFlagSetScopes(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
+33 -12
View File
@@ -4,9 +4,11 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"net"
"net/http" "net/http"
"os" "os"
"os/signal" "os/signal"
"strconv"
"strings" "strings"
"syscall" "syscall"
"time" "time"
@@ -36,11 +38,6 @@ import (
// base64 file content create_or_update_file accepts. // base64 file content create_or_update_file accepts.
const maxRequestBodyBytes = 32 << 20 const maxRequestBodyBytes = 32 << 20
// sessionTimeout expires idle sessions, which the SDK otherwise keeps for the
// process lifetime: a client that goes away without DELETE /mcp leaks its
// session, and initialize takes no token. Clients re-initialize on the 404.
const sessionTimeout = 30 * time.Minute
// httpReadHeaderTimeout bounds slow header reads without limiting SSE writes. // httpReadHeaderTimeout bounds slow header reads without limiting SSE writes.
const httpReadHeaderTimeout = 10 * time.Second const httpReadHeaderTimeout = 10 * time.Second
@@ -107,17 +104,35 @@ func authTokenMiddleware(next mcp.MethodHandler) mcp.MethodHandler {
} }
} }
func protectMCPOrigin(next http.Handler) http.Handler {
protection := http.NewCrossOriginProtection()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Check exempts safe methods, but MCP requires Origin validation on every request.
checkRequest := r
switch r.Method {
case http.MethodGet, http.MethodHead, http.MethodOptions:
checkRequest = r.Clone(r.Context())
checkRequest.Method = http.MethodPost
}
if err := protection.Check(checkRequest); err != nil {
http.Error(w, err.Error(), http.StatusForbidden)
return
}
next.ServeHTTP(w, r)
})
}
func newHTTPServer(addr string, s *mcp.Server) *http.Server { func newHTTPServer(addr string, s *mcp.Server) *http.Server {
mux := http.NewServeMux() mux := http.NewServeMux()
mux.Handle("/mcp", mcp.NewStreamableHTTPHandler( mux.Handle("/mcp", protectMCPOrigin(mcp.NewStreamableHTTPHandler(
func(*http.Request) *mcp.Server { return s }, func(*http.Request) *mcp.Server { return s },
&mcp.StreamableHTTPOptions{ &mcp.StreamableHTTPOptions{
Logger: log.Slog(), Logger: log.Slog(),
MaxRequestBodyBytes: maxRequestBodyBytes, MaxRequestBodyBytes: maxRequestBodyBytes,
Stateless: false, // SessionTimeout requires stateful sessions. Stateless: true,
SessionTimeout: sessionTimeout, PropagateRequestCancellation: true,
}, },
)) )))
return &http.Server{ return &http.Server{
Addr: addr, Addr: addr,
Handler: mux, Handler: mux,
@@ -134,8 +149,9 @@ func Run() error {
return err return err
} }
case "http": case "http":
httpServer := newHTTPServer(fmt.Sprintf(":%d", flag.Port), mcpServer) addr := net.JoinHostPort(flag.Bind, strconv.Itoa(flag.Port))
log.Infof("Gitea MCP HTTP server listening on :%d", flag.Port) httpServer := newHTTPServer(addr, mcpServer)
log.Infof("Gitea MCP HTTP server listening on %s (stateless, protocol up to 2026-07-28)", addr)
// Graceful shutdown setup // Graceful shutdown setup
sigCh := make(chan os.Signal, 1) sigCh := make(chan os.Signal, 1)
@@ -171,7 +187,12 @@ func newMCPServer(version string) *mcp.Server {
Name: "Gitea MCP Server", Name: "Gitea MCP Server",
Version: version, Version: version,
}, },
&mcp.ServerOptions{Logger: log.Slog()}, &mcp.ServerOptions{
Logger: log.Slog(),
Capabilities: &mcp.ServerCapabilities{
Tools: &mcp.ToolCapabilities{},
},
},
) )
s.AddReceivingMiddleware(authTokenMiddleware) s.AddReceivingMiddleware(authTokenMiddleware)
return s return s
+12 -17
View File
@@ -299,25 +299,20 @@ func TestPackageWriteDelete(t *testing.T) {
} }
} }
func TestPackageReadUnknownMethod(t *testing.T) { func TestPackageUnknownMethod(t *testing.T) {
ctx := context.Background() for name, fn := range map[string]func(context.Context, map[string]any) (*mcp.CallToolResult, error){
args := map[string]any{ "packageReadFn": packageReadFn,
"method": "bogus", "packageWriteFn": packageWriteFn,
"owner": "test-org", } {
t.Run(name, func(t *testing.T) {
result, err := fn(context.Background(), map[string]any{"method": "bogus", "owner": "test-org"})
if err != nil {
t.Fatalf("%s() error = %v", name, err)
} }
if _, err := packageReadFn(ctx, args); err == nil { if result == nil || !result.IsError {
t.Fatal("expected error for unknown method") t.Fatalf("%s() result = %#v, want an error result", name, result)
} }
} })
func TestPackageWriteUnknownMethod(t *testing.T) {
ctx := context.Background()
args := map[string]any{
"method": "bogus",
"owner": "test-org",
}
if _, err := packageWriteFn(ctx, args); err == nil {
t.Fatal("expected error for unknown method")
} }
} }
+512 -9
View File
@@ -1,7 +1,9 @@
package operation package operation
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"io" "io"
@@ -10,6 +12,7 @@ import (
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"slices"
"strings" "strings"
"sync" "sync"
"testing" "testing"
@@ -17,7 +20,10 @@ import (
mcpContext "gitea.com/gitea/gitea-mcp/pkg/context" mcpContext "gitea.com/gitea/gitea-mcp/pkg/context"
"gitea.com/gitea/gitea-mcp/pkg/flag" "gitea.com/gitea/gitea-mcp/pkg/flag"
projectTo "gitea.com/gitea/gitea-mcp/pkg/to"
projectTool "gitea.com/gitea/gitea-mcp/pkg/tool"
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
"github.com/modelcontextprotocol/go-sdk/mcp" "github.com/modelcontextprotocol/go-sdk/mcp"
) )
@@ -25,7 +31,6 @@ import (
const ( const (
testServerVersion = "test-version" testServerVersion = "test-version"
expectedProtocolVersion = "2026-07-28" expectedProtocolVersion = "2026-07-28"
expectedStatefulHTTPProtocolVersion = "2025-11-25"
) )
func exposeAllTools(t *testing.T) { func exposeAllTools(t *testing.T) {
@@ -86,7 +91,7 @@ func textContent(t *testing.T, result *mcp.CallToolResult) string {
// listAndCallVersion is the round trip every transport must support. wantText // listAndCallVersion is the round trip every transport must support. wantText
// differs per transport: the stdio subprocess resolves its version from the VCS // differs per transport: the stdio subprocess resolves its version from the VCS
// build info (main.go:14), so only the in-process servers have a known one. // build info (main.go:14), so only the in-process servers have a known one.
func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSession, wantText string) { func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSession, wantText string) *mcp.ListToolsResult {
t.Helper() t.Helper()
result, err := session.ListTools(ctx, nil) result, err := session.ListTools(ctx, nil)
if err != nil { if err != nil {
@@ -104,6 +109,128 @@ func listAndCallVersion(ctx context.Context, t *testing.T, session *mcp.ClientSe
if got := textContent(t, callResult); !strings.Contains(got, wantText) { if got := textContent(t, callResult); !strings.Contains(got, wantText) {
t.Errorf("version tool result = %q, want it to contain %q", got, wantText) t.Errorf("version tool result = %q, want it to contain %q", got, wantText)
} }
return result
}
func assertToolsOnlyCapabilities(t *testing.T, capabilities *mcp.ServerCapabilities) {
t.Helper()
wireCapabilities, err := json.Marshal(capabilities)
if err != nil {
t.Fatalf("Marshal(server capabilities) error = %v", err)
}
// Any extra capability, or listChanged, changes these bytes.
if want := `{"tools":{}}`; string(wireCapabilities) != want {
t.Errorf("server capabilities = %s, want %s", wireCapabilities, want)
}
}
type rawRPCResponse struct {
status int
header http.Header
body []byte
}
type rpcRequest struct {
protocolVersion string
methodHeader string
nameHeader string
method string
params map[string]any
}
// postRPCRequest exists because the SDK client cannot be pinned to an old
// protocol version or made to send mismatched headers.
func postRPCRequest(t *testing.T, server *httptest.Server, call rpcRequest) rawRPCResponse {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
body, err := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": 1,
"method": call.method,
"params": call.params,
})
if err != nil {
t.Fatalf("Marshal() error = %v", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, server.URL+"/mcp", bytes.NewReader(body))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json, text/event-stream")
if call.protocolVersion != "" {
request.Header.Set("Mcp-Protocol-Version", call.protocolVersion)
}
if call.methodHeader != "" {
request.Header.Set("Mcp-Method", call.methodHeader)
}
if call.nameHeader != "" {
request.Header.Set("Mcp-Name", call.nameHeader)
}
response, err := server.Client().Do(request)
if err != nil {
t.Fatalf("POST %s error = %v", call.method, err)
}
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
t.Fatalf("ReadAll() error = %v", err)
}
return rawRPCResponse{status: response.StatusCode, header: response.Header.Clone(), body: responseBody}
}
func modernRequestMeta(protocolVersion string) map[string]any {
return map[string]any{
mcp.MetaKeyProtocolVersion: protocolVersion,
mcp.MetaKeyClientInfo: map[string]any{"name": "gitea-mcp-wire-test", "version": "1"},
mcp.MetaKeyClientCapabilities: map[string]any{},
}
}
func rpcPayload(response rawRPCResponse) []byte {
payload := bytes.TrimSpace(response.body)
for line := range bytes.SplitSeq(payload, []byte("\n")) {
if data, ok := bytes.CutPrefix(line, []byte("data: ")); ok {
return data
}
}
return payload
}
func rpcResult(t *testing.T, response rawRPCResponse) json.RawMessage {
t.Helper()
var wire struct {
Result json.RawMessage `json:"result"`
Error *json.RawMessage `json:"error"`
}
if err := json.Unmarshal(rpcPayload(response), &wire); err != nil {
t.Fatalf("Unmarshal(JSON-RPC response) error = %v; body = %s", err, response.body)
}
if wire.Error != nil {
t.Fatalf("JSON-RPC response has error %s", *wire.Error)
}
if len(wire.Result) == 0 {
t.Fatalf("JSON-RPC response has no result: %s", response.body)
}
return wire.Result
}
func rpcErrorCode(t *testing.T, response rawRPCResponse) int {
t.Helper()
payload := rpcPayload(response)
var wire struct {
Error *struct {
Code int `json:"code"`
} `json:"error"`
}
if err := json.Unmarshal(payload, &wire); err != nil {
t.Fatalf("Unmarshal(%q) error = %v", payload, err)
}
if wire.Error == nil {
t.Fatalf("response has no JSON-RPC error: %s", response.body)
}
return wire.Error.Code
} }
func TestOfficialSDKInMemory(t *testing.T) { func TestOfficialSDKInMemory(t *testing.T) {
@@ -127,6 +254,7 @@ func TestOfficialSDKInMemory(t *testing.T) {
if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion { if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion {
t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion) t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion)
} }
assertToolsOnlyCapabilities(t, session.InitializeResult().Capabilities)
listAndCallVersion(ctx, t, session, testServerVersion) listAndCallVersion(ctx, t, session, testServerVersion)
if err := session.Close(); err != nil { if err := session.Close(); err != nil {
t.Fatalf("Close() error = %v", err) t.Fatalf("Close() error = %v", err)
@@ -141,7 +269,7 @@ func TestOfficialSDKInMemory(t *testing.T) {
} }
} }
func TestStreamableHTTPStateful(t *testing.T) { func TestStreamableHTTP(t *testing.T) {
exposeAllTools(t) exposeAllTools(t)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
@@ -162,13 +290,25 @@ func TestStreamableHTTPStateful(t *testing.T) {
t.Fatalf("Connect() error = %v", err) t.Fatalf("Connect() error = %v", err)
} }
defer session.Close() defer session.Close()
// Stateful Streamable HTTP cannot negotiate the sessionless 2026 protocol. if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion {
if got := session.InitializeResult().ProtocolVersion; got != expectedStatefulHTTPProtocolVersion { t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion)
t.Errorf("protocol version = %q, want %q", got, expectedStatefulHTTPProtocolVersion)
} }
listAndCallVersion(ctx, t, session, testServerVersion) assertToolsOnlyCapabilities(t, session.InitializeResult().Capabilities)
firstList := listAndCallVersion(ctx, t, session, testServerVersion)
response, err := httpTestServer.Client().Get(httpTestServer.URL + "/not-mcp") secondList, err := session.ListTools(ctx, nil)
if err != nil {
t.Fatalf("second ListTools() error = %v", err)
}
if !slices.EqualFunc(firstList.Tools, secondList.Tools, func(a, b *mcp.Tool) bool { return a.Name == b.Name }) {
t.Error("tools/list order changed between requests")
}
request, err := http.NewRequestWithContext(ctx, http.MethodGet, httpTestServer.URL+"/not-mcp", nil)
if err != nil {
t.Fatalf("NewRequest(GET outside /mcp) error = %v", err)
}
response, err := httpTestServer.Client().Do(request)
if err != nil { if err != nil {
t.Fatalf("GET outside /mcp error = %v", err) t.Fatalf("GET outside /mcp error = %v", err)
} }
@@ -176,6 +316,264 @@ func TestStreamableHTTPStateful(t *testing.T) {
if response.StatusCode != http.StatusNotFound { if response.StatusCode != http.StatusNotFound {
t.Errorf("GET outside /mcp status = %d, want %d", response.StatusCode, http.StatusNotFound) t.Errorf("GET outside /mcp status = %d, want %d", response.StatusCode, http.StatusNotFound)
} }
for _, method := range []string{http.MethodGet, http.MethodDelete} {
request, err := http.NewRequestWithContext(ctx, method, httpTestServer.URL+"/mcp", nil)
if err != nil {
t.Fatalf("NewRequest(%s) error = %v", method, err)
}
response, err := httpTestServer.Client().Do(request)
if err != nil {
t.Fatalf("%s /mcp error = %v", method, err)
}
_ = response.Body.Close()
if response.StatusCode != http.StatusMethodNotAllowed {
t.Errorf("%s /mcp status = %d, want %d", method, response.StatusCode, http.StatusMethodNotAllowed)
}
if allow := response.Header.Get("Allow"); allow != http.MethodPost {
t.Errorf("%s /mcp Allow = %q, want %q", method, allow, http.MethodPost)
}
}
}
func TestStreamableHTTP20260728Wire(t *testing.T) {
invoked := make(chan struct{}, 1)
server := newMCPServer(testServerVersion)
definition := &mcp.Tool{
Name: "test_execution_error",
Description: "Record valid calls and return a controlled tool execution error.",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
}
server.AddTool(definition, projectTool.ServerTool{
Tool: definition,
Handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) {
invoked <- struct{}{}
return projectTo.ErrorResult(errors.New("controlled execution failure"))
},
}.MCPHandler())
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close()
callParams := map[string]any{
"_meta": modernRequestMeta(expectedProtocolVersion),
"name": definition.Name,
"arguments": map[string]any{},
}
t.Run("discover", func(t *testing.T) {
discover := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: expectedProtocolVersion,
methodHeader: "server/discover",
method: "server/discover",
params: map[string]any{"_meta": modernRequestMeta(expectedProtocolVersion)},
})
if discover.status != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", discover.status, http.StatusOK, discover.body)
}
if sessionID := discover.header.Get("Mcp-Session-Id"); sessionID != "" {
t.Errorf("Mcp-Session-Id = %q, want empty", sessionID)
}
var result struct {
Meta map[string]json.RawMessage `json:"_meta"`
SupportedVersions []string `json:"supportedVersions"`
Capabilities *mcp.ServerCapabilities `json:"capabilities"`
}
if err := json.Unmarshal(rpcResult(t, discover), &result); err != nil {
t.Fatalf("Unmarshal(server/discover) error = %v", err)
}
if !slices.Contains(result.SupportedVersions, expectedProtocolVersion) {
t.Errorf("supportedVersions = %v, want %q", result.SupportedVersions, expectedProtocolVersion)
}
assertToolsOnlyCapabilities(t, result.Capabilities)
var serverInfo mcp.Implementation
if err := json.Unmarshal(result.Meta[mcp.MetaKeyServerInfo], &serverInfo); err != nil {
t.Fatalf("Unmarshal(%s) error = %v", mcp.MetaKeyServerInfo, err)
}
if serverInfo.Name != "Gitea MCP Server" || serverInfo.Version != testServerVersion {
t.Errorf("serverInfo = %+v, want Gitea MCP Server %s", serverInfo, testServerVersion)
}
})
t.Run("tool execution error", func(t *testing.T) {
call := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: expectedProtocolVersion,
methodHeader: "tools/call",
nameHeader: definition.Name,
method: "tools/call",
params: callParams,
})
if call.status != http.StatusOK {
t.Fatalf("status = %d, want %d; body = %s", call.status, http.StatusOK, call.body)
}
if sessionID := call.header.Get("Mcp-Session-Id"); sessionID != "" {
t.Errorf("Mcp-Session-Id = %q, want empty", sessionID)
}
var result struct {
IsError bool `json:"isError"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(rpcResult(t, call), &result); err != nil {
t.Fatalf("Unmarshal(tools/call) error = %v", err)
}
if !result.IsError {
t.Error("isError = false, want true")
}
if len(result.Content) != 1 || result.Content[0].Type != "text" || result.Content[0].Text != "controlled execution failure" {
t.Errorf("content = %+v, want controlled execution failure text", result.Content)
}
select {
case <-invoked:
default:
t.Error("valid tools/call did not invoke the tool handler")
}
})
for _, test := range []struct {
name string
protocolVersion string
methodHeader string
nameHeader string
}{
{name: "missing protocol", methodHeader: "tools/call", nameHeader: "test_execution_error"},
{name: "mismatched protocol", protocolVersion: "2025-11-25", methodHeader: "tools/call", nameHeader: "test_execution_error"},
{name: "missing method", protocolVersion: expectedProtocolVersion, nameHeader: "test_execution_error"},
{name: "mismatched method", protocolVersion: expectedProtocolVersion, methodHeader: "tools/list", nameHeader: "test_execution_error"},
{name: "missing name", protocolVersion: expectedProtocolVersion, methodHeader: "tools/call"},
{name: "mismatched name", protocolVersion: expectedProtocolVersion, methodHeader: "tools/call", nameHeader: "wrong_tool"},
} {
t.Run(test.name, func(t *testing.T) {
response := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: test.protocolVersion,
methodHeader: test.methodHeader,
nameHeader: test.nameHeader,
method: "tools/call",
params: callParams,
})
if response.status != http.StatusBadRequest {
t.Errorf("status = %d, want %d", response.status, http.StatusBadRequest)
}
if code := rpcErrorCode(t, response); code != mcp.CodeHeaderMismatch {
t.Errorf("error code = %d, want %d", code, mcp.CodeHeaderMismatch)
}
select {
case <-invoked:
t.Error("invalid headers invoked the tool handler")
default:
}
})
}
t.Run("unsupported protocol", func(t *testing.T) {
futureVersion := "2027-01-01"
response := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: futureVersion,
methodHeader: "server/discover",
method: "server/discover",
params: map[string]any{"_meta": modernRequestMeta(futureVersion)},
})
if response.status != http.StatusBadRequest {
t.Errorf("status = %d, want %d", response.status, http.StatusBadRequest)
}
if code := rpcErrorCode(t, response); code != mcp.CodeUnsupportedProtocolVersion {
t.Errorf("error code = %d, want %d", code, mcp.CodeUnsupportedProtocolVersion)
}
})
t.Run("unknown method", func(t *testing.T) {
response := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: expectedProtocolVersion,
methodHeader: "test/unknown",
method: "test/unknown",
params: map[string]any{"_meta": modernRequestMeta(expectedProtocolVersion)},
})
if response.status != http.StatusNotFound {
t.Errorf("status = %d, want %d", response.status, http.StatusNotFound)
}
if code := rpcErrorCode(t, response); code != jsonrpc.CodeMethodNotFound {
t.Errorf("error code = %d, want %d", code, jsonrpc.CodeMethodNotFound)
}
})
}
func TestStreamableHTTPLegacyCore(t *testing.T) {
exposeAllTools(t)
server := newMCPServer(testServerVersion)
RegisterTool(server)
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close()
for _, protocolVersion := range []string{"2025-11-25", "2025-06-18", "2025-03-26", "2024-11-05"} {
t.Run(protocolVersion, func(t *testing.T) {
initialize := postRPCRequest(t, httpTestServer, rpcRequest{
method: "initialize",
params: map[string]any{
"protocolVersion": protocolVersion,
"clientInfo": map[string]any{"name": "gitea-mcp-legacy-test", "version": "1"},
"capabilities": map[string]any{},
},
})
if initialize.status != http.StatusOK {
t.Fatalf("initialize status = %d, want %d; body = %s", initialize.status, http.StatusOK, initialize.body)
}
var initializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
}
if err := json.Unmarshal(rpcResult(t, initialize), &initializeResult); err != nil {
t.Fatalf("Unmarshal(initialize result) error = %v", err)
}
if initializeResult.ProtocolVersion != protocolVersion {
t.Errorf("initialize protocolVersion = %q, want %q", initializeResult.ProtocolVersion, protocolVersion)
}
if sessionID := initialize.header.Get("Mcp-Session-Id"); sessionID != "" {
t.Errorf("initialize Mcp-Session-Id = %q, want empty", sessionID)
}
list := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: protocolVersion,
method: "tools/list",
params: map[string]any{},
})
if list.status != http.StatusOK {
t.Fatalf("tools/list status = %d, want %d; body = %s", list.status, http.StatusOK, list.body)
}
var listResult struct {
Tools []json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(rpcResult(t, list), &listResult); err != nil {
t.Fatalf("Unmarshal(tools/list result) error = %v", err)
}
if len(listResult.Tools) != registeredToolCount() {
t.Errorf("tools/list count = %d, want %d", len(listResult.Tools), registeredToolCount())
}
})
}
// The call path does not vary by version, unlike the two requests above.
call := postRPCRequest(t, httpTestServer, rpcRequest{
protocolVersion: "2025-11-25",
method: "tools/call",
params: map[string]any{
"name": "get_gitea_mcp_server_version",
"arguments": map[string]any{},
},
})
if call.status != http.StatusOK {
t.Fatalf("tools/call status = %d, want %d; body = %s", call.status, http.StatusOK, call.body)
}
var callResult struct {
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(rpcResult(t, call), &callResult); err != nil {
t.Fatalf("Unmarshal(tools/call result) error = %v", err)
}
if len(callResult.Content) != 1 || !strings.Contains(callResult.Content[0].Text, testServerVersion) {
t.Errorf("tools/call content = %+v, want version %q", callResult.Content, testServerVersion)
}
} }
// spaceReader yields an endless run of spaces, so oversized bodies can be sent // spaceReader yields an endless run of spaces, so oversized bodies can be sent
@@ -190,6 +588,8 @@ func (spaceReader) Read(p []byte) (int, error) {
} }
func TestStreamableHTTPRequestBodyLimit(t *testing.T) { func TestStreamableHTTPRequestBodyLimit(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
server := newMCPServer(testServerVersion) server := newMCPServer(testServerVersion)
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler) httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close() defer httpTestServer.Close()
@@ -203,7 +603,7 @@ func TestStreamableHTTPRequestBodyLimit(t *testing.T) {
{name: "above our own limit", size: maxRequestBodyBytes + 1, tooLarge: true}, {name: "above our own limit", size: maxRequestBodyBytes + 1, tooLarge: true},
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
request, err := http.NewRequest(http.MethodPost, httpTestServer.URL+"/mcp", io.LimitReader(spaceReader{}, test.size)) request, err := http.NewRequestWithContext(ctx, http.MethodPost, httpTestServer.URL+"/mcp", io.LimitReader(spaceReader{}, test.size))
if err != nil { if err != nil {
t.Fatalf("NewRequest() error = %v", err) t.Fatalf("NewRequest() error = %v", err)
} }
@@ -222,6 +622,108 @@ func TestStreamableHTTPRequestBodyLimit(t *testing.T) {
} }
} }
func TestStreamableHTTPOriginProtection(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
httpTestServer := httptest.NewServer(newHTTPServer("", newMCPServer(testServerVersion)).Handler)
defer httpTestServer.Close()
for _, test := range []struct {
name string
method string
origin string
wantForbidden bool
}{
{name: "native client without origin", method: http.MethodPost},
{name: "same origin", method: http.MethodPost, origin: httpTestServer.URL},
{name: "cross origin POST", method: http.MethodPost, origin: "https://attacker.example", wantForbidden: true},
{name: "cross origin GET", method: http.MethodGet, origin: "https://attacker.example", wantForbidden: true},
} {
t.Run(test.name, func(t *testing.T) {
request, err := http.NewRequestWithContext(ctx, test.method, httpTestServer.URL+"/mcp", strings.NewReader("{}"))
if err != nil {
t.Fatalf("NewRequest() error = %v", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Accept", "application/json, text/event-stream")
if test.origin != "" {
request.Header.Set("Origin", test.origin)
}
response, err := httpTestServer.Client().Do(request)
if err != nil {
t.Fatalf("%s /mcp error = %v", test.method, err)
}
_, _ = io.Copy(io.Discard, response.Body)
_ = response.Body.Close()
if forbidden := response.StatusCode == http.StatusForbidden; forbidden != test.wantForbidden {
t.Errorf("status = %d, want forbidden = %v", response.StatusCode, test.wantForbidden)
}
})
}
}
func TestStreamableHTTPCancellation(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
started := make(chan struct{})
handlerCanceled := make(chan struct{})
server := newMCPServer(testServerVersion)
server.AddTool(
&mcp.Tool{
Name: "test_cancellation",
Description: "Wait for the request context to be canceled.",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
},
func(ctx context.Context, _ *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
close(started)
<-ctx.Done()
close(handlerCanceled)
return nil, ctx.Err()
},
)
httpTestServer := httptest.NewServer(newHTTPServer("", server).Handler)
defer httpTestServer.Close()
client := mcp.NewClient(&mcp.Implementation{Name: "gitea-mcp-cancellation-test", Version: "1"}, nil)
session, err := client.Connect(ctx, &mcp.StreamableClientTransport{
Endpoint: httpTestServer.URL + "/mcp",
HTTPClient: httpTestServer.Client(),
DisableStandaloneSSE: true,
MaxRetries: -1,
}, nil)
if err != nil {
t.Fatalf("Connect() error = %v", err)
}
defer session.Close()
callCtx, cancelCall := context.WithCancel(ctx)
callDone := make(chan error, 1)
go func() {
_, err := session.CallTool(callCtx, &mcp.CallToolParams{Name: "test_cancellation"})
callDone <- err
}()
select {
case <-started:
case <-ctx.Done():
t.Fatal("tool handler did not start")
}
cancelCall()
select {
case <-handlerCanceled:
case <-ctx.Done():
t.Fatal("HTTP request cancellation did not reach the tool handler")
}
select {
case err := <-callDone:
if err == nil {
t.Error("CallTool() error = nil after cancellation")
}
case <-ctx.Done():
t.Fatal("CallTool() did not return after cancellation")
}
}
type authorizationTransport struct { type authorizationTransport struct {
base http.RoundTripper base http.RoundTripper
mu sync.RWMutex mu sync.RWMutex
@@ -400,5 +902,6 @@ func TestStdioCommandTransport(t *testing.T) {
if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion { if got := session.InitializeResult().ProtocolVersion; got != expectedProtocolVersion {
t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion) t.Errorf("protocol version = %q, want %q", got, expectedProtocolVersion)
} }
assertToolsOnlyCapabilities(t, session.InitializeResult().Capabilities)
listAndCallVersion(ctx, t, session, "Gitea MCP Server version:") listAndCallVersion(ctx, t, session, "Gitea MCP Server version:")
} }
+1
View File
@@ -2,6 +2,7 @@ package flag
var ( var (
Host string Host string
Bind string
Port int Port int
Token string Token string
Version string Version string
+3 -1
View File
@@ -25,5 +25,7 @@ func TextResult(v any) (*mcp.CallToolResult, error) {
func ErrorResult(err error) (*mcp.CallToolResult, error) { func ErrorResult(err error) (*mcp.CallToolResult, error) {
log.Errorf("%s", err.Error()) log.Errorf("%s", err.Error())
return nil, err var result mcp.CallToolResult
result.SetError(err)
return &result, nil
} }
+11 -2
View File
@@ -27,7 +27,16 @@ func TestTextResult(t *testing.T) {
func TestErrorResult(t *testing.T) { func TestErrorResult(t *testing.T) {
want := errors.New("failed") want := errors.New("failed")
result, err := ErrorResult(want) result, err := ErrorResult(want)
if result != nil || !errors.Is(err, want) { if err != nil {
t.Errorf("ErrorResult() = (%#v, %v), want (nil, %v)", result, err, want) t.Fatalf("ErrorResult() error = %v", err)
}
if !result.IsError {
t.Error("IsError = false, want true")
}
if len(result.Content) != 1 {
t.Fatalf("len(Content) = %d, want 1", len(result.Content))
}
if content, ok := result.Content[0].(*mcp.TextContent); !ok || content.Text != want.Error() {
t.Errorf("Content[0] = %#v, want text %q", result.Content[0], want)
} }
} }
+18 -9
View File
@@ -68,27 +68,36 @@ func TestMCPHandlerAcceptsAbsentArguments(t *testing.T) {
} }
} }
func TestMCPHandlerConvertsErrorsAndRecoversPanics(t *testing.T) { func TestMCPHandlerErrorClassification(t *testing.T) {
for _, test := range []struct { for _, test := range []struct {
name string name string
handler Handler handler Handler
wantCode int64
}{ }{
{ {
name: "handler error", name: "server error",
handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { return nil, errors.New("failed") },
return nil, errors.New("failed") wantCode: jsonrpc.CodeInternalError,
}, },
{
name: "protocol error",
handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) {
return nil, &jsonrpc.Error{Code: jsonrpc.CodeInvalidParams, Message: "failed"}
},
wantCode: jsonrpc.CodeInvalidParams,
}, },
{ {
name: "panic", name: "panic",
handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { handler: func(context.Context, map[string]any) (*mcp.CallToolResult, error) { panic("failed") },
panic("failed") wantCode: jsonrpc.CodeInternalError,
},
}, },
} { } {
t.Run(test.name, func(t *testing.T) { t.Run(test.name, func(t *testing.T) {
_, err := callTool(test.handler, nil) result, err := callTool(test.handler, nil)
assertProtocolErrorCode(t, err, jsonrpc.CodeInternalError) if result != nil {
t.Errorf("result = %#v, want nil", result)
}
assertProtocolErrorCode(t, err, test.wantCode)
}) })
} }
} }
+1 -2
View File
@@ -107,8 +107,7 @@ func (s ServerTool) MCPHandler() mcp.ToolHandler {
if errors.As(err, &protocolErr) { if errors.As(err, &protocolErr) {
return nil, err return nil, err
} }
// Preserve mcp-go behavior; tool-result errors are a separate change. return nil, internalError(err) // Expected failures never reach here, handlers use CallToolResult.
return nil, internalError(err)
} }
return result, nil return result, nil
} }