OpsKat封装MCP:用 Go 标准库把运维 CLI 变成 AI 的"手"
本文记录了
opsctl-mcp-server项目从零到可用的完整过程:不引入任何第三方依赖,仅用 Go 标准库实现 MCP(Model Context Protocol)协议的 stdio 传输,把 OpsKat 的opsctlCLI 桥接给 Cursor、Claude 等 MCP 客户端,让 AI 助手可以直接管理服务器资产、执行远程命令。
一、为什么要把 CLI 封装成 MCP
OpsKat 是一套资产与远程运维管理工具,其核心是 opsctl CLI:通过它可以列出/查看已管理的资产(服务器、数据库、Redis、MongoDB、Kafka、K8s 等),并对任意资产执行命令------所有敏感凭据由 OpsKat 桌面应用统一托管,写操作需要桌面端审批。
在日常运维中,我的工作流大量发生在 AI 编码助手里:"看下 prod-db 的连接信息"、"在这台机器上执行 df -h"。以前的做法是把 opsctl 的输出复制粘贴给 AI;现在的做法是让 AI 直接调用 opsctl。
MCP 正是为这件事而生的协议:它把"能力"以**工具(Tool)**的形式暴露给 LLM 宿主(Cursor、Claude Desktop 等),宿主把工具的 JSON Schema 注入模型上下文,模型决定何时调用,宿主负责转发调用并回传结果。
二、设计决策
动工前先定了三条原则:
1. 零第三方依赖。 MCP 本质就是 JSON-RPC 2.0,encoding/json + os/exec 足够。官方 Go SDK(modelcontextprotocol/go-sdk)当时尚不稳定,而协议最小可用集其实非常小,自己写反而可控。
2. stdio 传输。 MCP 支持两种传输:HTTP(Streamable HTTP/SSE)和 stdio。stdio 是 MCP 客户端"最原生"的方式------客户端自己拉起子进程,通过标准输入/输出交换消息,进程生命周期由客户端托管,不用关心端口占用、防火墙和健康检查。这也意味着:
- stdout 只能写协议消息,日志必须走 stderr;
- 不需要配置文件也能跑,配置全靠环境变量注入。
3. 桥接而不是重写。 不直接调 OpsKat 的 API,而是把 MCP 工具调用翻译成 opsctl 命令行执行。好处是权限、审批、加密全复用 CLI 已有的逻辑,MCP 层只做"翻译官"。
三、项目结构
整个项目 6 个文件,约 700 行:
opsctl-mcp-server/
├── go.mod # module opsctl-mcp-server, go 1.20
├── configs/
│ └── config.json # opsctl 路径 / data-dir
├── cmd/
│ └── server/
│ └── main.go # 入口:加载配置 → 建 client/server → stdio 循环
└── internal/
├── config/
│ └── config.go # 配置加载(config.json + 环境变量覆盖)
├── opsctl/
│ └── opsctl.go # 封装 os/exec 调用 opsctl 可执行文件
└── mcp/
├── types.go # JSON-RPC 2.0 / MCP 协议结构
├── tools.go # 8 个桥接工具的定义与命令组装
└── server.go # stdio 服务循环 + JSON-RPC 方法分发
四、协议层:JSON-RPC 2.0 只需要四个结构体
MCP 交互的全流程只有三种消息:请求(带 id)、通知(无 id)、响应。用结构体表达:
go
// rpcRequest 表示一条 JSON-RPC 请求(id 为 nil 表示通知)。
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Result interface{} `json:"result"`
}
type rpcError struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Error rpcErrorObj `json:"error"`
}
type rpcErrorObj struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
几个实现细节值得注意:
Params用json.RawMessage延迟解析------不同方法的参数结构完全不同,在各自的 handler 里再 unmarshal;- 工具参数是
map[string]interface{},schema 校验交给客户端,服务端做防御性取值即可。
MCP 侧只需要实现最小方法集:initialize、tools/list、tools/call,外加一个 ping。initialize 时把客户端声明的协议版本原样回显(最宽松的兼容策略):
go
func (s *Server) handleInitialize(req *rpcRequest) (*rpcResponse, *rpcError) {
var params initializeParams
_ = json.Unmarshal(req.Params, ¶ms)
protoVer := params.ProtocolVersion
if protoVer == "" {
protoVer = "2024-11-05"
}
result := initializeResult{
ProtocolVersion: protoVer,
Capabilities: serverCapabilities{Tools: &struct{}{}},
ServerInfo: serverInfo{Name: serverName, Version: serverVersion},
}
return &rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: result}, nil
}
五、stdio 服务循环:30 行核心
协议跑通的本质是一个循环:从 stdin 解码请求 → 分发 → 向 stdout 编码响应。
go
func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error {
dec := json.NewDecoder(in)
enc := json.NewEncoder(out)
for {
if err := ctx.Err(); err != nil {
return err
}
var req rpcRequest
if err := dec.Decode(&req); err != nil {
if err == io.EOF {
return nil // stdin 关闭,客户端退出
}
// 解析失败:按 JSON-RPC 规范返回 id 为 null 的 parse error
if err := enc.Encode(newRPCError(nil, -32700, "parse error", err.Error())); err != nil {
return err
}
continue
}
// 通知(无 id)无需响应,例如客户端发来的 notifications/initialized
if req.ID == nil {
continue
}
resp, rpcErr := s.dispatch(ctx, &req)
var outMsg interface{}
if rpcErr != nil {
outMsg = rpcErr
} else {
outMsg = resp
}
if err := enc.Encode(outMsg); err != nil {
return err
}
}
}
两个容易踩的坑:
- 通知必须静默 。客户端在
initialize之后会发notifications/initialized通知(无 id 字段),如果给它回了响应,客户端会报 "unexpected response"。 json.Decoder天然支持 newline-delimited JSON 。MCP stdio 约定一行一条消息,Decode每次恰好取出一个完整 JSON 值,不需要自己做行分割。
六、CLI 桥接层:一个 58 行的客户端
桥接层要做的只有一件事:把工具参数翻译成 opsctl 的 argv,然后 os/exec 执行:
go
type Client struct {
path string
dataDir string
}
func (c *Client) Run(ctx context.Context, stdin string, args ...string) (*Result, error) {
full := make([]string, 0, len(args)+2)
if c.dataDir != "" {
full = append(full, "--data-dir", c.dataDir)
}
full = append(full, args...)
cmd := exec.CommandContext(ctx, c.path, full...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if stdin != "" {
cmd.Stdin = bytes.NewBufferString(stdin)
}
err := cmd.Run()
res := &Result{Stdout: stdout.String(), Stderr: stderr.String()}
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
res.ExitCode = exitErr.ExitCode()
return res, nil // 退出码也是结果,不是错误
}
return res, err
}
return res, nil
}
两个设计点:
- 非零退出码不算错误。opsctl 执行失败(如命令返回 1)是正常的业务结果,要把 stdout/stderr/exit_code 一起交回给模型判断,而不是抛异常中断对话;
--data-dir全局注入,MCP 层不需要关心会话存在哪个目录。
七、工具定义:MCP 的"灵魂"
工具是模型能看到的唯一接口,description 的质量直接决定模型调用得对不对。以 exec 为例:
go
{
tool: tool{
Name: "exec",
Description: "对任意类型资产执行命令/语句(ssh 远程命令、database SQL、redis 命令、mongodb、etcd、kafka、k8s 等)。写操作需要桌面应用审批。",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]property{
"asset": {Type: "string", Description: "目标资产名称、数字 ID 或 分组/名称 路径"},
"type": {Type: "string", Description: "资产类型断言(可选,已知时建议传入):ssh、database、redis、mongodb、etcd、kafka、k8s、serial"},
"command": {Type: "string", Description: "要执行的命令或语句"},
},
Required: []string{"asset", "command"},
},
},
handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
return runExec(c, args)
},
},
handler 里的参数拼接要注意顺序确定性------map 遍历顺序不确定,所以按固定键序拼接:
go
// runExec 处理 exec 工具:opsctl exec <asset> [--type <type>] -- <command>
func runExec(c *opsctl.Client, args map[string]interface{}) (string, error) {
argv := []string{"exec", strArg(args, "asset")}
if v := strArg(args, "type"); v != "" {
argv = append(argv, "--type", v)
}
argv = append(argv, "--", strArg(args, "command"))
res, err := c.Run(context.Background(), "", argv...)
if err != nil {
return "", err
}
return combine(res), nil
}
-- 分隔符很关键:模型传来的命令内容不可信(可能以 - 开头),必须防止被 opsctl 当成自己的 flag。
batch 工具则展示了另一种参数传递方式------结构化数组走 stdin:
go
func runBatch(c *opsctl.Client, args map[string]interface{}) (string, error) {
raw, ok := args["commands"]
if !ok {
return "", fmt.Errorf("缺少 commands 参数")
}
payload, _ := json.Marshal(map[string]interface{}{"commands": raw})
res, err := c.Run(context.Background(), string(payload), "batch")
...
}
最终注册了 8 个工具:
| 工具 | 说明 |
|---|---|
list_assets |
列出资产,支持 type/group_id 过滤 |
list_groups |
列出分组 |
get_asset |
资产详情 |
get_group |
分组详情 |
exec |
对资产执行命令(写操作需桌面审批) |
batch |
并行执行多条命令,仅需一次审批 |
help |
打印配置契约与语法(只读,永不触发审批) |
version |
CLI 版本 |
help 是个容易被忽略但极其实用的工具:模型不确定某类资产的语法时可以先 help 再 exec,形成自我纠错的闭环。
八、配置:文件 + 环境变量三层覆盖
go
func Load(configPath string) (*Config, error) {
cfg := &Config{OpsctlPath: "opsctl", DataDir: ""}
// 第二层:config.json
if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config
if err := json.Unmarshal(data, &fileCfg); err == nil { /* 覆盖非空字段 */ }
}
// 第三层:环境变量(优先级最高)
if v := os.Getenv("OPSCTL_PATH"); v != "" {
cfg.OpsctlPath = v
}
if v := os.Getenv("OPSKAT_DATA_DIR"); v != "" {
cfg.DataDir = v
}
return cfg, nil
}
环境变量优先级最高不是随意定的:MCP 客户端拉起子进程时通常只能注入环境变量(如 Cursor 的 env 字段),这一层必须能压过一切,多台机器、多套会话才不需要改配置文件。
九、入口:把日志从 stdout 上赶走
stdio 传输下最经典的翻车点:Go 的 log 包默认输出到 stderr 没错,但任何不小心打到 stdout 的内容(fmt.Println、被桥接的子进程输出、依赖库日志)都会污染协议流,客户端直接解析失败。所以入口第一行就是加固:
go
func main() {
// stdio 传输下 stdout 专用于 JSON-RPC 消息,日志必须走 stderr。
log.SetOutput(os.Stderr)
configPath := flag.String("config", "configs/config.json", "配置文件路径")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
client := opsctl.New(cfg.OpsctlPath, cfg.DataDir)
server := mcp.NewServer(client)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
log.Printf("opsctl-mcp-server 启动(stdio 传输),opsctl=%s data_dir=%q", cfg.OpsctlPath, cfg.DataDir)
if err := server.Serve(ctx, os.Stdin, os.Stdout); err != nil {
log.Fatalf("MCP 服务退出: %v", err)
}
}
signal.NotifyContext 让 Ctrl+C / SIGTERM 时优雅退出------客户端关闭子进程的方式不可控,处理好信号能避免留下半截响应。
十、自测:一条管道就是完整的集成测试
stdio 服务最大的好处是测试不需要写一行代码------管道里塞几条 JSON-RPC 就是端到端联调:
powershell
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","clientInfo":{"name":"test","version":"0"}}}',
'{"jsonrpc":"2.0","id":2,"method":"tools/list"}',
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"version","arguments":{}}}' | .\opsctl-mcp-server.exe
依次输出三条响应:
json
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2024-11-05","capabilities":{"tools":{}},"serverInfo":{"name":"opsctl-mcp-server","version":"1.0.0"}}}
{"jsonrpc":"2.0","id":2,"result":{"tools":[...8 个工具...]}}
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"v1.13.2 (e131260)"}]}}
工具调用结果用 content: [{type: "text", text: "..."}] 返回,combine 函数把 stdout、stderr 和退出码拼成模型可读的纯文本。
十一、接入 Cursor
编辑 ~/.cursor/mcp.json(或 设置 → MCP → Add new MCP Server):
json
{
"mcpServers": {
"opsctl-mcp-server": {
"command": "d:\\code\\opsctl-mcp-server\\opsctl-mcp-server.exe",
"args": ["-config", "d:\\code\\opsctl-mcp-server\\configs\\config.json"],
"env": {
"OPSCTL_PATH": "C:\\Users\\admin\\AppData\\Local\\opskat\\opsctl.exe",
"OPSKAT_DATA_DIR": "C:\\Users\\admin\\AppData\\Local\\opskat"
}
}
}
}
Cursor 会拉起进程、完成 initialize 握手、拉取 tools/list,然后你就可以在对话框里直接说"帮我看下有哪些服务器资产"了。
十二、安全与注意事项
封装 MCP 把"人操作 CLI"变成了"模型操作 CLI",有几个必须正视的点:
- 审批链路不能绕过 。
exec的写操作会触发 OpsKat 桌面应用审批,MCP 调用会阻塞等待人工批准------这是特性不是缺陷,AI 的每个危险动作都有人兜底; - 明文面扩大 。
get_asset返回的连接信息会进入模型上下文,使用受信任的本地客户端; - 命令注入面 。工具参数通过
os/execargv 数组传递(不经 shell),且用--隔离子命令参数,风险已收敛,但command内容本身仍由 opsctl 的审批体系把关。
十三、小结
整个项目验证了一个判断:MCP 服务器的最小实现成本被严重高估了。JSON-RPC 2.0 + 四个方法 + stdio 循环,核心代码不到 200 行,剩下的工作量都在工具定义和参数翻译上。
后续可以演进的方向:
notifications/tools/list_changed:支持工具热更新;logging能力:把审批进度通过协议日志推给客户端,而不是让调用阻塞到超时;- 并发处理:当前是单线程串行响应,可以按请求 ID 并发执行工具调用;
- 同样的骨架可以快速复刻其他 CLI 的 MCP 封装------事实上我已经用它封装了 Windows 凭据管理器(
wincred-mcp-server),协议层代码一行没改。
代码即协议,管道即测试。如果你的手边也有一把趁手的 CLI,不妨花一小时把它交给 AI。
十四、完整代码实现
以下是全部源码,按文件排列,可直接对应目录结构复制使用。
go.mod
text
module opsctl-mcp-server
go 1.20
configs/config.json
json
{
"opsctl_path": "C:\\Users\\admin\\AppData\\Local\\opskat\\opsctl.exe",
"data_dir": "C:\\Users\\admin\\AppData\\Local\\opskat"
}
cmd/server/main.go
go
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"syscall"
"opsctl-mcp-server/internal/config"
"opsctl-mcp-server/internal/mcp"
"opsctl-mcp-server/internal/opsctl"
)
func main() {
// stdio 传输下 stdout 专用于 JSON-RPC 消息,日志必须走 stderr。
log.SetOutput(os.Stderr)
configPath := flag.String("config", "configs/config.json", "配置文件路径")
flag.Parse()
cfg, err := config.Load(*configPath)
if err != nil {
log.Fatalf("加载配置失败: %v", err)
}
client := opsctl.New(cfg.OpsctlPath, cfg.DataDir)
server := mcp.NewServer(client)
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
log.Printf("opsctl-mcp-server 启动(stdio 传输),opsctl=%s data_dir=%q", cfg.OpsctlPath, cfg.DataDir)
if err := server.Serve(ctx, os.Stdin, os.Stdout); err != nil {
log.Fatalf("MCP 服务退出: %v", err)
}
}
internal/config/config.go
go
package config
import (
"encoding/json"
"os"
)
// Config 保存 opsctl-mcp-server 的运行时配置。
type Config struct {
// OpsctlPath opsctl 可执行文件的绝对路径。
OpsctlPath string `json:"opsctl_path"`
// DataDir opsctl 数据目录(对应 --data-dir)。
DataDir string `json:"data_dir"`
}
// Load 依次从默认值、config.json、环境变量加载配置。
// 环境变量优先级最高:OPSCTL_PATH、OPSKAT_DATA_DIR。
func Load(configPath string) (*Config, error) {
cfg := &Config{
OpsctlPath: "opsctl",
DataDir: "",
}
if data, err := os.ReadFile(configPath); err == nil {
var fileCfg Config
if err := json.Unmarshal(data, &fileCfg); err == nil {
if fileCfg.OpsctlPath != "" {
cfg.OpsctlPath = fileCfg.OpsctlPath
}
if fileCfg.DataDir != "" {
cfg.DataDir = fileCfg.DataDir
}
}
}
if v := os.Getenv("OPSCTL_PATH"); v != "" {
cfg.OpsctlPath = v
}
if v := os.Getenv("OPSKAT_DATA_DIR"); v != "" {
cfg.DataDir = v
}
return cfg, nil
}
internal/opsctl/opsctl.go
go
package opsctl
import (
"bytes"
"context"
"os/exec"
)
// Client 封装对 opsctl 可执行文件的调用。
type Client struct {
path string
dataDir string
}
// New 创建一个 opsctl 调用客户端。
func New(path, dataDir string) *Client {
return &Client{path: path, dataDir: dataDir}
}
// Result 是一次 CLI 调用的结果。
type Result struct {
Stdout string
Stderr string
ExitCode int
}
// Run 执行 opsctl 命令。args 是主命令及其参数(不含全局 flag),
// dataDir 非空时自动前置 --data-dir 与 --master-key 之外的全局 flag。
func (c *Client) Run(ctx context.Context, stdin string, args ...string) (*Result, error) {
full := make([]string, 0, len(args)+2)
if c.dataDir != "" {
full = append(full, "--data-dir", c.dataDir)
}
full = append(full, args...)
cmd := exec.CommandContext(ctx, c.path, full...)
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if stdin != "" {
cmd.Stdin = bytes.NewBufferString(stdin)
}
err := cmd.Run()
res := &Result{
Stdout: stdout.String(),
Stderr: stderr.String(),
}
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
res.ExitCode = exitErr.ExitCode()
return res, nil
}
return res, err
}
return res, nil
}
internal/mcp/types.go
go
package mcp
import "encoding/json"
// JSON-RPC 2.0 消息基础结构。
// rpcRequest 表示一条 JSON-RPC 请求(id 为 nil 表示通知)。
type rpcRequest struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id,omitempty"`
Method string `json:"method"`
Params json.RawMessage `json:"params,omitempty"`
}
// rpcResponse 表示一条 JSON-RPC 成功响应。
type rpcResponse struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Result interface{} `json:"result"`
}
// rpcError 表示一条 JSON-RPC 错误响应。
type rpcError struct {
JSONRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Error rpcErrorObj `json:"error"`
}
type rpcErrorObj struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
// MCP 协议结构。
// initializeParams 是 initialize 方法的参数。
type initializeParams struct {
ProtocolVersion string `json:"protocolVersion"`
ClientInfo struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"clientInfo"`
}
// initializeResult 是 initialize 的返回结果。
type initializeResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities serverCapabilities `json:"capabilities"`
ServerInfo serverInfo `json:"serverInfo"`
}
type serverCapabilities struct {
Tools *struct {
ListChanged bool `json:"listChanged,omitempty"`
} `json:"tools,omitempty"`
}
type serverInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// tool 是 MCP 工具描述。
type tool struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema toolInputSchema `json:"inputSchema"`
}
// toolInputSchema 是工具的 JSON Schema 描述。
type toolInputSchema struct {
Type string `json:"type"`
Properties map[string]property `json:"properties,omitempty"`
Required []string `json:"required,omitempty"`
Items *toolInputSchema `json:"items,omitempty"`
}
type property struct {
Type string `json:"type"`
Description string `json:"description,omitempty"`
Enum []string `json:"enum,omitempty"`
Items *toolInputSchema `json:"items,omitempty"`
}
// callToolParams 是 tools/call 的参数。
type callToolParams struct {
Name string `json:"name"`
Arguments map[string]interface{} `json:"arguments"`
}
// callToolResult 是 tools/call 的返回结果。
type callToolResult struct {
Content []contentBlock `json:"content"`
IsError bool `json:"isError,omitempty"`
}
type contentBlock struct {
Type string `json:"type"`
Text string `json:"text"`
}
// listToolsResult 是 tools/list 的返回结果。
type listToolsResult struct {
Tools []tool `json:"tools"`
}
internal/mcp/server.go
go
package mcp
import (
"context"
"encoding/json"
"io"
"opsctl-mcp-server/internal/opsctl"
)
// Server 是一个 stdio 传输的 MCP server。
type Server struct {
client *opsctl.Client
tools []toolDef
}
// NewServer 创建 MCP server。
func NewServer(client *opsctl.Client) *Server {
return &Server{
client: client,
tools: buildTools(client),
}
}
// Serve 以 stdio 传输运行 MCP server:从 in 逐条读取 JSON-RPC 2.0 消息,
// 处理后向 out 写响应。通知(无 id)不产生响应。in 到达 EOF 或 ctx 取消时返回。
func (s *Server) Serve(ctx context.Context, in io.Reader, out io.Writer) error {
dec := json.NewDecoder(in)
enc := json.NewEncoder(out)
for {
if err := ctx.Err(); err != nil {
return err
}
var req rpcRequest
if err := dec.Decode(&req); err != nil {
if err == io.EOF {
return nil
}
// 解析失败:按 JSON-RPC 规范返回 id 为 null 的 parse error。
if err := enc.Encode(newRPCError(nil, -32700, "parse error", err.Error())); err != nil {
return err
}
continue
}
// 通知(无 id)无需响应。
if req.ID == nil {
continue
}
resp, rpcErr := s.dispatch(ctx, &req)
var outMsg interface{}
if rpcErr != nil {
outMsg = rpcErr
} else {
outMsg = resp
}
if err := enc.Encode(outMsg); err != nil {
return err
}
}
}
// dispatch 根据 method 分发处理。
func (s *Server) dispatch(ctx context.Context, req *rpcRequest) (*rpcResponse, *rpcError) {
switch req.Method {
case "initialize":
return s.handleInitialize(req)
case "tools/list":
return s.handleListTools(req)
case "tools/call":
return s.handleCallTool(ctx, req)
case "ping":
return &rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: struct{}{}}, nil
default:
return nil, newRPCError(req.ID, -32601, "method not found", req.Method)
}
}
func (s *Server) handleInitialize(req *rpcRequest) (*rpcResponse, *rpcError) {
var params initializeParams
_ = json.Unmarshal(req.Params, ¶ms)
protoVer := params.ProtocolVersion
if protoVer == "" {
protoVer = "2024-11-05"
}
result := initializeResult{
ProtocolVersion: protoVer,
Capabilities: serverCapabilities{
Tools: &struct {
ListChanged bool `json:"listChanged,omitempty"`
}{},
},
ServerInfo: serverInfo{Name: serverName, Version: serverVersion},
}
return &rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: result}, nil
}
func (s *Server) handleListTools(req *rpcRequest) (*rpcResponse, *rpcError) {
result := listToolsResult{Tools: listTools(s.tools)}
return &rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: result}, nil
}
func (s *Server) handleCallTool(ctx context.Context, req *rpcRequest) (*rpcResponse, *rpcError) {
var params callToolParams
if err := json.Unmarshal(req.Params, ¶ms); err != nil {
return nil, newRPCError(req.ID, -32602, "invalid params", err.Error())
}
// 查找工具。
var found *toolDef
for i := range s.tools {
if s.tools[i].tool.Name == params.Name {
found = &s.tools[i]
break
}
}
if found == nil {
return nil, newRPCError(req.ID, -32602, "unknown tool", params.Name)
}
text, err := found.handler(ctx, params.Arguments)
result := callToolResult{
Content: []contentBlock{{Type: "text", Text: text}},
}
if err != nil {
result.IsError = true
result.Content[0].Text = err.Error()
}
return &rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: result}, nil
}
func newRPCError(id interface{}, code int, msg string, data interface{}) *rpcError {
return &rpcError{
JSONRPC: "2.0",
ID: id,
Error: rpcErrorObj{Code: code, Message: msg, Data: data},
}
}
internal/mcp/tools.go
go
package mcp
import (
"context"
"encoding/json"
"fmt"
"strings"
"opsctl-mcp-server/internal/opsctl"
)
const (
serverName = "opsctl-mcp-server"
serverVersion = "1.0.0"
)
// toolHandler 处理单个工具调用,返回文本结果。
type toolHandler func(ctx context.Context, args map[string]interface{}) (string, error)
type toolDef struct {
tool tool
handler toolHandler
}
// buildTools 构造并注册全部桥接工具。
func buildTools(c *opsctl.Client) []toolDef {
return []toolDef{
{
tool: tool{
Name: "list_assets",
Description: "列出 opsctl 已管理的资产(服务器、数据库、Redis、MongoDB、Kafka、Kubernetes 等)。可用 --type/--group-id 过滤。",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]property{
"type": {Type: "string", Description: "按资产类型过滤,例如 ssh、database、redis、mongodb、etcd、kafka、k8s、serial"},
"group_id": {Type: "integer", Description: "按分组 ID 过滤(0 表示未分组,缺省为全部)"},
},
},
},
handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
return runOpsctl(c, nil, args, "list", "assets")
},
},
{
tool: tool{
Name: "list_groups",
Description: "列出 opsctl 已管理的所有资产分组。",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]property{},
},
},
handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
return runOpsctl(c, nil, args, "list", "groups")
},
},
{
tool: tool{
Name: "get_asset",
Description: "获取单个资产的详细信息(含描述、主机、端口、用户名、认证方式等连接配置)。资产可用名称、数字 ID 或 分组/名称 路径引用。",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]property{
"asset": {Type: "string", Description: "资产名称、数字 ID 或 分组/名称 路径"},
},
Required: []string{"asset"},
},
},
handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
return runOpsctl(c, nil, args, "get", "asset")
},
},
{
tool: tool{
Name: "get_group",
Description: "获取单个资产分组的详细信息(含描述)。分组可用名称或数字 ID 引用。",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]property{
"group": {Type: "string", Description: "分组名称或数字 ID"},
},
Required: []string{"group"},
},
},
handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
return runOpsctl(c, nil, args, "get", "group")
},
},
{
tool: tool{
Name: "exec",
Description: "对任意类型资产执行命令/语句(ssh 远程命令、database SQL、redis 命令、mongodb、etcd、kafka、k8s 等)。写操作需要桌面应用审批。",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]property{
"asset": {Type: "string", Description: "目标资产名称、数字 ID 或 分组/名称 路径"},
"type": {Type: "string", Description: "资产类型断言(可选,已知时建议传入):ssh、database、redis、mongodb、etcd、kafka、k8s、serial"},
"command": {Type: "string", Description: "要执行的命令或语句"},
},
Required: []string{"asset", "command"},
},
},
handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
return runExec(c, args)
},
},
{
tool: tool{
Name: "batch",
Description: "并行执行多条命令,仅需一次审批。输入为命令数组,每项含 asset/type/command。",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]property{
"commands": {
Type: "array",
Description: "命令列表,每项包含 asset、type、command 三个字符串字段",
Items: &toolInputSchema{
Type: "object",
Properties: map[string]property{
"asset": {Type: "string", Description: "目标资产名称、数字 ID 或 分组/名称 路径"},
"type": {Type: "string", Description: "资产类型断言(可选)"},
"command": {Type: "string", Description: "要执行的命令或语句"},
},
Required: []string{"asset", "command"},
},
},
},
Required: []string{"commands"},
},
},
handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
return runBatch(c, args)
},
},
{
tool: tool{
Name: "help",
Description: "打印某资产或资产类型的配置契约、命令语法和用法说明(只读,永不触发审批)。",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]property{
"target": {Type: "string", Description: "资产名称/ID,或资产类型名(如 ssh、database、kafka)"},
},
Required: []string{"target"},
},
},
handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
return runOpsctl(c, nil, args, "help")
},
},
{
tool: tool{
Name: "version",
Description: "打印 opsctl CLI 版本。",
InputSchema: toolInputSchema{
Type: "object",
Properties: map[string]property{},
},
},
handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
return runOpsctl(c, nil, args, "version")
},
},
}
}
// listTools 返回所有工具的工具列表(用于 tools/list)。
func listTools(defs []toolDef) []tool {
out := make([]tool, 0, len(defs))
for _, d := range defs {
out = append(out, d.tool)
}
return out
}
// runOpsctl 执行一个无特殊处理的 opsctl 子命令,把可选字符串参数按固定键拼接到 args 之后。
// 键顺序即参数顺序,常见键:type、asset、group、target。
func runOpsctl(c *opsctl.Client, _ interface{}, args map[string]interface{}, sub ...string) (string, error) {
argv := append([]string{}, sub...)
// 按固定顺序追加可选参数,避免 map 遍历顺序不确定。
if v := strArg(args, "type"); v != "" {
argv = append(argv, "--type", v)
}
if v := strArg(args, "group_id"); v != "" {
argv = append(argv, "--group-id", v)
}
if v := strArg(args, "asset"); v != "" {
argv = append(argv, v)
}
if v := strArg(args, "group"); v != "" {
argv = append(argv, v)
}
if v := strArg(args, "target"); v != "" {
argv = append(argv, v)
}
res, err := c.Run(context.Background(), "", argv...)
if err != nil {
return "", err
}
return combine(res), nil
}
// runExec 处理 exec 工具:opsctl exec <asset> [--type <type>] -- <command>。
func runExec(c *opsctl.Client, args map[string]interface{}) (string, error) {
argv := []string{"exec", strArg(args, "asset")}
if v := strArg(args, "type"); v != "" {
argv = append(argv, "--type", v)
}
argv = append(argv, "--", strArg(args, "command"))
res, err := c.Run(context.Background(), "", argv...)
if err != nil {
return "", err
}
return combine(res), nil
}
// runBatch 处理 batch 工具:将 commands 数组序列化为 JSON 走 stdin 模式。
func runBatch(c *opsctl.Client, args map[string]interface{}) (string, error) {
raw, ok := args["commands"]
if !ok {
return "", fmt.Errorf("缺少 commands 参数")
}
payload, err := json.Marshal(map[string]interface{}{"commands": raw})
if err != nil {
return "", fmt.Errorf("序列化 commands 失败: %w", err)
}
res, err := c.Run(context.Background(), string(payload), "batch")
if err != nil {
return "", err
}
return combine(res), nil
}
// strArg 从参数 map 中读取字符串值。
func strArg(args map[string]interface{}, key string) string {
if v, ok := args[key]; ok {
if s, ok := v.(string); ok {
return s
}
// 兼容 JSON number 以字符串形式传入的场景。
return fmt.Sprintf("%v", v)
}
return ""
}
// combine 合并 stdout/stderr 与退出码为可读文本。
func combine(res *opsctl.Result) string {
var b strings.Builder
if res.Stdout != "" {
b.WriteString(res.Stdout)
}
if res.Stderr != "" {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString("[stderr] ")
b.WriteString(res.Stderr)
}
if res.ExitCode != 0 {
if b.Len() > 0 {
b.WriteString("\n")
}
b.WriteString(fmt.Sprintf("[exit_code=%d]", res.ExitCode))
}
return b.String()
}
项目代码:opsctl-mcp-server(Go 1.20,零第三方依赖,约 700 行)