返回市场
用户添加

用户添加

作者:mslacken2 星标更新:2025-11-18

项目介绍

如何创建一个MCP服务器

本指南适用于希望构建MCP服务器的开发者。它描述了如何实现一个用于列出和添加用户的MCP。

什么是MCP服务器?

MCP服务器是一个位于大型语言模型(LLM)和应用程序之间的封装器,将从LLM到应用程序的调用包装成JSON格式。 你可能会被诱惑通过fastapi和fastmcp来封装应用现有的API,但正如mostly harmless所述,这是一个糟糕的想法。

主要原因在于,LLM基于“下载”的互联网进行文本补全,并且只能专注于不超过大约100页文本的话题。很难用聊天填充这些页面,你可能从未遇到过这个限制。这也意味着你需要用户故事或任务来填充这本书,包括所有可能的失败和死胡同。在我们的示例中,我们将向系统添加用户“tux”

这个想象中的书的前几页已经被系统提示和MCP工具及其参数的描述所填充。这个描述由工具的作者提供,因此在编写工具描述时可以非常详细。再多几行文字也不会造成伤害。

每个工具调用都有一个JSON覆盖层,所以你也想避免过多的工具调用。尽量减少工具的数量,并将相似的操作合并为一个工具。例如,如果你有一个与systemd交互的工具,那么你会有一个工具结合启用、禁用、启动和服务重启,而不是每个操作一个工具。

对于工具输出,不要犹豫尽可能多地组合信息。一个好的工具输出不应该只返回组ID(GID),还应该返回组名。

这里需要注意的是,你可以很容易地用太多的信息淹没LLM,比如返回find /的输出。这将完全填满LLM对话的想象之书。在这种情况下,修剪信息并为工具提供参数,如过滤输出。

这归结为以下几点:

  • 为工具准备用户故事。
  • 提供详细的工具及其参数描述。
  • 将工具压缩为合理的操作,并不犹豫添加许多参数。
  • 工具调用可以有多个API调用。
  • 避免超载:LLM无法忽略输出,因此你负责修剪信息。 还有一个我在过程中学到的额外要点:
  • 避免使用verbose参数;LLM总是会使用它。

[!NOTE] 始终记住: “上下文是王”

构建一个示例MCP服务器

用户故事

首先,我们需要构思一个用户故事。我们必须决定用户能够使用该工具做什么。

我们的用户故事很简单:“我想向系统添加一个用户。”

第一步

我们将使用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”工具后,我们会得到如下屏幕。

调用了工具Foo 输入“Baar” 响应是{"response": "Foo收到了你的消息:Baar"}

让我们分解一下我们的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
}

结果如下所示: 调用了ListUsers工具 输出是一个结构化的用户列表

这现在为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工具是如何工作的。