chore: initial release v0.1.0
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
This commit is contained in:
@@ -0,0 +1,192 @@
|
||||
# Архитектура MCP-серверов на forge-toolkit
|
||||
|
||||
> **Кому:** разработчику, который пишет MCP-сервер на Go с этим тулкитом.
|
||||
> После прочтения ты должен уметь написать сервер любого домена: файлы, БД,
|
||||
> HTTP, DevOps и т.д.
|
||||
|
||||
Эталон рядом: [`template/`](../template) (каркас) и
|
||||
[`examples/hello-tool/`](../examples/hello-tool) (минимальный рабочий пример).
|
||||
|
||||
---
|
||||
|
||||
## 1. Что такое сервер на forge-toolkit
|
||||
|
||||
MCP-сервер — **самостоятельная консольная программа**: читает JSON-RPC из
|
||||
stdin, пишет в stdout. Один сервер решает **одну задачу** и даёт один или
|
||||
несколько инструментов. Никакого «заодно могу и…».
|
||||
|
||||
Сервер собирается из:
|
||||
- **каркаса** — `toolkit` / `toolkit/validate` / `configreload` (домен-агностичные
|
||||
хелперы);
|
||||
- **домена** — код конкретной задачи (политики, клиенты, маппинг аргументов).
|
||||
|
||||
Домен живёт отдельно от каркаса; каркас не расширяется «на будущее».
|
||||
|
||||
---
|
||||
|
||||
## 2. Три столпа
|
||||
|
||||
1. **stdio-only.** Транспорт — stdin/stdout (`mcp.StdioTransport`). Сервер не
|
||||
поднимает HTTP/SSE/TCP и не заводит session-pool: хост сам решает, как
|
||||
запускать процесс (один на сервер, один на агента и т.д.).
|
||||
2. **Один потокобезопасный Manager.** Состояние процесса — один объект-
|
||||
диспетчер. Если он изменяемый (пулы соединений, кэши) — под мутексом
|
||||
(`sync.RWMutex`): инструменты вызываются конкурентно.
|
||||
3. **Ручные схемы.** Инструмент объявляется явно:
|
||||
`&mcp.Tool{Name, Description, InputSchema}` + обработчик. `InputSchema` —
|
||||
JSON Schema 2020-12, собирается хелперами `toolkit.Schema/StrProps/...`.
|
||||
|
||||
---
|
||||
|
||||
## 3. Жизненный цикл `main.go`
|
||||
|
||||
```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`): хост
|
||||
добавит свой префикс при регистрации.
|
||||
|
||||
```go
|
||||
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`:
|
||||
|
||||
```go
|
||||
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-вызов позволяет узнать
|
||||
паттерны **без выполнения действия**.
|
||||
|
||||
```go
|
||||
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`](mcp-contract.md)).
|
||||
|
||||
---
|
||||
|
||||
## 10. Тестирование
|
||||
|
||||
- Юнит-тесты домена и парсинга конфига; `go test -race ./...`.
|
||||
- Для обработчиков — прямой вызов с собранным `*mcp.CallToolRequest`.
|
||||
- Проверяй probe: действие не выполняется, паттерны возвращаются.
|
||||
|
||||
---
|
||||
|
||||
## 11. Definition of Done
|
||||
|
||||
1. Отдельный Go-модуль, stdio-only, один потокобезопасный Manager.
|
||||
2. `main.go` использует `toolkit.Health()` и `toolkit.Run(...)`.
|
||||
3. Каркас — из `toolkit`/`toolkit/validate`/`configreload`; домен — в своём пакете.
|
||||
4. Конфиг с `${VAR}` (fail-closed) и live-reload; статический `-config` — фолбэк.
|
||||
5. Side-effecting инструменты объявляют паттерны через `RegisterPatternTool`;
|
||||
дефолт политики — least-privilege.
|
||||
6. Юнит-тесты; `go test -race ./...` зелёный.
|
||||
7. `README.md`: инструменты, безопасность, подключение, схема approval.
|
||||
8. `go mod tidy`, `golangci-lint run ./...`, `gofmt` — чисто.
|
||||
Reference in New Issue
Block a user