forge-toolkit: хелперы для разработки MCP-серверов на Go. - toolkit: args/schema/result/env/probe/server - toolkit/validate: валидаторы синтаксиса - configreload: live-reload конфига по контент-хэшу - docs: ARCHITECTURE, quickstart, mcp-contract - template/ и examples/hello-tool/ - CI (Gitea Actions), лицензия Apache-2.0
8.9 KiB
Архитектура MCP-серверов на forge-toolkit
Кому: разработчику, который пишет MCP-сервер на Go с этим тулкитом. После прочтения ты должен уметь написать сервер любого домена: файлы, БД, HTTP, DevOps и т.д.
Эталон рядом: template/ (каркас) и
examples/hello-tool/ (минимальный рабочий пример).
1. Что такое сервер на forge-toolkit
MCP-сервер — самостоятельная консольная программа: читает JSON-RPC из stdin, пишет в stdout. Один сервер решает одну задачу и даёт один или несколько инструментов. Никакого «заодно могу и…».
Сервер собирается из:
- каркаса —
toolkit/toolkit/validate/configreload(домен-агностичные хелперы); - домена — код конкретной задачи (политики, клиенты, маппинг аргументов).
Домен живёт отдельно от каркаса; каркас не расширяется «на будущее».
2. Три столпа
- stdio-only. Транспорт — stdin/stdout (
mcp.StdioTransport). Сервер не поднимает HTTP/SSE/TCP и не заводит session-pool: хост сам решает, как запускать процесс (один на сервер, один на агента и т.д.). - Один потокобезопасный Manager. Состояние процесса — один объект-
диспетчер. Если он изменяемый (пулы соединений, кэши) — под мутексом
(
sync.RWMutex): инструменты вызываются конкурентно. - Ручные схемы. Инструмент объявляется явно:
&mcp.Tool{Name, Description, InputSchema}+ обработчик.InputSchema— JSON Schema 2020-12, собирается хелперамиtoolkit.Schema/StrProps/....
3. Жизненный цикл main.go
func main() {
if toolkit.Health() { // --health → печатает "ok" и выходит (дешёво)
return
}
mgr := mydomain.NewManager() // один Manager на процесс
defer mgr.Close()
if err := toolkit.Run("my-server", func(s *mcp.Server) {
tools.RegisterAll(s, mgr)
}); err != nil {
fmt.Fprintf(os.Stderr, "my-server: %v\n", err)
os.Exit(1)
}
}
toolkit.Run поднимает mcp.NewServer и stdio-цикл с graceful shutdown по
SIGINT/SIGTERM.
4. Инструмент: объявление и схема
Имя инструмента — без префикса сервера (read_file, run_query): хост
добавит свой префикс при регистрации.
mcp.AddTool(s, &mcp.Tool{
Name: "read_file",
Description: "Читает файл внутри разрешённых корней. Read-only.",
InputSchema: toolkit.Schema(map[string]any{
"path": toolkit.StrProps("путь относительно корня", true),
}, []string{"path"}),
}, func(ctx context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
args := toolkit.RequestArgs(req)
path, err := toolkit.RequireString(args, "path")
if err != nil {
return toolkit.Error(err.Error()), nil, nil
}
// ... доменная работа ...
return toolkit.Text("..."), nil, nil
})
properties в схеме — всегда объект (даже пустой): часть провайдеров
отклоняет "properties": null.
5. Результат и ошибки
mcp.CallToolResult:
- успех —
toolkit.Text(msg); - доменная ошибка (модель должна исправиться) —
toolkit.Error(msg)(IsError=true), цикл агента продолжается; - инфраструктурный сбой — вернуть Go-
error: вызов считается неудачным.
Правило: всё, что модель может поправить (неверный аргумент, не найдено,
недостаточно прав) — toolkit.Error; всё, что сломано на стороне сервера
(диск, сеть, паника) — Go-error.
6. Хелперы аргументов
toolkit.RequestArgs(req) → map[string]any. Далее:
| Хелпер | Назначение |
|---|---|
GetString(args, key, def) |
строка (не-строки приводятся через %v) |
RequireString(args, key) |
непустая строка или ошибка |
GetInt(args, key, def) |
int (принимает float64/строку) |
GetBool(args, key, def) |
bool |
GetStringArray / GetIntArray |
непустые срезы |
GetStringMap(args, key) |
объект строк (например, заголовки) |
Валидация аргументов — в обработчике, а не в схеме: схема подсказывает модели, обработчик — источник истины.
7. Конфиг и live-reload
Сервер читает свой конфиг из файла (по флагу -config) и/или из каталога
тенанта FORGE_TENANT_CONFIG/<server>.json. Для перечитывания на лету —
configreload:
loader := configreload.New(path, parseConfig) // сверяет контент по sha256
cfg, err := loader.Get() // перечитывает только при изменении
${VAR} в конфиге разворачивается через toolkit.Expand; отсутствующая
переменная даёт пустую строку — дальнейшая валидация отвергнет пустое
обязательное поле (fail-closed).
8. Безопасность
- Минимум прав по умолчанию. Политики задают явные allow/deny; дефолт — запрет, а не разрешение.
- Ограничивай область. Пути — внутри разрешённых корней (jail); SQL — только параметризованный и read-only там, где это возможно; сеть — только разрешённые схемы/хосты.
- Ограничивай время. Любая внешняя операция — с
contextи таймаутом; инструмент не должен подвешивать сервер. - Секреты — только из окружения (
${VAR}), не в конфиге и не в коде.
9. Approval и probe (эмитированные паттерны)
Инструменты, выполняющие side-effect, могут объявить permission-паттерны: хост использует их для политики одобрения, а probe-вызов позволяет узнать паттерны без выполнения действия.
toolkit.RegisterPatternTool(s, tool, func(args map[string]any) (patterns, always []string) {
return []string{"write:" + toolkit.GetString(args, "path", "")}, nil
}, handler)
Probe-вызов (_meta["forge.permission_patterns"]=true) возвращает паттерны в
_meta и не выполняет действие (см. mcp-contract.md).
10. Тестирование
- Юнит-тесты домена и парсинга конфига;
go test -race ./.... - Для обработчиков — прямой вызов с собранным
*mcp.CallToolRequest. - Проверяй probe: действие не выполняется, паттерны возвращаются.
11. Definition of Done
- Отдельный Go-модуль, stdio-only, один потокобезопасный Manager.
main.goиспользуетtoolkit.Health()иtoolkit.Run(...).- Каркас — из
toolkit/toolkit/validate/configreload; домен — в своём пакете. - Конфиг с
${VAR}(fail-closed) и live-reload; статический-config— фолбэк. - Side-effecting инструменты объявляют паттерны через
RegisterPatternTool; дефолт политики — least-privilege. - Юнит-тесты;
go test -race ./...зелёный. README.md: инструменты, безопасность, подключение, схема approval.go mod tidy,golangci-lint run ./...,gofmt— чисто.