Skip to content

Commit 8b85d71

Browse files
keyclaude
andauthored
Add serial_query tool and device profiles (#20)
* Add serial_query MCP tool for direct serial instrument communication Adds a new serial_query tool that sends commands over serial ports and returns device responses, independent of sigrok-cli. This enables communication with any serial-attached instrument (e.g. SCPI DMMs like OWON XDM1241) that sigrok drivers don't recognize. - New internal/serial package with Querier interface and PortQuerier impl - Uses go.bug.st/serial for cross-platform serial I/O (pure Go on Linux) - Input validation with validPortRe and validCommandRe for security - Full test coverage including mock port, error cases, and param validation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Fix errcheck lint: handle port.Close() return value Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * Add device profiles with MCP resources and lookup tool Enable LLMs to discover device-specific connection settings and commands before using serial_query. Embeds device profiles as JSON, exposes them via MCP resources (resources/list, resources/read) and a get_device_profile tool that matches by name, model, manufacturer, or *IDN? response. Includes OWON XDM1241 profile with baudrate 115200, timeout 3000ms, and 7 non-standard SCPI commands. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 5910cad commit 8b85d71

File tree

12 files changed

+1519
-43
lines changed

12 files changed

+1519
-43
lines changed

CLAUDE.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,14 @@ Written in Go. Licensed under MIT (Kenos, Inc.).
4242
- `cmd/sigrok-mcp-server/` — Entrypoint (`main.go`): wires config, executor, handlers, starts stdio server
4343
- `internal/config/` — Env-based configuration (`SIGROK_CLI_PATH`, `SIGROK_TIMEOUT_SECONDS`, `SIGROK_WORKING_DIR`)
4444
- `internal/sigrok/` — CLI executor (`Executor`) and output parsers; testdata/ contains golden output files
45+
- `internal/serial/` — Serial port querier for direct instrument communication (independent of sigrok-cli)
46+
- `internal/devices/` — Device profile registry with embedded JSON profiles; supports lookup by name, model, or `*IDN?` response
4547
- `internal/tools/` — MCP tool definitions (`tools.go`) and handler implementations (`handlers.go`)
4648

4749
## Key Dependencies
4850

4951
- `github.com/mark3labs/mcp-go` — MCP protocol framework (tool registration, JSON-RPC stdio transport)
52+
- `go.bug.st/serial` — Cross-platform serial port library (pure Go on Linux)
5053

5154
## Environment Variables
5255

cmd/sigrok-mcp-server/main.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import (
55
"os"
66

77
"github.com/KenosInc/sigrok-mcp-server/internal/config"
8+
"github.com/KenosInc/sigrok-mcp-server/internal/devices"
9+
"github.com/KenosInc/sigrok-mcp-server/internal/serial"
810
"github.com/KenosInc/sigrok-mcp-server/internal/sigrok"
911
"github.com/KenosInc/sigrok-mcp-server/internal/tools"
1012
"github.com/mark3labs/mcp-go/server"
@@ -13,10 +15,20 @@ import (
1315
func main() {
1416
cfg := config.Load()
1517
executor := sigrok.NewExecutor(cfg.SigrokCLIPath, cfg.Timeout, cfg.WorkingDir)
16-
handlers := tools.NewHandlers(executor, config.FirmwareDirs())
1718

18-
srv := server.NewMCPServer("sigrok-mcp-server", "0.1.0")
19+
deviceRegistry, err := devices.LoadEmbedded()
20+
if err != nil {
21+
fmt.Fprintf(os.Stderr, "sigrok-mcp-server: load device profiles: %v\n", err)
22+
os.Exit(1)
23+
}
24+
25+
handlers := tools.NewHandlers(executor, config.FirmwareDirs(), serial.NewPortQuerier(), deviceRegistry)
26+
27+
srv := server.NewMCPServer("sigrok-mcp-server", "0.1.0",
28+
server.WithResourceCapabilities(false, false),
29+
)
1930
tools.RegisterAll(srv, handlers)
31+
tools.RegisterResources(srv, deviceRegistry)
2032

2133
if err := server.ServeStdio(srv); err != nil {
2234
fmt.Fprintf(os.Stderr, "sigrok-mcp-server: %v\n", err)

go.mod

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,14 @@ require github.com/mark3labs/mcp-go v0.43.2
77
require (
88
github.com/bahlo/generic-list-go v0.2.0 // indirect
99
github.com/buger/jsonparser v1.1.1 // indirect
10+
github.com/creack/goselect v0.1.2 // indirect
1011
github.com/google/uuid v1.6.0 // indirect
1112
github.com/invopop/jsonschema v0.13.0 // indirect
1213
github.com/mailru/easyjson v0.7.7 // indirect
1314
github.com/spf13/cast v1.7.1 // indirect
1415
github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect
1516
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
17+
go.bug.st/serial v1.6.4 // indirect
18+
golang.org/x/sys v0.19.0 // indirect
1619
gopkg.in/yaml.v3 v3.0.1 // indirect
1720
)

go.sum

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPn
22
github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=
33
github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=
44
github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
5+
github.com/creack/goselect v0.1.2 h1:2DNy14+JPjRBgPzAd1thbQp4BSIihxcBf0IXhQXDRa0=
6+
github.com/creack/goselect v0.1.2/go.mod h1:a/NhLweNvqIYMuxcMOuWY516Cimucms3DglDzQP3hKY=
57
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
68
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
79
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -33,6 +35,10 @@ github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/
3335
github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=
3436
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
3537
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
38+
go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A=
39+
go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI=
40+
golang.org/x/sys v0.19.0 h1:q5f1RH2jigJ1MoAWp2KTp3gm5zAGFUTarQZ5U386+4o=
41+
golang.org/x/sys v0.19.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
3642
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
3743
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
3844
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

internal/devices/devices.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package devices
2+
3+
import (
4+
"embed"
5+
"encoding/json"
6+
"fmt"
7+
"sort"
8+
"strings"
9+
)
10+
11+
//go:embed profiles/*.json
12+
var profilesFS embed.FS
13+
14+
// Profile represents a device profile with connection settings and command reference.
15+
type Profile struct {
16+
ID string `json:"id"`
17+
Manufacturer string `json:"manufacturer"`
18+
Model string `json:"model"`
19+
Description string `json:"description"`
20+
IDNPattern string `json:"idn_pattern,omitempty"`
21+
Connection Connection `json:"connection"`
22+
Commands []Command `json:"commands,omitempty"`
23+
Notes []string `json:"notes,omitempty"`
24+
}
25+
26+
// Connection holds serial port settings for a device.
27+
type Connection struct {
28+
BaudRate int `json:"baudrate"`
29+
DataBits int `json:"databits"`
30+
Parity string `json:"parity"`
31+
StopBits string `json:"stopbits"`
32+
TimeoutMs int `json:"timeout_ms"`
33+
}
34+
35+
// Command describes a device command with an optional example response.
36+
type Command struct {
37+
Name string `json:"name"`
38+
Description string `json:"description"`
39+
ExampleResponse string `json:"example_response,omitempty"`
40+
}
41+
42+
// Registry holds loaded device profiles and supports lookup.
43+
type Registry struct {
44+
profiles []*Profile
45+
byID map[string]*Profile
46+
}
47+
48+
// LoadEmbedded reads all embedded profile JSON files and returns a Registry.
49+
func LoadEmbedded() (*Registry, error) {
50+
entries, err := profilesFS.ReadDir("profiles")
51+
if err != nil {
52+
return nil, fmt.Errorf("read embedded profiles: %w", err)
53+
}
54+
55+
var profiles []*Profile
56+
for _, e := range entries {
57+
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
58+
continue
59+
}
60+
data, err := profilesFS.ReadFile("profiles/" + e.Name())
61+
if err != nil {
62+
return nil, fmt.Errorf("read profile %s: %w", e.Name(), err)
63+
}
64+
var p Profile
65+
if err := json.Unmarshal(data, &p); err != nil {
66+
return nil, fmt.Errorf("parse profile %s: %w", e.Name(), err)
67+
}
68+
profiles = append(profiles, &p)
69+
}
70+
71+
return NewRegistry(profiles), nil
72+
}
73+
74+
// NewRegistry creates a Registry from a slice of profiles.
75+
func NewRegistry(profiles []*Profile) *Registry {
76+
byID := make(map[string]*Profile, len(profiles))
77+
for _, p := range profiles {
78+
byID[strings.ToLower(p.ID)] = p
79+
}
80+
return &Registry{profiles: profiles, byID: byID}
81+
}
82+
83+
// Get returns a profile by exact ID (case-insensitive).
84+
func (r *Registry) Get(id string) (*Profile, bool) {
85+
p, ok := r.byID[strings.ToLower(id)]
86+
return p, ok
87+
}
88+
89+
// List returns all profiles sorted by ID.
90+
func (r *Registry) List() []*Profile {
91+
sorted := make([]*Profile, len(r.profiles))
92+
copy(sorted, r.profiles)
93+
sort.Slice(sorted, func(i, j int) bool {
94+
return sorted[i].ID < sorted[j].ID
95+
})
96+
return sorted
97+
}
98+
99+
// Lookup finds profiles matching a query string. It checks against ID, model,
100+
// manufacturer, and IDN pattern (in that order). An exact ID match returns
101+
// immediately with a single result.
102+
func (r *Registry) Lookup(query string) []*Profile {
103+
if query == "" {
104+
return nil
105+
}
106+
lower := strings.ToLower(query)
107+
108+
// Exact ID match
109+
if p, ok := r.byID[lower]; ok {
110+
return []*Profile{p}
111+
}
112+
113+
var matches []*Profile
114+
seen := make(map[string]bool)
115+
116+
// Model match (case-insensitive substring)
117+
for _, p := range r.profiles {
118+
if strings.Contains(lower, strings.ToLower(p.Model)) {
119+
if !seen[p.ID] {
120+
matches = append(matches, p)
121+
seen[p.ID] = true
122+
}
123+
}
124+
}
125+
126+
// Manufacturer match (case-insensitive substring)
127+
for _, p := range r.profiles {
128+
if strings.Contains(lower, strings.ToLower(p.Manufacturer)) {
129+
if !seen[p.ID] {
130+
matches = append(matches, p)
131+
seen[p.ID] = true
132+
}
133+
}
134+
}
135+
136+
// IDN pattern match (check if query contains the idn_pattern)
137+
for _, p := range r.profiles {
138+
if p.IDNPattern != "" && strings.Contains(lower, strings.ToLower(p.IDNPattern)) {
139+
if !seen[p.ID] {
140+
matches = append(matches, p)
141+
seen[p.ID] = true
142+
}
143+
}
144+
}
145+
146+
return matches
147+
}

0 commit comments

Comments
 (0)