- Go 99.7%
- Dockerfile 0.3%
|
|
||
|---|---|---|
| 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) — идентификация пользователей по session-JWT (HS256 на
WEBUI_SECRET_KEY), per-user переопределениеexposeчерезusersи проброс того же JWT на апстримные MCP-серверы (M8, D-37). Claim с user id настраивается (auth.id_claim, по умолчаниюsub; для Open-WebUI —id). - Per-user discovery — список функций снимается с апстрима по JWT пользователя (эпизодические
tools/list-сессии с кэшем TTL), так что приватные инструменты апстрима (например, приватные БД в mcp-db-suite) видны владельцу и скрыты от чужих (M9, D-38). - Гибкие 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. Хранилище многоуровневое: общая директория (по умолчанию), иммутабельные readonly-каталоги со скриптами-утилитами и персональные каталоги поidпользователя из JWT (M11). - Цепочки скриптов: сохранённую запись можно вызвать из другого скрипта через
code.run("name", params)(JS/Lua; Go —code.Run) — значение вложенного скрипта возвращается вызывающему, логи аккумулируются общие, язык вложенного скрипта не важен (каждый запуск — свежий интерпретатор); глубина вложенности ограниченаlimits.max_code_depth(дефолт 10). - Сам сервер может работать через 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 |
--discovery-ttl |
DISCOVERY_TTL |
connector.discovery_ttl_s |
60 |
--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 # период фонового переподключения к недоступным серверам
discovery_ttl_s: 60 # TTL (сек) кэша per-user discovery (M9); >= 1
# Логирование (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
id_claim: sub # claim с user id; default "sub", для Open-WebUI — id
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 # Таймаут одного вызова внешнего инструмента
max_code_depth: 10 # Максимальная глубина вложенности цепочек скриптов (code.run); 0 = без ограничения
# Хранилище кода (опционально): успешно выполнившиеся скрипты сохраняются и переиспользуются по name
store:
dir: "" # общая директория (анонимы + дефолт); пусто + нет других = выключено
readonly_dirs: [] # иммутабельная библиотека утилит (сидируется оператором); список путей
user_dirs: [] # персональные каталоги: [{id, dir}]; id — из JWT (auth.id_claim)
# user_dirs:
# - id: "user-1"
# dir: /var/lib/mcp-code-mode/code/user-1
# Список внешних 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. Сервер может:
- Идентифицировать пользователя по его claim в 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 |
Claim с user id (auth.id_claim): сервер читает user id из claim, заданного в auth.id_claim. По умолчанию — sub (стандартное поле subject JWT). Open-WebUI хранит user id в claim id, поэтому для него в конфиге нужно указать id_claim: id:
auth:
jwt_secret: "<WEBUI_SECRET_KEY>"
id_claim: id
Токен без этого claim (или с нестроковым значением) трактуется как anonymous. Обратите внимание на миграцию: до введения auth.id_claim сервер всегда читал claim id; теперь по умолчанию читается sub — развёрнутым конфигурациям (в первую очередь Open-WebUI) при обновлении нужно добавить id_claim: id.
Отсутствие секции 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>"
id_claim: id
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) при этом всегда скрывает недоступные функции — они не рекламируются, но уже написанные скрипты дают понятную ошибку доступа.
Discovery — per-user (M9): помимо скрытия по нашим
expose-правилам (M8), список функций теперь ещё и дополняется инструментами, которые апстрим отдаёт по JWT конкретного пользователя (эпизодическиеtools/list-сессии с кэшем TTL) — «как при прямом подключении». Подробности в разделе «Per-user discovery (M9)».
Форвард JWT на апстрим
Если в запросе присутствует JWT и вызов не был отклонён (в reject-режиме — после успешной верификации), тот же токен передаётся апстримному MCP-серверу на каждом вызове внешней функции:
- прямой прокси
alias__tool→ заголовокAuthorization: Bearer <JWT>на HTTP-запросе апстриму; execute_code→ все вложенные host-функции, внешние к другим MCP-серверам, несут тот же JWT.
Форвард действует независимо от того, есть ли у пользователя запись в users (anonymous — тоже). Передаются только JWT-образные Bearer-токены; прочие заголовки (API-ключи, Basic) апстриму не форвардятся. Идентичность не «перемешивается» между параллельными запросами разных пользователей: каждый исходящий запрос получает заголовок из контекста своего вызова.
Per-user discovery (M9)
Форвард JWT (выше) работает на пути вызова. Для discovery (списка функций) этого мало: tools/list снимается с апстрима один раз при подключении и кэшируется глобально — апстрим видит анонима и отдаёт только публичный набор. Если апстрим фильтрует инструменты по JWT (например, mcp-db-suite скрывает приватные БД от не-владельцев), их список в discovery у всех одинаков — приватные инструменты невидимы даже владельцу.
Начиная с M9 discovery становится per-user:
- Для запроса от идентифицированного пользователя список функций снимается с апстрима эпизодической
tools/list-сессией с заголовкомAuthorization: Bearer <JWT этого пользователя>— апстрим применяет свои правила, как при прямом подключении владельца; - Результат кэшируется per
(сервер, пользователь)наconnector.discovery_ttl_s(default 60 сек); конкурентные запросы одного пользователя коалесцируются (singleflight); ошибки discovery кэшируются негативно на короткий срок; - Anonymous (нет JWT / политика выключена) — пер-сессии не создаются: используется уже закэшированный анонимный
tools/list; - Сбой discovery не роняет запрос:
WARN-лог + фолбэк на анонимный набор (пользователь видит публичные инструменты); - stdio/SSE и in-memory (фейковые) апстримы пер-сессий не создают.
Per-user discovery влияет на все точки, где отдаётся список функций: протокольный tools/list (панель Open-WebUI), get_functions_description, get_functions_raw, search_functions, list_servers (per-user toolCount) и биндинг host-функций в execute_code (приватный инструмент владельца доступен в коде, у чужих — «not defined»). Гейт tools/call (M8) остаётся страховкой.
connector:
discovery_ttl_s: 60 # TTL (сек) кэша per-user discovery (M9); >= 1
Как получить 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, readonly, createdAt, updatedAt}; query — «похожий» поиск по name+description+instructions с score (порог minScore, лимит limit). Видимость — по каталогам пользователя (M11): свой + общая + readonly; readonly — запись из иммутабельного каталога утилит. |
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).
Мультимедиа в ответах execute_code (M10/D-39): скрипты и внешние инструменты могут возвращать изображения/аудио. Строковое представление медиа — data URI (RFC 2397): data:<mime>;base64,<данные>. Механика:
- host-функции
content.*(namespacecontent) явно прикладывают медиа к ответу: JS/Luacontent.image(dataURI)/content.audio(dataURI)/content.attach(base64, mime); Gocontent.Image/content.Audio/content.Attach(CamelCase, паттернv, err :=по D-20); - авто-экстракт: если в возвращённом
valueесть data-URI-строка (например, скрипт вернул результат внешнего инструмента, отдающегоImageContent), она автоматически превращается во вложение, а вvalueподставляется компактный маркер"[attached image/png (N B)]"— base64 не попадает в текстовый JSON (контекст LLM не раздувается); - выход: клиент получает ответ
execute_codeкак несколько content-блоков: текстовый JSONExecuteResult+ нативныеImageContent/AudioContentдля каждого вложения (панель Open-WebUI рендерит их как картинки/аудио); - медиа от внешнего инструмента, вызванного из скрипта, приходит в скрипт как data URI-строка — скрипт сам решает: вернуть её (авто-экстракт) или передать в
content.image; - суммарный лимит вложений — 8MB на одно исполнение; превышение в
content.*— ошибка скрипта, при авто-экстракте значение остаётся нетронутым с пометкой вlogs; - одинаковые вложения дедуплицируются по
(mime, data): если скрипт явно приложил data URI черезcontent.*и вернул ту же строку вvalue, картинка уйдёт клиенту одним блоком, а не двумя; - прямой прокси (
alias__tool) media-блоки не меняет —CallToolResultпробрасывается как есть (M6).
| Язык | Вызов |
|---|---|
| JavaScript | content.image("data:image/png;base64,...") |
| Lua | content.image("data:image/png;base64,...") |
| Go | v, err := content.Image("data:image/png;base64,...") |
Встроенные функции песочницы (математика и др.)
Помимо 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.
Каталоги хранилища
Хранилище многоуровневое: записи разнесены по каталогам в зависимости от статуса и пользователя (M11).
| Каталог | Назначение | Кто пишет | Кто читает |
|---|---|---|---|
store.dir (общая) |
записи по умолчанию | анонимы и вошедшие без персонального каталога | все |
store.readonly_dirs |
иммутабельная библиотека скриптов-утилит (сидируется оператором) | никто (read-only) | все |
store.user_dirs |
личное пространство пользователя | только владелец (id из JWT) |
владелец + (общие/утилиты) |
store:
dir: /var/lib/mcp-code-mode/code # общая (анонимы + дефолт)
readonly_dirs: # перманентные утилиты, нельзя перезаписать
- /var/lib/mcp-code-mode/utils
user_dirs: # персональные каталоги; id — из JWT (auth.id_claim)
- id: "user-1"
dir: /var/lib/mcp-code-mode/users/user-1
Правила:
- Приоритет имени при
get_code,execute_codeпо имени,code.runи вlist_code— личный каталог > утилиты > общая (user > readonly > common). Своя запись важнее утилиты; утилиты не затеняются случайными записями из общей директории. - Запись (
execute_codeсname) идёт только в личный каталог пользователя (или в общую, если персонального нет); readonly-каталоги не пишутся — перезапись утилиты вернёт ошибку. - Имена утилит зарезервированы:
execute_codeсname, уже существующим в любом readonly-каталоге (с кодом или light-skill), отклоняется целиком до исполнения с ошибкой «script is exists» — запись не создаётся ни в личном каталоге, ни в общей (никакого unionfs-оверлея поверх утилит). list_codeвошедшему показывает его каталог + общую + утилиты (дедупликация по приоритету); записи из readonly-каталогов помечены флагомreadonly.code.runтоже персональный: цепочки скриптов видят свои записи и разделяемые утилиты.- Только
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, readonly, createdAt, updatedAt, score?}], отсортированных по updatedAt (новые сверху). query — «похожий» поиск по name+description+instructions (без эмбеддингов: substring-boost + совпадение токенов + символьные биграммы — устойчив к опечаткам и порядку слов); в результаты добавляется score [0,1], порог minScore (по умолчанию 0.2), максимум limit (по умолчанию 20). language — фильтр по языку. codeLen — размер кода в байтах (подсказка, стоит ли тянуть содержимое). hasCode различает исполняемые сниппеты и «лёгкие скиллы» (записи без кода — только читаются через get_code). readonly — запись из иммутабельного каталога утилит (не перезаписывается). Живая доступность внешних серверов (могут ли вызовы в коде снова сработать) видна через 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 — «как применять».
Цепочки скриптов: code.run
Сохранённую запись (с кодом) можно вызвать изнутри другого скрипта через встроенную host-функцию code.run:
// Скрипт B (JS) вызывает сохранённый скрипт A (может быть Lua, Go или JS)
function Run() {
var r = code.run("normalize", { phone: "+7 999 123-45-67" });
console.log(r.e164); // вывод вложенного скрипта попадает в те же logs
return r;
}
-- Скрипт B (Lua) вызывает сохранённый скрипт A
function Run()
local r = code.run("normalize", { phone = "+7 999 123-45-67" })
console.log(r.e164)
return r
end
// Скрипт B (Go) вызывает сохранённый скрипт A
package main
import "code"
import "console"
func Run() interface{} {
r, err := code.Run("normalize", map[string]interface{}{"phone": "+7 999 123-45-67"})
if err != nil { return err.Error() }
console.Log("e164", r.(map[string]interface{})["e164"])
return r
}
Правила:
name— только изlist_code(правило «never guess function names» распространяется и на записи хранилища). Запись без кода («лёгкий скилл») вызвать нельзя —code.runвернёт ошибку с подсказкойget_code.- Параметры: объект
paramsпередаётся в entry-функцию вложенного скрипта как её единственный аргумент (Run(params)); возвращаемое значение вложенного скрипта становится результатомcode.run. - Логи общие: весь вывод вложенного скрипта (
console.*, встроенная печать) попадает в те жеlogs, что и у вызывающего — LLM видит всю цепочку в одном результате. - Кросс-язык: язык вложенного скрипта не важен — JS вызывает Lua, Lua вызывает Go и т.д. Каждый запуск создаёт свежий интерпретатор (D-10); через границу передаются только JSON-сериализуемые данные (объекты/массивы/строки/числа/bool/nil), функции не пересекают границу.
- Ошибки ведут себя как у любого host-вызова: JS
throw(ловитсяtry/catch; необработанное — выполнение B падает), Lua —pcall, Go —(value, err). Текст ошибки несёт имя записи и цепочку вложенности:code.run("A") failed: <error>. - Глубина вложенности ограничена
limits.max_code_depth(дефолт 10, настраивается) — защита от циклов A→B→A. Превышение — ошибка host-функции. - Массовые расчёты: для пачки данных лучше циклировать в одном скрипте (
code.run("normalize", { item: ... })внутри цикла), а не создаватьexecute_codeна каждый элемент.
Записи хранилища персональные (M11): code.run резолвит имя в своём каталоге пользователя, затем в утилитах и в общей директории (приоритет user > readonly > common) — вошедший пользователь видит свои записи и разделяемые утилиты, аноним — общую и утилиты.
Структура репозитория
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 и др. Полный список первоисточников — ниже. ↩︎