告别 Vault 的复杂度:用 Go 标准库给 Windows 凭据管理器装上 MCP

告别 Vault 的复杂度:用 Go 标准库给 Windows 凭据管理器装上 MCP

背景:Vault 很强,但真的太繁琐了

做开发的同学大概都有过这样的经历:团队里要管理一堆数据库密码、API Key、服务账号,大家七嘴八舌地推荐 HashiCorp Vault。于是你开始搭 Vault,然后发现面前横着一条概念鸿沟:

  • 初始化(Init) :Vault 首次启动是密封(sealed)状态,必须先 vault operator init 生成根令牌和解封密钥;
  • 解封密钥(Unseal Keys):Vault 默认用 Shamir 分片算法把主密钥拆成 5 份,重启后要凑齐 3 份才能解封------你还得理解什么是密钥分片、门限值;
  • 根令牌(Root Token):初始化产物,权限无限大,官方建议用完即撤销,然后你得创建策略(Policy)、配 AppRole/OIDC 等认证方式;
  • Secrets Engine 与路径挂载(Path Mount) :KV v1 还是 KV v2?挂载在 secret/ 还是自定义路径?动态数据库凭据又要挂 database/ 引擎,配置 connection、role、TTL......
  • 还有 namespace、lease 续租、audit 日志等等。

等这一切配置完,你可能只是想让 AI 助手帮你查一个数据库密码而已。对于个人开发者或者小团队来说,这套流程属实是"高射炮打蚊子"。

其实回头想想,Windows 系统本身就自带一个凭据保险箱------凭据管理器(Credential Manager)。它就在控制面板里,由操作系统按用户隔离存储,Explorer、git-credential-manager、远程桌面都在用它。既然日常开发就在 Windows 上,为什么不让 AI 直接读写这个系统级的凭据存储呢?

于是就有了这个项目:wincred-mcp-server------一个用 Go 标准库实现、零第三方依赖的 Windows 凭据管理器 MCP Server + CLI。把 Vault 的那套复杂度压缩成"一个 target 就是一个凭据"的心智模型,让 Cursor / Claude 等 MCP 客户端直接以工具调用的方式管理你的账号密码。

项目概览

先看整体结构:

复制代码
wincred-mcp-server/
├── go.mod                          # module wincred-mcp-server, go 1.20
├── cmd/
│   └── wincred-mcp-server/
│       └── main.go                 # 入口:无参数 → MCP stdio;带子命令 → CLI
└── internal/
    ├── cred/
    │   └── cred.go                 # Windows 凭据管理器封装(CredRead/Write/Delete/Enumerate)
    └── mcp/
        ├── types.go                # JSON-RPC 2.0 / MCP 协议结构
        ├── tools.go                # 工具定义与实现
        └── server.go               # stdio 服务循环 + JSON-RPC 方法分发

两个设计目标:

  1. 零依赖 :MCP 协议用 encoding/json 手写实现,Windows API 用 syscall 直接调用,go.mod 里干干净净;
  2. 双形态:同一个 exe,无参数启动就是 stdio 传输的 MCP Server,带子命令就是普通 CLI------方便人工调试,也方便 AI 调用。

值得一提的是工具命名参照了 HashiCorp 官方 vault-mcp-server 的 KV 工具风格(list_secrets / read_secret / write_secret / delete_secret),所以叫 list_credentials / read_credential / write_credential / delete_credential。用过 Vault MCP 的同学可以无缝切换,只是背后不再需要 init、unseal、mount。

第一步:用 syscall 直捣 Windows 凭据管理器

Windows 凭据管理器的底层 API 藏在 advapi32.dll 里,核心是 Cred*W 系列函数。Go 的 syscall 包提供了 NewLazyDLL,不需要任何 cgo:

go 复制代码
var (
	advapi32           = syscall.NewLazyDLL("advapi32.dll")
	procCredReadW      = advapi32.NewProc("CredReadW")
	procCredWriteW     = advapi32.NewProc("CredWriteW")
	procCredDeleteW    = advapi32.NewProc("CredDeleteW")
	procCredEnumerateW = advapi32.NewProc("CredEnumerateW")
	procCredFree       = advapi32.NewProc("CredFree")
)

要把 Go 结构体传给 Win32 API,必须按 C 的内存布局定义对应的 CREDENTIALW 结构。这里有几个关键点:

go 复制代码
// credentialW 对应 Win32 CREDENTIALW 结构(仅 Windows 平台可用)。
type credentialW struct {
	Flags              uint32
	Type               uint32
	TargetName         *uint16
	Comment            *uint16
	LastWritten        syscall.Filetime
	CredentialBlobSize uint32
	CredentialBlob     uintptr
	Persist            uint32
	AttributeCount     uint32
	Attributes         uintptr
	TargetAlias        *uint16
	UserName           *uint16
}

注意几个细节:

  • 字符串字段全是 *uint16 :Win32 的 W 系列 API 用 UTF-16,Go 这边用 syscall.UTF16PtrFromString 转换,读回来自己写一个 utf16PtrToString 解码;
  • 密码是 CredentialBlob 原始字节 :系统不关心内容格式,写入什么字节就存什么字节。本工具统一用 UTF-8 写入,但读取时要兼容 cmdkey 等工具写的 UTF-16LE,所以解码逻辑做了双格式探测;
  • Persist 字段决定持久化范围 :这里固定用 CRED_PERSIST_ENTERPRISE(值为 3),凭据随用户配置文件漫游;
  • 大小限制 :blob 上限 2560 字节(CRED_MAX_CREDENTIAL_BLOB_SIZE),写入前先校验。

枚举凭据的 CredEnumerateW 返回的是指针数组 + 数量,用 unsafe.Slice 可以优雅地转成 Go 切片,最后别忘用 CredFree 释放:

go 复制代码
func List() ([]Credential, error) {
	var count uint32
	var creds **credentialW
	r1, _, callErr := procCredEnumerateW.Call(0, 0,
		uintptr(unsafe.Pointer(&count)), uintptr(unsafe.Pointer(&creds)))
	if r1 == 0 {
		if errno, ok := callErr.(syscall.Errno); ok && errno == errNotFound {
			return []Credential{}, nil // 没有任何凭据,不算错误
		}
		return nil, fmt.Errorf("CredEnumerateW 失败: %w", callErr)
	}
	defer procCredFree.Call(uintptr(unsafe.Pointer(creds)))

	items := unsafe.Slice(creds, count)
	out := make([]Credential, 0, len(items))
	for _, c := range items {
		if c.Type != credTypeGeneric {
			continue // 只处理通用凭据,跳过域凭据等
		}
		out = append(out, Credential{
			TargetName:  utf16PtrToString(c.TargetName),
			UserName:    utf16PtrToString(c.UserName),
			Comment:     utf16PtrToString(c.Comment),
			LastWritten: filetimeToTime(c.LastWritten),
			BlobSize:    int(c.CredentialBlobSize),
		})
	}
	return out, nil
}

这里有个容易被坑的点:只处理 CRED_TYPE_GENERIC(通用凭据)。凭据管理器里还有域凭据(Domain Password)、证书凭据等类型,其中域凭据的密码 blob 受系统保护根本读不出来,必须过滤掉。

错误处理也有讲究:ERROR_NOT_FOUND(1168)是"凭据不存在"的正常业务语义,应该转成友好的错误信息而不是裸抛系统错误。

第二步:手写一个 MCP 协议实现

MCP(Model Context Protocol)本质上就是基于 stdio 的 JSON-RPC 2.0 消息循环 。协议核心就四个方法:initializetools/listtools/callping。用 encoding/json 完全够用,没必要引入 SDK。

服务循环

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 // 客户端关闭了输入流,正常退出
			}
			// 解析失败:按 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
		}
	}
}

几个实现细节:

  • json.Decoder 而不是 json.Unmarshal :Decoder 天然支持流式读取多条 JSON 消息(newline-delimited),读到 io.EOF 表示客户端断开,优雅退出;
  • 通知(notification)不响应 :JSON-RPC 规范里没有 id 的消息是通知,发响应反而会污染通道;
  • stdout 专用于协议消息 :stdio 传输下千万不能往 stdout 打日志,否则客户端 JSON 解析直接爆炸。main.go 里专门 log.SetOutput(os.Stderr)

协议握手

initialize 握手时回显客户端请求的协议版本(缺省 2024-11-05),声明本服务具备 tools 能力:

go 复制代码
result := initializeResult{
	ProtocolVersion: protoVer,
	Capabilities: serverCapabilities{
		Tools: &struct {
			ListChanged bool `json:"listChanged,omitempty"`
		}{},
	},
	ServerInfo: serverInfo{Name: serverName, Version: serverVersion},
}

工具定义

每个工具是一个名字、描述、JSON Schema 输入参数的定义,加上一个处理函数。以 read_credential 为例:

go 复制代码
{
	tool: tool{
		Name:        "read_credential",
		Description: "读取单条凭据详情。默认密码以掩码显示,设置 reveal=true 获取明文密码。",
		InputSchema: toolInputSchema{
			Type: "object",
			Properties: map[string]property{
				"target": {Type: "string", Description: "凭据目标名"},
				"reveal": {Type: "boolean", Description: "是否返回明文密码(默认 false)"},
			},
			Required: []string{"target"},
		},
	},
	handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
		return runRead(args)
	},
},

最终暴露给 AI 的 4 个工具:

工具 参数 说明
list_credentials filter(可选) 列出通用凭据:目标名、用户名、备注、最后写入时间,不返回密码
read_credential target(必填)、reveal(可选) 读取单条凭据;密码默认掩码,reveal=true 返回明文
write_credential targetusernamepassword(必填)、comment(可选) 创建或更新凭据(同目标名直接覆盖)
delete_credential target(必填) 删除凭据

一个安全设计:默认掩码

给 LLM 开放密码读取权限是有风险的。这里的做法是默认掩码、显式揭示list 永远不返回密码,read 默认返回掩码,只有调用方明确传 reveal=true 才返回明文。

go 复制代码
func runRead(args map[string]interface{}) (string, error) {
	target := strArg(args, "target")
	// ...参数校验与读取...
	if !boolArg(args, "reveal") {
		c.Password = "******(已掩码,设置 reveal=true 查看明文)"
	}
	b, err := json.MarshalIndent(c, "", "  ")
	// ...
}

这样在对话里 AI 先看结构,人确认后再让它带 reveal 参数取明文,多了一道心理防线。当然,真正的防线还是"仅在受信任的本地环境使用"。

第三步:一个 exe 两种人格

入口逻辑非常简单------有参数就是 CLI,没参数就是 MCP Server:

go 复制代码
func main() {
	if len(os.Args) > 1 {
		if err := runCLI(os.Args[1:]); err != nil {
			fmt.Fprintf(os.Stderr, "错误: %v\n", err)
			os.Exit(1)
		}
		return
	}
	runMCP()
}

CLI 提供了与 MCP 工具一一对应的子命令:

powershell 复制代码
# 列出所有通用凭据(不含密码)
.\wincred-mcp-server.exe list

# 创建凭据(--password 省略时从 stdin 读取,不留在命令历史里)
.\wincred-mcp-server.exe create my-server admin --password "P@ss123" --comment "生产环境"

# 查看凭据(默认掩码密码,--reveal 显示明文)
.\wincred-mcp-server.exe view my-server --reveal

# 编辑凭据(仅更新提供的字段,其余保持原值)
.\wincred-mcp-server.exe edit my-server --username newadmin

# 删除凭据
.\wincred-mcp-server.exe delete my-server

CLI 参数解析也是手写的(parseArgs),支持 -flag value--flag=value 与位置参数任意混排------又省掉一个 flag 解析依赖。edit 子命令内部复用了 cred.Update 的"读-改-写"模式,只覆盖显式提供的字段。

第四步:接入 Cursor

编译:

powershell 复制代码
go build -o wincred-mcp-server.exe ./cmd/wincred-mcp-server

在 Cursor 的 设置 → MCP → Add new MCP Server(或编辑 ~/.cursor/mcp.json)中添加:

json 复制代码
{
  "mcpServers": {
    "wincred-mcp-server": {
      "command": "d:\\code\\wincred-mcp-server\\wincred-mcp-server.exe"
    }
  }
}

Cursor 会自动拉起进程、完成 initialize 握手并发现全部 4 个工具。之后你就可以在对话里说"帮我把测试库的新密码存一下"或"查一下 git 凭据的用户名",AI 会直接调用工具完成操作------没有 init,没有 unseal,没有 mount,target 就是全部的路径语义。

与 Vault 的对比:什么时候选什么

写到这里有必要说句公道话:Vault 的复杂度不是无缘无故的。做一个对比:

维度 Vault Windows 凭据管理器 + 本工具
概念成本 init / unseal / root token / policy / mount / lease target 一个概念
部署成本 服务端部署 + 高可用 + 存储后端 无,操作系统自带
跨平台 全平台 仅 Windows
多用户共享 天然支持(配 ACL) 仅当前用户,单机
动态密钥 / 轮转 支持(数据库引擎、TTL、lease) 不支持
审计 完整 audit log
密文强度 服务端加密(Shamir 主密钥) 依赖系统 DPAPI 与用户隔离

结论很清晰:

  • 个人开发机、单机 Windows 环境、想让 AI 帮你管理本地账号密码 → 本方案,五分钟搞定;
  • 团队共享、跨平台、需要动态密钥和完整审计 → 还是老老实实用 Vault,那些复杂概念正是为这些场景存在的。

安全注意事项

最后强调几条底线:

  1. MCP 客户端及背后的 LLM 可以通过 read_credential(reveal=true) 拿到明文密码,请只在受信任的本地环境注册此服务;
  2. 凭据持久化为 CRED_PERSIST_ENTERPRISE,随用户配置文件漫游,登录域环境时注意这一点;
  3. 本工具只能读写当前 Windows 用户的凭据存储,无法访问其他用户的凭据(这是系统安全边界,也是特性而非缺陷);
  4. CLI 的 --password 参数会留在 shell 历史里,交互场景建议省略它,让程序从 stdin 安全读取。

结语

这个项目最有意思的地方在于"做减法 ":MCP 协议手写只要两三百行,Windows 凭据管理器调用不需要任何依赖,最终一个 exe 同时是 MCP Server 和 CLI,go.mod 里没有任何第三方包。

Vault 教会我们企业级密钥管理该长什么样,而日常个人开发中,我们需要的可能只是一个"AI 能安全触达的系统级保险箱"。Windows 凭据管理器一直都在那里,缺的只是一层 MCP 的桥------现在它有了。

完整代码实现

以下按文件顺序给出全部源码,共 5 个 Go 文件 + 1 个 go.mod,无任何第三方依赖。

go.mod

go 复制代码
module wincred-mcp-server

go 1.20

cmd/wincred-mcp-server/main.go

go 复制代码
package main

import (
	"bufio"
	"context"
	"fmt"
	"log"
	"os"
	"os/signal"
	"strings"
	"syscall"
	"text/tabwriter"

	"wincred-mcp-server/internal/cred"
	"wincred-mcp-server/internal/mcp"
)

const usage = `wincred-mcp-server ------ Windows 凭据管理器 MCP 服务 + CLI

默认(无参数)启动 MCP stdio 服务,供 Cursor 等 MCP 客户端拉起。

CLI 用法:
  wincred-mcp-server list                            列出所有通用凭据
  wincred-mcp-server view <target> [--reveal]        查看凭据(--reveal 显示明文密码)
  wincred-mcp-server create <target> <username> [--password <pwd>] [--comment <note>]
                                                     创建凭据(省略 --password 时从 stdin 读取)
  wincred-mcp-server edit <target> [--username <u>] [--password <pwd>] [--comment <note>]
                                                     编辑凭据(仅更新提供的字段)
  wincred-mcp-server delete <target>                 删除凭据
`

func main() {
	if len(os.Args) > 1 {
		if err := runCLI(os.Args[1:]); err != nil {
			fmt.Fprintf(os.Stderr, "错误: %v\n", err)
			os.Exit(1)
		}
		return
	}
	runMCP()
}

// runCLI 处理命令行子命令。
func runCLI(args []string) error {
	switch args[0] {
	case "list":
		return cliList()
	case "view":
		return cliView(args[1:])
	case "create":
		return cliCreate(args[1:])
	case "edit":
		return cliEdit(args[1:])
	case "delete":
		return cliDelete(args[1:])
	case "help", "-h", "--help":
		fmt.Print(usage)
		return nil
	default:
		fmt.Fprintf(os.Stderr, "未知子命令: %s\n\n", args[0])
		fmt.Print(usage)
		os.Exit(1)
		return nil
	}
}

// cliFlags 保存解析出的键值参数(键不含前导连字符)。
type cliFlags map[string]string

// parseArgs 手工解析 CLI 参数:支持 -flag value、--flag=value 与位置参数任意混排。
// valueFlags 声明哪些 flag 带值;未声明的视为布尔开关(存在即 true)。
func parseArgs(args []string, valueFlags map[string]bool) (cliFlags, []string, error) {
	flags := cliFlags{}
	var pos []string
	for i := 0; i < len(args); i++ {
		tok := args[i]
		if !strings.HasPrefix(tok, "-") {
			pos = append(pos, tok)
			continue
		}
		name := strings.TrimLeft(tok, "-")
		if name == "" {
			return nil, nil, fmt.Errorf("无效参数: %s", tok)
		}
		if eq := strings.Index(name, "="); eq >= 0 {
			flags[name[:eq]] = name[eq+1:]
			continue
		}
		if valueFlags[name] {
			if i+1 >= len(args) {
				return nil, nil, fmt.Errorf("参数 %s 缺少值", tok)
			}
			i++
			flags[name] = args[i]
		} else {
			flags[name] = "true"
		}
	}
	return flags, pos, nil
}

func cliList() error {
	creds, err := cred.List()
	if err != nil {
		return err
	}
	if len(creds) == 0 {
		fmt.Println("(当前用户没有通用凭据)")
		return nil
	}
	w := tabwriter.NewWriter(os.Stdout, 0, 4, 2, ' ', 0)
	fmt.Fprintln(w, "目标\t用户名\t备注\t最后写入")
	for _, c := range creds {
		fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", c.TargetName, c.UserName, c.Comment, c.LastWritten.Format("2006-01-02 15:04:05"))
	}
	return w.Flush()
}

func cliView(args []string) error {
	flags, pos, err := parseArgs(args, nil)
	if err != nil {
		return err
	}
	if len(pos) != 1 {
		return fmt.Errorf("用法: view <target> [--reveal]")
	}
	c, err := cred.Read(pos[0])
	if err != nil {
		return err
	}
	pwd := "******"
	if flags["reveal"] == "true" {
		pwd = c.Password
	}
	fmt.Printf("目标: %s\n用户名: %s\n备注: %s\n最后写入: %s\n密码: %s\n",
		c.TargetName, c.UserName, c.Comment, c.LastWritten.Format("2006-01-02 15:04:05"), pwd)
	return nil
}

func cliCreate(args []string) error {
	flags, pos, err := parseArgs(args, map[string]bool{"password": true, "comment": true})
	if err != nil {
		return err
	}
	if len(pos) != 2 {
		return fmt.Errorf("用法: create <target> <username> [--password <pwd>] [--comment <note>]")
	}
	target, username := pos[0], pos[1]
	password := flags["password"]
	if password == "" {
		fmt.Fprint(os.Stderr, "请输入密码: ")
		line, _ := bufio.NewReader(os.Stdin).ReadString('\n')
		password = strings.TrimRight(line, "\r\n")
	}
	if password == "" {
		return fmt.Errorf("密码不能为空")
	}
	if err := cred.Write(target, username, password, flags["comment"]); err != nil {
		return err
	}
	fmt.Printf("已创建凭据 %q(用户名 %q)\n", target, username)
	return nil
}

func cliEdit(args []string) error {
	flags, pos, err := parseArgs(args, map[string]bool{"username": true, "password": true, "comment": true})
	if err != nil {
		return err
	}
	if len(pos) != 1 {
		return fmt.Errorf("用法: edit <target> [--username <u>] [--password <pwd>] [--comment <note>]")
	}
	if flags["username"] == "" && flags["password"] == "" && flags["comment"] == "" {
		return fmt.Errorf("至少提供 --username / --password / --comment 之一")
	}
	var u, p, c *string
	if v := flags["username"]; v != "" {
		u = &v
	}
	if v := flags["password"]; v != "" {
		p = &v
	}
	if v := flags["comment"]; v != "" {
		c = &v
	}
	if err := cred.Update(pos[0], u, p, c); err != nil {
		return err
	}
	fmt.Printf("已更新凭据 %q\n", pos[0])
	return nil
}

func cliDelete(args []string) error {
	_, pos, err := parseArgs(args, nil)
	if err != nil {
		return err
	}
	if len(pos) != 1 {
		return fmt.Errorf("用法: delete <target>")
	}
	if err := cred.Delete(pos[0]); err != nil {
		return err
	}
	fmt.Printf("已删除凭据 %q\n", pos[0])
	return nil
}

// runMCP 启动 stdio 传输的 MCP 服务。
func runMCP() {
	// stdio 传输下 stdout 专用于 JSON-RPC 消息,日志必须走 stderr。
	log.SetOutput(os.Stderr)

	server := mcp.NewServer()

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	log.Printf("wincred-mcp-server 启动(stdio 传输)")
	if err := server.Serve(ctx, os.Stdin, os.Stdout); err != nil {
		log.Fatalf("MCP 服务退出: %v", err)
	}
}

internal/cred/cred.go

go 复制代码
// Package cred 封装 Windows 凭据管理器(Credential Manager)的读写操作。
// 通过 advapi32.dll 的 Cred*W 系列 API 实现,仅处理通用凭据(CRED_TYPE_GENERIC)。
package cred

import (
	"fmt"
	"syscall"
	"time"
	"unicode/utf16"
	"unicode/utf8"
	"unsafe"
)

const (
	// credTypeGeneric 通用凭据类型(账号密码类)。
	credTypeGeneric = 1
	// credPersistEnterprise 凭据随用户配置文件漫游。
	credPersistEnterprise = 3
	// errNotFound 对应 Win32 ERROR_NOT_FOUND。
	errNotFound = 1168
	// maxBlobSize 对应 CRED_MAX_CREDENTIAL_BLOB_SIZE(5*512 字节)。
	maxBlobSize = 5 * 512
)

var (
	advapi32           = syscall.NewLazyDLL("advapi32.dll")
	procCredReadW      = advapi32.NewProc("CredReadW")
	procCredWriteW     = advapi32.NewProc("CredWriteW")
	procCredDeleteW    = advapi32.NewProc("CredDeleteW")
	procCredEnumerateW = advapi32.NewProc("CredEnumerateW")
	procCredFree       = advapi32.NewProc("CredFree")
)

// credentialW 对应 Win32 CREDENTIALW 结构(仅 Windows 平台可用)。
type credentialW struct {
	Flags              uint32
	Type               uint32
	TargetName         *uint16
	Comment            *uint16
	LastWritten        syscall.Filetime
	CredentialBlobSize uint32
	CredentialBlob     uintptr
	Persist            uint32
	AttributeCount     uint32
	Attributes         uintptr
	TargetAlias        *uint16
	UserName           *uint16
}

// Credential 是凭据的 Go 表示。
type Credential struct {
	TargetName  string    `json:"target_name"`
	UserName    string    `json:"username"`
	Comment     string    `json:"comment"`
	LastWritten time.Time `json:"last_written"`
	// Password 明文密码,仅在读取单条凭据时填充。
	Password string `json:"password,omitempty"`
	// BlobSize 密码 blob 字节数(枚举列表使用,不返回明文)。
	BlobSize int `json:"blob_size,omitempty"`
}

// List 枚举当前用户的所有通用凭据(不含密码明文)。
func List() ([]Credential, error) {
	var count uint32
	var creds **credentialW
	r1, _, callErr := procCredEnumerateW.Call(0, 0, uintptr(unsafe.Pointer(&count)), uintptr(unsafe.Pointer(&creds)))
	if r1 == 0 {
		if errno, ok := callErr.(syscall.Errno); ok && errno == errNotFound {
			return []Credential{}, nil
		}
		return nil, fmt.Errorf("CredEnumerateW 失败: %w", callErr)
	}
	defer procCredFree.Call(uintptr(unsafe.Pointer(creds)))

	items := unsafe.Slice(creds, count)
	out := make([]Credential, 0, len(items))
	for _, c := range items {
		if c.Type != credTypeGeneric {
			continue
		}
		out = append(out, Credential{
			TargetName:  utf16PtrToString(c.TargetName),
			UserName:    utf16PtrToString(c.UserName),
			Comment:     utf16PtrToString(c.Comment),
			LastWritten: filetimeToTime(c.LastWritten),
			BlobSize:    int(c.CredentialBlobSize),
		})
	}
	return out, nil
}

// Read 读取指定目标的通用凭据(含密码)。
func Read(target string) (*Credential, error) {
	targetPtr, err := syscall.UTF16PtrFromString(target)
	if err != nil {
		return nil, err
	}
	var c *credentialW
	r1, _, callErr := procCredReadW.Call(uintptr(unsafe.Pointer(targetPtr)), credTypeGeneric, 0, uintptr(unsafe.Pointer(&c)))
	if r1 == 0 {
		if errno, ok := callErr.(syscall.Errno); ok && errno == errNotFound {
			return nil, fmt.Errorf("凭据 %q 不存在", target)
		}
		return nil, fmt.Errorf("CredReadW 失败: %w", callErr)
	}
	defer procCredFree.Call(uintptr(unsafe.Pointer(c)))

	out := &Credential{
		TargetName:  utf16PtrToString(c.TargetName),
		UserName:    utf16PtrToString(c.UserName),
		Comment:     utf16PtrToString(c.Comment),
		LastWritten: filetimeToTime(c.LastWritten),
	}
	if c.CredentialBlobSize > 0 && c.CredentialBlob != 0 {
		blob := unsafe.Slice((*byte)(unsafe.Pointer(c.CredentialBlob)), c.CredentialBlobSize)
		out.Password = blobToString(blob)
	}
	return out, nil
}

// Write 创建或更新通用凭据(同目标名直接覆盖)。
// 密码以 UTF-8 编码存入 blob;用户名、备注为 UTF-16 由系统转换。
func Write(target, username, password, comment string) error {
	if target == "" {
		return fmt.Errorf("目标名不能为空")
	}
	if len(password) > maxBlobSize {
		return fmt.Errorf("密码过长(%d 字节,上限 %d 字节)", len(password), maxBlobSize)
	}

	targetPtr, err := syscall.UTF16PtrFromString(target)
	if err != nil {
		return err
	}
	userPtr, err := syscall.UTF16PtrFromString(username)
	if err != nil {
		return err
	}
	commentPtr, err := syscall.UTF16PtrFromString(comment)
	if err != nil {
		return err
	}

	blob := []byte(password)
	c := credentialW{
		Type:       credTypeGeneric,
		TargetName: targetPtr,
		Comment:    commentPtr,
		Persist:    credPersistEnterprise,
		UserName:   userPtr,
	}
	if len(blob) > 0 {
		c.CredentialBlobSize = uint32(len(blob))
		c.CredentialBlob = uintptr(unsafe.Pointer(&blob[0]))
	}

	r1, _, callErr := procCredWriteW.Call(uintptr(unsafe.Pointer(&c)), 0)
	if r1 == 0 {
		return fmt.Errorf("CredWriteW 失败: %w", callErr)
	}
	return nil
}

// Update 更新已存在的凭据,仅修改非 nil 的字段(未提供的保持原值)。
func Update(target string, username, password, comment *string) error {
	cur, err := Read(target)
	if err != nil {
		return err
	}
	newUser, newPwd, newComment := cur.UserName, cur.Password, cur.Comment
	if username != nil {
		newUser = *username
	}
	if password != nil {
		newPwd = *password
	}
	if comment != nil {
		newComment = *comment
	}
	return Write(target, newUser, newPwd, newComment)
}

// Delete 删除指定目标的通用凭据。
func Delete(target string) error {
	targetPtr, err := syscall.UTF16PtrFromString(target)
	if err != nil {
		return err
	}
	r1, _, callErr := procCredDeleteW.Call(uintptr(unsafe.Pointer(targetPtr)), credTypeGeneric, 0)
	if r1 == 0 {
		if errno, ok := callErr.(syscall.Errno); ok && errno == errNotFound {
			return fmt.Errorf("凭据 %q 不存在", target)
		}
		return fmt.Errorf("CredDeleteW 失败: %w", callErr)
	}
	return nil
}

// utf16PtrToString 将 NUL 结尾的 UTF-16 字符串指针转为 Go string。
func utf16PtrToString(p *uint16) string {
	if p == nil {
		return ""
	}
	n := 0
	for ptr := unsafe.Pointer(p); *(*uint16)(ptr) != 0; n++ {
		ptr = unsafe.Pointer(uintptr(ptr) + 2)
	}
	return string(utf16.Decode(unsafe.Slice(p, n)))
}

// filetimeToTime 将 Windows FILETIME 转换为本地 time.Time。
func filetimeToTime(ft syscall.Filetime) time.Time {
	return time.Unix(0, ft.Nanoseconds()).Local()
}

// blobToString 解码密码 blob:优先 UTF-8(本工具写入的格式),
// 含 NUL 字节或非法 UTF-8 时尝试 UTF-16LE(兼容 cmdkey 等工具写入的凭据)。
func blobToString(b []byte) string {
	if len(b) == 0 {
		return ""
	}
	hasNUL := false
	for _, v := range b {
		if v == 0 {
			hasNUL = true
			break
		}
	}
	if !hasNUL && utf8.Valid(b) {
		return string(b)
	}
	if len(b)%2 == 0 {
		u16 := make([]uint16, 0, len(b)/2)
		for i := 0; i+1 < len(b); i += 2 {
			u16 = append(u16, uint16(b[i])|uint16(b[i+1])<<8)
		}
		if s := syscall.UTF16ToString(u16); s != "" {
			return s
		}
	}
	return string(b)
}

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/tools.go

go 复制代码
package mcp

import (
	"context"
	"encoding/json"
	"fmt"
	"strings"

	"wincred-mcp-server/internal/cred"
)

const (
	serverName    = "wincred-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 构造并注册全部工具。
// 工具命名参照 HashiCorp vault-mcp-server 的 KV 工具风格
// (list_secrets / read_secret / write_secret / delete_secret)。
func buildTools() []toolDef {
	return []toolDef{
		{
			tool: tool{
				Name:        "list_credentials",
				Description: "列出 Windows 凭据管理器中当前用户的通用凭据(账号密码类)。返回目标名、用户名、备注、最后写入时间与密码 blob 大小,不返回明文密码。可用 filter 按目标名模糊过滤。",
				InputSchema: toolInputSchema{
					Type: "object",
					Properties: map[string]property{
						"filter": {Type: "string", Description: "可选,按目标名子串过滤(不区分大小写)"},
					},
				},
			},
			handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
				return runList(args)
			},
		},
		{
			tool: tool{
				Name:        "read_credential",
				Description: "读取单条凭据详情。默认密码以掩码显示,设置 reveal=true 获取明文密码。",
				InputSchema: toolInputSchema{
					Type: "object",
					Properties: map[string]property{
						"target": {Type: "string", Description: "凭据目标名"},
						"reveal": {Type: "boolean", Description: "是否返回明文密码(默认 false)"},
					},
					Required: []string{"target"},
				},
			},
			handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
				return runRead(args)
			},
		},
		{
			tool: tool{
				Name:        "write_credential",
				Description: "创建或更新凭据(同目标名直接覆盖),存储账号密码到 Windows 凭据管理器。",
				InputSchema: toolInputSchema{
					Type: "object",
					Properties: map[string]property{
						"target":   {Type: "string", Description: "凭据目标名(唯一标识)"},
						"username": {Type: "string", Description: "用户名/账号"},
						"password": {Type: "string", Description: "密码"},
						"comment":  {Type: "string", Description: "可选备注"},
					},
					Required: []string{"target", "username", "password"},
				},
			},
			handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
				return runWrite(args)
			},
		},
		{
			tool: tool{
				Name:        "delete_credential",
				Description: "删除指定目标的凭据。",
				InputSchema: toolInputSchema{
					Type: "object",
					Properties: map[string]property{
						"target": {Type: "string", Description: "凭据目标名"},
					},
					Required: []string{"target"},
				},
			},
			handler: func(ctx context.Context, args map[string]interface{}) (string, error) {
				return runDelete(args)
			},
		},
	}
}

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

// runList 处理 list_credentials:枚举通用凭据,输出 JSON。
func runList(args map[string]interface{}) (string, error) {
	creds, err := cred.List()
	if err != nil {
		return "", err
	}
	if filter := strings.ToLower(strArg(args, "filter")); filter != "" {
		filtered := make([]cred.Credential, 0, len(creds))
		for _, c := range creds {
			if strings.Contains(strings.ToLower(c.TargetName), filter) {
				filtered = append(filtered, c)
			}
		}
		creds = filtered
	}
	if len(creds) == 0 {
		return "(没有匹配的通用凭据)", nil
	}
	b, err := json.MarshalIndent(creds, "", "  ")
	if err != nil {
		return "", err
	}
	return string(b), nil
}

// runRead 处理 read_credential:默认掩码密码,reveal=true 返回明文。
func runRead(args map[string]interface{}) (string, error) {
	target := strArg(args, "target")
	if target == "" {
		return "", fmt.Errorf("缺少 target 参数")
	}
	c, err := cred.Read(target)
	if err != nil {
		return "", err
	}
	if !boolArg(args, "reveal") {
		c.Password = "******(已掩码,设置 reveal=true 查看明文)"
	}
	b, err := json.MarshalIndent(c, "", "  ")
	if err != nil {
		return "", err
	}
	return string(b), nil
}

// runWrite 处理 write_credential。
func runWrite(args map[string]interface{}) (string, error) {
	target := strArg(args, "target")
	username := strArg(args, "username")
	password := strArg(args, "password")
	comment := strArg(args, "comment")
	if target == "" {
		return "", fmt.Errorf("缺少 target 参数")
	}
	if username == "" {
		return "", fmt.Errorf("缺少 username 参数")
	}
	if password == "" {
		return "", fmt.Errorf("password 不能为空")
	}
	if err := cred.Write(target, username, password, comment); err != nil {
		return "", err
	}
	return fmt.Sprintf("已保存凭据 %q(用户名 %q)", target, username), nil
}

// runDelete 处理 delete_credential。
func runDelete(args map[string]interface{}) (string, error) {
	target := strArg(args, "target")
	if target == "" {
		return "", fmt.Errorf("缺少 target 参数")
	}
	if err := cred.Delete(target); err != nil {
		return "", err
	}
	return fmt.Sprintf("已删除凭据 %q", target), 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 ""
}

// boolArg 从参数 map 中读取布尔值。
func boolArg(args map[string]interface{}, key string) bool {
	if v, ok := args[key]; ok {
		if b, ok := v.(bool); ok {
			return b
		}
	}
	return false
}

internal/mcp/server.go

go 复制代码
package mcp

import (
	"context"
	"encoding/json"
	"io"
)

// Server 是一个 stdio 传输的 MCP server。
type Server struct {
	tools []toolDef
}

// NewServer 创建 MCP server。
func NewServer() *Server {
	return &Server{tools: buildTools()}
}

// 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},
	}
}
相关推荐
Mrs_DongDong1 小时前
华为云 Windows 服务器远程桌面自定义端口排障指南
服务器·windows·华为云
Sagittarius_A*1 小时前
CVE-2026-42271:从 MCP stdio 测试接口看 LiteLLM 的命令执行风险与防御闭环
网络·windows·安全·cve·rce
水饺编程1 小时前
第5章,[Win32 章节] :圆角矩形教学插图绘制程序
c语言·c++·windows·visual studio
极小狐2 小时前
极狐GitLab Duo 功能更新:扩展 MCP 工具集、支持 MR 事件触发
运维·gitlab·agent·mr·极狐gitlab·mcp·极狐gitlab duo
名字还没想好☜2 小时前
Go 时间格式化为什么用 2006-01-02:time.Format/Parse 的参考时间、时区与解析踩坑
开发语言·后端·golang·go
公子小六15 小时前
基于.NET的Windows窗体编程之WinForms滑块与上下控件
windows·microsoft·c#·.net·winforms
FfHUCisI16 小时前
Golang Map 哈希表实现
golang·哈希算法·散列表
源代码•宸17 小时前
前置准备:定时微服务背景和现状
开发语言·经验分享·后端·微服务·云原生·架构·golang
李燚19 小时前
把规则搬回家:三个 BC 的贫血→充血重构实录(第103篇)
golang·agent·ddd·领域驱动设计·eino·deepflux·eino adk