SPB Git

Repository Public MIT

mcp.test.ts 8.8 KB 133 lines typescript

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
Files main
Menu

Theme