mirror of
https://github.com/lukaszraczylo/claude-mnemonic.git
synced 2026-06-05 23:03:55 +00:00
5c2685c7b6
* feat(leann-phase2): implement hybrid vector storage and graph-based search
- [x] Add AST-aware code chunking for Go, Python, and TypeScript using tree-sitter
- [x] Implement LEANN-inspired hybrid vector storage with hub detection and selective embedding storage (60-80% savings)
- [x] Add observation relationship graph with CSR format and edge detection (file overlap, semantic similarity, temporal, concept)
- [x] Implement graph-aware search with two-level traversal and relationship-based ranking
- [x] Add auto-tuning system for dynamic hub threshold adjustment based on query performance
- [x] Add comprehensive metrics tracking for vector storage, queries, latency, and graph traversals
- [x] Update configuration system with graph and hybrid storage settings
- [x] Add graph stats and vector metrics endpoints to worker service
- [x] Enhance UI sidebar with advanced metrics display and graph visualization
- [x] Optimize struct field alignment throughout codebase for memory efficiency
- [x] Update documentation with LEANN Phase 2 features and performance benefits
- [x] Add tree-sitter dependency for AST parsing
* fix: add fts5 build tag to CI workflow
Pass build-tags: "fts5" to shared workflow to properly compile
sqlite-vec-go-bindings with SQLite FTS5 support.
This fixes test failures in hybrid vector storage tests that require
CGO and FTS5 build tags.
Requires shared-actions@8f7f235 or later.
* docs: add testing documentation and macOS ARM64 known issue
Document the macOS ARM64 CGO linking issue with sqlite-vec-go-bindings
that prevents hybrid package tests from compiling locally.
Added:
- .github/TESTING.md: Comprehensive testing guide with platform-specific
issues, workarounds, and CI configuration details
- internal/vector/hybrid/README.md: Package-specific documentation
explaining the macOS limitation
- .github/CI_FIX_SUMMARY.md: Technical details of the CI fix
Key points:
- 41 out of 42 packages test successfully on all platforms
- hybrid package tests fail only on macOS ARM64 (local dev issue)
- Linux CI tests pass with proper build-tags: "fts5" configuration
- Production builds and runtime functionality unaffected
This is a known limitation of sqlite-vec-go-bindings on macOS ARM64
and does not impact CI/CD or production deployments.
* fix: add SQLite busy_timeout to prevent database locked errors
Set PRAGMA busy_timeout=5000 (5 seconds) to allow SQLite to retry
when the database is locked instead of failing immediately.
This fixes race conditions when multiple goroutines try to write
simultaneously, particularly in tests where StoreObservation spawns
async cleanup goroutines.
Root cause:
- StoreObservation launches goroutine -> CleanupOldObservations
- Multiple concurrent cleanups caused "database is locked" errors
- Without busy_timeout, SQLite fails immediately on lock contention
Solution:
- Add 5-second busy timeout for automatic retry on lock
- Standard practice for concurrent SQLite usage
- Works with existing WAL mode configuration
Fixes TestObservationStore_CleanupOldObservations in CI.
* docs: complete summary of all CI test fixes
Comprehensive documentation of all fixes applied:
1. Missing build tags (fts5)
2. Database locked errors (busy_timeout)
All 41/42 packages now pass tests. The hybrid package has a known
macOS ARM64 limitation that doesn't affect CI or production.
No functionality was removed - all fixes are additive only.
* fix: add SQLite driver import to hybrid tests for CGO linking
Add blank import of mattn/go-sqlite3 to hybrid test files to ensure
the SQLite driver is linked into the test binary. This provides the
SQLite symbols that sqlite-vec-go-bindings requires.
Root cause:
- hybrid package imports sqlitevec (transitively depends on sqlite-vec CGO)
- Test binary needs SQLite symbols for linking
- sqlitevec tests already had this import, but hybrid tests didn't
- Without the driver import, linker fails with "undefined symbols"
This fix enables hybrid tests to run with -race flag on all platforms.
Before: 41/42 packages pass (hybrid failed to link)
After: 42/42 packages pass ✅
Fixes hybrid test compilation on macOS ARM64, Linux, and Windows.
* docs: remove outdated macOS limitation documentation
The hybrid test linking issue has been fixed by adding the SQLite
driver import. All tests now pass on all platforms including macOS.
Removed:
- internal/vector/hybrid/README.md (documented workaround no longer needed)
- .github/TESTING.md (macOS limitation section obsolete)
All 42/42 packages now test successfully with -race flag.
* docs: final comprehensive summary of all CI fixes
All three issues now resolved:
1. Missing fts5 build tags
2. Database busy_timeout for concurrent writes
3. Missing SQLite driver import in hybrid tests
Result: 42/42 packages pass with -race on all platforms.
Credit to reviewer for identifying the race detector concern.
1084 lines
28 KiB
Go
1084 lines
28 KiB
Go
// Package mcp provides the MCP (Model Context Protocol) server for claude-mnemonic.
|
|
package mcp
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/stretchr/testify/suite"
|
|
)
|
|
|
|
// ServerSuite is a test suite for MCP Server operations.
|
|
type ServerSuite struct {
|
|
suite.Suite
|
|
}
|
|
|
|
func TestServerSuite(t *testing.T) {
|
|
suite.Run(t, new(ServerSuite))
|
|
}
|
|
|
|
// TestNewServer tests server creation.
|
|
func (s *ServerSuite) TestNewServer() {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
s.NotNil(server)
|
|
s.Nil(server.searchMgr)
|
|
s.Equal("1.0.0", server.version)
|
|
}
|
|
|
|
// TestRequest tests Request struct JSON marshaling.
|
|
func TestRequest(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
expected string
|
|
req Request
|
|
}{
|
|
{
|
|
name: "initialize request",
|
|
req: Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "initialize",
|
|
},
|
|
expected: `{"jsonrpc":"2.0","id":1,"method":"initialize"}`,
|
|
},
|
|
{
|
|
name: "tools/list request",
|
|
req: Request{
|
|
JSONRPC: "2.0",
|
|
ID: "abc",
|
|
Method: "tools/list",
|
|
},
|
|
expected: `{"jsonrpc":"2.0","id":"abc","method":"tools/list"}`,
|
|
},
|
|
{
|
|
name: "tools/call with params",
|
|
req: Request{
|
|
JSONRPC: "2.0",
|
|
ID: 2,
|
|
Method: "tools/call",
|
|
Params: json.RawMessage(`{"name":"search","arguments":{}}`),
|
|
},
|
|
expected: `{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"search","arguments":{}}}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data, err := json.Marshal(tt.req)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, tt.expected, string(data))
|
|
|
|
// Test unmarshaling
|
|
var parsed Request
|
|
err = json.Unmarshal(data, &parsed)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.req.JSONRPC, parsed.JSONRPC)
|
|
assert.Equal(t, tt.req.Method, parsed.Method)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestResponse tests Response struct JSON marshaling.
|
|
func TestResponse(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
resp Response
|
|
expected string
|
|
}{
|
|
{
|
|
name: "success response",
|
|
resp: Response{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Result: map[string]string{"status": "ok"},
|
|
},
|
|
expected: `{"jsonrpc":"2.0","id":1,"result":{"status":"ok"}}`,
|
|
},
|
|
{
|
|
name: "error response",
|
|
resp: Response{
|
|
JSONRPC: "2.0",
|
|
ID: 2,
|
|
Error: &Error{
|
|
Code: -32600,
|
|
Message: "Invalid Request",
|
|
},
|
|
},
|
|
expected: `{"jsonrpc":"2.0","id":2,"error":{"code":-32600,"message":"Invalid Request"}}`,
|
|
},
|
|
{
|
|
name: "error with data",
|
|
resp: Response{
|
|
JSONRPC: "2.0",
|
|
ID: 3,
|
|
Error: &Error{
|
|
Code: -32602,
|
|
Message: "Invalid params",
|
|
Data: "missing field",
|
|
},
|
|
},
|
|
expected: `{"jsonrpc":"2.0","id":3,"error":{"code":-32602,"message":"Invalid params","data":"missing field"}}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data, err := json.Marshal(tt.resp)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, tt.expected, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestError tests Error struct.
|
|
func TestError(t *testing.T) {
|
|
tests := []struct {
|
|
expected string
|
|
name string
|
|
err Error
|
|
}{
|
|
{
|
|
name: "parse error",
|
|
err: Error{
|
|
Code: -32700,
|
|
Message: "Parse error",
|
|
},
|
|
expected: `{"code":-32700,"message":"Parse error"}`,
|
|
},
|
|
{
|
|
name: "method not found",
|
|
err: Error{
|
|
Code: -32601,
|
|
Message: "Method not found",
|
|
},
|
|
expected: `{"code":-32601,"message":"Method not found"}`,
|
|
},
|
|
{
|
|
name: "invalid params",
|
|
err: Error{
|
|
Code: -32602,
|
|
Message: "Invalid params",
|
|
Data: "details here",
|
|
},
|
|
expected: `{"code":-32602,"message":"Invalid params","data":"details here"}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
data, err := json.Marshal(tt.err)
|
|
require.NoError(t, err)
|
|
assert.JSONEq(t, tt.expected, string(data))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestToolCallParams tests ToolCallParams struct.
|
|
func TestToolCallParams(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected ToolCallParams
|
|
}{
|
|
{
|
|
name: "search tool call",
|
|
input: `{"name":"search","arguments":{"query":"test"}}`,
|
|
expected: ToolCallParams{
|
|
Name: "search",
|
|
Arguments: json.RawMessage(`{"query":"test"}`),
|
|
},
|
|
},
|
|
{
|
|
name: "decisions tool call",
|
|
input: `{"name":"decisions","arguments":{"query":"auth"}}`,
|
|
expected: ToolCallParams{
|
|
Name: "decisions",
|
|
Arguments: json.RawMessage(`{"query":"auth"}`),
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var params ToolCallParams
|
|
err := json.Unmarshal([]byte(tt.input), ¶ms)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.expected.Name, params.Name)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTool tests Tool struct.
|
|
func TestTool(t *testing.T) {
|
|
tool := Tool{
|
|
Name: "search",
|
|
Description: "Search observations",
|
|
InputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"query": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(tool)
|
|
require.NoError(t, err)
|
|
|
|
var parsed Tool
|
|
err = json.Unmarshal(data, &parsed)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "search", parsed.Name)
|
|
assert.Equal(t, "Search observations", parsed.Description)
|
|
}
|
|
|
|
// TestTimelineParams tests TimelineParams struct.
|
|
func TestTimelineParams(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected TimelineParams
|
|
}{
|
|
{
|
|
name: "with anchor_id",
|
|
input: `{"anchor_id":123,"before":5,"after":5}`,
|
|
expected: TimelineParams{
|
|
AnchorID: 123,
|
|
Before: 5,
|
|
After: 5,
|
|
},
|
|
},
|
|
{
|
|
name: "with query",
|
|
input: `{"query":"test query","project":"my-project"}`,
|
|
expected: TimelineParams{
|
|
Query: "test query",
|
|
Project: "my-project",
|
|
},
|
|
},
|
|
{
|
|
name: "full params",
|
|
input: `{"anchor_id":100,"query":"search","before":10,"after":20,"project":"proj","obs_type":"bugfix","concepts":"security","files":"main.go","dateStart":1234567890,"dateEnd":9876543210,"format":"full"}`,
|
|
expected: TimelineParams{
|
|
AnchorID: 100,
|
|
Query: "search",
|
|
Before: 10,
|
|
After: 20,
|
|
Project: "proj",
|
|
ObsType: "bugfix",
|
|
Concepts: "security",
|
|
Files: "main.go",
|
|
DateStart: 1234567890,
|
|
DateEnd: 9876543210,
|
|
Format: "full",
|
|
},
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var params TimelineParams
|
|
err := json.Unmarshal([]byte(tt.input), ¶ms)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, tt.expected.AnchorID, params.AnchorID)
|
|
assert.Equal(t, tt.expected.Query, params.Query)
|
|
assert.Equal(t, tt.expected.Project, params.Project)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleInitialize tests the initialize handler.
|
|
func TestHandleInitialize(t *testing.T) {
|
|
server := NewServer(nil, "1.2.3", nil, nil, nil, nil, nil, nil, nil)
|
|
|
|
req := &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "initialize",
|
|
}
|
|
|
|
resp := server.handleInitialize(req)
|
|
|
|
assert.Equal(t, "2.0", resp.JSONRPC)
|
|
assert.Equal(t, 1, resp.ID)
|
|
assert.Nil(t, resp.Error)
|
|
assert.NotNil(t, resp.Result)
|
|
|
|
result, ok := resp.Result.(map[string]any)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "2024-11-05", result["protocolVersion"])
|
|
|
|
serverInfo, ok := result["serverInfo"].(map[string]any)
|
|
require.True(t, ok)
|
|
assert.Equal(t, "claude-mnemonic", serverInfo["name"])
|
|
assert.Equal(t, "1.2.3", serverInfo["version"])
|
|
}
|
|
|
|
// TestHandleToolsList tests the tools/list handler.
|
|
func TestHandleToolsList(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
|
|
req := &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "tools/list",
|
|
}
|
|
|
|
resp := server.handleToolsList(req)
|
|
|
|
assert.Equal(t, "2.0", resp.JSONRPC)
|
|
assert.Equal(t, 1, resp.ID)
|
|
assert.Nil(t, resp.Error)
|
|
|
|
result, ok := resp.Result.(map[string]any)
|
|
require.True(t, ok)
|
|
|
|
tools, ok := result["tools"].([]Tool)
|
|
require.True(t, ok)
|
|
assert.NotEmpty(t, tools)
|
|
|
|
// Verify expected tools are present
|
|
toolNames := make(map[string]bool)
|
|
for _, tool := range tools {
|
|
toolNames[tool.Name] = true
|
|
}
|
|
|
|
expectedTools := []string{
|
|
"search", "timeline", "decisions", "changes",
|
|
"how_it_works", "find_by_concept", "find_by_file",
|
|
"find_by_type", "get_recent_context", "get_context_timeline",
|
|
"get_timeline_by_query",
|
|
}
|
|
|
|
for _, name := range expectedTools {
|
|
assert.True(t, toolNames[name], "expected tool %s to be present", name)
|
|
}
|
|
}
|
|
|
|
// TestHandleRequest tests request routing.
|
|
func TestHandleRequest(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
tests := []struct {
|
|
req *Request
|
|
name string
|
|
errorMessage string
|
|
errorCode int
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "initialize method",
|
|
req: &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "initialize",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "tools/list method",
|
|
req: &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 2,
|
|
Method: "tools/list",
|
|
},
|
|
expectError: false,
|
|
},
|
|
{
|
|
name: "unknown method",
|
|
req: &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 3,
|
|
Method: "unknown_method",
|
|
},
|
|
expectError: true,
|
|
errorCode: -32601,
|
|
errorMessage: "Method not found",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
resp := server.handleRequest(ctx, tt.req)
|
|
|
|
assert.Equal(t, "2.0", resp.JSONRPC)
|
|
assert.Equal(t, tt.req.ID, resp.ID)
|
|
|
|
if tt.expectError {
|
|
require.NotNil(t, resp.Error)
|
|
assert.Equal(t, tt.errorCode, resp.Error.Code)
|
|
assert.Equal(t, tt.errorMessage, resp.Error.Message)
|
|
} else {
|
|
assert.Nil(t, resp.Error)
|
|
assert.NotNil(t, resp.Result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleToolsCall_InvalidParams tests tools/call with invalid params.
|
|
func TestHandleToolsCall_InvalidParams(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
req := &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "tools/call",
|
|
Params: json.RawMessage(`invalid json`),
|
|
}
|
|
|
|
resp := server.handleToolsCall(ctx, req)
|
|
|
|
require.NotNil(t, resp.Error)
|
|
assert.Equal(t, -32602, resp.Error.Code)
|
|
assert.Equal(t, "Invalid params", resp.Error.Message)
|
|
}
|
|
|
|
// TestCallTool_UnknownTool tests callTool with unknown tool name.
|
|
func TestCallTool_UnknownTool(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
_, err := server.callTool(ctx, "nonexistent_tool", json.RawMessage(`{}`))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "unknown tool")
|
|
}
|
|
|
|
// TestCallTool_InvalidArgs tests callTool with invalid arguments.
|
|
func TestCallTool_InvalidArgs(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
_, err := server.callTool(ctx, "search", json.RawMessage(`invalid json`))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "invalid arguments")
|
|
}
|
|
|
|
// TestSendResponse tests response sending.
|
|
func TestSendResponse(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
server := &Server{
|
|
stdout: &buf,
|
|
}
|
|
|
|
resp := &Response{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Result: map[string]string{"status": "ok"},
|
|
}
|
|
|
|
server.sendResponse(resp)
|
|
|
|
output := buf.String()
|
|
assert.Contains(t, output, `"jsonrpc":"2.0"`)
|
|
assert.Contains(t, output, `"id":1`)
|
|
assert.Contains(t, output, `"result"`)
|
|
}
|
|
|
|
// TestSendError tests error response sending.
|
|
func TestSendError(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
server := &Server{
|
|
stdout: &buf,
|
|
}
|
|
|
|
server.sendError(1, -32700, "Parse error", "details")
|
|
|
|
output := buf.String()
|
|
assert.Contains(t, output, `"error"`)
|
|
assert.Contains(t, output, `-32700`)
|
|
assert.Contains(t, output, `"Parse error"`)
|
|
}
|
|
|
|
// TestRun_ParseError tests Run with invalid JSON input.
|
|
func TestRun_ParseError(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
stdin := strings.NewReader("invalid json\n")
|
|
|
|
server := &Server{
|
|
stdin: stdin,
|
|
stdout: &stdout,
|
|
}
|
|
|
|
err := server.Run(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
output := stdout.String()
|
|
assert.Contains(t, output, `"error"`)
|
|
assert.Contains(t, output, `-32700`)
|
|
assert.Contains(t, output, `"Parse error"`)
|
|
}
|
|
|
|
// TestRun_EmptyLine tests Run skips empty lines.
|
|
func TestRun_EmptyLine(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
stdin := strings.NewReader("\n\n")
|
|
|
|
server := &Server{
|
|
stdin: stdin,
|
|
stdout: &stdout,
|
|
}
|
|
|
|
err := server.Run(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
// Should be empty - no responses for empty lines
|
|
assert.Empty(t, stdout.String())
|
|
}
|
|
|
|
// TestRun_ValidRequest tests Run with a valid request.
|
|
func TestRun_ValidRequest(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
req := `{"jsonrpc":"2.0","id":1,"method":"initialize"}`
|
|
stdin := strings.NewReader(req + "\n")
|
|
|
|
server := &Server{
|
|
stdin: stdin,
|
|
stdout: &stdout,
|
|
version: "1.0.0",
|
|
}
|
|
|
|
err := server.Run(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
output := stdout.String()
|
|
assert.Contains(t, output, `"jsonrpc":"2.0"`)
|
|
assert.Contains(t, output, `"result"`)
|
|
assert.Contains(t, output, `"protocolVersion"`)
|
|
}
|
|
|
|
// TestJSONRPCErrorCodes tests standard JSON-RPC error codes.
|
|
func TestJSONRPCErrorCodes(t *testing.T) {
|
|
errorCodes := map[string]int{
|
|
"Parse error": -32700,
|
|
"Invalid Request": -32600,
|
|
"Method not found": -32601,
|
|
"Invalid params": -32602,
|
|
"Internal error": -32603,
|
|
}
|
|
|
|
for msg, code := range errorCodes {
|
|
t.Run(msg, func(t *testing.T) {
|
|
err := Error{Code: code, Message: msg}
|
|
assert.Equal(t, code, err.Code)
|
|
assert.Equal(t, msg, err.Message)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestToolListContainsExpectedSchemas tests that tool schemas are valid.
|
|
func TestToolListContainsExpectedSchemas(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
|
|
req := &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "tools/list",
|
|
}
|
|
|
|
resp := server.handleToolsList(req)
|
|
result := resp.Result.(map[string]any)
|
|
tools := result["tools"].([]Tool)
|
|
|
|
for _, tool := range tools {
|
|
assert.NotEmpty(t, tool.Name)
|
|
assert.NotEmpty(t, tool.Description)
|
|
assert.NotNil(t, tool.InputSchema)
|
|
|
|
// Check schema has type
|
|
schema := tool.InputSchema
|
|
_, hasType := schema["type"]
|
|
assert.True(t, hasType, "tool %s schema should have type", tool.Name)
|
|
}
|
|
}
|
|
|
|
// TestHandleToolsCall_UnknownTool tests tools/call with unknown tool name.
|
|
func TestHandleToolsCall_UnknownTool(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
req := &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "tools/call",
|
|
Params: json.RawMessage(`{"name":"unknown_tool","arguments":{}}`),
|
|
}
|
|
|
|
resp := server.handleToolsCall(ctx, req)
|
|
require.NotNil(t, resp.Error)
|
|
assert.Equal(t, -32000, resp.Error.Code)
|
|
assert.Contains(t, resp.Error.Data, "unknown tool")
|
|
}
|
|
|
|
// TestCallTool_ToolNameRecognition tests that valid tool names are recognized (not "unknown tool").
|
|
func TestCallTool_ToolNameRecognition(t *testing.T) {
|
|
// Note: This test verifies tool routing logic, not execution (which requires searchMgr)
|
|
// All valid tool names should be in the handleToolsList response
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
|
|
req := &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "tools/list",
|
|
}
|
|
|
|
resp := server.handleToolsList(req)
|
|
result := resp.Result.(map[string]any)
|
|
tools := result["tools"].([]Tool)
|
|
|
|
// Verify all expected tools are registered
|
|
expectedTools := map[string]bool{
|
|
"search": true,
|
|
"timeline": true,
|
|
"decisions": true,
|
|
"changes": true,
|
|
"how_it_works": true,
|
|
"find_by_concept": true,
|
|
"find_by_file": true,
|
|
"find_by_type": true,
|
|
"get_recent_context": true,
|
|
"get_context_timeline": true,
|
|
"get_timeline_by_query": true,
|
|
}
|
|
|
|
foundTools := make(map[string]bool)
|
|
for _, tool := range tools {
|
|
foundTools[tool.Name] = true
|
|
}
|
|
|
|
for name := range expectedTools {
|
|
assert.True(t, foundTools[name], "tool %s should be registered", name)
|
|
}
|
|
}
|
|
|
|
// TestRun_MultipleRequests tests Run with multiple sequential requests.
|
|
func TestRun_MultipleRequests(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
req1 := `{"jsonrpc":"2.0","id":1,"method":"initialize"}`
|
|
req2 := `{"jsonrpc":"2.0","id":2,"method":"tools/list"}`
|
|
stdin := strings.NewReader(req1 + "\n" + req2 + "\n")
|
|
|
|
server := &Server{
|
|
stdin: stdin,
|
|
stdout: &stdout,
|
|
version: "1.0.0",
|
|
}
|
|
|
|
err := server.Run(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
output := stdout.String()
|
|
// Should contain responses for both requests
|
|
assert.Contains(t, output, `"id":1`)
|
|
assert.Contains(t, output, `"id":2`)
|
|
}
|
|
|
|
// TestHandleTimeline_Defaults tests timeline default values.
|
|
func TestHandleTimeline_Defaults(t *testing.T) {
|
|
// Test that handleTimeline sets default before/after values
|
|
params := TimelineParams{
|
|
AnchorID: 0,
|
|
Query: "",
|
|
Before: 0,
|
|
After: 0,
|
|
}
|
|
|
|
// Simulate the default value assignment from handleTimeline
|
|
if params.Before <= 0 {
|
|
params.Before = 10
|
|
}
|
|
if params.After <= 0 {
|
|
params.After = 10
|
|
}
|
|
|
|
assert.Equal(t, 10, params.Before)
|
|
assert.Equal(t, 10, params.After)
|
|
}
|
|
|
|
// TestTimelineParams_Complete tests complete TimelineParams parsing.
|
|
func TestTimelineParams_Complete(t *testing.T) {
|
|
input := `{
|
|
"anchor_id": 100,
|
|
"query": "test query",
|
|
"before": 5,
|
|
"after": 15,
|
|
"project": "my-project",
|
|
"obs_type": "bugfix",
|
|
"concepts": "security,auth",
|
|
"files": "main.go,handler.go",
|
|
"dateStart": 1700000000000,
|
|
"dateEnd": 1700100000000,
|
|
"format": "full"
|
|
}`
|
|
|
|
var params TimelineParams
|
|
err := json.Unmarshal([]byte(input), ¶ms)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, int64(100), params.AnchorID)
|
|
assert.Equal(t, "test query", params.Query)
|
|
assert.Equal(t, 5, params.Before)
|
|
assert.Equal(t, 15, params.After)
|
|
assert.Equal(t, "my-project", params.Project)
|
|
assert.Equal(t, "bugfix", params.ObsType)
|
|
assert.Equal(t, "security,auth", params.Concepts)
|
|
assert.Equal(t, "main.go,handler.go", params.Files)
|
|
assert.Equal(t, int64(1700000000000), params.DateStart)
|
|
assert.Equal(t, int64(1700100000000), params.DateEnd)
|
|
assert.Equal(t, "full", params.Format)
|
|
}
|
|
|
|
// TestServerStdinStdoutConfig tests that server stdin/stdout can be configured.
|
|
func TestServerStdinStdoutConfig(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stdin bytes.Buffer
|
|
|
|
server := &Server{
|
|
stdin: &stdin,
|
|
stdout: &stdout,
|
|
version: "test-version",
|
|
}
|
|
|
|
assert.Equal(t, &stdin, server.stdin)
|
|
assert.Equal(t, &stdout, server.stdout)
|
|
assert.Equal(t, "test-version", server.version)
|
|
}
|
|
|
|
// TestResponseIDTypes tests that response IDs can be various types.
|
|
func TestResponseIDTypes(t *testing.T) {
|
|
tests := []struct {
|
|
id any
|
|
name string
|
|
}{
|
|
{name: "integer id", id: 1},
|
|
{name: "string id", id: "abc-123"},
|
|
{name: "float id", id: 1.5},
|
|
{name: "null id", id: nil},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
server := &Server{stdout: &buf}
|
|
|
|
resp := &Response{
|
|
JSONRPC: "2.0",
|
|
ID: tt.id,
|
|
Result: "ok",
|
|
}
|
|
|
|
server.sendResponse(resp)
|
|
output := buf.String()
|
|
assert.Contains(t, output, `"jsonrpc":"2.0"`)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleTimelineByQuery_EmptyQuery tests timeline by query with empty query.
|
|
func TestHandleTimelineByQuery_EmptyQuery(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
// Empty query should error
|
|
_, err := server.handleTimelineByQuery(ctx, json.RawMessage(`{}`))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "query is required")
|
|
}
|
|
|
|
// TestHandleTimeline_InvalidJSON tests timeline with invalid JSON.
|
|
func TestHandleTimeline_InvalidJSON(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
_, err := server.handleTimeline(ctx, json.RawMessage(`{invalid`))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "invalid timeline params")
|
|
}
|
|
|
|
// TestHandleTimelineByQuery_InvalidJSON tests timeline by query with invalid JSON.
|
|
func TestHandleTimelineByQuery_InvalidJSON(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
_, err := server.handleTimelineByQuery(ctx, json.RawMessage(`{invalid`))
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "invalid timeline params")
|
|
}
|
|
|
|
// TestHandleTimeline_NoAnchorNoQuery tests timeline with no anchor and no query.
|
|
func TestHandleTimeline_NoAnchorNoQuery(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
// No anchor_id and no query should return empty result
|
|
result, err := server.handleTimeline(ctx, json.RawMessage(`{}`))
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.Empty(t, result.Results)
|
|
}
|
|
|
|
// TestHandleTimeline_WithDefaults tests timeline default values are applied.
|
|
func TestHandleTimeline_WithDefaults(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
// With anchor_id but no before/after, defaults should be applied
|
|
// However, since searchMgr is nil, this will fail after defaults are applied
|
|
result, err := server.handleTimeline(ctx, json.RawMessage(`{"anchor_id": 0}`))
|
|
// Should return empty result since anchor_id is 0
|
|
require.NoError(t, err)
|
|
assert.NotNil(t, result)
|
|
assert.Empty(t, result.Results)
|
|
}
|
|
|
|
// TestServerFields tests Server struct fields.
|
|
func TestServerFields(t *testing.T) {
|
|
server := NewServer(nil, "2.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
|
|
assert.Equal(t, "2.0.0", server.version)
|
|
assert.Nil(t, server.searchMgr)
|
|
assert.NotNil(t, server.stdin)
|
|
assert.NotNil(t, server.stdout)
|
|
}
|
|
|
|
// TestRequestUnmarshalWithNullID tests Request unmarshaling with null ID.
|
|
func TestRequestUnmarshalWithNullID(t *testing.T) {
|
|
input := `{"jsonrpc":"2.0","id":null,"method":"initialize"}`
|
|
|
|
var req Request
|
|
err := json.Unmarshal([]byte(input), &req)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "2.0", req.JSONRPC)
|
|
assert.Nil(t, req.ID)
|
|
assert.Equal(t, "initialize", req.Method)
|
|
}
|
|
|
|
// TestResponseWithNullError tests Response without error.
|
|
func TestResponseWithNullError(t *testing.T) {
|
|
resp := Response{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Result: "success",
|
|
Error: nil,
|
|
}
|
|
|
|
data, err := json.Marshal(resp)
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(data), `"result":"success"`)
|
|
assert.NotContains(t, string(data), `"error"`)
|
|
}
|
|
|
|
// TestErrorWithNilData tests Error without data.
|
|
func TestErrorWithNilData(t *testing.T) {
|
|
err := Error{
|
|
Code: -32600,
|
|
Message: "Invalid Request",
|
|
Data: nil,
|
|
}
|
|
|
|
data, errMarshal := json.Marshal(err)
|
|
require.NoError(t, errMarshal)
|
|
assert.Contains(t, string(data), `"code":-32600`)
|
|
assert.Contains(t, string(data), `"message":"Invalid Request"`)
|
|
assert.NotContains(t, string(data), `"data"`)
|
|
}
|
|
|
|
// TestToolInputSchema tests that tool input schemas have required fields.
|
|
func TestToolInputSchema(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
|
|
req := &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "tools/list",
|
|
}
|
|
|
|
resp := server.handleToolsList(req)
|
|
result := resp.Result.(map[string]any)
|
|
tools := result["tools"].([]Tool)
|
|
|
|
for _, tool := range tools {
|
|
schema := tool.InputSchema
|
|
schemaType, ok := schema["type"]
|
|
assert.True(t, ok, "tool %s schema should have type", tool.Name)
|
|
assert.Equal(t, "object", schemaType, "tool %s schema type should be object", tool.Name)
|
|
|
|
// All tools should have properties
|
|
_, hasProperties := schema["properties"]
|
|
assert.True(t, hasProperties, "tool %s should have properties", tool.Name)
|
|
}
|
|
}
|
|
|
|
// TestRunMixedRequests tests Run with mixed valid and invalid requests.
|
|
func TestRunMixedRequests(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
req1 := `{"jsonrpc":"2.0","id":1,"method":"initialize"}`
|
|
req2 := `invalid json`
|
|
req3 := `{"jsonrpc":"2.0","id":3,"method":"tools/list"}`
|
|
stdin := strings.NewReader(req1 + "\n" + req2 + "\n" + req3 + "\n")
|
|
|
|
server := &Server{
|
|
stdin: stdin,
|
|
stdout: &stdout,
|
|
version: "1.0.0",
|
|
}
|
|
|
|
err := server.Run(context.Background())
|
|
require.NoError(t, err)
|
|
|
|
output := stdout.String()
|
|
// Should have responses for all three requests
|
|
assert.Contains(t, output, `"id":1`)
|
|
assert.Contains(t, output, `"error"`) // Parse error for invalid json
|
|
assert.Contains(t, output, `"id":3`)
|
|
}
|
|
|
|
// TestToolCallParamsWithComplexArgs tests ToolCallParams with complex arguments.
|
|
func TestToolCallParamsWithComplexArgs(t *testing.T) {
|
|
input := `{
|
|
"name": "search",
|
|
"arguments": {
|
|
"query": "authentication bug",
|
|
"project": "my-project",
|
|
"limit": 10,
|
|
"type": "observations"
|
|
}
|
|
}`
|
|
|
|
var params ToolCallParams
|
|
err := json.Unmarshal([]byte(input), ¶ms)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "search", params.Name)
|
|
assert.NotEmpty(t, params.Arguments)
|
|
}
|
|
|
|
// TestCallTool_UnknownToolName tests callTool with various unknown tool names.
|
|
func TestCallTool_UnknownToolName(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
unknownTools := []string{
|
|
"invalid_tool",
|
|
"nonexistent",
|
|
"search_v2",
|
|
"timeline_special",
|
|
}
|
|
|
|
for _, name := range unknownTools {
|
|
t.Run(name, func(t *testing.T) {
|
|
result, err := server.callTool(ctx, name, json.RawMessage(`{}`))
|
|
assert.Error(t, err)
|
|
assert.Empty(t, result)
|
|
assert.Contains(t, err.Error(), "unknown tool")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestTimelineParams_Validation tests TimelineParams struct field validation.
|
|
func TestTimelineParams_Validation(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
json string
|
|
wantOK bool
|
|
}{
|
|
{"valid with anchor_id", `{"anchor_id":123,"before":5,"after":5}`, true},
|
|
{"valid with query only", `{"query":"test query"}`, true},
|
|
{"empty params", `{}`, true},
|
|
{"with all fields", `{"anchor_id":1,"query":"test","before":10,"after":10,"project":"proj","obs_type":"bugfix","format":"full"}`, true},
|
|
{"invalid json", `{invalid`, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var params TimelineParams
|
|
err := json.Unmarshal([]byte(tt.json), ¶ms)
|
|
if tt.wantOK {
|
|
assert.NoError(t, err)
|
|
} else {
|
|
assert.Error(t, err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestHandleToolsCall_UnknownToolNameError tests tools/call with unknown tool returns error.
|
|
func TestHandleToolsCall_UnknownToolNameError(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
req := &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "tools/call",
|
|
Params: json.RawMessage(`{"name":"very_unknown_tool_name","arguments":{}}`),
|
|
}
|
|
|
|
resp := server.handleToolsCall(ctx, req)
|
|
|
|
// Should get an error response
|
|
assert.Equal(t, "2.0", resp.JSONRPC)
|
|
assert.Equal(t, 1, resp.ID)
|
|
require.NotNil(t, resp.Error)
|
|
// Error is "Tool error" with message containing "unknown tool"
|
|
assert.True(t, resp.Error.Code != 0)
|
|
}
|
|
|
|
// TestHandleToolsCall_EmptyParams tests tools/call with empty params.
|
|
func TestHandleToolsCall_EmptyParams(t *testing.T) {
|
|
server := NewServer(nil, "1.0.0", nil, nil, nil, nil, nil, nil, nil)
|
|
ctx := context.Background()
|
|
|
|
req := &Request{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Method: "tools/call",
|
|
Params: json.RawMessage(`{}`),
|
|
}
|
|
|
|
resp := server.handleToolsCall(ctx, req)
|
|
|
|
// Should error due to missing name
|
|
require.NotNil(t, resp.Error)
|
|
}
|
|
|
|
// TestSendResponse_WithError tests sendResponse with an error response.
|
|
func TestSendResponse_WithError(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
server := &Server{stdout: &buf}
|
|
|
|
resp := &Response{
|
|
JSONRPC: "2.0",
|
|
ID: 1,
|
|
Error: &Error{Code: -32600, Message: "Invalid Request"},
|
|
}
|
|
|
|
server.sendResponse(resp)
|
|
|
|
output := buf.String()
|
|
assert.Contains(t, output, `"error"`)
|
|
assert.Contains(t, output, `-32600`)
|
|
}
|
|
|
|
// TestSendResponse_NilID tests sendResponse with nil ID.
|
|
func TestSendResponse_NilID(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
server := &Server{stdout: &buf}
|
|
|
|
resp := &Response{
|
|
JSONRPC: "2.0",
|
|
ID: nil,
|
|
Result: "notification response",
|
|
}
|
|
|
|
server.sendResponse(resp)
|
|
|
|
output := buf.String()
|
|
assert.Contains(t, output, `"id":null`)
|
|
}
|