本指南适用于希望构建MCP服务器的开发者。它描述了如何实现一个用于列出和添加用户的MCP。
MCP服务器是一个位于大型语言模型(LLM)和应用程序之间的封装器,将从LLM到应用程序的调用包装成JSON格式。 你可能会被诱惑通过fastapi和fastmcp来封装应用现有的API,但正如mostly harmless所述,这是一个糟糕的想法。
主要原因在于,LLM基于“下载”的互联网进行文本补全,并且只能专注于不超过大约100页文本的话题。很难用聊天填充这些页面,你可能从未遇到过这个限制。这也意味着你需要用户故事或任务来填充这本书,包括所有可能的失败和死胡同。在我们的示例中,我们将向系统添加用户“tux”。
这个想象中的书的前几页已经被系统提示和MCP工具及其参数的描述所填充。这个描述由工具的作者提供,因此在编写工具描述时可以非常详细。再多几行文字也不会造成伤害。
每个工具调用都有一个JSON覆盖层,所以你也想避免过多的工具调用。尽量减少工具的数量,并将相似的操作合并为一个工具。例如,如果你有一个与systemd交互的工具,那么你会有一个工具结合启用、禁用、启动和服务重启,而不是每个操作一个工具。
对于工具输出,不要犹豫尽可能多地组合信息。一个好的工具输出不应该只返回组ID(GID),还应该返回组名。
这里需要注意的是,你可以很容易地用太多的信息淹没LLM,比如返回find /的输出。这将完全填满LLM对话的想象之书。在这种情况下,修剪信息并为工具提供参数,如过滤输出。
这归结为以下几点:
verbose参数;LLM总是会使用它。[!NOTE] 始终记住: “上下文是王”
首先,我们需要构思一个用户故事。我们必须决定用户能够使用该工具做什么。
我们的用户故事很简单:“我想向系统添加一个用户。”
我们将使用Go进行此项目,并从这段简单的样板代码开始,该代码添加了工具“Foo”:
package main
import (
"context"
"flag"
"log/slog"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Foo工具的输入结构。
type FooInput struct {
Message string `json:"message,omitempty" jsonschema:"Foo工具的消息"`
}
// Foo工具的输出结构。
type FooOutput struct {
Response string `json:"response" jsonschema:"来自Foo工具的响应"`
}
// Foo函数实现了Foo工具。
func Foo(ctx context.Context, req *mcp.CallToolRequest, input FooInput) (
*mcp.CallToolResult, FooOutput, error,
) {
slog.Info("调用了Foo工具", "消息", input.Message)
return nil, FooOutput{Response: "Foo收到了你的消息:" + input.Message}, nil
}
func main() {
listenAddr := flag.String("http", "", "HTTP传输的地址,默认为stdio")
flag.Parse()
server := mcp.NewServer(&mcp.Implementation{Name: "useradd", Version: "v0.0.1"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "Foo",
Description: "一个简单的Foo工具",
}, Foo)
if *listenAddr == "" {
// 在stdio传输上运行服务器。
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
slog.Error("服务器失败", "错误", err)
}
} else {
// 创建可流式传输的HTTP处理器。
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, nil)
// 在HTTP传输上运行服务器。
slog.Info("服务器正在监听", "地址", *listenAddr)
if err := http.ListenAndServe(*listenAddr, handler); err != nil {
slog.Error("服务器失败", "错误", err)
}
}
}
要运行服务器,我们首先需要初始化Go依赖项:
go mod init github.com/mslacken/mcp-useradd
go mod tidy
现在可以通过以下命令运行服务器:
go run main.go -http localhost:8666
我们还可以通过以下命令在附加终端中运行基于JavaScript的浏览器:
npx @modelcontextprotocol/inspector http://localhost:8666 --transport http
在调用带有输入“Baar”的“Foo”工具后,我们会得到如下屏幕。

让我们分解一下我们的Go代码。在导入之后,我们立即有两个结构体管理我们的工具的输入和输出。Go有一个内置的数据结构序列化库。关键字json:"message,omitempty"告诉序列化库使用“message”作为变量名称。“omitempty”选项标记这是一个可选输入参数;如果为空,则变量不会出现在输出中。“jsonschema”参数描述了这个参数的作用以及期望的输入。尽管参数类型可以从结构体中推断出来,但描述至关重要。工具的方法通过构造输出结构体并返回它来返回消息。
该方法本身被添加到MCP服务器实例中,也需要具有名称和描述。工具的描述也非常重要,这是LLM了解工具功能的唯一方式。
本节的完整代码可以在git提交simple user list中找到。
由于我们不想在这个早期阶段改变系统,整个项目都需要一个工具来获取系统实际的用户。所以我们添加工具get_users。
为了简单起见,我们将仅使用getent passwd的输出来完成此任务。
可以执行此任务的函数如下:
// User结构表示单个用户帐户。
type User struct {
Username string `json:"username"`
Password string `json:"password"`
UID int `json:"uid"`
GID int `json:"gid"`
Comment string `json:"comment"`
Home string `json:"home"`
Shell string `json:"shell"`
}
func ListUsers(ctx context.Context, req *mcp.CallToolRequest, _ ListUsersInput) (
*mcp.CallToolResult, ListUsersOutput, error,
) {
slog.Info("调用了ListUsers工具")
cmd := exec.Command("getent", "passwd")
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()
if err != nil {
return nil, ListUsersOutput{}, err
}
var users []User
scanner := bufio.NewScanner(&out)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) != 7 {
continue
}
uid, _ := strconv.Atoi(parts[2])
gid, _ := strconv.Atoi(parts[3])
users = append(users, User{
Username: parts[0],
Password: parts[1],
UID: uid,
GID: gid,
Comment: parts[4],
Home: parts[5],
Shell: parts[6],
})
}
return nil, ListUsersOutput{Users: users}, nil
}
当你检查此方法时,你会发现输出只是一个用户及其属性的列表(称为Go中的切片)。
虽然看起来正确,但此方法缺少一些重要事项:
提出这样的问题并提供相关信息是编写MCP工具时最重要的部分。这些信息对LLM来说是未知的,但可能会定义添加用户时的输入参数。
相反,一个真实的此类工具的实现将把所有“gid < 1000”的用户视为系统用户。
我们还将添加一个对getent group的调用来获取用户所属的所有组。
当现在调用此工具时,输出也应该包含组信息,因为这将使LLM能够完成诸如“将用户chris添加到系统并使其成为witcher组的一部分”之类的任务。
本节的完整代码可以在git提交better user list中找到。
有了这些信息,工具调用现在如下所示:
// ListUsers函数实现了ListUsers工具。
func ListUsers(ctx context.Context, req *mcp.CallToolRequest, _ ListUsersInput) (
*mcp.CallToolResult, ListUsersOutput, error,
) {
slog.Info("调用了ListUsers工具")
cmd := exec.Command("getent", "passwd")
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()
if err != nil {
return nil, ListUsersOutput{}, err
}
var users []User
scanner := bufio.NewScanner(&out)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) != 7 {
continue
}
uid, _ := strconv.Atoi(parts[2])
gid, _ := strconv.Atoi(parts[3])
users = append(users, User{
Username: parts[0],
Password: parts[1],
UID: uid,
GID: gid,
Comment: parts[4],
Home: parts[5],
Shell: parts[6],
IsSystemUser: gid < 1000,
Groups: []string{},
})
}
cmd = exec.Command("getent", "group")
var groupOut bytes.Buffer
cmd.Stdout = &groupOut
err = cmd.Run()
if err != nil {
return nil, ListUsersOutput{}, err
}
var groups []Group
groupScanner := bufio.NewScanner(&groupOut)
for groupScanner.Scan() {
line := groupScanner.Text()
parts := strings.Split(line, ":")
if len(parts) != 4 {
continue
}
gid, _ := strconv.Atoi(parts[2])
members := strings.Split(parts[3], ",")
groups = append(groups, Group{
Name: parts[0],
Password: parts[1],
GID: gid,
Members: members,
})
groupName := parts[0]
for _, member := range members {
for i, user := range users {
if user.Username == member {
users[i].Groups = append(users[i].Groups, groupName)
}
}
}
}
return nil, ListUsersOutput{Users: users, Groups: groups}, nil
}
结果如下所示:

这现在为LLM提供了关于系统的更多有意义的信息,例如,如果我要求“将chris添加到系统并将其添加到witcher组”,而系统中没有名为‘witcher’的组,而是名为‘hexer’,它可能会发现这是一个德语系统,也许‘hexer’就是正确的组。
作为锦上添花,我们现在重构列表方法,以便可以传递用户名作为可选参数。这样可以限制工具的输出。这对于许多后续工具调用非常重要。对于真正的生产级软件,参数也可以是正则表达式甚至是模糊匹配。 本节的完整代码可以在git提交filter with a username中找到。 这将工具调用转换为:
// ListUsers函数实现了ListUsers工具。
func ListUsers(ctx context.Context, req *mcp.CallToolRequest, input ListUsersInput) (
*mcp.CallToolResult, ListUsersOutput, error,
) {
slog.Info("调用了ListUsers工具")
users, err := getUsers(input.Username)
if err != nil {
return nil, ListUsersOutput{}, err
}
if input.Username != "" {
return nil, ListUsersOutput{Users: users}, nil
}
groups, err := getGroups()
if err != nil {
return nil, ListUsersOutput{}, err
}
for _, group := range groups {
for _, member := range group.Members {
for i, user := range users {
if user.Username == member {
users[i].Groups = append(users[i].Groups, group.Name)
}
}
}
}
return nil, ListUsersOutput{Users: users, Groups: groups}, nil
}
func getUsers(username string) ([]User, error) {
args := []string{"passwd"}
if username != "" {
args = append(args, username)
}
cmd := exec.Command("getent", args...)
var out bytes.Buffer
cmd.Stdout = &out
err := cmd.Run()
if err != nil {
return nil, err
}
var users []User
scanner := bufio.NewScanner(&out)
for scanner.Scan() {
line := scanner.Text()
parts := strings.Split(line, ":")
if len(parts) != 7 {
continue
}
uid, _ := strconv.Atoi(parts[2])
gid, _ := strconv.Atoi(parts[3])
users = append(users, User{
Username: parts[0],
Password: parts[1],
UID: uid,
GID: gid,
Comment: parts[4],
Home: parts[5],
Shell: parts[6],
IsSystemUser: gid < 1000,
Groups: []string{},
})
}
if username != "" && len(users) > 0 {
groups, err := getUserGroups(username)
if err == nil {
users[0].Groups = groups
}
}
return users, nil
}
仍然有许多可以添加到这里作为参数的东西,比如只过滤非系统用户,检查与用户交互的'pam.d'选项等...
为了完整性,我们现在添加一个用于添加用户的工具,该工具通过SUSE特定的useradd调用来实现。
本节的完整代码可以在git提交added user add method中找到。
一个工具可以如下所示:
// AddUser工具的输入结构。
type AddUserInput struct {
Username string `json:"username" jsonschema:"新帐户的用户名"`
BaseDir string `json:"base_dir,omitempty" jsonschema:"新帐户主目录的基础目录"`
Comment string `json:"comment,omitempty" jsonschema:"新帐户的GECOS字段"`
HomeDir string `json:"home_dir,omitempty" jsonschema:"新帐户的主目录"`
ExpireDate string `json:"expire_date,omitempty" jsonschema:"新帐户的到期日期"`
Inactive int `json:"inactive,omitempty" jsonschema:"新帐户的密码不活动期"`
Gid string `json:"gid,omitempty" jsonschema:"新帐户的主要组的名称或ID"`
Groups []string `json:"groups,omitempty" jsonschema:"新帐户的补充组列表"`
SkelDir string `json:"skel_dir,omitempty" jsonschema:"替代骨架目录"`
CreateHome bool `json:"create_home,omitempty" jsonschema:"创建用户的主目录"`
NoCreateHome bool `json:"no_create_home,omitempty" jsonschema:"不创建用户的主目录"`
NoUserGroup bool `json:"no_user_group,omitempty" jsonschema:"不创建与用户同名的组"`
NonUnique bool `json:"non_unique,omitempty" jsonschema:"允许创建具有重复(非唯一)UID的用户"`
Password string `json:"password,omitempty" jsonschema:"新帐户的加密密码"`
System bool `json:"system,omitempty" jsonschema:"创建系统帐户"`
Shell string `json:"shell,omitempty" jsonschema:"新帐户的登录shell"`
Uid int `json:"uid,omitempty" jsonschema:"新帐户的用户ID"`
UserGroup bool `json:"user_group,omitempty" jsonschema:"创建与用户同名的组"`
SelinuxUser string `json:"selinux_user,omitempty" jsonschema:"SELinux用户映射的特定SEUSER"`
SelinuxRange string `json:"selinux_range,omitempty" jsonschema:"SELinux用户映射的特定MLS范围"`
}
func AddUser(ctx context.Context, req *mcp.CallToolRequest, input AddUserInput) (
*mcp.CallToolResult, AddUserOutput, error,
) {
slog.Info("调用了AddUser工具")
args := []string{}
if input.BaseDir != "" {
args = append(args, "-b", input.BaseDir)
}
/*
Cutted a lot of command line parameter settings
*/
if input.SelinuxUser != "" {
args = append(args, "-Z", input.SelinuxUser)
}
args = append(args, input.Username)
cmd := exec.Command("useradd", args...)
var out bytes.Buffer
cmd.Stdout = &out
cmd.Stderr = &out
err := cmd.Run()
if err != nil {
return nil, AddUserOutput{Success: false, Message: out.String()}, err
}
return nil, AddUserOutput{Success: true, Message: out.String()}, nil
}
对于真正的MCP服务器,工具也可以意识到标准的主目录位置,并仅在这些位置提供btrfs相关选项,同样,如果启用了SELinux,则只需添加这些选项。 但我认为现在很清楚MCP工具是如何工作的。