MCP服务器构建工具

MCP服务器是大型语言模型(LLM)与应用程序之间的中间件,通过JSON封装调用,适用于开发者构建用户管理工具。

  • by mslacken
  • Go
  • 本地部署
  • 2 星标
  • 更新于
在 GitHub 查看 mslacken/mcp-useradd

使用说明

如何创建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工具是如何崩溃的。