- Go 99.6%
- Dockerfile 0.4%
|
|
||
|---|---|---|
| cmd/mcp-code-mode | ||
| docs | ||
| internal | ||
| tests/integration | ||
| .gitignore | ||
| .woodpecker.yml | ||
| config.example.yaml | ||
| Dockerfile | ||
| go.mod | ||
| go.sum | ||
| LICENSE | ||
| README.md | ||
MCP Code Mode
Go-сервер, который подключается к списку внешних MCP-серверов, агрегирует их инструменты и предоставляет LLM-клиенту два способа работы с этими инструментами:
- Code Mode — инструменты внешних серверов превращаются в host-функции (
os.read_file,weather.current). LLM запрашивает их описание черезget_functions_description(language)и пишет код на JavaScript / Lua / Go, который исполняется в песочнице черезexecute_code(language, code). Вызовы host-функций внутри кода реально выполняются на внешних MCP-серверах, а промежуточные результаты не попадают в контекст LLM — модель получает только финальный результат функцииRun(). - Прямой доступ — каждый внешний инструмент дополнительно выставляется как отдельный MCP-инструмент (
os__read_file) для вызова напрямую, без написания кода (классическое tool-calling / проксирование).
В основе — паттерн «код как компактный план»1: вместо тотального tool-calling (где каждый внешний инструмент регистрируется отдельным MCP-tool'ом и его схема целиком засоряет контекст LLM) модель пишет код против скриптового API host-функций. Код исполняется в песочнице на сервере, и в контекст возвращается только результат Run(). Уникальная комбинация проекта: агрегация нескольких MCP-серверов + code mode + три языковых бэкенда исполнения (JavaScript, Lua, Go) в одном процессе + хранилище с «лёгкими скиллами» (записи без кода с instructions).
Содержание
- Возможности
- Запуск
- Конфигурация
- Права доступа по пользователям (JWT)
- Инструменты, которые видит LLM-клиент
- Как это работает
- Языки исполнения и контракт скрипта
- Хранилище кода
- Структура репозитория
- Непрерывная интеграция
- Технологический стек
- Документация
- Концептуальные первоисточники
- Лицензия
Возможности
- Агрегация внешних MCP-серверов — подключение по всем трём транспортам:
stdio, SSE, Streamable HTTP. - Code Mode с тремя языками исполнения:
- JavaScript (ES5.1) — бэкенд Goja;
- Lua — бэкенд Golua;
- Go — бэкенд Yaegi.
- Прямой доступ к каждому внешнему инструменту как к отдельному MCP-инструменту (
alias__tool). - Режимы доступности инструментов — per-server
expose(both|proxy|code) и per-tooltool_expose(включаяnoneдля полного скрытия инструмента из набора LLM). - Права доступа по пользователям (JWT) — идентификация пользователей Open-WebUI по
idиз session-JWT (HS256 наWEBUI_SECRET_KEY), per-user переопределениеexposeчерезusersи проброс того же JWT на апстримные MCP-серверы (M8, D-37). - Гибкие namespace в скриптах: кастомный
aliasсервера из конфига (os.<fn>,http.<fn>,weather.<fn>), а не жёстко зашитыйinternal. - Строгая изоляция кода: песочница от
go-interpret-agregate(нет доступа к FS/сети/системным вызовам у скриптов) + лимиты CPU-времени, памяти и блокировка горутин. - Защита от фан-аута: лимит на число вызовов внешних инструментов за одно выполнение (
max_tool_calls, дефолт 1000). - Контракт скрипта: LLM определяет функцию
entry(по умолчаниюRun()); возвращаемое значение возвращается модели. Верхнеуровневый код — только подготовительные шаги. - LLM-интерфейс на английском: описания инструментов, шпаргалка и ошибки
ExecuteResult— на английском, единым блоком с кодом (D-32). - Хранилище кода: успешно выполнившиеся функции сохраняются на диск (
name/description), переиспользуются по имени и находятся черезlist_code; поддерживаются «лёгкие скиллы» — записи без кода сinstructions, доступные черезlist_code/get_code. - Сам сервер может работать через stdio или Streamable HTTP (а также legacy SSE).
- Структурные логи в формате JSONL (
log/slog): уровниdebug–error, файл или stdout/stderr, отключение черезoff— см. «Логирование».
Запуск
Окружение
# Приватный Forgejo не в публичном модульном прокси
go env -w GOPRIVATE='git.ymnuktech.ru/*'
go version # требуется Go 1.26+ (см. go.mod)
Сборка
git clone https://git.ymnuktech.ru/ymnuk/mcp-interpreter-agregate.git
cd mcp-interpreter-agregate
go build -o mcp-code-mode ./cmd/mcp-code-mode
Режим stdio
Сервер общается с MCP-клиентом через стандартный ввод/вывод. Подходит для локальных клиентов, запускающих сервер как дочерний процесс: Claude Desktop, Cursor, claude, npx-обёртки и т.д.
# stdio — транспорт по умолчанию, флаг --server-transport можно не указывать
./mcp-code-mode --config config.yaml
Подключение в Claude Desktop (claude_desktop_config.json):
{
"mcpServers": {
"mcp-code-mode": {
"command": "/path/to/mcp-code-mode",
"args": ["--config", "/path/to/config.yaml"]
}
}
}
Проверка вручную из терминала — сервер читает JSON-RPC из stdin:
printf '%s\n' \
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"shell","version":"1.0"}}}' \
'{"jsonrpc":"2.0","method":"notifications/initialized"}' \
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}' \
| ./mcp-code-mode --config config.yaml
В stdio-режиме логи и диагностику не выводите в stdout — только в stderr; протокол идёт по stdin/stdout.
Режим HTTP Streamable
Сервер поднимает HTTP-эндпоинт и общается с клиентами по транспорту Streamable HTTP (спецификация MCP, JSON-RPC поверх HTTP с поддержкой text/event-stream). Подходит для удалённых клиентов и веб-приложений.
# Streamable HTTP: http://<host>:<port>/mcp
./mcp-code-mode --config config.yaml \
--server-transport http \
--server-address :8000
Пример клиента на curl:
# 1. initialize — запоминаем MCP-Session-Id из ответа
curl -i -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'
# 2. initialized (notification, id не нужен) — передаём полученный Session-Id
curl -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Session-Id: <SESSION_ID>' \
-d '{"jsonrpc":"2.0","method":"notifications/initialized"}'
# 3. tools/list — список инструментов (get_functions_description, execute_code, alias__tool, ...)
curl -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Session-Id: <SESSION_ID>' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}'
# 4. tools/call — выполнить код (пример на JavaScript)
curl -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' \
-H 'Accept: application/json, text/event-stream' \
-H 'MCP-Session-Id: <SESSION_ID>' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"execute_code","arguments":{"language":"javascript","code":"function Run(){ var f = os.stat({path:\"/workspace/data.txt\"}); return f; }"}}}'
Ответ tools/call для execute_code — JSON-строка результата в TextContent:
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{ "type": "text", "text": "{\"ok\":true,\"value\":{\"size\":123},\"timedOut\":false,\"durationMs\":5}" }
]
}
}
Поля ExecuteResult: ok, value, logs (JS console.log), error, timedOut, durationMs.
HTTP Streamable сервер по умолчанию защищён от DNS-rebinding (go-sdk): для localhost-запросов проверяется заголовок
Host.
Режим SSE (legacy)
Поддерживается для обратной совместимости с клиентами, которые умеют только SSE:
./mcp-code-mode --config config.yaml \
--server-transport sse \
--server-address :8000
Docker
Многостадийная сборка Dockerfile: статический бинарник на базе scratch + CA-сертификаты для TLS-подключений к удалённым MCP-серверам (Streamable HTTP/SSE). В контейнере по умолчанию включён Streamable HTTP (SERVER_TRANSPORT=http, SERVER_ADDRESS=:8000), т.к. stdio-режим в scratch-контейнере требует наличия запускаемых upstream-процессов (node/npx).
# Сборка
docker build -t mcp-code-mode:latest .
# Запуск с подмонтированным конфигом (upstream по HTTP/SSE)
docker run --rm -p 8000:8000 \
-v "$PWD/config.yaml:/config.yaml" \
mcp-code-mode:latest --config /config.yaml
Переопределить транспорт можно флагами/переменными окружения: --server-transport stdio, SERVER_TRANSPORT=stdio и т.д.
Флаги запуска
| Флаг | Env | YAML | Default |
|---|---|---|---|
--config <path> |
CONFIG_FILE |
— | — |
--server-name |
SERVER_NAME |
server.name |
mcp-code-mode |
--server-version |
SERVER_VERSION |
server.version |
0.1.0 |
--server-transport |
SERVER_TRANSPORT |
server.transport |
stdio |
--server-address |
SERVER_ADDRESS |
server.address |
:8000 |
--reconnect-interval |
RECONNECT_INTERVAL |
connector.reconnect_interval_s |
30 |
--languages |
LANGUAGES |
languages |
javascript,lua,go |
--code-store-dir |
CODE_STORE_DIR |
store.dir |
(пусто = выключено) |
--log-level |
LOG_LEVEL |
log.level |
off |
--log-file |
LOG_FILE |
log.file |
(пусто = stdout; для stdio — stderr) |
Параметры лимитов (
limits.*) задаются через конфиг-файл/env и CLI-флагов не имеют.
Конфигурация
Приоритет источников (от высшего к низшему): CLI-аргументы → переменные окружения → YAML/JSON файл → значения по умолчанию (реализуется go-simple-args).
Файл конфига задаётся флагом --config <path> или переменной окружения CONFIG_FILE.
server:
name: mcp-code-mode # Имя MCP-сервера
version: 0.1.0 # Версия
transport: stdio # stdio | http | sse — транспорт, на котором работает сам сервер
address: ":8000" # для http/sse
# Параметры подключения к внешним серверам
connector:
reconnect_interval_s: 30 # период фонового переподключения к недоступным серверам
# Логирование (JSONL через slog). Уровень: debug|info|warn|error|off.
# По умолчанию off — логи выключены, пока не задан LOG_LEVEL / log.level.
log:
level: info # debug|info|warn|error|off (по умолчанию off)
file: "" # путь к файлу логов; пусто = stdout (для stdio — stderr)
# Аутентификация по JWT и права доступа по пользователям (см. «Права доступа…»)
auth:
jwt_secret: "" # секрет подписи JWT (HS256); в Open-WebUI — WEBUI_SECRET_KEY
invalid_jwt: reject # reject | skip
# Разрешённые языки исполнения кода (бэкенды интерпретаторов)
languages: [javascript, lua, go]
limits:
max_cpu_time_ms: 1000 # Лимит CPU-времени на выполнение одного скрипта (все бэкенды)
max_memory_bytes: 67108864 # Лимит памяти (эффективен для Lua; для Goja/Yaegi управляет Go runtime)
allow_threads: false # false — запрет `go`/`select` в скриптах Yaegi (безопасный режим)
max_tool_calls: 1000 # Максимум вызовов внешних инструментов за одно выполнение кода
call_timeout_ms: 30000 # Таймаут одного вызова внешнего инструмента
# Хранилище кода (опционально): успешно выполнившиеся скрипты сохраняются и переиспользуются по name
store:
dir: "" # директория; пусто = хранилище выключено
# Список внешних MCP-серверов
upstream:
- name: filesystem-mcp # Уникальное имя сервера (обязательно)
alias: os # namespace host-функций в скриптах: os.read_file(...)
transport: stdio # stdio | http | sse
command: npx # для stdio: команда
args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
env: { KEEP_ALIVE: "1" } # доп. переменные окружения для процесса
working_dir: "" # рабочая директория процесса (необязательно)
expose: both # режим: both | proxy | code (default both)
tool_expose: # per-tool override
- name: delete_path # полностью скрыть инструмент
expose: none
users: # ... но для этого пользователя открыть в код
- id: "<open-webui-user-id>"
expose: code
- name: weather
transport: http # alias не задан → namespace = name = "weather"
url: "http://localhost:8000/mcp" # для http/sse
headers: { Authorization: "Bearer <token>" }
max_retries: 5 # переподключение (только http)
connect_timeout_s: 30 # таймаут handshake (discover/initialize + tools/list); default 30.
# Не даёт зависшему/медленному серверу заблокировать старт; для http
# также ограничивает каждый запрос сессии (включая прямые вызовы).
expose: code # только host-функции, без прямых weather__* инструментов
users: # для этого пользователя — и прямые, и host-функции
- id: "<open-webui-user-id>"
expose: both
- name: legacy
transport: sse
url: "http://localhost:9000/sse"
expose: proxy # только прямые legacy__* инструменты, без кода
Полный пример — в файле config.example.yaml.
Правила именования:
| Контекст | Формат | Пример |
|---|---|---|
| namespace в скрипте | alias (или name сервера) |
os, weather |
| host-функция в JS/Lua | alias.<snake_tool> |
os.read_file |
| host-функция в Go (Yaegi) | import "alias" → alias.<CamelTool> |
os.ReadFile |
| прямой MCP-инструмент | alias__tool (без точек, [a-zA-Z0-9_-]) |
os__read_file |
В Go-бэкенде namespace, совпадающий со stdlib-пакетом (
os,net, ...), безопасен: библиотекаgo-interpret-agregatev0.1.0+ затеняет stdlib-пакет, иimport "os"даёт скрипту только host-функции (реальный stdlib недоступен).
Режимы доступности инструментов (expose):
По умолчанию каждый инструмент внешнего сервера доступен двумя способами сразу: как прямой MCP-инструмент alias__tool и как host-функция в коде. При необходимости это настраивается на уровне сервера (expose) и на уровне отдельных инструментов (tool_expose):
| Уровень | Поле | Варианты | Значение |
|---|---|---|---|
| сервер | expose |
both (default) |
инструменты и в прокси, и в коде |
proxy |
только прямые alias__tool; в код и шпаргалку не попадают |
||
code |
только host-функции и шпаргалка; прямых alias__tool нет |
||
| инструмент | tool_expose[].expose |
both / proxy / code |
переопределяет серверный режим для конкретного инструмента (по оригинальному имени) |
none |
инструмент полностью скрыт: ни прямого вызова, ни кода (распространённый приём в агентах — убрать «опасные»/нерелевантные инструменты из набора LLM) |
none разрешён только на уровне отдельного инструмента (tool_expose); на уровне сервера expose принимает только both|proxy|code. Инструменты с режимом proxy/none не появляются в get_functions_description и get_functions_raw; серверный режим виден в list_servers (поле expose).
upstream:
- name: filesystem-mcp
expose: both # default сервера
tool_expose:
- name: delete_path # «опасный» инструмент — скрыть полностью
expose: none
- name: weather
expose: code # только в код, без прямых weather__* инструментов
- name: legacy
expose: proxy # только прямые legacy__* инструменты, без кода
Права доступа по пользователям (JWT)
Когда mcp-code-mode подключён как внешний MCP-сервер к Open-WebUI (транспорт http, соединение с auth_type: session), каждый запрос приходит с заголовком Authorization: Bearer <session JWT> пользователя Open-WebUI. Сервер может:
- Идентифицировать пользователя по
idиз JWT (верификация подписи HS256 наauth.jwt_secret— для Open-WebUI этоWEBUI_SECRET_KEY); - Применить per-user права доступа к серверам/инструментам;
- Передать тот же JWT дальше апстримным MCP-серверам, чтобы и они могли применить свои проверки.
Идентификация и поведение invalid_jwt
| Режим | Верификация | Невалидный/просроченный JWT |
|---|---|---|
reject (default) |
подпись HS256 + exp |
запрос отклоняется (нужен auth.jwt_secret) |
skip |
нет (только декод payload) | считается anonymous; нечитаемый токен → anonymous |
Отсутствие секции auth — политика выключена (полностью обратная совместимость, поведение как раньше). stdio/SSE считаются локальным запуском: идентификация не выполняется, все вызовы — anonymous.
Per-user права
Права задаются прямо в секциях users — на уровне сервера (upstream[].users) и на уровне инструмента (upstream[].tool_expose[].users). Каждая запись — { id: <user-id>, expose: <режим> }, где expose — режим доступности для этого конкретного пользователя (both | proxy | code; на уровне инструмента допустим и none).
Приоритет (сверху вниз, по специфичности):
tool_expose[].users[id] → tool_expose[].expose → upstream[].users[id] → upstream[].expose
- Пользователь с записью → для него действует его override;
- Пользователь без записи (или без JWT) = anonymous → применяются обычные правила
expose/tool_expose; - Если
usersнигде не указаны → политика per-user выключена, поведение текущее.
auth:
jwt_secret: "<WEBUI_SECRET_KEY>"
invalid_jwt: reject
upstream:
- name: filesystem-mcp
expose: both
tool_expose:
- name: delete_path # опасный инструмент — скрыт для всех
expose: none
users:
- id: "<user-id-1>" # ... кроме этого пользователя: доступен в коде
expose: code
users:
- id: "<user-id-2>" # для этого пользователя весь сервер — и прокси, и код
expose: both
Закрытый
tool_expose: noneинструмент не открывается сервернымupstream.users[id]— толькоtool_expose[].users[id].
Видимость в
tools/listтоже per-user: недоступные пользователю pass-through инструменты скрываются в протокольном ответеtools/list(панель Open-WebUI), см. docs/TOOLS_VISIBILITY.md. Гейт наtools/callостаётся страховкой.
Ошибки доступа vs ошибки скрипта в
execute_code: если скрипт (например, сохранённая запись) вызывает host-функцию, которая есть в реестре, но заблокирована для этого пользователя per-user правилами, выполнение падает с явной ошибкойforbidden— это проблема прав, а не скрипта, поэтому запись не помечается invalid и не удаляется (self-healing D-36 не срабатывает). Если же функция реально отсутствует (удалена/переименована на апстриме), скрипт падает с «not found/not defined» и запись помечается invalid (D-36). Discovery (get_functions_description,search_functions,get_functions_raw,list_servers) при этом всегда скрывает недоступные функции — они не рекламируются, но уже написанные скрипты дают понятную ошибку доступа.
Форвард JWT на апстрим
Если в запросе присутствует JWT и вызов не был отклонён (в reject-режиме — после успешной верификации), тот же токен передаётся апстримному MCP-серверу на каждом вызове внешней функции:
- прямой прокси
alias__tool→ заголовокAuthorization: Bearer <JWT>на HTTP-запросе апстриму; execute_code→ все вложенные host-функции, внешние к другим MCP-серверам, несут тот же JWT.
Форвард действует независимо от того, есть ли у пользователя запись в users (anonymous — тоже). Передаются только JWT-образные Bearer-токены; прочие заголовки (API-ключи, Basic) апстриму не форвардятся. Идентичность не «перемешивается» между параллельными запросами разных пользователей: каждый исходящий запрос получает заголовок из контекста своего вызова.
Как получить id пользователя из Open-WebUI
Open-WebUI подписывает session-токен ключом WEBUI_SECRET_KEY (HS256); payload содержит sub, id (стабильный UUID пользователя — первичный ключ), email, name, role, iss: "open-webui", iat, exp. id меняется только при смене учётной записи — его и используйте в users[].id. Пример декода (без проверки):
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null
Логирование
Логи пишутся через стандартный log/slog в формате JSONL (по одному JSON-объекту на строку). Логирование по умолчанию выключено: пока не задан уровень, сервер не пишет ничего.
- Уровень —
--log-level/LOG_LEVEL/log.level:debug|info|warn|error|off. По умолчанию —off(логи выключены).offтакже доступен для явного отключения при запуске через env/флаг. - Файл —
--log-file/LOG_FILE/log.file. Если файл не указан, логи идут в stdout; дляtransport: stdio— в stderr (stdout занят протоколом JSON-RPC, иначе логи сломают клиент). Если файл указан — открывается в режиме append (создаётся при отсутствии), журнал дописывается между перезапусками.
Что логируется (по уровням):
| Уровень | События |
|---|---|
debug |
подключение/переподключение upstream, состав реестра, вызовы read-only-инструментов (get_functions_description, list_code, get_code, list_servers, get_functions_raw), каждый host-вызов инструмента из скрипта, регистрация инструментов, успешные HTTP-запросы (status < 400), протокольные запросы клиента, кроме ключевых (см. info) |
info |
старт сервера, подключение upstream (число инструментов), переподключение, готовность, выполнение скрипта (execute_code), прямой прокси-вызов alias__tool, жизненный цикл MCP-сессий (connect/disconnect), ключевые протокольные запросы: initialize/server/discover (клиент, версия протокола, session) и tools/list (число инструментов) |
warn |
провал скрипта, ошибка вызова инструмента, неудачное переподключение, ошибки read-only-инструментов, HTTP-ответы с ошибкой (status >= 400) |
error |
отказ соединения upstream, ошибки записи в хранилище, фатальные ошибки HTTP-сервера |
Протокольные и HTTP-логи: входящие JSON-RPC-запросы клиента логируются через middleware на уровне протокола (mcp initialized/mcp discovered — INFO, mcp tools listed — INFO с числом инструментов, mcp call tool — DEBUG с именем инструмента), а каждый HTTP-запрос — access-логом (http request: метод, путь, статус, байты, remote, длительность). Секреты и тела запросов не логируются (для execute_code — код и аргументы). Это позволяет диагностировать, что именно делает клиент (например, OpenWebUI): какой протокол-версией и каким клиентом инициализируется сессия и какие инструменты запрашиваются.
Пример строки:
{"time":"2026-08-03T10:05:00.000Z","level":"INFO","msg":"server starting","name":"mcp-code-mode","version":"0.1.0","transport":"http","address":":8000","languages":"javascript,lua,go","upstreams":2,"logLevel":"info","logFile":""}
Почему порт «не открывается» и как это диагностировать
HTTP/SSE-листенер биндится до подключения upstream-серверов, поэтому порт открыт с первой секунды даже если upstream медленный или висит. Прямые инструменты (alias__tool) и host-функции появляются по мере подключения upstream (клиент уведомляется через tools/list_changed).
- В логах есть
server readyиhttp server listening→ сервер слушает порт.connection refusedпри этом означает проблему вне сервера (не тот адрес/порт, сеть, firewall,docker run -p, port-forward в кластере). - Есть
http server listening, но процесс упал → ищиERROR http server bind failed ... address already in use(порт занят) или фатальную ошибку на stderr. - Нет
http server listeningи нетserver ready, а upstream висит → раньше (доconnect_timeout_s) подключение к зависшему upstream блокировало старт целиком; теперь каждое подключение ограниченоupstream.connect_timeout_s(default 30), зависший сервер уходит вfailedи ретраится в фоне каждыеreconnect_interval_s.
Инструменты, которые видит LLM-клиент
| Инструмент | Описание |
|---|---|
get_functions_description(language, server?) |
Code Mode discovery: возвращает полный текстовый список host-функций для указанного языка (javascript/lua/go): сигнатуры вызова в синтаксисе языка, namespace, параметры (имя/тип/required), описание, исходный сервер и имя инструмента. На английском, с примером скрипта под каждый язык. |
execute_code(language, code?, entry?, params?, name?, description?, instructions?) |
Исполняет код в песочнице. Код обязан определять функцию entry (по умолчанию Run) — именно она вызывается; верхнеуровневый код — только подготовительные шаги. params — единственный аргумент entry; name/description/instructions — сохранение и переиспользование записей в хранилище. Возвращает значение Run(), накопленные console.*/print/fmt.* в logs, ошибку, признак таймаута. |
list_code(language?, query?, minScore?, limit?) |
Список сохранённых записей из хранилища (без содержимого): {name, description, language, runs, codeLen, hasCode, hasInstructions, createdAt, updatedAt}; query — «похожий» поиск по name+description+instructions с score (порог minScore, лимит limit). |
get_code(name) |
Полная запись сохранённой записи (с code и instructions) по имени. |
прямые инструменты alias__tool |
Каждый внешний инструмент как отдельный MCP-инструмент: схема входных данных — из JSON Schema внешнего инструмента; вызов — напрямую на внешний сервер. Регистрируются только для серверов/инструментов с режимом both/proxy (см. «Режимы доступности инструментов»). |
list_servers() |
Статус подключений к внешним серверам: транспорт, состояние, ошибка, число инструментов, список имён, серверный режим expose. |
get_functions_raw(server?) |
JSON-каталог: {server, alias, namespace, tool, originalName, description, inputSchema, outputSchema} для детального разбора. |
Как это работает
┌─────────────┐ MCP (stdio/HTTP Streamable/SSE) ┌──────────────────────────────┐
│ LLM-клиент │ ◄────────────────────────────────► │ MCP Code Mode (mcp.Server) │
│ (Claude, │ │ get_functions_description │
│ Cursor…) │ │ execute_code │
└─────────────┘ │ прямые tools: alias__tool │
│ list_servers │
└───────────────┬──────────────┘
host-функции│ MCP-клиент (go-sdk)
(go-interpret- │
agregate) │ session.CallTool
▼ ▼
┌──────────────────────────────┐
│ Внешние MCP-серверы │
│ (stdio / SSE / Streamable │
│ HTTP) │
└──────────────────────────────┘
Типовой цикл Code Mode:
-
LLM вызывает
get_functions_description(language="javascript")→ получает шпаргалку host-функций (на английском, с примером скрипта):os.read_file({path}),weather.current({city})и т.д. -
LLM пишет код, определяющий
Run()и вызывающий host-функции:function Run() { const meta = os.stat({ path: "/workspace/data.txt" }); const rows = weather.current({ city: "Moscow" }); return { size: meta.size, temp: rows.temp }; } -
LLM вызывает
execute_code(language="javascript", code="..."). -
Сервер создаёт свежий интерпретатор Goja, регистрирует host-функции из реестра, подготавливает скрипт, вызывает
Run(). -
Каждый вызов
os.stat(...)из кода маршрутизируется вsession.CallToolсоответствующего внешнего сервера; результат (StructuredContent или текст) возвращается в скрипт. -
Сервер возвращает значение
Run()модели. Промежуточные результаты в контекст LLM не попадают.
Прямой доступ: LLM просто вызывает MCP-инструмент os__read_file с аргументами — сервер транслирует его в session.CallTool на внешний сервер.
Подход: код как компактный план против тотального tool-calling
Схема инструментов и контракт скриптов живут на сервере: в контекст LLM попадает только компактное описание (шпаргалка, сводки хранилища) и результат выполнения — а не полные JSON Schema каждого инструмента. Это противоположность классическому MCP, где каждый инструмент/endpoint регистрируется отдельным tool'ом.
Особенности этого проекта:
- Discovery —
get_functions_description(language)отдаёт шпаргалку host-функций;list_code— «похожий» поиск по хранилищу (name+description+instructions) сscore. Полные схемы — только по запросу (get_code,get_functions_raw). - Исполнение —
execute_code(language, code)в песочнице goja/golua/yaegi; наружу возвращается толькоExecuteResult; промежуточные результаты в контекст не попадают. - Мультиязычность — один контракт
Run()на JavaScript + Lua + Go. - Агрегация — произвольное множество внешних MCP-серверов свёрнуто в host-функции по неймспейсам (
os.read_file), плюс прямой доступalias__tool. - Хранилище и «лёгкие скиллы» — переиспользование успешно выполнившегося кода по
name; записи без кода (толькоdescription/instructions) для инструктивного дискавери.
Возможные следующие шаги (см. docs/ROADMAP.md, docs/DECISIONS.md): подтверждение опасных вызовов (approvals), поэлементное раскрытие схем отдельных инструментов, поиск по внешним спекам кодом.
Полный пример: конфигурация → запуск → вызов внешнего инструмента
1. Конфигурация (config.yaml) — сервер подключается к двум внешним MCP-серверам: локальному по stdio (filesystem) и удалённому по Streamable HTTP (math):
server:
name: mcp-code-mode
transport: http # сам сервер работает по Streamable HTTP
address: :8000
languages: [javascript, lua, go]
upstream:
- name: filesystem-mcp # внешний сервер №1: локальный процесс (npx)
alias: os
transport: stdio
command: npx
args: ["-y", "@modelcontextprotocol/server-filesystem", "/workspace"]
- name: weather-mcp # внешний сервер №2: удалённый по HTTP
alias: math
transport: http
url: "http://127.0.0.1:9001/mcp"
2. Запуск:
./mcp-code-mode --config config.yaml
# готово: Streamable HTTP на http://localhost:8000/mcp
3. Discovery — get_functions_description (LLM узнаёт доступные host-функции):
curl -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Session-Id: <SESSION_ID>' \
-d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"get_functions_description","arguments":{"language":"javascript"}}}'
Ответ содержит сигнатуры host-функций обоих внешних серверов, например:
os.stat({path: string})
os.read_file({path: string})
math.add({a: number, b: number})
4. Code Mode — execute_code (скрипт вызывает внешние функции os.stat и math.add):
curl -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Session-Id: <SESSION_ID>' \
-d '{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"execute_code","arguments":{"language":"javascript","code":"function Run(){ var s = os.stat({path:\"/workspace/data.txt\"}); var m = math.add({a:2,b:40}); return {size:s.size, sum:m.result}; }"}}}'
Сервер исполняет код, вызовы os.stat и math.add реально уходят на внешние MCP-серверы, и возвращает только результат Run():
{ "ok": true, "value": { "size": 123, "sum": "ok:add" }, "timedOut": false, "durationMs": 4 }
5. Прямой доступ — math__add (без кода, классическое tool-calling):
curl -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Session-Id: <SESSION_ID>' \
-d '{"jsonrpc":"2.0","id":4,"method":"tools/call","params":{"name":"math__add","arguments":{"a":1,"b":2}}}'
Весь этот сценарий покрыт интеграционными тестами
tests/integration/config_driven_test.go: там внешние серверы поднимаются на реальных HTTP-портах, конфиг грузится из YAML-файла, и проверяется как discovery, так и вызов внешней функции (os.echo,math.add) черезexecute_codeи прямой инструмент.
Языки исполнения и контракт скрипта
Каждый execute_code создаёт свежий интерпретатор (лёгкая одноразовая песочница). Контракт для всех языков одинаков:
- код обязан определять функцию, имя которой передаётся в параметре
entry(по умолчаниюRun— заглавная, т.к. Yaegi требует экспортируемых символов); именноentryвызывается после подготовки кода, её возвращаемое значение — результат; - верхнеуровневый код (вне
entry) — только подготовительные шаги (инициализация констант/структур); host-функции (console.*и др.) доступны и в top-level коде, их вывод попадает вlogs(с v0.2.2); с v0.2.3 top-level исполняется ровно один раз во всех бэкендах; без функцииentryвыполнение завершается ошибкой «функция не найдена»; - host-функции доступны через namespace сервера;
- результатом выполнения считается возвращаемое значение
entry.
// JavaScript (Goja)
function Run() {
const list = os.list({ path: "/workspace" });
return list;
}
-- Lua (Golua)
function Run()
local list = os.list({ path = "/workspace" })
return list
end
// Go (Yaegi)
package main
import "os"
func Run() interface{} {
list, err := os.List(map[string]interface{}{"path": "/workspace"})
if err != nil {
panic(err.Error())
}
return list
}
Особенности Go-бэкенда (Yaegi):
- host-функции возвращают
(value, error)— в скрипте присваивайте оба значения (v, err := os.List(...)); присваивание одного значения (return os.List(...)) вызывает панику интерпретатора; - namespace, совпадающий со stdlib-пакетом (
os,net, ...), безопасен (с v0.1.0): библиотека затеняет stdlib-пакет,import "os"даёт скрипту только host-функции (реальный stdlib недоступен). BUG-3 (смешивание stdlib-пакета с host-namespace) исправлен на стороне библиотеки.
Ограничения песочницы (go-interpret-agregate): скрипты не имеют доступа к файловой системе, сети и системным вызовам; для Yaegi запрещены go/select при allow_threads: false.
Вывод из скрипта (console.* и встроенная печать): для логирования промежуточных результатов доступны host-функции console.*, единые для всех языков, а также встроенные функции печати (print в Lua; fmt.* и log.* в Go). Весь вывод накапливается в буфер и возвращается в поле logs результата execute_code (ExecuteResult.logs) — в реальные stdout/stderr процесса ничего не пишется (критично для transport: stdio, где stdout занят JSON-RPC).
| Язык | Вызов | Варианты |
|---|---|---|
| JavaScript | console.log("x =", x) |
console.log / warn / error / info / debug |
| Lua | console.log("x =", x) |
console.log / warn / error / info / debug; встроенный print(...) |
| Go | console.Log("x =", x) |
console.Log / Warn / Error / Info / Debug (CamelCase); встроенные fmt.Print*/Println, log.* |
Перехват встроенной печати реализован на стороне библиотеки go-interpret-agregate v0.2.0 (Config.Stdout/Stderr / опция WithOutput: print/fmt.*/log.*/console.* пишут в переданные io.Writer). Приложение передаёт буфер результата как io.Writer при создании интерпретатора. Никаких форков/патчей/vendor сторонней библиотеки — только потребление её публичного API (зависимость поднята с v0.1.0 до v0.2.0).
При таймауте (Lua/Go) фоновая горутина может дописывать строки в буфер после возврата из
execute_code; приложение возвращает снимок логов на момент завершения (см. DECISIONS D-31, ROADMAP M7.19).
Встроенные функции песочницы (математика и др.)
Помимо host-функций (инструментов внешних серверов) в каждом языке доступна встроенная математика:
| Язык | Встроенная математика | Пример |
|---|---|---|
| JavaScript (Goja) | глобальный объект Math (ES5.1) |
Math.sqrt(16), Math.sin(x), Math.PI, Math.pow(a,b), Math.random() |
| Lua (Golua) | стандартная библиотека math |
math.sqrt(16), math.sin(x), math.pi, math.floor(x), math.max(a,b) |
| Go (Yaegi) | stdlib math (+ math/big, math/bits, math/cmplx, math/rand) |
import "math" → math.Sqrt(16), math.Sin(x), math.Pi |
Покрыто тестами internal/runner/math_test.go.
Нюанс с alias
math. Если апстрим-серверу задан aliasmath(напримерweather-mcp→math), поведение зависит от языка:
- Go: host-namespace
mathзатеняет stdlib-пакетmath—import "math"даёт только host-функции, аmath.Sqrt/math.Piнедоступны. Это осознанное поведение (защита песочницы от обхода через stdlib, см.filteredStdlibв библиотеке v0.1.0).- Lua: host-функции сливаются в существующую таблицу
math—math.add(host) иmath.sin(встроенный) сосуществуют. Осторожно: инструмент апстрима с именемsin/floor/randomперезапишет встроенную функцию.- JavaScript: конфликта нет — встроенный
Math(заглавная) и host-namespacemath(строчная) разные.Рекомендация: если скриптам Go нужна stdlib-математика, не используйте alias
math— задайте неконфликтующий (напримерcalc,numbers). Для JS/Lua aliasmathбезопасен.
Хранилище кода
Сервер может сохранять успешно выполнившиеся скрипты на диск и переиспользовать их. Это позволяет LLM один раз написать и отладить функцию, а затем вызывать её по имени с разными параметрами. Хранилище также поддерживает «лёгкие скиллы» — записи без кода, только с description/instructions, которые LLM находит через list_code и читает через get_code.
Включение: в конфиге задайте директорию хранилища:
store:
dir: /var/lib/mcp-code-mode/code # пусто = хранилище выключено
или флагом/переменной окружения: --code-store-dir / CODE_STORE_DIR.
Поля execute_code:
| Поле | Назначение |
|---|---|
language |
язык: javascript | lua | go |
code |
текст скрипта (определяет entry, по умолчанию Run); может отсутствовать для «лёгкого скилла» |
entry |
имя entry-функции (по умолчанию Run) |
params |
объект, передаваемый в entry как единственный аргумент (Run(params)) |
name |
идентификатор записи [a-zA-Z0-9_-]{1,64} |
description |
семантическое описание — «зачем» (doc-комментарий); его генерирует LLM; используется в поиске list_code |
instructions |
руководство для LLM — «как применять»: когда/зачем использовать, предусловия (какие серверы/namespace нужны), шаги, пример, предостережения; сохраняется и доступно через get_code |
Матрица поведения:
- без
name→ выполнитьcode(безcodeи безname— ошибка); nameбезcodeи безdescription/instructions→ загрузить из хранилища и выполнить (сparams, если заданы); записи нет → ошибка;name+code→ сначала выполнить, при успехе сохранить/перезаписать (вместе сdescription/instructions), затем вернуть результат;nameбезcode, но сdescriptionи/илиinstructions→ сохранить/обновить запись без выполнения («лёгкий скилл»): новая запись создаётся без кода, у существующей сохраняютсяcodeиlanguage; запись с пустымcodeнельзя выполнить (execute_codeпо имени вернёт ошибку с подсказкойget_code);- хранилище не настроено +
name→ ошибка.
// 1. Сохранить и выполнить
{ "name": "stats", "description": "stat файла и сумма размеров",
"instructions": "Передайте в params путь к файлу; результат {size}. Работает только при живом сервере os.",
"code": "function Run(p){ var s = os.stat({path: p.path}); return {size: s.size}; }",
"params": { "path": "/workspace/data.txt" } }
// 2. Переиспользовать по имени с новыми параметрами
{ "name": "stats", "params": { "path": "/workspace/other.txt" } }
// 3. Сохранить «лёгкий скилл» без кода (только руководство для LLM)
{ "name": "date_notes", "language": "javascript",
"description": "как отвечать на вопросы о датах",
"instructions": "Предпочитайте ISO 8601 и всегда указывайте часовой пояс. Не выдумывайте дни недели." }
list_code — показывает сохранённые записи без содержимого (для решения LLM, что читать/переиспользовать):
curl -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Session-Id: <SESSION_ID>' \
-d '{"jsonrpc":"2.0","id":5,"method":"tools/call","params":{"name":"list_code","arguments":{"query":"stat"}}}'
Ответ — JSON-массив сводок [{name, description, language, runs, codeLen, hasCode, hasInstructions, createdAt, updatedAt, score?}], отсортированных по updatedAt (новые сверху). query — «похожий» поиск по name+description+instructions (без эмбеддингов: substring-boost + совпадение токенов + символьные биграммы — устойчив к опечаткам и порядку слов); в результаты добавляется score [0,1], порог minScore (по умолчанию 0.2), максимум limit (по умолчанию 20). language — фильтр по языку. codeLen — размер кода в байтах (подсказка, стоит ли тянуть содержимое). hasCode различает исполняемые сниппеты и «лёгкие скиллы» (записи без кода — только читаются через get_code). Живая доступность внешних серверов (могут ли вызовы в коде снова сработать) видна через list_servers.
get_code(name) — возвращает полную запись, включая code и instructions (для чтения скилла или встраивания кода перед переиспользованием):
curl -sS -X POST http://localhost:8000/mcp \
-H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \
-H 'MCP-Session-Id: <SESSION_ID>' \
-d '{"jsonrpc":"2.0","id":6,"method":"tools/call","params":{"name":"get_code","arguments":{"name":"stats"}}}'
Ответ — {name, description, language, code, instructions, createdAt, updatedAt, runs}. Чтение не меняет счётчик запусков; неизвестное имя или выключенное хранилище → ошибка.
«Лёгкие скиллы»: записи без кода — дискавери-слой для инструкций. LLM находит их по list_code (поиск учитывает instructions), читает целиком через get_code и применяет на основе инструкции, не запуская код. Это минималистичный аналог skills: description — «зачем» (как doc-комментарий), instructions — «как применять».
Структура репозитория
mcp-interpreter-agregate/
├── cmd/mcp-code-mode/main.go # точка входа: запуск сервера по server.transport
├── internal/
│ ├── config/ # структуры конфига + загрузка (go-simple-args + YAML)
│ ├── connector/ # подключение к внешним MCP-серверам (stdio/SSE/HTTP), ListTools
│ ├── registry/ # реестр инструментов: namespace/alias, санитизация, коллизии
│ ├── describe/ # генерация per-language «шпаргалок» + raw JSON каталога
│ ├── runner/ # host-функции, лимиты, max_tool_calls, execute_code
│ ├── store/ # хранилище кода на диске (name/description/list_code)
│ ├── search/ # «похожий» поиск по коду без эмбеддингов (score)
│ └── mcpcode/ # mcp.Server + LLM-инструменты + транспорты
├── tests/integration/ # э2е-тесты полного стека (in-process MCP-сервер)
├── docs/
│ ├── ARCHITECTURE.md # полное описание архитектуры и взаимодействия
│ ├── DECISIONS.md # исследование и журнал принятых решений (ADR)
│ └── ROADMAP.md # этапы реализации с декомпозицией
├── config.example.yaml # пример конфигурации
├── .woodpecker.yml # CI: тесты, сборка, релиз (Woodpecker CI)
├── go.mod / go.sum
└── README.md
Непрерывная интеграция
Проект использует Woodpecker CI (.woodpecker.yml). Пайплайн запускается на создание git-тега и выполняет:
go vet+go test ./...с покрытием и бенчмарками;- сборку статических бинарников
mcp-code-modeдляlinux/amd64,linux/arm64,windows/amd64; - загрузку бинарников в Gitea-релиз тега;
- email-уведомление с отчётами о покрытии.
Технологический стек
| Библиотека | Назначение |
|---|---|
github.com/modelcontextprotocol/go-sdk |
MCP-сервер и MCP-клиент: mcp.Server, mcp.Client, транспорты (StdioTransport, CommandTransport, SSEClientTransport, StreamableClientTransport), ClientSession.ListTools/CallTool |
git.ymnuktech.ru/ymnuk/go-interpret-agregate |
Единый интерфейс исполнения JS (Goja), Lua (Golua), Go (Yaegi) с лимитами ресурсов, host-функциями и валидацией типов. Найденные баги (потеря host-функций в namespace у goja, смешивание stdlib-пакета с host-namespace у yaegi) исправлены апстримом в v0.1.0. |
git.ymnuktech.ru/ymnuk/go-simple-args |
Парсинг CLI-аргументов + env + .env + YAML/JSON с приоритетом источников |
Документация
- docs/ARCHITECTURE.md — архитектура, компоненты, потоки взаимодействия, контракты, безопасность.
- docs/DECISIONS.md — исследование существующих решений и журнал принятых решений.
- docs/ROADMAP.md — дорожная карта реализации с детальной декомпозицией.
- config.example.yaml — пример конфигурации.
Концептуальные первоисточники
- Cloudflare — «Code Mode: the better way to use MCP» (сентябрь 2025)
- Cloudflare — «Code Mode: give agents an entire API in 1,000 tokens» (server-side Code Mode MCP, февраль 2026)
- Cloudflare — Code Mode MCP server (
search()+execute()) - Cloudflare — Code Mode SDK / Agents (
McpConnector,codemode.search()/describe(), approvals) - Cloudflare — «Code Mode MCP server patterns» (docs) (single-code-tool / search-and-execute)
- Cloudflare — MCP Server Portals (композиция MCP-серверов за шлюзом)
- Anthropic — «Code execution with MCP: Building more efficient agents» (ноябрь 2025)
- Anthropic — Programmatic tool calling (Claude SDK)
- FastMCP — CodeMode transform (Python, sandbox Monty)
- TBXark —
mcp-proxy(Go-агрегация без code execution)
Лицензия
MIT License — см. файл LICENSE. Copyright (c) 2026 Fedoryuk Alexandr.
-
Термин «Code Mode» введён Cloudflare; аналогичные подходы описаны Anthropic и др. Полный список первоисточников — ниже. ↩︎