Перейти к основному содержимому

MCP Registry

MCP Registry — это то, как сервер находят и устанавливают: автор публикует server.json в пространстве имён, владение которым он доказал, а клиенты читают запись, чтобы понять, что устанавливать и как это запускать.

Бо́льшую часть того, что попадает в этот документ, сервер уже знает — свою версию, настроенный транспорт, крейт, который собирает Cargo, — поэтому neva генерирует манифест, вместо того чтобы оставлять его на ручное заполнение и на расхождение с кодом при следующем релизе.

[dependencies]
neva = { version = "...", features = ["server-macros", "registry"] }

registry входит в server-full. Это только типы и валидатор, новых зависимостей он не тянет, и включается явно — чтобы встроенный сервер не носил его с собой.

Манифест печатает сам сервер​

Манифест — это артефакт сборки, а не файл, который вы поддерживаете вручную. Дайте бинарнику флаг, который его печатает, и опубликованная версия будет ровно той, которую вы только что собрали:

use neva::prelude::*;

#[tool(descr = "Возвращает прогноз для города")]
async fn forecast(city: String) -> String {
format!("В городе {city}, как всегда, солнечно")
}

#[tokio::main]
async fn main() {
let app = App::new().with_options(|opt| opt
.with_stdio()
.with_name("weather")
.with_version(env!("CARGO_PKG_VERSION")));

if std::env::args().any(|arg| arg == "--emit-manifest") {
// То, что знает приложение, плюс то, что знает Cargo об этом крейте.
match neva::server_manifest!(app, "io.github.example-user/weather").to_json() {
Ok(json) => print!("{json}"),
Err(err) => {
eprintln!("такой server.json схеме не подходит: {err}");
std::process::exit(1);
}
}
return;
}

app.run().await;
}
cargo run -- --emit-manifest > server.json

Ненулевой код выхода на невалидном манифесте — это и есть смысл держать генерацию здесь: релиз, который не может выдать корректный server.json, падает в той сборке, где он собран, а не при загрузке в реестр.

Имя — единственное, что никогда не выводится автоматически​

Имя в реестре — это не имя MCP-сервера

with_name("weather") задаёт MCP-имя: то, что несёт serverInfo и что видит пользователь в клиенте. name в реестре — это обратно-доменный идентификатор в пространстве имён, владение которым вы доказали: io.github.<user>/<server>.

Это разные строки с разными правилами, и подстановка одной вместо другой даёт манифест, который не пройдёт проверку владения пространством имён. Поэтому server_manifest(name) всегда требует его явно.

Что выводится, а что говорите вы​

ПолеОткуда берётся
versionwith_version(..) либо версия крейта через with_cargo
packages[].transportТранспорт, на который настроено приложение
packages[] (запись cargo)with_cargo / with_cargo_package — имя и версия крейта
descriptiondescription из Cargo.toml, если with_description не задал своё раньше
repositoryrepository из Cargo.toml, когда это URL на github.com или gitlab.com
websiteUrlhomepage из Cargo.toml
nameВсегда ваше — см. выше
title, icons, _metaВсегда ваши

with_cargo никогда не перезаписывает уже заданное, поэтому сказать что-то отличное от того, что говорит Cargo, — значит сказать это до вызова with_cargo.

use neva::prelude::*;
use neva::registry::KeyValueInput;

fn main() {
let app = App::new().with_options(|opt| opt
.with_stdio()
.with_name("weather")
.with_version("0.3.0"));

let manifest = app
.server_manifest("io.github.example-user/weather")
.with_title("Weather")
// Сказано до `with_cargo_package`, поэтому описание крейта не победит:
// реестр даёт 100 символов, а crates.io на это не смотрит.
.with_description("Прогнозы национальной метеослужбы")
.with_cargo_package(neva::cargo_env!(), |package| package
.with_environment_variable(
KeyValueInput::new("WEATHER_API_KEY")
.with_description("Ключ API поставщика прогнозов")
.required()
.secret()));

print!("{}", manifest.to_json().expect("полный манифест"));
}

cargo_env!() — именно макрос, а не функция: CARGO_PKG_* — это compile-time значения вашего крейта, а функция внутри neva прочитала бы значения самой neva. В build.rs он тоже работает — там манифест пишут не реже.

Правило версии​

App::server_manifest публикует версию только тогда, когда with_version действительно вызывали, — а не когда значение просто выглядит заданным. Приложение, которое его не вызывало, сообщает версию самой neva, и опубликовать версию SDK как версию сервера — ошибка, которую никто не заметит. В этом случае поле остаётся для with_cargo, чтобы тот взял версию из крейта, и отвергается валидацией, если его так никто и не заполнил.

Версия, сознательно выставленная в ту же, что сейчас у neva, — всё равно ваша и сохраняется.

Правило транспорта​

Запись пакета говорит, как клиент общается с тем, что он установил, и это читается с приложения:

  • with_stdio() → "transport": { "type": "stdio" } — то, что оставляет после себя cargo install: бинарник в PATH, который клиент запускает сам;
  • with_http(..) / with_default_http() → streamable-http по URL, на котором сервер отвечает. Привязка на wildcard публикуется как адрес, по которому клиент действительно может постучаться: 0.0.0.0:3000 → 127.0.0.1:3000, [::] → [::1].

Приложение без транспорта не даёт транспорта вовсе — а не stdio по умолчанию: такой сервер не может запуститься, и публиковать для него установку значило бы публиковать заведомо неработающее. Валидация об этом скажет.

Как описать пакет​

Всё, что клиенту нужно, чтобы запустить сервер, живёт в записи пакета:

МетодДля чего
with_environment_variable(KeyValueInput::new(..))Переменные окружения, с которыми запускают сервер
with_package_argument(Argument::named("--port"))Аргументы самого сервера
with_runtime_argument(..)Аргументы среды запуска (контейнера, лаунчера)
with_runtime_hint("docker")Чем клиенту запускать — пакету cargo не нужно
with_file_sha256(..)Хеш архива mcpb

KeyValueInput и Argument несут то, что нужно UI настройки в клиенте: .required(), .secret(), .with_default(..), .with_choices([..]), .with_placeholder(..), .with_format(InputFormat::Number).

Пользовательские значения — лучше через переменные окружения

Аргументы попадают в командную строку, и клиент, который прогоняет её через шелл, можно заставить запустить больше, чем сервер. Секрету место в with_environment_variable(..) с пометкой .secret().

Другие типы пакетов и уже запущенные серверы​

Package::cargo(..) — это сокращение; Package::new(..) принимает любой тип. RegistryType называет Cargo, Oci и Mcpb, а Other(..) — реестр, который этот SDK не знает по имени: до реестра доезжает строка, поэтому Other("mcpb".into()) — это пакет mcpb со всеми правилами mcpb.

Хостируемому серверу нечего устанавливать, поэтому он описывается как remote: URL для вызова, рядом с которым объявлены все {подстановки} в нём.

use neva::registry::{Input, Remote, ServerManifest, Transport};

fn main() {
let manifest = ServerManifest::new("io.github.example-user/weather", "0.3.0")
.with_description("Прогнозы национальной метеослужбы")
.with_remote(
Remote::new(Transport::streamable_http("https://{tenant}.weather.example/mcp"))
.with_variable("tenant", Input::new()
.with_description("ваш тенант")
.required()));

assert!(manifest.to_json().is_ok());
}

Манифест может нести и то и другое: пакеты — для тех, кто хостит сам, remotes — для хостируемого предложения. Хотя бы одно из двух обязательно.

Remote никогда не бывает stdio — на этой стороне нет процесса, который можно запустить, — а каждый {name} в URL должен заполняться чем-то, что объявлено рядом: variables у remote либо переменные окружения и аргументы у пакета. Проверяется и то, и другое.

Проверка происходит до загрузки, а не во время неё​

to_json() сначала выполняет validate(): в server.json, который не соответствует схеме, нет никакого смысла. Ловятся ровно те правила, которые манифест нарушает, выглядя при этом совершенно нормально:

  • name вне обратно-доменной формы (чаще всего — просто weather);
  • description длиннее 100 символов — тот самый предел, на котором все спотыкаются, потому что у crates.io такого потолка нет, — или вовсе пустое;
  • title, который есть, но пустой;
  • диапазон версий (^1.2, >=1.0, <2.0) там, где нужна точная версия;
  • поля format: uri — они разбираются парсером, а не сверяются по префиксу;
  • источник иконки не по https:// или длиннее 255 символов: data:-URI с самой картинкой — это URI, но не иконка для витрины;
  • {template}, который ничто не объявляет;
  • нечего устанавливать и некуда звонить;
  • remote поверх stdio или пакет, выведенный из приложения без транспорта.
Это не валидатор конкретного реестра

У реестра есть собственные правила — с каких хостов он готов забрать архив, какие базовые URL принимает, как относится к loopback-адресу, — и менять их его право. Ok здесь означает, что документ имеет форму, описанную схемой; когда реестр его отвергает, он называет своё нарушенное правило, и действовать нужно по этому сообщению.

$schema в документе зафиксирована, а не указывает на draft: опубликованный манифест не должен менять смысл под автором. Переопределите её через with_schema_url(..), если новая схема выйдет раньше, чем её подхватит neva.

Публикация​

  1. cargo publish — чтобы было что устанавливать.

  2. Поместите имя сервера в README крейта видимым текстом — так доказывается владение на crates.io:

    - MCP Registry name: `mcp-name: io.github.<your-user>/<your-server>`

    Не HTML-комментарием: crates.io вырезает их при рендеринге markdown, а валидатор читает отрендеренный HTML. (PyPI и NuGet комментарии сохраняют, cargo — исключение.)

  3. Сгенерируйте манифест, докажите, что пространство имён ваше, и публикуйте:

    cargo run -- --emit-manifest > server.json
    mcp-publisher login github
    mcp-publisher publish

login github и делает io.github.<user>/… вашим для публикации; доменное пространство имён (com.example/…) доказывается через DNS.

mcp-publisher init для крейта на Rust пишет не тот файл

Он определяет тип пакета по наличию package.json, pyproject.toml или Dockerfile и по умолчанию скатывается в npm — крейт выходит как "registryType": "npm" с заглушкой вместо идентификатора. К тому же генерация из сервера держит версию в актуальном состоянии на каждом релизе, чего файл, написанный однажды и правленный руками, не делает.

Учимся на примере​

Полный путь — от --emit-manifest до mcp-publisher publish — разобран в examples/registry.