告别 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 方法分发
两个设计目标:
- 零依赖 :MCP 协议用
encoding/json手写实现,Windows API 用syscall直接调用,go.mod里干干净净; - 双形态:同一个 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 消息循环 。协议核心就四个方法:initialize、tools/list、tools/call、ping。用 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 |
target、username、password(必填)、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,那些复杂概念正是为这些场景存在的。
安全注意事项
最后强调几条底线:
- MCP 客户端及背后的 LLM 可以通过
read_credential(reveal=true)拿到明文密码,请只在受信任的本地环境注册此服务; - 凭据持久化为
CRED_PERSIST_ENTERPRISE,随用户配置文件漫游,登录域环境时注意这一点; - 本工具只能读写当前 Windows 用户的凭据存储,无法访问其他用户的凭据(这是系统安全边界,也是特性而非缺陷);
- 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, ¶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},
}
}