Repository Public MIT
1import { afterAll, beforeAll, describe, expect, it } from "vitest";2import { Client } from "@modelcontextprotocol/sdk/client/index.js";3import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";4import { packFiles } from "@skills/registry-core/node";5import { PUBLIC_TOOLS, PUBLISHER_TOOLS } from "../../apps/registry-mcp/src/index";6import { call, publish, setupTestEnv, skillFiles, tokenFor, type TestEnv } from "../helpers";7import { startTestServer } from "../http-server";89let env: TestEnv;10let http: Awaited<ReturnType<typeof startTestServer>>;11let token: string;12const clients: Client[] = [];1314async function connect(bearer?: string): Promise<Client> {15 const client = new Client({ name: "vitest-mcp-client", version: "1.0.0" });16 await client.connect(new StreamableHTTPClientTransport(new URL(`${http.url}/mcp`), bearer ? { requestInit: { headers: { Authorization: `Bearer ${bearer}` } } } : undefined));17 clients.push(client);18 return client;19}20const data = (r: any) => r.structuredContent as any;2122beforeAll(async () => {23 env = await setupTestEnv();24 http = await startTestServer();25 token = (await tokenFor(env, env.owner, ["research"], ["artifacts:read", "artifacts:create", "artifacts:update", "artifacts:deprecate", "uploads:create", "releases:publish"])).token;26 await publish(token, skillFiles());27});28afterAll(async () => {29 for (const c of clients) await c.close().catch(() => {});30 await http.close();31 await env.close();32});3334describe("registry MCP server (official client over Streamable HTTP)", () => {35 it("negotiates the protocol and lists typed tools", async () => {36 const c = await connect();37 expect(c.getServerVersion()).toMatchObject({ name: "skills-registry" });38 expect(c.getServerCapabilities()).toHaveProperty("tools");39 expect(c.getInstructions()).toMatch(/untrusted/i);40 const { tools } = await c.listTools();41 expect(tools.map((t) => t.name).sort()).toEqual([...PUBLIC_TOOLS, ...PUBLISHER_TOOLS].sort());42 const read = tools.find((t) => t.name === "read_public_file")!;43 expect(read.inputSchema.required).toEqual(expect.arrayContaining(["namespace", "slug", "version", "path"]));44 expect(read.annotations).toMatchObject({ readOnlyHint: true });45 expect(tools.find((t) => t.name === "deprecate_artifact")!.annotations).toMatchObject({ destructiveHint: true });46 // No dangerous surface is exposed.47 expect(tools.map((t) => t.name).join(" ")).not.toMatch(/shell|exec|fetch_url|token|filesystem/);48 });4950 it("serves discovery tools anonymously", async () => {51 const c = await connect();52 const s = data(await c.callTool({ name: "search_artifacts", arguments: { query: "hello" } }));53 expect(s.items[0]).toMatchObject({ name: "@research/hello-skill", latestVersion: "1.0.0" });54 expect(s._notice).toMatch(/untrusted/i);55 const a = data(await c.callTool({ name: "get_artifact", arguments: { namespace: "research", slug: "hello-skill" } }));56 expect(a.latestRelease.sha256).toMatch(/^[0-9a-f]{64}$/);57 const v = data(await c.callTool({ name: "list_versions", arguments: { namespace: "research", slug: "hello-skill" } }));58 expect(v.items).toHaveLength(1);59 const i = data(await c.callTool({ name: "get_install_instructions", arguments: { namespace: "research", slug: "hello-skill", client: "claude-code" } }));60 expect(i.instructions[0].destination.user).toBe("~/.claude/skills/hello-skill");61 const f = data(await c.callTool({ name: "read_public_file", arguments: { namespace: "research", slug: "hello-skill", version: "latest", path: "SKILL.md" } }));62 expect(f.untrustedContent).toContain("name: hello-skill");63 expect(data(await c.callTool({ name: "list_collections", arguments: {} })).items).toEqual([]);64 });6566 it("refuses unsafe paths and invalid input", async () => {67 const c = await connect();68 const trav = await c.callTool({ name: "read_public_file", arguments: { namespace: "research", slug: "hello-skill", version: "1.0.0", path: "../../../etc/passwd" } });69 expect(trav.isError).toBe(true);70 expect(data(trav).error.status).toBe(400);71 const bad = await c.callTool({ name: "get_artifact", arguments: { namespace: "../etc", slug: "x" } }).catch((e) => ({ isError: true, thrown: String(e) }));72 expect((bad as any).isError).toBe(true);73 const missing = await c.callTool({ name: "get_artifact", arguments: { namespace: "research", slug: "nope" } });74 expect(data(missing).error.code).toBe("not_found");75 });7677 it("requires authentication and scopes for publisher tools", async () => {78 const anon = await connect();79 const denied = await anon.callTool({ name: "create_artifact", arguments: { metadata: { namespace: "research", slug: "via-mcp", type: "prompt-pack", summary: "Created through the MCP server." } } });80 expect(denied.isError).toBe(true);81 expect(data(denied).error.status).toBe(401);82 const vm = await anon.callTool({ name: "validate_manifest", arguments: { manifest: { name: "x" } } });83 expect(data(vm).error.status).toBe(401);8485 const reader = await connect((await tokenFor(env, env.owner, ["research"], ["artifacts:read"])).token);86 const noScope = await reader.callTool({ name: "create_artifact", arguments: { metadata: { namespace: "research", slug: "via-mcp", type: "prompt-pack", summary: "Created through the MCP server." } } });87 expect(data(noScope).error.code).toBe("insufficient_scope");88 const forged = await connect("skr_aaaaaaaaaa_" + "A".repeat(43));89 expect(data(await forged.callTool({ name: "validate_manifest", arguments: { manifest: {} } })).error.status).toBe(401);90 });9192 it("publishes end to end through MCP tools plus an HTTP PUT", async () => {93 const c = await connect(token);94 const files = skillFiles({ name: "@research/via-mcp", type: "skill" });95 const manifestObj = JSON.parse(files[0]!.data.toString());96 expect(data(await c.callTool({ name: "validate_manifest", arguments: { manifest: manifestObj } })).valid).toBe(true);97 expect(data(await c.callTool({ name: "validate_manifest", arguments: { manifest: { ...manifestObj, version: "one" } } })).valid).toBe(false);9899 const needConfirm = await c.callTool({ name: "create_artifact", arguments: { metadata: { namespace: "research", slug: "via-mcp", type: "skill", summary: manifestObj.description, visibility: "public" } } });100 expect(data(needConfirm).error.code).toBe("confirmation_required");101 const created = data(await c.callTool({ name: "create_artifact", arguments: { metadata: { namespace: "research", slug: "via-mcp", type: "skill", summary: manifestObj.description, visibility: "public", confirmPublic: true } } }));102 expect(created.name).toBe("@research/via-mcp");103104 const { archive, sha256 } = packFiles(files);105 const begun = data(await c.callTool({ name: "begin_release_upload", arguments: { artifact: created.id, version: "1.0.0", metadata: { sha256, sizeBytes: archive.length } } }));106 expect(begun.uploadUrl).toBe(`${http.url}/api/v1/uploads/${begun.uploadId}`);107 const put = await fetch(begun.uploadUrl, { method: "PUT", headers: { Authorization: `Bearer ${token}`, "Content-Type": "application/gzip" }, body: new Uint8Array(archive) });108 expect(put.status).toBe(200);109 const fin = data(await c.callTool({ name: "finalize_release", arguments: { upload_id: begun.uploadId, sha256 } }));110 expect(fin.release).toMatchObject({ version: "1.0.0", state: "published", sha256 });111112 const upd = data(await c.callTool({ name: "update_artifact_metadata", arguments: { id: created.id, patch: { keywords: ["demo"] } } }));113 expect(upd.keywords).toEqual(["demo"]);114 const noConfirm = await c.callTool({ name: "deprecate_artifact", arguments: { id: created.id, reason: "superseded by something else" } });115 expect(data(noConfirm).error.code).toBe("confirmation_required");116 const dep = data(await c.callTool({ name: "deprecate_artifact", arguments: { id: created.id, reason: "superseded by something else", confirm: true } }));117 expect(dep.state).toBe("deprecated");118119 // Same audit trail as REST, attributed to the token and the forwarded client address.120 const log = await env.sql`SELECT action, actor_label, ip FROM audit_events WHERE target_label LIKE '@research/via-mcp%' ORDER BY id`;121 expect(log.map((l) => l.action)).toEqual(["artifact.create", "upload.begin", "upload.receive", "release.publish", "artifact.update", "artifact.deprecate"]);122 expect(log.every((l) => l.actor_label.startsWith("token:"))).toBe(true);123 expect(log[0]!.ip).toBe("203.0.113.50");124 });125126 it("is stateless and rejects non-POST methods and malformed credentials", async () => {127 expect((await fetch(`${http.url}/mcp`)).status).toBe(405);128 const bad = await fetch(`${http.url}/mcp`, { method: "POST", headers: { Authorization: "Basic abc", "Content-Type": "application/json" }, body: "{}" });129 expect(bad.status).toBe(401);130 void call;131 });132});133