使用说明
如何创建MCP服务器
本指南适用于想要构建MCP服务器的开发人员。它描述了如何实现用于列出和添加用户的MCP。
什么是MCP服务器?
MCP服务器是一个位于大型语言模型(LLM)和应用程序之间的包装器,以JSON形式包装从LLM到应用程序的调用。 您可能会想通过以下方式包装应用程序的现有API fastapi和fastmcp,但如所述 基本无害,这是个坏主意。
其主要原因是LLM基于“下载”的互联网进行文本完成,并且可以专注于不超过约100页文本的主题。很难用聊天填满这些页面,你可能从未遇到过这个限制。这也意味着你需要一个用户故事或任务来填充这本书,包括所有可能的失败和死胡同。在我们的示例中,我们将 add a user "tux" to the system.
这本虚构的书的第一页已经被 系统提示 以及MCP工具及其参数的描述。此描述由工具的作者提供,因此您在编写工具描述时可以非常具有描述性。多写几行文字不会有什么坏处。
每个工具调用都有一个JSON覆盖,因此您还需要避免太多的工具调用。尽量减少工具的数量,并将类似的操作组合到一个工具中。例如,如果你有一个工具与 系统守护进程,您将只有一个工具来组合启用、禁用、启动和重新启动服务,而不是每个操作都有一个工具。
对于工具输出,不要犹豫,尽可能多地组合信息。一个好的工具的输出不应该只返回组ID(GID),还应该返回组名。
这里的警告是,您很容易用太多的信息使LLM过饱和,例如返回 find /这将完全填满LLM对话的想象书。在这种情况下,修剪信息并为工具提供参数,如过滤输出。
这归结为以下几点:
- 为这些工具编写一个用户故事。
- 为工具及其参数提供详尽的描述。
- 将工具精简为合理的操作,并毫不犹豫地添加许多参数。
- 一个工具调用可以有多个API调用。
- 避免过载:LLM不能忽略输出,因此您有责任修剪信息。 还有我一路上学到的以下加分:
- 避免a
verbose参数;法学硕士将始终使用它。
[!注意]永远记住: “背景为王”
构建示例MCP服务器
用户故事
首先,我们必须提出一个用户故事。我们必须决定用户应该能够使用该工具做什么。
我们的用户故事很简单:“我想在系统中添加一个用户。”
第一步
我们将在这个项目中使用Go,并从这个简单的样板代码开始,它添加了工具“Foo”:
package main
import (
"context"
"flag"
"log/slog"
"net/http"
"github.com/modelcontextprotocol/go-sdk/mcp"
)
// Input struct for the Foo tool.
type FooInput struct {
Message string `json:"message,omitempty" jsonschema:"a message for the Foo tool"`
}
// Output struct for the Foo tool.
type FooOutput struct {
Response string `json:"response" jsonschema:"the response from the Foo tool"`
}
// Foo function implements the Foo tool.
func Foo(ctx context.Context, req *mcp.CallToolRequest, input FooInput) (
*mcp.CallToolResult, FooOutput, error,
) {
slog.Info("Foo tool called", "message", input.Message)
return nil, FooOutput{Response: "Foo received your message: " + input.Message}, nil
}
func main() {
listenAddr := flag.String("http", "", "address for http transport, defaults to stdio")
flag.Parse()
server := mcp.NewServer(&mcp.Implementation{Name: "useradd", Version: "v0.0.1"}, nil)
mcp.AddTool(server, &mcp.Tool{
Name: "Foo",
Description: "A simple Foo tool",
}, Foo)
if *listenAddr == "" {
// Run the server on the stdio transport.
if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil {
slog.Error("Server failed", "error", err)
}
} else {
// Create a streamable HTTP handler.
handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server {
return server
}, nil)
// Run the server on the HTTP transport.
slog.Info("Server listening", "address", *listenAddr)
if err := http.ListenAndServe(*listenAddr, handler); err != nil {
slog.Error("Server failed", "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代码。之后 imports,我们马上有两个 structs 管理我们工具的输入和输出。Go有一个内置的数据结构序列化器。关键字 json:"message,omitempty" 告诉序列化库使用“message”作为变量的名称。更重要的是第二个选项“omitempty”,它将此标记为可选输入参数;如果为空,则变量将不在输出中。“jsonschema”参数描述了此参数的作用以及预期的输入。虽然参数的类型是从结构中推断出来的,但描述至关重要。该工具的方法通过构造输出结构并返回它来返回消息。
方法本身被添加到MCP服务器实例中,还需要有一个名称和描述。工具的描述也非常重要 仅 LLM了解工具正在做什么的方法。
将工具具体化
本节的完整代码可以在git commit中找到 简单用户列表
因为我们不想在这个早期阶段改变系统,整个项目需要一个工具来获取系统的实际用户。那么,让我们添加工具 get_users.
为了简单起见,我们只使用 getent passwd 完成这项任务。
可以执行此操作的函数如下
// User struct represents a single user account.
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 tool called")
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中的slice)。
虽然这看起来是正确的,但这种方法缺少了一些重要的东西
- 用户类型,是系统还是用户,是人类的用户帐户
- 其中组是用户部分
问这类问题,然后提供信息是 最重要的 在编写MCP工具时。LLM不知道此信息,但在添加用户时可能会定义输入参数。
与此相反,这种工具的真正实现只会将所有gid<1000的用户视为系统用户。
此外,我们都会添加一个呼叫 getent group 获取用户所属的所有组。
现在调用此工具时,输出组信息也是明智的,因为这将使LLM能够完成诸如“将用户chris添加到系统中并使其成为巫师组的一部分”之类的任务。
本节的完整代码可以在git commit中找到 更好的用户列表
有了这些信息,工具调用现在喜欢
// ListUsers function implements the ListUsers tool.
func ListUsers(ctx context.Context, req *mcp.CallToolRequest, _ ListUsersInput) (
*mcp.CallToolResult, ListUsersOutput, error,
) {
slog.Info("ListUsers tool called")
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添加到系统中并将他添加到巫师组”,系统上不会是“巫师”组,但在被称为“hexer”的组上,它可能会发现这是德国系统,也许“hexer'是正确的组。
雪上加霜的是,我们现在重构了list方法,以便可以将用户名作为可选参数传递。通过这种方式,可以限制工具的输出。这对于后续的许多工具调用都很重要。对于实际的生产花园软件,参数也可以是正则表达式,甚至是模糊匹配。 本节的完整代码可以在git commit中找到 使用用户名进行筛选 这将工具调用转换为
// ListUsers function implements the ListUsers tool.
func ListUsers(ctx context.Context, req *mcp.CallToolRequest, input ListUsersInput) (
*mcp.CallToolResult, ListUsersOutput, error,
) {
slog.Info("ListUsers tool called")
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 commit中找到 添加用户添加方法
工具可以看起来像
// Input struct for the AddUser tool.
type AddUserInput struct {
Username string `json:"username" jsonschema:"the username of the new account"`
BaseDir string `json:"base_dir,omitempty" jsonschema:"the base directory for the home directory of the new account"`
Comment string `json:"comment,omitempty" jsonschema:"the GECOS field of the new account"`
HomeDir string `json:"home_dir,omitempty" jsonschema:"the home directory of the new account"`
ExpireDate string `json:"expire_date,omitempty" jsonschema:"the expiration date of the new account"`
Inactive int `json:"inactive,omitempty" jsonschema:"the password inactivity period of the new account"`
Gid string `json:"gid,omitempty" jsonschema:"the name or ID of the primary group of the new account"`
Groups []string `json:"groups,omitempty" jsonschema:"the list of supplementary groups of the new account"`
SkelDir string `json:"skel_dir,omitempty" jsonschema:"the alternative skeleton directory"`
CreateHome bool `json:"create_home,omitempty" jsonschema:"create the user's home directory"`
NoCreateHome bool `json:"no_create_home,omitempty" jsonschema:"do not create the user's home directory"`
NoUserGroup bool `json:"no_user_group,omitempty" jsonschema:"do not create a group with the same name as the user"`
NonUnique bool `json:"non_unique,omitempty" jsonschema:"allow to create users with duplicate (non-unique) UID"`
Password string `json:"password,omitempty" jsonschema:"the encrypted password of the new account"`
System bool `json:"system,omitempty" jsonschema:"create a system account"`
Shell string `json:"shell,omitempty" jsonschema:"the login shell of the new account"`
Uid int `json:"uid,omitempty" jsonschema:"the user ID of the new account"`
UserGroup bool `json:"user_group,omitempty" jsonschema:"create a group with the same name as the user"`
SelinuxUser string `json:"selinux_user,omitempty" jsonschema:"the specific SEUSER for the SELinux user mapping"`
SelinuxRange string `json:"selinux_range,omitempty" jsonschema:"the specific MLS range for the SELinux user mapping"`
}
func AddUser(ctx context.Context, req *mcp.CallToolRequest, input AddUserInput) (
*mcp.CallToolResult, AddUserOutput, error,
) {
slog.Info("AddUser tool called")
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工具是如何崩溃的。
