Feat/mcp 2026 - #38
Merged
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.