Skip to content
Merged
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
62 changes: 62 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
name: CI

on:
push:
branches: [main]
pull_request:
branches: [main]

permissions:
contents: read

jobs:
test:
name: Test (Go ${{ matrix.go-version }})
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
# Test against the module's minimum supported version and the latest
# stable release to catch version-specific regressions.
go-version: ['1.24.3', 'stable']
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: ${{ matrix.go-version }}

- name: Verify gofmt
run: |
unformatted=$(gofmt -l .)
if [ -n "$unformatted" ]; then
echo "The following files are not gofmt-formatted:"
echo "$unformatted"
exit 1
fi

- name: Vet
run: go vet ./...

- name: Test (race + coverage)
run: go test ./gomcp/ -race -count=1 -coverprofile=coverage.out -covermode=atomic

- name: Coverage summary
run: go tool cover -func=coverage.out

- name: Build
run: go build ./...

- name: Build examples
# Mirrors the Makefile `examples` target. db-explorer is intentionally
# omitted: it is gated behind a build constraint and is not part of the
# default build.
run: |
go build -o /dev/null ./examples/greet/
go build -o /dev/null ./examples/sys-monitor/
go build -o /dev/null ./examples/fs-navigator/

- name: Benchmarks (compile + smoke run)
run: go test ./gomcp/ -run '^$' -bench . -benchmem -benchtime 10x
6 changes: 4 additions & 2 deletions examples/db-explorer/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@
//
// Requires: github.com/lib/pq driver
// Build: go build -tags ignore -o db-explorer .
// or remove the build tag and: go get github.com/lib/pq
//
// or remove the build tag and: go get github.com/lib/pq
//
// Test: echo '{"jsonrpc":"2.0","id":1,"method":"tools/list"}' | ./db-explorer
package main

Expand All @@ -21,8 +23,8 @@ import (
"os"
"strings"

_ "github.com/lib/pq"
"github.com/BackendStack21/go-mcp/gomcp"
_ "github.com/lib/pq"
)

func main() {
Expand Down
75 changes: 75 additions & 0 deletions gomcp/bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package gomcp

import (
"context"
"io"
"strings"
"testing"
)

// newInitializedServer returns a server marked as initialized so benchmarks can
// exercise post-handshake request handling directly.
func newInitializedServer() *Server {
srv := NewServer("bench-server", "1.0.0")
srv.initialized = true
return srv
}

// runOnce feeds a single request followed by EOF through the server, discarding
// the encoded output. It models the per-request decode → dispatch → encode path.
func runOnce(b *testing.B, srv *Server, req string) {
b.Helper()
if err := srv.RunWithIO(strings.NewReader(req), io.Discard); err != nil {
b.Fatalf("RunWithIO: %v", err)
}
}

func BenchmarkInitialize(b *testing.B) {
srv := NewServer("bench-server", "1.0.0")
req := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}` + "\n"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
runOnce(b, srv, req)
}
}

func BenchmarkToolsList(b *testing.B) {
srv := newInitializedServer()
for i := 0; i < 16; i++ {
name := "tool" + string(rune('a'+i))
srv.AddTool(Tool{
Name: name,
Description: "a benchmark tool",
InputSchema: InputSchema{
Type: "object",
Properties: map[string]Property{
"value": {Type: "string", Description: "the value"},
},
Required: []string{"value"},
},
})
}
req := `{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}` + "\n"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
runOnce(b, srv, req)
}
}

func BenchmarkToolsCall(b *testing.B) {
srv := newInitializedServer()
srv.AddTool(Tool{
Name: "echo",
Handler: func(ctx context.Context, args map[string]any) (string, error) {
return "hello world", nil
},
})
req := `{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"echo","arguments":{"text":"hi"}}}` + "\n"
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
runOnce(b, srv, req)
}
}
84 changes: 34 additions & 50 deletions gomcp/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ const DefaultProtocolVersion = "2025-03-26"
// It handles the MCP handshake and dispatches tools, resources, and prompts
// to registered handlers.
type Server struct {
name string
version string
protocolVer string
tools map[string]Tool
resources map[string]Resource
prompts map[string]Prompt
initialized bool
mu sync.Mutex
name string
version string
protocolVer string
tools map[string]Tool
resources map[string]Resource
prompts map[string]Prompt
initialized bool
mu sync.Mutex
}

// NewServer creates a new MCP server with the given name and version.
Expand Down Expand Up @@ -111,7 +111,7 @@ func (s *Server) RunWithIO(r io.Reader, w io.Writer) error {
respErr = encoder.Encode(JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{},
Result: emptyObject{},
})
case "tools/list":
respErr = s.handleToolsList(req, encoder)
Expand Down Expand Up @@ -148,16 +148,11 @@ func (s *Server) handleInitialize(req JSONRPCRequest, encoder *json.Encoder) err
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"protocolVersion": ver,
"serverInfo": map[string]any{
"name": s.name,
"version": s.version,
},
"capabilities": map[string]any{
"tools": map[string]any{},
"resources": map[string]any{},
"prompts": map[string]any{},
Result: initializeResult{
ProtocolVersion: ver,
ServerInfo: serverInfo{
Name: s.name,
Version: s.version,
},
},
}
Expand All @@ -180,9 +175,7 @@ func (s *Server) handleToolsList(req JSONRPCRequest, encoder *json.Encoder) erro
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"tools": toolList,
},
Result: toolsListResult{Tools: toolList},
}
return encoder.Encode(resp)
}
Expand Down Expand Up @@ -214,11 +207,11 @@ func (s *Server) handleToolsCall(req JSONRPCRequest, encoder *json.Encoder) erro
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"content": []map[string]any{
{"type": "text", "text": fmt.Sprintf("Unknown tool: %s", params.Name)},
Result: toolCallResult{
Content: []textContent{
{Type: "text", Text: fmt.Sprintf("Unknown tool: %s", params.Name)},
},
"isError": true,
IsError: true,
},
}
return encoder.Encode(resp)
Expand All @@ -231,11 +224,11 @@ func (s *Server) handleToolsCall(req JSONRPCRequest, encoder *json.Encoder) erro
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"content": []map[string]any{
{"type": "text", "text": fmt.Sprintf("Error: %v", err)},
Result: toolCallResult{
Content: []textContent{
{Type: "text", Text: fmt.Sprintf("Error: %v", err)},
},
"isError": true,
IsError: true,
},
}
return encoder.Encode(resp)
Expand All @@ -244,12 +237,9 @@ func (s *Server) handleToolsCall(req JSONRPCRequest, encoder *json.Encoder) erro
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"content": []map[string]any{
{
"type": "text",
"text": result,
},
Result: toolCallResult{
Content: []textContent{
{Type: "text", Text: result},
},
},
}
Expand All @@ -265,9 +255,7 @@ func (s *Server) handleResourcesList(req JSONRPCRequest, encoder *json.Encoder)
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"resources": resourceList,
},
Result: resourcesListResult{Resources: resourceList},
}
return encoder.Encode(resp)
}
Expand Down Expand Up @@ -298,12 +286,12 @@ func (s *Server) handleResourcesRead(req JSONRPCRequest, encoder *json.Encoder)
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"contents": []map[string]any{
Result: resourcesReadResult{
Contents: []resourceContent{
{
"uri": res.URI,
"mimeType": res.MimeType,
"text": content,
URI: res.URI,
MimeType: res.MimeType,
Text: content,
},
},
},
Expand All @@ -320,9 +308,7 @@ func (s *Server) handlePromptsList(req JSONRPCRequest, encoder *json.Encoder) er
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"prompts": promptList,
},
Result: promptsListResult{Prompts: promptList},
}
return encoder.Encode(resp)
}
Expand Down Expand Up @@ -354,9 +340,7 @@ func (s *Server) handlePromptsGet(req JSONRPCRequest, encoder *json.Encoder) err
resp := JSONRPCResponse{
JSONRPC: "2.0",
ID: req.ID,
Result: map[string]any{
"messages": messages,
},
Result: promptsGetResult{Messages: messages},
}
return encoder.Encode(resp)
}
Loading
Loading