Skip to content

Feat/mcp 2026 - #38

Merged
vyrti merged 10 commits into
mainfrom
feat/mcp-2026
Aug 14, 2026
Merged

Feat/mcp 2026#38
vyrti merged 10 commits into
mainfrom
feat/mcp-2026

Conversation

@vyrti

@vyrti vyrti commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

No description provided.

vyrti added 10 commits August 13, 2026 23:14
The MCP server implemented the 2024-11-05 HTTP+SSE transport (GET /sse then
POST /mcp/message?client_id=) while claiming protocolVersion 2025-03-26. That
transport is now formally Deprecated, and the version string never matched what
the code did.

Replace both endpoints with a single POST /mcp speaking 2026-07-28: server/discover,
per-request _meta, mirrored Mcp-Method/Mcp-Name headers validated against the body,
resultType, ttlMs/cacheScope, and structuredContent alongside the text mirror.

Answer the initialize handshake on the same endpoint for 2025-03-26 through
2025-11-25. Shipping clients are still on that era — Claude Code 2.1.231 opens with
initialize at 2025-11-25 and sends no mirrored headers — so a modern-only endpoint
would refuse the clients this server exists to be driven by. The era is resolved per
request, and legacy results are not stamped with fields their schemas predate.

Removing protocol sessions removes the state behind them: AppState::mcp_clients,
McpClient, the client-id capability and the 30-minute TTL are all gone. With the
response travelling in the POST body rather than over a separate SSE stream, an
oversize result is now an error the client actually receives instead of a hang.

Also:
- Validate Origin, which the transport requires and nothing did before.
- Register on the primary listener only, not on both ports.
- Add [mcp] config (enabled, read_only, require_auth) with VUIO_MCP_* env vars,
  defaulting to today's behaviour, plus an Admin tab section.
- Give every tool a title, outputSchema and behaviour annotations; read_only hides
  the mutating and casting tools from tools/list and refuses them by name.
- Add list_library_roots, list_music_categories, find_music, get_playback_status,
  reorder_playlist and cast_folder_to_renderer over queries that already existed.
- Return stream_url, cover_url and subtitle_url plus the full tag and stream
  metadata, so a result is actionable rather than merely descriptive.
- Page browse_folder instead of reading a whole directory, and check the path
  against the configured roots the way /api/browse does.
The startup banner said nothing about an MCP endpoint that any host on the
network could reach without a credential, even though its tools delete playlists
and start playback on real devices. Warn once at bind time when it is neither on
loopback nor behind a token, naming the three settings that close it.

Also carry [mcp] into config.example.toml and the VUIO_MCP_* variables into both
compose files, so the section is discoverable from the places operators read.
Search was LIKE '%needle%' over filename, title, artist and album. A leading
wildcard is unindexable, so every query read the whole table; results came back
in row order because there was no relevance to sort by; and a library with
accents or punctuation in it searched badly or not at all.

Schema v4 adds two FTS5 indexes. media_fts covers filename, title, artist, album,
album artist, genre, composer and comment; mediainfo_fts covers the titles and
synopsis fetched from metadata providers, which live in another table and so need
their own index. Both are external-content, so the text is not duplicated and a
rebuild is one statement. Searches union the two and keep the better rank per
file, so a film matching on both title and synopsis ranks well rather than
appearing twice.

Both are maintained by triggers rather than at the call sites. media_files is
written from the scanner's bulk paths, the watcher's incremental ones, subtree
removal and native cleanup; a trigger covers all of them by construction and
cannot be forgotten by the next write path someone adds.

User text is turned into a MATCH expression by quoting each token and joining
with AND, with the last token also matching as a prefix. Unquoted, a search for
AC/DC or "rock & roll" is an FTS5 syntax error rather than a query. A query with
no searchable tokens matches nothing rather than everything.

MediaFileQuery::Search is separate from Filtered because the two cannot share a
cursor: Filtered scans in id order and resumes from after_id, while ranked results
have no such anchor and page by offset. /api/media?query= and the MCP search_media
tool both route through it, so the browser, DLNA and agents get the same ranking.

The migration backfills rows already on disk — a record only reaches the index
through a trigger, and existing ones predate it — and rebuild_derived_indexes
recomputes both indexes as the repair path.

list_library_roots now reports what the last scan indexed rather than a count of
the root's direct children, which was zero for any library organised in folders.
Some MCP clients will only launch a local process and talk to it over pipes;
Claude Desktop is the one that matters. VuIO had no way to be that process, so
those clients could not reach it at all.

`vuio mcp --url <server>` reads JSON-RPC on stdin, forwards each message to the
server's /mcp endpoint, and writes the answers to stdout. It is a proxy, not a
second server: running the tools in-process would put a second writer on a
single-writer SQLite file, and the casting tools need the renderer cache and SSDP
state that only the running server has.

The proxy is also the boundary between the two eras of the protocol. It speaks
the stateless revision upstream and answers initialize and ping itself, because
neither exists there and a stdio client is very likely to open with one. It
derives the mirrored Mcp-Method and Mcp-Name headers from each message, so the
client never has to know it is talking to HTTP.

A transport failure is answered with a JSON-RPC error carrying the client's own
id rather than dropped, so a client waiting on a response fails one call instead
of hanging the session.

vuio-cli gains its first subcommand. `args_conflicts_with_subcommands` keeps the
bare `vuio /media` form working, with a test to prove it.
The server spoke MCP but nothing carried that to a user. Connecting it meant
hand-writing a client config, and the tool schemas alone do not say which order
to call things in — that renderer ids are stable while friendly names are not,
that casting is discover → resolve → cast → verify, or that the facet tools match
tag values exactly while search does not.

claude/plugin is a Claude Code plugin bundling the MCP server entry, a skill
carrying those workflows, and three commands (/cast, /playlist, /library). With
.claude-plugin/marketplace.json it installs in two lines:

    claude plugin marketplace add vuiodev/vuio
    claude plugin install vuio@vuio

claude/mcpb packages the stdio bridge as an MCP Bundle for Claude Desktop, which
launches a local process rather than calling an endpoint. build.sh stages the
release binary and zips it; the manifest prompts for the server address and an
optional token file.

mcp/reference.json is now generated from the catalog and checked by a test. It
was maintained by hand and had drifted badly — it still documented list_tvs,
cast_media_to_tv and control_tv, renamed to *_renderer several releases ago.
Regenerate with VUIO_UPDATE_MCP_REFERENCE=1.

mcp/mcp.json becomes an http entry pointing at /mcp with an Authorization header,
replacing the sse entry that only worked with authentication switched off.
The four MCP tool entry points in the cast helpers are reachable only from the
MCP dispatcher, and cast_folder_to_renderer reached back into web::mcp for path
resolution. That reintroduced exactly the coupling this module was split out to
remove: `--no-default-features --features casting` stopped compiling, which the
CI feature matrix builds.

Gate them on the mcp feature, where they belong — without it they are dead code
as well as a broken reference.
… fmt and clippy

`generate_test_media` lost its `[[bin]]` entry, and with it the
`required-features = ["testdata"]` that kept it out of ordinary builds. Cargo
then auto-discovered `src/bin/generate_test_media.rs` instead, and an
auto-discovered target carries no feature requirement — so every build without
`testdata` tried to compile it and failed on the `audiotags` import it needs.

Declare all three binaries explicitly and turn `autobins` off, so a file dropped
into `src/bin/` cannot silently become a target of the default build again. The
`readme` key went missing in the same edit and is restored; it is publish
metadata, and crates.io is on the roadmap.

Also:
- `10_000.max(1)` in the benchmark is a constant that cannot be smaller than 1,
  which clippy rejects.
- Format the workspace.

And fix a test that could never pass on a machine already running VuIO:
`advertised_service_is_discoverable` accepted any mDNS service whose name
contained "VuIO", so a developer's own server was resolved instead of the
advertisement the test had just published, and the assertions then compared its
uuid against one that was never ours. Match on that uuid instead.
`System::new_all()` enumerates every process up front. On FreeBSD that is
`kinfo_getfile`, the same family of sysinfo call as the disk enumeration beside
it, and it faults the same way under the QEMU guest CI runs in — which is why
three test binaries are already excluded there with "AppState harness SIGSEGV".
`issue_24_pagination` is a fourth of the same shape and has now started crashing
too.

Every `AppState` constructs one of these collectors, including in tests that
never take a sample, so the walk is pure cost on a hot path. `refresh()` already
calls `refresh_all()`, which repeats it. On FreeBSD, leave the table to that
call; elsewhere keep priming it, since a CPU reading needs an earlier sample to
difference against and the first one would otherwise report zero.

Confined to FreeBSD, matching the existing workaround for `Disks` directly below
and for the same reason.
@vyrti
vyrti merged commit 4683e10 into main Aug 14, 2026
23 checks passed
@vyrti
vyrti deleted the feat/mcp-2026 branch August 14, 2026 01:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant