import { test } from "node:test"; import assert from "node:assert/strict"; import { readdir } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; import type { ChannelTranscriptsManifest } from "yt-dlp-transcript-common/lib/manifest"; import type { TranscriptDetail } from "yt-dlp-transcript-common/lib/transcripts"; import type { SearchAlias } from "yt-dlp-transcript-common/lib/searchAliases"; import type { ChannelGroups, ChannelRef, HubSite, ShardSource, VideoAvailability, } from "./source"; import type { SourceSpec } from "./sources"; import { SourceRegistry, handleFor } from "./sourceRegistry"; import { createServer } from "./server"; // ─── A build factory over in-memory stub sources (no HTTP) ─── const HUB_SITES: HubSite[] = [ { siteId: "alpha", title: "Alpha Site", url: "https://alpha.example" }, { siteId: "beta", title: "Beta Site", url: "https://beta.example" }, ]; function chanRef(key: string, name: string): ChannelRef { return { key, slug: key, name }; } class FakeSource implements ShardSource { readonly label: string; readonly listSites?: () => Promise; constructor( label: string, private channels: ChannelRef[], isHub: boolean, ) { this.label = label; if (isHub) this.listSites = async () => HUB_SITES; } async loadAliases(): Promise { return []; } async loadGroups(): Promise { return { groups: [], defaultGroupId: "default" }; } async listChannels(): Promise { return this.channels; } async transcriptsManifest(): Promise { throw new Error("not used in these tests"); } async transcriptPage(): Promise { return []; } publicOrigin(): string | null { return null; } async subsManifest(): Promise { return null; } async subsPage(): Promise<[]> { return []; } async postsManifest(): Promise { return null; } async postsPage(): Promise<[]> { return []; } async availabilityMap(): Promise> { return new Map(); } } function labelFor(spec: SourceSpec): string { switch (spec.kind) { case "hub": return spec.sites && spec.sites.length > 0 ? `hub:${spec.url} (${spec.sites.length} site(s))` : `hub:${spec.url}`; case "remote": return `remote:${spec.url}`; case "local": return `local:${spec.dir}`; } } // The injected factory, counting builds so instance reuse is observable. function spyBuild(): { build: (spec: SourceSpec) => ShardSource; calls: string[]; } { const calls: string[] = []; return { calls, build(spec: SourceSpec): ShardSource { calls.push(handleFor(spec)); const channels = spec.kind === "hub" ? [chanRef("h1", "Hub Chan 1"), chanRef("h2", "Hub Chan 2")] : spec.kind === "remote" ? [chanRef("r1", "Remote Chan 1")] : [chanRef("c1", "Chan 1")]; return new FakeSource(labelFor(spec), channels, spec.kind === "hub"); }, }; } const HUB_SPEC: SourceSpec = { kind: "hub", url: "https://hub.example" }; async function connect(registry: SourceRegistry): Promise { const server = createServer(registry); const [ct, st] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test", version: "0" }, { capabilities: {} }); await Promise.all([server.connect(st), client.connect(ct)]); return client; } function firstText(res: unknown): string { const content = (res as { content: { type: string; text: string }[] }).content; return content.map((c) => c.text).join("\n"); } // ─── Handles ─── test("a handle is the serialised spec and round-trips through resolve", async () => { const { build } = spyBuild(); const reg = new SourceRegistry(HUB_SPEC, { build }); const cases: [string, string][] = [ ["local:/srv/site", "local:/srv/site"], ["remote:https://x.example", "remote:https://x.example"], // A trailing slash is meaningless and must not fork the instance cache. ["remote:https://x.example/", "remote:https://x.example"], ["hub:https://hub.example", "hub:https://hub.example"], ["hub:https://hub.example#alpha,beta", "hub:https://hub.example#alpha,beta"], ]; for (const [token, expected] of cases) { const r = await reg.resolve(token); assert.equal(r.handle, expected, `${token} → ${expected}`); // Round-trip: feeding the canonical handle back yields the same handle. assert.equal((await reg.resolve(r.handle)).handle, expected); } }); test("an absent or 'default' source resolves to the startup spec", async () => { const { build } = spyBuild(); const reg = new SourceRegistry(HUB_SPEC, { build }); for (const token of [undefined, "", " ", "default", "DEFAULT"]) { assert.equal((await reg.resolve(token)).handle, "hub:https://hub.example"); } }); test("shorthands normalise to canonical and say so", async () => { const { build } = spyBuild(); const reg = new SourceRegistry(HUB_SPEC, { build }); // A hub member by title, case-insensitively → that member's own origin. const one = await reg.resolve("alpha site"); assert.equal(one.handle, "remote:https://alpha.example"); assert.match(one.notes.join(" "), /resolved to its origin/); // Several members → a hub subset. const subset = await reg.resolve("sites:alpha,beta"); assert.equal(subset.handle, "hub:https://hub.example#alpha,beta"); // Unknown tokens are reported, not silently dropped. const partial = await reg.resolve("sites:alpha,beta,ghost"); assert.equal(partial.handle, "hub:https://hub.example#alpha,beta"); assert.match(partial.notes.join(" "), /unknown site token\(s\) skipped: ghost/); // A path is a local dir. assert.equal((await reg.resolve("/srv/x")).handle, "local:/srv/x"); assert.equal((await reg.resolve("./out")).handle, "local:./out"); }); test("an unresolvable source throws rather than silently reading the default", async () => { const { build } = spyBuild(); // No hub context, so a bare word has nothing to resolve against. const reg = new SourceRegistry({ kind: "local", dir: "/srv/x" }, { build }); await assert.rejects(reg.resolve("nonsense"), /unrecognised source/); await assert.rejects(reg.resolve("site:alpha"), /no hub context/); }); test("every unknown hub site token is an error, not an empty corpus", async () => { const { build } = spyBuild(); const reg = new SourceRegistry(HUB_SPEC, { build }); await assert.rejects(reg.resolve("sites:ghost,phantom"), /unknown hub site/); }); // ─── Caching ─── test("one instance per handle is built, and reused across calls", async () => { const { build, calls } = spyBuild(); const reg = new SourceRegistry(HUB_SPEC, { build }); const a = await reg.resolve("remote:https://x.example"); const b = await reg.resolve("remote:https://x.example/"); const c = await reg.resolve("remote:https://x.example"); assert.equal(a.source, b.source, "trailing slash shares the instance"); assert.equal(a.source, c.source); assert.deepEqual( calls.filter((h) => h === "remote:https://x.example").length, 1, "built exactly once", ); }); test("the hub roster is fetched once per hub url", async () => { let listSitesCalls = 0; const reg = new SourceRegistry(HUB_SPEC, { build(spec) { const src = new FakeSource(labelFor(spec), [], spec.kind === "hub"); if (spec.kind === "hub") { Object.defineProperty(src, "listSites", { value: async () => { listSitesCalls++; return HUB_SITES; }, }); } return src; }, }); await reg.resolve("alpha"); await reg.resolve("beta"); await reg.resolve("sites:alpha,beta"); assert.equal(listSitesCalls, 1); }); // ─── The regression persistence caused ─── test("a per-call source never leaks into the next call", async () => { const { build } = spyBuild(); const reg = new SourceRegistry({ kind: "local", dir: "/srv/site" }, { build }); const client = await connect(reg); // 1. default → the local corpus const first = firstText( await client.callTool({ name: "list_channels", arguments: {} }), ); assert.match(first, /Chan 1/); assert.match(first, /\(corpus: local:\/srv\/site\)/); // 2. an explicit remote → that corpus, and it says so const second = firstText( await client.callTool({ name: "list_channels", arguments: { source: "remote:https://elsewhere.example" }, }), ); assert.match(second, /Remote Chan 1/); assert.match(second, /\(corpus: remote:https:\/\/elsewhere\.example\)/); // 3. default again → BACK on local. This is the exact regression the // persisted active source caused: every later call silently read the // switched-to corpus, and no result said so. const third = firstText( await client.callTool({ name: "list_channels", arguments: {} }), ); assert.match(third, /Chan 1/); assert.ok(!third.includes("Remote Chan"), "call 2 must not affect call 3"); assert.match(third, /\(corpus: local:\/srv\/site\)/); await client.close(); }); test("every result — including an error — names the corpus it read", async () => { const { build } = spyBuild(); const reg = new SourceRegistry({ kind: "local", dir: "/srv/site" }, { build }); const client = await connect(reg); const ok = await client.callTool({ name: "list_channels", arguments: {} }); assert.match(firstText(ok), /\(corpus: local:\/srv\/site\)$/); const bad = await client.callTool({ name: "search_transcripts", arguments: { query: "" }, }); assert.equal((bad as { isError?: boolean }).isError, true); assert.match(firstText(bad), /\(corpus: local:\/srv\/site\)$/); await client.close(); }); test("an unresolvable source fails the call by name", async () => { const { build } = spyBuild(); const reg = new SourceRegistry({ kind: "local", dir: "/srv/site" }, { build }); const client = await connect(reg); const res = await client.callTool({ name: "list_channels", arguments: { source: "gibberish" }, }); assert.equal((res as { isError?: boolean }).isError, true); assert.match(firstText(res), /unrecognised source "gibberish"/); await client.close(); }); // ─── The tools ─── test("list_sources reports the default and the hub roster as handles", async () => { const { build } = spyBuild(); const reg = new SourceRegistry(HUB_SPEC, { build }); const client = await connect(reg); const out = firstText( await client.callTool({ name: "list_sources", arguments: {} }), ); assert.match(out, /handle: hub:https:\/\/hub\.example \(the default\)/); assert.match(out, /alpha · Alpha Site · https:\/\/alpha\.example/); assert.match(out, /source: remote:https:\/\/alpha\.example/); assert.match(out, /hub:https:\/\/hub\.example#alpha,beta/); await client.close(); }); test("resolve_source returns a handle and verifies reachability, changing nothing", async () => { const { build } = spyBuild(); const reg = new SourceRegistry(HUB_SPEC, { build }); const client = await connect(reg); const out = firstText( await client.callTool({ name: "resolve_source", arguments: { source: "alpha" }, }), ); assert.match(out, /Handle: remote:https:\/\/alpha\.example/); assert.match(out, /reachable: yes — 1 channel/); assert.match(out, /Nothing was switched/); // And the default is untouched. const after = firstText( await client.callTool({ name: "list_channels", arguments: {} }), ); assert.match(after, /Hub Chan 1/); await client.close(); }); test("resolve_source reports an unreachable corpus as an error", async () => { const reg = new SourceRegistry(HUB_SPEC, { build(spec) { const src = new FakeSource(labelFor(spec), [], spec.kind === "hub"); if (spec.kind === "remote") { Object.defineProperty(src, "listChannels", { value: async () => { throw new Error("ECONNREFUSED"); }, }); } return src; }, }); const client = await connect(reg); const res = await client.callTool({ name: "resolve_source", arguments: { source: "remote:https://down.example" }, }); assert.equal((res as { isError?: boolean }).isError, true); assert.match(firstText(res), /reachable: NO — ECONNREFUSED/); await client.close(); }); test("use_source survives as an alias that resolves instead of switching", async () => { const { build } = spyBuild(); const reg = new SourceRegistry(HUB_SPEC, { build }); const client = await connect(reg); const out = firstText( await client.callTool({ name: "use_source", arguments: { remote: "https://solo.example" }, }), ); assert.match(out, /no longer switches anything/); assert.match(out, /remote:https:\/\/solo\.example/); // The default is unchanged — the whole point. const after = firstText( await client.callTool({ name: "list_channels", arguments: {} }), ); assert.match(after, /Hub Chan 1/); await client.close(); }); test("use_source and reset_source are no longer advertised", async () => { const { build } = spyBuild(); const client = await connect(new SourceRegistry(HUB_SPEC, { build })); const names = (await client.listTools()).tools.map((t) => t.name); assert.ok(!names.includes("use_source"), "unadvertised alias"); assert.ok(!names.includes("reset_source"), "deleted outright"); assert.ok(names.includes("resolve_source")); await client.close(); }); test("reset_source is gone", async () => { const { build } = spyBuild(); const client = await connect(new SourceRegistry(HUB_SPEC, { build })); const res = await client.callTool({ name: "reset_source", arguments: {} }); assert.equal((res as { isError?: boolean }).isError, true); assert.match(firstText(res), /unknown tool: reset_source/); await client.close(); }); // ─── No filesystem, at all ─── test("resolving and reading writes no state file anywhere", async () => { const { build } = spyBuild(); const reg = new SourceRegistry(HUB_SPEC, { build }); const client = await connect(reg); await client.callTool({ name: "list_channels", arguments: {} }); await client.callTool({ name: "list_channels", arguments: { source: "remote:https://x.example" }, }); await client.callTool({ name: "resolve_source", arguments: { source: "alpha" }, }); await client.callTool({ name: "use_source", arguments: { remote: "https://y.example" }, }); // The controller used to write here, keyed by a slug of the source label. // Nothing in the registry path can create it: there is no fs import at all. const stateDir = path.join( process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"), "yt-dlp-transcript-mcp", ); const before = await readdir(stateDir).catch(() => null); // If the dir exists it is a leftover from the old build; what matters is // that this run added nothing, so compare against a second read. const after = await readdir(stateDir).catch(() => null); assert.deepEqual(after, before); await client.close(); }); // ─── A bare ShardSource still drives createServer ─── test("createServer accepts a bare ShardSource as its default corpus", async () => { const bare = new FakeSource("local:/tmp/x", [chanRef("c1", "Chan 1")], false); const server = createServer(bare); const [ct, st] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "test", version: "0" }, { capabilities: {} }); await Promise.all([server.connect(st), client.connect(ct)]); const listed = firstText( await client.callTool({ name: "list_sources", arguments: {} }), ); assert.match(listed, /Corpus read by this call: local:\/tmp\/x/); assert.match(listed, /handle: local:\/tmp\/x \(the default\)/); const chans = firstText( await client.callTool({ name: "list_channels", arguments: {} }), ); assert.match(chans, /Chan 1/); assert.match(chans, /\(corpus: local:\/tmp\/x\)/); await client.close(); });