diff --git a/.gitignore b/.gitignore index 60bf563..248de91 100644 --- a/.gitignore +++ b/.gitignore @@ -29,4 +29,5 @@ go.work.sum configs/*.local.yaml cmd/amcs-server/__debug_* bin/ -OB1/ \ No newline at end of file +.cache/ +OB1/ diff --git a/Makefile b/Makefile index 0f7bc18..dc24f94 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,9 @@ BIN_DIR := bin +GO_CACHE_DIR := $(CURDIR)/.cache/go-build SERVER_BIN := $(BIN_DIR)/amcs-server CMD_SERVER := ./cmd/amcs-server BUILDINFO_PKG := git.warky.dev/wdevs/amcs/internal/buildinfo +PATCH_INCREMENT ?= 1 VERSION_TAG ?= $(shell git describe --tags --exact-match 2>/dev/null || echo dev) COMMIT_SHA ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown) BUILD_DATE ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) @@ -11,7 +13,7 @@ LDFLAGS := -s -w \ -X $(BUILDINFO_PKG).Commit=$(COMMIT_SHA) \ -X $(BUILDINFO_PKG).BuildDate=$(BUILD_DATE) -.PHONY: all build clean migrate +.PHONY: all build clean migrate release-version test all: build @@ -19,6 +21,30 @@ build: @mkdir -p $(BIN_DIR) go build -ldflags "$(LDFLAGS)" -o $(SERVER_BIN) $(CMD_SERVER) +test: + @mkdir -p $(GO_CACHE_DIR) + GOCACHE=$(GO_CACHE_DIR) go test ./... + +release-version: + @case "$(PATCH_INCREMENT)" in \ + ''|*[!0-9]*|0) echo "PATCH_INCREMENT must be a positive integer" >&2; exit 1 ;; \ + esac + @latest=$$(git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -n 1); \ + if [ -z "$$latest" ]; then latest="v0.0.0"; fi; \ + version=$${latest#v}; \ + major=$${version%%.*}; \ + rest=$${version#*.}; \ + minor=$${rest%%.*}; \ + patch=$${rest##*.}; \ + next_patch=$$((patch + $(PATCH_INCREMENT))); \ + next_tag="v$$major.$$minor.$$next_patch"; \ + if git rev-parse -q --verify "refs/tags/$$next_tag" >/dev/null; then \ + echo "$$next_tag already exists" >&2; \ + exit 1; \ + fi; \ + git tag -a "$$next_tag" -m "Release $$next_tag"; \ + echo "$$next_tag" + migrate: ./scripts/migrate.sh diff --git a/internal/mcpserver/schema_test.go b/internal/mcpserver/schema_test.go index 4f99882..5e7b9e3 100644 --- a/internal/mcpserver/schema_test.go +++ b/internal/mcpserver/schema_test.go @@ -3,6 +3,7 @@ package mcpserver import ( "context" "encoding/json" + "errors" "strings" "testing" @@ -99,3 +100,130 @@ func TestAddToolReturnsSchemaErrorInsteadOfPanicking(t *testing.T) { t.Fatalf("addTool() error = %q, want tool context", err.Error()) } } + +func TestAddToolAppliesInputDefaultsAndSetsStructuredContent(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + + type helloInput struct { + Name string `json:"name,omitempty"` + } + type helloOutput struct { + Message string `json:"message"` + } + + tool := &mcp.Tool{ + Name: "hello", + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "name": { + Type: "string", + Default: json.RawMessage(`"world"`), + }, + }, + }, + } + + var gotInput helloInput + if err := addTool(server, nil, tool, func(_ context.Context, _ *mcp.CallToolRequest, in helloInput) (*mcp.CallToolResult, helloOutput, error) { + gotInput = in + return nil, helloOutput{Message: "hello " + in.Name}, nil + }); err != nil { + t.Fatalf("addTool() error = %v", err) + } + + ct, st := mcp.NewInMemoryTransports() + _, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("connect server: %v", err) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "0.0.1"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + if err != nil { + t.Fatalf("connect client: %v", err) + } + defer func() { + _ = cs.Close() + }() + + result, err := cs.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "hello", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("CallTool(hello) error = %v", err) + } + + if gotInput.Name != "world" { + t.Fatalf("handler input name = %q, want %q", gotInput.Name, "world") + } + + gotStructured, ok := result.StructuredContent.(map[string]any) + if !ok { + t.Fatalf("structured content type = %T, want map[string]any", result.StructuredContent) + } + if gotStructured["message"] != "hello world" { + t.Fatalf("structured content message = %#v, want %q", gotStructured["message"], "hello world") + } + + if len(result.Content) != 1 { + t.Fatalf("content count = %d, want 1", len(result.Content)) + } + + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("content[0] type = %T, want *mcp.TextContent", result.Content[0]) + } + if textContent.Text != `{"message":"hello world"}` { + t.Fatalf("content[0].Text = %q, want %q", textContent.Text, `{"message":"hello world"}`) + } +} + +func TestAddToolWrapsRegularErrorsInToolResults(t *testing.T) { + server := mcp.NewServer(&mcp.Implementation{Name: "test", Version: "0.0.1"}, nil) + + toolErr := errors.New("boom") + if err := addTool(server, nil, &mcp.Tool{Name: "explode"}, func(_ context.Context, _ *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, struct{}, error) { + return nil, struct{}{}, toolErr + }); err != nil { + t.Fatalf("addTool() error = %v", err) + } + + ct, st := mcp.NewInMemoryTransports() + _, err := server.Connect(context.Background(), st, nil) + if err != nil { + t.Fatalf("connect server: %v", err) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "0.0.1"}, nil) + cs, err := client.Connect(context.Background(), ct, nil) + if err != nil { + t.Fatalf("connect client: %v", err) + } + defer func() { + _ = cs.Close() + }() + + result, err := cs.CallTool(context.Background(), &mcp.CallToolParams{Name: "explode"}) + if err != nil { + t.Fatalf("CallTool(explode) error = %v, want nil transport error", err) + } + if !result.IsError { + t.Fatal("CallTool(explode) IsError = false, want true") + } + if result.StructuredContent != nil { + t.Fatalf("structured content = %#v, want nil", result.StructuredContent) + } + if len(result.Content) != 1 { + t.Fatalf("content count = %d, want 1", len(result.Content)) + } + + textContent, ok := result.Content[0].(*mcp.TextContent) + if !ok { + t.Fatalf("content[0] type = %T, want *mcp.TextContent", result.Content[0]) + } + if textContent.Text != toolErr.Error() { + t.Fatalf("content[0].Text = %q, want %q", textContent.Text, toolErr.Error()) + } +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..b635bbf --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,151 @@ +package mcpserver + +import ( + "context" + "net/http/httptest" + "reflect" + "sort" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "git.warky.dev/wdevs/amcs/internal/config" +) + +func TestNewListsAllRegisteredTools(t *testing.T) { + cs := newStreamableTestClient(t) + + result, err := cs.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("ListTools() error = %v", err) + } + + got := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + got = append(got, tool.Name) + } + sort.Strings(got) + + want := []string{ + "add_activity", + "add_family_member", + "add_guardrail", + "add_household_item", + "add_important_date", + "add_maintenance_task", + "add_professional_contact", + "add_project_guardrail", + "add_project_skill", + "add_recipe", + "add_skill", + "add_vendor", + "archive_thought", + "backfill_embeddings", + "capture_thought", + "create_meal_plan", + "create_opportunity", + "create_project", + "delete_thought", + "generate_shopping_list", + "get_active_project", + "get_contact_history", + "get_follow_ups_due", + "get_household_item", + "get_meal_plan", + "get_project_context", + "get_thought", + "get_upcoming_dates", + "get_upcoming_maintenance", + "get_version_info", + "get_week_schedule", + "link_thought_to_contact", + "link_thoughts", + "list_family_members", + "list_files", + "list_guardrails", + "list_project_guardrails", + "list_project_skills", + "list_projects", + "list_skills", + "list_thoughts", + "list_vendors", + "load_file", + "log_interaction", + "log_maintenance", + "recall_context", + "related_thoughts", + "remove_guardrail", + "remove_project_guardrail", + "remove_project_skill", + "remove_skill", + "reparse_thought_metadata", + "retry_failed_metadata", + "save_file", + "search_activities", + "search_contacts", + "search_household_items", + "search_maintenance_history", + "search_recipes", + "search_thoughts", + "set_active_project", + "summarize_thoughts", + "thought_stats", + "update_recipe", + "update_thought", + "upload_file", + } + sort.Strings(want) + + if !reflect.DeepEqual(got, want) { + t.Fatalf("ListTools() names = %#v, want %#v", got, want) + } +} + +func TestNewListsStoredFileResourceTemplate(t *testing.T) { + cs := newStreamableTestClient(t) + + result, err := cs.ListResourceTemplates(context.Background(), nil) + if err != nil { + t.Fatalf("ListResourceTemplates() error = %v", err) + } + + if len(result.ResourceTemplates) != 1 { + t.Fatalf("ListResourceTemplates() count = %d, want 1", len(result.ResourceTemplates)) + } + + template := result.ResourceTemplates[0] + if template.Name != "stored_file" { + t.Fatalf("resource template name = %q, want %q", template.Name, "stored_file") + } + if template.URITemplate != "amcs://files/{id}" { + t.Fatalf("resource template uri = %q, want %q", template.URITemplate, "amcs://files/{id}") + } +} + +func newStreamableTestClient(t *testing.T) *mcp.ClientSession { + t.Helper() + + handler, err := New(config.MCPConfig{ + ServerName: "test", + Version: "0.0.1", + SessionTimeout: time.Minute, + }, nil, streamableTestToolSet(), nil) + if err != nil { + t.Fatalf("mcpserver.New() error = %v", err) + } + + httpServer := httptest.NewServer(handler) + t.Cleanup(httpServer.Close) + + client := mcp.NewClient(&mcp.Implementation{Name: "client", Version: "0.0.1"}, nil) + cs, err := client.Connect(context.Background(), &mcp.StreamableClientTransport{Endpoint: httpServer.URL}, nil) + if err != nil { + t.Fatalf("connect client: %v", err) + } + t.Cleanup(func() { + _ = cs.Close() + }) + + return cs +}