chore: initial release v0.1.0
ci / test (push) Failing after 5s
ci / lint (push) Failing after 4s

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:
Maksim Totmin
2026-09-14 00:23:08 +07:00
commit c398045664
43 changed files with 2581 additions and 0 deletions
+192
View File
@@ -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` — чисто.
+71
View File
@@ -0,0 +1,71 @@
# Контракт на проводе
Сервер — стандартный MCP-сервер (JSON-RPC 2.0 через stdio). Здесь зафиксировано
то, что важно для корректной работы с хостом: формат результата и
forge-специфичный probe.
## `CallToolResult`
Успех:
```json
{ "content": [ { "type": "text", "text": "..." } ] }
```
Доменная ошибка (модель должна увидеть и исправить):
```json
{ "content": [ { "type": "text", "text": "не найдено: ..." } ], "isError": true }
```
- `isError: true` — ошибка **инструмента**, а не транспорта: цикл агента
продолжается.
- Инфраструктурный сбой возвращается как Go-`error` (JSON-RPC error), а не
как `isError`.
Хелперы: `toolkit.Text`, `toolkit.Error`.
## Probe: эмитированные permission-паттерны
Инструменты с побочными эффектами могут сообщать хосту **канонические
паттерны** доступа. Хост вызывает инструмент в режиме probe, получает
паттерны и применяет политику одобрения, **не выполняя действие**.
Запрос probe — стандартное поле `_meta` вызова:
```json
{
"method": "tools/call",
"params": {
"name": "write_file",
"arguments": { "path": "a.txt", "content": "hi" },
"_meta": { "forge.permission_patterns": true }
}
}
```
Ответ probe — паттерны в `_meta`, без побочного эффекта:
```json
{
"content": [],
"_meta": {
"forge.patterns": ["write:a.txt"],
"forge.always": []
}
}
```
Ключи (константы в `toolkit/probe.go`):
| Ключ | Смысл |
|---|---|
| `forge.permission_patterns` | флаг запроса probe |
| `forge.patterns` | паттерны, зависящие от аргументов |
| `forge.always` | паттерны, действующие всегда |
| `forge.supports_permission_patterns` | флаг в `Tool.Meta`: сервер поддерживает probe |
Инструменты без побочных эффектов probe не поддерживают — хост не шлёт им
probe-вызов.
Хелперы: `toolkit.RegisterPatternTool`, `toolkit.WrapProbe`, `toolkit.IsProbe`.
+123
View File
@@ -0,0 +1,123 @@
# Быстрый старт: первый инструмент за 10 минут
Сделаем MCP-сервер с одним инструментом `echo`, который возвращает переданный
текст.
## 1. Модуль
```bash
mkdir my-mcp && cd my-mcp
go mod init example.com/my-mcp
export GOPRIVATE=git.totmin.ru
go get git.totmin.ru/en2zmax/forge-toolkit@latest
go get github.com/modelcontextprotocol/go-sdk@latest
```
## 2. Инструмент
`internal/tools/tools.go`:
```go
package tools
import (
"context"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.totmin.ru/en2zmax/forge-toolkit"
)
func RegisterAll(s *mcp.Server) {
mcp.AddTool(s, &mcp.Tool{
Name: "echo",
Description: "Возвращает переданный текст. Без побочных эффектов.",
InputSchema: toolkit.Schema(map[string]any{
"text": toolkit.StrProps("текст для возврата", true),
}, []string{"text"}),
}, handleEcho)
}
func handleEcho(_ context.Context, req *mcp.CallToolRequest, _ struct{}) (*mcp.CallToolResult, any, error) {
text, err := toolkit.RequireString(toolkit.RequestArgs(req), "text")
if err != nil {
return toolkit.Error(err.Error()), nil, nil
}
return toolkit.Text(text), nil, nil
}
```
## 3. `main.go`
```go
package main
import (
"fmt"
"os"
"example.com/my-mcp/internal/tools"
"github.com/modelcontextprotocol/go-sdk/mcp"
"git.totmin.ru/en2zmax/forge-toolkit"
)
func main() {
if toolkit.Health() {
return
}
if err := toolkit.Run("my-mcp", func(s *mcp.Server) {
tools.RegisterAll(s)
}); err != nil {
fmt.Fprintf(os.Stderr, "my-mcp: %v\n", err)
os.Exit(1)
}
}
```
## 4. Сборка и проверка
```bash
go mod tidy
go build -o my-mcp .
./my-mcp --health # → ok
```
## 5. Подключение
**Любой MCP-клиент** (Claude Desktop, Cursor и т.п.) — как обычный stdio-сервер:
```json
{
"mcpServers": {
"my-mcp": { "command": "/abs/path/my-mcp" }
}
}
```
**Агентная платформа Forge** — сервер объявляется оператором в конфиге, а
агент подключает его по имени. Например, глобально:
```json
{
"mcp": {
"servers": {
"my-mcp": { "command": "/abs/path/my-mcp", "args": [] }
}
}
}
```
и в агенте:
```yaml
mcp_servers:
- my-mcp
```
Инструмент станет доступен модели как `my-mcp__echo`.
## Дальше
- Контракт и правила — [`ARCHITECTURE.md`](ARCHITECTURE.md).
- Формат результата/probe — [`mcp-contract.md`](mcp-contract.md).
- Каркас с конфигом и Makefile — [`../template/`](../template).