feat: add go helper client and mcp tools
This commit is contained in:
22
cmd/isphere-mcp/main.go
Normal file
22
cmd/isphere-mcp/main.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
16
go.mod
Normal file
16
go.mod
Normal file
@@ -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
|
||||||
|
)
|
||||||
20
go.sum
Normal file
20
go.sum
Normal file
@@ -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=
|
||||||
222
internal/helperclient/client.go
Normal file
222
internal/helperclient/client.go
Normal 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())
|
||||||
|
}
|
||||||
266
internal/helperclient/client_test.go
Normal file
266
internal/helperclient/client_test.go
Normal 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
|
||||||
|
}
|
||||||
28
internal/helperclient/contract.go
Normal file
28
internal/helperclient/contract.go
Normal 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"`
|
||||||
|
}
|
||||||
24
internal/mcpserver/server.go
Normal file
24
internal/mcpserver/server.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
59
internal/mcpserver/server_test.go
Normal file
59
internal/mcpserver/server_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
122
internal/tools/winhelper.go
Normal file
122
internal/tools/winhelper.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
235
internal/tools/winhelper_test.go
Normal file
235
internal/tools/winhelper_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user