Shared parts of the Sylphx MCP servers (repomap, lockdocs, anymd), so each server keeps only its own tools.
| Part | What it does |
|---|---|
Rust crate sylphx-mcp-kit |
Runs an MCP server over stdio on rmcp. Picks the directory a call works on (argument, env var, --root, the client's roots, the working directory). Registers the server with MCP clients (setup) and adds a Claude Code hook. With the embed feature: local embeddings from a small static model. |
npm/launcher.js |
The bin script of an npm package. It runs the native binary for this platform from an optional dependency. |
.github/workflows/release.yml |
A reusable release workflow. It builds 5 native binaries, publishes to npm with trusted publishing, smoke-tests with npx, and creates the GitHub release and MCP Registry entry. Optionally it attaches MCP Bundles (.mcpb), pushes a GHCR image and retires old registry names. |
MIT licensed.
[dependencies]
sylphx-mcp-kit = "0.7.3"
# only the embeddings, without the server and setup parts:
# sylphx-mcp-kit = { version = "0.7.3", default-features = false, features = ["embed"] }Features: server and setup (default), embed, search, licence, remote. Cache roots and CLI hints need no feature.
use mcp_kit::server::{run_stdio, App, Call, Info};
use serde_json::{json, Value};
struct Echo;
impl App for Echo {
fn info(&self) -> Info {
Info { name: "echo".into(), title: "Echo".into(), version: "1.0.0".into(),
website: "https://example.com".into(), instructions: "Echoes text.".into() }
}
fn tools(&self) -> Vec<Value> {
vec![json!({"name": "echo", "description": "Echo `text`.",
"inputSchema": {"type": "object", "properties": {"text": {"type": "string"}}}})]
}
fn call(&self, name: &str, args: &Value, call: &Call) -> Result<String, String> {
// call.client_roots: the client's workspace folders that exist here.
Ok(args["text"].as_str().unwrap_or_default().to_string())
}
}
fn main() -> anyhow::Result<()> {
run_stdio(Echo)
}callruns on a blocking thread.Err(text)goes back to the agent as a readable tool error.warm(optional) runs once after the client connects. Use it to fill a cache.roots::pickchooses the working directory from the call's arguments andcall.client_roots.
rmcp is the official Rust SDK, kept in step with the MCP spec, so a server needs no hand-written JSON-RPC loop. It handles:
- protocol version negotiation over every published version
- cancellation, progress and logging
- pagination and result caching fields
- tasks, and structured and error results with the right shape for each protocol version
With the remote feature the same App is served over Streamable HTTP as an OAuth resource server, the way the MCP authorization spec asks:
use mcp_kit::remote::{serve, Remote};
let remote = Remote::new("https://example.com/mcp", "https://auth.example.com")
.scopes(["things:read", "things:write"])
.require(["things:read"]) // every request
.tool_scopes("make_thing", ["things:write"]); // one tool (step-up)
serve(Echo, remote, "0.0.0.0:8080").await?;POST /mcpis stateless (any replica answers), with JSON responses.GET /.well-known/oauth-protected-resource/mcpis the RFC 9728 metadata naming the issuer.- The bearer must be a JWT access token signed by a key in the issuer's JWKS (
jwks_uri, or discovered through RFC 8414, then OpenID Connect; refreshed every 10 minutes and on an unknown key id), issued by that issuer, with the resource URL inaud(RFC 8707) and a liveexp(60 s skew). ES256, RS256, PS256 and EdDSA are accepted;noneand HMAC never are. - No token or a bad one is 401, a missing scope 403
insufficient_scope; both carry an RFC 6750WWW-Authenticatechallenge withresource_metadata, so a client can find the authorization server and ask for the scope. - Override
App::call_asto act for the caller: it receives aremote::Principal(subject, scopes, all claims). Hostmust be the resource URL's host (DNS-rebinding defence);allowed_hostsandallowed_originschange that.
use mcp_kit::setup::{run, Options, Server};
let server = Server { name: "repomap".into(), package: "@sylphx/repomap".into(), args: vec!["mcp".into()] };
run(&server, &Options { dry_run: false, remove: false, clients: None })?;Supported clients:
| Client | Where the entry is written |
|---|---|
| Claude Code | claude mcp add --scope user, or ~/.claude.json |
| Codex | ~/.codex/config.toml |
| Cursor | ~/.cursor/mcp.json |
| VS Code (and Insiders) | the user mcp.json |
| Claude Desktop | its config |
| Windsurf | its config |
| Gemini CLI | its config |
Every change is safe to repeat and is printed. remove: true undoes it. setup::claude_hook adds, updates or removes a Claude Code hook identified by a marker string.
use mcp_kit::embed::{self, Model, POTION_CODE_16M};
embed::ensure(&POTION_CODE_16M, "tool", "Set TOOL_EMBED=0 to stay keyword-only.")?; // once, 33 MB
let model = Model::load(&POTION_CODE_16M)?;
let v = model.embed("where are failed requests retried").unwrap(); // unit length, 256 numbers- Models:
POTION_CODE_16M(potion-code-16M-v2, code search) andPOTION_RETRIEVAL_32M(English text). Both are MIT licensed model2vec static models: an embedding is the mean of the token vectors, so a CPU embeds a large repository in about a second. ensuredownloads the pinned revision from Hugging Face once, checks its SHA-256, and stores it as int8 in~/.cache/sylphx/models(orSYLPHX_MODEL_DIR), shared by every tool. It prints one line before downloading. After a failed download it waits an hour before trying again.- The tokenizer matches the model's own (BERT normalization and WordPiece). CI checks tokens and vectors against
model2vecitself. Vec8,quantizeandcosinestore and compare vectors as int8.
searchowns identifier splitting,tokenize,path_terms,chunk_termsand UTF-8-safe byte limits (floor_char). Ranking stays in each tool.star_hint::after_success(message, opt_out_env, state_dir, mcp)prints once, after the fifth successful interactive CLI run. It never counts or prints in MCP mode, with non-TTY stderr, in CI, or when opted out. It keeps the existingstar-hintcounter format; callers choose their cache root.cache::root(env, product_dir, fallback, override_policy)preserves the caller's cache rules: OS cache with temp/no fallback, or environment-only platform paths. Overrides are either nonempty OS strings or UTF-8 strings including empty strings. Callers keep their own layout and retention.embed::ensure_atandModel::load_dirsupport existing model caches and full model URL overrides.Tokenization::Identifierskeeps the original identifier-aware, untruncated vectors; the default model2vec behavior is unchanged. Themodel.q8,vocab.txtand serializedVec8formats are unchanged, so existing embedding indexes stay readable.
The licence feature (off by default) lets a server keep its core free and unlock extra tools with an offline-verified token. The kit holds no product logic: a server describes itself in one LicencePolicy.
use mcp_kit::licence::{require, run_cli, LicencePolicy};
const POLICY: LicencePolicy = LicencePolicy {
product: "lockdocs",
require_product: true, // false only for anymd back-compat
accepted_plans: &["pro", "team"],
public_keys: &["<base64url Ed25519 public key>"], // a list, so rotation is additive
env_var: "LOCKDOCS_LICENCE_TOKEN", // read first
file_name: "licence", // then <config dir>/lockdocs/licence
upgrade_url: "https://example.com/pro", // also the renewal link in expiry warnings
tier: "Pro", // what users see: "lockdocs Pro", or "Team"
checkout_base: Some("https://checkout.example.com"), // enables `licence buy`; None prints upgrade_url
};
// In a Pro tool: the licence, or a polite notice the agent relays.
match require(&POLICY, "Team reports") {
Ok(licence) => { /* licence.plan, .seats, .expires_at, ... */ }
Err(required) => return Ok(required.to_string()),
}- Token:
base64url(payloadJSON).base64url(Ed25519 signature over the payload bytes), payload{"plan", "email"?, "issuedAt" (ms), "product"?, "order"?, "grant"?, "seats"?, "expiresAt"? (ms)}. Unknown fields are ignored. It is byte-compatible with the anymd and GPDT verifiers. policy.verify(token)(orlicence::verify(&policy, token)) checks the signature against any key, then the plan, theproduct(a token naming another product is refused; one naming none is refused whenrequire_productis true), andexpiresAt.requirereads the env var first, even when it holds a bad token (no silent fallback), then the token file.- An unlicensed call is not an error.
required_result_json(&required)returns the MCP result: text " is part of . Learn more and get it: " plusstructuredContent{"pro_required": {"feature", "product", "tier", "url"}}so an agent can act on it. With theserverfeature,required_resultreturns the rmcp type: overrideApp::call_resultin a gated tool to return it. A Pro tool must not declare anoutputSchemawithoutpro_required. run_cli(&POLICY, args)is thelicence status | activate <token>subcommand to mount in the server binary;activateverifies the token and writes the file with 0600 permissions.statusalso prints where the token was read (env var or file) and why it is invalid, and during the last 30 days of a licence it warns "expires in N days" withupgrade_urlas the renewal link.licence.expires_soon(Duration)gives the same check to your own code, e.g. to add a line to a Pro answer; it is false for a licence with noexpiresAt, and true when expiry is within the window (inclusive) or past.licence buy [--pack <id>] [--qty <n>] [--no-browser] [--json](inrun_cli) sells through the shared checkout service atcheckout_base(https only;Noneprintsupgrade_url). It creates a claim (POST {checkout_base}/api/v1/claims), prints the browser URL and opens it unless--no-browser,--json, no display or non-interactive, then polls the claim (Retry-Afterhonoured, backoff capped at 5 s, 30 minutes at most). When paid it verifies the returned tokens, saves the first valid one the wayactivatedoes and prints thestatusreport; on expiry or timeout it points at{checkout_base}/recover. Nothing is saved before a token verifies, so Ctrl+C leaves no state.- For agents,
--jsonprints a line withclaim_idandbrowser_urlas soon as the claim exists (show the link to your user) and a final{"event":"result","status":...}line. Exit codes: 0 paid, 1 failed, 2 usage, 3 expired, 4 timed out.
A runnable example is in crates/mcp-kit/examples/licence_server.rs.
Copy npm/launcher.js to packages/<name>/bin/<name>.js, and declare one optional dependency per platform:
{
"name": "@sylphx/tool",
"bin": { "tool": "bin/tool.js" },
"optionalDependencies": {
"@sylphx/tool-darwin-arm64": "1.0.0", "@sylphx/tool-darwin-x64": "1.0.0",
"@sylphx/tool-linux-x64-gnu": "1.0.0", "@sylphx/tool-linux-arm64-gnu": "1.0.0",
"@sylphx/tool-win32-x64-msvc": "1.0.0"
}
}Each platform package (packages/npm/<platform>/package.json) sets os, cpu and, on Linux, libc, and ships the binary. TOOL_BIN=/path overrides the binary.
# .github/workflows/release.yml in the server's repo
name: release
on:
push: { branches: [main] }
workflow_dispatch:
jobs:
release:
uses: SylphxAI/mcp-kit/.github/workflows/release.yml@v0
permissions: { contents: write, id-token: write, packages: write }
with:
name: tool
npm-package: '@sylphx/tool'
mcp-name: io.github.SylphxAI/toolThe workflow checks every requested delivery channel: all five native npm packages, the launcher and aliases, the GitHub release assets (including requested bundles), the exact MCP Registry version, and the optional GHCR image. A fresh run finishes missing channels without republishing delivered packages or overwriting existing release assets. HTTP 404 alone means absent; authentication, throttling, server and transport errors stop the run rather than authorizing publication.
Opt in with one input each; the release then writes Formula/<name>.rb (macOS and Linux, arm64 and x86_64) to the tap and bucket/<name>.json (Windows x64, with checkver and autoupdate) to the bucket, from the digests of the release archives. It runs after the release, and again on a no-op run, so a missed update heals. Unchanged files are not committed.
with:
homebrew: true # and/or scoop: true
secrets:
TAP_APP_ID: ${{ secrets.TAP_APP_ID }}
TAP_APP_PRIVATE_KEY: ${{ secrets.TAP_APP_PRIVATE_KEY }}| Input | Default |
|---|---|
homebrew, scoop |
false |
homebrew-tap |
SylphxAI/homebrew-tap |
scoop-bucket |
SylphxAI/scoop-bucket |
description, homepage, license |
the main npm package's, then the GitHub repository for homepage |
Writing to another repository uses a GitHub App, never a token: create an org app with Contents: read and write on the tap and bucket repositories only, install it on those two, and store its ID and private key as org secrets TAP_APP_ID and TAP_APP_PRIVATE_KEY (visible to the server repositories). Pass them by name as above and never use secrets: inherit: it would hand the reusable workflow every caller secret, including publish tokens. Without the secrets the job skips with a notice and the release is unaffected. Tap and bucket must share one owner. Winget is not generated.
Users: brew install SylphxAI/tap/<name>, scoop bucket add sylphx https://github.com/SylphxAI/scoop-bucket && scoop install <name>. The rendering lives in scripts/package-managers.mjs.
The selected Cargo package, npm manifests and server.json must carry the same version and publication identity. Executable native targets must report that exact version. Every native artifact carries its platform, version, source and binary SHA-256; staging checks these before publishing, including cross-compiled targets.
A version has one canonical repository commit, established from original platform identities and published native source revisions; conflicting sources stop the run. Recovery preserves the original platform identity and verifies its binary digest, version and canonical source. GitHub archives require a digest-verified original identity asset; npm fallback requires registry integrity, the embedded manifest and original identity, with a matching registry source revision. Already-complete legacy releases remain verified no-ops: every native, launcher and alias must share an immutable npm source revision, the exact MCP Registry version must be active, and all five archives and requested bundles must exist. No new sidecars or build provenance are manufactured. A requested legacy GHCR image must have both digest-verified Linux architectures and an established GitHub Packages version record binding the exact version tag to its index digest; this verifies existing delivery, not native-source provenance. Any conflicting version label fails. When recovery is needed, missing GitHub sidecars are not proof of a legacy release: the planner first verifies available npm tarball integrity, embedded manifests and original native identities against the shared canonical source. Interrupted modern deliveries resume using those original identities. Legacy partial releases without original identities fail closed when a requested channel needs recovery, rather than relabelling or overwriting old bytes. Missing natives compile from the canonical commit, not a later same-version main commit. GHCR readback verifies digest-addressed child manifests and configs for both architectures, checking version, canonical revision, source and native digests; an existing stale version tag is an error. Authorized GitHub package metadata confirms first-image absence; token denials never mean absent. Old MCP Registry names are retired only after the replacement's exact version is verified available, including otherwise complete no-op runs.
kit-ref supplies the release helpers as well as the bundle builder. When pinning the reusable workflow to a commit, pin kit-ref to that same commit. The release publishes npm packages and then waits per package (natives, main, aliases) with npm view name@V version, backing off from 5 s to 30 s for up to 10 minutes and retrying only ETARGET, 404 and "No matching version" (scripts/npm-settle.sh) before the npx smoke; a caller's smoke can . "$SETTLE_LIB" and run checks through settle. This change bumps the compatible kit version to 0.3.3. Changes on main do not reach @v0 callers until the normal kit publication updates that tag; do not move it while a workflow change is still under review.
The success gates follow GitHub Actions dependency and status-check semantics. Publisher authentication follows npm trusted publishing; authentication does not replace artifact identity or delivery readback.
npm trusted publishing checks the calling workflow file. So every npm package trusts <owner>/<repo> with the file release.yml:
npm trust github @sylphx/tool --file release.yml --repo SylphxAI/tool --allow-publish --otp <code>Inputs: alias-dirs, smoke, docker-image, retired-mcp-names, retired-message, major-tag, mcpb and mcpb-icon. They are documented in the workflow file.
mcpb: true attaches MCP Bundles to the GitHub release, for one-click install in Claude Desktop and other hosts:
<name>-<version>.mcpb: every platform's binary and a small Node launcher (hosts such as Claude Desktop ship Node).<name>-<version>-<platform>.mcpb: one binary each, about a fifth of the size.
The manifest comes from server.json (title, description, website, arguments) and the npm package (license, keywords). The tool list comes from starting the server once. An optional mcpb.json at the repository root is merged into every manifest, for example to ask for a project folder:
{
"server": { "mcp_config": { "env": { "TOOL_ROOT": "${user_config.project}" } } },
"user_config": { "project": { "type": "directory", "title": "Project folder", "description": "…", "required": true } }
}mcpb-icon points at a 512×512 PNG. scripts/mcpb.mjs builds the bundles with the official mcpb CLI; scripts/mcpb.test.mjs checks them.
Bump version in Cargo.toml and merge. publish.yml publishes the crate to crates.io, tags vX.Y.Z and moves v0, which the servers' release workflows use. A change to the release workflow reaches the servers only with a version bump.