feat: add go helper client and mcp tools

This commit is contained in:
zhaoyilun
2026-07-05 19:19:07 +08:00
parent 63cda718ba
commit 8956f331bf
10 changed files with 1014 additions and 0 deletions

View File

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

View File

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

View File

@@ -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"`
}