#!/usr/bin/env python3 # -*- coding: utf-8 -*- """MCP-сервер MARFOR: доступ ассистента (Claude и любого MCP-клиента) к MARFOR по HTTP API. Работает от имени пользователя и видит ровно то, что видит он сам. Основной вход — персональный токен доступа: заголовок `Authorization: Bearer …` уходит с каждым запросом к своему стенду, cookie-сессии нет, отзыв токена на сайте действует сразу. Токен берётся из переменной MARFOR_TOKEN либо из файла ~/.marfor_mcp/token.json (ключ файла — адрес стенда, чтобы токен не ушёл на другой адрес). Получить его проще всего подтверждением в браузере — секрет не проходит ни через чат, ни через конфигурацию клиента, ни через историю оболочки: python3 server.py --login Остальные команды — `python3 server.py --help`, инструкция — https://marfor.pro/mcp. Запасной путь — вход паролем (MARFOR_EMAIL + пароль) для стендов без токенов: сервер сам делает POST /login и держит cookie в памяти. Работает ТОЛЬКО с явно заданным MARFOR_BASE_URL: стенд по умолчанию — marfor.pro, и без этого условия пароль от другого стенда ушёл бы туда. Пароль сервер НЕ хранит и НЕ пишет на диск: берёт из Keychain macOS (`security find-generic-password -s marfor-mcp -a -w`; на других системах Keychain нет) либо из переменной окружения MARFOR_PASSWORD. Запись в Keychain заводит сам пользователь: security add-generic-password -s marfor-mcp -a -w Зависимости: только стандартная библиотека Python 3.8+. Протокол: MCP поверх stdio (JSON-RPC 2.0, по одному сообщению на строку). В stdout уходит ТОЛЬКО протокол, все логи — в stderr. """ import argparse import copy import csv import datetime import decimal import difflib import hashlib import http.client import io import json import os import re import shlex import socket import subprocess import sys import tempfile import threading import time import urllib.error import urllib.parse import urllib.request import uuid from http.cookiejar import CookieJar SERVER_NAME = 'marfor' SERVER_VERSION = '0.7.1' SUPPORTED_PROTOCOLS = ('2025-06-18', '2025-03-26', '2024-11-05') # Публичный стенд. По нему же решается, какие наборы инструментов видны по умолчанию # и есть ли смысл спрашивать сайт о новой версии сервера. PUBLIC_BASE = 'https://marfor.pro' PUBLIC_HOSTS = ('marfor.pro', 'www.marfor.pro') BASE_URL = (os.environ.get('MARFOR_BASE_URL') or PUBLIC_BASE).strip().rstrip('/') # Задан ли стенд ЯВНО. Парольный вход без этого запрещён: умолчание — публичный marfor.pro, # и пароль от другого стенда ушёл бы туда POST-ом на /login. Так вышло бы у любого сценария, # который импортирует этот файл и держался на прежнем умолчании (до 0.4.0 оно было другим). BASE_URL_EXPLICIT = bool((os.environ.get('MARFOR_BASE_URL') or '').strip()) EMAIL = os.environ.get('MARFOR_EMAIL', '') KEYCHAIN_SERVICE = os.environ.get('MARFOR_KEYCHAIN_SERVICE', 'marfor-mcp') HTTP_TIMEOUT = int(os.environ.get('MARFOR_HTTP_TIMEOUT', '180')) # Генерация BI-страницы синхронная и ДОЛГАЯ: план запросов + сборка HTML # чанками по ~1500 токенов (до 20 чанков по ~45с) — минуты, в пределе ~20. # nginx на mar держит запрос 1800с, обычного MARFOR_HTTP_TIMEOUT не хватит. BI_GENERATE_TIMEOUT = int(os.environ.get('MARFOR_BI_GENERATE_TIMEOUT', '1500')) JOB_DIR = os.path.expanduser('~/.marfor_mcp/jobs') PAGE_DIR = os.path.expanduser('~/.marfor_mcp/pages') CONFIG_DIR = os.path.expanduser('~/.marfor_mcp') TOKEN_FILE = os.path.join(CONFIG_DIR, 'token.json') # {"<адрес стенда>": "<токен>"} PENDING_FILE = os.path.join(CONFIG_DIR, 'pending.json') # начатый, но не завершённый вход # Форма токена. Проверяется ДО того, как он попадёт в заголовок: http.client на мусоре # в заголовке бросает ValueError с самим значением в тексте — токен утёк бы в чат. # fullmatch, а не match с «$»: у «$» в Python есть лазейка — он совпадает и перед # завершающим переводом строки, то есть «токен\n» прошёл бы проверку. TOKEN_RE = re.compile(r'marfor_pat_[A-Za-z0-9_-]{20,200}') # Сколько секунд помнить токен, который стенд отверг (401 или 429 too_many_attempts). Пока # помним — с тем же значением в сеть не ходим: у стенда лимит неудач на IP, и отозванный # токен, повторяемый на каждый вызов инструмента, выбрал бы его за минуты. Другое значение # токена идёт в сеть сразу. REFUSED_TOKEN_TTL = 60 # Батч длиной в часы не влезает в один вызов инструмента, поэтому идёт фоном. _JOBS = {} _JOBS_LOCK = threading.Lock() # Удалённый режим (remote_call_tool) молчит: в веб-процессе stdout и stderr — журнал сервиса, а # каталог коннекторов Claude требует не писать туда содержимое переписки (email, user_id, данные # из текстов ошибок). _QUIET = False def log(*a): if _QUIET: return print(*a, file=sys.stderr, flush=True) # ─────────────────────── стенд: свой ли адрес, публичный ли ─────────────────── def _base_host(): try: u = urllib.parse.urlsplit(BASE_URL) return u.scheme, (u.hostname or '').lower() except ValueError: # кривой адрес вроде https://[::1 — urlsplit бросает return '', '' def _is_public_stand(): return _base_host()[1] in PUBLIC_HOSTS def _is_base_url(url): """Адрес принадлежит СВОЕМУ стенду? Голого startswith(BASE_URL) мало: под него подходят и https://marfor.pro.evil.example, и https://marfor.pro@evil.example, и соседний порт 127.0.0.1:50001 при стенде 127.0.0.1:5000. После адреса стенда обязан идти путь или строка запроса.""" return url == BASE_URL or url.startswith(BASE_URL + '/') or url.startswith(BASE_URL + '?') def _base_url_problem(): """Текст отказа, если стенд задан небезопасно; None — всё в порядке. По http токен и пароль ушли бы открытым текстом, поэтому http — только для localhost/127.0.0.1 (подставной стенд тестов, локальная разработка).""" scheme, host = _base_host() if scheme == 'https' and host: return None if scheme == 'http' and host in ('localhost', '127.0.0.1'): return None return ('MARFOR_BASE_URL=%s — небезопасный адрес: нужен https://… (http разрешён только ' 'для localhost и 127.0.0.1). Сервер не запускается, чтобы токен не ушёл по сети ' 'открытым текстом. Адрес по умолчанию — %s.' % (BASE_URL or '(пусто)', PUBLIC_BASE)) # ───────────────────────── токен доступа: где лежит и как пишется ───────────── def _secure_dir(): """Каталог ~/.marfor_mcp с правами 0700. На Windows chmod прав не ограничивает — там каталог и так лежит в профиле пользователя, шаг молча пропускаем.""" os.makedirs(CONFIG_DIR, exist_ok=True) if os.name != 'nt': try: os.chmod(CONFIG_DIR, 0o700) except OSError: pass def _write_private_json(path, obj): """Атомарная запись JSON с правами 0600: временный файл рядом + os.replace. Обрыв на середине не оставит ни полфайла, ни секрета, открытого на чтение всем.""" _secure_dir() fd, tmp = tempfile.mkstemp(prefix='.tmp_', suffix='.json', dir=CONFIG_DIR) try: with os.fdopen(fd, 'w', encoding='utf-8') as f: json.dump(obj, f, ensure_ascii=False, indent=1) if os.name != 'nt': os.chmod(tmp, 0o600) os.replace(tmp, path) except BaseException: try: os.unlink(tmp) except OSError: pass raise def _read_json(path): try: with open(path, encoding='utf-8') as f: js = json.load(f) return js if isinstance(js, dict) else {} except (OSError, ValueError): return {} def _find_token(): """(токен, источник). Порядок: MARFOR_TOKEN → token.json с ключом по адресу стенда. Ключ по стенду нужен, чтобы токен marfor.pro не ушёл на другой адрес, когда MARFOR_BASE_URL поменяли, а файл остался прежним.""" tok = (os.environ.get('MARFOR_TOKEN') or '').strip() if tok: return tok, 'env' tok = _read_json(TOKEN_FILE).get(BASE_URL) if isinstance(tok, str) and tok.strip(): return tok.strip(), 'file' return '', None def _save_token(token): data = _read_json(TOKEN_FILE) data[BASE_URL] = token _write_private_json(TOKEN_FILE, data) def _forget_token(): """Убрать из файла токен ЭТОГО стенда (токены других стендов остаются). True — был.""" data = _read_json(TOKEN_FILE) if BASE_URL not in data: return False del data[BASE_URL] if data: _write_private_json(TOKEN_FILE, data) else: try: os.unlink(TOKEN_FILE) except OSError: pass return True _NT_PLAIN_ARG = re.compile(r'[A-Za-z0-9_.:/=@%+,-]+') def _shq(s, path=False): """Аргумент команды, которую человек или ассистент скопирует и выполнит. POSIX — shlex.quote. Windows: путь ВСЕГДА в двойных кавычках и с прямыми слэшами ("C:/Users/…/server.py"). Такую запись одинаково читают Git Bash (им исполняет команды Claude Code на Windows; путь без кавычек он лишал обратных слэшей, и сервер регистрировался с несуществующим адресом C:Usersivan.marfor_mcpserver.py), PowerShell и cmd. Остальные аргументы берутся в кавычки, только когда в них есть что-то кроме букв, цифр и безобидных знаков: «claude» в кавычках PowerShell командой не считает.""" if os.name != 'nt': return shlex.quote(s) if path: s = s.replace('\\', '/') if path or not _NT_PLAIN_ARG.fullmatch(s): return '"%s"' % s.replace('"', '\\"') return s def _self_cmd(*extra): """Команда запуска ЭТОГО файла ЭТИМ интерпретатором, с абсолютными путями: подсказка «python3 server.py --login» не сработает, если человек стоит в другом каталоге. Стенд не по умолчанию едет вместе с командой: без переменной вход по подсказке начался бы на marfor.pro, токен лёг бы в файл под чужим ключом, а сервер снова ответил бы «токен не найден» — по кругу. Записи «переменная перед командой», общей для оболочек Windows, нет, поэтому там то же самое говорит словами _win_notes().""" parts = [_shq(sys.executable or 'python3', path=True), _shq(os.path.abspath(__file__), path=True)] + [_shq(x) for x in extra] if BASE_URL != PUBLIC_BASE and os.name != 'nt': parts.insert(0, 'MARFOR_BASE_URL=' + shlex.quote(BASE_URL)) return ' '.join(parts) def _win_notes(): """Пояснение к напечатанным командам для Windows; на остальных системах — пусто. Команда там начинается со строки в кавычках: cmd и Git Bash исполняют её как есть, а PowerShell считает строку в начале выражением и без «&» отвечает ошибкой разбора.""" if os.name != 'nt': return '' out = ('\nWindows: в cmd и Git Bash команды верны как напечатаны; в PowerShell перед ' 'командой нужен знак & и пробел.') if BASE_URL != PUBLIC_BASE: out += (' Перед запуском нужна переменная окружения MARFOR_BASE_URL=%s (стенд не по ' 'умолчанию; без неё вход уйдёт на %s).' % (BASE_URL, PUBLIC_BASE)) return out # С 0.7.1 тексты ОТВЕТОВ инструментов — факты: что случилось, что изменилось, где что взять. Ни # ассистенту («Объясните пользователю…», «не повторяйте вызов»), ни пользователю («Войдите # заново») ответ не командует: ревьюеры каталога коннекторов Claude читают обращения к модели в # результатах как попытку ею управлять. Сторожит test_directory_readiness.py, раздел 8. def _two_step_login_text(): """Вход без терминала с вводом (команды запускает ассистент) — описание двух шагов.""" return ('Когда команды запускает ассистент (терминала с вводом нет), вход идёт в два шага: ' '%s печатает ссылку и код для пользователя, %s ждёт его «Разрешить» в браузере (код ' 'возврата 2 — подтверждения ещё нет, команда запускается снова).\n' % (_self_cmd('--login-start'), _self_cmd('--login-finish'))) def _no_credentials_text(): return ('Вход в MARFOR не настроен: токен доступа для %s не найден (нет ни переменной ' 'MARFOR_TOKEN, ни записи в %s).\n' 'Вход подтверждением в браузере — команда в терминале:\n' ' %s\n' '%s' 'Перезапуск клиента после входа не нужен. Инструкция: %s/mcp%s' % (BASE_URL, TOKEN_FILE, _self_cmd('--login'), _two_step_login_text(), PUBLIC_BASE, _win_notes())) def _password_needs_base_text(): return ('Парольный вход (MARFOR_EMAIL) работает только с явно заданным MARFOR_BASE_URL. ' 'Сейчас переменная не задана, стенд по умолчанию — %s, и пароль от другого стенда ' 'ушёл бы туда. Пароль НЕ отправлен.\n' 'Для другого стенда рядом с MARFOR_EMAIL нужна переменная ' 'MARFOR_BASE_URL=https://<адрес стенда>.\n' 'Для %s вход — токеном, подтверждением в браузере:\n %s%s' % (PUBLIC_BASE, PUBLIC_BASE, _self_cmd('--login'), _win_notes())) def _token_refusal(code, js): """Код отказа подлинности токена (401 или 429) либо None. 401 — всегда отказ. 429 с code=too_many_attempts хук Bearer отдаёт ТОЛЬКО неверному токену — после 30 неудач с адреса за 10 минут; верный токен проходит всегда. Это тот же отказ, и повторять его так же бессмысленно. Прочие 429 (лимиты самих ручек) к токену отношения не имеют.""" if code == 401: return 401 if code == 429 and isinstance(js, dict) and js.get('code') == 'too_many_attempts': return 429 return None def _token_rejected_text(src=None, http=401): # Токен из переменной важнее файла: без этой оговорки человек вошёл бы заново, токен # лёг бы в файл, а сервер продолжал бы слать старый из конфигурации клиента — по кругу. env_note = ('Этот токен задан переменной MARFOR_TOKEN в конфигурации MCP-клиента, и она ' 'важнее файла: новый вход подействует, когда переменной там не будет или в ней ' 'будет новый токен.\n') if src == 'env' else '' head = 'Токен недействителен: он отозван или истёк (HTTP 401 от %s).\n' % BASE_URL if http == 429: head = ('Токен недействителен: он отозван или истёк. Стенд %s ответил HTTP 429 ' '(too_many_attempts): с этого адреса пришло слишком много запросов с неверным ' 'токеном, и проверять его стенд перестал. Ждать не нужно: новый токен заработает ' 'сразу.\n' % BASE_URL) return (head + env_note + ('Новый вход — команда в терминале:\n' ' %s\n' '%s' 'Перезапуск клиента не нужен. Токены видны и отзываются на %s/account, ' 'раздел «Подключение Claude (MCP)».%s' % (_self_cmd('--login'), _two_step_login_text(), PUBLIC_BASE, _win_notes()))) # ──────────────── ответы стенда: «нет сессии» и ошибки сети словами ─────────── _UNAUTH_MESSAGES = ('Требуется авторизация', 'Не авторизован', 'Необходима авторизация') def _json_or_none(raw): try: return json.loads(raw.decode('utf-8', 'replace')) except Exception: return None def _looks_unauthorized(js): """Отказ «нет сессии» — по РАЗОБРАННОМУ ответу. Искать фразу в сыром тексте нельзя: стенд отдаёт кириллицу \\u-экранированной, и такая проверка не срабатывала ни разу.""" if not isinstance(js, dict) or js.get('success') is not False: return False return str(js.get('message') or '').strip().startswith(_UNAUTH_MESSAGES) def _session_stale(code, hdrs, js): """Протухла ли парольная сессия. 403 сюда НЕ входит: это законный отказ («Нет доступа к проекту»), повторный вход его не вылечит — только лишний раз сходит на /login.""" if code == 401: return True loc = (hdrs.get('Location') or '') if hdrs else '' if code in (301, 302, 303, 307, 308) and '/login' in loc: return True return _looks_unauthorized(js) def _net_error_text(e, url, timeout): """Сбой сети человеческим языком. Пользователь здесь один на один с ассистентом, и строка «» ему ничего не скажет.""" reason = getattr(e, 'reason', None) or e txt = '%s: %s' % (type(reason).__name__, reason) host = urllib.parse.urlsplit(url).netloc or url if isinstance(e, http.client.InvalidURL): return ('Недопустимый адрес запроса: в идентификаторе или пути, переданном инструменту, ' 'есть пробел или управляющий символ.') if 'CERTIFICATE_VERIFY_FAILED' in txt: return ('Python не смог проверить сертификат %s (CERTIFICATE_VERIFY_FAILED). Обычная ' 'причина на macOS — Python, установленный с python.org, без корневых ' 'сертификатов: их ставит «Install Certificates.command» из папки «Программы → ' 'Python 3.x». Другая возможная причина — корпоративный прокси или антивирус, ' 'подменяющий сертификаты.' % host) if isinstance(reason, (socket.timeout, TimeoutError)) or 'timed out' in txt: return ('%s не ответил за %s с: нет связи либо операция дольше предела. Предел ' 'ожидания задаёт MARFOR_HTTP_TIMEOUT.' % (host, timeout)) if isinstance(reason, socket.gaierror): return ('Не удалось найти адрес %s: нет интернета или VPN либо неверен ' 'MARFOR_BASE_URL.' % host) if isinstance(reason, ConnectionRefusedError): return ('%s не принимает соединения: сервис недоступен либо адрес в MARFOR_BASE_URL ' 'неверный.' % host) return 'Нет связи с %s (%s): причина — интернет, VPN или прокси.' % (host, txt) # ─────────────────────────── HTTP-клиент: токен или сессия ─────────────────── class TokenRefused(RuntimeError): """Стенд отверг токен: 401 token_invalid либо 429 too_many_attempts (см. _token_refusal) — он отозван или истёк. Отдельный класс нужен фоновому батчу: для него это конец задания, а не «ошибка очередной правки».""" class Marfor: """Тонкий клиент, два режима. Токен: Bearer в каждом запросе к своему стенду, без /login и cookie, отказ — сразу понятным текстом. Пароль: вход через /login, cookie в памяти, после протухания сессии — один повторный вход.""" def __init__(self): self.jar = CookieJar() self.opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(self.jar), _NoRedirect(), ) # Режим токена и публичные ручки ходят БЕЗ cookie: сессии там нет по построению. self.opener_plain = urllib.request.build_opener(_NoRedirect()) self.logged_in = False self.account = None self.token = '' self.token_source = None self.refused = None # (sha256 отвергнутого токена, когда отвергнут) — см. _is_refused self.lock = threading.Lock() # -- пароль -- @staticmethod def _password(): pw = os.environ.get('MARFOR_PASSWORD') if pw: return pw if not EMAIL: raise RuntimeError('не задан MARFOR_EMAIL') if sys.platform != 'darwin': # Keychain и команда security есть только на macOS. Раньше на Linux и Windows здесь # получалось «[Errno 2] No such file or directory: 'security'» — и ни слова о том, # что делать дальше. raise RuntimeError( 'пароль взять неоткуда: Keychain есть только на macOS, а переменная ' 'MARFOR_PASSWORD не задана. Обычный путь — вход токеном, подтверждением в ' 'браузере:\n %s\nДля CI и стендов без токенов пароль берётся из переменной ' 'MARFOR_PASSWORD рядом с MARFOR_EMAIL.%s' % (_self_cmd('--login'), _win_notes())) try: out = subprocess.run( ['security', 'find-generic-password', '-s', KEYCHAIN_SERVICE, '-a', EMAIL, '-w'], capture_output=True, text=True, timeout=20) except Exception as e: raise RuntimeError(f'не удалось спросить Keychain: {e}') if out.returncode != 0: raise RuntimeError( f'пароль не найден в Keychain. Запись заводит команда:\n' f' security add-generic-password -s {KEYCHAIN_SERVICE} ' f'-a {EMAIL} -w') return out.stdout.strip('\n') # -- низкий уровень -- def _raw(self, method, path, *, data=None, form=None, headers=None, timeout=None, auth=True): """auth=False — публичные ручки (вход подтверждением в браузере, version.json): токен к ним прикладывать нельзя, стенд ответит на него 401/403 вместо дела.""" url = path if path.startswith('http') else BASE_URL + path body, hdr = None, {'User-Agent': 'marfor-mcp/%s' % SERVER_VERSION} if form is not None: body = urllib.parse.urlencode(form).encode() hdr['Content-Type'] = 'application/x-www-form-urlencoded' elif data is not None: body = json.dumps(data, ensure_ascii=False).encode() hdr['Content-Type'] = 'application/json' hdr.update(headers or {}) # Токен уходит ТОЛЬКО на свой стенд: метод принимает и абсолютные адреса. # Редиректы отключены (_NoRedirect), так что и переадресацией его не увести. if auth and self.token and _is_base_url(url): hdr['Authorization'] = 'Bearer ' + self.token opener = self.opener if (auth and not self.token) else self.opener_plain req = urllib.request.Request(url, data=body, headers=hdr, method=method) t = timeout or HTTP_TIMEOUT try: resp = opener.open(req, timeout=t) return resp.getcode(), resp.headers, resp.read() except urllib.error.HTTPError as e: try: err_body = e.read() except Exception: err_body = b'' return e.code, e.headers, err_body except (OSError, http.client.HTTPException) as e: # URLError, таймауты, обрывы и ошибки TLS — все наследники OSError raise RuntimeError(_net_error_text(e, url, t)) def login(self): with self.lock: if not BASE_URL_EXPLICIT: # Стоит ДО чтения пароля: без явного стенда не трогаем ни Keychain, ни сеть. raise RuntimeError(_password_needs_base_text()) pw = self._password() code, hdrs, _ = self._raw('POST', '/login', form={'email': EMAIL, 'password': pw}) del pw loc = hdrs.get('Location', '') if hdrs else '' if code not in (200, 302, 303) or (code == 200): # 200 на POST /login = страница входа с ошибкой (редиректа не было) if code == 200: raise RuntimeError('вход отклонён: неверный логин или пароль') if '/login' in loc: raise RuntimeError('вход отклонён: сервер вернул обратно на /login') # Проверка входа идёт по /api/account/balance: он отдаёт user_id и email, # то есть сразу видно, ПОД КАКИМ аккаунтом мы вошли. /api/projects для # этого не годится — 05.08 он падал с «string indices must be integers» # и молча выглядел как отказ авторизации. code, _, raw = self._raw('GET', '/api/account/balance') if code != 200: raise RuntimeError(f'после входа /api/account/balance отдал HTTP {code}') try: js = json.loads(raw.decode('utf-8', 'replace')) except Exception: raise RuntimeError('после входа /api/account/balance вернул не JSON') bal = (js or {}).get('balance') or {} if not bal.get('user_id'): raise RuntimeError(f'после входа не удалось определить аккаунт: ' f'{str(js)[:200]}') self.logged_in = True self.account = {'email': bal.get('email') or EMAIL, 'base_url': BASE_URL, 'user_id': bal.get('user_id'), 'tariff': bal.get('tariff')} log(f'[marfor] вход выполнен: {self.account["email"]} ' f'(user_id={self.account["user_id"]}) @ {BASE_URL}') return self.account def token_login(self, tok, src): """Режим токена: ни /login, ни cookie. Один GET /api/account/balance проверяет токен и заодно говорит, ПОД КАКИМ аккаунтом идёт работа.""" with self.lock: if not TOKEN_RE.fullmatch(tok): raise RuntimeError( 'Токен из %s не похож на токен MARFOR: он начинается с marfor_pat_ и ' 'состоит из латинских букв, цифр, «-» и «_». Частая причина — токен ' 'скопирован не целиком или с кавычками. Новый вход: %s%s' % ('переменной MARFOR_TOKEN' if src == 'env' else TOKEN_FILE, _self_cmd('--login'), _win_notes())) self.token, self.token_source = tok, src try: # Первое обращение: на «чёрной дыре» вместо сети ждать три минуты незачем code, _, raw = self._raw('GET', '/api/account/balance', timeout=min(HTTP_TIMEOUT, 30)) js = _json_or_none(raw) refusal = _token_refusal(code, js) if refusal: self._remember_refused(tok, refusal) raise TokenRefused(_token_rejected_text(src, refusal)) if _looks_unauthorized(js): raise RuntimeError( 'Стенд %s не узнал токен: похоже, вход по токену на нём не включён либо ' 'MARFOR_BASE_URL указывает не на тот стенд (по умолчанию %s).' % (BASE_URL, PUBLIC_BASE)) bal = (js or {}).get('balance') if isinstance(js, dict) else None if code != 200 or not isinstance(bal, dict) or not bal.get('user_id'): msg = (js or {}).get('message') if isinstance(js, dict) else None raise RuntimeError('проверка токена не удалась: /api/account/balance ' 'отдал HTTP %s%s' % (code, (' — %s' % msg) if msg else '')) except RuntimeError: self.token, self.token_source, self.logged_in = '', None, False raise self.logged_in = True self.account = {'email': bal.get('email'), 'base_url': BASE_URL, 'user_id': bal.get('user_id'), 'tariff': bal.get('tariff')} log(f'[marfor] токен принят: {self.account["email"]} ' f'(user_id={self.account["user_id"]}) @ {BASE_URL}') return self.account def _token_refused(self, http=401): # Токен забываем: следующий вызов перечитает переменную и файл — человек мог # успеть войти заново, и тогда всё заработает без перезапуска клиента. src = self.token_source self._remember_refused(self.token, http) self.token, self.token_source, self.logged_in = '', None, False return TokenRefused(_token_rejected_text(src, http)) @staticmethod def _digest(tok): return hashlib.sha256(tok.encode('utf-8', 'replace')).hexdigest() def _remember_refused(self, tok, http=401): # В памяти остаётся хеш, а не сам токен: отвергнутое значение хранить незачем. self.refused = (self._digest(tok), time.monotonic(), http) if tok else None def _is_refused(self, tok): """Код прежнего отказа, если этот самый токен стенд только что отверг, иначе None. С отвергнутым токеном в сеть не идём: отказ повтором не лечится, а у стенда лимит неудач на IP (30 за 10 минут). Раньше каждый вызов инструмента заново читал тот же token.json и снова получал отказ. Память короткая (REFUSED_TOKEN_TTL) и только на это значение: после нового входа токен другой и идёт в сеть сразу.""" ref = self.refused if (ref and ref[0] == self._digest(tok) and time.monotonic() - ref[1] < REFUSED_TOKEN_TTL): return ref[2] if len(ref) > 2 else 401 return None def ensure(self): """Вход, если его ещё не было. Токен ищется КАЖДЫЙ раз заново: сервер обычно уже запущен клиентом, когда человек делает --login, и перезапуска это требовать не должно.""" if self.logged_in: return tok, src = _find_token() if tok: refused = self._is_refused(tok) if refused: raise TokenRefused(_token_rejected_text(src, refused)) self.token_login(tok, src) elif EMAIL: self.token, self.token_source = '', None self.login() else: raise RuntimeError(_no_credentials_text()) def call(self, method, path, *, data=None, params=None, _retry=True, timeout=None): """GET/POST к /api/*. Пароль: при протухшей сессии — один повторный вход. Токен: повторять нечем, отказ сразу превращается в понятный текст.""" self.ensure() if params: path = path + ('&' if '?' in path else '?') + urllib.parse.urlencode(params) code, hdrs, raw = self._raw(method, path, data=data, timeout=timeout) text = raw.decode('utf-8', 'replace') try: js = json.loads(text) except Exception: js = {'_raw': text[:4000], '_not_json': True} if self.token: refusal = _token_refusal(code, js) if refusal: raise self._token_refused(refusal) return code, js if _retry and _session_stale(code, hdrs, js): log('[marfor] сессия протухла — повторный вход') self.logged_in = False self.login() return self.call(method, path, data=data, _retry=False, timeout=timeout) return code, js def fetch_raw(self, path, *, timeout=None, _retry=True): """GET, отдающий тело КАК ЕСТЬ (HTML AI-страниц — это не JSON).""" self.ensure() code, hdrs, raw = self._raw('GET', path, timeout=timeout) if self.token: refusal = _token_refusal(code, _json_or_none(raw) if code == 429 else None) if refusal: raise self._token_refused(refusal) return code, raw js = _json_or_none(raw) if raw[:1] == b'{' else None if _retry and _session_stale(code, hdrs, js): log('[marfor] сессия протухла — повторный вход') self.logged_in = False self.login() return self.fetch_raw(path, timeout=timeout, _retry=False) return code, raw class _NoRedirect(urllib.request.HTTPRedirectHandler): """Редирект на /login должен быть виден как признак «не авторизован».""" def redirect_request(self, req, fp, code, msg, headers, newurl): return None def _count_projects(js): if isinstance(js, list): return len(js) if isinstance(js, dict): for k in ('projects', 'data', 'items'): if isinstance(js.get(k), list): return len(js[k]) return None MF = Marfor() # ───────────────────────────── прикладные операции ──────────────────────────── DRIVER_DIMS = ['region_to', 'channel_new', 'segment_new', 'category'] def apply_edit(session_id, metric, filters, new_value, base_value=None, wait=True, poll_sec=2, timeout_sec=1800, pivot_dimensions=None): """Одна правка моделирования — тем же путём, что и правка ячейки в UI.""" payload = { 'metric': metric, 'mode': 'slices', 'filters': filters, 'filter_fields': list(filters.keys()), 'filter_values': [], 'time_period': [], 'is_total': False, 'base_value': base_value, # None → сервер посчитает базу сам 'new_value': new_value, 'recursive': True, 'apply_ui_filters': True, 'is_chain_constant': False, 'mmm_slice_key': None, # ε берётся построчно из колонки elasticity 'pivot_dimensions': pivot_dimensions or DRIVER_DIMS, } code, js = MF.call('POST', f'/api/modeling_task/{session_id}', data={'action': 'apply', 'payload': payload}) if not isinstance(js, dict): return {'ok': False, 'message': f'HTTP {code}: неожиданный ответ'} if js.get('busy'): return {'ok': False, 'busy': True, 'message': js.get('message') or js.get('reason') or 'сервер занят'} plan = _plan_limit_text(code, js) if plan: # Тарифный отказ — текстом для продажи (_plan_limit_text); инструмент отдаёт его как есть. return {'ok': False, 'plan_limit': True, 'message': plan} if not js.get('success') or not js.get('task_id'): return {'ok': False, 'message': js.get('message') or js.get('error') or str(js)[:300]} task_id = js['task_id'] if not wait: return {'ok': True, 'task_id': task_id, 'pending': True} t0 = time.time() while time.time() - t0 < timeout_sec: time.sleep(poll_sec) _, st = MF.call('GET', f'/api/modeling_task_status/{task_id}') if not isinstance(st, dict): continue status = st.get('task_status') if status == 'completed': return {'ok': st.get('success') is not False, 'task_id': task_id, 'seconds': round(time.time() - t0, 1), 'message': st.get('message') or 'ok'} if status in ('error', 'failed'): return {'ok': False, 'task_id': task_id, 'seconds': round(time.time() - t0, 1), 'message': st.get('error') or st.get('message') or 'ошибка задачи'} return {'ok': False, 'task_id': task_id, 'message': f'таймаут {timeout_sec}с'} def _query_filters(filters): """У /api/query формат фильтра — {'колонка': {'values': [...], 'mode': 'include'}}. Плоский список он МОЛЧА игнорирует (`if not isinstance(fval, dict): continue`) и считает по всей таблице — так 05.08 «russia Q3» превратилось в 4,87 млрд за всю историю. У apply_modeling_changes формат обратный (плоские значения), поэтому нормализуем здесь, а не в вызывающем коде.""" out = {} for k, v in (filters or {}).items(): if isinstance(v, dict) and 'values' in v: out[k] = {'values': list(v['values']), 'mode': v.get('mode', 'include')} elif isinstance(v, (list, tuple, set)): out[k] = {'values': list(v), 'mode': 'include'} elif v is not None: out[k] = {'values': [v], 'mode': 'include'} return out def _query_call(session_id, metrics, dimensions=None, filters=None, date_from=None, date_to=None, data_source='scenario'): """(HTTP-код, ответ) /api/query: код нужен marfor_query, чтобы узнать тарифный отказ.""" body = {'metrics': metrics, 'dimensions': dimensions or [], 'filters': _query_filters(filters), 'data_source': data_source} if date_from: body['date_from'] = date_from if date_to: body['date_to'] = date_to return MF.call('POST', f'/api/query/{session_id}', data=body) def query(session_id, metrics, dimensions=None, filters=None, date_from=None, date_to=None, data_source='scenario'): return _query_call(session_id, metrics, dimensions, filters, date_from, date_to, data_source)[1] _DERIVED_CACHE = {} def _derived_formulas(session_id): """{имя вычисляемой метрики: формула} из «Метрик и правил» сессии.""" if session_id not in _DERIVED_CACHE: _, js = MF.call('GET', f'/api/derived_metrics/{session_id}') lst = (js or {}).get('derived_metrics') or [] _DERIVED_CACHE[session_id] = { d['name']: d['formula'] for d in lst if isinstance(d, dict) and d.get('name') and d.get('formula')} return _DERIVED_CACHE[session_id] def _expand_formula(session_id, metric, depth=0): """Развернуть метрику до выражения только из базовых колонок. Драйвер сценария сплошь и рядом вычисляемый («reatr. Рекламные расходы без НДС» = (ads_cost_reattributed + comission_cost) − nds_cost_reattributed), своей колонки у него нет, и /api/query по имени отдаёт пусто. Разворачиваем формулу рекурсивно: компонент сам может быть вычисляемым.""" import re forms = _derived_formulas(session_id) expr = forms.get(metric) if not expr: return f'[[{metric}]]' if depth > 5: return expr def sub(m): return '(' + _expand_formula(session_id, m.group(1), depth + 1) + ')' return re.sub(r'\[\[(.+?)\]\]', sub, expr) def _metric_sum(session_id, metric, filters): """Текущее значение метрики в срезе — для сухого прогона и коэффициентов. Компоненты суммируются по отдельности, и только потом считается выражение: для метрики-отношения это даёт Σn/Σd, а не сумму построчных отношений.""" import re expr = _expand_formula(session_id, metric) names = sorted(set(re.findall(r'\[\[(.+?)\]\]', expr))) if not names: return None js = query(session_id, names, [], filters) rows = js.get('data') if isinstance(js, dict) else None if not rows: return None sums, got = {}, False for r in rows: for k, v in r.items(): if k in names and isinstance(v, (int, float)): sums[k] = sums.get(k, 0.0) + float(v) got = True if not got: return None filled = re.sub(r'\[\[(.+?)\]\]', lambda m: repr(sums.get(m.group(1), 0.0)), expr) if not re.fullmatch(r'[0-9eE_.+\-*/() ]+', filled): return None # в формуле что-то кроме арифметики — не считаем try: return float(eval(filled, {'__builtins__': {}}, {})) except Exception: return None # ─────────── массовое применение: правки страницы, одним запросом ───────────── # Модельная математика живёт в MARFOR, не здесь. Коннектор берёт значения по # срезам и разворачивает их в payload'ы РОВНО того формата, который строит # страница в режиме «Массовые изменения», — включая mmm_slice_key и # pivot_dimensions. Ручка /api/apply_modeling_bulk исполняет каждый payload той # же задачей, что и одиночную правку (мутация на срез, не одна на всех), а # батчит только цепочку метрик и пересчёт бэндов — по разу на весь прогон. # # Так было не всегда: сначала коннектор считал множители сам, потом ручка # считала отклик своей копией формулы. Оба раза числа расходились с ручным # вводом — во второй раз на адстоке (325 370 против 319 505). Отсюда правило: # один и тот же код МАЛО, нужен ещё и тот же запрос. DEFAULT_PIVOT_DIMS = ['region_to', 'channel_new', 'segment_new', 'category'] TIME_KEYS = ('year', 'month', 'quarter', 'halfyear') def build_change(metric, slice_dict, value, pivot_dimensions): """Payload ровно в том формате, который страница строит в режиме «Массовые изменения» (_queueOnly → {action:'apply', payload}). Ключевое — mmm_slice_key и pivot_dimensions: адсток по ним решает, по каким срезам копится запас бюджета. Подменять их нельзя, иначе тот же код даст другие числа (вердикт шлюза 05.08: 325 370 против 319 505). Страница строит ключ как значения фильтров строки через «|». """ filters, dims = {}, list(pivot_dimensions or DEFAULT_PIVOT_DIMS) for k, v in (slice_dict or {}).items(): filters[k] = [str(v)] if k in TIME_KEYS else v key_parts = [str(slice_dict[d]) for d in dims if d in (slice_dict or {})] return { 'metric': metric, 'mode': 'slices', 'filters': filters, 'filter_fields': list(filters.keys()), 'filter_values': [], 'time_period': [], 'is_total': False, 'base_value': None, # базу считает сервер — клиент её знать не обязан 'new_value': float(value), 'recursive': True, 'apply_ui_filters': True, 'is_chain_constant': False, 'mmm_slice_key': '|'.join(key_parts) if key_parts else None, 'pivot_dimensions': dims, } def apply_values(session_id, changes, scope, dry_run=True): payload = {'changes': changes, 'scope': scope or {}, 'dry_run': bool(dry_run)} return MF.call('POST', f'/api/apply_modeling_bulk/{session_id}', data=payload) # ────────────────────────────── фоновые батчи ───────────────────────────────── def _job_path(job_id): os.makedirs(JOB_DIR, exist_ok=True) return os.path.join(JOB_DIR, f'{job_id}.jsonl') def _job_write(job_id, rec): with open(_job_path(job_id), 'a', encoding='utf-8') as f: f.write(json.dumps(rec, ensure_ascii=False) + '\n') def batch_worker(job_id, session_id, metric, edits, stop_on_error): job = _JOBS[job_id] t0 = time.time() for i, e in enumerate(edits): with _JOBS_LOCK: if job['stop']: job['status'] = 'stopped' break job['current'] = i ts = time.time() fatal = None try: res = apply_edit(session_id, e.get('metric') or metric, e['filters'], e['new_value']) except TokenRefused as ex: # Токен отозван или истёк: остальные правки получили бы тот же отказ. Это конец # задания при ЛЮБОМ stop_on_error — иначе отозванный токен уходил бы на стенд с # каждой оставшейся правкой (60 правок → 61 отказ за 0,1 с) и выбирал бы лимит # неудач стенда на IP. Отзыв токена — ещё и способ пользователя остановить запись. fatal = str(ex) res = {'ok': False, 'message': 'Токен недействителен: отозван или истёк. Задание ' 'остановлено, оставшиеся правки не отправлены.'} except Exception as ex: # Сбой сети посреди батча: без перехвата поток умер бы молча, а задача навсегда # осталась бы в статусе running. res = {'ok': False, 'message': str(ex)} rec = {'i': i, 'label': e.get('label', ''), 'new_value': e['new_value'], 'seconds': round(time.time() - ts, 1), 'ok': res.get('ok'), 'message': res.get('message', '')} _job_write(job_id, rec) with _JOBS_LOCK: job['done'] = i + 1 job['ok'] += 1 if res.get('ok') else 0 job['fail'] += 0 if res.get('ok') else 1 job['last'] = rec if fatal: with _JOBS_LOCK: job['status'] = 'error' job['error'] = fatal break if not res.get('ok') and stop_on_error: with _JOBS_LOCK: job['status'] = 'failed' break with _JOBS_LOCK: if job['status'] == 'running': job['status'] = 'finished' job['elapsed_min'] = round((time.time() - t0) / 60, 1) # ──────────────────────────── BI: дашборды и AI-страницы ───────────────────── # Кастомные HTML-дашборды (План/Факт и другие) руками агента: посмотреть # датасеты → сгенерировать или положить свой HTML → опубликовать по адресу # /r/. Слаги и PUT-ручка появляются на стенде патчем # deploy_gate/patches/260809_bi_slug_public_links.py; без него инструменты # печатают отказ сервера как есть. def _bi_dataset_key(d): """Ключ датасета в формате dataset_keys у /api/bi/ai/generate.""" return '%s||%s' % (d.get('session_id'), d.get('data_source')) def _bi_html_title(html): import re as _re m = _re.search(r']*>(.*?)', html, _re.S | _re.I) return (m.group(1).strip()[:120] if m else '(без )') def bi_publish(dashboard_id, slug=None, mode=None, unpublish=False, chrome=None, indexable=None): if unpublish: code, js = MF.call('DELETE', '/api/bi/dashboards/%s/publish' % dashboard_id) if isinstance(js, dict) and js.get('success'): return 'Публикация отозвана: публичная ссылка дашборда %s больше не работает.\nСтенд: %s' % (dashboard_id, BASE_URL) return _refusal(code, js) if mode is None or indexable is None: # Стенд берёт режим и индексацию только из запроса: перепубликация без них превращала # снимок в «живые данные» и выключала индексацию. Неуказанное — как в текущей публикации. code, js = MF.call('GET', '/api/bi/dashboards/%s' % _q(dashboard_id)) if not isinstance(js, dict) or not js.get('success'): return ('%s\nНичего не опубликовано: не удалось прочитать текущие настройки публикации ' 'дашборда %s.' % (_refusal(code, js), dashboard_id)) prev = (js.get('dashboard') or {}).get('public_link') or {} if mode is None: mode = prev.get('mode') or 'live' if indexable is None: indexable = bool(prev.get('is_indexable')) body = {'mode': 'snapshot' if mode == 'snapshot' else 'live', 'is_indexable': bool(indexable)} if chrome is not None: # Вид публичной обёртки AI-страницы: 'none' — без шапки MARFOR, # 'default' — вернуть шапку. Валидирует сервер (патч 260810). body['chrome'] = str(chrome) if slug: # Слаг = осознанный отказ от секретности адреса; подтверждение # public_by_name сервер требует явно — передаём его вместе со слагом. body['slug'] = str(slug) body['public_by_name'] = True code, js = MF.call('POST', '/api/bi/dashboards/%s/publish' % dashboard_id, data=body) if not isinstance(js, dict) or not js.get('success'): return _refusal(code, js) link = js.get('public_link') or {} url = link.get('url') or '' indexed = bool(link.get('is_indexable', body['is_indexable'])) out = ['ОПУБЛИКОВАНО. %s' % url, 'Стенд: %s режим: %s индексация поисковиками: %s дашборд: %s' % (BASE_URL, link.get('mode'), 'включена' if indexed else 'выключена', dashboard_id)] if indexed: out.append('⚠️ Индексация включена: страницу могут найти через поисковики.') if chrome == 'none': out.append('Обёртка отключена (chrome=none): /r/<адрес> отдаёт страницу ' 'без шапки MARFOR, как /raw. Требует патча 260810 на стенде: ' 'БЕЗ патча сервер молча игнорирует ключ и шапка остаётся — ' 'это видно на странице по ссылке выше.') if slug and str(link.get('token')) != str(slug).strip().lower(): out.append('🔴 ВНИМАНИЕ: запрошен slug «%s», но сервер вернул токен «%s» — ' 'стенд БЕЗ патча слагов молча публикует со случайным токеном. ' 'Ссылка выше рабочая, но не именная.' % (slug, link.get('token'))) elif slug: out.append('⚠️ Адрес именной, то есть УГАДЫВАЕМЫЙ: страница доступна всем, ' 'кто знает или подберёт имя.') return '\n'.join(out) # ─────────────────────────────── описание тулов ─────────────────────────────── # С 0.7.0 описания, title и подсказки параметров — по-английски и ОПИСАТЕЛЬНО: что делает # инструмент, что принимает и возвращает, какие побочные эффекты и пределы. Указаний модели, как # себя вести («always/never/must/ask the user/answer in…»), в них нет: ревьюеры каталога # коннекторов Claude отклоняют такое как prompt injection. Правила записи живут в annotations # (ниже) и в фактах описаний: «Applies the change immediately; there is no dry run.». # # annotations — ToolAnnotations спецификации MCP 2025-03-26+: title, readOnlyHint, destructiveHint, # idempotentHint, openWorldHint. Отдаются в tools/list при любой версии протокола: старые клиенты # лишнее поле пропускают. Разметка с обоснованием — DOCS.md, «Разметка инструментов»; тест # test_directory_readiness.py сверяет её с этой таблицей. По annotations же MARFOR_READONLY узнаёт # пишущие инструменты (_is_write_tool). def _ann_read(title): """Только чтение: ни данные MARFOR, ни файлы этого компьютера инструмент не меняет. openWorldHint=false — мир инструмента замкнут: аккаунт пользователя в MARFOR.""" return {'title': title, 'readOnlyHint': True, 'openWorldHint': False} def _ann_write(title, destructive, idempotent, open_world=False): """Меняет окружение. destructive=False — изменения только добавляют (новое обращение, новый прогон в песочнице) или останавливают начатое, ничего существующего не переписывая.""" return {'title': title, 'readOnlyHint': False, 'destructiveHint': destructive, 'idempotentHint': idempotent, 'openWorldHint': open_world} _SAVE_TO = {'type': 'string', 'description': 'Optional local file path; the full JSON response is written there ' '(an existing file at that path is replaced) and the result names it'} _MAX_CHARS = {'type': 'integer', 'description': 'Length limit of the JSON in the result, default 8000 characters'} TOOLS = [ { 'name': 'marfor_guide', 'description': 'marfor_guide returns the reference for the MARFOR tools of this server, ' 'as text in Russian: what MARFOR is, the call order (marfor_whoami -> ' 'marfor_projects -> marfor_datasets -> marfor_describe -> marfor_query), ' 'how the write tools work (which ones have a dry run and which apply ' 'changes immediately, the period every edit needs), the two filter ' 'formats, BI publishing, what an empty account means, refusal codes ' '(402 plan_limit, 401, 403 token_scope), local exports and setup help. ' 'Static text: no network request, available before sign-in.', 'inputSchema': {'type': 'object', 'properties': {}}, 'annotations': _ann_read('MARFOR guide'), }, { 'name': 'marfor_whoami', 'description': 'Checks the sign-in to MARFOR and reports the account behind it: sign-in ' 'method, stand address, email, user_id, plan, server version, enabled ' 'toolsets and the number of projects with their names. For an account ' 'with exactly one project it adds the project stage and the next setup ' 'step. On marfor.pro it also reports a newer published server version. ' 'Read-only.', 'inputSchema': {'type': 'object', 'properties': {}}, 'annotations': _ann_read('Check sign-in'), }, { 'name': 'marfor_projects', 'description': 'Lists the projects of the signed-in user, own and shared: id, name and ' 'access level. project_id from this list is the input of marfor_datasets ' 'and marfor_next_step. An empty list means a new account: projects are ' 'created and data are uploaded on the website, MCP has no tools for that. ' 'Read-only.', 'inputSchema': {'type': 'object', 'properties': {}}, 'annotations': _ann_read('List projects'), }, # ── помощник по настройке (0.6.0): справка, «что дальше», обращения ── # Все три — набор core и видны при MARFOR_READONLY: данных проектов они не меняют. Вопрос к # справке и обращение уходят на стенд POST-ом — в журнал справки и в поддержку, — но # пишущими в смысле READONLY не считаются: marfor_help размечен как чтение, marfor_feedback — # destructiveHint=false (новое обращение ничего существующего не переписывает). { 'name': 'marfor_help', 'description': 'Searches the MARFOR help: the same articles as on the website, covering ' 'setup steps, terms and troubleshooting. Without arguments: the table of ' 'contents. topic: one whole article by id (from the table of contents or ' 'from article links). question: a free-text question; MARFOR returns the ' 'best matching article and similar ones, or says that no article covers ' 'it (such a question can go to support through ' 'marfor_feedback(kind="question")). Every question is stored in the MARFOR ' 'help log, which is used to ' 'improve the help; a question containing an access token is refused before ' 'sending. question and topic together: the question is logged and the ' 'topic article is returned. Limits: a question is up to 2000 characters, ' '120 questions per hour. Project data are neither read nor changed.', 'inputSchema': { 'type': 'object', 'properties': { 'question': {'type': 'string', 'description': 'Question in plain words, up to 2000 characters; ' 'stored in the MARFOR help log'}, 'topic': {'type': 'string', 'description': 'Article id such as mapping.roles, from the table of ' 'contents or from article links'}, }, }, 'annotations': _ann_read('Search MARFOR help'), }, { 'name': 'marfor_next_step', 'description': 'Reports where a project stands and what comes next: stage (data, mapping, ' 'forecast, model, scenarios), what is already done, the next steps with ' 'links to website pages and help articles, warnings, and steps blocked by ' 'the plan. Without project_id: an account with one project gets that ' 'project; with several projects the result lists them and no status is ' 'fetched. The steps themselves are done on the website; MCP has no tools ' 'for them. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'project_id': {'type': 'string', 'description': 'Project id from marfor_projects; optional when the ' 'account has one project'}, }, }, 'annotations': _ann_read('Project next step'), }, { 'name': 'marfor_feedback', 'description': 'Sends a ticket to MARFOR support on behalf of the user. kind: bug, ' 'complaint, question (no help article covers it) or idea. text: the ticket ' 'text, sent as given, up to 4000 characters; people at MARFOR support read ' 'it and it is kept in the support log. context (optional) carries only ' 'tool, error (cut to 500 characters), page and project_id; other fields ' 'are dropped. A ticket containing an access token is refused before ' 'sending. The result is the ticket number and whether the email to support ' 'went out. The same ticket is sent once per hour per server process; a ' 'repeat returns the first number. Limit: 10 tickets per hour. Project data ' 'are not changed.', 'inputSchema': { 'type': 'object', 'properties': { 'kind': {'type': 'string', 'enum': ['bug', 'complaint', 'question', 'idea'], 'description': 'bug: an error; complaint; question: no help article ' 'covers it; idea'}, 'text': {'type': 'string', 'description': 'Ticket text as approved by the user, up to 4000 ' 'characters; sent unchanged'}, 'context': {'type': 'object', 'description': 'Optional: {"tool": "tool name", "error": "error ' 'text", "page": "website page", "project_id": "..."}; ' 'other fields are not sent', 'properties': {'tool': {'type': 'string'}, 'error': {'type': 'string'}, 'page': {'type': 'string'}, 'project_id': {'type': 'string'}}}, }, 'required': ['kind', 'text'], }, 'annotations': _ann_write('Send ticket to support', destructive=False, idempotent=False, open_world=True), }, { 'name': 'marfor_datasets', 'description': 'Lists the datasets of a project, grouped by kind: sources (uploaded ' 'data), forecasts and scenarios. For each: session_id and data_source ' '(inputs of marfor_describe and marfor_query), the key ' 'session_id||data_source (the dataset_keys format of the BI page ' 'generator), snapshot and "table not built" marks and the data update ' 'time. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'project_id': {'type': 'string', 'description': 'Project id from marfor_projects'}, 'max_chars': {'type': 'integer', 'description': 'Optional: also append the full JSON response, cut ' 'to this length'}, }, 'required': ['project_id'], }, 'annotations': _ann_read('List project datasets'), }, { 'name': 'marfor_describe', 'description': 'Describes one dataset: exact metric column names with their labels, ' 'dimensions with sample values, row count, date range, available years, ' 'quarters and months, and calculated metrics with their formulas. Column ' 'names differ between projects; marfor_query and the write tools take the ' 'names printed here. In a scenario each metric also has a <name>_modeling ' 'column holding the value after edits. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'session_id': {'type': 'string'}, 'data_source': {'type': 'string', 'description': 'From marfor_datasets: processed (source), ' 'forecast or scenario; default scenario'}, 'save_to': {'type': 'string', 'description': 'Optional local file path; the full JSON (without the ' 'internal mapping_config) is written there, replacing ' 'an existing file'}, 'max_values': {'type': 'integer', 'description': 'How many values of each dimension to print, ' 'default 12'}, }, 'required': ['session_id'], }, 'annotations': _ann_read('Describe dataset'), }, { 'name': 'marfor_api_get', 'description': 'Sends a GET request to the JSON API of the MARFOR web application (paths ' 'under /api/) and returns the HTTP status and the response as is: the same ' 'JSON the MARFOR web interface receives, within the access of the ' 'signed-in user. Read-only: GET is the only method, and paths outside ' '/api/ are refused. There is no separate public reference of this API; ' 'the paths are the ones the web interface uses, and the MCP connector is ' 'documented at https://marfor.pro/mcp. A long response is cut to max_chars ' '(default 8000). Belongs to the raw toolset, which is off by default on ' 'marfor.pro.', 'inputSchema': { 'type': 'object', 'properties': { 'path': {'type': 'string', 'description': 'API path starting with /api/'}, 'params': {'type': 'object', 'description': 'Query-string parameters'}, 'save_to': _SAVE_TO, 'max_chars': _MAX_CHARS, }, 'required': ['path'], }, 'annotations': _ann_read('Raw API GET'), }, { 'name': 'marfor_query', 'description': 'Returns aggregated data of a dataset: metrics summed by the chosen ' 'dimensions over a period, as flat rows of dimensions, year, quarter, ' 'month and metrics (the route behind the MARFOR pivot table). metrics and ' 'dimensions take column names from marfor_describe. A calculated metric ' '(a ratio such as CAC or CRR) has no column: its formula components are ' 'queried and the ratio is computed from their sums. Limits: a scenario ' 'refuses more than about 25,000 combinations; a source is cut at 200,000 ' 'rows with meta.truncated=true. The filter format here differs from the ' 'one of the write tools. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'session_id': {'type': 'string'}, 'metrics': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Metric column names from marfor_describe (names, not ' 'labels)'}, 'dimensions': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Dimension names from marfor_describe; time (year, ' 'month, quarter) is added automatically'}, 'filters': {'type': 'object', 'description': 'A value, a list or {values, mode}: {"region_to": ' '["russia"], "year": [2026]} or {"region_to": ' '{"values": ["russia"], "mode": "exclude"}}'}, 'date_from': {'type': 'string', 'description': 'Start date, YYYY-MM-DD'}, 'date_to': {'type': 'string', 'description': 'End date, YYYY-MM-DD'}, 'data_source': {'type': 'string', 'description': 'processed, forecast or scenario; default ' 'scenario'}, 'save_to': _SAVE_TO, 'max_chars': _MAX_CHARS, }, 'required': ['session_id', 'metrics'], }, 'annotations': _ann_read('Query aggregated data'), }, { 'name': 'marfor_apply_edit', 'description': 'Sets a new absolute value of a metric in one slice of a scenario, the ' 'same as editing a cell in the MARFOR interface, and recalculates the ' 'dependent metrics of the chain. Applies the change immediately; there is ' 'no dry run, and a call with dry_run=true is refused without sending ' 'anything. A numeric preview of the same edit: marfor_apply_batch with one ' 'edit and dry_run=true. filters take flat values and lists, time as lists ' 'of strings: {"region_to": "russia", "year": ["2026"], "month": ["7"]}; a ' 'month without a year matches that month in every year. new_value=0 is ' 'rejected; 0.01 stands for zero. Edits are not cumulative: each one is ' 'computed from the base forecast, so repeating the same value changes ' 'nothing, and "Reset chain" on the scenario page restores the base. The ' 'stand computes the base value itself. Takes about a minute; returns the ' 'task result, or a task_id with wait=false.', 'inputSchema': { 'type': 'object', 'properties': { 'session_id': {'type': 'string'}, 'metric': {'type': 'string', 'description': 'Metric name as shown in the scenario'}, 'filters': {'type': 'object', 'description': 'Slice and period, flat values and lists: ' '{"region_to": "russia", "channel_new": "vk_ads", ' '"year": ["2026"], "month": ["7"]}'}, 'new_value': {'type': 'number', 'description': 'Absolute target value of the slice; 0 is rejected'}, 'wait': {'type': 'boolean', 'description': 'Wait for the task to finish (default true); false ' 'returns a task_id for marfor_job_status'}, }, 'required': ['session_id', 'metric', 'filters', 'new_value'], }, 'annotations': _ann_write('Apply one scenario edit', destructive=True, idempotent=True), }, { 'name': 'marfor_apply_batch', 'description': 'Runs a list of single scenario edits one after another, each with its own ' 'chain recalculation; edits are {label, filters, new_value} with the ' 'filter format of marfor_apply_edit. dry_run=true (the default) writes ' 'nothing and returns a reconciliation per slice: current base -> target -> ' 'coefficient. dry_run=false starts a background job in this server process ' 'and returns a job_id at once; marfor_job_status reports progress and the ' 'log, marfor_job_stop stops the job after the current edit. A batch takes ' 'minutes to hours; restarting the MCP client ends it, and a revoked token ' 'stops it. new_value=0 is rejected.', 'inputSchema': { 'type': 'object', 'properties': { 'session_id': {'type': 'string'}, 'metric': {'type': 'string', 'description': 'Metric name; an edit may carry its own metric'}, 'edits': {'type': 'array', 'description': 'List of {label, filters, new_value}', 'items': {'type': 'object'}}, 'edits_file': {'type': 'string', 'description': 'Path to a local JSON file with the edit list, ' 'instead of edits'}, 'dry_run': {'type': 'boolean', 'description': 'Default true: reconciliation only, nothing written'}, 'stop_on_error': {'type': 'boolean', 'description': 'Stop the job at the first failed edit ' '(default true)'}, }, 'required': ['session_id', 'metric'], }, 'annotations': _ann_write('Apply edits in background batch', destructive=True, idempotent=False), }, { 'name': 'marfor_apply_values', 'description': 'Bulk write: sets absolute values of one metric (calculated metrics ' 'included) in many slices of a scenario with one request. Each value ' 'becomes a change in exactly the format the MARFOR page builds in its bulk ' 'edit mode (including mmm_slice_key and pivot_dimensions), and MARFOR ' 'applies each with the same task as a manual edit, so the numbers match ' 'the interface. scope bounds the change: period and common filters as ' 'lists, such as {"year": [2026], "quarter": ["Q3"]}; the stand refuses a ' 'bulk change without a period. values: [{"slice": {flat values}, "value": ' 'target}]. dry_run=true (the default) writes nothing and returns what ' 'would change: rows, years and regions per slice, with wide edits flagged. ' 'dry_run=false queues a MARFOR task and returns a task_id; ' 'marfor_job_status(task_id) reports slices applied, unchanged and failed. ' 'Slices with a zero base are skipped. Speed: tens of seconds per slice. ' 'Needs the /api/apply_modeling_bulk route on the stand.', 'inputSchema': { 'type': 'object', 'properties': { 'session_id': {'type': 'string'}, 'metric': {'type': 'string', 'description': 'Metric name as shown in the scenario; calculated ' 'metrics allowed'}, 'scope': {'type': 'object', 'description': 'Bounds of the change: period and common filters, e.g. ' '{"year": [2026], "quarter": ["Q3"], "region_to": ' '["russia"]}'}, 'values': {'type': 'array', 'items': {'type': 'object'}, 'description': 'List of {"slice": {"channel_new": "vk_ads", "month": ' '7}, "value": 5100000}; slice uses dimension columns ' 'inside scope, value is the absolute target of the ' 'slice'}, 'values_file': {'type': 'string', 'description': 'Path to a local JSON file with the same list, ' 'instead of values'}, 'pivot_dimensions': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Pivot dimensions that form mmm_slice_key; ' 'default region_to, channel_new, segment_new, ' 'category'}, 'dry_run': {'type': 'boolean', 'description': 'Default true: preview only, nothing written'}, 'max_chars': {'type': 'integer', 'description': 'Accepted for compatibility; has no effect'}, }, 'required': ['session_id', 'metric', 'scope'], }, 'annotations': _ann_write('Apply values to many slices', destructive=True, idempotent=True), }, { 'name': 'marfor_job_status', 'description': 'Progress of a write. job_id: background batch of marfor_apply_batch - ' 'edits done, succeeded and failed, the last edit, the log tail and the ' 'reason when the job stopped. task_id: MARFOR task returned by ' 'marfor_apply_values or marfor_apply_edit(wait=false) - still running, or ' 'completed with slices applied, unchanged and failed and whether the ' 'chain was recalculated. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'job_id': {'type': 'string', 'description': 'From marfor_apply_batch'}, 'task_id': {'type': 'string', 'description': 'From marfor_apply_values or marfor_apply_edit'}, 'tail': {'type': 'integer', 'description': 'How many last log records of a batch to show, ' 'default 5'}, }, }, 'annotations': _ann_read('Write job status'), }, { 'name': 'marfor_job_stop', 'description': 'Stops a background batch of marfor_apply_batch after the edit in ' 'progress: edits already applied stay applied, the remaining ones are not ' 'sent. Changes no MARFOR data by itself. Jobs live in this server process, ' 'so only jobs started by it are known.', 'inputSchema': {'type': 'object', 'properties': {'job_id': {'type': 'string', 'description': 'From marfor_apply_batch'}}, 'required': ['job_id']}, 'annotations': _ann_write('Stop background batch', destructive=False, idempotent=True), }, { 'name': 'marfor_bi_datasets', 'description': 'Former name of marfor_datasets, kept for compatibility; returns the same ' 'text: the datasets of a project with session_id, data_source and the ' 'keys session_id||data_source in the dataset_keys format of the BI page ' 'generator. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'project_id': {'type': 'string'}, 'max_chars': {'type': 'integer', 'description': 'Optional: also append the full JSON response, cut ' 'to this length'}, }, 'required': ['project_id'], }, 'annotations': _ann_read('List datasets (legacy name)'), }, { 'name': 'marfor_bi_dashboards', 'description': 'Lists the BI dashboards available to the user, own and shared: id, title, ' 'kind (canvas: widget dashboard; ai_page: HTML page), role, project and ' 'whether it is published. project_id narrows the list to one project. ' 'Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'project_id': {'type': 'string'}, 'max_chars': {'type': 'integer', 'description': 'Accepted for compatibility; has no effect'}, }, }, 'annotations': _ann_read('List BI dashboards'), }, { 'name': 'marfor_bi_generate', 'description': 'Generates an AI page (interactive HTML dashboard, report or presentation) ' 'from project datasets with the MARFOR built-in generator. mode=create ' '(the default) creates a new dashboard and takes project_id and ' 'dataset_keys (keys session_id||data_source from marfor_datasets). ' 'mode=revise rewrites the page of an existing dashboard (dashboard_id). ' 'skill: dashboard, presentation, plan_fact, forecast_report, ' 'scenario_report or custom. Writes immediately; there is no dry run. The ' 'request is synchronous and long: minutes, up to about 20 for large ' 'pages; the tool has its own timeout (MARFOR_BI_GENERATE_TIMEOUT, default ' '1500 s). Returns the dashboard id, title and editor link.', 'inputSchema': { 'type': 'object', 'properties': { 'project_id': {'type': 'string'}, 'prompt': {'type': 'string', 'description': 'What to build, up to 4000 characters'}, 'skill': {'type': 'string', 'description': 'dashboard, presentation, plan_fact, forecast_report, ' 'scenario_report or custom'}, 'dataset_keys': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Keys session_id||data_source from ' 'marfor_datasets; required for mode=create'}, 'dashboard_id': {'type': 'string', 'description': 'For mode=revise: the dashboard whose page is ' 'rewritten (from marfor_bi_dashboards)'}, 'mode': {'type': 'string', 'description': 'create (default) or revise'}, 'timeout_s': {'type': 'integer', 'description': 'Request timeout in seconds; default ' 'MARFOR_BI_GENERATE_TIMEOUT'}, }, 'required': ['prompt'], }, 'annotations': _ann_write('Generate AI dashboard page', destructive=True, idempotent=False), }, { 'name': 'marfor_bi_get_page', 'description': 'Downloads the HTML of the AI page of a dashboard into a file on this ' 'computer: the server folder ~/.marfor_mcp/pages/<dashboard_id>.html, or ' 'the save_to path (an existing file there is replaced). The result is a ' 'summary: path, size and <title>. MARFOR data are only read.', 'inputSchema': { 'type': 'object', 'properties': { 'dashboard_id': {'type': 'string'}, 'save_to': {'type': 'string', 'description': 'Local file path for the HTML; default ' '~/.marfor_mcp/pages/<dashboard_id>.html'}, }, 'required': ['dashboard_id'], }, 'annotations': _ann_read('Download AI page HTML'), }, { 'name': 'marfor_bi_put_page', 'description': 'Replaces the HTML of the AI page of a dashboard with given HTML, for ' 'dashboards built outside the MARFOR generator. Input: a whole document ' '<!DOCTYPE html>...</html>, up to 5 MB. Works on an AI page or an empty ' 'dashboard (which is converted); a widget dashboard is refused. Writes ' 'immediately; there is no dry run. A public link in live mode shows the ' 'new page at once, a snapshot link after the next publish. Needs the PUT ' '/api/bi/ai_pages/<id> route on the stand; without it the stand refuses. ' 'Returns the size written and the editor link.', 'inputSchema': { 'type': 'object', 'properties': { 'dashboard_id': {'type': 'string'}, 'html_file': {'type': 'string', 'description': 'Path to a local HTML file (alternative to html)'}, 'html': {'type': 'string', 'description': 'Page HTML as a string'}, }, 'required': ['dashboard_id'], }, 'annotations': _ann_write('Upload AI page HTML', destructive=True, idempotent=True), }, { 'name': 'marfor_bi_publish', 'description': 'Publishes a dashboard at a public link, or revokes the link with ' 'unpublish=true. Without slug the address is a secret random token ' '/r/<43 characters>. A slug gives a readable address such as /r/plan_fact ' 'that anyone who knows or guesses the name can open; the tool sends the ' 'public_by_name confirmation the stand requires together with the slug. ' 'Changing the slug frees the old address; republishing without slug keeps ' 'the current address. mode: live (the page reads current data) or ' 'snapshot (frozen at publish time). indexable: search engines may index ' 'the page. mode, indexable and chrome that are not given are kept as in ' 'the current publication; a new publication is live, not indexable and ' 'has the MARFOR header. chrome="none" serves an AI page without the ' 'MARFOR header. Applies immediately; there is no dry run. Returns the ' 'public URL; a stand without the slug patch publishes with a random token, ' 'and the result says so.', 'inputSchema': { 'type': 'object', 'properties': { 'dashboard_id': {'type': 'string'}, 'slug': {'type': 'string', 'description': '3-64 characters: a-z, 0-9, "-", "_"; makes the address ' 'guessable'}, 'mode': {'type': 'string', 'description': 'live or snapshot; not given: as in the current ' 'publication'}, 'indexable': {'type': 'boolean', 'description': 'Search engine indexing; not given: as in the ' 'current publication (new: off)'}, 'chrome': {'type': 'string', 'enum': ['none', 'default'], 'description': 'Public AI page wrapper: none - without the MARFOR ' 'header, default - with it; not given: kept as before'}, 'unpublish': {'type': 'boolean', 'description': 'true revokes the public link'}, }, 'required': ['dashboard_id'], }, 'annotations': _ann_write('Publish dashboard link', destructive=True, idempotent=True, open_world=True), }, { 'name': 'marfor_mmm_params', 'description': 'MMM calibration: reference of the model knobs - what can be tuned, the ' 'allowed ranges and what each default stands for. Calibration runs ' '(marfor_mmm_calibrate) take these names; a value outside its range is ' 'clamped by the stand. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'save_to': _SAVE_TO, 'max_chars': _MAX_CHARS, }, }, 'annotations': _ann_read('MMM calibration knobs'), }, { 'name': 'marfor_mmm_calibrate', 'description': 'MMM calibration: starts a sandbox run of the model with the given knobs. ' 'The model, the project and the data stay unchanged; the result is stored ' 'as a separate run under its own run_id. Returns the run_id at once; the ' 'run takes minutes to hours in the background, one running run per ' 'session. Progress: marfor_mmm_runs; result: marfor_mmm_report; knob names ' 'and ranges: marfor_mmm_params. draws=150 and tune=100 give a quick rough ' 'run. holdout_periods>0 gives a validation run that measures accuracy: its ' 'channel shares are not an attribution, and marfor_mmm_apply refuses it.', 'inputSchema': { 'type': 'object', 'properties': { 'session_id': {'type': 'string'}, 'params': {'type': 'object', 'description': 'Model knobs: prior_strength, baseline_prior, ' 'season_per_market, n_harmonics, season_sigma, ' 'level_mode, level_rw_sigma, trend_sigma, ' 'min_spend_share, holdout_periods, draws, tune, ' 'target_accept, market_dims, channel_dims, drivers, ' 'dependent; the rest keep their defaults'}, 'label': {'type': 'string', 'description': 'Note on the purpose of the run, shown in the run list ' 'and in comparisons'}, }, 'required': ['session_id'], }, 'annotations': _ann_write('Start MMM calibration run', destructive=False, idempotent=False), }, { 'name': 'marfor_mmm_runs', 'description': 'MMM calibration: the runs of a session - what is running and what has ' 'finished, knobs, progress, headline result, errors, stalled runs and ' 'whether a run was applied to the model. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'session_id': {'type': 'string'}, 'limit': {'type': 'integer', 'description': 'How many runs, default 30'}, 'save_to': _SAVE_TO, 'max_chars': _MAX_CHARS, }, 'required': ['session_id'], }, 'annotations': _ann_read('List MMM calibration runs'), }, { 'name': 'marfor_mmm_report', 'description': 'MMM calibration: full report of a run - channel shares with intervals, ' 'the baseline of each market, R2 and MAPE, convergence (rhat, ESS, ' 'divergences), formal acceptance and validation metrics. Monthly series ' 'are in marfor_mmm_diagnose. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'run_id': {'type': 'string', 'description': 'From marfor_mmm_runs or ' 'marfor_mmm_calibrate'}, 'market': {'type': 'string', 'description': 'Only this market; default all markets'}, 'save_to': _SAVE_TO, 'max_chars': _MAX_CHARS, }, 'required': ['run_id'], }, 'annotations': _ann_read('MMM run report'), }, { 'name': 'marfor_mmm_compare', 'description': 'MMM calibration: compares two or more runs side by side - shares, ' 'quality, convergence and acceptance - and reports the spread of the paid ' 'share between runs, a measure of how much the conclusion depends on ' 'assumptions rather than on data. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'run_ids': {'type': 'array', 'items': {'type': 'string'}, 'description': 'At least two run_id values'}, 'save_to': _SAVE_TO, 'max_chars': _MAX_CHARS, }, 'required': ['run_ids'], }, 'annotations': _ann_read('Compare MMM runs'), }, { 'name': 'marfor_mmm_diagnose', 'description': 'MMM calibration: explains a run. Splits the monthly KPI dynamics into ' 'season, level and media; lists channels whose elasticity interval covers ' 'zero or whose contribution barely varies (the share rests on the prior ' 'rather than on data) and channels confounded with seasonality. Read-only.', 'inputSchema': { 'type': 'object', 'properties': { 'run_id': {'type': 'string', 'description': 'From marfor_mmm_runs or ' 'marfor_mmm_calibrate'}, 'market': {'type': 'string', 'description': 'Market to diagnose; default the largest by KPI'}, 'save_to': _SAVE_TO, 'max_chars': _MAX_CHARS, }, 'required': ['run_id'], }, 'annotations': _ann_read('Diagnose MMM run'), }, { 'name': 'marfor_mmm_apply', 'description': 'MMM calibration: applies a finished calibration run to an MMM model - ' 'the channel elasticities of the run replace those of the model. The only ' 'calibration tool that changes data. Without confirm=true it is a dry ' 'run: returns the report and acceptance and writes nothing. With ' 'confirm=true it writes. model_id defaults to the latest finished MMM ' 'model of the session. Applies only on development stands (dev.* hosts ' 'and localhost): elsewhere a call with confirm=true is refused before ' 'sending. Validation runs (holdout_periods>0) are refused by the stand.', 'inputSchema': { 'type': 'object', 'properties': { 'run_id': {'type': 'string', 'description': 'From marfor_mmm_runs or ' 'marfor_mmm_calibrate'}, 'confirm': {'type': 'boolean', 'description': 'true writes to the model; without it, a preview only'}, 'model_id': {'type': 'string', 'description': 'Target model; default the latest finished MMM model ' 'of the session'}, }, 'required': ['run_id'], }, 'annotations': _ann_write('Apply MMM run to model', destructive=True, idempotent=True), }, ] # ───────────────── путеводитель: instructions и marfor_guide ────────────────── # Поле instructions из ответа initialize читает Claude Code и режет его на 2 КБ — лимит # считается в БАЙТАХ, поэтому текст английский и короткий. Claude Desktop и claude.ai это поле до # модели не доносят вовсе, поэтому всё существенное есть и в marfor_guide, а первая фраза описания # каждого инструмента на него ссылается (см. _presented). С 0.7.0 оба текста описательные: что # делают инструменты и как устроена запись, без указаний модели («Answer in the user's language», # «never», «ask the user» — каталог коннекторов Claude считает такое prompt injection). INSTRUCTIONS = ( 'MARFOR (https://marfor.pro) is a forecasting and scenario-planning service for marketers: ' 'forecasts, budget scenarios, channel elasticities, BI dashboards. This server acts as the ' 'signed-in user and sees only that user\'s projects. marfor_guide returns the full reference ' 'in Russian: call order, filter formats, write rules, refusal codes.\n\n' 'Discovery: marfor_whoami -> marfor_projects -> marfor_datasets(project_id) -> ' 'marfor_describe(session_id, data_source) -> marfor_query. Ids and metric and dimension names ' 'come from these tools and differ between projects. Projects and data are created on the ' 'website; MCP has no upload. Setup help: marfor_next_step (project stage, next step) and ' 'marfor_help (help articles); marfor_feedback sends a ticket to support. On this computer, ' 'marfor_export_* keep a data file for the user\'s own dashboard, refreshed on a schedule.\n\n' # Сухой прогон назван ПОИМЁННО: у apply_edit и BI-инструментов параметра dry_run нет, и общее # «always run with dry_run=true first» когда-то приводило к записи без «да» пользователя. 'Writes: tools that change MARFOR data are annotated readOnlyHint=false, destructiveHint=true. ' 'marfor_apply_values and marfor_apply_batch default to dry_run=true: they return a ' 'reconciliation and write nothing. marfor_apply_edit, marfor_bi_generate, marfor_bi_put_page ' 'and marfor_bi_publish have no dry run: a call applies the change immediately. A month ' 'without a year matches that month in every year; a bulk edit without a period is rejected. ' 'new_value=0 is rejected; 0.01 stands for zero. marfor_query and the apply tools use ' 'different filter formats. marfor_apply_batch runs in the background; marfor_job_status ' 'reports progress. A slug in marfor_bi_publish makes the page address guessable.\n\n' 'Refusals: HTTP 402 plan_limit means the action is outside the user\'s plan, and a repeated ' 'call gets the same answer. A rejected token is replaced by a new sign-in in the browser; the ' 'error text contains the command, and tokens and passwords are not exchanged through the ' 'chat. Help: https://marfor.pro/mcp, support@marfor.pro' ) # Путеводитель собран из разделов: удалённый коннектор берёт общие разделы и свои варианты # остальных (без служебных команд и выгрузок). Номер раздела про настройку проекта у редакций # разный, поэтому раздел 2 ссылается на него меткой §HELP§. _GUIDE_HEAD = """\ MARFOR — ПУТЕВОДИТЕЛЬ ПО ИНСТРУМЕНТАМ MCP Справочник: как устроены инструменты, в каком порядке их вызывать и как работает запись. Помощь: https://marfor.pro/mcp · support@marfor.pro """ _GUIDE_ABOUT = """\ 1. ЧТО ТАКОЕ MARFOR MARFOR (https://marfor.pro) — сервис прогнозирования и моделирования сценариев для маркетологов: прогнозы метрик, сценарии бюджетов («что будет с заказами и выручкой, если перераспределить расходы по каналам»), эластичности каналов и BI-дашборды. Данные лежат в проектах; датасеты трёх видов: источник (загруженные данные), прогноз и сценарий (прогноз плюс правки моделирования). Сервер работает от имени вошедшего пользователя и видит ровно то же, что он сам в веб-интерфейсе: свои проекты и те, к которым ему дали доступ. """ _GUIDE_ORDER = """\ 2. ПОРЯДОК ВЫЗОВОВ Инструменты рассчитаны на такую последовательность: 1) marfor_whoami — проверка входа: стенд, email, тариф, число проектов. 2) marfor_projects — проекты; project_id выбранного проекта нужен следующему шагу. 3) marfor_datasets(project_id) — датасеты проекта; отсюда берутся session_id и data_source (processed — источник, forecast — прогноз, scenario — сценарий). 4) marfor_describe(session_id, data_source) — точные имена метрик и измерений, доступные годы и месяцы, вычисляемые метрики с формулами. 5) marfor_query — цифры: метрики по срезам за период. Идентификаторы и имена у каждого проекта свои: их источник — шаги 2–4, угадать их нельзя. Вопросы «как настроить…» и «что дальше?» — раздел §HELP§: marfor_next_step и marfor_help. """ _GUIDE_READ = """\ 3. ЧТЕНИЕ: marfor_query • metrics — имена КОЛОНОК (левый столбец marfor_describe), а не подписи. • dimensions — измерения разреза; время (year, month, quarter) добавляется само. • Период: date_from / date_to (ГГГГ-ММ-ДД) либо фильтры year, month, quarter. • У сценария рядом с метрикой есть колонка <имя>_modeling — значение после правок; колонка без суффикса — исходный прогноз. «Было → стало» = обе колонки в одном запросе. • Вычисляемая метрика (CRR, CAC, средний чек, конверсия) своей колонки не имеет: верный ответ — компоненты её формулы и расчёт ОТ СУММ (Σчислитель / Σзнаменатель). Сумма или среднее готовых отношений по строкам даёт неверный ответ. • Слишком подробный разрез: у сценария сервер откажет (кап ~25 000 комбинаций), у источника обрежет до 200 000 строк с meta.truncated=true; помогает убрать измерение или сузить период. """ _GUIDE_READ_FILE = """\ • Большой ответ можно сохранить в файл (save_to); в диалог тогда идёт сводка. """ _GUIDE_WRITE = """\ 4. ЗАПИСЬ: КАК УСТРОЕНЫ ПИШУЩИЕ ИНСТРУМЕНТЫ Пишущие инструменты размечены в annotations: readOnlyHint=false, destructiveHint=true. Запись меняет данные пользователя: изменение показывают ему заранее, вызов — после явного «да». 1) С сухим прогоном (параметр dry_run): marfor_apply_values, marfor_apply_batch. dry_run=true стоит по умолчанию: ответ — сверка (какие срезы, сколько строк, какие годы и регионы, «база → цель»), ничего не записывается. Запись — тот же вызов с dry_run=false. У marfor_mmm_apply (набор mmm) сухой прогон — вызов без confirm=true. 2) БЕЗ сухого прогона: marfor_apply_edit, marfor_bi_generate, marfor_bi_put_page, marfor_bi_publish. Параметра dry_run у них нет, вызов пишет СРАЗУ; вызов с dry_run=true сервер отклоняет, ничего не записав. «Было → станет» видно до вызова: текущее значение даёт marfor_query; для BI — какой дашборд создаётся, перезаписывается или публикуется. """ _GUIDE_WRITE_RULES = """\ 3) Период правки задают год (year) либо пара дат date_from + date_to. Месяц без года — тот же месяц ВО ВСЕХ годах; массовую правку без периода сервер отклоняет. • new_value=0 сервер отклоняет, ничего не отправив: ноль уводит MARFOR на медленную ветку, которая читает в память всю таблицу сценария. Срез обнуляет значение 0.01. • Значение — АБСОЛЮТНОЕ («сколько должно стать в срезе»), не прирост и не множитель. • Правки не накапливаются: каждая считается от базового прогноза, повтор с тем же значением ничего не меняет. Откат — «Сброс цепочки» на странице сценария. • Срез с нулевой базой умножением не поднять: массовая правка такие срезы пропускает. • Ответ «сервер занят» — идёт другая правка; повтор после паузы проходит. """ _GUIDE_TOOLS = """\ 5. КАКОЙ ИНСТРУМЕНТ ЗАПИСИ ДЛЯ ЧЕГО • marfor_apply_values — основной: значения по многим срезам одним запросом. scope — общие границы (период и общие фильтры), values — список {"slice": {…}, "value": число}. Запись возвращает task_id — это ещё не результат: итог (task_status=completed, сколько срезов применено) показывает marfor_job_status(task_id=…). • marfor_apply_edit — одна правка одного среза, как ячейка в интерфейсе (~минута). Сухого прогона нет (правило 2); сверку цифрами для той же правки даёт marfor_apply_batch с одной правкой и dry_run=true. • marfor_apply_batch — цепочка одиночных правок ФОНОМ: сразу возвращает job_id и идёт от минут до часов. Ход — marfor_job_status(job_id=…), остановка — marfor_job_stop. Задача живёт в процессе этого сервера: её обрывают перезапуск клиента и отозванный токен (статус error); после нового входа запускают только оставшиеся правки. """ _GUIDE_FILTERS = """\ 6. ФИЛЬТРЫ: ДВА РАЗНЫХ ФОРМАТА • marfor_query принимает любую форму и сам приводит к нужной: {"region": "msk"} {"region": ["msk", "spb"]} {"region": {"values": ["msk"], "mode": "exclude"}} • marfor_apply_edit и правки marfor_apply_batch — ТОЛЬКО плоские значения и списки, время — списками строк; словарь {"values": …} здесь не работает: {"region": "msk", "channel": "vk_ads", "year": ["2026"], "month": ["7"]} • marfor_apply_values: scope — списки ({"year": [2026], "quarter": ["Q3"]}), slice — плоские значения ({"channel": "vk_ads", "month": 7}). Имена колонок в примерах условные; настоящие — в marfor_describe, там же измерения сводной сценария для pivot_dimensions у marfor_apply_values: умолчание подходит не всякому проекту. """ _GUIDE_BI = """\ 7. BI-ДАШБОРДЫ marfor_bi_dashboards — список; marfor_bi_generate — собрать страницу встроенным генератором (вызов синхронный и ДОЛГИЙ: минуты, до ~20); marfor_bi_get_page и marfor_bi_put_page — скачать HTML или положить свой; marfor_bi_publish — публичная ссылка. Без slug адрес секретный. Со slug он УГАДЫВАЕМЫЙ — страницу увидит любой, кто знает имя, поэтому читаемый адрес ставят, только когда пользователь сам о нём просит. """ _GUIDE_EMPTY = """\ 8. ЕСЛИ АККАУНТ ПУСТОЙ (нет проектов или датасетов) Через MCP нельзя ни создать проект, ни загрузить данные: это делается в веб-интерфейсе https://marfor.pro — создать проект, загрузить данные, построить прогноз и сценарий. После этого они появятся в marfor_datasets. Демонстрационных данных у сервера нет. """ _GUIDE_ERRORS_HEAD = """\ 9. ОТКАЗЫ И ОШИБКИ: ЧТО ОНИ ЗНАЧАТ • HTTP 402, "error": "plan_limit" — тарифный предел: функция недоступна на тарифе или исчерпана квота. Ответ: «Тарифный предел: …», тариф, на котором функция доступна, цена и ссылка на оплату, если стенд их прислал, иначе страница тарифов https://marfor.pro/account и статья справки account.plans-billing. Повторный вызов получает тот же отказ. """ _GUIDE_ERRORS_TOKEN = """\ • «Токен недействителен» (HTTP 401) — токен отозван или истёк. Помогает новый вход: python3 server.py --login (точная команда — в тексте ошибки). Вход идёт через браузер: токен и пароль через чат не передаются; токен, попавший в чат, отзывается на https://marfor.pro/account, раздел «Подключение Claude (MCP)». """ _GUIDE_ERRORS_TAIL = """\ • HTTP 403 token_scope — токену это действие не разрешено (оплата, доступы и приглашения, удаление проекта, управление токенами): оно делается в веб-интерфейсе. • «Проект не найден или нет доступа» — чаще всего вход не под тем аккаунтом (его видно в marfor_whoami) или проекта нет в marfor_projects; доступ к чужому проекту выдаёт владелец. """ _GUIDE_ERRORS_LOCAL = """\ • «Инструмент выключен» — ответ сам называет переменную (MARFOR_TOOLSETS или MARFOR_READONLY). Наборы mmm (калибровка MMM) и raw на marfor.pro по умолчанию выключены. • Нет связи, таймаут, ошибка сертификата — в тексте ошибки есть подсказка, что проверить. """ _GUIDE_EXPORTS = """\ 10. КАК НАСТРОИТЬ ДЭШ ПОЛЬЗОВАТЕЛЯ (выгрузка данных в файл по расписанию) 1) marfor_describe — имена; метрики, срезы и период (относительный: ytd, last_months, years…) выбирает пользователь. 2) marfor_export_save с пробным запуском — в ответе путь и первые строки. 3) marfor_export_schedule печатает настройку планировщика ОС и сам ничего не ставит: установка — отдельный шаг, с согласия пользователя. 4) Дэш читает файл (csv, json; js — для HTML с диска) или ходит в API сам: marfor_export_recipe. • Стенд во внутренней сети (VPN): облачные сервисы дашбордов до него не достанут. • Вычисляемые — от сумм: в дэше их пересчитывают из сумм, а не складывают и не усредняют. """ _GUIDE_SETUP = """\ §N§. НАСТРОЙКА ПРОЕКТА: СПРАВКА, «ЧТО ДАЛЬШЕ», ОБРАЩЕНИЯ 1) Факты о проекте: marfor_next_step(project_id) — стадия проекта и следующий шаг со ссылкой; marfor_help(question="…") — статья справки по вопросу; marfor_help(topic="…") — статья целиком; marfor_help() — оглавление. Вопрос пишется в журнал справки MARFOR (по нему дописывают справку); вопрос с токеном доступа сервер не отправляет. 2) Статья — первоисточник: шаги по порядку, кнопки и разделы в ней — ТОЧНО как на сайте, ссылка на страницу — в ответе. Чего нет ни в статье, ни в ответах инструментов, того справка не подтверждает. 3) Статьи по вопросу нет — ответ marfor_help говорит об этом прямо; вопрос можно передать в поддержку: marfor_feedback(kind="question"); ошибка — "bug", жалоба — "complaint", идея — "idea". Обращение уходит от имени пользователя, его читают люди в поддержке: вызов — после явного «да» на показанный пользователю текст; обращение с токеном доступа сервер не отправляет. Номер обращения приходит в ответе, по нему поддержка находит обращение. 4) Через MCP нельзя загрузить данные, настроить маппинг или нажать кнопку: эти шаги делаются на сайте, после шага marfor_next_step показывает новое состояние. • «Помощник ещё не включён» — справки для Claude на стенде нет; в ответе — ссылка на сайт. """ def _guide(parts, help_no): text = '\n'.join(parts) return text.replace('§HELP§', str(help_no)).replace('§N§', str(help_no)) GUIDE_TEXT = _guide([_GUIDE_HEAD, _GUIDE_ABOUT, _GUIDE_ORDER, _GUIDE_READ + _GUIDE_READ_FILE, _GUIDE_WRITE + _GUIDE_WRITE_RULES, _GUIDE_TOOLS, _GUIDE_FILTERS, _GUIDE_BI, _GUIDE_EMPTY, _GUIDE_ERRORS_HEAD + _GUIDE_ERRORS_TOKEN + _GUIDE_ERRORS_TAIL + _GUIDE_ERRORS_LOCAL, _GUIDE_EXPORTS, _GUIDE_SETUP], 11) EMPTY_ACCOUNT_TEXT = ( 'В этом аккаунте пока нет проектов.\n' 'Через MCP нельзя ни создать проект, ни загрузить данные — это делается в веб-интерфейсе %s ' 'под этим же аккаунтом:\n' ' 1. создание проекта и загрузка в него данных;\n' ' 2. прогноз и сценарий.\n' 'После этого проект появится в marfor_projects, а его данные — в marfor_datasets.\n' 'Пошагово проведёт справка: marfor_help(question="с чего начать") или оглавление marfor_help().' % PUBLIC_BASE) # ─────────────────────────── наборы инструментов ───────────────────────────── # Токен умеет ровно то, что умеет этот сервер, поэтому на публичном стенде по умолчанию # видны только наборы, ручки которых там есть и безопасны: core, write, bi. Набор mmm # требует ручек калибровки (их на marfor.pro нет), raw — произвольный GET по /api/. # На ЛЮБОМ другом стенде умолчание — всё: прежние настройки и тесты не меняются. # Набор export (выгрузки в файл для своего дашборда) включён по умолчанию везде: MARFOR он # только читает. Стоит последним: в подсказке «как включить» перечисление идёт в этом порядке. TOOLSET_NAMES = ('core', 'write', 'bi', 'mmm', 'raw', 'export') PUBLIC_TOOLSETS = ('core', 'write', 'bi', 'export') _TOOLSET_OF = { 'marfor_api_get': 'raw', 'marfor_apply_edit': 'write', 'marfor_apply_batch': 'write', 'marfor_apply_values': 'write', 'marfor_job_status': 'write', 'marfor_job_stop': 'write', } # Без пишущих инструментов бессмысленны — прячутся при MARFOR_READONLY вместе с ними, хотя # данных MARFOR не меняют: marfor_job_status только читает, marfor_job_stop останавливает # фоновый батч этого процесса (по annotations он не «пишущий»: destructiveHint=false). _WRITE_FLOW = ('marfor_job_status', 'marfor_job_stop') # Отсылка к путеводителю в начале каждого описания (кроме самого marfor_guide) и подсказки # идентификаторам без своего описания. По-английски и описательно, как и сами описания. GUIDE_HINT = 'Call order, filter formats and write rules: marfor_guide. ' _PARAM_HINTS = { 'session_id': 'Dataset id from marfor_datasets', 'project_id': 'Project id from marfor_projects', 'dashboard_id': 'Dashboard id from marfor_bi_dashboards', } def _toolset_of(name): if name in _TOOLSET_OF: return _TOOLSET_OF[name] if name.startswith('marfor_mmm_'): return 'mmm' if name.startswith('marfor_bi_'): return 'bi' if name.startswith('marfor_export_'): return 'export' return 'core' def _is_write_tool(tool): """Меняет ли инструмент данные MARFOR — по annotations, а не по тексту описания (до 0.7.0 признаком было слово «ЗАПИСЬ»). Пишущий: readOnlyHint не true и destructiveHint не false — умолчания спецификации MCP (readOnlyHint=false, destructiveHint=true), так что инструмент без разметки считается пишущим. Не входят: аддитивные (destructiveHint=false — обращение в поддержку, прогон калибровки в песочнице, остановка фонового батча) и LOCAL_ONLY_TOOLS — выгрузки пишут только файлы этого компьютера, а MARFOR лишь читают (константа — в разделе «удалённый коннектор» ниже). По этому признаку MARFOR_READONLY прячет и отклоняет инструмент, а _no_dry_run_refusal ловит dry_run=true у пишущих без сухого прогона.""" if tool.get('name') in LOCAL_ONLY_TOOLS: return False ann = tool.get('annotations') or {} if ann.get('readOnlyHint', False) is True: return False return ann.get('destructiveHint', True) is not False def _readonly(): return (os.environ.get('MARFOR_READONLY') or '').strip().lower() in ('1', 'true', 'yes', 'on') def _active_toolsets(): raw = (os.environ.get('MARFOR_TOOLSETS') or '').strip().lower() if not raw: return set(PUBLIC_TOOLSETS) if _is_public_stand() else set(TOOLSET_NAMES) names = {x.strip() for x in raw.replace(';', ',').split(',') if x.strip()} if 'all' in names: return set(TOOLSET_NAMES) # core включён всегда: в нём marfor_guide, на который ссылается каждое описание return (names & set(TOOLSET_NAMES)) | {'core'} def _toolsets_label(): active = _active_toolsets() return ', '.join(x for x in TOOLSET_NAMES if x in active) def _hidden_reason(name): """None — инструмент доступен (или такого нет вовсе); иначе текст, как его включить.""" tool = next((t for t in TOOLS if t['name'] == name), None) if tool is None: return None ts, active = _toolset_of(name), _active_toolsets() if ts not in active: want = ','.join(x for x in TOOLSET_NAMES if x in active or x == ts) return ('Инструмент %s выключен: его набор «%s» не входит во включённые (%s). Включает ' 'его переменная окружения сервера MARFOR_TOOLSETS=%s (или all) в конфигурации ' 'MCP-клиента; действует после перезапуска клиента. Готовую команду регистрации с ' 'этой переменной печатает --print-config, запущенный с ней.' % (name, ts, _toolsets_label(), want)) if _readonly() and (_is_write_tool(tool) or name in _WRITE_FLOW): return ('Инструмент %s меняет данные, а сервер запущен только для чтения ' '(MARFOR_READONLY=1). Запись разрешена, когда этой переменной нет в конфигурации ' 'MCP-клиента; действует после перезапуска клиента.' % name) return None def _presented(tool): """Инструмент в том виде, в каком его видит клиент: копия, первая фраза описания отсылает к marfor_guide, идентификаторы без своего описания получают подсказку, откуда их брать. annotations едут как есть — при любой версии протокола. Исходный TOOLS не меняется.""" t = copy.deepcopy(tool) if t['name'] != 'marfor_guide': t['description'] = GUIDE_HINT + t['description'] props = (t.get('inputSchema') or {}).get('properties') or {} for key, hint in _PARAM_HINTS.items(): if isinstance(props.get(key), dict) and not props[key].get('description'): props[key]['description'] = hint return t def visible_tools(): """Список для tools/list: только включённые наборы, без пишущих при MARFOR_READONLY.""" return [_presented(tool) for tool in TOOLS if not _hidden_reason(tool['name'])] # Тарифный отказ стенда — HTTP 402 или error=plan_limit (тарифный гейт веб-приложения: message, # required_plan, reason, used/quota). С 0.7.1 он — понятный факт для продажи: что упёрлось в тариф, # на каком тарифе функция есть, цена и ссылка на оплату. price_rub и upgrade_url — поля на будущее # (06.10.2026 стенд их ещё не шлёт): без upgrade_url ответ ведёт на страницу тарифов и в статью # справки. Раньше здесь стояло «Объясните это пользователю и не повторяйте вызов» — указание модели. PLAN_HELP_TOPIC = 'account.plans-billing' _PLAN_NAMES = {'free': 'Free', 'pro': 'Pro', 'business': 'Business', 'enterprise': 'Enterprise'} def _is_plan_limit(code, js): return code == 402 or (isinstance(js, dict) and js.get('error') == 'plan_limit') def _sentence(text): """Фраза стенда внутри своей: одной строкой и с точкой в конце; обычное слово в начале (заглавная кириллица, за ней строчная) — с маленькой буквы, она идёт после двоеточия.""" s = ' '.join(str(text or '').split()) if not s: return '' if re.match(r'[А-ЯЁ][а-яё]', s): s = s[0].lower() + s[1:] return s if s[-1] in '.!?…' else s + '.' def _rub(v): """Цена из ответа стенда → «4 900» или «1 490,50»; не число, ноль и меньше — None.""" if isinstance(v, bool) or v is None: return None if isinstance(v, str): v = v.replace('\u00a0', '').replace(' ', '').replace(',', '.') try: x = float(v) except (TypeError, ValueError): return None if not 0 < x < 1e12: # NaN, бесконечность, ноль и отрицательное — не цена return None if x == int(x): return '{:,}'.format(int(x)).replace(',', ' ') return '{:,.2f}'.format(x).replace(',', ' ').replace('.', ',') def _plan_url(u): """Ссылка на оплату из ответа стенда: полный http(s)-адрес или путь стенда, одной строкой.""" url = _abs_url(u) if isinstance(u, str) else '' if not url or len(url) > 500 or re.search(r'[\s<>"\'`]', url): return '' return url def _plan_limit_text(code, js, note=''): """Текст тарифного отказа или None, если отказ не тарифный. note дописывается к заголовку («— ничего не применено»).""" if not _is_plan_limit(code, js): return None js = js if isinstance(js, dict) and not js.get('_not_json') else {} head = ('ОТКАЗ СЕРВЕРА (HTTP %s)%s.' % (code, note)) if code else ('ОТКАЗ СЕРВЕРА%s.' % note) line = 'Тарифный предел: %s' % (_sentence(js.get('message')) or 'действие недоступно на текущем тарифе.') plan = str(js.get('required_plan') or '').strip() if plan: line += ' Функция доступна на тарифе %s.' % _PLAN_NAMES.get(plan.lower(), plan[:40]) buy = [] price = _rub(js.get('price_rub')) if price: buy.append('Цена: %s ₽ в месяц.' % price) url = _plan_url(js.get('upgrade_url')) if url: buy.append('Оплата: %s' % url) else: buy.append('Тарифы и оплата: %s/account. Подробно — статья справки %s ' '(marfor_help topic="%s").' % (BASE_URL, PLAN_HELP_TOPIC, PLAN_HELP_TOPIC)) return '\n'.join([head, line, ' '.join(buy), 'Повторный вызов даст тот же отказ.']) def _refusal(code, js, limit=300): """Отказ сервера одной строкой; тарифный (402 или error=plan_limit) — фактом для продажи, см. _plan_limit_text.""" plan = _plan_limit_text(code, js) if plan: return plan msg = (js.get('message') or js.get('error')) if isinstance(js, dict) else None return 'ОТКАЗ СЕРВЕРА (HTTP %s): %s' % (code, msg or str(js)[:limit]) def _refusal_raw(code, raw, text, limit=300): """Отказ ручки, которая отдаёт не JSON (HTML страницы): JSON-отказ — как у остальных (тарифный тоже), иначе — начало текста ответа.""" js = _json_or_none(raw) return _refusal(code, js if isinstance(js, dict) else text[:limit], limit) def _ver_tuple(v): return tuple(int(x) for x in re.findall(r'\d+', str(v or ''))[:3]) def _published_version(): """{version, sha256, size} публичной копии сервера. Только для публичного стенда и без токена: ручка открытая, а на токен вне /api/ стенд отвечает отказом. Любая ошибка — молча: проверка версии не должна мешать работе.""" if not _is_public_stand(): return None try: code, _, raw = MF._raw('GET', '/mcp/version.json', timeout=5, auth=False) js = _json_or_none(raw) if code == 200 else None return js if isinstance(js, dict) and js.get('version') else None except Exception: return None def _newer_version_note(): pub = _published_version() if not pub or _ver_tuple(pub.get('version')) <= _ver_tuple(SERVER_VERSION): return '' return ('⚠️ Доступна новая версия сервера: %s (установлена %s). Обновление — файл ' '%s/mcp/server.py поверх %s и перезапуск клиента.' % (pub.get('version'), SERVER_VERSION, PUBLIC_BASE, os.path.abspath(__file__))) def _login_mode_label(): if MF.token: return ('токен из переменной MARFOR_TOKEN' if MF.token_source == 'env' else 'токен из файла %s' % TOKEN_FILE) return 'пароль (MARFOR_EMAIL)' def _projects_info(): """(«Проектов: N …», список проектов) для whoami. Ошибка списка — не повод ронять проверку входа: тогда строки нет и список пуст.""" try: code, js = MF.call('GET', '/api/bi/projects') except Exception: return '', [] n = _count_projects(js) if isinstance(js, dict) and js.get('success') else None if n is None: return '', [] if n == 0: return 'Проектов: 0 — аккаунт пустой. ' + EMPTY_ACCOUNT_TEXT.split('\n', 1)[1], [] rows = [p for p in (js.get('projects') or []) if isinstance(p, dict)] names = ', '.join('«%s»' % p.get('name') for p in rows[:5]) return 'Проектов: %d (%s%s)' % (n, names, ', …' if n > 5 else ''), rows def _projects_line(): return _projects_info()[0] def _datasets_text(args): code, js = MF.call('GET', '/api/bi/datasets/%s' % _q(args['project_id'])) if not isinstance(js, dict) or not js.get('success'): return _refusal(code, js) ds = [d for d in (js.get('datasets') or []) if isinstance(d, dict)] if not ds: return ('В проекте %s пока нет датасетов: данные ещё не загружены. Загрузка данных, ' 'прогноз и сценарий делаются в веб-интерфейсе %s — через MCP загрузки нет.' % (args['project_id'], PUBLIC_BASE)) kinds = (('source', 'ИСТОЧНИКИ (загруженные данные)'), ('forecast', 'ПРОГНОЗЫ'), ('scenario', 'СЦЕНАРИИ')) out = ['Датасетов: %d проект: %s стенд: %s' % (len(ds), args['project_id'], BASE_URL)] for kind, title in kinds + ((None, 'ПРОЧЕЕ'),): rows = [d for d in ds if (d.get('kind') == kind if kind else d.get('kind') not in ('source', 'forecast', 'scenario'))] if not rows: continue out += ['', title] for d in rows: marks = [] if d.get('snapshot'): marks.append('снапшот') if d.get('available') is False: marks.append('таблица НЕ построена: она строится при открытии сценария в ' 'веб-интерфейсе') upd = d.get('data_updated_at') or d.get('updated_at') if upd: marks.append('данные от %s' % str(upd)[:16]) out.append(' %s%s' % (d.get('name'), (' · ' + ' · '.join(marks)) if marks else '')) out.append(' session_id=%s data_source=%s' % (d.get('session_id'), d.get('data_source'))) out.append(' ключ для marfor_bi_generate: %s' % _bi_dataset_key(d)) first = ds[0] out += ['', 'marfor_query принимает session_id и data_source датасета — оба значения в том ' 'виде, как они напечатаны выше.', 'Точные имена метрик и измерений — marfor_describe(session_id="%s", data_source="%s").' % (first.get('session_id'), first.get('data_source'))] text = '\n'.join(out) return _emit(js, args, prefix=text + '\n\nПолный JSON:\n') if args.get('max_chars') else text def _describe_text(args): sid = args['session_id'] src = args.get('data_source') or 'scenario' code, js = MF.call('GET', '/api/metadata/%s' % _q(sid), params={'data_source': src}) if not isinstance(js, dict) or not js.get('success'): return _refusal(code, js) # Формулы — отдельной ручкой: в метаданных вычисляемые метрики есть не всегда. # Отказ здесь не мешает отдать остальное. derived = [] try: _, dj = MF.call('GET', '/api/derived_metrics/%s' % _q(sid)) if isinstance(dj, dict) and dj.get('success'): derived = [d for d in (dj.get('derived_metrics') or []) if isinstance(d, dict)] except Exception: pass if not derived: derived = [d for d in (js.get('derived_metrics') or []) if isinstance(d, dict)] metrics = [m for m in (js.get('metrics') or []) if isinstance(m, dict)] dims = [d for d in (js.get('dimensions') or []) if isinstance(d, dict)] values = js.get('dimensions_values') if isinstance(js.get('dimensions_values'), dict) else {} cap = int(args.get('max_values') or 12) rng = js.get('date_range') or {} out = ['Датасет: session_id=%s data_source=%s стенд: %s' % (sid, src, BASE_URL), 'Строк: %s даты: %s … %s' % (js.get('total_rows'), rng.get('min'), rng.get('max'))] for key, title in (('available_years', 'Годы'), ('available_quarters', 'Кварталы'), ('available_months', 'Месяцы')): if js.get(key): out.append('%s: %s' % (title, ', '.join(str(x) for x in js[key]))) def _named(row): name, disp = row.get('name'), row.get('display_name') return ' %s%s' % (name, (' — %s' % disp) if disp and disp != name else '') out += ['', 'МЕТРИКИ (%d) — в marfor_query передаётся имя слева:' % len(metrics)] out += [_named(m) for m in metrics] or [' (нет)'] out += ['', 'ИЗМЕРЕНИЯ (%d):' % len(dims)] for d in dims: line = _named(d) vals = values.get(d.get('name')) if isinstance(vals, list) and vals: line += ' [%s%s]' % (', '.join(str(v) for v in vals[:cap]), ', … всего %d' % len(vals) if len(vals) > cap else '') out.append(line) if not dims: out.append(' (нет)') if dims and not values: out.append(' Значения измерения: marfor_query с dimensions=["<имя>"] и одной метрикой.') if derived: out += ['', 'ВЫЧИСЛЯЕМЫЕ МЕТРИКИ (%d) — формулы поверх колонок. Своей колонки в ' 'marfor_query у них нет: значение считается ОТ СУММ компонентов формулы ' '(Σчислитель / Σзнаменатель), а не суммой готовых отношений.' % len(derived)] out += [' %s = %s' % (d.get('name'), d.get('formula')) for d in derived] if src == 'scenario': out += ['', 'Это сценарий: рядом с метрикой есть колонка <имя>_modeling — значение ' 'после правок; колонка без суффикса — исходный прогноз.'] if args.get('save_to'): # mapping_config в файл не кладём: это служебная разметка колонок на сотни строк slim = {k: v for k, v in js.items() if k != 'mapping_config'} slim['derived_metrics'] = derived p = os.path.expanduser(args['save_to']) os.makedirs(os.path.dirname(p) or '.', exist_ok=True) with open(p, 'w', encoding='utf-8') as f: json.dump(slim, f, ensure_ascii=False, indent=1) out += ['', 'Полный ответ сохранён: %s' % p] return '\n'.join(out) def _task_status_text(args): tid = args['task_id'] code, js = MF.call('GET', '/api/modeling_task_status/%s' % _q(tid)) plan = _plan_limit_text(code, js) if plan: return plan if not isinstance(js, dict): return 'HTTP %s: неожиданный ответ сервера' % code st = js.get('task_status') out = ['Задача %s: %s стенд: %s' % (tid, st or 'статус неизвестен', BASE_URL)] if st == 'completed' and 'applied' in js: out += [' применено : %s из %s' % (js.get('applied'), js.get('changes')), ' без эффекта : %d' % len(js.get('unchanged') or []), ' ошибок : %d' % len(js.get('failed') or []), ' цепочка : %s' % ('пересчитана' if js.get('chain_propagated') else 'НЕ пересчитана')] elif st not in ('completed', 'error', 'failed') and js.get('success') is not False: out.append(' Ещё идёт: итог появится в ответе marfor_job_status после завершения задачи.') if js.get('message') or js.get('error'): out.append(' %s' % (js.get('error') or js.get('message'))) if st in ('completed', 'error', 'failed') or js.get('success') is False: return _emit(js, args, prefix='\n'.join(out) + '\n\nПолный ответ:\n') return '\n'.join(out) def _no_dry_run_refusal(name, args): """Пишущий инструмент БЕЗ параметра dry_run вызван с dry_run=true → отказ, в сеть не идём. Лишний аргумент схема пропускает, а ветка инструмента его не читает: вызов marfor_apply_edit(dry_run=true) сразу делал POST и менял сценарий без «да» пользователя.""" if not args.get('dry_run'): return None tool = next((t for t in TOOLS if t['name'] == name), None) if tool is None or not _is_write_tool(tool): return None props = (tool.get('inputSchema') or {}).get('properties') or {} if 'dry_run' in props: return None how = ('Сухой прогон у него — вызов без confirm=true.' if 'confirm' in props else 'Сверку «база → цель» без записи дают marfor_apply_batch и marfor_apply_values с ' 'dry_run=true; у этого инструмента её нет.') return ('ОТКАЗ: вызов НЕ выполнен, ничего не записано. У инструмента %s нет сухого прогона ' '(параметра dry_run): он пишет сразу, а dry_run=true был бы молча пропущен. %s' % (name, how)) # ─────────── помощник по настройке: справка, «что дальше», обращения (0.6.0) ─────────── # Справка MARFOR — статьи на стенде; те же статьи открыты на сайте (/docs/<id>). Ручки стенда: # GET /api/assistant/help — оглавление; # GET /api/assistant/help/<id> — статья (Markdown); # POST /api/assistant/ask — вопрос → статья и похожие; вопрос — в журнал; # POST /api/assistant/feedback — обращение → номер MARF-…; # GET /api/assistant/project_status?project_id= — стадия проекта и следующие шаги. # Стенд без этих ручек отвечает 404 не от них (HTML или JSON без success) — это «помощник ещё не # включён», а не поломка. POST-вызовы записаны буквально, константами: по ним стенд сверяет права # токена (его тест разбирает этот файл и находит каждый пишущий вызов). HELP_QUESTION_MAX = 2000 # вопрос к справке HELP_TEXT_MAX = 20000 # длиннее — статья в диалоге обрезается, целиком она по ссылке FEEDBACK_TEXT_MAX = 4000 # текст обращения FEEDBACK_ERROR_MAX = 500 # context.error FEEDBACK_REPEAT_TTL = 3600 # то же обращение повторно в этом процессе не уходит FEEDBACK_KINDS = (('bug', 'ошибка'), ('complaint', 'жалоба'), ('question', 'вопрос'), ('idea', 'идея')) _FEEDBACK_CONTEXT_KEYS = ('tool', 'error', 'page', 'project_id') _FEEDBACK_SENT = {} # sha256 обращения → (номер, когда отправлено) _FEEDBACK_LOCK = threading.Lock() # id статьи: латиница, цифры, точка, дефис; на краях — буква или цифра (точка в конце фразы — # не часть id). _HELP_ID = r'[A-Za-z0-9](?:[A-Za-z0-9.-]{0,98}[A-Za-z0-9])?' _HELP_ID_RE = re.compile(_HELP_ID) _HELP_LINK_RE = re.compile(r'\[([^\]\n]*)\]\(\s*help:(%s)\s*\)' % _HELP_ID) _HELP_BARE_RE = re.compile(r'(?<![\w/.:-])help:(%s)' % _HELP_ID) _REL_LINK_RE = re.compile(r'\((/(?!/)[^\s()]*)\)') _ONLY_MARK_RE = re.compile(r'(?m)^[ \t]*<!--\s*/?only(?::[\w-]+)?\s*-->[ \t]*(?:\n|$)') # Секреты, которым не место ни в журнале справки, ни в обращении: токен MARFOR и любой Bearer. _SECRET_RE = re.compile(r'marfor_pat_[A-Za-z0-9_-]{8,}|bearer\s+[A-Za-z0-9._~+/=-]{16,}', re.I) _STAGE_WORDS = { 'empty': 'проект пустой: источников данных ещё нет', 'loading': 'данные загружаются', 'load_error': 'загрузка данных не удалась', 'needs_mapping': 'данные есть, нужен маппинг: дата, метрики, измерения', 'ready_for_forecast': 'данные готовы — можно строить прогноз', 'forecast_running': 'прогноз считается', 'ready_for_model': 'можно строить модель', 'model_running': 'модель считается', 'ready_for_scenario': 'модель готова — можно строить сценарий', 'scenarios_ready': 'сценарии готовы — можно моделировать', } _STATE_WORDS = { 'ready': 'готов', 'running': 'считается', 'loading': 'загружается', 'building': 'строится', 'error': 'ошибка', 'failed': 'не удался', 'interrupted': 'прерван', 'draft': 'заготовка', 'not_built': 'не построен', 'connected': 'подключён', 'preview': 'только превью', 'empty': 'пустой', } _ROLE_WORDS = {'owner': 'владелец', 'editor': 'редактор', 'viewer': 'только просмотр'} def _abs_url(u): """Ссылка стенда для чата: «/путь» → «<стенд>/путь»; полный http(s)-адрес — как есть.""" u = str(u or '').strip() if u.startswith('/') and not u.startswith('//'): return BASE_URL + u if u.startswith(('https://', 'http://')): return u return '' def _help_ref(aid): return 'статья %s — marfor_help(topic="%s")' % (aid, aid) def _help_md(text): """Статья для чата. Ссылка на статью [Маппинг](help:mapping.roles) становится «Маппинг (статья mapping.roles — marfor_help(topic="mapping.roles"))»: по ней ассистент откроет статью, а не пойдёт по адресу, которого нет. Путь сайта (/data_sources) — полным адресом стенда: пользователю его открывать. Строки-маркеры редакций, если стенд их оставил, убираются.""" text = _ONLY_MARK_RE.sub('', str(text or '')) text = _HELP_LINK_RE.sub( lambda m: ('%s (%s)' % (m.group(1).strip(), _help_ref(m.group(2)))).lstrip(), text) text = _HELP_BARE_RE.sub(lambda m: _help_ref(m.group(1)), text) return _REL_LINK_RE.sub(lambda m: '(%s%s)' % (BASE_URL, m.group(1)), text) def _assistant_absent(code, js): """404/405 не от ручек помощника — их на стенде ещё нет. Отказ самих ручек («статья не найдена», «проект не найден») приходит JSON-ом с полем success: это ответ, а не отсутствие.""" if code not in (404, 405): return False return not (isinstance(js, dict) and 'success' in js and not js.get('_not_json')) def _assistant_scope_denied(code, js): """403 token_scope на POST: стенд с токенами, но без правил записи помощника — его там нет.""" return code == 403 and isinstance(js, dict) and js.get('code') == 'token_scope' # Чего нет ни в справке, ни в ответах инструментов, того справка не подтверждает — фактом, как в # путеводителе (раздел про настройку проекта). До 0.7.1 здесь было «…того не обещайте». _HELP_UNCONFIRMED = ('Чего нет ни в справке на сайте, ни в ответах инструментов, того справка не ' 'подтверждает.') def _assistant_absent_text(tail): return ('На этом стенде помощник ещё не включён; справка — %s/help.\n%s' % (BASE_URL, tail)).rstrip() def _assistant_error(code, js, limit_note=''): """Отказ ручки помощника словами. В режиме токена 401 и 429 too_many_attempts сюда не доходят: MF.call превращает их в TokenRefused — тот же текст «войдите заново», что у остальных.""" msg = (js.get('message') or js.get('error')) if isinstance(js, dict) else None tail = (': %s' % msg) if msg and not (isinstance(js, dict) and js.get('_not_json')) else '' if code == 401: return ('Стенд не принял вход (HTTP 401%s). Состояние входа показывает marfor_whoami.' % tail) if code == 429: return ('Стенд просит подождать (HTTP 429%s)%s. Немедленный повторный вызов получит тот ' 'же ответ; предел снимается со временем.' % (tail, limit_note)) if code == 503: return ('Справка MARFOR временно недоступна (HTTP 503%s); справка на сайте — %s/help.' % (tail, BASE_URL)) if isinstance(js, dict) and js.get('_not_json'): return ('Неожиданный ответ стенда (HTTP %s, не JSON) — обычно это временный сбой; справка ' 'на сайте — %s/help.' % (code, BASE_URL)) return _refusal(code, js) def _help_match_line(m): return ' %s — %s%s → marfor_help(topic="%s")' % ( m['id'], m.get('title') or '', (': %s' % m['summary']) if m.get('summary') else '', m['id']) def _help_article_lines(a): aid = str(a.get('id') or '') url = _abs_url(a.get('url')) meta = [x for x in (('раздел «%s»' % a['section']) if a.get('section') else '', ('обновлена %s' % a['updated']) if a.get('updated') else '', ('на сайте: %s' % url) if url else '') if x] out = ['СТАТЬЯ «%s» topic="%s"' % (a.get('title') or aid, aid)] out += [', '.join(meta)] if meta else [] out.append('Статья справки MARFOR — первоисточник: шаги, названия кнопок и ссылки в ней — как ' 'на сайте.') body = _help_md(a.get('text')).strip('\n') if len(body) > HELP_TEXT_MAX: body = body[:HELP_TEXT_MAX] + '\n… статья обрезана' + ('; целиком — %s' % url if url else '') out += ['', body] rel = [r for r in (a.get('related') or []) if isinstance(r, dict) and r.get('id')] if rel: out += ['', 'СВЯЗАННЫЕ СТАТЬИ:'] + [_help_match_line(r) for r in rel] return out def _help_toc_text(): code, js = MF.call('GET', '/api/assistant/help') if _assistant_absent(code, js): return _assistant_absent_text(_HELP_UNCONFIRMED) if code != 200 or not isinstance(js, dict) or not js.get('success'): return _assistant_error(code, js) out, total = [], 0 for sec in (js.get('sections') or []): arts = [a for a in ((sec.get('articles') if isinstance(sec, dict) else None) or []) if isinstance(a, dict) and a.get('id')] if arts: total += len(arts) out += ['', str(sec.get('title') or 'Прочее').upper()] out += [' %s — %s%s' % (a['id'], a.get('title') or '', (': %s' % a['summary']) if a.get('summary') else '') for a in arts] if not total: return 'В справке этого стенда пока нет статей. Справка на сайте — %s/help.' % BASE_URL return '\n'.join(['Справка MARFOR — оглавление, статей: %d стенд: %s' % (total, BASE_URL)] + out + ['', 'Статья целиком: marfor_help(topic="<id>"). Вопрос своими словами: ' 'marfor_help(question="…").', 'Справка на сайте: %s/docs' % BASE_URL]) def _help_topic_text(topic): code, js = MF.call('GET', '/api/assistant/help/%s' % _q(topic)) if _assistant_absent(code, js): return _assistant_absent_text(_HELP_UNCONFIRMED) if code == 404: return ('Статьи «%s» в справке этого стенда нет. Оглавление — marfor_help(), вопрос своими ' 'словами — marfor_help(question="…").' % topic) if code != 200 or not isinstance(js, dict) or not js.get('success'): return _assistant_error(code, js) return '\n'.join(_help_article_lines(js)) def _help_ask_text(question, topic=None): """Вопрос — в журнал справки ВСЕГДА, когда он задан, в том числе вместе с topic: по договору стенд при заданном topic кладёт эту статью в article. Её там нет — один GET статьи.""" code, js = MF.call('POST', '/api/assistant/ask', data={'question': question, 'topic': topic, 'project_id': None}) if _assistant_absent(code, js) or _assistant_scope_denied(code, js): return _assistant_absent_text('Вопрос не передан. ' + _HELP_UNCONFIRMED) if code != 200 or not isinstance(js, dict) or not js.get('success'): return _assistant_error(code, js, ' — у справки предел: 120 вопросов в час') art = js.get('article') art = art if isinstance(art, dict) and art.get('id') else None out = [] if not art and topic: try: code2, js2 = MF.call('GET', '/api/assistant/help/%s' % _q(topic)) except RuntimeError as e: # вопрос уже в журнале — похожие всё равно покажем code2, js2 = None, str(e) if code2 == 200 and isinstance(js2, dict) and js2.get('success') and js2.get('id'): art = js2 elif code2 == 404 and not _assistant_absent(code2, js2): out.append('Статьи «%s» в справке этого стенда нет.' % topic) else: out.append('Статью «%s» получить не удалось: %s' % ( topic, js2 if code2 is None else _assistant_error(code2, js2))) # Похожие — без самой статьи и без того, что уже стоит в её «связанных». shown = set() if art: shown = {art['id']} | {r.get('id') for r in (art.get('related') or []) if isinstance(r, dict)} others = [m for m in (js.get('matches') or []) if isinstance(m, dict) and m.get('id') and m['id'] not in shown][:5] if art: out += _help_article_lines(art) if others: out += ['', 'ПОХОЖИЕ СТАТЬИ — если эта не про то:'] + [_help_match_line(m) for m in others] elif others: out += ['Уверенного ответа в справке нет. Ближе всего по словам вопроса:'] out += [_help_match_line(m) for m in others] out += ['', 'Статья целиком — marfor_help(topic="…"). Если ни одна не подходит, ответа ' 'в справке нет; вопрос принимает поддержка — marfor_feedback(kind="question").'] else: out += ['В справке MARFOR статьи по этому вопросу нет: ответ на него справкой не ' 'подтверждён.', 'Вопрос принимает поддержка — marfor_feedback(kind="question"); обращение уходит от ' 'имени пользователя, его читают люди. Оглавление справки — marfor_help().'] if js.get('logged'): out += ['', 'Вопрос записан в журнал справки MARFOR.'] return '\n'.join(out) def _help_text(args): topic = str(args.get('topic') or '').strip() if topic[:5].lower() == 'help:': topic = topic[5:].strip() question = str(args.get('question') or '').strip() if topic and not _HELP_ID_RE.fullmatch(topic): return ('ОТКАЗ: topic «%s» — не id статьи. id выглядит как mapping.roles: латиница, ' 'цифры, точка, дефис; id статей есть в оглавлении (marfor_help() без аргументов) и ' 'в ссылках «статья …».' % topic[:80]) if not question: return _help_topic_text(topic) if topic else _help_toc_text() if len(question) > HELP_QUESTION_MAX: return ('ОТКАЗ: вопрос длиннее %d символов (%d) — ничего не отправлено. Справка ищет по ' 'короткой формулировке вопроса; вставленные данные ей не нужны.' % (HELP_QUESTION_MAX, len(question))) if _SECRET_RE.search(question): return ('ОТКАЗ: в вопросе есть токен доступа — ничего не отправлено: вопрос пишется в ' 'журнал справки. Вопрос без токена проходит. Токен, попавший в чат, отзывается на ' '%s/account, раздел «Подключение Claude (MCP)».' % PUBLIC_BASE) return _help_ask_text(question, topic or None) def _projects_table(rows): out = ['Проектов: %d стенд: %s' % (len(rows), BASE_URL), ''] for p in rows: out.append(' %s «%s» доступ: %s' % (p.get('id'), p.get('name'), p.get('access'))) return out def _status_fetch(pid): return MF.call('GET', '/api/assistant/project_status', params={'project_id': pid}) def _stage_text(stage): s = str(stage or '') return _STAGE_WORDS.get(s) or s or 'не определена' def _status_steps(js): return [s for s in (js.get('next_steps') or []) if isinstance(s, dict) and (s.get('text') or s.get('code'))] def _status_item(kind, x): """«Имя» — состояние: для сводки «что уже сделано». Только имена и состояния — без данных.""" st = x.get('state') words = [_STATE_WORDS.get(st, st)] if isinstance(st, str) and st else [] pct = x.get('percent') if (st in ('running', 'loading', 'building') and isinstance(pct, (int, float)) and not isinstance(pct, bool)): words[-1] += ' %d %%' % pct m = x.get('mapping') if kind == 'sources' and isinstance(m, dict): words.append('маппинг применён' if m.get('applied') else 'маппинг сохранён, но не применён' if m.get('configured') else 'маппинга нет') if kind == 'scenarios' and x.get('base_outdated'): words.append('база устарела') return '«%s»%s' % (x.get('name') or '—', (' — ' + ', '.join(words)) if words else '') def _status_done_lines(js): out, seen = [], False for key, title in (('sources', 'Источники'), ('blends', 'Бленды'), ('forecasts', 'Прогнозы'), ('models', 'Модели'), ('scenarios', 'Сценарии')): if isinstance(js.get(key), list): seen = True items = [x for x in (js.get(key) or []) if isinstance(x, dict)] if items: out.append(' %s (%d): %s%s' % (title, len(items), '; '.join(_status_item(key, x) for x in items[:6]), '; …' if len(items) > 6 else '')) bi = js.get('bi') if isinstance(js.get('bi'), dict) else {} n = bi.get('dashboards') n = len(n) if isinstance(n, list) else n if isinstance(n, int) and not isinstance(n, bool) and n: pub = bi.get('published') out.append(' BI-дашборды: %d%s' % (n, (', опубликовано: %s' % ( len(pub) if isinstance(pub, list) else pub)) if pub else '')) if not out and seen: out.append(' пока ничего') return out def _status_lines(js, pid): proj = js.get('project') if isinstance(js.get('project'), dict) else {} pid = proj.get('id') or pid role = proj.get('my_role') head = 'Проект «%s» id: %s' % (proj.get('name') or '—', pid) if role: head += ' роль пользователя: %s' % _ROLE_WORDS.get(role, role) stage = js.get('stage') out = [head + ' стенд: %s' % BASE_URL, 'Стадия: %s%s' % (_stage_text(stage), (' [%s]' % stage) if stage in _STAGE_WORDS else '')] done = _status_done_lines(js) if done: out += ['', 'УЖЕ СДЕЛАНО:'] + done steps = _status_steps(js) if steps: out += ['', 'СЛЕДУЮЩИЙ ШАГ:' if len(steps) == 1 else 'СЛЕДУЮЩИЕ ШАГИ (по порядку):'] for i, s in enumerate(steps[:8], 1): out.append(' %d. %s%s' % (i, s.get('text') or s.get('code'), ' ← обязательный' if s.get('blocking') else '')) url = _abs_url(s.get('url')) if url: out.append(' где: %s' % url) if s.get('help'): out.append(' как: %s' % _help_ref(s['help'])) else: out += ['', 'Следующих шагов стенд не назвал.'] warns = [w for w in (js.get('warnings') or []) if isinstance(w, dict) and w.get('text')] if warns: out += ['', 'ПРЕДУПРЕЖДЕНИЯ:'] out += [' ⚠️ %s%s' % (w['text'], (' (%s)' % _help_ref(w['help'])) if w.get('help') else '') for w in warns[:8]] if proj.get('can_edit') is False: out += ['', 'У пользователя доступ только на просмотр: шаги, которые меняют проект, сделает ' 'владелец или редактор; доступ выдаёт владелец проекта.'] plan = js.get('plan') if isinstance(js.get('plan'), dict) else {} blocked = [b if isinstance(b, str) else (b.get('text') or b.get('step') or b.get('code')) for b in (plan.get('blocked') or []) if isinstance(b, (str, dict))] blocked = [str(b) for b in blocked if b] if blocked: out += ['', 'Упирается в тариф: %s. Тарифы и оплата: %s/account (%s).' % ('; '.join(blocked), BASE_URL, _help_ref(PLAN_HELP_TOPIC))] out += ['', 'Через MCP эти шаги не выполняются: они делаются на сайте, по ссылкам выше; кнопки и ' 'разделы в статьях справки названы так же, как на сайте. Новое состояние после ' 'шага — marfor_next_step(project_id="%s").' % pid] return out def _next_step_text(args): pid = str(args.get('project_id') or '').strip() lead = [] if not pid: code, js = MF.call('GET', '/api/bi/projects') if not isinstance(js, dict) or not js.get('success'): return _refusal(code, js) rows = [p for p in (js.get('projects') or []) if isinstance(p, dict)] if not rows: return EMPTY_ACCOUNT_TEXT if len(rows) > 1: return '\n'.join(_projects_table(rows) + [ '', 'Проектов несколько; статус одного проекта — marfor_next_step(project_id="…") ' 'с id из списка.']) pid = str(rows[0].get('id') or '') lead = ['Проект в аккаунте один — «%s»; ниже его статус.' % rows[0].get('name')] code, js = _status_fetch(pid) if _assistant_absent(code, js): return _assistant_absent_text('Что уже есть в проекте, покажет ' 'marfor_datasets(project_id="%s").' % pid) msg = (js.get('message') or js.get('error')) if isinstance(js, dict) else None if code == 403: return ('Нет доступа к проекту %s (HTTP 403%s). Аккаунт входа показывает marfor_whoami, ' 'доступные ему проекты — marfor_projects; доступ к чужому проекту выдаёт его ' 'владелец.' % (pid, (': %s' % msg) if msg else '')) if code == 404: return ('Проект %s не найден (HTTP 404%s); project_id проектов аккаунта — в ' 'marfor_projects.' % (pid, (': %s' % msg) if msg else '')) if code != 200 or not isinstance(js, dict) or not js.get('success'): return _assistant_error(code, js) return '\n'.join(lead + _status_lines(js, pid)) def _whoami_stage_line(rows): """«Стадия … следующий шаг …» для единственного проекта. Одна попытка: любая неудача (ручки нет, отказ, сеть) — молча и без повторов; проверке входа она не мешает.""" if len(rows) != 1 or not rows[0].get('id'): return '' try: code, js = _status_fetch(str(rows[0]['id'])) except Exception: return '' if code != 200 or not isinstance(js, dict) or not js.get('success'): return '' line = 'Стадия : %s.' % _stage_text(js.get('stage')) steps = _status_steps(js) if steps: url = _abs_url(steps[0].get('url')) line += ' Следующий шаг: %s%s.' % (str(steps[0].get('text') or steps[0].get('code')).rstrip('.'), (' (%s)' % url) if url else '') return line + ' Подробнее — marfor_next_step.' def _feedback_text(args): kinds = dict(FEEDBACK_KINDS) kind = str(args.get('kind') or '').strip().lower() if kind not in kinds: return ('ОТКАЗ: kind — одно из: %s. Ничего не отправлено.' % ', '.join('%s (%s)' % kv for kv in FEEDBACK_KINDS)) text = str(args.get('text') or '').strip() if not text: return 'ОТКАЗ: пустой text — отправлять нечего. Ничего не отправлено.' if len(text) > FEEDBACK_TEXT_MAX: return ('ОТКАЗ: текст длиннее %d символов (%d) — ничего не отправлено. Сам сервер текст не ' 'сокращает: обращение уходит от имени пользователя слово в слово.' % (FEEDBACK_TEXT_MAX, len(text))) raw_ctx = args.get('context') if raw_ctx is not None and not isinstance(raw_ctx, dict): return ('ОТКАЗ: context — объект {"tool", "error", "page", "project_id"}. Ничего не ' 'отправлено.') ctx, dropped, cut = {}, [], False for k, v in (raw_ctx or {}).items(): if k not in _FEEDBACK_CONTEXT_KEYS: dropped.append(str(k)) continue if v is None or v == '': continue v = v if isinstance(v, str) else json.dumps(v, ensure_ascii=False) if k == 'error' and len(v) > FEEDBACK_ERROR_MAX: v, cut = v[:FEEDBACK_ERROR_MAX], True ctx[k] = v if _SECRET_RE.search(text + '\n' + json.dumps(ctx, ensure_ascii=False)): return ('ОТКАЗ: в обращении есть токен доступа — ничего не отправлено. Обращение без ' 'токена проходит. Токен, попавший в чат, отзывается на %s/account, раздел ' '«Подключение Claude (MCP)».' % PUBLIC_BASE) body = {'kind': kind, 'text': text, 'context': ctx} digest = hashlib.sha256(json.dumps([BASE_URL, body], ensure_ascii=False, sort_keys=True) .encode('utf-8')).hexdigest() with _FEEDBACK_LOCK: prev = _FEEDBACK_SENT.get(digest) if prev and time.monotonic() - prev[1] < FEEDBACK_REPEAT_TTL: return ('Это обращение уже отправлено: номер %s. Повторно оно не отправлено — второй ' 'номер поддержке не нужен.' % prev[0]) code, js = MF.call('POST', '/api/assistant/feedback', data=body) if _assistant_absent(code, js) or _assistant_scope_denied(code, js): return _assistant_absent_text('Обращение НЕ отправлено. Пользователь может написать в ' 'поддержку сам: support@marfor.pro.') ticket = js.get('ticket_id') if isinstance(js, dict) and js.get('success') else None if code != 200 or not ticket: return 'Обращение НЕ отправлено. ' + _assistant_error( code, js, ' — у обращений предел: 10 в час') with _FEEDBACK_LOCK: _FEEDBACK_SENT[digest] = (ticket, time.monotonic()) out = ['Обращение отправлено: номер %s (%s). стенд: %s' % (ticket, kinds[kind], BASE_URL), 'Письмо в поддержку ушло.' if js.get('emailed') else 'Письмо в поддержку не ушло, но обращение записано в журнал — его увидят при разборе.'] if cut: out.append('Текст ошибки в context обрезан до %d символов.' % FEEDBACK_ERROR_MAX) if dropped: out.append('Поля context не отправлены: %s — передаются только tool, error, page, ' 'project_id.' % ', '.join(dropped)) out.append('Номер обращения: %s — по нему поддержка найдёт обращение.' % ticket) return '\n'.join(out) def _is_zero(v): try: return float(v) == 0 except (TypeError, ValueError): return False # new_value=0 отклоняется ДО сети (0.7.0). Раньше правило жило только словами «Never pass # new_value=0» в instructions; в описательных текстах приказа больше нет, поэтому правило держит # код, а описания говорят фактом: «new_value=0 is rejected; 0.01 stands for zero». ZERO_VALUE_TEXT = ('ОТКАЗ: new_value=0 — ничего не отправлено. Ноль уводит MARFOR на медленную ' 'ветку: она читает в память воркера всю таблицу сценария (десятки миллионов ' 'строк). Срез обнуляет значение 0.01.') def call_tool(name, args): hidden = _hidden_reason(name) if hidden: return hidden return _run_tool(name, args) def _run_tool(name, args): """Исполнение инструмента. Наборы и MARFOR_READONLY проверяет вызывающий: call_tool — по окружению этого процесса, remote_call_tool — по каталогу удалённого коннектора.""" refusal = _no_dry_run_refusal(name, args) if refusal: return refusal if name.startswith('marfor_export_'): return _export_tool(name, args) if name == 'marfor_guide': return GUIDE_TEXT if name == 'marfor_whoami': MF.logged_in = False MF.ensure() acc = MF.account out = [f"Вход выполнен ({_login_mode_label()}).", f"Стенд : {acc['base_url']}", f"Email : {acc['email']}", f"user_id : {acc['user_id']}", f"Тариф : {acc.get('tariff')}", f"Сервер : marfor-mcp {SERVER_VERSION}, наборы инструментов: {_toolsets_label()}" + (', только чтение (MARFOR_READONLY)' if _readonly() else '')] projects, rows = _projects_info() out += [projects] if projects else [] out += [x for x in (_whoami_stage_line(rows),) if x] if _is_public_stand(): if not projects.startswith('Проектов: 0'): # пустому аккаунту идти пока некуда out.append('Дальше : marfor_projects → marfor_datasets → marfor_describe → ' 'marfor_query (подробности — marfor_guide).') else: out.append("⚠️ Таблицы сценариев называются user_<id>_<проект>_modeling_<сессия>: " "при user_id не владельца данных ручки сценария отвечают «Проект не найден " "или нет доступа».") out += [x for x in (_newer_version_note(),) if x] return '\n'.join(out) if name == 'marfor_projects': code, js = MF.call('GET', '/api/bi/projects') if not isinstance(js, dict) or not js.get('success'): return _refusal(code, js) rows = [p for p in (js.get('projects') or []) if isinstance(p, dict)] if not rows: return EMPTY_ACCOUNT_TEXT out = _projects_table(rows) out += ['', 'Дальше: marfor_datasets(project_id="…") — датасеты проекта с этим id.', 'Настройка проекта, «что дальше?» — marfor_next_step(project_id="…").'] return '\n'.join(out) if name == 'marfor_help': return _help_text(args) if name == 'marfor_next_step': return _next_step_text(args) if name == 'marfor_feedback': return _feedback_text(args) if name in ('marfor_datasets', 'marfor_bi_datasets'): return _datasets_text(args) if name == 'marfor_describe': return _describe_text(args) if name == 'marfor_api_get': path = args['path'] if not path.startswith('/api/'): return 'Разрешены только пути, начинающиеся с /api/' code, js = MF.call('GET', path, params=args.get('params')) return _emit(js, args, prefix=f'HTTP {code}\n') if name == 'marfor_query': code, js = _query_call(args['session_id'], args['metrics'], args.get('dimensions'), args.get('filters'), args.get('date_from'), args.get('date_to'), args.get('data_source', 'scenario')) plan = _plan_limit_text(code, js) if plan: return plan rows = js.get('data') if isinstance(js, dict) else None head = f'строк: {len(rows)}\n' if isinstance(rows, list) else '' return _emit(js, args, prefix=head) if name == 'marfor_apply_edit': if _is_zero(args.get('new_value')): return ZERO_VALUE_TEXT res = apply_edit(args['session_id'], args['metric'], args['filters'], float(args['new_value']), wait=args.get('wait', True)) if res.get('plan_limit'): return res['message'] out = json.dumps(res, ensure_ascii=False, indent=1) if res.get('pending') and res.get('task_id'): out += ('\nПравка поставлена в очередь стенда; итог — ' 'marfor_job_status(task_id="%s").' % res['task_id']) return out if name == 'marfor_apply_batch': edits = args.get('edits') if not edits and args.get('edits_file'): with open(os.path.expanduser(args['edits_file']), encoding='utf-8') as f: edits = json.load(f) edits = [_norm_edit(e) for e in (edits or [])] if not edits: return 'Список правок пуст: нет ни edits, ни edits_file.' zero = [str(e.get('label') or i) for i, e in enumerate(edits) if _is_zero(e.get('new_value'))] if zero: return ('%s\nПравки с нулём: %s%s. Ни одна правка пачки не отправлена и не посчитана.' % (ZERO_VALUE_TEXT, ', '.join(zero[:10]), ' …' if len(zero) > 10 else '')) sid, metric = args['session_id'], args['metric'] if args.get('dry_run', True): out = [f'СУХОЙ ПРОГОН — ничего не изменено. Правок: {len(edits)}', f'Стенд: {BASE_URL} сессия: {sid} метрика: {metric}', ''] tot_base = tot_new = 0.0 for e in edits[:200]: base = _metric_sum(sid, e.get('metric') or metric, e['filters']) k = (e['new_value'] / base) if base else None tot_base += base or 0 tot_new += e['new_value'] out.append('%-52s база %14s → цель %14s %s' % ( (e.get('label') or json.dumps(e['filters'], ensure_ascii=False))[:52], f'{base:,.0f}' if base is not None else 'н/д', f"{e['new_value']:,.0f}", f'×{k:.3f}' if k else '')) if len(edits) > 200: out.append(f'… и ещё {len(edits) - 200} правок (сверка показана для первых 200)') out += ['', f'ИТОГО база {tot_base:,.0f} → цель {tot_new:,.0f}', 'Запись — тот же вызов с dry_run=false.'] return '\n'.join(out) job_id = uuid.uuid4().hex[:12] with _JOBS_LOCK: _JOBS[job_id] = {'status': 'running', 'total': len(edits), 'done': 0, 'ok': 0, 'fail': 0, 'stop': False, 'current': -1, 'last': None, 'error': None, 'session_id': sid, 'metric': metric} threading.Thread(target=batch_worker, daemon=True, args=(job_id, sid, metric, edits, args.get('stop_on_error', True))).start() return (f'Батч запущен. job_id={job_id}, правок: {len(edits)}\n' f'Стенд: {BASE_URL}, сессия: {sid}, метрика: {metric}\n' f'Лог: {_job_path(job_id)}\n' f'Прогресс: marfor_job_status(job_id="{job_id}")') if name == 'marfor_apply_values': vals = args.get('values') if not vals and args.get('values_file'): with open(os.path.expanduser(args['values_file']), encoding='utf-8') as f: vals = json.load(f) if not vals: return 'Пустой список: нет ни values, ни values_file.' dims = args.get('pivot_dimensions') or DEFAULT_PIVOT_DIMS changes = [build_change(args['metric'], v.get('slice') or {}, v.get('value'), dims) for v in vals] code, js = apply_values(args['session_id'], changes, args.get('scope') or {}, dry_run=args.get('dry_run', True)) plan = _plan_limit_text(code, js, ' — ничего не применено') if plan: return plan if not isinstance(js, dict): return f'HTTP {code}: неожиданный ответ сервера' # Печатаем ТОЛЬКО то, что вернул сервер. Раньше обёртка писала «ПРИМЕНЕНО» # независимо от содержимого — из-за этого 06.08 правка молча не применилась, # а в диалоге стояло «применено». if not js.get('success'): return ('ОТКАЗ СЕРВЕРА — ничего не применено.\n' f'{js.get("message") or js.get("error") or json.dumps(js, ensure_ascii=False)[:600]}') if js.get('async') and js.get('task_id'): return (f'ЗАДАЧА ПОСТАВЛЕНА (правок отправлено: {len(changes)}). ' f'task_id={js["task_id"]}\n' f'Стенд: {BASE_URL} сессия: {args["session_id"]}\n' f'⚠️ Это ещё НЕ результат: итог — task_status=completed и число применённых ' f'срезов — показывает marfor_job_status(task_id="{js["task_id"]}").') if js.get('dry_run'): # Ширина правки — главное, ради чего сухой прогон существует: увидеть # «82 193 строки, годы [2023…2026]» ДО записи. Сервер это отдаёт; # обёртка обязана показать, иначе предохранитель не доходит до человека. prev = js.get('preview') or [] wide = js.get('multi_year_or_region') or [] out = [f'СУХОЙ ПРОГОН — ничего не изменено. Правок: {js.get("changes")}', f'Стенд: {BASE_URL} сессия: {js.get("session_id")} ' f'таблица: {js.get("table")}', f'Строк будет затронуто всего: {js.get("rows_total")}'] if wide: out.append(f'🔴 ШИРОКИХ ПРАВОК: {len(wide)} — выходят за один год или ' f'один регион (индексы {wide[:10]}); в списке ниже они отмечены 🔴.') nk = js.get('without_mmm_slice_key') or [] if nk: out.append(f'⚠️ Без mmm_slice_key: {len(nk)} правок (индексы {nk[:10]}) — ' f'адсток посчитает срезы иначе, чем при ручном вводе') out.append('') for p in prev[:15]: _mark = '🔴' if p.get('i') in wide else ' ' out.append(f'{_mark} #{p.get("i")} {p.get("mmm_slice_key") or "—"} ' f'→ {p.get("new_value")}') out.append(f' строк {p.get("rows")} годы {p.get("years")} ' f'регионы {p.get("regions")}') out.append(f' условие: {p.get("where")}') if len(prev) > 15: out.append(f' … и ещё {len(prev) - 15} правок (показаны первые 15)') out.append('') out.append('Выше — строки, годы и регионы каждой правки. Запись — тот же вызов с ' 'dry_run=false.') return '\n'.join(out) return ('РЕЗУЛЬТАТ СЕРВЕРА:\n' f' применено : {js.get("applied")} из {js.get("changes")}\n' f' без эффекта : {len(js.get("unchanged") or [])}\n' f' ошибок : {len(js.get("failed") or [])}\n' f' цепочка : {"пересчитана" if js.get("chain_propagated") else "НЕ пересчитана"}\n' f' {js.get("message") or ""}') if name == 'marfor_job_status': if args.get('task_id'): return _task_status_text(args) if not args.get('job_id'): return ('Нужен job_id (фоновый батч marfor_apply_batch) или task_id (задача из ' 'ответа marfor_apply_values / marfor_apply_edit).') job_id = args['job_id'] with _JOBS_LOCK: job = dict(_JOBS.get(job_id) or {}) if not job: return f'Задача {job_id} не найдена (сервер перезапускался?). Лог: {_job_path(job_id)}' lines = [f"job {job_id}: {job['status']} {job['done']}/{job['total']} " f"успешно {job['ok']}, ошибок {job['fail']}"] if job.get('error'): lines += ['ЗАДАНИЕ ОСТАНОВЛЕНО: стенд отверг токен. Оставшиеся правки не отправлены: ' '%d из %d; после нового входа повторного запуска ждут только они — ' 'применённые видны в журнале ниже.' % (job['total'] - job['done'], job['total']), job['error']] tail = int(args.get('tail') or 5) try: with open(_job_path(job_id), encoding='utf-8') as f: recs = f.readlines()[-tail:] lines += [' ' + r.strip() for r in recs] except OSError: pass return '\n'.join(lines) if name == 'marfor_job_stop': with _JOBS_LOCK: job = _JOBS.get(args['job_id']) if not job: return 'Задача не найдена' job['stop'] = True return 'Остановится после текущей правки.' # marfor_bi_datasets — прежнее имя marfor_datasets, обслуживается выше той же функцией if name == 'marfor_bi_dashboards': params = {'project': args['project_id']} if args.get('project_id') else None code, js = MF.call('GET', '/api/bi/dashboards', params=params) if not isinstance(js, dict) or not js.get('success'): return _refusal(code, js) rows = js.get('dashboards') or [] out = ['Дашбордов: %d Стенд: %s' % (len(rows), BASE_URL), ''] for r in rows: pub = 'опубликован' if r.get('is_published') else '—' out.append('%s\n «%s» · %s · роль %s · проект «%s» · публикация: %s' % (r.get('id'), r.get('title'), r.get('kind'), r.get('role'), r.get('project_name'), pub)) return '\n'.join(out) if name == 'marfor_bi_generate': mode = 'revise' if args.get('mode') == 'revise' else 'create' if mode == 'revise' and not args.get('dashboard_id'): return 'Для mode=revise обязателен dashboard_id.' if mode == 'create' and not args.get('project_id'): return 'Для создания обязателен project_id.' if mode == 'create' and not args.get('dataset_keys'): return ('Для создания обязательны dataset_keys — они есть в ответе ' 'marfor_bi_datasets(project_id).') body = {'prompt': args['prompt'], 'mode': mode} for k in ('project_id', 'skill', 'dataset_keys', 'dashboard_id'): if args.get(k): body[k] = args[k] t = int(args.get('timeout_s') or max(BI_GENERATE_TIMEOUT, HTTP_TIMEOUT)) code, js = MF.call('POST', '/api/bi/ai/generate', data=body, timeout=t) if not isinstance(js, dict) or not js.get('success'): return _refusal(code, js, limit=400) return ('ГОТОВО. Дашборд %s — «%s»\n' 'Стенд: %s редактор: %s%s\n' 'Дальше: marfor_bi_get_page — забрать HTML; ' 'marfor_bi_publish — опубликовать.' % (js.get('dashboard_id'), js.get('title'), BASE_URL, BASE_URL, js.get('url'))) if name == 'marfor_bi_get_page': did = args['dashboard_id'] code, raw = MF.fetch_raw('/api/bi/ai_pages/%s/raw' % did) text = raw.decode('utf-8', 'replace') if code != 200: return _refusal_raw(code, raw, text) if not text.lstrip()[:200].lower().startswith(('<!doctype', '<html')): # сервер отвечает HTML и на «страница не сгенерирована» if 'не сгенерирована' in text[:400]: return 'Страница дашборда %s ещё не сгенерирована.' % did p = os.path.expanduser(args.get('save_to') or os.path.join(PAGE_DIR, '%s.html' % did)) os.makedirs(os.path.dirname(p) or '.', exist_ok=True) with open(p, 'w', encoding='utf-8') as f: f.write(text) return ('Сохранено: %s\nРазмер: %d байт <title>: %s\nСтенд: %s' % (p, len(raw), _bi_html_title(text), BASE_URL)) if name == 'marfor_bi_put_page': html = args.get('html') if not html and args.get('html_file'): with open(os.path.expanduser(args['html_file']), encoding='utf-8') as f: html = f.read() if not html: return 'Нет ни html_file (путь к файлу), ни html (строка).' code, js = MF.call('PUT', '/api/bi/ai_pages/%s' % args['dashboard_id'], data={'html': html}) if not isinstance(js, dict) or not js.get('success'): return _refusal(code, js) return ('ЗАПИСАНО: %d байт в AI-страницу дашборда %s\n' 'Стенд: %s редактор: %s%s\n' 'Публикация — marfor_bi_publish(dashboard_id="%s")' % (js.get('bytes') or len(html.encode('utf-8')), js.get('dashboard_id'), BASE_URL, BASE_URL, js.get('url'), js.get('dashboard_id'))) if name == 'marfor_bi_publish': return bi_publish(args['dashboard_id'], slug=args.get('slug'), mode=args.get('mode') or None, unpublish=bool(args.get('unpublish')), chrome=args.get('chrome'), indexable=_flag(args.get('indexable'), None)) # ── калибровка MMM ─────────────────────────────────────────────────────── # Считает всё стенд MARFOR, сервер только носит запросы: прогон MMM идёт часами и на # Маке ему делать нечего. Тяжёлое здесь — только форматирование ответа под чтение. if name == 'marfor_mmm_params': code, js = MF.call('GET', '/api/mmm_calib/params') err = _mmm_err(code, js, 'marfor_mmm_params') if err: return err return _emit({k: v for k, v in js.items() if k != 'success'}, args, prefix='Ручки калибровки MMM (стенд %s, env=%s)\n' % (BASE_URL, js.get('env'))) if name == 'marfor_mmm_calibrate': code, js = MF.call('POST', '/api/mmm_calib/run/%s' % _q(args['session_id']), data={'params': args.get('params') or {}, 'label': args.get('label') or ''}) if isinstance(js, dict) and js.get('in_progress'): return ('Прогон НЕ запущен: %s\nХод идущего прогона — marfor_mmm_runs(session_id="%s")' % (js.get('message'), args['session_id'])) err = _mmm_err(code, js, 'marfor_mmm_calibrate') if err: return err p = js.get('params') or {} out = ['Прогон запущен: run_id = %s' % js.get('run_id'), 'Стенд : %s' % BASE_URL, 'Сессия : %s' % args['session_id']] if args.get('label'): out.append('Пометка : %s' % args['label']) out.append('Ручки : ' + ', '.join('%s=%s' % (k, v) for k, v in sorted(p.items()))) if js.get('unknown_params'): out.append('⚠️ Незнакомые параметры отброшены: %s (список ручек — marfor_mmm_params)' % ', '.join(js['unknown_params'])) if int(p.get('holdout_periods') or 0): out.append('⚠️ Это ПРОВЕРОЧНЫЙ прогон: последние %s периодов скрыты от обучения. ' 'Доли каналов из него брать нельзя — он мерит точность.' % p['holdout_periods']) out += ['', 'Счёт идёт фоном, ничего в модели не меняется.', 'Ход : marfor_mmm_runs(session_id="%s")' % args['session_id'], 'Результат: marfor_mmm_report(run_id="%s")' % js.get('run_id')] return '\n'.join(out) if name == 'marfor_mmm_runs': code, js = MF.call('GET', '/api/mmm_calib/runs/%s' % _q(args['session_id']), params={'limit': args.get('limit') or 30}) err = _mmm_err(code, js, 'marfor_mmm_runs') if err: return err runs = js.get('runs') or [] if not runs: return ('В сессии %s прогонов калибровки ещё нет.\nЗапуск — ' 'marfor_mmm_calibrate(session_id="%s")' % (args['session_id'], args['session_id'])) if args.get('save_to') or args.get('max_chars'): return _emit(runs, args, prefix='Прогоны сессии %s\n' % args['session_id']) lines = ['Прогоны калибровки MMM, сессия %s (стенд %s)' % (args['session_id'], BASE_URL), ''] for r in runs: h = r.get('headline') or {} head = ('платные %s %% (ласт-клик %s %%), rhat %s, дивергенций %s' % (h.get('paid_mmm_pct'), h.get('paid_lastclick_pct'), h.get('rhat_max'), h.get('divergences'))) if h else '' lines.append('%-10s %-9s %s' % (r.get('run_id'), r.get('status'), r.get('label') or '')) lines.append(' ручки : ' + ', '.join('%s=%s' % (k, v) for k, v in sorted((r.get('params') or {}).items()))) if r.get('status') == 'running': lines.append(' идёт : %s %% — %s' % (r.get('progress'), r.get('message'))) elif r.get('status') == 'stalled': lines.append(' ⚠️ считался, но перестал подавать признаки жизни (процесс убит?)') elif r.get('error'): lines.append(' ошибка: %s' % r['error']) if head: lines.append(' итог : %s (%s с)' % (head, r.get('elapsed_sec'))) if r.get('applied_to_model'): lines.append(' ✅ применён к модели %s (%s)' % (r['applied_to_model'].get('model_id'), r['applied_to_model'].get('at'))) lines.append('') return '\n'.join(lines) if name == 'marfor_mmm_report': code, js = MF.call('GET', '/api/mmm_calib/report/%s' % _q(args['run_id']), params=({'market': args['market']} if args.get('market') else None)) err = _mmm_err(code, js, 'marfor_mmm_report') if err: return err rep = js.get('report') or {} acc = rep.get('acceptance') or {} pref = 'Отчёт по прогону %s' % args['run_id'] if rep.get('label'): pref += ' («%s»)' % rep['label'] pref += '\nСтенд: %s\n' % BASE_URL if rep.get('status') != 'done': return pref + ('Статус: %s (%s %%) — %s\n%s' % (rep.get('status'), rep.get('progress'), rep.get('message'), rep.get('error') or '')) if acc: bad = [c['name'] for c in (acc.get('checks') or []) if not c.get('ok')] pref += ('Приёмка: %s%s\n' % ('пройдена' if acc.get('passed') else 'НЕ пройдена', ('; провалено: ' + ', '.join(bad)) if bad else '')) if rep.get('ВНИМАНИЕ'): pref += '⚠️ %s\n' % rep['ВНИМАНИЕ'] return _emit(rep, args, prefix=pref) if name == 'marfor_mmm_compare': ids = [str(x) for x in (args.get('run_ids') or []) if x] if len(ids) < 2: return 'Нужно минимум два run_id: сравнивать нечего.' code, js = MF.call('POST', '/api/mmm_calib/compare', data={'run_ids': ids}) err = _mmm_err(code, js, 'marfor_mmm_compare') if err: return err spread = js.get('разброс доли платных между прогонами, п.п.') pref = 'Сравнение прогонов: %s\nСтенд: %s\n' % (', '.join(ids), BASE_URL) if spread is not None: pref += ('Разброс доли платных между вариантами: %s п.п. — %s\n' % (spread, 'вывод держится на данных' if spread < 5 else 'вывод сильно зависит от допущений, одним прогоном решать нельзя')) return _emit({k: v for k, v in js.items() if k != 'success'}, args, prefix=pref) if name == 'marfor_mmm_diagnose': code, js = MF.call('GET', '/api/mmm_calib/diagnose/%s' % _q(args['run_id']), params=({'market': args['market']} if args.get('market') else None)) err = _mmm_err(code, js, 'marfor_mmm_diagnose') if err: return err d = js.get('diagnosis') or {} pref = 'Диагностика прогона %s, рынок «%s»\nСтенд: %s\n' % ( args['run_id'], d.get('market'), BASE_URL) if d.get('слабо идентифицируемы'): pref += ('⚠️ Держатся на приоре, а не на данных: %s\n' % ', '.join(d['слабо идентифицируемы'])) if d.get('спутаны с сезоном'): pref += ('⚠️ Спутаны с сезоном (расход идёт в такт календарю): %s\n' % ', '.join(d['спутаны с сезоном'])) return _emit(d, args, prefix=pref) if name == 'marfor_mmm_apply': confirm = bool(args.get('confirm')) # Клиентский стоп-кран: на боевом стенде не отправляем запрос вообще. Отказ есть и на # сервере, но он появится там только после выкатки — а промахнуться стендом можно уже сейчас. if confirm and not _is_dev_stand(): return ('ОТКАЗ. Стенд %s — боевой, применять калибровочный прогон к модели из чата ' 'нельзя.\nНа боевом стенде прогон применяет дежурный по заявке в шлюз ' '(deploy_gate/inbox/ГГММДД_имя.md): run_id=%s, параметры прогона и к какой ' 'модели применить.\nDev — это сервер, запущенный с ' 'MARFOR_BASE_URL=https://dev.marfor.pro.' % (BASE_URL, args['run_id'])) data = {'confirm': confirm} if args.get('model_id'): data['model_id'] = str(args['model_id']) code, js = MF.call('POST', '/api/mmm_calib/apply/%s' % _q(args['run_id']), data=data) if isinstance(js, dict) and js.get('refused') == 'prod': return 'ОТКАЗ СТЕНДА: %s' % js.get('message') if isinstance(js, dict) and js.get('needs_confirm'): rep = js.get('preview') or {} acc = rep.get('acceptance') or {} out = ['СУХОЙ ПРОГОН — ничего не изменено.', 'Прогон : %s%s' % (args['run_id'], (' («%s»)' % rep['label']) if rep.get('label') else ''), 'Стенд : %s' % BASE_URL, 'Приёмка: %s' % ('пройдена' if acc.get('passed') else 'НЕ пройдена')] h = rep.get('headline') or {} if h: out.append('Итог : платные %s %% против %s %% по ласт-клику, рынков %s' % (h.get('paid_mmm_pct'), h.get('paid_lastclick_pct'), h.get('markets'))) if rep.get('ВНИМАНИЕ'): out.append('⚠️ %s' % rep['ВНИМАНИЕ']) out += ['', 'В модель уедут доли каналов из этого прогона (эластичности заменятся).', 'Запись — тот же вызов с confirm=true.'] return '\n'.join(out) err = _mmm_err(code, js, 'marfor_mmm_apply') if err: return err out = ['ЗАПИСАНО: прогон %s применён к модели %s' % (args['run_id'], js.get('model_id')), 'Стенд: %s' % BASE_URL] for w in (js.get('warnings') or [])[:6]: out.append('⚠️ %s' % w) return '\n'.join(out) return f'Неизвестный инструмент: {name}' def _is_dev_stand(): # Дев узнаём по имени хоста стенда. Нужно ровно для одного: не дать применить # калибровочный прогон к боевой модели, промахнувшись стендом. host = urllib.parse.urlparse(BASE_URL).hostname or '' return host.startswith('dev.') or host in ('127.0.0.1', 'localhost') def _q(v): # Сегмент пути: run_id и session_id приходят из чата, и не-ASCII в них не должен # валить urllib на кодировании URL. return urllib.parse.quote(str(v), safe='') def _mmm_err(code, js, tool): # У всех ручек калибровки один конверт {success, message}. Отдельно ловим случай # «роутов на стенде ещё нет»: это не поломка, а невыкаченный патч, и сказать надо именно так. if _is_plan_limit(code, js): return _refusal(code, js) if code == 404: return ('На стенде %s нет ручек калибровки MMM (HTTP 404). Они живут на dev — это сервер, ' 'запущенный с MARFOR_BASE_URL=https://dev.marfor.pro; в бой едут отдельной заявкой ' 'через шлюз.' % BASE_URL) if not isinstance(js, dict) or js.get('_not_json'): return 'ОТКАЗ СЕРВЕРА (HTTP %s) на %s: %s' % (code, tool, str(js)[:400]) if not js.get('success'): return 'ОТКАЗ СЕРВЕРА (HTTP %s) на %s: %s' % (code, tool, js.get('message') or str(js)[:400]) return None def _norm_edit(e): if 'filters' in e and 'new_value' in e: return e # формат сегодняшнего edits.json: {kind, channel, segment, category, filters, new_value} return {'label': e.get('label') or '|'.join( str(e.get(k, '')) for k in ('channel', 'segment', 'category') if e.get(k)), 'filters': e['filters'], 'new_value': e['new_value'], 'metric': e.get('metric')} def _emit(js, args, prefix=''): full = json.dumps(js, ensure_ascii=False, indent=1) if args.get('save_to'): p = os.path.expanduser(args['save_to']) os.makedirs(os.path.dirname(p) or '.', exist_ok=True) with open(p, 'w', encoding='utf-8') as f: f.write(full) prefix += f'полный ответ сохранён: {p} ({len(full)} символов)\n' cap = int(args.get('max_chars') or 8000) if len(full) > cap: return prefix + full[:cap] + f'\n… обрезано, всего {len(full)} символов' return prefix + full # ─────────────── выгрузки: данные датасета → файл для дашборда пользователя ─────────────── # У пользователя свой дашборд, и MARFOR нужен ему как источник свежих данных без участия # человека. Профиль выгрузки (что брать и куда класть) лежит на ЭТОМ компьютере; запуск — из MCP # (marfor_export_run) или командой --export ИМЯ, которую вызывает планировщик ОС. MARFOR выгрузка # только читает: /api/query, описание датасета, формулы. Пишет лишь локальные файлы — профиль, # файл данных и его статус. # # Два принципа проекта держит код, а не договорённость: # • обрезанный ответ не пишется: год, упёршийся в предел строк стенда, берётся по месяцам, а # обрезанный месяц — громкий отказ; прежний файл цел, ошибка — в статусе; # • вычисляемая метрика — формула поверх СУММ строки (Σчисл/Σзнам, не среднее отношений), а # NULL и 0 не сливаются: компонент без значения или деление на ноль дают пустое значение. EXPORT_DIR = os.path.join(CONFIG_DIR, 'exports') # профили <имя>.json, замки, журналы запусков EXPORT_OUT_DIR = os.path.join(CONFIG_DIR, 'out') # файлы данных по умолчанию EXPORT_VERSION = 1 EXPORT_NAME_RE = re.compile(r'[a-z0-9_-]{1,40}') EXPORT_FORMATS = ('csv', 'json', 'js') EXPORT_EVERY = (('15m', 900), ('30m', 1800), ('1h', 3600), ('3h', 10800), ('6h', 21600), ('12h', 43200), ('1d', 86400)) EXPORT_PERIOD_KINDS = ('all', 'years', 'ytd', 'last_months', 'this_and_next_year', 'months') EXPORT_TIME_COLS = ('year', 'quarter', 'month') # время в файле есть всегда, в этом порядке # Время не бывает ни срезом, ни фильтром выгрузки: его задаёт period. _EXPORT_TIME_KEYS = ('year', 'quarter', 'month', 'halfyear', 'week', 'date', 'day_of_month', 'day_of_week', 'season', 'date_from', 'date_to') # Предел строк /api/query для processed (QUERY_ROW_LIMIT стенда). Ответ такой длины считается # обрезанным и без флага meta.truncated: стенд без этого флага обрезал бы молча. EXPORT_ROW_LIMIT = 200000 EXPORT_MAX_MONTHS = 240 # длиннее относительного периода не бывает EXPORT_LOCK_STALE = 6 * 3600 # замок старше — брошен убитым процессом _INF = float('inf') # Папки, куда macOS не пускает фоновые программы без «Полного доступа к диску» (TCC) _TCC_DIRS = ('Desktop', 'Documents', 'Downloads', os.path.join('Library', 'Mobile Documents')) class ExportUsage(Exception): """Так выгрузку не выполнить: имя, профиль, стенд, аргументы. У --export — код возврата 2.""" class ExportError(Exception): """Запуск не удался: сеть, отказ стенда, обрезанный ответ, запись файла. Код возврата 1.""" def _today(): """«Сегодня» для относительных периодов — одна точка на весь код: тесты подменяют её.""" return datetime.date.today() def _now_iso(): return datetime.datetime.now().astimezone().isoformat(timespec='seconds') def _flag(v, default): """Булев аргумент инструмента; клиент может прислать и строку «false».""" if v is None or v == '': return default if isinstance(v, str): return v.strip().lower() not in ('0', 'false', 'no', 'off', 'нет') return bool(v) # ── профиль: имя, пути, атомарная запись ── def _export_check_name(name): if isinstance(name, str) and EXPORT_NAME_RE.fullmatch(name): return name hint = '' if isinstance(name, str): cand = re.sub(r'[^a-z0-9_-]+', '_', name.strip().lower()).strip('_-')[:40] if cand and EXPORT_NAME_RE.fullmatch(cand): hint = ' Например: %s.' % cand raise ExportUsage('Имя выгрузки «%s» не подходит: нужны строчные латинские буквы, цифры, «_» ' 'и «-», от 1 до 40 символов.%s' % (name, hint)) def _export_path(name): return os.path.join(EXPORT_DIR, name + '.json') def _export_default_out(name, fmt): return os.path.join(EXPORT_OUT_DIR, '%s.%s' % (name, fmt)) def _export_status_path(out): return out + '.status.json' def _export_private_dir(path): """Каталог с правами 0700; если он лежит прямо в ~/.marfor_mcp — и она тоже 0700.""" os.makedirs(path, exist_ok=True) if os.name == 'nt': return for p in ((CONFIG_DIR, path) if os.path.dirname(path) == CONFIG_DIR else (path,)): try: os.chmod(p, 0o700) except OSError: pass def _export_atomic_write(path, data, keep_mode=False): """Атомарная запись: временный файл в том же каталоге + os.replace. Читатель (дэш) видит либо прежний файл целиком, либо новый, но не половину. Права нового файла — 0600; keep_mode — прежние права существующего файла (их мог настроить сам пользователь).""" d = os.path.dirname(path) or '.' os.makedirs(d, exist_ok=True) mode = 0o600 if keep_mode: try: mode = os.stat(path).st_mode & 0o777 except OSError: pass fd, tmp = tempfile.mkstemp(prefix='.%s.' % os.path.basename(path), suffix='.tmp', dir=d) try: with os.fdopen(fd, 'wb') as f: f.write(data) f.flush() os.fsync(f.fileno()) if os.name != 'nt': os.chmod(tmp, mode) os.replace(tmp, path) except BaseException: _unlink(tmp) raise def _export_load(name): _export_check_name(name) path = _export_path(name) if not os.path.exists(path): raise ExportUsage('Выгрузки «%s» на этом компьютере нет (%s). Список — marfor_export_list ' 'или --list-exports; создать — marfor_export_save.' % (name, path)) try: with open(path, encoding='utf-8') as f: prof = json.load(f) except (OSError, ValueError) as e: raise ExportUsage('Профиль %s не читается: %s' % (path, e)) if (not isinstance(prof, dict) or prof.get('version') != EXPORT_VERSION or prof.get('name') != name or not isinstance(prof.get('period'), dict)): raise ExportUsage('Файл %s не похож на профиль выгрузки версии %d: выгрузку пересоздаёт ' 'marfor_export_save.' % (path, EXPORT_VERSION)) return prof def _export_profiles(): """Все профили этого компьютера по имени; нечитаемый — {'name', '_broken'}.""" try: files = sorted(os.listdir(EXPORT_DIR)) except OSError: return [] out = [] for fn in files: name = fn[:-5] if fn.endswith('.json') else '' if not EXPORT_NAME_RE.fullmatch(name): continue try: out.append(_export_load(name)) except ExportUsage as e: out.append({'name': name, '_broken': str(e)}) return out def _export_check_stand(prof): if prof.get('base_url') != BASE_URL: raise ExportUsage( 'Выгрузка «%s» создана для стенда %s, а сервер запущен для %s. Её запускает сервер с ' 'MARFOR_BASE_URL=%s; для этого стенда выгрузку создают заново.' % (prof.get('name'), prof.get('base_url'), BASE_URL, prof.get('base_url'))) def _export_names(v, field): """Список имён из аргумента инструмента: строка — список из одного, повторы убираются.""" if v is None or v == '': return [] if isinstance(v, str): v = [v] if not isinstance(v, (list, tuple)): raise ExportUsage('%s — список имён, а пришло: %s' % (field, json.dumps(v, ensure_ascii=False)[:100])) out = [] for x in v: s = str(x).strip() if x is not None else '' if s and s not in out: out.append(s) return out # ── период: относительный → годы и месяцы на сегодня ── def _export_ym(s, field): m = re.fullmatch(r'\s*(\d{4})-(\d{1,2})\s*', str(s if s is not None else '')) if not m or not 1 <= int(m.group(2)) <= 12: raise ExportUsage('period.%s — месяц вида ГГГГ-ММ (например 2026-01), пришло «%s».' % (field, s)) return int(m.group(1)) * 12 + int(m.group(2)) - 1 def _export_ym_text(k): return '%04d-%02d' % (k // 12, k % 12 + 1) def _export_norm_period(period): """Относительный период профиля: проверка и единый вид. Без сети.""" kinds = ', '.join(EXPORT_PERIOD_KINDS) if period is None or period == '' or period == {}: return {'kind': 'all'} if isinstance(period, str): period = {'kind': period} if not isinstance(period, dict): raise ExportUsage('period — объект вида {"kind": "ytd"}; виды: %s.' % kinds) kind = str(period.get('kind') or 'all').strip() if kind in ('all', 'ytd', 'this_and_next_year'): return {'kind': kind} if kind == 'years': ys = period.get('years') if isinstance(ys, (int, str)) and not isinstance(ys, bool): ys = [ys] try: ys = sorted({int(y) for y in ys if not isinstance(y, bool)}) except (TypeError, ValueError): ys = [] if not ys or len(ys) > 30 or any(not 1900 <= y <= 2200 for y in ys): raise ExportUsage('period years ждёт список лет: {"kind": "years", "years": [2025, 2026]}.') return {'kind': 'years', 'years': ys} if kind == 'last_months': n = period.get('n') if (isinstance(n, bool) or not isinstance(n, (int, str)) or not str(n).strip().isdigit() or not 1 <= int(n) <= EXPORT_MAX_MONTHS): raise ExportUsage('period last_months ждёт n — сколько месяцев, включая текущий ' '(1…%d): {"kind": "last_months", "n": 6}.' % EXPORT_MAX_MONTHS) return {'kind': 'last_months', 'n': int(n)} if kind == 'months': a, b = _export_ym(period.get('from'), 'from'), _export_ym(period.get('to'), 'to') if a > b: raise ExportUsage('period: from (%s) позже to (%s).' % (_export_ym_text(a), _export_ym_text(b))) if b - a + 1 > EXPORT_MAX_MONTHS: raise ExportUsage('period: больше %d месяцев; длинный период задают years или all.' % EXPORT_MAX_MONTHS) return {'kind': 'months', 'from': _export_ym_text(a), 'to': _export_ym_text(b)} raise ExportUsage('Неизвестный вид периода «%s». Виды: %s.' % (kind, kinds)) def _export_year(y): if isinstance(y, bool): return None try: y = int(y) except (TypeError, ValueError): return None return y if y > 0 else None def _export_spans(period, today, years_available=None): """Период на дату today → ([(год, [месяцы] | None)], описание). Месяцы None — год целиком (без фильтра по месяцу); год None — запрос без времени: у датасета нет колонки year. В датасете План/Факта колонки даты нет, только year и month, поэтому период — это фильтры по ним, а не date_from/date_to.""" kind = period['kind'] if kind in ('all', 'years', 'this_and_next_year'): if kind == 'all': ys = sorted({y for y in map(_export_year, years_available or []) if y}) elif kind == 'years': ys = list(period['years']) else: ys = [today.year, today.year + 1] return ([(y, None) for y in ys] or [(None, None)], {'kind': kind, 'from': None, 'to': None, 'years': ys}) cur = today.year * 12 + today.month - 1 if kind == 'ytd': a, b = today.year * 12, cur elif kind == 'last_months': a, b = cur - int(period['n']) + 1, cur else: a, b = _export_ym(period['from'], 'from'), _export_ym(period['to'], 'to') by_year = {} for k in range(a, b + 1): by_year.setdefault(k // 12, []).append(k % 12 + 1) spans = [(y, None if ms == list(range(1, 13)) else ms) for y, ms in sorted(by_year.items())] return spans, {'kind': kind, 'from': _export_ym_text(a), 'to': _export_ym_text(b), 'years': [y for y, _ in spans]} def _export_period_text(res): if res.get('from'): return res['from'] if res['from'] == res['to'] else '%s … %s' % (res['from'], res['to']) if res.get('years'): return '%sгоды %s' % ('весь период, ' if res.get('kind') == 'all' else '', ', '.join(str(y) for y in res['years'])) return 'весь период одним запросом (у датасета нет колонки year)' def _export_period_brief(period): """Вид периода словами, без расчёта на сегодня (для списка и профиля).""" kind = period.get('kind') if kind == 'years': return 'годы %s' % ', '.join(str(y) for y in period.get('years') or []) if kind == 'last_months': return 'последние %s мес., включая текущий' % period.get('n') if kind == 'months': return '%s … %s' % (period.get('from'), period.get('to')) return {'all': 'весь период', 'ytd': 'с начала года по текущий месяц', 'this_and_next_year': 'этот и следующий год'}.get(kind, str(kind)) # ── формулы вычисляемых метрик: разбор без eval и счёт с NULL ── _FORMULA_TOKEN = re.compile(r'\s*(?:\[\[(.+?)\]\]|(\d+(?:\.\d*)?(?:[eE][-+]?\d+)?' r'|\.\d+(?:[eE][-+]?\d+)?)|([-+*/()]))') def _formula_parse(expr): """Формула «Метрик и правил» ([[имя]], числа, + − * /, скобки) → дерево. Ничего не исполняет: незнакомая конструкция — понятный отказ (ValueError), а не eval чужого текста.""" toks, pos, s = [], 0, str(expr) while pos < len(s): m = _FORMULA_TOKEN.match(s, pos) if not m: if s[pos:].strip(): raise ValueError('непонятное место «%s»' % s[pos:].strip()[:30]) break pos = m.end() if m.group(1) is not None: toks.append(('var', m.group(1))) elif m.group(2) is not None: toks.append(('num', float(m.group(2)))) else: toks.append(('op', m.group(3))) i = [0] def peek(): return toks[i[0]] if i[0] < len(toks) else None def take(): i[0] += 1 return toks[i[0] - 1] def expr_(): node = term() while peek() in (('op', '+'), ('op', '-')): node = ('add' if take()[1] == '+' else 'sub', node, term()) return node def term(): node = unary() while peek() in (('op', '*'), ('op', '/')): node = ('mul' if take()[1] == '*' else 'div', node, unary()) return node def unary(): if peek() in (('op', '-'), ('op', '+')): sign = take()[1] node = unary() return ('neg', node) if sign == '-' else node return atom() def atom(): t = peek() if t is None: raise ValueError('формула оборвалась') take() if t[0] in ('num', 'var'): return t if t == ('op', '('): node = expr_() if peek() != ('op', ')'): raise ValueError('не закрыта скобка') take() return node raise ValueError('лишний знак «%s»' % t[1]) node = expr_() if peek() is not None: raise ValueError('лишнее после формулы: «%s»' % peek()[1]) return node def _formula_eval(node, vals): """Значение дерева на суммах строки. NULL не становится нулём: компонент без значения → None, деление на ноль → None, переполнение (inf, nan) → None.""" kind = node[0] if kind == 'num': return node[1] if kind == 'var': v = vals.get(node[1]) return v if isinstance(v, (int, float)) and not isinstance(v, bool) else None a = _formula_eval(node[1], vals) if a is None: return None if kind == 'neg': return -a b = _formula_eval(node[2], vals) if b is None: return None try: if kind == 'add': r = a + b elif kind == 'sub': r = a - b elif kind == 'mul': r = a * b elif b == 0: return None else: r = a / b except (ArithmeticError, TypeError): return None if isinstance(r, float) and (r != r or r in (_INF, -_INF)): return None return r def _formula_vars(node, acc=None): """Имена компонентов дерева в порядке появления.""" acc = [] if acc is None else acc if node[0] == 'var': if node[1] not in acc: acc.append(node[1]) elif node[0] != 'num': for child in node[1:]: _formula_vars(child, acc) return acc def _formula_code(node, lang): """Дерево → выражение на Python ('py') или JS ('js') через add/sub/mul/div/neg, которые пропускают пустое значение дальше, а не превращают его в ноль (рецепт для дэша).""" kind = node[0] if kind == 'num': v = node[1] return str(int(v)) if v.is_integer() and abs(v) < 1e15 else repr(v) if kind == 'var': key = json.dumps(node[1], ensure_ascii=False) return ('r.get(%s)' if lang == 'py' else 'r[%s]') % key if kind == 'neg': return 'neg(%s)' % _formula_code(node[1], lang) return '%s(%s, %s)' % (kind, _formula_code(node[1], lang), _formula_code(node[2], lang)) def _export_num(v): """Значение метрики из ответа: число — как есть (nan/inf → None), None — None, строка-число (Decimal стенд отдаёт строкой) — float, прочее — как пришло.""" if v is None or isinstance(v, (bool, int)): return v if isinstance(v, float): return None if v != v or v in (_INF, -_INF) else v if isinstance(v, str): try: f = float(v) except ValueError: return v return None if f != f or f in (_INF, -_INF) else f return v # ── сеть: описание датасета, формулы, запросы данных ── def _export_metadata(sid, src): code, js = MF.call('GET', '/api/metadata/%s' % _q(sid), params={'data_source': src}) if not isinstance(js, dict) or not js.get('success'): raise ExportError('описание датасета %s (%s) не получено — %s' % (sid, src, _refusal(code, js))) return js def _export_fetch_derived(sid, meta=None): """{имя: {formula, format}} — тем же путём, что marfor_describe: ручка формул, при пустом ответе — derived_metrics из описания. Заодно кладёт формулы в _DERIVED_CACHE: так _expand_formula раскрывает вложенные по свежим формулам, а не по запомненным раньше.""" lst = [] _, dj = MF.call('GET', '/api/derived_metrics/%s' % _q(sid)) if isinstance(dj, dict) and dj.get('success'): lst = [d for d in (dj.get('derived_metrics') or []) if isinstance(d, dict)] if not lst and isinstance(meta, dict): lst = [d for d in (meta.get('derived_metrics') or []) if isinstance(d, dict)] defs = {} for d in lst: if d.get('name') and d.get('formula'): defs[str(d['name'])] = {'formula': str(d['formula']), 'format': d.get('format')} _DERIVED_CACHE[sid] = {k: v['formula'] for k, v in defs.items()} return defs def _export_trees(sid, derived, defs): """{имя: (раскрытая формула, дерево)}; вложенные раскрывает существующий _expand_formula.""" out = {} for d in derived: if d not in defs: raise ExportError('вычисляемой метрики «%s» больше нет в «Метриках и правилах» ' 'датасета: выгрузку нужно пересохранить со списком из marfor_describe.' % d) expr = _expand_formula(sid, d) try: out[d] = (expr, _formula_parse(expr)) except ValueError as e: raise ExportError('формулу «%s» выгрузка посчитать не умеет (%s): %s' % (d, e, defs[d]['formula'])) return out def _export_query(ctx, filters): """Один запрос /api/query через query(). → строки либо None, если ответ обрезан: флаг meta.truncated, отказ too_many_rows сценарной таблицы или ровно предел строк.""" ctx['requests'] += 1 js = query(ctx['sid'], ctx['metrics'], ctx['dims'], filters, data_source=ctx['src']) if not isinstance(js, dict) or js.get('_not_json'): raw = js.get('_raw') if isinstance(js, dict) else js raise ExportError('стенд ответил не JSON: %s' % str(raw)[:200]) if js.get('success') is False and js.get('too_many_rows'): return None rows = js.get('data') plan = _plan_limit_text(None, js) if plan: raise ExportError(plan) if js.get('success') is False or not isinstance(rows, list): raise ExportError('ОТКАЗ СЕРВЕРА: %s' % (js.get('message') or js.get('error') or str(js)[:300])) meta = js.get('meta') if isinstance(js.get('meta'), dict) else {} if meta.get('truncated') or len(rows) >= EXPORT_ROW_LIMIT: return None rows = [r for r in rows if isinstance(r, dict)] seen = set() for r in rows: seen.update(r) # Незнакомую метрику или срез стенд молча выбрасывает из запроса: без этой проверки в файле # оказался бы пустой столбец или итог без разреза. missing = [x for x in ctx['metrics'] + ctx['dims'] if rows and x not in seen] if missing: raise ExportError('стенд не вернул колонки %s: датасет их не знает (имена — в ' 'marfor_describe; выгрузку нужно пересохранить). Неполный файл выгрузка не ' 'пишет.' % ', '.join('«%s»' % x for x in missing)) return rows def _export_span_text(year, months): if not months: return str(year) if len(months) == 1: return '%d-%02d' % (year, months[0]) return '%d-%02d … %d-%02d' % (year, months[0], year, months[-1]) def _export_fetch(ctx, year, months): """Строки одного куска периода — года (или его месяцев); обрезан ответ — по месяцам.""" f = dict(ctx['filters']) if year is not None: f['year'] = [year] if months: f['month'] = list(months) rows = _export_query(ctx, f) if rows is not None: if not rows and year is not None: ctx['warnings'].append('%s: строк нет' % _export_span_text(year, months)) return rows if year is None: raise ExportError('ответ стенда обрезан (строк больше %d), а разбить запрос по годам нельзя: ' 'у датасета нет колонки year. Помогает меньше срезов (dimensions) или ' 'более узкие фильтры.' % EXPORT_ROW_LIMIT) out, ms = [], list(months or range(1, 13)) for m in ms: part = _export_query(ctx, dict(f, month=[m])) if part is None: raise ExportError('даже за один месяц (%d-%02d) ответ стенда обрезан: строк больше ' 'предела (%d). Помогает меньше срезов (dimensions) или более узкие ' 'фильтры — молча обрезать выгрузка не будет.' % (year, m, EXPORT_ROW_LIMIT)) out += part ctx['warnings'].append('%s: ответ за год обрезан стендом — взят по месяцам (%d запросов)' % (_export_span_text(year, months), len(ms))) return out def _export_sort_key(v): if v is None: return (0, 0.0, '') if isinstance(v, (int, float)) and not isinstance(v, bool): return (1, float(v), '') return (2, 0.0, str(v)) def _export_collect(prof): """Данные выгрузки: строки в порядке колонок, период на сегодня, счётчики запросов.""" sid, src = prof['session_id'], prof['data_source'] metrics = list(prof.get('metrics') or []) dims = list(prof.get('dimensions') or []) derived = list(prof.get('derived') or []) ctx = {'sid': sid, 'src': src, 'dims': dims, 'filters': dict(prof.get('filters') or {}), 'requests': 0, 'aux': 0, 'warnings': []} trees, formulas = {}, {} if derived: defs = _export_fetch_derived(sid) ctx['aux'] += 1 for d, (expr, tree) in _export_trees(sid, derived, defs).items(): trees[d] = tree formulas[d] = {'formula': expr, 'format': defs[d].get('format')} # Компоненты формул, которых нет среди метрик, докачиваются тем же запросом: строки ответа # совпадают с выгружаемыми один в один. В файл они не попадают. need = list(metrics) for d in derived: need += [v for v in _formula_vars(trees[d]) if v not in need] ctx['metrics'] = need years = None if prof['period'].get('kind') == 'all': years = _export_metadata(sid, src).get('available_years') or [] ctx['aux'] += 1 spans, resolved = _export_spans(prof['period'], _today(), years) raw = [] for year, months in spans: raw += _export_fetch(ctx, year, months) rows = [] for r in raw: vals = {m: _export_num(r.get(m)) for m in need} row = {c: r.get(c) for c in EXPORT_TIME_COLS} for d in dims: row[d] = r.get(d) for m in metrics: row[m] = vals[m] for d in derived: row[d] = _formula_eval(trees[d], vals) rows.append(row) order = ('year', 'month', 'quarter') + tuple(dims) rows.sort(key=lambda row: tuple(_export_sort_key(row.get(c)) for c in order)) return {'rows': rows, 'columns': list(EXPORT_TIME_COLS) + dims + metrics + derived, 'period': resolved, 'formulas': formulas, 'requests': ctx['requests'], 'aux': ctx['aux'], 'warnings': ctx['warnings']} # ── файл: CSV, JSON, JS; статус; замок ── def _export_cell(v): """Ячейка CSV: пусто — None (не «None» и не 0), числа без экспоненты, где это возможно.""" if v is None: return '' if isinstance(v, bool): return 'true' if v else 'false' if isinstance(v, int): return str(v) if isinstance(v, float): if v != v or v in (_INF, -_INF): return '' if v.is_integer() and abs(v) < 1e15: return str(int(v)) s = repr(v) return format(decimal.Decimal(s), 'f') if ('e' in s or 'E' in s) else s return str(v) def _export_csv_lines(columns, rows): buf = io.StringIO() w = csv.writer(buf, lineterminator='\n') w.writerow(columns) for r in rows: w.writerow([_export_cell(r.get(c)) for c in columns]) return buf.getvalue() def _export_render(prof, res, generated_at): cols, rows = res['columns'], res['rows'] if prof['format'] == 'csv': return _export_csv_lines(cols, rows).encode('utf-8') meta = {'name': prof['name'], 'dataset': prof['session_id'], 'data_source': prof['data_source'], 'base_url': prof['base_url'], 'period_resolved': res['period'], 'generated_at': generated_at, 'rows': len(rows), 'columns': cols} if res['formulas']: # Дэшу, который складывает строки (итоги по срезам), нужна сама формула: отношение после # сложения считается от сумм компонентов заново, а не усреднением готовых значений. meta['derived'] = res['formulas'] text = json.dumps({'meta': meta, 'rows': rows}, ensure_ascii=False, allow_nan=False) if prof['format'] == 'json': return (text + '\n').encode('utf-8') # js — для HTML-дэша, открытого с диска: fetch по file:// браузер не даёт, а <script src> # грузится. U+2028/2029 старые движки JS считают концом строки — экранируем. text = text.replace('\u2028', '\\u2028').replace('\u2029', '\\u2029') return ('window.MARFOR_DATA = window.MARFOR_DATA || {};\nwindow.MARFOR_DATA[%s] = %s;\n' % (json.dumps(prof['name']), text)).encode('utf-8') def _export_status_write(out, ok, error=None, rows=None, at=None): """<файл>.status.json: по нему дэш показывает «данные от …» и последнюю ошибку.""" path = _export_status_path(out) prev = _read_json(path) now = at or _now_iso() st = {'ok': bool(ok), 'last_success_at': now if ok else prev.get('last_success_at'), 'last_attempt_at': now, 'rows': rows if ok else prev.get('rows'), 'error': None if ok else error} _export_atomic_write(path, (json.dumps(st, ensure_ascii=False, indent=1) + '\n').encode('utf-8'), keep_mode=True) def _export_lock_stale(info, path): try: age = time.time() - float(info.get('t')) except (TypeError, ValueError): try: age = time.time() - os.path.getmtime(path) except OSError: return True if age > EXPORT_LOCK_STALE: return True pid = info.get('pid') # На Windows os.kill(pid, 0) — не проверка, а TerminateProcess: там только по возрасту. if os.name != 'nt' and isinstance(pid, int) and not isinstance(pid, bool) and pid > 0: try: os.kill(pid, 0) except ProcessLookupError: return True except OSError: return False return False def _export_lock_take(name): """Замок «выгрузка идёт»: второй запуск той же выгрузки (планировщик поверх ручного, cron поверх долгого прогона) не начинается и не грузит стенд теми же запросами повторно.""" path = os.path.join(EXPORT_DIR, name + '.lock') try: _export_private_dir(EXPORT_DIR) for _ in range(2): try: fd = os.open(path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) except FileExistsError: info = _read_json(path) if _export_lock_stale(info, path): _unlink(path) continue raise ExportError('выгрузка «%s» уже идёт (процесс %s, начата %s): второй запуск не ' 'начат, чтобы не грузить стенд теми же запросами. Следующий запуск ' 'возможен после конца текущего; если процесса нет, замок %s можно ' 'удалить.' % (name, info.get('pid', '?'), info.get('started_at', '?'), path)) with os.fdopen(fd, 'w', encoding='utf-8') as f: json.dump({'pid': os.getpid(), 'started_at': _now_iso(), 't': time.time()}, f) return path except OSError as e: raise ExportError('замок выгрузки %s не создан: %s' % (path, e)) raise ExportError('замок выгрузки %s занят и не освобождается' % path) def _export_error_text(e): if isinstance(e, (ExportError, RuntimeError)): return str(e) if isinstance(e, PermissionError): return ('нет прав на запись (%s) — или файл открыт другой программой, например Excel ' 'держит CSV' % e) if isinstance(e, OSError): return 'файл не записан: %s' % e return '%s: %s' % (type(e).__name__, e) def _export_run(prof, status='always'): """Запуск выгрузки. status: always — статус пишется при любом исходе; success — только при успехе: пробный запуск ещё не сохранённого профиля не портит статус действующей выгрузки. Неудача → ExportError, прежний файл не тронут.""" _export_check_stand(prof) out, t0 = prof['out'], time.time() lock = _export_lock_take(prof['name']) try: try: res = _export_collect(prof) gen = _now_iso() if os.path.dirname(out) == EXPORT_OUT_DIR: _export_private_dir(EXPORT_OUT_DIR) _export_atomic_write(out, _export_render(prof, res, gen), keep_mode=True) except Exception as e: msg = _export_error_text(e) if status == 'always': try: _export_status_write(out, False, msg) except OSError as se: msg += ' (статус тоже не записан: %s)' % se raise ExportError(msg) if status in ('always', 'success'): try: _export_status_write(out, True, rows=len(res['rows']), at=gen) except OSError as e: raise ExportError('данные записаны в %s, а статус — нет: %s' % (out, e)) finally: _unlink(lock) res.update(out=out, generated_at=gen, seconds=round(time.time() - t0, 1)) return res # ── проверка профиля по описанию датасета ── def _export_norm_filters(filters): if filters in (None, '', {}): return {} if not isinstance(filters, dict): raise ExportUsage('filters — объект {"срез": ["значение", …]}, как у marfor_query.') out = {} for k, v in filters.items(): k = str(k) if k in _EXPORT_TIME_KEYS: raise ExportUsage('Фильтр «%s» — это время: период задаёт period (ytd, last_months, years, ' 'months …), а не filters.' % k) if isinstance(v, dict) and 'values' not in v: raise ExportUsage('Фильтр «%s»: нужен список значений или {"values": […], "mode": ' '"include" | "exclude"}.' % k) norm = _query_filters({k: v}).get(k) if not norm or not norm['values']: raise ExportUsage('Фильтр «%s» пустой: стенд молча пропустил бы его и посчитал по всем ' 'значениям. Фильтр без значений не принимается.' % k) if norm['mode'] not in ('include', 'exclude'): raise ExportUsage('Фильтр «%s»: mode — include или exclude, пришло «%s».' % (k, norm['mode'])) out[k] = norm return out def _export_filters_text(filters): return '; '.join('%s %s %s' % (k, 'кроме' if f.get('mode') == 'exclude' else '=', ', '.join(str(x) for x in f.get('values') or [])) for k, f in (filters or {}).items()) def _export_unknown(what, name, cands, labels=None): """Строка отказа «нет такого имени» с похожими: ассистент поправится по ней сам, без догадок.""" low = name.lower() sim = difflib.get_close_matches(name, cands, n=5, cutoff=0.6) for c in cands: if len(sim) >= 8: break lab = (labels or {}).get(c, '').lower() if c not in sim and low and (low in c.lower() or (len(c) >= 4 and c.lower() in low) or (lab and (low == lab or low in lab))): sim.append(c) hint = ('похожие: %s' % ', '.join(sim)) if sim else ( 'похожих нет; всего %d — полный список даёт marfor_describe' % len(cands)) return 'нет %s «%s» — %s' % (what, name, hint) def _export_validate(sid, src, meta, defs, metrics, dims, derived, filters): """Имена профиля против описания датасета. Отказ — сразу списком всех расхождений.""" mrows = [m for m in (meta.get('metrics') or []) if isinstance(m, dict) and m.get('name')] mnames = [str(m['name']) for m in mrows] labels = {str(m['name']): str(m.get('display_name') or '') for m in mrows} dnames = [str(d['name']) for d in (meta.get('dimensions') or []) if isinstance(d, dict) and d.get('name')] known = set(mnames) problems = [] for m in metrics: if m in known or (src == 'scenario' and m.endswith('_modeling') and m[:-len('_modeling')] in known): continue hint = ' (это вычисляемая метрика — её место в derived)' if m in defs else '' problems.append(_export_unknown('метрики', m, mnames, labels) + hint) for d in dims: if d in _EXPORT_TIME_KEYS: problems.append('«%s» — это время: year, quarter и month в выгрузке есть всегда, а ' 'период задаёт period' % d) elif d not in dnames: problems.append(_export_unknown('среза', d, dnames)) for k in filters: if k not in dnames: problems.append(_export_unknown('среза для фильтра', k, dnames)) for d in derived: if d not in defs: hint = ' (это обычная метрика — её место в metrics)' if d in known else '' problems.append(_export_unknown('вычисляемой метрики', d, list(defs)) + hint) continue try: _formula_parse(_expand_formula(sid, d)) except ValueError as e: problems.append('формулу «%s» выгрузка посчитать не умеет (%s): %s' % (d, e, defs[d]['formula'])) cols = (list(EXPORT_TIME_COLS) + [d for d in dims if d not in _EXPORT_TIME_KEYS] + metrics + derived) dup = sorted({c for c in cols if cols.count(c) > 1}) if dup: problems.append('одно имя в двух ролях: %s — колонки файла должны различаться' % ', '.join(dup)) if problems: raise ExportUsage('Профиль не сохранён:\n • ' + '\n • '.join(problems)) def _export_under(path, root): try: rel = os.path.relpath(os.path.abspath(path), os.path.abspath(root)) except ValueError: # Windows: другой диск return False return rel == os.curdir or not (rel == os.pardir or rel.startswith(os.pardir + os.sep)) def _export_norm_out(out_arg, name, fmt, prev): """Путь файла данных: абсолютный; не внутри служебного ~/.marfor_mcp (кроме out/); расширение не спорит с форматом. Без нового out прежний свой путь сохраняется.""" default = _export_default_out(name, fmt) if out_arg not in (None, ''): p = os.path.expanduser(str(out_arg).strip()) if not os.path.isabs(p): raise ExportUsage('out — нужен АБСОЛЮТНЫЙ путь к файлу: планировщик запускает выгрузку ' 'из другого каталога. Например %s; пришло «%s».' % (default, out_arg)) p = os.path.normpath(p) elif prev and prev.get('out') and prev['out'] != _export_default_out(name, prev.get('format') or fmt): p = prev['out'] # свой путь прежнего профиля: дэш читает именно его else: return default if _export_under(p, CONFIG_DIR) and not _export_under(p, EXPORT_OUT_DIR): raise ExportUsage('out внутри %s — там профили, токен и служебные файлы сервера. Место для ' 'файла данных — %s или свой каталог.' % (CONFIG_DIR, EXPORT_OUT_DIR)) ext = os.path.splitext(p)[1].lower().lstrip('.') if ext in EXPORT_FORMATS and ext != fmt: raise ExportUsage('out %s оканчивается на .%s, а формат выгрузки — %s: расширение и format ' 'должны совпадать.' % (p, ext, fmt)) if os.path.isdir(p): raise ExportUsage('out — путь к файлу, а %s — каталог.' % p) return p def _export_check_out_free(name, out, prev): """В один файл пишет одна выгрузка; чужой файл (не выгрузку) выгрузка не перезаписывает.""" key = os.path.normcase(os.path.abspath(out)) for other in _export_profiles(): if (other.get('name') != name and other.get('out') and os.path.normcase(os.path.abspath(other['out'])) == key): raise ExportUsage('В файл %s уже пишет выгрузка «%s»: две выгрузки в одном файле затирали ' 'бы друг друга. Нужен другой out.' % (out, other['name'])) if (os.path.exists(out) and not (prev and prev.get('out') == out) and not os.path.exists(_export_status_path(out))): raise ExportUsage('Файл %s уже есть и не похож на выгрузку (рядом нет %s): выгрузка его не ' 'перезапишет. Нужен другой out; этот файл выгрузка не трогает.' % (out, os.path.basename(_export_status_path(out)))) # ── тексты инструментов ── def _export_result_lines(prof, res): out = prof['out'] lines = ['Данные : строк %d → %s (%s); статус — %s' % (len(res['rows']), out, prof['format'], _export_status_path(out)), 'Период : %s (на %s)' % (_export_period_text(res['period']), _today().isoformat()), 'Запросов: к данным %d%s; %s с' % (res['requests'], (', служебных %d (формулы, описание)' % res['aux']) if res['aux'] else '', res['seconds']), 'Первые строки (как CSV):'] lines += [' ' + ln for ln in _export_csv_lines(res['columns'], res['rows'][:3]).splitlines()] if any(f.get('format') == 'percent' for f in res['formulas'].values()): lines.append('Вычисляемые с форматом percent лежат в файле долями: 0.05 = 5 %.') lines += ['⚠️ %s' % w for w in res['warnings']] return lines def _export_save_text(args): name = _export_check_name(args.get('name')) sid = str(args.get('session_id') or '').strip() src = str(args.get('data_source') or '').strip() if not sid or not src: raise ExportUsage('Нужны session_id и data_source — оба из marfor_datasets.') metrics = _export_names(args.get('metrics'), 'metrics') dims = _export_names(args.get('dimensions'), 'dimensions') derived = _export_names(args.get('derived'), 'derived') if not metrics and not derived: raise ExportUsage('Выгружать нечего: нет ни metrics (имена колонок из marfor_describe), ' 'ни derived (вычисляемые оттуда же).') fmt = str(args.get('format') or 'csv').strip().lower() if fmt not in EXPORT_FORMATS: raise ExportUsage('format — csv, json или js; пришло «%s».' % args.get('format')) period = _export_norm_period(args.get('period')) filters = _export_norm_filters(args.get('filters')) try: prev = _export_load(name) if os.path.exists(_export_path(name)) else None except ExportUsage: prev = None # повреждённый профиль заменяется новым if prev and prev.get('base_url') != BASE_URL: raise ExportUsage('Выгрузка «%s» уже есть и создана для стенда %s, а сервер запущен для %s. ' 'Нужно другое имя; прежнюю удаляет marfor_export_delete.' % (name, prev.get('base_url'), BASE_URL)) out = _export_norm_out(args.get('out'), name, fmt, prev) _export_check_out_free(name, out, prev) note = args.get('note') if note is None and prev: note = prev.get('note') try: meta = _export_metadata(sid, src) defs = _export_fetch_derived(sid, meta) except (ExportError, RuntimeError) as e: return 'ПРОФИЛЬ НЕ СОХРАНЁН: имена не с чем сверить — %s' % e _export_validate(sid, src, meta, defs, metrics, dims, derived, filters) now = _now_iso() prof = {'version': EXPORT_VERSION, 'name': name, 'base_url': BASE_URL, 'session_id': sid, 'data_source': src, 'metrics': metrics, 'dimensions': dims, 'filters': filters, 'period': period, 'derived': derived, 'format': fmt, 'out': out, 'note': str(note) if note else '', 'created_at': (prev or {}).get('created_at') or now, 'updated_at': now} res = None if _flag(args.get('test_run'), True): try: res = _export_run(prof, status='success') except ExportError as e: return ('ПРОФИЛЬ НЕ СОХРАНЁН: пробный запуск не удался.\nПричина: %s\nФайл %s не тронут. ' 'Обычно помогает меньше срезов, более узкие фильтры или короче период; ' 'сохранение без пробного запуска — test_run=false.' % (e, out)) _export_private_dir(EXPORT_DIR) _export_atomic_write(_export_path(name), (json.dumps(prof, ensure_ascii=False, indent=1) + '\n').encode('utf-8')) lines = ['%s: «%s» → %s' % ('ПРОФИЛЬ ОБНОВЛЁН' if prev else 'ПРОФИЛЬ СОХРАНЁН', name, _export_path(name)), 'Датасет : %s (%s), стенд %s' % (sid, src, BASE_URL), 'Метрики : %s' % (', '.join(metrics) or '—'), 'Срезы : %s' % (', '.join(dims) or '— (только время)'), 'Фильтры : %s' % (_export_filters_text(filters) or '—')] if derived: lines.append('Вычисляемые: %s — по формуле от сумм строки' % ', '.join(derived)) lines += ['Период : %s' % _export_period_brief(period), 'Файл : %s (%s)' % (out, fmt)] if res is None: lines.append('Пробного запуска не было (test_run=false): файл появится при первом запуске — ' 'marfor_export_run(name="%s").' % name) else: lines += ['', 'ПРОБНЫЙ ЗАПУСК прошёл:'] + _export_result_lines(prof, res) lines += ['', 'Дальше: обновление по расписанию — marfor_export_schedule(name="%s", ' 'every="1h"); запрос к API для самого дэша — marfor_export_recipe(name="%s").' % (name, name)] return '\n'.join(lines) def _export_run_text(prof): try: res = _export_run(prof) except ExportError as e: return ('ДАННЫЕ НЕ ОБНОВЛЕНЫ: выгрузка «%s».\nПричина: %s\nПрежний файл не тронут: %s\n' 'Статус: %s' % (prof['name'], e, prof['out'], _export_status_path(prof['out']))) return '\n'.join(['ДАННЫЕ ОБНОВЛЕНЫ: выгрузка «%s», стенд %s' % (prof['name'], BASE_URL)] + _export_result_lines(prof, res)) def _export_status_text(out): st = _read_json(_export_status_path(out)) if out else {} if not st: return 'запусков ещё не было' if st.get('ok'): return 'последний запуск: OK, %s, строк %s' % (st.get('last_attempt_at'), st.get('rows')) tail = (('; в файле данные от %s' % st['last_success_at']) if st.get('last_success_at') else '; удачных запусков не было') return 'последний запуск: ОШИБКА, %s — %s%s' % (st.get('last_attempt_at'), st.get('error'), tail) def _export_list_text(): profs = _export_profiles() if not profs: return 'Выгрузок на этом компьютере нет (%s). Создать — marfor_export_save.' % EXPORT_DIR lines = ['Выгрузок на этом компьютере: %d (%s); сервер запущен для стенда %s' % (len(profs), EXPORT_DIR, BASE_URL), ''] for p in profs: if p.get('_broken'): lines.append(' %s — профиль повреждён: %s' % (p['name'], p['_broken'])) continue lines.append(' %s — %s (%s), %s, %s → %s' % (p['name'], p.get('session_id'), p.get('data_source'), _export_period_brief(p.get('period') or {}), p.get('format'), p.get('out'))) if p.get('note'): lines.append(' %s' % p['note']) if p.get('base_url') != BASE_URL: lines.append(' стенд %s — с сервером для %s не запускается' % (p.get('base_url'), BASE_URL)) lines.append(' ' + _export_status_text(p.get('out'))) return '\n'.join(lines) def _export_unschedule_cmds(name, platform=None): platform = platform or sys.platform label = 'pro.marfor.export.%s' % name if platform == 'darwin': plist = os.path.expanduser('~/Library/LaunchAgents/%s.plist' % label) return ['launchctl bootout gui/$(id -u)/%s; rm -f %s' % (label, shlex.quote(plist))] if platform.startswith('win'): return ['schtasks /Delete /TN "MARFOR export %s" /F' % name] return ["crontab -l | grep -v '# marfor-export %s$' | crontab -" % name] def _export_delete_text(name, delete_output): _export_check_name(name) path = _export_path(name) try: prof = _export_load(name) except ExportUsage: if not os.path.exists(path): raise prof = {} # повреждённый профиль удаляется как есть try: os.unlink(path) except OSError as e: raise ExportError('профиль %s не удалён: %s' % (path, e)) removed, kept = [path], [] out = prof.get('out') extra = [os.path.join(EXPORT_DIR, name + '.log')] if delete_output and out: extra += [out, _export_status_path(out)] for p in extra: if os.path.exists(p): try: os.unlink(p) removed.append(p) except OSError as e: kept.append('%s (%s)' % (p, e)) lines = ['Выгрузка «%s» удалена с этого компьютера; в MARFOR ничего не менялось.' % name, 'Удалено: %s' % ', '.join(removed)] if kept: lines.append('⚠️ Не удалось удалить: %s' % ', '.join(kept)) if out and not delete_output: lines.append('Файл данных оставлен: %s (удалить вместе с ним — delete_output=true).' % out) lines.append('Расписание выгрузки, если оно настроено, остаётся в планировщике и будет ' 'запускать несуществующую выгрузку; снимает его команда:') lines += [' ' + x for x in _export_unschedule_cmds(name)] return '\n'.join(lines) # ── расписание: готовая настройка планировщика ОС, сам сервер ничего не ставит ── # Шаги установки расписания — отдельное действие с согласия пользователя; ответ это сообщает # фактом (до 0.7.1 — «покажите шаги пользователю и выполняйте только с его согласия»). _SCHED_CONSENT = ('Сервер ничего не устанавливает сам: ниже — шаги установки, это отдельное ' 'действие с согласия пользователя.') def _export_sched_env(): """Окружение фонового запуска: стенд и данные входа паролем — те, что заданы у этого процесса. MARFOR_PASSWORD и токен не вписываются НИКОГДА.""" env = [] if BASE_URL_EXPLICIT: env.append(('MARFOR_BASE_URL', BASE_URL)) for key in ('MARFOR_EMAIL', 'MARFOR_KEYCHAIN_SERVICE', 'MARFOR_HTTP_TIMEOUT'): val = (os.environ.get(key) or '').strip() if val: env.append((key, val)) return env def _export_xml(s): return str(s).replace('&', '&').replace('<', '<').replace('>', '>') def _export_sched_macos(name, every, secs, exe, server, env, log_path): label = 'pro.marfor.export.%s' % name plist_path = os.path.expanduser('~/Library/LaunchAgents/%s.plist' % label) args = ''.join('\n <string>%s</string>' % _export_xml(a) for a in (exe, server, '--export', name)) env_xml = ''.join('\n <key>%s</key><string>%s</string>' % (_export_xml(k), _export_xml(v)) for k, v in env) plist = ('<?xml version="1.0" encoding="UTF-8"?>\n' '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" ' '"http://www.apple.com/DTDs/PropertyList-1.0.dtd">\n' '<plist version="1.0">\n<dict>\n' ' <key>Label</key><string>%s</string>\n' ' <key>ProgramArguments</key>\n <array>%s\n </array>\n' '%s' ' <key>StartInterval</key><integer>%d</integer>\n' ' <key>RunAtLoad</key><true/>\n' ' <key>StandardOutPath</key><string>%s</string>\n' ' <key>StandardErrorPath</key><string>%s</string>\n' '</dict>\n</plist>' % (label, args, (' <key>EnvironmentVariables</key>\n <dict>%s\n </dict>\n' % env_xml) if env else '', secs, _export_xml(log_path), _export_xml(log_path))) q = shlex.quote(plist_path) return ['РАСПИСАНИЕ выгрузки «%s»: каждые %s — macOS, агент launchd пользователя.' % (name, every), _SCHED_CONSENT, '', '1) Создать %s:' % plist_path, "mkdir -p ~/Library/LaunchAgents && cat > %s <<'PLIST'" % q, plist, 'PLIST', '', '2) Включить (первый запуск — сразу, дальше каждые %s):' % every, ' launchctl bootstrap gui/$(id -u) %s' % q, ' Проверить: launchctl print gui/$(id -u)/%s; журнал запусков — %s' % (label, log_path), '3) Снять: %s' % _export_unschedule_cmds(name, 'darwin')[0], ' Сменить частоту: снять, поправить StartInterval (секунды) и включить снова.'] def _export_cron_expr(name, every): minute = sum(ord(c) for c in name) % 60 # своя минута у каждой выгрузки, не все разом в :00 return {'15m': '*/15 * * * *', '30m': '*/30 * * * *', '1h': '%d * * * *' % minute, '3h': '%d */3 * * *' % minute, '6h': '%d */6 * * *' % minute, '12h': '%d */12 * * *' % minute, '1d': '%d 7 * * *' % minute}[every] def _export_sched_cron(name, every, exe, server, env, log_path): pre = ''.join('%s=%s ' % (k, shlex.quote(v)) for k, v in env) line = ('%s %s%s %s --export %s >> %s 2>&1 # marfor-export %s' % (_export_cron_expr(name, every), pre, _shq(exe, path=True), _shq(server, path=True), name, shlex.quote(log_path), name)) line = line.replace('%', '\\%') # cron считает «%» концом команды return ['РАСПИСАНИЕ выгрузки «%s»: каждые %s — cron.' % (name, every), _SCHED_CONSENT, '', 'Строка crontab:', line, '', 'Добавить: ( crontab -l 2>/dev/null; echo %s ) | crontab -' % shlex.quote(line), 'Проверить: crontab -l; журнал запусков — %s' % log_path, 'Снять: %s' % _export_unschedule_cmds(name, 'linux')[0]] def _export_sched_windows(name, every, exe, server, env): sc = {'15m': '/SC MINUTE /MO 15', '30m': '/SC MINUTE /MO 30', '1h': '/SC HOURLY /MO 1', '3h': '/SC HOURLY /MO 3', '6h': '/SC HOURLY /MO 6', '12h': '/SC HOURLY /MO 12', '1d': '/SC DAILY /ST 07:00'}[every] inner = '%s %s --export %s' % (_shq(exe, path=True), _shq(server, path=True), name) task = 'MARFOR export %s' % name lines = ['РАСПИСАНИЕ выгрузки «%s»: каждые %s — Планировщик заданий Windows.' % (name, every), _SCHED_CONSENT + ' Команды — для cmd: в PowerShell кавычки внутри /TR разбираются ' 'иначе.', ''] if env: lines.append('1) Переменные окружения пользователя (своего окружения у задания нет; setx ' 'запоминает их насовсем):') lines += [' setx %s "%s"' % (k, v.replace('"', '')) for k, v in env] lines.append('2) Задание:') lines += [' schtasks /Create /TN "%s" /TR "%s" %s /F' % (task, inner.replace('"', '\\"'), sc), ' Запустить сейчас: schtasks /Run /TN "%s"; проверить: schtasks /Query /TN "%s" ' '/V /FO LIST' % (task, task), ' Снять: %s' % _export_unschedule_cmds(name, 'win32')[0], ' Без окна консоли при запуске — pythonw.exe вместо python.exe в /TR.'] return lines def _export_sched_notes(prof, platform, server): host = _base_host()[1] notes = ['Пароль и токен в настройку не вписаны. Итог каждого запуска — в %s (ok, ' 'last_success_at, last_attempt_at, rows, error): по нему дэш покажет «данные от …».' % _export_status_path(prof['out'])] if not _is_public_stand(): notes.append('⚠️ Если стенд %s доступен только из внутренней сети (VPN), без неё запуск ' 'провалится: прежний файл останется на месте, ошибка будет в статусе.' % host) tok, src = _find_token() if src == 'env': notes.append('⚠️ Токен сейчас задан переменной MARFOR_TOKEN — в настройку он не вписан. ' 'Фоновому запуску нужен токен в файле %s; его сохраняет команда %s' % (TOKEN_FILE, _self_cmd('--set-token', '--stdin'))) elif not tok and EMAIL: if platform == 'darwin': notes.append('⚠️ Вход паролем из Keychain в фоне работает только у launchd-агента ' 'пользователя (как здесь), не у cron: Keychain отвечает лишь процессам его ' 'сеанса. Надёжный путь для фона — токен, если стенд поддерживает вход ' 'токенами (%s).' % _self_cmd('--login')) else: notes.append('⚠️ Keychain есть только на macOS: здесь фоновому запуску нужен токен ' '(%s; стенд должен поддерживать вход токенами). Пароль в настройку не ' 'вписывается.' % _self_cmd('--login')) elif not tok: notes.append('⚠️ Вход не настроен: нет ни токена, ни MARFOR_EMAIL. Без входа (%s) до ' 'установки расписания каждый запуск закончится ошибкой входа.' % _self_cmd('--login')) if platform == 'darwin': home = os.path.expanduser('~') risky = [p for p in (prof['out'], server) if any(_export_under(p, os.path.join(home, d)) for d in _TCC_DIRS)] if risky: notes.append('⚠️ %s — в папке, куда macOS не пускает фоновые программы без «Полного ' 'доступа к диску» (Рабочий стол, Документы, Загрузки, iCloud Drive): ' 'launchd-агент упрётся в отказ. Вне этих папок — например, в %s — ' 'отказа нет.' % (', '.join(risky), EXPORT_OUT_DIR)) notes.append('Сама команда запуска (её можно выполнить и вручную, с тем же окружением): %s' % ' '.join([_shq(sys.executable or 'python3', path=True), _shq(server, path=True), '--export', prof['name']])) return notes def _export_schedule_text(name, every, platform=None): prof = _export_load(name) _export_check_stand(prof) every = str(every or '').strip() secs = dict(EXPORT_EVERY).get(every) if not secs: raise ExportUsage('every — одно из: %s; пришло «%s».' % (', '.join(k for k, _ in EXPORT_EVERY), every)) platform = platform or sys.platform exe, server = sys.executable or 'python3', os.path.abspath(__file__) env = _export_sched_env() log_path = os.path.join(EXPORT_DIR, name + '.log') if platform == 'darwin': body = _export_sched_macos(name, every, secs, exe, server, env, log_path) elif platform.startswith('win'): body = _export_sched_windows(name, every, exe, server, env) else: body = _export_sched_cron(name, every, exe, server, env, log_path) return '\n'.join(body + [''] + _export_sched_notes(prof, platform, server)) # ── рецепт: как дэшу ходить в API самому ── def _export_recipe_py(url, body, periods, trees): code = ['import json', 'import os', 'import urllib.request', '', 'URL = %r' % url, 'BODY = %r' % (body,), 'PERIODS = %r # по запросу на год' % (periods,), '', '', 'def fetch(period):', ' body = dict(BODY, filters=dict(BODY["filters"], **period))', ' req = urllib.request.Request(URL, data=json.dumps(body).encode("utf-8"), ' 'method="POST", headers={', ' "Authorization": "Bearer " + os.environ["MARFOR_TOKEN"], ' '"Content-Type": "application/json"})', ' with urllib.request.urlopen(req, timeout=180) as resp:', ' js = json.load(resp)', ' if js.get("success") is False or (js.get("meta") or {}).get("truncated"):', ' raise RuntimeError(js.get("message") or "ответ обрезан: нужен запрос по ' 'месяцам или меньше срезов")', ' return js["data"]', '', '', 'rows = [row for period in PERIODS for row in fetch(period)]'] if trees: code += ['', '# Вычисляемые — в каждой строке от СУММ компонентов; None — значения нет (не 0).', 'def _ok(*xs): return all(isinstance(x, (int, float)) for x in xs)', 'def add(a, b): return a + b if _ok(a, b) else None', 'def sub(a, b): return a - b if _ok(a, b) else None', 'def mul(a, b): return a * b if _ok(a, b) else None', 'def div(a, b): return a / b if _ok(a, b) and b != 0 else None', 'def neg(a): return -a if _ok(a) else None', '', '', 'for r in rows:'] code += [' r[%s] = %s' % (json.dumps(d, ensure_ascii=False), _formula_code(t, 'py')) for d, t in trees.items()] code.append('print(len(rows), "строк")') return '\n'.join(code) def _export_recipe_js(url, body, periods, trees): code = ['const ENDPOINT = %s;' % json.dumps(url), 'const BODY = %s;' % json.dumps(body, ensure_ascii=False), 'const PERIODS = %s; // по запросу на год' % json.dumps(periods, ensure_ascii=False), '', 'async function fetchRows(period) {', ' const res = await fetch(ENDPOINT, {', ' method: "POST",', ' headers: {"Authorization": "Bearer " + process.env.MARFOR_TOKEN, ' '"Content-Type": "application/json"},', ' body: JSON.stringify({...BODY, filters: {...BODY.filters, ...period}}),', ' });', ' const js = await res.json();', ' if (js.success === false || (js.meta && js.meta.truncated)) {', ' throw new Error(js.message || "ответ обрезан: нужен запрос по месяцам или ' 'меньше срезов");', ' }', ' return js.data;', '}', '', '(async () => {', ' const rows = [];', ' for (const period of PERIODS) for (const row of await fetchRows(period)) rows.push(row);'] if trees: code += [' // Вычисляемые — в каждой строке от СУММ компонентов; null — значения нет (не 0).', ' const ok = (...xs) => xs.every((x) => typeof x === "number");', ' const add = (a, b) => (ok(a, b) ? a + b : null);', ' const sub = (a, b) => (ok(a, b) ? a - b : null);', ' const mul = (a, b) => (ok(a, b) ? a * b : null);', ' const div = (a, b) => (ok(a, b) && b !== 0 ? a / b : null);', ' const neg = (a) => (ok(a) ? -a : null);', ' for (const r of rows) {'] code += [' r[%s] = %s;' % (json.dumps(d, ensure_ascii=False), _formula_code(t, 'js')) for d, t in trees.items()] code.append(' }') code += [' console.log(rows.length, "строк");', '})();'] return '\n'.join(code) def _export_recipe_text(name): prof = _export_load(name) _export_check_stand(prof) sid, src = prof['session_id'], prof['data_source'] metrics = list(prof.get('metrics') or []) dims = list(prof.get('dimensions') or []) derived = list(prof.get('derived') or []) trees = {} if derived: defs = _export_fetch_derived(sid) trees = {d: tree for d, (expr, tree) in _export_trees(sid, derived, defs).items()} need = list(metrics) for d in derived: need += [v for v in _formula_vars(trees[d]) if v not in need] years = None if prof['period'].get('kind') == 'all': years = _export_metadata(sid, src).get('available_years') or [] spans, resolved = _export_spans(prof['period'], _today(), years) periods = [] for year, months in spans: f = {} if year is not None: f['year'] = {'values': [year], 'mode': 'include'} if months: f['month'] = {'values': list(months), 'mode': 'include'} periods.append(f) body = {'metrics': need, 'dimensions': dims, 'filters': prof.get('filters') or {}, 'data_source': src} url = '%s/api/query/%s' % (BASE_URL, _q(sid)) first = dict(body, filters=dict(body['filters'], **periods[0])) lines = ['РЕЦЕПТ: как дэшу самому брать данные выгрузки «%s» из API MARFOR (период на %s: %s).' % (name, _today().isoformat(), _export_period_text(resolved)), 'Запрос: POST %s с JSON-телом ниже; время (year, quarter, month) стенд добавляет в ' 'разрез сам, по запросу на год (PERIODS).' % url, 'Вход: заголовок Authorization: Bearer $MARFOR_TOKEN. $MARFOR_TOKEN — заглушка: настоящий ' 'токен живёт в переменной окружения, не в коде и не в чате. На стенде должен быть ' 'включён вход токенами; токен создаётся на %s/account, раздел «Подключение Claude ' '(MCP)».' % BASE_URL] if _password_mode(): lines.append('⚠️ Этот сервер вошёл на стенд паролем, не токеном: если входа токенами на ' 'стенде нет, рецепт не заработает; остаётся файл выгрузки (marfor_export_run, ' '--export).') if not _is_public_stand(): lines.append('⚠️ Если стенд %s доступен только из внутренней сети (VPN), облачный сервис ' 'дашбордов до него не достанет: запрос должен идти с машины в этой сети.' % _base_host()[1]) lines += ['Ответ: {"success": true, "data": [строки], "meta": {"truncated": false}}. ' 'meta.truncated=true — строки обрезаны стендом (предел 200 000): такой ответ ' 'неполный, нужен запрос по месяцам или меньше срезов.', 'Пустую сумму метрики стенд отдаёт нулём: «нет данных» и 0 в ответе не различить.'] if derived: lines.append('Вычисляемые (%s): их компоненты уже добавлены в metrics; формула считается в ' 'каждой строке ОТ СУММ. Итог по срезам — тоже: сначала сумма компонентов, потом ' 'формула; сумма или среднее готовых отношений дают неверный итог.' % ', '.join(derived)) lines += ['', 'curl (bash, zsh):', 'curl -sS -X POST %s \\' % shlex.quote(url), ' -H "Authorization: Bearer $MARFOR_TOKEN" -H \'Content-Type: application/json\' \\', ' --data %s' % shlex.quote(json.dumps(first, ensure_ascii=False)), '', 'Python 3 (только стандартная библиотека):', _export_recipe_py(url, body, periods, trees), '', 'JS (fetch: Node.js 18+ или серверная часть дэша):', _export_recipe_js(url, body, periods, trees), '', '⚠️ Из HTML-страницы в браузере такой запрос к стенду не пройдёт: стенд не разрешает ' 'запросы с чужих страниц (CORS), а токен в странице увидит каждый, кто её откроет. ' 'Дэшу в браузере — файл выгрузки в формате js (marfor_export_save, format="js").'] return '\n'.join(lines) def _export_tool(name, args): """Вход инструментов marfor_export_*: отказ и неудача — текстом, как у остальных.""" try: if name == 'marfor_export_save': return _export_save_text(args) if name == 'marfor_export_run': return _export_run_text(_export_load(args.get('name'))) if name == 'marfor_export_list': return _export_list_text() if name == 'marfor_export_delete': return _export_delete_text(args.get('name'), _flag(args.get('delete_output'), False)) if name == 'marfor_export_schedule': return _export_schedule_text(args.get('name'), args.get('every')) if name == 'marfor_export_recipe': return _export_recipe_text(args.get('name')) except ExportUsage as e: return 'ОТКАЗ: %s' % e except ExportError as e: return 'НЕ ВЫПОЛНЕНО: %s' % e return 'Неизвестный инструмент: %s' % name _EXPORT_NAME_PROP = {'type': 'string', 'description': 'Export name: lowercase Latin letters, digits, "_" and "-", up ' 'to 40 characters'} # Разметка выгрузок (решение 0.7.0, обоснование — DOCS.md, «Разметка инструментов»). MARFOR они # только читают, пишут файлы ЭТОГО компьютера. readOnlyHint=false у тех, что пишут файлы: save # (профиль и файл данных), run (файл данных), delete (удаляет профиль и, по желанию, файл). # destructiveHint=true — save (заменяет профиль с тем же именем) и delete; у run — false: он # переписывает только СВОЙ файл данных, атомарно и свежими данными из MARFOR, при неудаче прежний # файл цел, чужой файл выгрузка не трогает. list, schedule и recipe ничего не пишут: schedule # только печатает настройку планировщика, recipe — код запроса. Все выгрузки — LOCAL_ONLY_TOOLS: # пишущими в смысле MARFOR_READONLY они не считаются (данные MARFOR не меняют) и в удалённый # коннектор не входят. _EXPORT_TOOLS = [ { 'name': 'marfor_export_save', 'description': 'Export for the user\'s own dashboard: saves an export profile on this ' 'computer (dataset, metrics, dimensions, filters, a RELATIVE period, ' 'calculated metrics and the file format csv, json or js) and, with ' 'test_run (default true), runs it at once and writes the data file. Names ' 'are checked against the dataset description; an unknown name is refused ' 'with a list of similar ones. MARFOR is only read. Files: the profile ' '~/.marfor_mcp/exports/<name>.json and the data file (default ' '~/.marfor_mcp/out/<name>.<format>); an existing file that is not an export ' 'output is left as is and the call is refused. Saving again under the same ' 'name replaces the profile; out and note keep their previous values unless ' 'given. Related: marfor_export_schedule (scheduled refresh), ' 'marfor_export_recipe (a direct API request for the dashboard).', 'inputSchema': { 'type': 'object', 'properties': { 'name': _EXPORT_NAME_PROP, 'session_id': {'type': 'string'}, 'data_source': {'type': 'string', 'description': 'From marfor_datasets: processed (source), ' 'forecast or scenario'}, 'metrics': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Metric column names from marfor_describe'}, 'dimensions': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Dimensions from marfor_describe; every file also ' 'has year, quarter and month columns'}, 'filters': {'type': 'object', 'description': 'Same as in marfor_query: {"region_to": ["russia"]} or ' '{"region_to": {"values": [...], "mode": "exclude"}}; ' 'time is set by period, not here'}, 'period': {'type': 'object', 'description': 'Relative period, resolved on the day of each run: ' '{"kind":"all"} (default) | {"kind":"years",' '"years":[2025,2026]} | {"kind":"ytd"} | ' '{"kind":"last_months","n":6} (current month included) | ' '{"kind":"this_and_next_year"} | {"kind":"months",' '"from":"2026-01","to":"2026-12"}'}, 'derived': {'type': 'array', 'items': {'type': 'string'}, 'description': 'Calculated metrics from marfor_describe; computed per ' 'row by their formula from sums'}, 'format': {'type': 'string', 'enum': list(EXPORT_FORMATS), 'description': 'csv (default) | json | js (for an HTML dashboard ' 'opened from disk: window.MARFOR_DATA["<name>"])'}, 'out': {'type': 'string', 'description': 'Absolute path of the data file; default ' '~/.marfor_mcp/out/<name>.<format>'}, 'note': {'type': 'string', 'description': 'Note: for whom and why'}, 'test_run': {'type': 'boolean', 'description': 'Run at once and write the file (default true)'}, }, 'required': ['name', 'session_id', 'data_source', 'metrics'], }, 'annotations': _ann_write('Save export profile', destructive=True, idempotent=True), }, { 'name': 'marfor_export_run', 'description': 'Runs a saved export now and rewrites its data file on this computer. ' 'Data are read from MARFOR per year, and per month when a year comes back ' 'truncated; if a month is still truncated the run fails and leaves the ' 'file untouched, so a file is not written with cut data. The file is ' 'written atomically, with a status file <file>.status.json next to it ' '(ok, last_success_at, error). Returns the row count, the path, the period ' 'resolved for today, the number of requests and warnings. MARFOR is only ' 'read.', 'inputSchema': {'type': 'object', 'properties': {'name': _EXPORT_NAME_PROP}, 'required': ['name']}, 'annotations': _ann_write('Run export now', destructive=False, idempotent=True), }, { 'name': 'marfor_export_list', 'description': 'Lists the exports saved on this computer: name, dataset, period, format, ' 'data file path and the result of the last run. No network request.', 'inputSchema': {'type': 'object', 'properties': {}}, 'annotations': _ann_read('List exports'), }, { 'name': 'marfor_export_delete', 'description': 'Deletes an export from this computer: its profile and run log, and with ' 'delete_output=true also the data file and its status file. Nothing ' 'changes in MARFOR. A scheduled refresh stays in place; the result prints ' 'the command that removes it.', 'inputSchema': { 'type': 'object', 'properties': { 'name': _EXPORT_NAME_PROP, 'delete_output': {'type': 'boolean', 'description': 'Also delete the data file and its status ' '(default false)'}, }, 'required': ['name'], }, 'annotations': _ann_write('Delete export', destructive=True, idempotent=True), }, { 'name': 'marfor_export_schedule', 'description': 'Prints a ready-made scheduler setup that refreshes an export on this ' 'computer: a launchd plist and launchctl commands on macOS, a crontab line ' 'on Linux, a schtasks command on Windows. Installs nothing itself: ' 'installing is a separate step of running the printed commands. Passwords ' 'and tokens are not written into the setup.', 'inputSchema': { 'type': 'object', 'properties': { 'name': _EXPORT_NAME_PROP, 'every': {'type': 'string', 'enum': [k for k, _ in EXPORT_EVERY], 'description': 'Refresh interval: 15m, 30m, 1h, 3h, 6h, 12h or 1d'}, }, 'required': ['name', 'every'], }, 'annotations': _ann_read('Export schedule setup'), }, { 'name': 'marfor_export_recipe', 'description': 'Prints how a dashboard can fetch the same data from the MARFOR API by ' 'itself: curl, Python and JavaScript for POST /api/query with the request ' 'body of the export profile (period resolved for today) and the header ' 'Authorization: Bearer $MARFOR_TOKEN (a placeholder; no token is printed). ' 'Calculated metrics come as functions of sums. Works when the stand ' 'accepts token sign-in and the dashboard can reach the stand over the ' 'network.', 'inputSchema': {'type': 'object', 'properties': {'name': _EXPORT_NAME_PROP}, 'required': ['name']}, 'annotations': _ann_read('Export API recipe'), }, ] TOOLS.extend(_EXPORT_TOOLS) # ─────────────── удалённый коннектор (каталог Claude): что ему отдавать ─────────────── # Удалённая точка MCP (https://marfor.pro/api/mcp, OAuth; живёт в веб-приложении) берёт список # инструментов отсюда, чтобы описания и разметка не разъехались с локальным сервером. Импорт этого # файла побочных эффектов не имеет: ни сети, ни файлов, ни потоков. # # LOCAL_ONLY_TOOLS — инструменты, которым место только на компьютере пользователя: их работа — # файлы этого компьютера (профили и файлы выгрузок, настройка планировщика ОС). На удалённой # точке такой файл лёг бы на диск сервера MARFOR. Для MARFOR_READONLY они не пишущие. # REMOTE_EXCLUDED_TOOLSETS — наборы, которых в удалённой точке нет: raw (произвольный GET по # /api/), mmm (ручки калибровки есть только на dev), export (локальные файлы). # REMOTE_EXCLUDED_TOOLS — инструменты из оставшихся наборов, которых в точке нет тоже: # marfor_apply_batch и marfor_job_stop — фоновый поток внутри процесса, веб-сервер его не # держит; marfor_bi_generate — синхронная генерация до ~20 минут, а предел вызова у Claude 240 с. # LOCAL_FILE_PARAMS — параметры с путём на локальном диске (прочитать или записать файл). Из схем # удалённого каталога они убраны: на сервере это было бы чтение и запись любого его файла. # PUBLIC_REMOTE_TOOLS — ровно состав удалённого каталога, явным списком: новый инструмент попадает # в каталог Claude только осознанно (тест сверяет список с remote_tool_catalog()). LOCAL_ONLY_TOOLS = ('marfor_export_save', 'marfor_export_run', 'marfor_export_list', 'marfor_export_delete', 'marfor_export_schedule', 'marfor_export_recipe') REMOTE_EXCLUDED_TOOLSETS = ('raw', 'mmm', 'export') REMOTE_EXCLUDED_TOOLS = ('marfor_apply_batch', 'marfor_job_stop', 'marfor_bi_generate') LOCAL_FILE_PARAMS = ('save_to', 'edits_file', 'values_file', 'html_file') PUBLIC_REMOTE_TOOLS = ( 'marfor_guide', 'marfor_whoami', 'marfor_projects', 'marfor_help', 'marfor_next_step', 'marfor_feedback', 'marfor_datasets', 'marfor_describe', 'marfor_query', 'marfor_apply_edit', 'marfor_apply_values', 'marfor_job_status', 'marfor_bi_datasets', 'marfor_bi_dashboards', 'marfor_bi_get_page', 'marfor_bi_put_page', 'marfor_bi_publish', ) # Где удалённое поведение отличается от локального, описание и схема свои (в схемах — без # LOCAL_FILE_PARAMS у всех): marfor_guide — редакция без служебных команд и выгрузок; # marfor_whoami — без путей токена, команды входа и проверки версии; marfor_apply_edit сразу # отдаёт task_id (ожидание до 30 минут в вызов с пределом 240 с не влезает); marfor_job_status — # только task_id (фоновых батчей нет); marfor_bi_get_page отдаёт HTML текстом, а не файлом. _REMOTE_OVERRIDES = { 'marfor_guide': { 'description': 'marfor_guide returns the reference for the MARFOR tools of this connector, ' 'as text in Russian: what MARFOR is, the call order (marfor_whoami -> ' 'marfor_projects -> marfor_datasets -> marfor_describe -> marfor_query), ' 'how the write tools work (which one has a dry run and which apply ' 'changes immediately, the period every edit needs), the two filter ' 'formats, BI publishing, what an empty account means, refusal codes ' '(402 plan_limit, 401, 403 token_scope) and setup help. Static text: no ' 'request to MARFOR.', }, 'marfor_whoami': { 'description': 'Reports the MARFOR account behind this connection: stand address, email, ' 'user_id, plan and the number of projects with their names. For an ' 'account with exactly one project it adds the project stage and the next ' 'setup step. Read-only.', }, 'marfor_apply_edit': { 'description': 'Sets a new absolute value of a metric in one slice of a scenario, the ' 'same as editing a cell in the MARFOR interface, and recalculates the ' 'dependent metrics of the chain. Applies the change immediately; there is ' 'no dry run, and a call with dry_run=true is refused without sending ' 'anything. A preview of the rows a change touches: marfor_apply_values ' 'with dry_run=true. filters take flat values and lists, time as lists of ' 'strings: {"region_to": "russia", "year": ["2026"], "month": ["7"]}; a ' 'month without a year matches that month in every year. new_value=0 is ' 'rejected; 0.01 stands for zero. Edits are not cumulative: each one is ' 'computed from the base forecast, so repeating the same value changes ' 'nothing, and "Reset chain" on the scenario page restores the base. The ' 'stand computes the base value itself. Returns a task_id at once; the ' 'edit takes about a minute, and marfor_job_status(task_id) reports the ' 'result.', 'drop': ('wait',), }, 'marfor_job_status': { 'description': 'Status of a MARFOR write task by task_id, as returned by ' 'marfor_apply_values and marfor_apply_edit: still running, or completed ' 'with slices applied, unchanged and failed and whether the chain was ' 'recalculated. Read-only.', 'drop': ('job_id', 'tail'), 'required': ['task_id'], }, 'marfor_bi_get_page': { 'description': 'Returns the HTML of the AI page of a dashboard as text, with its size and ' '<title>; a page longer than 100,000 characters is cut, and the result ' 'says so. Read-only.', }, } def remote_tool_catalog(): """[{name, title, description, inputSchema, annotations}] — каталог удалённого коннектора, в порядке TOOLS. Не зависит от окружения процесса (MARFOR_TOOLSETS, MARFOR_READONLY, стенд): это каталог точки, а не выдача tools/list этого процесса. Описания — как в tools/list (с отсылкой к marfor_guide и подсказками идентификаторов) либо удалённые (_REMOTE_OVERRIDES); из схем убраны LOCAL_FILE_PARAMS. Каждый вызов отдаёт новые копии: правка результата TOOLS не трогает.""" out = [] for tool in TOOLS: if tool['name'] not in PUBLIC_REMOTE_TOOLS: continue ov = _REMOTE_OVERRIDES.get(tool['name'], {}) t = _presented(dict(tool, description=ov['description']) if 'description' in ov else tool) schema = t.get('inputSchema') or {'type': 'object', 'properties': {}} props = schema.setdefault('properties', {}) drop = LOCAL_FILE_PARAMS + tuple(ov.get('drop', ())) for key in drop: props.pop(key, None) if 'required' in ov: schema['required'] = list(ov['required']) elif 'required' in schema: schema['required'] = [k for k in schema['required'] if k not in drop] out.append({'name': t['name'], 'title': t['annotations']['title'], 'description': t['description'], 'inputSchema': schema, 'annotations': t['annotations']}) return out # ─────────────── удалённый коннектор: instructions, путеводитель, вызов ─────────────── REMOTE_INSTRUCTIONS = ( 'MARFOR (https://marfor.pro) is a forecasting and scenario-planning service for marketers: ' 'forecasts, budget scenarios, channel elasticities, BI dashboards. The connector acts as the ' 'signed-in MARFOR user and sees only that user\'s projects. marfor_guide returns the full ' 'reference in Russian: call order, filter formats, write rules, refusal codes.\n\n' 'Discovery: marfor_whoami -> marfor_projects -> marfor_datasets(project_id) -> ' 'marfor_describe(session_id, data_source) -> marfor_query. Ids and metric and dimension names ' 'come from these tools and differ between projects. Projects and data are created on the ' 'website; the connector has no upload. Setup help: marfor_next_step (project stage, next ' 'step) and marfor_help (help articles); marfor_feedback sends a ticket to support.\n\n' 'Writes: tools that change MARFOR data are annotated readOnlyHint=false, destructiveHint=true. ' 'marfor_apply_values defaults to dry_run=true: it returns a preview and writes nothing. ' 'marfor_apply_edit, marfor_bi_put_page and marfor_bi_publish have no dry run: a call applies ' 'the change immediately. Scenario writes return a task_id; marfor_job_status(task_id) ' 'reports the result. A month without a year matches that month in every year; a bulk edit ' 'without a period is rejected. new_value=0 is rejected; 0.01 stands for zero. marfor_query ' 'and the write tools use different filter formats. A slug in marfor_bi_publish makes the ' 'page address guessable.\n\n' 'Refusals: HTTP 402 plan_limit means the action is outside the user\'s plan, and a repeated ' 'call gets the same answer. Help: https://marfor.pro/mcp, support@marfor.pro' ) _GUIDE_WRITE_REMOTE = """\ 4. ЗАПИСЬ: КАК УСТРОЕНЫ ПИШУЩИЕ ИНСТРУМЕНТЫ Пишущие инструменты размечены в annotations: readOnlyHint=false, destructiveHint=true. Запись меняет данные пользователя: изменение показывают ему заранее, вызов — после явного «да». 1) С сухим прогоном (параметр dry_run): marfor_apply_values. dry_run=true стоит по умолчанию: ответ — сверка (какие срезы, сколько строк, какие годы и регионы), ничего не записывается. Запись — тот же вызов с dry_run=false. 2) БЕЗ сухого прогона: marfor_apply_edit, marfor_bi_put_page, marfor_bi_publish. Параметра dry_run у них нет, вызов пишет СРАЗУ; вызов с dry_run=true сервер отклоняет, ничего не записав. «Было → станет» видно до вызова: текущее значение даёт marfor_query; для BI — какая страница перезаписывается или публикуется и по какому адресу. """ _GUIDE_TOOLS_REMOTE = """\ 5. КАКОЙ ИНСТРУМЕНТ ЗАПИСИ ДЛЯ ЧЕГО • marfor_apply_values — основной: значения по многим срезам одним запросом. scope — общие границы (период и общие фильтры), values — список {"slice": {…}, "value": число}. • marfor_apply_edit — одна правка одного среза, как ячейка в интерфейсе (~минута). Сухого прогона нет (правило 2); какие строки затронет правка, показывает marfor_apply_values с dry_run=true. • Запись сценария возвращает task_id — это ещё не результат: итог (task_status=completed, сколько срезов применено) показывает marfor_job_status(task_id=…). """ _GUIDE_BI_REMOTE = """\ 7. BI-ДАШБОРДЫ marfor_bi_dashboards — список; marfor_bi_get_page — HTML страницы текстом; marfor_bi_put_page — положить свой HTML; marfor_bi_publish — публичная ссылка. Генератор страниц работает в редакторе BI на сайте. Без slug адрес секретный. Со slug он УГАДЫВАЕМЫЙ — страницу увидит любой, кто знает имя, поэтому читаемый адрес ставят, только когда пользователь сам о нём просит. """ _GUIDE_ERRORS_TOKEN_REMOTE = """\ • HTTP 401 — вход не подтверждён: доступ коннектора отозван или истёк; его восстанавливает повторное подключение MARFOR в настройках клиента. Токены и пароли через чат не передаются. """ # Редакция удалённого коннектора: без служебных команд (--login, MARFOR_TOOLSETS), выгрузок, # фонового батча и генератора BI, но с разделом про настройку проекта (он здесь 10-й). REMOTE_GUIDE_TEXT = _guide([_GUIDE_HEAD, _GUIDE_ABOUT, _GUIDE_ORDER, _GUIDE_READ, _GUIDE_WRITE_REMOTE + _GUIDE_WRITE_RULES, _GUIDE_TOOLS_REMOTE, _GUIDE_FILTERS.replace('marfor_apply_edit и правки marfor_apply_batch', 'marfor_apply_edit'), _GUIDE_BI_REMOTE, _GUIDE_EMPTY, _GUIDE_ERRORS_HEAD + _GUIDE_ERRORS_TOKEN_REMOTE + _GUIDE_ERRORS_TAIL, _GUIDE_SETUP], 10) REMOTE_HTML_MAX = 100000 # HTML страницы в ответе; длиннее — обрезка с пометкой REMOTE_CALL_BUDGET = 150 # с: все запросы одного вызова; предел вызова у Claude — 240 с # Идентификаторы, которые ложатся в путь запроса: «/», «?», «#», «%», пробелы и «..» в них — # отказ, чтобы аргумент не увёл запрос на другой маршрут веб-приложения. _REMOTE_PATH_IDS = ('session_id', 'project_id', 'dashboard_id', 'task_id') _REMOTE_BAD_ID = re.compile(r'[/\\?#%\x00-\x20\x7f]') # Глобальное состояние модуля, которое удалённый вызов подменяет на свой и возвращает после. _REMOTE_STATE = ('MF', 'BASE_URL', '_DERIVED_CACHE', '_FEEDBACK_SENT', '_JOBS', '_QUIET') _REMOTE_LOCK = threading.Lock() class _RemoteRefusal(Exception): """Отказ удалённого вызова до исполнения инструмента: текст для пользователя, is_error=True.""" class _RemoteClient: """Клиент удалённого режима — замена Marfor на время одного вызова. Каждый запрос идёт через transport вызывающего: личность пользователя ставит он, токена здесь нет, своей сети, cookie и файлов нет. Все запросы вызова укладываются в REMOTE_CALL_BUDGET: таймаут каждого — не больше остатка, исчерпанный остаток — отказ без запроса.""" def __init__(self, transport, base_url, deadline=None): self.transport, self.base_url = transport, base_url # Срок считается от входа в remote_call_tool: ожидание очереди тоже входит в бюджет. self.deadline = deadline if deadline is not None else time.monotonic() + REMOTE_CALL_BUDGET self.logged_in, self.account, self.token, self.token_source = True, None, '', None def ensure(self): return None def _raw(self, method, path, *, data=None, form=None, headers=None, timeout=None, auth=True): if not isinstance(path, str) or not path.startswith('/') or path.startswith('//'): raise RuntimeError('удалённый режим: запрос только по пути стенда, без адреса') left = self.deadline - time.monotonic() if left <= 0: raise RuntimeError('время вызова вышло (%d с на все запросы одного вызова)' % REMOTE_CALL_BUDGET) body, hdr = None, {'Accept': 'application/json', 'User-Agent': 'marfor-mcp-remote/%s' % SERVER_VERSION} if form is not None: body = urllib.parse.urlencode(form).encode('utf-8') hdr['Content-Type'] = 'application/x-www-form-urlencoded' elif data is not None: body = json.dumps(data, ensure_ascii=False).encode('utf-8') hdr['Content-Type'] = 'application/json' hdr.update(headers or {}) t = max(1, int(min(timeout or HTTP_TIMEOUT, left))) try: status, rh, raw = self.transport(method, path, body, hdr, t) except Exception as e: raise RuntimeError('стенд не ответил: %s: %s' % (type(e).__name__, e)) status = int(status) if isinstance(raw, str): raw = raw.encode('utf-8') raw = raw or b'' loc = (rh.get('Location') or '') if rh is not None else '' if status in (301, 302, 303, 307, 308) and '/login' in loc: raise RuntimeError('стенд не узнал пользователя (HTTP %s, переадресация на вход): ' 'доступ коннектора не подтверждён' % status) return status, rh, raw def call(self, method, path, *, data=None, params=None, _retry=True, timeout=None): if params: path = path + ('&' if '?' in path else '?') + urllib.parse.urlencode(params) code, _, raw = self._raw(method, path, data=data, timeout=timeout) text = raw.decode('utf-8', 'replace') try: js = json.loads(text) except Exception: js = {'_raw': text[:4000], '_not_json': True} return code, js def fetch_raw(self, path, *, timeout=None, _retry=True): code, _, raw = self._raw('GET', path, timeout=timeout) return code, raw def _remote_whoami(): """whoami удалённой точки: аккаунт и проекты, без путей токена, команды входа и проверки версии (ни файлов, ни /mcp/version.json).""" code, js = MF.call('GET', '/api/account/balance') bal = js.get('balance') if isinstance(js, dict) else None if code != 200 or not isinstance(bal, dict) or not bal.get('user_id'): msg = (js.get('message') or js.get('error')) if isinstance(js, dict) else None raise _RemoteRefusal('Вход не подтверждён: стенд %s на /api/account/balance ответил HTTP %s%s.' % (BASE_URL, code, (' — %s' % msg) if msg else '')) out = ['Вход выполнен (удалённый коннектор MARFOR).', 'Стенд : %s' % BASE_URL, 'Email : %s' % bal.get('email'), 'user_id : %s' % bal.get('user_id'), 'Тариф : %s' % bal.get('tariff'), 'Сервер : marfor-mcp %s, удалённый коннектор' % SERVER_VERSION] projects, rows = _projects_info() out += [projects] if projects else [] out += [x for x in (_whoami_stage_line(rows),) if x] if not projects.startswith('Проектов: 0'): out.append('Дальше : marfor_projects → marfor_datasets → marfor_describe → ' 'marfor_query (подробности — marfor_guide).') return '\n'.join(out) def _remote_page_text(args): """HTML AI-страницы текстом: файла на сервере нет, длинная страница обрезается с пометкой.""" did = str(args['dashboard_id']) code, raw = MF.fetch_raw('/api/bi/ai_pages/%s/raw' % _q(did)) text = raw.decode('utf-8', 'replace') if code != 200: return _refusal_raw(code, raw, text) if not text.lstrip()[:200].lower().startswith(('<!doctype', '<html')) \ and 'не сгенерирована' in text[:400]: return 'Страница дашборда %s ещё не сгенерирована.' % did head = ('HTML AI-страницы дашборда %s: %d символов (%d байт), <title>: %s\nСтенд: %s' % (did, len(text), len(raw), _bi_html_title(text), BASE_URL)) if len(text) <= REMOTE_HTML_MAX: return head + '\n\n' + text return ('%s\n⚠️ Страница длиннее %d символов: ниже первые %d, остальное обрезано.\n\n%s\n' '… обрезано: показано %d из %d символов.' % (head, REMOTE_HTML_MAX, REMOTE_HTML_MAX, text[:REMOTE_HTML_MAX], REMOTE_HTML_MAX, len(text))) def _remote_refusal_for(name): if name in REMOTE_EXCLUDED_TOOLS: why = {'marfor_apply_batch': 'фоновый батч живёт в потоке процесса, удалённая точка его не ' 'держит — массовая правка здесь marfor_apply_values', 'marfor_job_stop': 'фоновых батчей в удалённом коннекторе нет', 'marfor_bi_generate': 'генерация идёт до ~20 минут, а предел одного вызова — 240 с; ' 'страницу генератором собирают в редакторе BI на сайте'}[name] elif name in LOCAL_ONLY_TOOLS or _toolset_of(str(name)) == 'export': why = 'выгрузки пишут файлы на компьютере пользователя — это инструмент локального сервера' elif _toolset_of(str(name)) in REMOTE_EXCLUDED_TOOLSETS and any( t['name'] == name for t in TOOLS): why = 'набор «%s» в удалённый коннектор не входит' % _toolset_of(name) else: why = 'такого инструмента нет' return ('ОТКАЗ: инструмента %s в удалённом коннекторе MARFOR нет (%s). Ничего не выполнено. ' 'Доступны: %s.' % (name, why, ', '.join(PUBLIC_REMOTE_TOOLS))) def _remote_check(name, args): """Проверки до исполнения: имя из каталога, без локальных файловых параметров, безопасные идентификаторы пути.""" if name not in PUBLIC_REMOTE_TOOLS: raise _RemoteRefusal(_remote_refusal_for(name)) if not isinstance(args, dict): raise _RemoteRefusal('ОТКАЗ: аргументы инструмента — объект JSON. Ничего не выполнено.') files = [k for k in LOCAL_FILE_PARAMS if k in args] if files: raise _RemoteRefusal( 'ОТКАЗ: параметр %s — путь к файлу на диске, а удалённый коннектор работает на сервере ' 'MARFOR: файлы он не читает и не пишет. Ничего не выполнено.%s' % (', '.join(files), ' HTML страницы передаётся параметром html.' if 'html_file' in files else '')) for key in _REMOTE_PATH_IDS: v = args.get(key) if v is None: continue s = str(v) if isinstance(v, (dict, list)) or not s or len(s) > 200 or s in ('.', '..') \ or _REMOTE_BAD_ID.search(s): raise _RemoteRefusal('ОТКАЗ: %s=«%s» не похож на идентификатор MARFOR (в нём есть ' '«/», «?», «#», «%%», пробел или он пустой). Ничего не выполнено.' % (key, s[:80])) def _remote_run(name, args): if name == 'marfor_guide': return REMOTE_GUIDE_TEXT if name == 'marfor_whoami': return _remote_whoami() if name == 'marfor_job_status': if args.get('job_id') not in (None, ''): raise _RemoteRefusal('ОТКАЗ: job_id — фоновый батч локального сервера, в удалённом ' 'коннекторе их нет. Статус записи — по task_id из ответа ' 'marfor_apply_values или marfor_apply_edit.') if not args.get('task_id'): raise _RemoteRefusal('ОТКАЗ: нужен task_id — его возвращают marfor_apply_values и ' 'marfor_apply_edit. Ничего не выполнено.') args = {k: v for k, v in args.items() if k in ('task_id', 'max_chars')} if name == 'marfor_apply_edit': # Ждать задачу внутри вызова нельзя: опрос шёл бы до 30 минут, а предел вызова — 240 с. args = dict(args, wait=False) if name == 'marfor_bi_get_page': return _remote_page_text(args) return _run_tool(name, args) def remote_call_tool(name, args, transport, base_url): """Вызов инструмента удалённым коннектором MARFOR → (текст, is_error). transport(method, path, body_bytes | None, headers_dict, timeout_s) → (status: int, headers, body: bytes) — подзапрос в то же веб-приложение от имени пользователя: личность ставит transport, токена здесь нет. headers ответа — объект с .get(имя) без учёта регистра (werkzeug.Headers). base_url — адрес стенда для текстов («Стенд: …») и ссылок. Удалённый режим: • только PUBLIC_REMOTE_TOOLS; другое имя — отказ (is_error=True); • LOCAL_FILE_PARAMS (save_to, edits_file, values_file, html_file) — отказ: на сервере это чтение и запись любого его файла; идентификаторы пути с «/», «?», «#», «%», пробелом — отказ; • ни файлов, ни ~/.marfor_mcp, ни потоков, ни сети мимо transport; • log() и print молчат: stdout и stderr веб-процесса — журнал сервиса; • ожидания нет: marfor_apply_edit сразу отдаёт task_id (итог — marfor_job_status), все запросы вызова укладываются в REMOTE_CALL_BUDGET (150 с; у Claude предел вызова 240 с); • кэши (_DERIVED_CACHE, _FEEDBACK_SENT, _JOBS) — только на этот вызов. Изоляция между пользователями: вызывающий создаёт СВЕЖИЙ экземпляр модуля на каждый вызов — exec заранее скомпилированного кода этого файла в новый модуль, порядка 11 мс. Глобальное состояние (MF, BASE_URL, кэши, _QUIET) вызов подменяет на своё и в finally возвращает прежнее, так что MF с transport одного пользователя наружу не утекает и в модуле не остаётся. Если экземпляр модуля всё же общий, вызовы в нём идут по одному (_REMOTE_LOCK): медленнее, но личность одного пользователя в чужой вызов не попадёт; бюджет 150 с считается от входа в вызов, вместе с ожиданием очереди, и не дождавшийся её вызов получает отказ.""" t0 = time.monotonic() try: _remote_check(name, args) base = str(base_url or '').strip().rstrip('/') u = urllib.parse.urlsplit(base) if u.scheme not in ('https', 'http') or not u.netloc or not callable(transport): raise _RemoteRefusal('ОТКАЗ: удалённый вызов настроен неверно (base_url «%s», transport ' '%s). Ничего не выполнено.' % (base[:80], 'есть' if callable(transport) else 'не функция')) except _RemoteRefusal as e: return str(e), True if not _REMOTE_LOCK.acquire(timeout=REMOTE_CALL_BUDGET): return ('Коннектор занят другим вызовом дольше %d с; ничего не выполнено. Повторный вызов ' 'пройдёт, когда очередь освободится.' % REMOTE_CALL_BUDGET), True g = globals() saved = {k: g[k] for k in _REMOTE_STATE} try: g.update(MF=_RemoteClient(transport, base, deadline=t0 + REMOTE_CALL_BUDGET), BASE_URL=base, _DERIVED_CACHE={}, _FEEDBACK_SENT={}, _JOBS={}, _QUIET=True) try: return str(_remote_run(name, args)), False except _RemoteRefusal as e: return str(e), True except KeyError as e: return 'Ошибка: нет обязательного аргумента %s.' % e, True except Exception as e: return 'Ошибка: %s' % e, True finally: g.update(saved) _REMOTE_LOCK.release() # ─────────────────────── служебные команды (не MCP-режим) ───────────────────── # Разбираются ДО цикла stdio; stdout здесь — обычный текст для человека или ассистента. # Вход разбит на --login-start / --login-finish не из любви к флагам: у оболочки, которой # пользуется ассистент, нет терминала с вводом, а время одной команды ограничено (120 с) — # один блокирующий --login там невозможен. Это предел КОМАНДЫ, а не входа: подтверждённый # вход стенд держит до исходного срока кода (10 минут от --login-start), --login-finish # можно повторять. Секрет при этом не проходит ни через чат, ни через конфигурацию клиента, # ни через историю оболочки: он сразу ложится в token.json. LOGIN_WAIT_MAX = 110 # потолок --wait: запас под предел времени команды у оболочки ассистента (120 с) LOGIN_WAIT_DEFAULT = 100 # Таймаут одного опроса; у последнего — короче, см. _login_finish. Стенд выпускает токен # ВНУТРИ ответа на опрос (запись в базу): оборви клиент такой запрос раньше времени — токен # создан, а получить его уже некому. Поэтому запас щедрый; обычный опрос отвечает за миллисекунды, # а потолок всей команды держит не эта константа, а срок ожидания (--wait). POLL_HTTP_TIMEOUT = 15 class _CliError(Exception): """Отказ служебной команды: текст для человека, код возврата 1.""" def _unlink(path): try: os.unlink(path) except OSError: pass def _client_label(): """Как подключение назовётся на странице подтверждения и в списке токенов на сайте: по имени компьютера человек отличит свой ноутбук от чужого запроса.""" try: host = socket.gethostname().split('.')[0] except Exception: host = '' return ('Claude @ %s' % host if host else 'Claude')[:60] def _manual_token_hint(): return ('Запасной путь — токен вручную: создайте его на %s/account (раздел «Подключение ' 'Claude (MCP)») и сохраните командой\n %s\nОна спросит токен скрытым вводом, ' 'поэтому ей нужен настоящий терминал; в чат и в командную строку токен не ' 'вставляйте.%s' % (PUBLIC_BASE, _self_cmd('--set-token'), _win_notes())) def _device_start(): code, _, raw = MF._raw('POST', '/api/mcp/auth/start', data={'client': _client_label()}, timeout=30, auth=False) js = _json_or_none(raw) js = js if isinstance(js, dict) else {} if code == 503 or js.get('code') == 'device_flow_unavailable': # Код в скобках — устойчивый признак для инструкции ассистенту (install.md): # русскую фразу можно переписать, а по коду случай узнаётся всегда. raise _CliError('Вход подтверждением в браузере сейчас недоступен на %s ' '(device_flow_unavailable, HTTP %s).\n%s' % (BASE_URL, code, _manual_token_hint())) if code == 404: raise _CliError('На стенде %s нет входа подтверждением в браузере (HTTP 404).\n%s' % (BASE_URL, _manual_token_hint())) if code == 429: raise _CliError('Слишком много попыток входа подряд. Подождите 10 минут и повторите.') if code != 200 or not js.get('success') or not js.get('device_code') \ or not js.get('user_code'): raise _CliError('Стенд %s не начал вход: HTTP %s %s\n%s' % (BASE_URL, code, js.get('message') or '', _manual_token_hint())) return js def _pending_save(js): """Начатый вход → pending.json (0600). device_code — почти секрет: им забирают токен, поэтому он живёт только в этом файле и на экран не печатается.""" code = str(js['user_code']) url = js.get('verification_uri_complete') or ( (js.get('verification_uri') or BASE_URL + '/mcp/connect') + '?code=' + urllib.parse.quote(code)) rec = {'base_url': BASE_URL, 'device_code': str(js['device_code']), 'user_code': code, 'url': str(url), 'interval': max(1, min(int(js.get('interval') or 3), 30)), 'expires_at': time.time() + max(30, min(int(js.get('expires_in') or 600), 3600))} _write_private_json(PENDING_FILE, rec) return rec def _print_login_invite(rec): mins = max(1, int(round((rec['expires_at'] - time.time()) / 60.0))) print('Откройте ссылку в браузере, сверьте код и нажмите «Разрешить»:') print('URL: %s' % rec['url']) print('CODE: %s' % rec['user_code']) print('Ссылка действует %d мин. Код на странице обязан совпасть с кодом выше; не совпал — ' 'нажмите «Отклонить».' % mins) def _open_browser(url): if os.environ.get('MARFOR_NO_BROWSER'): return False u = urllib.parse.urlsplit(url) local = (u.hostname or '').lower() in ('localhost', '127.0.0.1') if not (u.scheme == 'https' or (u.scheme == 'http' and local)): return False if sys.platform.startswith('linux') and not (os.environ.get('DISPLAY') or os.environ.get('WAYLAND_DISPLAY')): return False # без графики поднялся бы консольный браузер и занял терминал try: import webbrowser return bool(webbrowser.open(url)) except Exception: return False def _device_poll(device_code, timeout=POLL_HTTP_TIMEOUT): """→ (статус, токен, пояснение). Статус: pending | approved | denied | expired — ответ стенда; retry — временный сбой, стоит спросить ещё раз; error — продолжать незачем. Пояснение у denied/expired — поле message стенда, если оно есть: токен выпускается в момент опроса, и отказ может прийти уже после «Разрешить» (например, исчерпан предел в 10 активных токенов) — причину человеку надо показать, а не заменять общей фразой.""" try: code, _, raw = MF._raw('POST', '/api/mcp/auth/poll', data={'device_code': device_code}, timeout=timeout, auth=False) except RuntimeError as e: return 'retry', None, str(e) js = _json_or_none(raw) js = js if isinstance(js, dict) else {} st = js.get('status') if st in ('pending', 'approved', 'denied', 'expired'): msg = js.get('message') return st, js.get('token'), (str(msg).strip()[:500] or None) if msg else None if code == 429 or code >= 500: return 'retry', None, 'стенд ответил HTTP %s' % code return 'error', None, 'HTTP %s %s' % (code, js.get('message') or '') def _report_saved(tok): print('Готово: токен сохранён в %s' % TOKEN_FILE) if (os.environ.get('MARFOR_TOKEN') or '').strip(): print('⚠️ Задана переменная MARFOR_TOKEN — она важнее файла. Уберите её, чтобы работал ' 'сохранённый токен.') try: acc = MF.token_login(tok, 'file') print('Вы вошли как %s (user_id=%s), стенд %s' % (acc['email'], acc['user_id'], BASE_URL)) except RuntimeError as e: print('⚠️ Токен сохранён, но пробный вызов не прошёл: %s' % e) print('Если сервер уже подключён к клиенту — продолжайте работу, перезапуск не нужен.\n' 'Если ещё нет — команду регистрации печатает:\n %s%s' % (_self_cmd('--print-config', 'claude-code'), _win_notes())) return 0 def _login_finish(wait): """0 — токен сохранён; 2 — ещё не подтверждено (подождать и повторить); 1 — отказ, истекло или начинать заново.""" rec = _read_json(PENDING_FILE) if not rec.get('device_code') or rec.get('base_url') != BASE_URL: print('Начатого входа для %s нет. Начните: %s%s' % (BASE_URL, _self_cmd('--login-start'), _win_notes()), file=sys.stderr) return 1 again = 'Начните заново: %s%s' % (_self_cmd('--login-start'), _win_notes()) deadline, note = time.time() + wait, None while True: if time.time() >= float(rec.get('expires_at') or 0): _unlink(PENDING_FILE) print('Время на подтверждение вышло. ' + again, file=sys.stderr) return 1 # Чем ближе потолок ожидания, тем короче таймаут опроса: вся команда обязана # уложиться в предел времени команды (120 с), иначе оболочка ассистента убьёт её # посреди запроса. st, tok, note = _device_poll( rec['device_code'], timeout=min(POLL_HTTP_TIMEOUT, max(5, int(deadline - time.time())))) if st == 'approved': if not isinstance(tok, str) or not TOKEN_RE.fullmatch(tok): _unlink(PENDING_FILE) print('Вход подтверждён, но токен не получен: он выдаётся один раз и, видимо, ' 'уже был забран. ' + again, file=sys.stderr) return 1 _save_token(tok) # сначала сохранить: токен стенд отдаёт ОДИН раз _unlink(PENDING_FILE) return _report_saved(tok) if st in ('denied', 'expired', 'error'): _unlink(PENDING_FILE) # Пояснение стенда важнее общей фразы: отказ может прийти и после «Разрешить». said = (note.rstrip('. ') + '. ') if note else '' print({'denied': ('Вход не состоялся: ' + said) if said else 'Вход отклонён на странице подтверждения. ', 'expired': 'Код входа истёк или уже использован. ' + said, 'error': 'Стенд не принял опрос входа (%s). ' % note}[st] + again, file=sys.stderr) return 1 pause = int(rec.get('interval') or 3) if time.time() + pause > deadline: break time.sleep(pause) print('Вход ещё не подтверждён%s.' % ((' (%s)' % note) if note else '')) print('URL: %s' % rec.get('url')) print('CODE: %s' % rec.get('user_code')) # Подтверждённый вход стенд держит до исходного срока кода, а токен выпускает в момент # опроса: нажать «Разрешить» и вернуться к этой команде можно в любой момент до конца срока. left = max(1, int(round((float(rec.get('expires_at') or 0) - time.time()) / 60.0))) print('Код действует ещё %d мин.: нажать «Разрешить» и повторить эту команду можно в любой ' 'момент до конца срока.' % left) print('Попросите пользователя нажать «Разрешить» и повторите: %s%s' % (_self_cmd('--login-finish'), _win_notes())) return 2 def _cli_login_start(): rec = _pending_save(_device_start()) _print_login_invite(rec) # «Сразу», а не «после подтверждения»: команда сама ждёт нажатия «Разрешить». Пока её не # запустили, стенд никто не опрашивает — ассистент, ждущий ответа в чате, терял на этом вход. print('Сразу запустите завершение входа — команда сама дождётся нажатия «Разрешить» ' '(код возврата 2 — ещё не подтверждено, повторите её):\n %s%s' % (_self_cmd('--login-finish'), _win_notes())) return 0 def _cli_login(): rec = _pending_save(_device_start()) _print_login_invite(rec) if _open_browser(rec['url']): print('Ссылка открыта в браузере.') print('Жду подтверждения… (Ctrl+C — прервать; завершить позже: %s)%s' % (_self_cmd('--login-finish'), _win_notes())) sys.stdout.flush() # ждать предстоит минуты: приглашение должно быть на экране сразу try: return _login_finish(max(0.0, rec['expires_at'] - time.time())) except KeyboardInterrupt: print('\nПрервано. Вход можно завершить позже: %s' % _self_cmd('--login-finish')) return 130 def _stdin_is_tty(): try: return bool(sys.stdin.isatty()) except (AttributeError, ValueError): # поток закрыт или подменён return False def _cli_set_token(from_stdin=False): if sys.stdin is None: raise _CliError('Нет потока ввода: запустите команду в терминале.') if _stdin_is_tty(): import getpass tok = getpass.getpass('Вставьте токен MARFOR (ввод не отображается): ') elif from_stdin: # Явный --stdin: токен пришёл по конвейеру или из файла. Эха у конвейера нет, # приглашения не печатаем — читать его некому. tok = sys.stdin.readline() else: # Без терминала скрытого ввода не бывает. Раньше команда здесь молча читала stdin: в # Git Bash (mintty) на Windows родной Python видит вместо терминала канал, человек # получал «зависшую» команду без приглашения, а вставленный токен — открытым на экране. raise _CliError( '--set-token спрашивает токен скрытым вводом, а у этой оболочки нет терминала ' '(stdin — не TTY): так бывает у ассистента и в Git Bash на Windows. Ничего не ' 'прочитано и не сохранено.\n' 'Запустите команду в обычном терминале (Windows: PowerShell или cmd; в Git Bash — ' 'через winpty). Осознанно передать токен через стандартный ввод, минуя экран и ' 'историю оболочки, позволяет флаг --stdin:\n %s\n' 'Токен подаётся ей на стандартный ввод из буфера обмена или из файла (macOS: ' '«pbpaste | команда», PowerShell: «Get-Clipboard | команда», bash: «команда < ' 'файл»). В чат и в аргументы команды токен не вставляйте.%s' % (_self_cmd('--set-token', '--stdin'), _win_notes())) tok = (tok or '').strip() if not TOKEN_RE.fullmatch(tok): raise _CliError('Это не похоже на токен MARFOR: он начинается с marfor_pat_ и состоит ' 'из латинских букв, цифр, «-» и «_» (всего 54 символа). Проверьте, что ' 'скопировали его целиком. Ничего не сохранено.') acc = MF.token_login(tok, 'file') # пробный вызов; отказ → RuntimeError _save_token(tok) print('Токен принят: %s (user_id=%s), стенд %s' % (acc['email'], acc['user_id'], BASE_URL)) print('Сохранён в %s' % TOKEN_FILE) if (os.environ.get('MARFOR_TOKEN') or '').strip(): print('⚠️ Задана переменная MARFOR_TOKEN — она важнее файла.') return 0 def _cli_logout(): had = _forget_token() _unlink(PENDING_FILE) print(('Токен для %s удалён из %s.' % (BASE_URL, TOKEN_FILE)) if had else ('Сохранённого токена для %s не было.' % BASE_URL)) if (os.environ.get('MARFOR_TOKEN') or '').strip(): print('⚠️ Токен задан ещё и переменной MARFOR_TOKEN — уберите её из конфигурации клиента.') if had: print('На стенде токен остаётся действующим, пока вы его не отзовёте: %s/account, ' 'раздел «Подключение Claude (MCP)».' % PUBLIC_BASE) return 0 def _self_sha256(): try: with open(os.path.abspath(__file__), 'rb') as f: return hashlib.sha256(f.read()).hexdigest() except OSError: return None def _cli_check(): """Диагностика для человека и для ассистента. Секрет НЕ печатается: от токена видны только последние 4 символа — те же, что показывает список токенов на сайте.""" import platform sha = _self_sha256() print('marfor-mcp %s' % SERVER_VERSION) print('Файл : %s' % os.path.abspath(__file__)) print('sha256 : %s' % (sha or 'не удалось прочитать файл')) print('Python : %s (%s), %s' % (platform.python_version(), sys.executable, sys.platform)) print('Стенд : %s%s' % (BASE_URL, ' (публичный)' if _is_public_stand() else '')) bad = _base_url_problem() if bad: print('ИТОГ : ОТКАЗ — ' + bad) return 1 print('Наборы : %s%s%s' % ( _toolsets_label(), '' if (os.environ.get('MARFOR_TOOLSETS') or '').strip() else ' (умолчание для стенда)', '; только чтение (MARFOR_READONLY)' if _readonly() else '')) tok, src = _find_token() if tok: where = 'переменная MARFOR_TOKEN' if src == 'env' else 'файл %s' % TOKEN_FILE # хвост показываем только у настоящего токена: в переменной может лежать что угодно print('Токен : найден — %s, %s' % (where, ('оканчивается на …%s' % tok[-4:]) if TOKEN_RE.fullmatch(tok) else 'но на токен MARFOR не похож')) if src == 'file' and os.name != 'nt': try: mode = os.stat(TOKEN_FILE).st_mode & 0o777 if mode != 0o600: print(' ⚠️ права файла %o, нужно 600: chmod 600 %s' % (mode, _shq(TOKEN_FILE))) except OSError: pass elif EMAIL: print('Токен : не найден; вход паролем под %s%s' % (EMAIL, '' if BASE_URL_EXPLICIT else ' — но MARFOR_BASE_URL не задан')) else: print('Токен : не найден') print('ИТОГ : вход не настроен. Войти: %s%s' % (_self_cmd('--login'), _win_notes())) return 1 try: MF.logged_in = False MF.ensure() except Exception as e: print('Вход : ОТКАЗ — %s' % e) print('ИТОГ : вход не работает (см. выше)') return 1 acc = MF.account print('Вход : OK — %s (user_id=%s, тариф %s)' % (acc.get('email'), acc.get('user_id'), acc.get('tariff'))) line = _projects_line() if line: print(line.split('\n', 1)[0]) pub = _published_version() if pub: theirs, ours = _ver_tuple(pub.get('version')), _ver_tuple(SERVER_VERSION) if theirs > ours: print('Версия : на сайте новее — %s. Обновление: скачать %s/mcp/server.py поверх ' 'этого файла.' % (pub.get('version'), PUBLIC_BASE)) elif theirs < ours: print('Версия : у вас новее опубликованной (%s)' % pub.get('version')) elif sha and pub.get('sha256') and pub['sha256'] != sha: print('Версия : ⚠️ файл отличается от опубликованного %s (sha256 не совпал): он ' 'изменён или скачан не полностью. Скачайте заново: %s/mcp/server.py' % (SERVER_VERSION, PUBLIC_BASE)) elif sha and pub.get('sha256'): print('Версия : актуальная, sha256 совпадает с опубликованным') else: print('Версия : актуальная') print('ИТОГ : всё в порядке') return 0 def _password_mode(): """Вход будет паролем: для стенда нет токена (ни переменной, ни файла), а MARFOR_EMAIL задан.""" return bool(EMAIL) and not _find_token()[0] def _config_env(): """Несекретные настройки из текущего окружения — их переносим в конфигурацию клиента. Стенд — если задан явно и он не публичный: без переменной зарегистрированный сервер ушёл бы на marfor.pro. При входе паролем — ещё MARFOR_EMAIL и MARFOR_KEYCHAIN_SERVICE (если не по умолчанию), а стенд тогда переносится всегда, когда задан явно: без явного стенда парольный вход запрещён. Токен и пароль сюда не попадают НИКОГДА: токен живёт в token.json, пароль — в Keychain.""" env = {} pw = _password_mode() and BASE_URL_EXPLICIT if BASE_URL_EXPLICIT and (pw or not _is_public_stand()): env['MARFOR_BASE_URL'] = BASE_URL if pw: env['MARFOR_EMAIL'] = EMAIL if KEYCHAIN_SERVICE and KEYCHAIN_SERVICE != 'marfor-mcp': env['MARFOR_KEYCHAIN_SERVICE'] = KEYCHAIN_SERVICE for key in ('MARFOR_TOOLSETS', 'MARFOR_READONLY'): val = (os.environ.get(key) or '').strip() if val: env[key] = val return env def _cli_print_config(client): """Готовая регистрация сервера с АБСОЛЮТНЫМИ путями: графический клиент не наследует PATH оболочки, и голое «python3» у него может оказаться другим Python или не найтись.""" exe, path, env = sys.executable or 'python3', os.path.abspath(__file__), _config_env() if client == 'claude-desktop': if os.name == 'nt': # Прямые слэши Windows понимает, а в JSON они не требуют экранирования: человек, # вливающий блок в свой конфиг руками, не споткнётся об удвоенные «\\». exe, path = exe.replace('\\', '/'), path.replace('\\', '/') entry = {'command': exe, 'args': [path]} if env: entry['env'] = env # ensure_ascii: путь с кириллицей останется верным JSON в любой кодировке консоли print(json.dumps({'mcpServers': {SERVER_NAME: entry}}, indent=2)) sys.stdout.flush() # пояснение идёт в stderr; в общем потоке оно обязано быть ПОСЛЕ log('Это блок для claude_desktop_config.json (Settings → Developer → Edit Config). ' 'Если в файле уже есть "mcpServers" — добавьте в него запись "%s", остальные не ' 'трогайте; затем полностью перезапустите Claude Desktop.' % SERVER_NAME) _print_config_login_note(env) return 0 parts = ['claude', 'mcp', 'add', SERVER_NAME, '--scope', 'user'] for key, val in env.items(): parts += ['--env', '%s=%s' % (key, val)] print(' '.join([_shq(p) for p in parts + ['--']] + [_shq(exe, path=True), _shq(path, path=True)])) # Ассистент читает stdout и stderr одним потоком, и велено выполнить «напечатанное»: без # сброса буфера русская строка пояснения оказывалась ПЕРЕД командой. sys.stdout.flush() log('Проверка после регистрации: claude mcp list — в строке %s должно быть Connected.' % SERVER_NAME) _print_config_login_note(env) return 0 def _print_config_login_note(env): """Вход паролем: откуда сервер в клиенте возьмёт пароль — в конфигурацию он не вписан.""" if 'MARFOR_EMAIL' not in env: return if sys.platform == 'darwin': log('Вход паролем: сервер в клиенте возьмёт пароль из Keychain (запись -s %s -a %s); ' 'в конфигурацию пароль не вписан.' % (KEYCHAIN_SERVICE, EMAIL)) else: log('Вход паролем: Keychain есть только на macOS — задайте MARFOR_PASSWORD в окружении ' 'клиента сами либо войдите токеном (--login); в эту конфигурацию пароль не вписан.') def _cli_export(name): """--export ИМЯ: запуск выгрузки без человека — так её вызывает планировщик. Коды: 0 — файл обновлён, 1 — запуск не удался (сеть, отказ стенда, обрезка; прежний файл цел), 2 — ошибка использования (имя, нет профиля, другой стенд).""" try: prof = _export_load(name) res = _export_run(prof) except ExportUsage as e: print('[%s] %s' % (_now_iso(), e), file=sys.stderr) return 2 except ExportError as e: print('[%s] Выгрузка «%s» НЕ обновлена: %s\nПрежний файл не тронут; статус — %s' % (_now_iso(), name, e, _export_status_path(prof['out'])), file=sys.stderr) return 1 print('[%s] Выгрузка «%s»: строк %d → %s (%s), период %s, запросов %d, %s с' % (res['generated_at'], name, len(res['rows']), res['out'], prof['format'], _export_period_text(res['period']), res['requests'] + res['aux'], res['seconds'])) for w in res['warnings']: print(' ⚠️ ' + w) return 0 def _cli_export_all(): """--export-all: все выгрузки этого стенда по очереди; выгрузки других стендов пропускаются.""" profs = _export_profiles() if not profs: print('Выгрузок на этом компьютере нет (%s).' % EXPORT_DIR) return 0 ran = failed = skipped = 0 for p in profs: if p.get('_broken'): print('[%s] %s' % (_now_iso(), p['_broken']), file=sys.stderr) failed += 1 elif p.get('base_url') != BASE_URL: print('[%s] Выгрузка «%s» пропущена: она для стенда %s, сервер запущен для %s.' % (_now_iso(), p['name'], p.get('base_url'), BASE_URL)) skipped += 1 else: ran += 1 failed += 1 if _cli_export(p['name']) else 0 if failed: return 1 return 2 if skipped and not ran else 0 def _cli_list_exports(): print(_export_list_text()) return 0 def _cli_print_schedule(name, every): try: print(_export_schedule_text(name, every)) except ExportUsage as e: print(str(e), file=sys.stderr) return 2 return 0 def _build_parser(): p = argparse.ArgumentParser( prog='server.py', allow_abbrev=False, formatter_class=argparse.RawDescriptionHelpFormatter, description='MCP-сервер MARFOR %s.\nБез аргументов — режим MCP (stdio): так его запускает ' 'клиент. С аргументом — служебная команда.' % SERVER_VERSION, epilog='Коды возврата --login-finish: 0 — токен сохранён, 2 — ещё не подтверждено ' '(подождать и повторить), 1 — отказ или время вышло.\n' 'Коды возврата --export, --export-all, --print-schedule: 0 — успех, 1 — ошибка ' 'выполнения (сеть, отказ стенда, обрезанный ответ), 2 — ошибка использования.\n' 'Стенд задаёт MARFOR_BASE_URL (по умолчанию %s).\nИнструкция: %s/mcp' % (PUBLIC_BASE, PUBLIC_BASE)) g = p.add_mutually_exclusive_group() g.add_argument('--login', action='store_true', help='войти подтверждением в браузере (для человека в терминале)') g.add_argument('--login-start', action='store_true', help='начать вход: напечатать ссылку и код и сразу выйти') g.add_argument('--login-finish', action='store_true', help='завершить вход, начатый --login-start') g.add_argument('--set-token', action='store_true', help='сохранить токен, созданный вручную на сайте (скрытый ввод; нужен ' 'терминал, без терминала — только вместе с --stdin)') g.add_argument('--logout', action='store_true', help='удалить сохранённый токен этого стенда') g.add_argument('--check', action='store_true', help='диагностика: версия, стенд, откуда токен, ответ стенда') g.add_argument('--print-config', choices=('claude-code', 'claude-desktop'), metavar='КЛИЕНТ', help='готовая регистрация сервера: claude-code — команда, ' 'claude-desktop — JSON') g.add_argument('--version', action='store_true', help='напечатать версию') g.add_argument('--export', metavar='ИМЯ', help='запустить выгрузку: данные датасета → файл (так её вызывает планировщик)') g.add_argument('--export-all', action='store_true', help='запустить все выгрузки этого стенда') g.add_argument('--list-exports', action='store_true', help='выгрузки этого компьютера и итог последнего запуска') g.add_argument('--print-schedule', metavar='ИМЯ', help='готовая настройка обновления выгрузки по расписанию (вместе с --every)') p.add_argument('--every', choices=[k for k, _ in EXPORT_EVERY], help='для --print-schedule: как часто обновлять') p.add_argument('--wait', type=int, metavar='N', help='для --login-finish: сколько секунд ждать подтверждения ' '(по умолчанию %d, не больше %d)' % (LOGIN_WAIT_DEFAULT, LOGIN_WAIT_MAX)) p.add_argument('--stdin', action='store_true', help='для --set-token: прочитать токен из стандартного ввода (конвейер, ' 'файл) без приглашения — когда терминала нет') return p def _run_cli(args): """Код возврата служебной команды; None — команды нет, работаем MCP-сервером.""" if args.version: print('marfor-mcp %s' % SERVER_VERSION) return 0 if args.logout: return _cli_logout() if args.check: return _cli_check() if args.list_exports: return _cli_list_exports() if args.export is not None or args.export_all or args.print_schedule is not None: bad = _base_url_problem() if bad: raise _CliError(bad) if args.print_schedule is not None: return _cli_print_schedule(args.print_schedule, args.every) return _cli_export(args.export) if args.export is not None else _cli_export_all() if not (args.login or args.login_start or args.login_finish or args.set_token or args.print_config): return None bad = _base_url_problem() if bad: raise _CliError(bad) if args.print_config: return _cli_print_config(args.print_config) if args.login: return _cli_login() if args.login_start: return _cli_login_start() if args.login_finish: wait = LOGIN_WAIT_DEFAULT if args.wait is None else args.wait return _login_finish(max(0, min(wait, LOGIN_WAIT_MAX))) return _cli_set_token(from_stdin=args.stdin) # ──────────────────────────────── MCP поверх stdio ──────────────────────────── def _utf8_stdio(): """UTF-8 на всех трёх потоках. На Windows поток по умолчанию в cp1251/cp1252, и tools/list падал на первой же «→» в описании: клиент не видел ни одного инструмента.""" # errors: битый байт на входе не должен ронять цикл чтения, а одинокий суррогат в # названии из базы — запись ответа (внутри JSON-строки «\udXXX» остаётся верным JSON). for stream, extra in ((sys.stdin, {'errors': 'replace'}), (sys.stdout, {'newline': '\n', 'errors': 'backslashreplace'}), (sys.stderr, {'errors': 'backslashreplace'})): try: stream.reconfigure(encoding='utf-8', **extra) except Exception: pass # поток подменён или закрыт — работаем с тем, что есть def respond(msg_id, result=None, error=None): out = {'jsonrpc': '2.0', 'id': msg_id} if error is not None: out['error'] = error else: out['result'] = result sys.stdout.write(json.dumps(out, ensure_ascii=False) + '\n') sys.stdout.flush() def main(argv=None): _utf8_stdio() parser = _build_parser() args = parser.parse_args(argv) # незнакомый аргумент — ошибка argparse, код 2 if args.wait is not None and not args.login_finish: parser.error('--wait имеет смысл только вместе с --login-finish') if args.stdin and not args.set_token: parser.error('--stdin имеет смысл только вместе с --set-token') if args.every is not None and args.print_schedule is None: parser.error('--every имеет смысл только вместе с --print-schedule') if args.print_schedule is not None and args.every is None: parser.error('--print-schedule требует --every: %s' % ', '.join(k for k, _ in EXPORT_EVERY)) try: rc = _run_cli(args) except (_CliError, RuntimeError) as e: print(str(e), file=sys.stderr) return 1 except OSError as e: # не удалось записать token.json / pending.json print('Не удалось записать файл в %s: %s' % (CONFIG_DIR, e), file=sys.stderr) return 1 except KeyboardInterrupt: print('\nПрервано.', file=sys.stderr) return 130 if rc is not None: return rc bad = _base_url_problem() if bad: log('[marfor] ' + bad) return 1 serve() return 0 def serve(): tok, src = _find_token() mode = (('токен из MARFOR_TOKEN' if src == 'env' else 'токен из файла') if tok else ('пароль, %s' % EMAIL if EMAIL else 'НЕ НАСТРОЕН — нужен --login')) log(f'[marfor] MCP-сервер {SERVER_VERSION} запущен, стенд {BASE_URL}, вход: {mode}, ' f'наборы: {_toolsets_label()}' + (', только чтение' if _readonly() else '')) asked = {x.strip() for x in (os.environ.get('MARFOR_TOOLSETS') or '').lower() .replace(';', ',').split(',') if x.strip()} unknown = sorted(asked - set(TOOLSET_NAMES) - {'all'}) if unknown: log('[marfor] MARFOR_TOOLSETS: незнакомые наборы пропущены: %s (есть: %s, all)' % (', '.join(unknown), ', '.join(TOOLSET_NAMES))) if not tok and EMAIL and not BASE_URL_EXPLICIT: log('[marfor] MARFOR_EMAIL задан, а MARFOR_BASE_URL — нет: парольный вход отключён, ' 'пароль на стенд по умолчанию (%s) не отправляется. Задайте MARFOR_BASE_URL ' 'или войдите токеном (--login).' % PUBLIC_BASE) try: if sys.stdin.isatty(): log('[marfor] Сервер ждёт сообщений MCP-клиента на stdin — так и задумано. ' 'Служебные команды для человека: --help') except Exception: pass for line in sys.stdin: line = line.strip() if not line: continue try: msg = json.loads(line) except Exception: continue method, msg_id = msg.get('method'), msg.get('id') try: if method == 'initialize': want = (msg.get('params') or {}).get('protocolVersion') proto = want if want in SUPPORTED_PROTOCOLS else SUPPORTED_PROTOCOLS[0] respond(msg_id, { 'protocolVersion': proto, 'capabilities': {'tools': {}}, 'serverInfo': {'name': SERVER_NAME, 'version': SERVER_VERSION}, 'instructions': INSTRUCTIONS, }) elif method == 'notifications/initialized': pass elif method == 'ping': respond(msg_id, {}) elif method == 'tools/list': respond(msg_id, {'tools': visible_tools()}) elif method == 'tools/call': p = msg.get('params') or {} try: text = call_tool(p.get('name'), p.get('arguments') or {}) respond(msg_id, {'content': [{'type': 'text', 'text': str(text)}]}) except Exception as e: log(f'[marfor] ошибка инструмента {p.get("name")}: {e}') respond(msg_id, {'content': [{'type': 'text', 'text': f'Ошибка: {e}'}], 'isError': True}) elif msg_id is not None: respond(msg_id, error={'code': -32601, 'message': f'нет метода {method}'}) except Exception as e: log(f'[marfor] сбой обработки {method}: {e}') if msg_id is not None: respond(msg_id, error={'code': -32603, 'message': str(e)}) if __name__ == '__main__': sys.exit(main())