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,
падает в той сборке, где он собран, а не при загрузке в реестр.
Имя — единственное, что никогда не выводится автоматически
with_name("weather") задаёт MCP-имя: то, что несёт serverInfo и что
видит пользователь в клиенте. name в реестре — это обратно-доменный
идентификатор в пространстве имён, владение которым вы доказали:
io.github.<user>/<server>.
Это разные строки с разными правилами, и подстановка одной вместо другой даёт
манифест, который не пройдёт проверку владения пространством имён. Поэтому
server_manifest(name) всегда требует его явно.
Что выводится, а что говорите вы
| Поле | Откуда берётся |
|---|---|
version | with_version(..) либо версия крейта через with_cargo |
packages[].transport | Транспорт, на который настроено приложение |
packages[] (запись cargo) | with_cargo / with_cargo_package — имя и версия крейта |
description | description из Cargo.toml, если with_description не задал своё раньше |
repository | repository из Cargo.toml, когда это URL на github.com или gitlab.com |
websiteUrl | homepage из 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.
Публикация
-
cargo publish— чтобы было что устанавливать. -
Поместите имя сервера в README крейта видимым текстом — так доказывается владение на crates.io:
- MCP Registry name: `mcp-name: io.github.<your-user>/<your-server>`Не HTML-комментарием: crates.io вырезает их при рендеринге markdown, а валидатор читает отрендеренный HTML. (PyPI и NuGet комментарии сохраняют, cargo — исключение.)
-
Сгенерируйте манифест, докажите, что пространство имён ваше, и публикуйте:
cargo run -- --emit-manifest > server.jsonmcp-publisher login githubmcp-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.