From 8956f331bf6f45253425d0ee897f4a79c34db457 Mon Sep 17 00:00:00 2001 From: zhaoyilun Date: Sun, 5 Jul 2026 19:19:07 +0800 Subject: [PATCH] feat: add go helper client and mcp tools --- cmd/isphere-mcp/main.go | 22 +++ go.mod | 16 ++ go.sum | 20 ++ internal/helperclient/client.go | 222 ++++++++++++++++++++++ internal/helperclient/client_test.go | 266 +++++++++++++++++++++++++++ internal/helperclient/contract.go | 28 +++ internal/mcpserver/server.go | 24 +++ internal/mcpserver/server_test.go | 59 ++++++ internal/tools/winhelper.go | 122 ++++++++++++ internal/tools/winhelper_test.go | 235 +++++++++++++++++++++++ 10 files changed, 1014 insertions(+) create mode 100644 cmd/isphere-mcp/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/helperclient/client.go create mode 100644 internal/helperclient/client_test.go create mode 100644 internal/helperclient/contract.go create mode 100644 internal/mcpserver/server.go create mode 100644 internal/mcpserver/server_test.go create mode 100644 internal/tools/winhelper.go create mode 100644 internal/tools/winhelper_test.go diff --git a/cmd/isphere-mcp/main.go b/cmd/isphere-mcp/main.go new file mode 100644 index 0000000..3b32208 --- /dev/null +++ b/cmd/isphere-mcp/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "context" + "log" + "os" + "os/signal" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "isphere-ai-bridge/internal/mcpserver" +) + +func main() { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + server := mcpserver.NewServer() + if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil { + log.Fatalf("isphere mcp server stopped: %v", err) + } +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..6ca0c80 --- /dev/null +++ b/go.mod @@ -0,0 +1,16 @@ +module isphere-ai-bridge + +go 1.23.0 + +toolchain go1.23.4 + +require github.com/modelcontextprotocol/go-sdk v1.3.1 + +require ( + github.com/google/jsonschema-go v0.4.2 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.3 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.35.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..bf821b7 --- /dev/null +++ b/go.sum @@ -0,0 +1,20 @@ +github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8= +github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.2 h1:tmrUohrwoLZZS/P3x7ex0WAVknEkBZM46iALbcqoRA8= +github.com/google/jsonschema-go v0.4.2/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= +github.com/modelcontextprotocol/go-sdk v1.3.1 h1:TfqtNKOIWN4Z1oqmPAiWDC2Jq7K9OdJaooe0teoXASI= +github.com/modelcontextprotocol/go-sdk v1.3.1/go.mod h1:DgVX498dMD8UJlseK1S5i1T4tFz2fkBk4xogC3D15nw= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= diff --git a/internal/helperclient/client.go b/internal/helperclient/client.go new file mode 100644 index 0000000..25b0a53 --- /dev/null +++ b/internal/helperclient/client.go @@ -0,0 +1,222 @@ +package helperclient + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "time" +) + +const ( + ErrorCodeMissingHelper = "MISSING_HELPER" + ErrorCodeTimeout = "TIMEOUT" + ErrorCodeBadJSON = "BAD_JSON" + ErrorCodeProcessFailed = "PROCESS_FAILED" +) + +type Client struct { + HelperPath string + Timeout time.Duration +} + +type ClientError struct { + Code string + Message string + HelperPath string + Stderr string + Stdout string + ExitCode int + Cause error +} + +func (e *ClientError) Error() string { + if e == nil { + return "" + } + if e.HelperPath == "" { + return fmt.Sprintf("%s: %s", e.Code, e.Message) + } + return fmt.Sprintf("%s: %s: %s", e.Code, e.HelperPath, e.Message) +} + +func (e *ClientError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} + +func (c Client) Call(ctx context.Context, op string, args map[string]any) (*HelperResponse, error) { + if ctx == nil { + ctx = context.Background() + } + + helperPath := c.HelperPath + if helperPath == "" { + helperPath = defaultHelperPath() + } + + if err := ensureHelperFile(helperPath); err != nil { + return nil, &ClientError{ + Code: ErrorCodeMissingHelper, + Message: "helper executable is not available", + HelperPath: helperPath, + Cause: err, + } + } + + callCtx := ctx + var cancel context.CancelFunc + if c.Timeout > 0 { + callCtx, cancel = context.WithTimeout(ctx, c.Timeout) + defer cancel() + } + + if args == nil { + args = map[string]any{} + } + + request := HelperRequest{ + Protocol: HelperProtocolV1, + RequestID: newRequestID(), + Op: op, + TimeoutMS: timeoutMillis(callCtx), + Args: args, + } + requestBytes, err := json.Marshal(request) + if err != nil { + return nil, &ClientError{ + Code: ErrorCodeProcessFailed, + Message: "marshal helper request", + HelperPath: helperPath, + Cause: err, + } + } + + cmd := exec.CommandContext(callCtx, helperPath, "--json") + cmd.Stdin = bytes.NewReader(requestBytes) + var stdout bytes.Buffer + var stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + runErr := cmd.Run() + stderrText := stderr.String() + stdoutText := stdout.String() + if callCtx.Err() != nil { + return nil, &ClientError{ + Code: ErrorCodeTimeout, + Message: "helper process timed out", + HelperPath: helperPath, + Stderr: stderrText, + Stdout: stdoutText, + ExitCode: exitCode(runErr), + Cause: callCtx.Err(), + } + } + if runErr != nil { + return nil, &ClientError{ + Code: ErrorCodeProcessFailed, + Message: "helper process failed", + HelperPath: helperPath, + Stderr: stderrText, + Stdout: stdoutText, + ExitCode: exitCode(runErr), + Cause: runErr, + } + } + + var response HelperResponse + if err := json.Unmarshal(stdout.Bytes(), &response); err != nil { + return nil, &ClientError{ + Code: ErrorCodeBadJSON, + Message: "helper stdout was not valid response JSON", + HelperPath: helperPath, + Stderr: stderrText, + Stdout: stdoutText, + Cause: err, + } + } + + return &response, nil +} + +func ensureHelperFile(path string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + if info.IsDir() { + return fmt.Errorf("helper path is a directory") + } + return nil +} + +func defaultHelperPath() string { + return filepath.Join(repoRoot(), "runs", "win-helper", "ISphereWinHelper.exe") +} + +func repoRoot() string { + wd, err := os.Getwd() + if err != nil { + return "." + } + + dir := wd + for { + if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { + return dir + } + parent := filepath.Dir(dir) + if parent == dir { + return wd + } + dir = parent + } +} + +func timeoutMillis(ctx context.Context) int { + deadline, ok := ctx.Deadline() + if !ok { + return 0 + } + remaining := time.Until(deadline) + if remaining <= 0 { + return 1 + } + millis := remaining.Milliseconds() + if millis < 1 { + return 1 + } + if millis > int64(math.MaxInt) { + return math.MaxInt + } + return int(millis) +} + +func exitCode(err error) int { + if err == nil { + return 0 + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return exitErr.ExitCode() + } + return -1 +} + +func newRequestID() string { + var bytes [16]byte + if _, err := rand.Read(bytes[:]); err == nil { + return hex.EncodeToString(bytes[:]) + } + return fmt.Sprintf("req-%d", time.Now().UnixNano()) +} diff --git a/internal/helperclient/client_test.go b/internal/helperclient/client_test.go new file mode 100644 index 0000000..d9a27b6 --- /dev/null +++ b/internal/helperclient/client_test.go @@ -0,0 +1,266 @@ +package helperclient + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestContractVersionRequestMarshalsProtocol(t *testing.T) { + req := HelperRequest{ + Protocol: "isphere.helper.v1", + RequestID: "req-version-1", + Op: "version", + TimeoutMS: 5000, + Args: map[string]any{}, + } + + encoded, err := json.Marshal(req) + if err != nil { + t.Fatalf("marshal request: %v", err) + } + + var got map[string]any + if err := json.Unmarshal(encoded, &got); err != nil { + t.Fatalf("unmarshal request json: %v", err) + } + + if got["protocol"] != "isphere.helper.v1" { + t.Fatalf("protocol = %v, want isphere.helper.v1; json=%s", got["protocol"], encoded) + } + if got["request_id"] != "req-version-1" { + t.Fatalf("request_id = %v, want req-version-1", got["request_id"]) + } + if got["op"] != "version" { + t.Fatalf("op = %v, want version", got["op"]) + } + if _, ok := got["args"].(map[string]any); !ok { + t.Fatalf("args missing or not an object: %#v", got["args"]) + } +} + +func TestContractSuccessResponsePreservesDataFields(t *testing.T) { + payload := []byte(`{ + "protocol":"isphere.helper.v1", + "request_id":"req-version-1", + "op":"version", + "ok":true, + "data":{ + "helper_name":"ISphereWinHelper", + "extra_unknown":{"nested":true} + }, + "error":null, + "warnings":[] + }`) + + var resp HelperResponse + if err := json.Unmarshal(payload, &resp); err != nil { + t.Fatalf("unmarshal success response: %v", err) + } + + if !resp.OK { + t.Fatalf("ok = false, want true") + } + if resp.Error != nil { + t.Fatalf("error = %#v, want nil", resp.Error) + } + + var helperName string + if err := json.Unmarshal(resp.Data["helper_name"], &helperName); err != nil { + t.Fatalf("unmarshal helper_name from data: %v", err) + } + if helperName != "ISphereWinHelper" { + t.Fatalf("data.helper_name = %q, want ISphereWinHelper", helperName) + } + + if _, ok := resp.Data["extra_unknown"]; !ok { + t.Fatalf("unknown data field extra_unknown was not preserved: %#v", resp.Data) + } +} + +func TestContractErrorResponseUnmarshalsErrorCode(t *testing.T) { + payload := []byte(`{ + "protocol":"isphere.helper.v1", + "request_id":"req-scan-1", + "op":"scan_windows", + "ok":false, + "data":null, + "error":{ + "code":"WINDOW_NOT_FOUND", + "message":"no matching iSphere window found" + }, + "warnings":[] + }`) + + var resp HelperResponse + if err := json.Unmarshal(payload, &resp); err != nil { + t.Fatalf("unmarshal error response: %v", err) + } + + if resp.OK { + t.Fatalf("ok = true, want false") + } + if resp.Error == nil { + t.Fatalf("error = nil, want HelperError") + } + if resp.Error.Code != "WINDOW_NOT_FOUND" { + t.Fatalf("error.code = %q, want WINDOW_NOT_FOUND", resp.Error.Code) + } + if len(resp.Data) != 0 { + t.Fatalf("data = %#v, want nil or empty for JSON null", resp.Data) + } +} + +func TestClientMissingHelperPathReturnsStructuredError(t *testing.T) { + client := Client{HelperPath: filepath.Join(t.TempDir(), "missing-helper.exe")} + + resp, err := client.Call(context.Background(), "version", map[string]any{}) + if resp != nil { + t.Fatalf("response = %#v, want nil", resp) + } + + clientErr := requireClientError(t, err) + if clientErr.Code != ErrorCodeMissingHelper { + t.Fatalf("error code = %q, want %q", clientErr.Code, ErrorCodeMissingHelper) + } + if clientErr.HelperPath != client.HelperPath { + t.Fatalf("helper path = %q, want %q", clientErr.HelperPath, client.HelperPath) + } +} + +func TestClientBadHelperOutputReturnsStructuredError(t *testing.T) { + stub := buildHelperStub(t, `package main +import "fmt" +func main() { fmt.Println("not-json") } +`) + client := Client{HelperPath: stub} + + resp, err := client.Call(context.Background(), "version", map[string]any{}) + if resp != nil { + t.Fatalf("response = %#v, want nil", resp) + } + + clientErr := requireClientError(t, err) + if clientErr.Code != ErrorCodeBadJSON { + t.Fatalf("error code = %q, want %q", clientErr.Code, ErrorCodeBadJSON) + } +} + +func TestClientTimeoutReturnsStructuredError(t *testing.T) { + stub := buildHelperStub(t, `package main +import "time" +func main() { time.Sleep(10 * time.Second) } +`) + client := Client{HelperPath: stub} + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + + started := time.Now() + resp, err := client.Call(ctx, "version", map[string]any{}) + elapsed := time.Since(started) + if resp != nil { + t.Fatalf("response = %#v, want nil", resp) + } + if elapsed > 3*time.Second { + t.Fatalf("timeout call took %s, want process terminated promptly", elapsed) + } + + clientErr := requireClientError(t, err) + if clientErr.Code != ErrorCodeTimeout { + t.Fatalf("error code = %q, want %q", clientErr.Code, ErrorCodeTimeout) + } +} + +func TestClientNonZeroExitCapturesStderrAsStructuredError(t *testing.T) { + stub := buildHelperStub(t, `package main +import ( + "fmt" + "os" +) +func main() { + fmt.Fprintln(os.Stderr, "stub stderr boom") + os.Exit(7) +} +`) + client := Client{HelperPath: stub} + + resp, err := client.Call(context.Background(), "version", map[string]any{}) + if resp != nil { + t.Fatalf("response = %#v, want nil", resp) + } + + clientErr := requireClientError(t, err) + if clientErr.Code != ErrorCodeProcessFailed { + t.Fatalf("error code = %q, want %q", clientErr.Code, ErrorCodeProcessFailed) + } + if clientErr.ExitCode != 7 { + t.Fatalf("exit code = %d, want 7", clientErr.ExitCode) + } + if !strings.Contains(clientErr.Stderr, "stub stderr boom") { + t.Fatalf("stderr = %q, want stub stderr boom", clientErr.Stderr) + } +} + +func TestClientRealVersionCallWhenHelperIsBuilt(t *testing.T) { + helperPath := defaultHelperPath() + if _, err := os.Stat(helperPath); err != nil { + if os.IsNotExist(err) { + t.Skipf("real helper is not built at %s", helperPath) + } + t.Fatalf("stat real helper: %v", err) + } + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + resp, err := (Client{}).Call(ctx, "version", map[string]any{}) + if err != nil { + t.Fatalf("real version call returned error: %v", err) + } + if resp == nil || !resp.OK { + t.Fatalf("real version response = %#v, want ok=true", resp) + } + + var helperName string + if err := json.Unmarshal(resp.Data["helper_name"], &helperName); err != nil { + t.Fatalf("unmarshal real helper_name: %v", err) + } + if helperName != "ISphereWinHelper" { + t.Fatalf("real data.helper_name = %q, want ISphereWinHelper", helperName) + } + t.Logf("real helper version ok: helper_name=%s path=%s", helperName, helperPath) +} + +func requireClientError(t *testing.T, err error) *ClientError { + t.Helper() + if err == nil { + t.Fatalf("error = nil, want *ClientError") + } + var clientErr *ClientError + if !errors.As(err, &clientErr) { + t.Fatalf("error type = %T, want *ClientError: %v", err, err) + } + return clientErr +} + +func buildHelperStub(t *testing.T, source string) string { + t.Helper() + dir := t.TempDir() + sourcePath := filepath.Join(dir, "main.go") + if err := os.WriteFile(sourcePath, []byte(source), 0600); err != nil { + t.Fatalf("write stub source: %v", err) + } + + exePath := filepath.Join(dir, "helper-stub.exe") + cmd := exec.Command("go", "build", "-o", exePath, sourcePath) + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("build stub helper: %v\n%s", err, output) + } + return exePath +} diff --git a/internal/helperclient/contract.go b/internal/helperclient/contract.go new file mode 100644 index 0000000..bfaa386 --- /dev/null +++ b/internal/helperclient/contract.go @@ -0,0 +1,28 @@ +package helperclient + +import "encoding/json" + +const HelperProtocolV1 = "isphere.helper.v1" + +type HelperRequest struct { + Protocol string `json:"protocol"` + RequestID string `json:"request_id"` + Op string `json:"op"` + TimeoutMS int `json:"timeout_ms"` + Args map[string]any `json:"args"` +} + +type HelperResponse struct { + Protocol string `json:"protocol"` + RequestID string `json:"request_id"` + Op string `json:"op"` + OK bool `json:"ok"` + Data map[string]json.RawMessage `json:"data"` + Error *HelperError `json:"error"` + Warnings []string `json:"warnings"` +} + +type HelperError struct { + Code string `json:"code"` + Message string `json:"message"` +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..4ef98ff --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,24 @@ +package mcpserver + +import ( + "github.com/modelcontextprotocol/go-sdk/mcp" + + "isphere-ai-bridge/internal/helperclient" + "isphere-ai-bridge/internal/tools" +) + +const ( + ServerName = "isphere-ai-bridge" + ServerTitle = "iSphere AI Bridge" + ServerVersion = "0.1.0" +) + +func NewServer() *mcp.Server { + server := mcp.NewServer(&mcp.Implementation{ + Name: ServerName, + Title: ServerTitle, + Version: ServerVersion, + }, nil) + tools.RegisterWinHelperTools(server, helperclient.Client{}) + return server +} diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..9e5ffe2 --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,59 @@ +package mcpserver + +import ( + "context" + "reflect" + "sort" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestNewServerConstructsMCPServer(t *testing.T) { + var server *mcp.Server = NewServer() + if server == nil { + t.Fatal("NewServer() returned nil") + } +} + +func TestNewServerRegistersN8WinHelperToolsWithoutCallingHelper(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + server := NewServer() + + errCh := make(chan error, 1) + go func() { + errCh <- server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "mcpserver-test-client", Version: "0.0.0"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + cancel() + t.Fatalf("connect client: %v", err) + } + defer func() { + _ = session.Close() + cancel() + select { + case <-errCh: + case <-time.After(2 * time.Second): + t.Fatalf("server did not stop") + } + }() + + result, err := session.ListTools(context.Background(), &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("list tools: %v", err) + } + gotNames := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + gotNames = append(gotNames, tool.Name) + } + sort.Strings(gotNames) + wantNames := []string{"win_helper_dump_uia", "win_helper_scan_windows", "win_helper_self_check", "win_helper_version"} + if !reflect.DeepEqual(gotNames, wantNames) { + t.Fatalf("server tool names = %#v, want %#v", gotNames, wantNames) + } +} diff --git a/internal/tools/winhelper.go b/internal/tools/winhelper.go new file mode 100644 index 0000000..d51c321 --- /dev/null +++ b/internal/tools/winhelper.go @@ -0,0 +1,122 @@ +package tools + +import ( + "context" + "encoding/json" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "isphere-ai-bridge/internal/helperclient" +) + +const ( + ToolNameVersion = "win_helper_version" + ToolNameSelfCheck = "win_helper_self_check" + ToolNameScanWindows = "win_helper_scan_windows" + ToolNameDumpUIA = "win_helper_dump_uia" +) + +type HelperCaller interface { + Call(ctx context.Context, op string, args map[string]any) (*helperclient.HelperResponse, error) +} + +type VersionArgs struct{} + +type SelfCheckArgs struct{} + +type ScanWindowsArgs struct { + IncludeAllVisible bool `json:"include_all_visible,omitempty" jsonschema:"include all visible desktop windows, not only likely iSphere candidates"` +} + +type DumpUIAArgs struct { + Hwnd string `json:"hwnd,omitempty" jsonschema:"window handle to dump, for example 0x001A0B2C"` + MaxDepth int `json:"max_depth,omitempty" jsonschema:"maximum UI Automation tree depth"` + IncludeText bool `json:"include_text,omitempty" jsonschema:"include text values when available"` + MaxChildren int `json:"max_children,omitempty" jsonschema:"maximum children per node"` +} + +func RegisterWinHelperTools(server *mcp.Server, caller HelperCaller) { + if caller == nil { + caller = helperclient.Client{} + } + + mcp.AddTool[VersionArgs, map[string]any](server, &mcp.Tool{ + Name: ToolNameVersion, + Description: "Return ISphereWinHelper version and protocol metadata.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ VersionArgs) (*mcp.CallToolResult, map[string]any, error) { + return callHelper(ctx, caller, "version", map[string]any{}) + }) + + mcp.AddTool[SelfCheckArgs, map[string]any](server, &mcp.Tool{ + Name: ToolNameSelfCheck, + Description: "Run ISphereWinHelper read-only desktop and UI Automation availability checks.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, _ SelfCheckArgs) (*mcp.CallToolResult, map[string]any, error) { + return callHelper(ctx, caller, "self_check", map[string]any{}) + }) + + mcp.AddTool[ScanWindowsArgs, map[string]any](server, &mcp.Tool{ + Name: ToolNameScanWindows, + Description: "List visible desktop windows or likely iSphere window candidates in read-only mode.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, input ScanWindowsArgs) (*mcp.CallToolResult, map[string]any, error) { + return callHelper(ctx, caller, "scan_windows", map[string]any{ + "include_all_visible": input.IncludeAllVisible, + }) + }) + + mcp.AddTool[DumpUIAArgs, map[string]any](server, &mcp.Tool{ + Name: ToolNameDumpUIA, + Description: "Dump a read-only UI Automation tree for a specified window handle.", + }, func(ctx context.Context, _ *mcp.CallToolRequest, input DumpUIAArgs) (*mcp.CallToolResult, map[string]any, error) { + return callHelper(ctx, caller, "dump_uia", map[string]any{ + "hwnd": input.Hwnd, + "max_depth": float64(input.MaxDepth), + "include_text": input.IncludeText, + "max_children": float64(input.MaxChildren), + }) + }) +} + +func callHelper(ctx context.Context, caller HelperCaller, op string, args map[string]any) (*mcp.CallToolResult, map[string]any, error) { + response, err := caller.Call(ctx, op, args) + if err != nil { + return nil, nil, err + } + return nil, helperResponseToMap(response), nil +} + +func helperResponseToMap(response *helperclient.HelperResponse) map[string]any { + if response == nil { + return map[string]any{"ok": false} + } + + out := map[string]any{ + "protocol": response.Protocol, + "request_id": response.RequestID, + "op": response.Op, + "ok": response.OK, + "data": decodeRawData(response.Data), + "warnings": response.Warnings, + } + if response.Error != nil { + out["error"] = map[string]any{ + "code": response.Error.Code, + "message": response.Error.Message, + } + } else { + out["error"] = nil + } + return out +} + +func decodeRawData(data map[string]json.RawMessage) map[string]any { + decoded := map[string]any{} + for key, raw := range data { + var value any + if err := json.Unmarshal(raw, &value); err != nil { + decoded[key] = string(raw) + continue + } + decoded[key] = value + } + return decoded +} diff --git a/internal/tools/winhelper_test.go b/internal/tools/winhelper_test.go new file mode 100644 index 0000000..54c5bf9 --- /dev/null +++ b/internal/tools/winhelper_test.go @@ -0,0 +1,235 @@ +package tools + +import ( + "context" + "encoding/json" + "reflect" + "sort" + "strings" + "testing" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "isphere-ai-bridge/internal/helperclient" +) + +var expectedWinHelperToolNames = []string{ + "win_helper_version", + "win_helper_self_check", + "win_helper_scan_windows", + "win_helper_dump_uia", +} + +func TestWinHelperToolsRegisterExactlyFourReadOnlyTools(t *testing.T) { + fake := &fakeHelperCaller{} + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterWinHelperTools(server, fake) + }) + defer cleanup() + + result, err := session.ListTools(context.Background(), &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("list tools: %v", err) + } + + gotNames := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + gotNames = append(gotNames, tool.Name) + lower := strings.ToLower(tool.Name) + forbidden := []string{"send", "search", "file", "login", "conversation", "upload", "download", "receive"} + for _, word := range forbidden { + if strings.Contains(lower, word) { + t.Fatalf("tool %q contains forbidden action word %q", tool.Name, word) + } + } + } + sort.Strings(gotNames) + wantNames := append([]string(nil), expectedWinHelperToolNames...) + sort.Strings(wantNames) + if !reflect.DeepEqual(gotNames, wantNames) { + t.Fatalf("registered tool names = %#v, want %#v", gotNames, wantNames) + } +} + +func TestWinHelperToolSchemasExposeOnlyAllowedParameters(t *testing.T) { + assertJSONFields[VersionArgs](t) + assertJSONFields[SelfCheckArgs](t) + assertJSONFields[ScanWindowsArgs](t, "include_all_visible") + assertJSONFields[DumpUIAArgs](t, "hwnd", "max_depth", "include_text", "max_children") + + fake := &fakeHelperCaller{} + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterWinHelperTools(server, fake) + }) + defer cleanup() + + result, err := session.ListTools(context.Background(), &mcp.ListToolsParams{}) + if err != nil { + t.Fatalf("list tools: %v", err) + } + schemas := map[string][]string{} + for _, tool := range result.Tools { + schemas[tool.Name] = schemaPropertyNames(t, tool.InputSchema) + } + + assertSameStrings(t, schemas["win_helper_version"], nil) + assertSameStrings(t, schemas["win_helper_self_check"], nil) + assertSameStrings(t, schemas["win_helper_scan_windows"], []string{"include_all_visible"}) + assertSameStrings(t, schemas["win_helper_dump_uia"], []string{"hwnd", "max_depth", "include_text", "max_children"}) +} + +func TestWinHelperHandlersCallHelperClientAbstractionWithCorrectOps(t *testing.T) { + fake := &fakeHelperCaller{} + session, cleanup := connectToolsTestSession(t, func(server *mcp.Server) { + RegisterWinHelperTools(server, fake) + }) + defer cleanup() + + calls := []struct { + tool string + args map[string]any + }{ + {tool: "win_helper_version", args: map[string]any{}}, + {tool: "win_helper_self_check", args: map[string]any{}}, + {tool: "win_helper_scan_windows", args: map[string]any{"include_all_visible": true}}, + {tool: "win_helper_dump_uia", args: map[string]any{"hwnd": "0x001A0B2C", "max_depth": 3, "include_text": true, "max_children": 25}}, + } + + for _, call := range calls { + if _, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: call.tool, Arguments: call.args}); err != nil { + t.Fatalf("call %s: %v", call.tool, err) + } + } + + wantOps := []string{"version", "self_check", "scan_windows", "dump_uia"} + if len(fake.calls) != len(wantOps) { + t.Fatalf("helper calls = %#v, want %d calls", fake.calls, len(wantOps)) + } + for i, wantOp := range wantOps { + if fake.calls[i].op != wantOp { + t.Fatalf("call %d op = %q, want %q", i, fake.calls[i].op, wantOp) + } + } + assertSameMap(t, fake.calls[0].args, map[string]any{}) + assertSameMap(t, fake.calls[1].args, map[string]any{}) + assertSameMap(t, fake.calls[2].args, map[string]any{"include_all_visible": true}) + assertSameMap(t, fake.calls[3].args, map[string]any{"hwnd": "0x001A0B2C", "max_depth": float64(3), "include_text": true, "max_children": float64(25)}) +} + +type fakeHelperCaller struct { + calls []helperCall +} + +type helperCall struct { + op string + args map[string]any +} + +func (f *fakeHelperCaller) Call(_ context.Context, op string, args map[string]any) (*helperclient.HelperResponse, error) { + copied := map[string]any{} + for k, v := range args { + copied[k] = v + } + f.calls = append(f.calls, helperCall{op: op, args: copied}) + return &helperclient.HelperResponse{ + Protocol: helperclient.HelperProtocolV1, + RequestID: "fake-request", + Op: op, + OK: true, + Data: map[string]json.RawMessage{ + "helper_name": json.RawMessage(`"ISphereWinHelper"`), + }, + Warnings: []string{}, + }, nil +} + +func connectToolsTestSession(t *testing.T, register func(*mcp.Server)) (*mcp.ClientSession, func()) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + serverTransport, clientTransport := mcp.NewInMemoryTransports() + server := mcp.NewServer(&mcp.Implementation{Name: "tools-test-server", Version: "0.0.0"}, nil) + register(server) + + errCh := make(chan error, 1) + go func() { + errCh <- server.Run(ctx, serverTransport) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "tools-test-client", Version: "0.0.0"}, nil) + session, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + cancel() + t.Fatalf("connect client: %v", err) + } + + cleanup := func() { + _ = session.Close() + cancel() + select { + case <-errCh: + case <-time.After(2 * time.Second): + t.Fatalf("server did not stop") + } + } + return session, cleanup +} + +func assertJSONFields[T any](t *testing.T, want ...string) { + t.Helper() + var zero T + typeOf := reflect.TypeOf(zero) + if typeOf.Kind() != reflect.Struct { + t.Fatalf("%T is %s, want struct", zero, typeOf.Kind()) + } + got := make([]string, 0, typeOf.NumField()) + for i := 0; i < typeOf.NumField(); i++ { + field := typeOf.Field(i) + name := strings.Split(field.Tag.Get("json"), ",")[0] + if name == "" || name == "-" { + continue + } + got = append(got, name) + } + assertSameStrings(t, got, want) +} + +func schemaPropertyNames(t *testing.T, schema any) []string { + t.Helper() + payload, err := json.Marshal(schema) + if err != nil { + t.Fatalf("marshal schema: %v", err) + } + var decoded map[string]any + if err := json.Unmarshal(payload, &decoded); err != nil { + t.Fatalf("unmarshal schema %s: %v", payload, err) + } + properties, _ := decoded["properties"].(map[string]any) + if len(properties) == 0 { + return nil + } + names := make([]string, 0, len(properties)) + for name := range properties { + names = append(names, name) + } + sort.Strings(names) + return names +} + +func assertSameStrings(t *testing.T, got []string, want []string) { + t.Helper() + gotCopy := append([]string(nil), got...) + wantCopy := append([]string(nil), want...) + sort.Strings(gotCopy) + sort.Strings(wantCopy) + if !reflect.DeepEqual(gotCopy, wantCopy) { + t.Fatalf("strings = %#v, want %#v", gotCopy, wantCopy) + } +} + +func assertSameMap(t *testing.T, got map[string]any, want map[string]any) { + t.Helper() + if !reflect.DeepEqual(got, want) { + t.Fatalf("map = %#v, want %#v", got, want) + } +}