diff --git a/.goreleaser.yml b/.goreleaser.yml index 43ae194..615990c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -64,7 +64,7 @@ release: prerelease: auto mode: replace header: | - {{ .ProjectName }} {{ .Tag }} is a cross-platform terminal system monitor with JSON output, a web dashboard, network diagnostics, and embeddable Go APIs. + {{ .ProjectName }} {{ .Tag }} is a cross-platform terminal system monitor with JSON output, a web dashboard, a read-only local MCP server, network diagnostics, and embeddable Go APIs. ## Changes footer: | @@ -84,13 +84,13 @@ release: Download archives and Linux packages from the assets below. Verify downloads against `checksums.txt` before installation. - [Documentation](https://vminfo.bestcheapvps.org) · [Quick start](https://vminfo.bestcheapvps.org/guide/quick-start) · [Command reference](https://vminfo.bestcheapvps.org/commands/) · [Compare releases](https://github.com/cloudapp3/vminfo/releases) + [Documentation](https://vminfo.bestcheapvps.org) · [MCP setup](https://vminfo.bestcheapvps.org/commands/mcp) · [Quick start](https://vminfo.bestcheapvps.org/guide/quick-start) · [Command reference](https://vminfo.bestcheapvps.org/commands/) · [Compare releases](https://github.com/cloudapp3/vminfo/releases) nfpms: - id: packages package_name: vminfo maintainer: cloudapp3 - description: Terminal system monitor with JSON output and a web dashboard + description: Terminal system monitor with JSON, web dashboard, and read-only MCP tools homepage: https://vminfo.bestcheapvps.org license: MIT formats: diff --git a/README.md b/README.md index c091337..57081ef 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# vminfo - cross-platform terminal system monitor, web dashboard, and Go library +# vminfo - cross-platform terminal system monitor, web dashboard, MCP server, and Go library -> A single-binary system monitoring toolkit for Linux, macOS, and Windows. Inspect CPU, memory, disk, network, and load in a live terminal UI, export JSON for automation, open a browser dashboard, or embed host metrics in Go. No background agent or configuration is required for local monitoring. +> A single-binary system monitoring toolkit for Linux, macOS, and Windows. Inspect CPU, memory, disk, network, and load in a live terminal UI, export JSON for automation, open a browser dashboard, connect an MCP client, or embed host metrics in Go. No background agent or configuration is required for local monitoring. [![CI](https://github.com/cloudapp3/vminfo/actions/workflows/ci.yml/badge.svg)](https://github.com/cloudapp3/vminfo/actions/workflows/ci.yml) [![Latest release](https://img.shields.io/github/v/release/cloudapp3/vminfo?display_name=tag)](https://github.com/cloudapp3/vminfo/releases/latest) @@ -8,9 +8,9 @@ [![Go Reference](https://pkg.go.dev/badge/github.com/cloudapp3/vminfo.svg)](https://pkg.go.dev/github.com/cloudapp3/vminfo) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -Documentation: [vminfo documentation](https://vminfo.bestcheapvps.org) · [中文说明](https://vminfo.bestcheapvps.org/zh/) · [HTTP API reference](https://vminfo.bestcheapvps.org/api) · [Docs source](https://github.com/cloudapp3/vmdocs) +Documentation: [vminfo documentation](https://vminfo.bestcheapvps.org) · [中文说明](https://vminfo.bestcheapvps.org/zh/) · [MCP server](https://vminfo.bestcheapvps.org/commands/mcp) · [HTTP API reference](https://vminfo.bestcheapvps.org/api) · [Docs source](https://github.com/cloudapp3/vmdocs) -[Preview](#preview) · [Quick start](#quick-start) · [Why vminfo](#why-vminfo) · [Commands](#commands) · [Platform support](#platform-support) · [FAQ](#faq) · [Contributing](#contributing) +[Preview](#preview) · [Quick start](#quick-start) · [Why vminfo](#why-vminfo) · [Commands](#commands) · [MCP](#mcp-server) · [Platform support](#platform-support) · [FAQ](#faq) · [Contributing](#contributing) ## Preview @@ -74,13 +74,15 @@ Use vminfo when you need to: - inspect and manage Linux processes without switching tools - export stable JSON snapshots or JSON Lines for scripts, CI, and automation - open a lightweight browser dashboard with `vminfo --web` +- let a local MCP client inspect host state and run bounded network diagnostics - embed host metrics collection or the TUI into your own Go tools -The same binary provides four interfaces: +The same binary provides five interfaces: - **Terminal UI** - full-screen, live-updating overview and process views - **JSON and text CLI** - one-shot or streaming output for automation - **Web dashboard** - browser UI with REST and WebSocket endpoints +- **MCP server** - read-only stdio tools for local AI clients - **Go library** - public collection APIs plus an embeddable TUI package Collected metrics include CPU per core, memory, swap, disk, disk I/O, network, load, TCP/UDP counts, TCP state distribution, conntrack usage, interface rates, processes, temperatures, uptime, and host metadata. @@ -162,6 +164,7 @@ vminfo net ping vminfo.bestcheapvps.org --tcp-port 443 # TCP ping (default; cr vminfo net ping vminfo.bestcheapvps.org --mode icmp # real ICMP ping (needs privileges) vminfo net ip # your public IP + ASN / geo vminfo net ip 8.8.8.8 # lookup a specific IP +vminfo mcp # read-only MCP server over stdio vminfo update # check + install the latest tagged release vminfo update --check # check without installing vminfo update --version vX.Y.Z @@ -175,6 +178,59 @@ Built-in languages: `en`, `zh`, `de`, `es`, `fr`, `ja`, `ko`, `pt`, `ru`. are accepted. CLI ping count is limited to 1-100 and probe timeouts must be positive and no greater than 10 seconds. +## MCP server + +`vminfo mcp` starts a foreground, tools-only MCP server over stdio. It does not +listen on a network port, run the web dashboard, or perform background update +checks. The process exits when its MCP client disconnects. + +Available tools: + +| Tool | Purpose | +| --- | --- | +| `get_system_snapshot` | Current host, CPU, memory, disk, network, load, process-count, and health data | +| `list_processes` | Filtered and sorted Linux process data, limited to 200 results | +| `resolve_dns` | DNS lookup with the system or a selected resolver | +| `check_port` | Bounded TCP connectivity and latency check | +| `ping_host` | Bounded TCP or ICMP reachability probes | +| `lookup_ip` | Public IP / ASN / geo lookup through `ip.bestcheapvps.org` | +| `get_version` | Version, build, repository, and schema metadata | + +Claude Desktop configuration: + +```json +{ + "mcpServers": { + "vminfo": { + "command": "/usr/local/bin/vminfo", + "args": ["mcp"] + } + } +} +``` + +Codex configuration: + +```toml +[mcp_servers.vminfo] +command = "/usr/local/bin/vminfo" +args = ["mcp"] +``` + +Example prompt after connecting an MCP client: + +> Check this host's CPU, memory, disk, and network health, then list the five +> processes using the most memory. Do not make any changes. + +The MCP server is read-only: it does not expose `kill`, `update`, shell +execution, file writes, prompts, or resources. Process command lines are hidden +unless a call explicitly sets `include_command` to `true`; command arguments +can contain credentials or other secrets. While command lines are hidden, the +process filter does not search them. Snapshot and process results are sent to +the model configured by the MCP client. Network tools contact the requested +target, and `lookup_ip` makes an explicit outbound request to +`ip.bestcheapvps.org`. + ## Platform support | Capability | Linux | macOS | Windows | @@ -182,11 +238,12 @@ positive and no greater than 10 seconds. | `summary` / `watch` | ✅ | ✅ | ✅ | | TUI | ✅ | ✅ | ✅ | | Web dashboard | ✅ | ✅ | ✅ | +| MCP server | ✅ | ✅ | ✅ | | `ps` / `kill` | ✅ | ⚠️ stub | ⚠️ stub | | `update --check` | ✅ | ✅ | ✅ | | `update` install | ✅ | ✅ | ⚠️ check-only | -TUI requires a real TTY. `ps` and `kill` are Linux-only by design. +TUI requires a real TTY. `ps`, `kill`, and the MCP `list_processes` tool are Linux-only by design. ## Web dashboard @@ -331,7 +388,7 @@ Status badges: `LIVE` · `PAUSED` · `LOADING` · `ERROR` · `STALE` ### Does vminfo require a daemon or configuration file? -No background service or configuration file is required for the TUI, `summary`, `watch`, or network diagnostics. Web mode starts a foreground HTTP server only when you request `vminfo --web`. +No background service or configuration file is required for the TUI, `summary`, `watch`, or network diagnostics. Web mode starts a foreground HTTP server only when you request `vminfo --web`; MCP mode starts a foreground stdio server only when an MCP client runs `vminfo mcp`. ### Does vminfo require root privileges? @@ -339,7 +396,7 @@ Normal monitoring commands do not require root. Installing into a protected dire ### Which features work on Windows and macOS? -The TUI, `summary`, `watch`, web dashboard, and update checks are cross-platform. `ps` and `kill` are Linux-only, and Windows self-update is currently check-only. See [Platform support](#platform-support). +The TUI, `summary`, `watch`, web dashboard, MCP server, and update checks are cross-platform. `ps`, `kill`, and the MCP process-list tool are Linux-only, and Windows self-update is currently check-only. See [Platform support](#platform-support). ### Can I use vminfo in scripts and CI? diff --git a/go.mod b/go.mod index adbde2b..ca9f6c8 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/gorilla/websocket v1.5.3 + github.com/modelcontextprotocol/go-sdk v1.6.1 github.com/shirou/gopsutil/v3 v3.24.5 github.com/tklauser/go-sysconf v0.3.12 golang.org/x/net v0.56.0 @@ -24,6 +25,7 @@ require ( github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -34,10 +36,14 @@ require ( github.com/muesli/termenv v0.16.0 // indirect github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect github.com/rivo/uniseg v0.4.7 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/tklauser/numcpus v0.6.1 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect + golang.org/x/oauth2 v0.35.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index cccbb5d..0a5c277 100644 --- a/go.sum +++ b/go.sum @@ -28,9 +28,13 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +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.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= @@ -43,6 +47,8 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/modelcontextprotocol/go-sdk v1.6.1 h1:0zOSupjKUxPKSocPT1Wtago+mUHU2/uZ4xSOY0FGReU= +github.com/modelcontextprotocol/go-sdk v1.6.1/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= @@ -55,6 +61,10 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +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.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= @@ -69,12 +79,16 @@ github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+F github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +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= github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= golang.org/x/exp v0.0.0-20231006140011-7918f672742d/go.mod h1:ldy0pHrwJyGW56pPQzzkH36rKxoZW1tw7ZJpeKx+hdo= golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -85,6 +99,8 @@ golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/mcp.go b/internal/app/mcp.go new file mode 100644 index 0000000..e70a442 --- /dev/null +++ b/internal/app/mcp.go @@ -0,0 +1,34 @@ +package app + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/cloudapp3/vminfo/internal/i18n" + "github.com/cloudapp3/vminfo/internal/mcpserver" +) + +func runMCP(ctx context.Context, stdin io.Reader, stdout io.Writer, args []string, tr *i18n.Translator) error { + if len(args) == 1 && isHelpAlias(args[0]) { + _, err := io.WriteString(stdout, mcpHelpText(tr)) + return err + } + if len(args) != 0 { + return fmt.Errorf("%w: mcp does not accept arguments: %s", ErrUsage, strings.Join(args, " ")) + } + if err := mcpserver.RunStdio(ctx, stdin, stdout); err != nil { + return fmt.Errorf("MCP server error: %w", err) + } + return nil +} + +func mcpHelpText(tr *i18n.Translator) string { + return strings.Join([]string{ + tr.T("Usage:"), + " vminfo mcp " + tr.T("start read-only MCP server over stdio"), + "", + tr.T("MCP mode reserves stdout for protocol messages and stops when the client disconnects."), + }, "\n") + "\n" +} diff --git a/internal/app/mcp_test.go b/internal/app/mcp_test.go new file mode 100644 index 0000000..3f24be9 --- /dev/null +++ b/internal/app/mcp_test.go @@ -0,0 +1,115 @@ +package app + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "testing" + "time" + + "github.com/cloudapp3/vminfo/internal/i18n" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestRunMCPRejectsArguments(t *testing.T) { + err := runMCP(context.Background(), strings.NewReader(""), &bytes.Buffer{}, []string{"unexpected"}, i18n.New("en")) + if !errors.Is(err, ErrUsage) { + t.Fatalf("runMCP error = %v, want ErrUsage", err) + } +} + +func TestRunMCPHelp(t *testing.T) { + var stdout bytes.Buffer + if err := runMCP(context.Background(), strings.NewReader(""), &stdout, []string{"--help"}, i18n.New("en")); err != nil { + t.Fatalf("runMCP returned error: %v", err) + } + if !strings.Contains(stdout.String(), "vminfo mcp") || !strings.Contains(stdout.String(), "stdio") { + t.Fatalf("unexpected MCP help: %q", stdout.String()) + } +} + +func TestRunWithIOMCPStopsOnEOF(t *testing.T) { + var stdout, stderr bytes.Buffer + if err := RunWithIO(context.Background(), []string{"mcp"}, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("RunWithIO returned error: %v", err) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Fatalf("MCP EOF output stdout=%q stderr=%q", stdout.String(), stderr.String()) + } +} + +func TestRunWithIOMCPProtocolRoundTrip(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + clientReader, serverWriter := io.Pipe() + serverReader, clientWriter := io.Pipe() + defer func() { + _ = clientReader.Close() + _ = serverWriter.Close() + _ = serverReader.Close() + _ = clientWriter.Close() + }() + + var stderr bytes.Buffer + serverErr := make(chan error, 1) + go func() { + serverErr <- RunWithIO(ctx, []string{"mcp"}, serverReader, serverWriter, &stderr) + }() + + client := mcp.NewClient(&mcp.Implementation{Name: "vminfo-app-test", Version: "v1"}, nil) + session, err := client.Connect(ctx, &mcp.IOTransport{ + Reader: clientReader, + Writer: clientWriter, + }, nil) + if err != nil { + t.Fatalf("connect MCP client: %v", err) + } + + tools, err := session.ListTools(ctx, nil) + if err != nil { + _ = session.Close() + t.Fatalf("list MCP tools: %v", err) + } + got := make(map[string]bool, len(tools.Tools)) + for _, tool := range tools.Tools { + got[tool.Name] = true + } + for _, name := range []string{ + "get_system_snapshot", + "list_processes", + "resolve_dns", + "check_port", + "ping_host", + "lookup_ip", + "get_version", + } { + if !got[name] { + _ = session.Close() + t.Fatalf("MCP tool %q is not advertised: %v", name, got) + } + } + + if err := session.Close(); err != nil { + t.Fatalf("close MCP client: %v", err) + } + select { + case err := <-serverErr: + if err != nil { + t.Fatalf("RunWithIO returned error: %v", err) + } + case <-ctx.Done(): + t.Fatalf("MCP server did not stop after client close: %v", ctx.Err()) + } + if stderr.Len() != 0 { + t.Fatalf("MCP wrote diagnostics to stderr: %q", stderr.String()) + } +} + +func TestHelpTextIncludesMCP(t *testing.T) { + if got := helpText(i18n.New("en")); !strings.Contains(got, "vminfo mcp") { + t.Fatalf("help text does not include MCP command: %q", got) + } +} diff --git a/internal/app/root.go b/internal/app/root.go index af197fa..6dcd10e 100644 --- a/internal/app/root.go +++ b/internal/app/root.go @@ -289,6 +289,12 @@ func startBackgroundUpdateCheck(ctx context.Context, stderr io.Writer, tr *i18n. } func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { + return RunWithIO(ctx, args, os.Stdin, stdout, stderr) +} + +// RunWithIO executes the CLI with explicit standard streams. stdin is used by +// protocol-oriented commands such as `vminfo mcp`. +func RunWithIO(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.Writer) error { stdout = defaultWriter(stdout) stderr = &synchronizedWriter{w: defaultWriter(stderr)} @@ -336,7 +342,7 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { } cmd := strings.ToLower(strings.TrimSpace(args[0])) - if cmd != "update" && !opts.noUpdateCheck && !opts.silent && !strings.EqualFold(strings.TrimSpace(meta.Version), "dev") { + if cmd != "update" && cmd != "mcp" && !opts.noUpdateCheck && !opts.silent && !strings.EqualFold(strings.TrimSpace(meta.Version), "dev") { defer startBackgroundUpdateCheck(ctx, stderr, tr, meta)() } @@ -374,6 +380,8 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { return runUpdate(ctx, stdout, stderr, args[1:], tr) case "net": return runNet(ctx, stdout, stderr, args[1:], tr) + case "mcp": + return runMCP(ctx, stdin, stdout, args[1:], tr) default: _, _ = fmt.Fprintf(stderr, tr.T("unknown command: %s")+"\n\n", cmd) _, _ = io.WriteString(stderr, helpText(tr)) @@ -1201,6 +1209,7 @@ func helpText(tr *i18n.Translator) string { " vminfo net port

" + tr.T("test TCP port connectivity"), " vminfo net ping " + tr.T("probe host reachability (default tcp; --mode icmp)"), " vminfo net ip [] " + tr.T("lookup IP geo/ASN (no arg = your public IP; via ip.bestcheapvps.org)"), + " vminfo mcp " + tr.T("start read-only MCP server over stdio"), " vminfo --version " + tr.T("show app version"), " vminfo --help " + tr.T("show help"), "", @@ -1216,7 +1225,7 @@ func helpText(tr *i18n.Translator) string { " --no-update-check " + tr.T("skip background update check"), "", tr.T("Status:"), - " " + tr.T("TUI, web, summary, watch, ps, kill, update, net, and version are implemented."), + " " + tr.T("TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented."), }, "\n") + "\n" } diff --git a/internal/i18n/locales/de.json b/internal/i18n/locales/de.json index eb9985d..2f23f64 100644 --- a/internal/i18n/locales/de.json +++ b/internal/i18n/locales/de.json @@ -175,7 +175,9 @@ "update available: %s (current %s)": "Update verfügbar: %s (aktuell %s)", "check for and install updates": "Nach Updates suchen und installieren", "skip background update check": "Hintergrund-Update-Prüfung überspringen", - "TUI, web, summary, watch, ps, kill, update, and version are implemented.": "TUI, web, summary, watch, ps, kill, update und version sind implementiert.", + "start read-only MCP server over stdio": "schreibgeschützten MCP-Server über stdio starten", + "MCP mode reserves stdout for protocol messages and stops when the client disconnects.": "Der MCP-Modus reserviert stdout für Protokollnachrichten und endet, wenn der Client die Verbindung trennt.", + "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.": "TUI, web, summary, watch, ps, kill, update, net, MCP und version sind implementiert.", "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.": "Eine neue Version von vminfo ist verfügbar: %s (aktuell: %s). Führen Sie 'vminfo update' aus, um zu aktualisieren.", "User:": "Benutzer:", "toggle kernel threads": "Kernel-Threads ein/aus", diff --git a/internal/i18n/locales/en.json b/internal/i18n/locales/en.json index 12859f6..70bed27 100644 --- a/internal/i18n/locales/en.json +++ b/internal/i18n/locales/en.json @@ -12,7 +12,9 @@ "update available: %s (current %s)": "update available: %s (current %s)", "check for and install updates": "check for and install updates", "skip background update check": "skip background update check", - "TUI, web, summary, watch, ps, kill, update, and version are implemented.": "TUI, web, summary, watch, ps, kill, update, and version are implemented.", + "start read-only MCP server over stdio": "start read-only MCP server over stdio", + "MCP mode reserves stdout for protocol messages and stops when the client disconnects.": "MCP mode reserves stdout for protocol messages and stops when the client disconnects.", + "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.": "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.", "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.": "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.", "User:": "User:", "toggle kernel threads": "toggle kernel threads", diff --git a/internal/i18n/locales/es.json b/internal/i18n/locales/es.json index 936d512..53f7391 100644 --- a/internal/i18n/locales/es.json +++ b/internal/i18n/locales/es.json @@ -175,7 +175,9 @@ "update available: %s (current %s)": "Actualización disponible: %s (actual %s)", "check for and install updates": "Buscar e instalar actualizaciones", "skip background update check": "Omitir verificación de actualizaciones en segundo plano", - "TUI, web, summary, watch, ps, kill, update, and version are implemented.": "TUI, web, summary, watch, ps, kill, update y version están implementados.", + "start read-only MCP server over stdio": "iniciar servidor MCP de solo lectura mediante stdio", + "MCP mode reserves stdout for protocol messages and stops when the client disconnects.": "El modo MCP reserva stdout para los mensajes del protocolo y termina cuando el cliente se desconecta.", + "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.": "TUI, web, summary, watch, ps, kill, update, net, MCP y version están implementados.", "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.": "Una nueva versión de vminfo está disponible: %s (actual: %s). Ejecute 'vminfo update' para actualizar.", "User:": "Usuario:", "toggle kernel threads": "Alternar hilos del kernel", diff --git a/internal/i18n/locales/fr.json b/internal/i18n/locales/fr.json index 3268854..563914d 100644 --- a/internal/i18n/locales/fr.json +++ b/internal/i18n/locales/fr.json @@ -175,7 +175,9 @@ "update available: %s (current %s)": "Mise à jour disponible : %s (actuelle %s)", "check for and install updates": "Vérifier et installer les mises à jour", "skip background update check": "Ignorer la vérification des mises à jour en arrière-plan", - "TUI, web, summary, watch, ps, kill, update, and version are implemented.": "TUI, web, summary, watch, ps, kill, update et version sont implémentés.", + "start read-only MCP server over stdio": "démarrer le serveur MCP en lecture seule via stdio", + "MCP mode reserves stdout for protocol messages and stops when the client disconnects.": "Le mode MCP réserve stdout aux messages du protocole et s'arrête lorsque le client se déconnecte.", + "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.": "TUI, web, summary, watch, ps, kill, update, net, MCP et version sont implémentés.", "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.": "Une nouvelle version de vminfo est disponible : %s (actuelle : %s). Exécutez 'vminfo update' pour mettre à jour.", "User:": "Utilisateur :", "toggle kernel threads": "Afficher/masquer les threads noyau", diff --git a/internal/i18n/locales/ja.json b/internal/i18n/locales/ja.json index 79db21a..42c2470 100644 --- a/internal/i18n/locales/ja.json +++ b/internal/i18n/locales/ja.json @@ -175,7 +175,9 @@ "update available: %s (current %s)": "アップデートがあります: %s (現在 %s)", "check for and install updates": "アップデートを確認してインストール", "skip background update check": "バックグラウンドのアップデート確認をスキップ", - "TUI, web, summary, watch, ps, kill, update, and version are implemented.": "TUI、web、summary、watch、ps、kill、update、version が実装済みです。", + "start read-only MCP server over stdio": "stdio 経由で読み取り専用 MCP サーバーを起動", + "MCP mode reserves stdout for protocol messages and stops when the client disconnects.": "MCP モードでは stdout をプロトコルメッセージ専用にし、クライアント切断時に終了します。", + "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.": "TUI、web、summary、watch、ps、kill、update、net、MCP、version が実装済みです。", "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.": "vminfo の新バージョンが利用可能です: %s (現在: %s)。'vminfo update' を実行してアップグレードしてください。", "User:": "ユーザー:", "toggle kernel threads": "カーネルスレッドを切り替え", diff --git a/internal/i18n/locales/ko.json b/internal/i18n/locales/ko.json index 10ad230..36fa58f 100644 --- a/internal/i18n/locales/ko.json +++ b/internal/i18n/locales/ko.json @@ -175,7 +175,9 @@ "update available: %s (current %s)": "업데이트 가능: %s (현재 %s)", "check for and install updates": "업데이트 확인 및 설치", "skip background update check": "백그라운드 업데이트 확인 건너뛰기", - "TUI, web, summary, watch, ps, kill, update, and version are implemented.": "TUI, web, summary, watch, ps, kill, update, version이 구현되어 있습니다.", + "start read-only MCP server over stdio": "stdio를 통해 읽기 전용 MCP 서버 시작", + "MCP mode reserves stdout for protocol messages and stops when the client disconnects.": "MCP 모드는 stdout을 프로토콜 메시지 전용으로 사용하며 클라이언트 연결이 끊어지면 종료됩니다.", + "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.": "TUI, web, summary, watch, ps, kill, update, net, MCP, version이 구현되어 있습니다.", "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.": "vminfo 새 버전이 있습니다: %s (현재: %s). 'vminfo update'를 실행하여 업그레이드하세요.", "User:": "사용자:", "toggle kernel threads": "커널 스레드 전환", diff --git a/internal/i18n/locales/pt.json b/internal/i18n/locales/pt.json index 06d6605..bc680c4 100644 --- a/internal/i18n/locales/pt.json +++ b/internal/i18n/locales/pt.json @@ -175,7 +175,9 @@ "update available: %s (current %s)": "Atualização disponível: %s (atual %s)", "check for and install updates": "Verificar e instalar atualizações", "skip background update check": "Pular verificação de atualização em segundo plano", - "TUI, web, summary, watch, ps, kill, update, and version are implemented.": "TUI, web, summary, watch, ps, kill, update e version estão implementados.", + "start read-only MCP server over stdio": "iniciar servidor MCP somente leitura via stdio", + "MCP mode reserves stdout for protocol messages and stops when the client disconnects.": "O modo MCP reserva stdout para mensagens de protocolo e encerra quando o cliente se desconecta.", + "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.": "TUI, web, summary, watch, ps, kill, update, net, MCP e version estão implementados.", "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.": "Uma nova versão do vminfo está disponível: %s (atual: %s). Execute 'vminfo update' para atualizar.", "User:": "Usuário:", "toggle kernel threads": "Alternar threads do kernel", diff --git a/internal/i18n/locales/ru.json b/internal/i18n/locales/ru.json index 0921c5b..55f91c7 100644 --- a/internal/i18n/locales/ru.json +++ b/internal/i18n/locales/ru.json @@ -175,7 +175,9 @@ "update available: %s (current %s)": "Доступно обновление: %s (текущая %s)", "check for and install updates": "Проверить и установить обновления", "skip background update check": "Пропустить фоновую проверку обновлений", - "TUI, web, summary, watch, ps, kill, update, and version are implemented.": "TUI, web, summary, watch, ps, kill, update и version реализованы.", + "start read-only MCP server over stdio": "запустить MCP-сервер только для чтения через stdio", + "MCP mode reserves stdout for protocol messages and stops when the client disconnects.": "Режим MCP использует stdout только для сообщений протокола и завершается при отключении клиента.", + "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.": "TUI, web, summary, watch, ps, kill, update, net, MCP и version реализованы.", "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.": "Доступна новая версия vminfo: %s (текущая: %s). Выполните 'vminfo update' для обновления.", "User:": "Пользователь:", "toggle kernel threads": "Показать/скрыть потоки ядра", diff --git a/internal/i18n/locales/zh.json b/internal/i18n/locales/zh.json index 981319c..0cae07e 100644 --- a/internal/i18n/locales/zh.json +++ b/internal/i18n/locales/zh.json @@ -176,7 +176,9 @@ "update available: %s (current %s)": "有可用更新: %s (当前 %s)", "check for and install updates": "检查并安装更新", "skip background update check": "跳过后台更新检查", - "TUI, web, summary, watch, ps, kill, update, and version are implemented.": "TUI、web、summary、watch、ps、kill、update 和 version 均已实现。", + "start read-only MCP server over stdio": "通过 stdio 启动只读 MCP 服务器", + "MCP mode reserves stdout for protocol messages and stops when the client disconnects.": "MCP 模式将 stdout 专用于协议消息,并在客户端断开后退出。", + "TUI, web, summary, watch, ps, kill, update, net, MCP, and version are implemented.": "TUI、web、summary、watch、ps、kill、update、net、MCP 和 version 均已实现。", "A new version of vminfo is available: %s (current: %s). Run 'vminfo update' to upgrade.": "vminfo 有新版本可用: %s (当前: %s)。运行 'vminfo update' 进行升级。", "User:": "用户:", "toggle kernel threads": "切换内核线程", diff --git a/internal/mcpserver/backend.go b/internal/mcpserver/backend.go new file mode 100644 index 0000000..1958af1 --- /dev/null +++ b/internal/mcpserver/backend.go @@ -0,0 +1,57 @@ +package mcpserver + +import ( + "context" + "time" + + "github.com/cloudapp3/vminfo" + "github.com/cloudapp3/vminfo/internal/collector" +) + +const snapshotSampleInterval = 200 * time.Millisecond + +// Backend isolates MCP tool handling from host and network collection so tool +// contracts can be tested without depending on the machine running the tests. +type Backend interface { + SystemSnapshot(context.Context) (collector.Snapshot, error) + ListProcesses(context.Context) ([]vminfo.ProcessInfo, error) + ResolveDNS(context.Context, string, string) vminfo.DNSResult + CheckPort(context.Context, string, int, time.Duration) vminfo.PortResult + Ping(context.Context, string, vminfo.PingOptions) vminfo.PingResult + LookupIP(context.Context, string) vminfo.IPInfo + Metadata() vminfo.AppMetadata +} + +type nativeBackend struct{} + +func (nativeBackend) SystemSnapshot(ctx context.Context) (collector.Snapshot, error) { + staticInfo, stats, err := vminfo.CollectAll(ctx, vminfo.Options{SampleInterval: snapshotSampleInterval}) + if err != nil { + return collector.Snapshot{}, err + } + return collector.BuildSnapshot(staticInfo, stats, nil, nil), nil +} + +func (nativeBackend) ListProcesses(ctx context.Context) ([]vminfo.ProcessInfo, error) { + return vminfo.ListProcesses(ctx) +} + +func (nativeBackend) ResolveDNS(ctx context.Context, domain, server string) vminfo.DNSResult { + return vminfo.ResolveDNS(ctx, domain, server) +} + +func (nativeBackend) CheckPort(ctx context.Context, host string, port int, timeout time.Duration) vminfo.PortResult { + return vminfo.CheckPort(ctx, host, port, timeout) +} + +func (nativeBackend) Ping(ctx context.Context, host string, opts vminfo.PingOptions) vminfo.PingResult { + return vminfo.Ping(ctx, host, opts) +} + +func (nativeBackend) LookupIP(ctx context.Context, ip string) vminfo.IPInfo { + return vminfo.LookupIP(ctx, ip, vminfo.DefaultIPLookupServer) +} + +func (nativeBackend) Metadata() vminfo.AppMetadata { + return vminfo.Metadata() +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go new file mode 100644 index 0000000..0f38eff --- /dev/null +++ b/internal/mcpserver/server.go @@ -0,0 +1,60 @@ +package mcpserver + +import ( + "context" + "errors" + "fmt" + "io" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const serverInstructions = "Read-only local host diagnostics. Process command lines are omitted unless include_command is true. lookup_ip sends an explicit request to ip.bestcheapvps.org." + +// NewServer builds a tools-only MCP server backed by local vminfo APIs. +func NewServer(backend Backend) *mcp.Server { + if backend == nil { + backend = nativeBackend{} + } + meta := backend.Metadata() + server := mcp.NewServer(&mcp.Implementation{ + Name: "vminfo", + Title: "vminfo host diagnostics", + Version: meta.Version, + WebsiteURL: meta.Homepage, + }, &mcp.ServerOptions{ + Instructions: serverInstructions, + Capabilities: &mcp.ServerCapabilities{}, + }) + registerTools(server, backend) + return server +} + +// RunStdio serves one MCP client over the supplied input and output streams. +// The streams are deliberately not closed because they are normally os.Stdin +// and os.Stdout owned by the caller. +func RunStdio(ctx context.Context, stdin io.Reader, stdout io.Writer) error { + if stdin == nil { + return fmt.Errorf("MCP stdin is required") + } + if stdout == nil { + return fmt.Errorf("MCP stdout is required") + } + transport := &mcp.IOTransport{ + Reader: io.NopCloser(stdin), + Writer: nopWriteCloser{Writer: stdout}, + } + if err := NewServer(nil).Run(ctx, transport); err != nil { + if errors.Is(err, context.Canceled) && ctx.Err() != nil { + return nil + } + return err + } + return nil +} + +type nopWriteCloser struct { + io.Writer +} + +func (nopWriteCloser) Close() error { return nil } diff --git a/internal/mcpserver/server_test.go b/internal/mcpserver/server_test.go new file mode 100644 index 0000000..058647d --- /dev/null +++ b/internal/mcpserver/server_test.go @@ -0,0 +1,156 @@ +package mcpserver + +import ( + "bytes" + "context" + "encoding/json" + "reflect" + "slices" + "strings" + "testing" + + "github.com/cloudapp3/vminfo" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestServerAdvertisesReadOnlyTools(t *testing.T) { + backend := newFakeBackend() + session := connectClient(t, NewServer(backend)) + capabilities := session.InitializeResult().Capabilities + if capabilities.Tools == nil || capabilities.Logging != nil || capabilities.Prompts != nil || capabilities.Resources != nil { + t.Fatalf("unexpected server capabilities: %+v", capabilities) + } + + result, err := session.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("ListTools returned error: %v", err) + } + gotNames := make([]string, 0, len(result.Tools)) + for _, tool := range result.Tools { + gotNames = append(gotNames, tool.Name) + if tool.Annotations == nil || !tool.Annotations.ReadOnlyHint { + t.Errorf("tool %q is not marked read-only", tool.Name) + } + if tool.InputSchema == nil || tool.OutputSchema == nil { + t.Errorf("tool %q is missing an input or output schema", tool.Name) + } + } + slices.Sort(gotNames) + wantNames := []string{ + toolCheckPort, + toolGetSystemSnapshot, + toolGetVersion, + toolListProcesses, + toolLookupIP, + toolPingHost, + toolResolveDNS, + } + slices.Sort(wantNames) + if !reflect.DeepEqual(gotNames, wantNames) { + t.Fatalf("tool names = %v, want %v", gotNames, wantNames) + } +} + +func TestServerReturnsStructuredVersion(t *testing.T) { + backend := newFakeBackend() + backend.metadata = vminfo.AppMetadata{Name: "vminfo", Version: "v9.8.7", Channel: "test"} + session := connectClient(t, NewServer(backend)) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: toolGetVersion}) + if err != nil { + t.Fatalf("CallTool returned error: %v", err) + } + if result.IsError { + t.Fatalf("CallTool returned tool error: %+v", result.Content) + } + var got vminfo.AppMetadata + decodeStructured(t, result.StructuredContent, &got) + if got.Version != "v9.8.7" || got.Channel != "test" { + t.Fatalf("version result = %+v", got) + } + if len(result.Content) != 1 { + t.Fatalf("content length = %d, want 1 JSON fallback", len(result.Content)) + } +} + +func TestServerReturnsStructuredSnapshotWithEmptyCollections(t *testing.T) { + backend := newFakeBackend() + backend.snapshot.System.Hostname = "empty-host" + session := connectClient(t, NewServer(backend)) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: toolGetSystemSnapshot}) + if err != nil { + t.Fatalf("CallTool returned protocol error: %v", err) + } + if result.IsError { + t.Fatalf("CallTool returned tool error: %+v", result.Content) + } + var got map[string]any + decodeStructured(t, result.StructuredContent, &got) + if got["system"].(map[string]any)["hostname"] != "empty-host" { + t.Fatalf("unexpected snapshot: %+v", got) + } +} + +func TestServerReturnsToolErrorsToClient(t *testing.T) { + session := connectClient(t, NewServer(newFakeBackend())) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: toolListProcesses, + Arguments: map[string]any{"sort_by": "rss"}, + }) + if err != nil { + t.Fatalf("CallTool returned protocol error: %v", err) + } + if !result.IsError { + t.Fatalf("CallTool result IsError = false, want true") + } + if len(result.Content) != 1 || !strings.Contains(result.Content[0].(*mcp.TextContent).Text, "sort_by") { + t.Fatalf("unexpected tool error content: %+v", result.Content) + } +} + +func TestRunStdioValidatesStreamsAndStopsOnEOF(t *testing.T) { + if err := RunStdio(context.Background(), nil, &bytes.Buffer{}); err == nil { + t.Fatal("RunStdio accepted nil stdin") + } + if err := RunStdio(context.Background(), strings.NewReader(""), nil); err == nil { + t.Fatal("RunStdio accepted nil stdout") + } + var stdout bytes.Buffer + if err := RunStdio(context.Background(), strings.NewReader(""), &stdout); err != nil { + t.Fatalf("RunStdio on EOF returned error: %v", err) + } + if stdout.Len() != 0 { + t.Fatalf("RunStdio wrote unexpected output: %q", stdout.String()) + } +} + +func connectClient(t *testing.T, server *mcp.Server) *mcp.ClientSession { + t.Helper() + clientTransport, serverTransport := mcp.NewInMemoryTransports() + serverSession, err := server.Connect(context.Background(), serverTransport, nil) + if err != nil { + t.Fatalf("server Connect returned error: %v", err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "vminfo-test", Version: "v1"}, nil) + clientSession, err := client.Connect(context.Background(), clientTransport, nil) + if err != nil { + t.Fatalf("client Connect returned error: %v", err) + } + t.Cleanup(func() { _ = clientSession.Close() }) + return clientSession +} + +func decodeStructured(t *testing.T, value any, dst any) { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal structured content: %v", err) + } + if err := json.Unmarshal(data, dst); err != nil { + t.Fatalf("unmarshal structured content: %v", err) + } +} diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go new file mode 100644 index 0000000..fbf776f --- /dev/null +++ b/internal/mcpserver/tools.go @@ -0,0 +1,376 @@ +package mcpserver + +import ( + "cmp" + "context" + "fmt" + "net" + "slices" + "strconv" + "strings" + "time" + + "github.com/cloudapp3/vminfo" + "github.com/cloudapp3/vminfo/internal/collector" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +const ( + toolGetSystemSnapshot = "get_system_snapshot" + toolListProcesses = "list_processes" + toolResolveDNS = "resolve_dns" + toolCheckPort = "check_port" + toolPingHost = "ping_host" + toolLookupIP = "lookup_ip" + toolGetVersion = "get_version" + + defaultProcessLimit = 20 + maxProcessLimit = 200 + maxProcessFilterLen = 256 + maxTargetLen = 255 + maxPingCount = 10 + maxPingTimeout = 3 * time.Second + maxPortTimeout = 10 * time.Second +) + +var ( + localAnnotations = &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(false)} + networkAnnotations = &mcp.ToolAnnotations{ReadOnlyHint: true, OpenWorldHint: boolPtr(true)} +) + +type emptyInput struct{} + +type listProcessesInput struct { + Filter string `json:"filter,omitempty" jsonschema:"case-insensitive filter for PID, name, user, or state; also searches command when include_command is true"` + SortBy string `json:"sort_by,omitempty" jsonschema:"sort order: cpu, mem, pid, or name; defaults to cpu"` + Limit int `json:"limit,omitempty" jsonschema:"maximum results to return; defaults to 20 and must not exceed 200"` + IncludeCommand bool `json:"include_command,omitempty" jsonschema:"include process command lines, which may contain sensitive arguments"` +} + +type listProcessesOutput struct { + Total int `json:"total"` + Matched int `json:"matched"` + Returned int `json:"returned"` + SortBy string `json:"sort_by"` + CommandIncluded bool `json:"command_included"` + Processes []vminfo.ProcessInfo `json:"processes"` +} + +type resolveDNSInput struct { + Domain string `json:"domain" jsonschema:"domain name to resolve"` + Server string `json:"server,omitempty" jsonschema:"optional DNS resolver as host or host:port"` +} + +type checkPortInput struct { + Host string `json:"host" jsonschema:"host name or IP address to connect to"` + Port int `json:"port" jsonschema:"TCP port from 1 to 65535"` + TimeoutMS int `json:"timeout_ms,omitempty" jsonschema:"connection timeout in milliseconds; defaults to 2000 and must not exceed 10000"` +} + +type pingHostInput struct { + Host string `json:"host" jsonschema:"host name or IP address to probe"` + Mode string `json:"mode,omitempty" jsonschema:"probe mode: tcp or icmp; defaults to tcp"` + Count int `json:"count,omitempty" jsonschema:"number of probes; defaults to 4 and must not exceed 10"` + TimeoutMS int `json:"timeout_ms,omitempty" jsonschema:"per-probe timeout in milliseconds; defaults to 1000 and must not exceed 3000"` + TCPPort int `json:"tcp_port,omitempty" jsonschema:"TCP target port; defaults to 80"` +} + +type lookupIPInput struct { + IP string `json:"ip,omitempty" jsonschema:"optional IPv4 or IPv6 address; empty returns the caller public IP"` +} + +func registerTools(server *mcp.Server, backend Backend) { + tools := &toolHandlers{backend: backend} + mcp.AddTool(server, &mcp.Tool{ + Name: toolGetSystemSnapshot, + Title: "Get system snapshot", + Description: "Collect a current read-only snapshot of local system, CPU, memory, disk, network, load, process count, and health data.", + Annotations: localAnnotations, + }, tools.getSystemSnapshot) + mcp.AddTool(server, &mcp.Tool{ + Name: toolListProcesses, + Title: "List processes", + Description: "List, filter, and sort local processes on Linux. Command lines are omitted unless explicitly requested.", + Annotations: localAnnotations, + }, tools.listProcesses) + mcp.AddTool(server, &mcp.Tool{ + Name: toolResolveDNS, + Title: "Resolve DNS", + Description: "Resolve a domain with the system resolver or an explicitly selected DNS server.", + Annotations: networkAnnotations, + }, tools.resolveDNS) + mcp.AddTool(server, &mcp.Tool{ + Name: toolCheckPort, + Title: "Check TCP port", + Description: "Test TCP connectivity and connection latency to a host and port.", + Annotations: networkAnnotations, + }, tools.checkPort) + mcp.AddTool(server, &mcp.Tool{ + Name: toolPingHost, + Title: "Ping host", + Description: "Measure host reachability and latency with TCP or ICMP probes. ICMP may require host privileges.", + Annotations: networkAnnotations, + }, tools.pingHost) + mcp.AddTool(server, &mcp.Tool{ + Name: toolLookupIP, + Title: "Look up IP information", + Description: "Look up public IP, ASN, location, and risk data through ip.bestcheapvps.org. An empty IP inspects the caller public IP.", + Annotations: networkAnnotations, + }, tools.lookupIP) + mcp.AddTool(server, &mcp.Tool{ + Name: toolGetVersion, + Title: "Get vminfo version", + Description: "Return vminfo version, build, repository, and schema metadata.", + Annotations: localAnnotations, + }, tools.getVersion) +} + +type toolHandlers struct { + backend Backend +} + +func (t *toolHandlers) getSystemSnapshot(ctx context.Context, _ *mcp.CallToolRequest, _ emptyInput) (*mcp.CallToolResult, collector.Snapshot, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + snapshot, err := t.backend.SystemSnapshot(ctx) + if err != nil { + return nil, collector.Snapshot{}, fmt.Errorf("collect system snapshot: %w", err) + } + normalizeSnapshotCollections(&snapshot) + return nil, snapshot, nil +} + +func (t *toolHandlers) listProcesses(ctx context.Context, _ *mcp.CallToolRequest, input listProcessesInput) (*mcp.CallToolResult, listProcessesOutput, error) { + filter := strings.TrimSpace(input.Filter) + if len(filter) > maxProcessFilterLen { + return nil, listProcessesOutput{}, fmt.Errorf("filter must not exceed %d bytes", maxProcessFilterLen) + } + sortBy := strings.ToLower(strings.TrimSpace(input.SortBy)) + if sortBy == "" { + sortBy = "cpu" + } + if sortBy != "cpu" && sortBy != "mem" && sortBy != "pid" && sortBy != "name" { + return nil, listProcessesOutput{}, fmt.Errorf("sort_by must be one of: cpu, mem, pid, name") + } + limit := input.Limit + if limit == 0 { + limit = defaultProcessLimit + } + if limit < 1 || limit > maxProcessLimit { + return nil, listProcessesOutput{}, fmt.Errorf("limit must be between 1 and %d", maxProcessLimit) + } + + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + items, err := t.backend.ListProcesses(ctx) + if err != nil { + return nil, listProcessesOutput{}, fmt.Errorf("list processes: %w", err) + } + total := len(items) + filtered := make([]vminfo.ProcessInfo, 0, total) + for _, item := range items { + if processMatchesFilter(item, filter, input.IncludeCommand) { + filtered = append(filtered, item) + } + } + sortProcessList(filtered, sortBy) + matched := len(filtered) + if len(filtered) > limit { + filtered = filtered[:limit] + } + if !input.IncludeCommand { + for i := range filtered { + filtered[i].Command = "" + } + } + return nil, listProcessesOutput{ + Total: total, + Matched: matched, + Returned: len(filtered), + SortBy: sortBy, + CommandIncluded: input.IncludeCommand, + Processes: filtered, + }, nil +} + +func (t *toolHandlers) resolveDNS(ctx context.Context, _ *mcp.CallToolRequest, input resolveDNSInput) (*mcp.CallToolResult, vminfo.DNSResult, error) { + domain, err := validateTarget("domain", input.Domain) + if err != nil { + return nil, vminfo.DNSResult{}, err + } + server := strings.TrimSpace(input.Server) + if len(server) > maxTargetLen || strings.ContainsAny(server, " \t\r\n") { + return nil, vminfo.DNSResult{}, fmt.Errorf("server must be a valid host or host:port") + } + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + return nil, t.backend.ResolveDNS(ctx, domain, server), nil +} + +func (t *toolHandlers) checkPort(ctx context.Context, _ *mcp.CallToolRequest, input checkPortInput) (*mcp.CallToolResult, vminfo.PortResult, error) { + host, err := validateTarget("host", input.Host) + if err != nil { + return nil, vminfo.PortResult{}, err + } + if input.Port < 1 || input.Port > 65535 { + return nil, vminfo.PortResult{}, fmt.Errorf("port must be between 1 and 65535") + } + timeout, err := timeoutFromMilliseconds(input.TimeoutMS, 2*time.Second, maxPortTimeout) + if err != nil { + return nil, vminfo.PortResult{}, err + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + return nil, t.backend.CheckPort(ctx, host, input.Port, timeout), nil +} + +func (t *toolHandlers) pingHost(ctx context.Context, _ *mcp.CallToolRequest, input pingHostInput) (*mcp.CallToolResult, vminfo.PingResult, error) { + host, err := validateTarget("host", input.Host) + if err != nil { + return nil, vminfo.PingResult{}, err + } + mode := strings.ToLower(strings.TrimSpace(input.Mode)) + if mode == "" { + mode = "tcp" + } + if mode != "tcp" && mode != "icmp" { + return nil, vminfo.PingResult{}, fmt.Errorf("mode must be tcp or icmp") + } + count := input.Count + if count == 0 { + count = 4 + } + if count < 1 || count > maxPingCount { + return nil, vminfo.PingResult{}, fmt.Errorf("count must be between 1 and %d", maxPingCount) + } + timeout, err := timeoutFromMilliseconds(input.TimeoutMS, time.Second, maxPingTimeout) + if err != nil { + return nil, vminfo.PingResult{}, err + } + port := input.TCPPort + if port == 0 { + port = 80 + } + if port < 1 || port > 65535 { + return nil, vminfo.PingResult{}, fmt.Errorf("tcp_port must be between 1 and 65535") + } + ctx, cancel := context.WithTimeout(ctx, time.Duration(count)*timeout+time.Second) + defer cancel() + return nil, t.backend.Ping(ctx, host, vminfo.PingOptions{ + Mode: mode, Count: count, Timeout: timeout, Port: port, + }), nil +} + +func (t *toolHandlers) lookupIP(ctx context.Context, _ *mcp.CallToolRequest, input lookupIPInput) (*mcp.CallToolResult, vminfo.IPInfo, error) { + ip := strings.TrimSpace(input.IP) + if ip != "" && net.ParseIP(ip) == nil { + return nil, vminfo.IPInfo{}, fmt.Errorf("ip must be a valid IPv4 or IPv6 address") + } + ctx, cancel := context.WithTimeout(ctx, 6*time.Second) + defer cancel() + return nil, t.backend.LookupIP(ctx, ip), nil +} + +func (t *toolHandlers) getVersion(context.Context, *mcp.CallToolRequest, emptyInput) (*mcp.CallToolResult, vminfo.AppMetadata, error) { + return nil, t.backend.Metadata(), nil +} + +func validateTarget(name, value string) (string, error) { + value = strings.TrimSpace(value) + if value == "" { + return "", fmt.Errorf("%s is required", name) + } + if len(value) > maxTargetLen || strings.ContainsAny(value, " \t\r\n") { + return "", fmt.Errorf("%s must be a valid host name or IP address", name) + } + return value, nil +} + +func timeoutFromMilliseconds(value int, fallback, maximum time.Duration) (time.Duration, error) { + if value == 0 { + return fallback, nil + } + if value < 1 { + return 0, fmt.Errorf("timeout_ms must be positive") + } + timeout := time.Duration(value) * time.Millisecond + if timeout > maximum { + return 0, fmt.Errorf("timeout_ms must not exceed %d", maximum/time.Millisecond) + } + return timeout, nil +} + +func processMatchesFilter(item vminfo.ProcessInfo, filter string, includeCommand bool) bool { + if filter == "" { + return true + } + query := strings.ToLower(filter) + fields := []string{ + strconv.FormatInt(int64(item.PID), 10), + strconv.FormatInt(int64(item.PPID), 10), + item.Name, + item.User, + item.State, + } + if includeCommand { + fields = append(fields, item.Command) + } + for _, field := range fields { + if strings.Contains(strings.ToLower(field), query) { + return true + } + } + return false +} + +func sortProcessList(items []vminfo.ProcessInfo, sortBy string) { + slices.SortFunc(items, func(a, b vminfo.ProcessInfo) int { + switch sortBy { + case "mem": + if a.MemoryPercent != b.MemoryPercent { + return cmp.Compare(b.MemoryPercent, a.MemoryPercent) + } + case "pid": + if a.PID != b.PID { + return cmp.Compare(a.PID, b.PID) + } + case "name": + aName := strings.ToLower(strings.TrimSpace(a.Name)) + bName := strings.ToLower(strings.TrimSpace(b.Name)) + if aName != bName { + return cmp.Compare(aName, bName) + } + default: + if a.CPUPercent != b.CPUPercent { + return cmp.Compare(b.CPUPercent, a.CPUPercent) + } + } + return cmp.Compare(a.PID, b.PID) + }) +} + +func normalizeSnapshotCollections(snapshot *collector.Snapshot) { + if snapshot.CPU.PerCore == nil { + snapshot.CPU.PerCore = []float64{} + } + if snapshot.CPU.History == nil { + snapshot.CPU.History = []float64{} + } + if snapshot.Disk.Filesystems == nil { + snapshot.Disk.Filesystems = []collector.Filesystem{} + } + if snapshot.Disk.IO == nil { + snapshot.Disk.IO = []collector.DiskIO{} + } + if snapshot.Network.Interfaces == nil { + snapshot.Network.Interfaces = []collector.NetInterface{} + } + if snapshot.Processes.List == nil { + snapshot.Processes.List = []collector.ProcessEntry{} + } + if snapshot.Health.Warnings == nil { + snapshot.Health.Warnings = []collector.HealthWarning{} + } +} + +func boolPtr(value bool) *bool { return &value } diff --git a/internal/mcpserver/tools_test.go b/internal/mcpserver/tools_test.go new file mode 100644 index 0000000..618941b --- /dev/null +++ b/internal/mcpserver/tools_test.go @@ -0,0 +1,220 @@ +package mcpserver + +import ( + "context" + "errors" + "strings" + "testing" + "time" + + "github.com/cloudapp3/vminfo" + "github.com/cloudapp3/vminfo/internal/collector" +) + +type fakeBackend struct { + snapshot collector.Snapshot + snapshotErr error + processes []vminfo.ProcessInfo + processErr error + metadata vminfo.AppMetadata + + dnsDomain string + dnsServer string + portHost string + port int + portWait time.Duration + pingHost string + pingOpts vminfo.PingOptions + lookupIP string +} + +func newFakeBackend() *fakeBackend { + return &fakeBackend{metadata: vminfo.AppMetadata{Name: "vminfo", Version: "vtest"}} +} + +func (f *fakeBackend) SystemSnapshot(context.Context) (collector.Snapshot, error) { + return f.snapshot, f.snapshotErr +} + +func (f *fakeBackend) ListProcesses(context.Context) ([]vminfo.ProcessInfo, error) { + return f.processes, f.processErr +} + +func (f *fakeBackend) ResolveDNS(_ context.Context, domain, server string) vminfo.DNSResult { + f.dnsDomain, f.dnsServer = domain, server + return vminfo.DNSResult{Domain: domain, Server: server, Addrs: []string{"192.0.2.1"}} +} + +func (f *fakeBackend) CheckPort(_ context.Context, host string, port int, timeout time.Duration) vminfo.PortResult { + f.portHost, f.port, f.portWait = host, port, timeout + return vminfo.PortResult{Host: host, Port: port, Open: true} +} + +func (f *fakeBackend) Ping(_ context.Context, host string, opts vminfo.PingOptions) vminfo.PingResult { + f.pingHost, f.pingOpts = host, opts + return vminfo.PingResult{Host: host, Mode: opts.Mode, Port: opts.Port, Sent: opts.Count} +} + +func (f *fakeBackend) LookupIP(_ context.Context, ip string) vminfo.IPInfo { + f.lookupIP = ip + return vminfo.IPInfo{IP: ip, CountryCode: "ZZ"} +} + +func (f *fakeBackend) Metadata() vminfo.AppMetadata { return f.metadata } + +func TestToolHandlersGetSystemSnapshot(t *testing.T) { + backend := newFakeBackend() + backend.snapshot.System.Hostname = "test-host" + handlers := &toolHandlers{backend: backend} + + _, got, err := handlers.getSystemSnapshot(context.Background(), nil, emptyInput{}) + if err != nil { + t.Fatalf("getSystemSnapshot returned error: %v", err) + } + if got.System.Hostname != "test-host" { + t.Fatalf("hostname = %q, want test-host", got.System.Hostname) + } + + backend.snapshotErr = errors.New("collector unavailable") + if _, _, err := handlers.getSystemSnapshot(context.Background(), nil, emptyInput{}); err == nil || !strings.Contains(err.Error(), "collector unavailable") { + t.Fatalf("getSystemSnapshot error = %v, want collector failure", err) + } +} + +func TestToolHandlersListProcessesFiltersSortsLimitsAndRedacts(t *testing.T) { + backend := newFakeBackend() + backend.processes = []vminfo.ProcessInfo{ + {PID: 11, Name: "api", User: "app", CPUPercent: 80, MemoryPercent: 10, Command: "/srv/api --token secret"}, + {PID: 22, Name: "db", User: "postgres", CPUPercent: 20, MemoryPercent: 70, Command: "/usr/bin/db"}, + {PID: 33, Name: "worker", User: "app", CPUPercent: 40, MemoryPercent: 30, Command: "/srv/worker"}, + } + handlers := &toolHandlers{backend: backend} + + _, got, err := handlers.listProcesses(context.Background(), nil, listProcessesInput{ + SortBy: "mem", + Limit: 1, + }) + if err != nil { + t.Fatalf("listProcesses returned error: %v", err) + } + if got.Total != 3 || got.Matched != 3 || got.Returned != 1 { + t.Fatalf("unexpected counts: %+v", got) + } + if got.Processes[0].PID != 22 { + t.Fatalf("first PID = %d, want 22", got.Processes[0].PID) + } + if got.Processes[0].Command != "" || got.CommandIncluded { + t.Fatalf("command was not redacted: %+v", got) + } + _, got, err = handlers.listProcesses(context.Background(), nil, listProcessesInput{Filter: "token secret"}) + if err != nil { + t.Fatalf("listProcesses with redacted command filter returned error: %v", err) + } + if got.Matched != 0 { + t.Fatalf("redacted command was searchable: %+v", got) + } + + _, got, err = handlers.listProcesses(context.Background(), nil, listProcessesInput{ + Filter: "token secret", + IncludeCommand: true, + }) + if err != nil { + t.Fatalf("listProcesses with command returned error: %v", err) + } + if got.Matched != 1 || got.Processes[0].PID != 11 || got.Processes[0].Command == "" { + t.Fatalf("unexpected command-inclusive result: %+v", got) + } +} + +func TestToolHandlersListProcessesRejectsInvalidOptions(t *testing.T) { + handlers := &toolHandlers{backend: newFakeBackend()} + tests := []struct { + name string + input listProcessesInput + }{ + {name: "sort", input: listProcessesInput{SortBy: "rss"}}, + {name: "negative limit", input: listProcessesInput{Limit: -1}}, + {name: "large limit", input: listProcessesInput{Limit: maxProcessLimit + 1}}, + {name: "large filter", input: listProcessesInput{Filter: strings.Repeat("x", maxProcessFilterLen+1)}}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, _, err := handlers.listProcesses(context.Background(), nil, test.input); err == nil { + t.Fatal("listProcesses returned nil error") + } + }) + } +} + +func TestToolHandlersNetworkDefaults(t *testing.T) { + backend := newFakeBackend() + handlers := &toolHandlers{backend: backend} + + if _, _, err := handlers.resolveDNS(context.Background(), nil, resolveDNSInput{Domain: "example.com", Server: "1.1.1.1"}); err != nil { + t.Fatalf("resolveDNS returned error: %v", err) + } + if backend.dnsDomain != "example.com" || backend.dnsServer != "1.1.1.1" { + t.Fatalf("unexpected DNS call: domain=%q server=%q", backend.dnsDomain, backend.dnsServer) + } + + if _, _, err := handlers.checkPort(context.Background(), nil, checkPortInput{Host: "example.com", Port: 443}); err != nil { + t.Fatalf("checkPort returned error: %v", err) + } + if backend.portHost != "example.com" || backend.port != 443 || backend.portWait != 2*time.Second { + t.Fatalf("unexpected port call: host=%q port=%d timeout=%s", backend.portHost, backend.port, backend.portWait) + } + + if _, _, err := handlers.pingHost(context.Background(), nil, pingHostInput{Host: "example.com"}); err != nil { + t.Fatalf("pingHost returned error: %v", err) + } + if backend.pingHost != "example.com" || backend.pingOpts.Mode != "tcp" || backend.pingOpts.Count != 4 || backend.pingOpts.Timeout != time.Second || backend.pingOpts.Port != 80 { + t.Fatalf("unexpected ping call: host=%q opts=%+v", backend.pingHost, backend.pingOpts) + } + + if _, _, err := handlers.lookupIP(context.Background(), nil, lookupIPInput{IP: "2001:db8::1"}); err != nil { + t.Fatalf("lookupIP returned error: %v", err) + } + if backend.lookupIP != "2001:db8::1" { + t.Fatalf("lookup IP = %q, want 2001:db8::1", backend.lookupIP) + } +} + +func TestToolHandlersRejectInvalidNetworkInput(t *testing.T) { + handlers := &toolHandlers{backend: newFakeBackend()} + tests := []struct { + name string + call func() error + }{ + {name: "empty domain", call: func() error { + _, _, err := handlers.resolveDNS(context.Background(), nil, resolveDNSInput{}) + return err + }}, + {name: "invalid port", call: func() error { + _, _, err := handlers.checkPort(context.Background(), nil, checkPortInput{Host: "example.com", Port: 0}) + return err + }}, + {name: "large port timeout", call: func() error { + _, _, err := handlers.checkPort(context.Background(), nil, checkPortInput{Host: "example.com", Port: 443, TimeoutMS: 10001}) + return err + }}, + {name: "invalid ping mode", call: func() error { + _, _, err := handlers.pingHost(context.Background(), nil, pingHostInput{Host: "example.com", Mode: "udp"}) + return err + }}, + {name: "large ping count", call: func() error { + _, _, err := handlers.pingHost(context.Background(), nil, pingHostInput{Host: "example.com", Count: maxPingCount + 1}) + return err + }}, + {name: "invalid lookup IP", call: func() error { + _, _, err := handlers.lookupIP(context.Background(), nil, lookupIPInput{IP: "example.com"}) + return err + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if err := test.call(); err == nil { + t.Fatal("call returned nil error") + } + }) + } +}