OpsKat封装MCP:用 Go 标准库把运维 CLI 变成 AI 的“手“

OpsKat封装MCP:用 Go 标准库把运维 CLI 变成 AI 的"手"

本文记录了 opsctl-mcp-server 项目从零到可用的完整过程:不引入任何第三方依赖,仅用 Go 标准库实现 MCP(Model Context Protocol)协议的 stdio 传输,把 OpsKat 的 opsctl CLI 桥接给 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"`
}

几个实现细节值得注意:

  • Paramsjson.RawMessage 延迟解析------不同方法的参数结构完全不同,在各自的 handler 里再 unmarshal;
  • 工具参数是 map[string]interface{},schema 校验交给客户端,服务端做防御性取值即可。

MCP 侧只需要实现最小方法集:initializetools/listtools/call,外加一个 pinginitialize 时把客户端声明的协议版本原样回显(最宽松的兼容策略):

go 复制代码
func (s *Server) handleInitialize(req *rpcRequest) (*rpcResponse, *rpcError) {
	var params initializeParams
	_ = json.Unmarshal(req.Params, &params)

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

两个容易踩的坑:

  1. 通知必须静默 。客户端在 initialize 之后会发 notifications/initialized 通知(无 id 字段),如果给它回了响应,客户端会报 "unexpected response"。
  2. 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 是个容易被忽略但极其实用的工具:模型不确定某类资产的语法时可以先 helpexec,形成自我纠错的闭环。

八、配置:文件 + 环境变量三层覆盖

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/exec argv 数组传递(不经 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, &params)

	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, &params); 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 行)

相关推荐
美股研究社21 分钟前
出海合规战,TEMU先胜“一城”
大数据·人工智能·物联网
元岳数字人小元22 分钟前
数字人一体机如何落地?多模态智能交互设备全解析
运维·人工智能·人机交互·交互·源代码管理
学着改变27528 分钟前
2026手持式超声波流量计品牌对比:巡检标定场景性能与续航评测
人工智能·科技·产品运营·能源·材质
qq4078556029 分钟前
2026 最新鼎捷 vs 轻流:生产管理系统功能对比与选型指南
大数据·人工智能·低代码·制造
夜雪一千29 分钟前
如何编写一个数据分析 Skill:完整拆解一个案例
人工智能·语言模型·数据挖掘·数据分析
liliangcsdn30 分钟前
如何对IC时间序列进行汇总统计分析示例
人工智能·算法·机器学习
世岩清上33 分钟前
展柜陈列想要高级质感,留白比例控制在多少最合适?
人工智能·展厅改造
Canace42 分钟前
基于 Codex Agent Harness 套壳实现自己的 AI 产品:Agent 运行时与任务编排实践
前端·人工智能·agent
人工智能研究所43 分钟前
手机随手拍一段视频,AI 能还原出一个可以 360° 旋转视角的“数字人“
人工智能·科技·ai·数字人