No description
  • Go 99.6%
  • Dockerfile 0.4%
Find a file
Ymnuk b6ec3640e7
All checks were successful
ci/woodpecker/tag/woodpecker Pipeline was successful
feat: Логирование
2026-08-05 21:51:08 +03:00
cmd/mcp-code-mode feat: Если есть JWT, то по id можно управлять правами доступа к функциям 2026-08-05 21:09:51 +03:00
docs feat: Реализация Forbidden 2026-08-05 21:48:30 +03:00
internal feat: Логирование 2026-08-05 21:51:08 +03:00
tests/integration feat: Реализация Forbidden 2026-08-05 21:48:30 +03:00
.gitignore feat: Реализация Forbidden 2026-08-05 21:48:30 +03:00
.woodpecker.yml feat: Реализация ограничений внешних MCP-серверов и их функций 2026-08-03 10:06:01 +03:00
config.example.yaml feat: Если есть JWT, то по id можно управлять правами доступа к функциям 2026-08-05 21:09:51 +03:00
Dockerfile feat: Тесты для математики 2026-08-02 21:22:19 +03:00
go.mod fix: Апстрим v0.2.3 про двойное исполнение Lua и усиление LLM-поверхности на английском 2026-08-04 20:56:14 +03:00
go.sum fix: Апстрим v0.2.3 про двойное исполнение Lua и усиление LLM-поверхности на английском 2026-08-04 20:56:14 +03:00
LICENSE first commit 2026-08-02 17:08:25 +03:00
README.md feat: Реализация Forbidden 2026-08-05 21:48:30 +03:00

MCP Code Mode

Go-сервер, который подключается к списку внешних MCP-серверов, агрегирует их инструменты и предоставляет LLM-клиенту два способа работы с этими инструментами:

  1. Code Mode — инструменты внешних серверов превращаются в host-функции (os.read_file, weather.current). LLM запрашивает их описание через get_functions_description(language) и пишет код на JavaScript / Lua / Go, который исполняется в песочнице через execute_code(language, code). Вызовы host-функций внутри кода реально выполняются на внешних MCP-серверах, а промежуточные результаты не попадают в контекст LLM — модель получает только финальный результат функции Run().
  2. Прямой доступ — каждый внешний инструмент дополнительно выставляется как отдельный MCP-инструмент (os__read_file) для вызова напрямую, без написания кода (классическое tool-calling / проксирование).

В основе — паттерн «код как компактный план»1: вместо тотального tool-calling (где каждый внешний инструмент регистрируется отдельным MCP-tool'ом и его схема целиком засоряет контекст LLM) модель пишет код против скриптового API host-функций. Код исполняется в песочнице на сервере, и в контекст возвращается только результат Run(). Уникальная комбинация проекта: агрегация нескольких MCP-серверов + code mode + три языковых бэкенда исполнения (JavaScript, Lua, Go) в одном процессе + хранилище с «лёгкими скиллами» (записи без кода с instructions).


Содержание


Возможности

  • Агрегация внешних 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-tool tool_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): уровни debugerror, файл или 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-agregate v0.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. Сервер может:

  1. Идентифицировать пользователя по id из JWT (верификация подписи HS256 на auth.jwt_secret — для Open-WebUI это WEBUI_SECRET_KEY);
  2. Применить per-user права доступа к серверам/инструментам;
  3. Передать тот же 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:

  1. LLM вызывает get_functions_description(language="javascript") → получает шпаргалку host-функций (на английском, с примером скрипта): os.read_file({path}), weather.current({city}) и т.д.

  2. 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 };
    }
    
  3. LLM вызывает execute_code(language="javascript", code="...").

  4. Сервер создаёт свежий интерпретатор Goja, регистрирует host-функции из реестра, подготавливает скрипт, вызывает Run().

  5. Каждый вызов os.stat(...) из кода маршрутизируется в session.CallTool соответствующего внешнего сервера; результат (StructuredContent или текст) возвращается в скрипт.

  6. Сервер возвращает значение Run() модели. Промежуточные результаты в контекст LLM не попадают.

Прямой доступ: LLM просто вызывает MCP-инструмент os__read_file с аргументами — сервер транслирует его в session.CallTool на внешний сервер.

Подход: код как компактный план против тотального tool-calling

Схема инструментов и контракт скриптов живут на сервере: в контекст LLM попадает только компактное описание (шпаргалка, сводки хранилища) и результат выполнения — а не полные JSON Schema каждого инструмента. Это противоположность классическому MCP, где каждый инструмент/endpoint регистрируется отдельным tool'ом.

Особенности этого проекта:

  • Discoveryget_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. Если апстрим-серверу задан alias math (например weather-mcpmath), поведение зависит от языка:

  • Go: host-namespace math затеняет stdlib-пакет mathimport "math" даёт только host-функции, а math.Sqrt/math.Pi недоступны. Это осознанное поведение (защита песочницы от обхода через stdlib, см. filteredStdlib в библиотеке v0.1.0).
  • Lua: host-функции сливаются в существующую таблицу mathmath.add (host) и math.sin (встроенный) сосуществуют. Осторожно: инструмент апстрима с именем sin/floor/random перезапишет встроенную функцию.
  • JavaScript: конфликта нет — встроенный Math (заглавная) и host-namespace math (строчная) разные.

Рекомендация: если скриптам Go нужна stdlib-математика, не используйте alias math — задайте неконфликтующий (например calc, numbers). Для JS/Lua alias math безопасен.


Хранилище кода

Сервер может сохранять успешно выполнившиеся скрипты на диск и переиспользовать их. Это позволяет 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-тега и выполняет:

  1. go vet + go test ./... с покрытием и бенчмарками;
  2. сборку статических бинарников mcp-code-mode для linux/amd64, linux/arm64, windows/amd64;
  3. загрузку бинарников в Gitea-релиз тега;
  4. 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 — пример конфигурации.

Концептуальные первоисточники


Лицензия

MIT License — см. файл LICENSE. Copyright (c) 2026 Fedoryuk Alexandr.


  1. Термин «Code Mode» введён Cloudflare; аналогичные подходы описаны Anthropic и др. Полный список первоисточников — ниже. ↩︎