Files
gitea-mcp/internal/mcp/stdio_test.go
T
mathias 2bb1fef543
CD / Lint / Test / Vet (push) Successful in 10s
CD / Build & Import (push) Successful in 5s
CD / Deploy via GitOps (push) Successful in 4s
chore(lint): adopt the canonical .golangci.yml from infra/ci-templates
Canonical lives at infra ci-templates/golangci.yml, distributed by the
infra#309 pattern: byte-identical copies, checksum drift gate in infra. Do
not edit this file here -- edit the canonical and run `task ci:sync`.

Verified locally before pushing, not pushed-and-hoped: golangci-lint v2.11.4
against a Go 1.26.1 toolchain (matching CI) reports 0 issues here.

Also applies gofumpt, which this config enables and the golangci-lint v2
default set does not check at all. That gap is why the drift was invisible:
earlier passes ran gofmt, which is weaker. Mechanical, deterministic,
build+vet verified.
2026-09-05 14:42:59 +02:00

117 lines
3.8 KiB
Go

package mcp_test
import (
"bytes"
"context"
"encoding/json"
"strings"
"testing"
"git.d-ma.be/mathias/gitea-mcp/internal/mcp"
"git.d-ma.be/mathias/gitea-mcp/internal/registry"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// The existing MCP server (server.go) only speaks HTTP. gitea-admin-mcp needs
// to run as a LOCAL stdio server (Claude Code's local .mcp.json stdio server
// type, spawned as a subprocess with real 1Password/admin auth already in its
// environment) -- reusing the exact same registry.Tool interface and
// jsonrpc.go message types, one JSON-RPC message per line on stdin/stdout,
// which is the standard MCP stdio framing.
type echoTool struct{}
func (echoTool) Descriptor() registry.ToolDescriptor {
return registry.ToolDescriptor{Name: "echo", Description: "echoes its input"}
}
func (echoTool) Call(_ context.Context, args json.RawMessage) (json.RawMessage, error) {
return args, nil
}
func TestServeStdio_Initialize(t *testing.T) {
reg := registry.New()
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + "\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
assert.EqualValues(t, 1, resp.ID)
assert.Nil(t, resp.Error)
}
func TestServeStdio_ToolsListAndCall(t *testing.T) {
reg := registry.New()
reg.Register(echoTool{})
in := strings.NewReader(
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n" +
`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"hello":"world"}}}` + "\n",
)
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
require.Len(t, lines, 2, "one JSON-RPC response per line, in request order")
var listResp mcp.Response
require.NoError(t, json.Unmarshal([]byte(lines[0]), &listResp))
assert.EqualValues(t, 1, listResp.ID)
var callResp mcp.Response
require.NoError(t, json.Unmarshal([]byte(lines[1]), &callResp))
assert.EqualValues(t, 2, callResp.ID)
require.NotNil(t, callResp.Result)
}
func TestServeStdio_NotificationGetsNoResponse(t *testing.T) {
// A request with no "id" is a notification per JSON-RPC 2.0 -- must not
// write anything back, and must not stop the loop from processing what
// follows it.
reg := registry.New()
in := strings.NewReader(
`{"jsonrpc":"2.0","method":"notifications/initialized"}` + "\n" +
`{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + "\n",
)
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
require.Len(t, lines, 1, "the notification must not produce a response line")
}
func TestServeStdio_UnknownMethodReturnsJSONRPCError(t *testing.T) {
reg := registry.New()
in := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"not/a/real/method"}` + "\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err)
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, -32601, resp.Error.Code)
}
func TestServeStdio_UnparseableLineReturnsParseError(t *testing.T) {
reg := registry.New()
in := strings.NewReader("not json at all\n")
out := &bytes.Buffer{}
err := mcp.ServeStdio(context.Background(), reg, in, out)
require.NoError(t, err, "a malformed line must not crash the loop")
var resp mcp.Response
require.NoError(t, json.Unmarshal(bytes.TrimSpace(out.Bytes()), &resp))
require.NotNil(t, resp.Error)
assert.Equal(t, -32700, resp.Error.Code)
}