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) }