Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ pnpm db:migrate:remote # Apply migrations to remote D1
| `/skills/<slug>.md` | lib/markdown/skill-markdown-handler.ts, dispatched in `workers/app.ts` (React Router has no partial dynamic segment) | None; always anonymous, public cache |
| `/llms.txt`, `/llms-full.txt` | llms-txt.ts, llms-full-txt.ts (`?limit=` snaps to fixed sizes; KV-cached per origin and size) | None; protected bodies withheld |
| `/docs/harness`, `/docs/publishers` | docs-harness.tsx, docs-publishers.tsx | None |
| `/api/mcp` | api.mcp.ts (read-only MCP over JSON-RPC: `search_packages`, `get_package`, `get_release`, `check_compatibility`; 64 KiB body cap, `RELEASE_READ_LIMITER` under `mcp:`) | Optional Key/Session; `get_release` is always anonymous |
| `/api/mcp` | api.mcp.ts (read-only MCP over JSON-RPC: `search_packages`, `get_package`, `get_release`, `check_compatibility`, `get_kit`; 64 KiB body cap, `RELEASE_READ_LIMITER` under `mcp:`) | None; only `get_kit` reads an unscoped Key/Session (owner view) |
| `/profile` | profile.tsx | Session |
| `/settings` | settings.tsx | Session |
| `/api/auth/*` | auth-catchall.tsx | Better Auth |
Expand Down
159 changes: 159 additions & 0 deletions apps/web/app/lib/mcp/handlers-kits.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/**
* `get_kit` must hide exactly what the personal kit API hides: a stranger sees
* a kit only when it is public and its effective revision was authored public,
* reviewed and activated; everything else looks like a kit that does not exist.
* The owner reads their own kit whatever its visibility.
*/

import { beforeEach, describe, expect, it, vi } from "vitest";

const h = vi.hoisted(() => ({
kit: null as Record<string, unknown> | null,
revision: null as Record<string, unknown> | null,
publisherState: "active",
}));

vi.mock("~/lib/personal-kits/kit-store", async (importOriginal) => {
const actual = await importOriginal<typeof import("~/lib/personal-kits/kit-store")>();
return {
...actual,
findKit: async () => h.kit,
getRevision: async () => h.revision,
};
});

import { callTool } from "./tools";

/** Only the publisher-state read reaches the database; kit rows come from the mocked store. */
const DB = {
prepare: () => ({ bind: () => ({ first: async () => ({ state: h.publisherState }) }) }),
};
const ENV = { SKILLX_PERSONAL_KITS_API: "1", DB } as never;
const OWNER = "user_owner";

function kitRow(visibility: string, effective: string | null = "rev_1") {
return {
id: "kit_1",
name: "@alpha/kit",
publisher_id: "pub_1",
owner_user_id: OWNER,
local_id: "local-1",
visibility,
generation: 3,
effective_revision_id: effective,
latest_revision_number: 1,
created_at: 0,
updated_at: 0,
};
}

function revisionRow(state: string, docVisibility: string, activated = true) {
return {
id: "rev_1",
kit_id: "kit_1",
revision_number: 1,
parent_revision_id: null,
revision_digest: "sha256:abc",
document_json: JSON.stringify({ schema: "skillx.personal-kit/v1", kit: "@alpha/kit", visibility: docVisibility, skills: [] }),
state,
origin: "manual",
author_user_id: OWNER,
idempotency_key: "k",
approval_json: null,
created_at: 0,
reviewed_at: 0,
activated_at: activated ? 0 : null,
rejected_at: null,
quarantined_at: null,
};
}

async function getKit(userId: string | null, env = ENV, kit: unknown = "@alpha/kit") {
const result = await callTool("get_kit", { kit }, { env, userId });
return { isError: result.isError === true, body: JSON.parse(result.content[0].text) as Record<string, any> };
}

beforeEach(() => {
h.kit = null;
h.revision = null;
h.publisherState = "active";
});

describe("get_kit", () => {
it("fails with feature_disabled while personal kits are off", async () => {
const { isError, body } = await getKit(null, { DB: {} } as never);
expect(isError).toBe(true);
expect(body.details.code).toBe("feature_disabled");
});

it("rejects a malformed kit name as invalid arguments", async () => {
const { isError, body } = await getKit(null, ENV, "not a kit");
expect(isError).toBe(true);
expect(body.details.code).toBe("invalid_arguments");
});

it.each([
["an anonymous caller", null],
["a signed-in stranger", "someone_else"],
])("shows %s a public, activated kit as metadata and digest only", async (_label, userId) => {
h.kit = kitRow("public");
h.revision = revisionRow("reviewed", "public");
const { body } = await getKit(userId);
expect(body).toEqual({
found: true,
viewer: "public",
kit: { kit: "@alpha/kit", visibility: "public" },
effectiveRevision: { digest: "sha256:abc", number: 1, activatedAt: new Date(0).toISOString() },
});
});

it("gives the owner of a public kit the full document", async () => {
h.kit = kitRow("public");
h.revision = revisionRow("reviewed", "public");
const { body } = await getKit(OWNER);
expect(body.viewer).toBe("owner");
expect(body.effectiveRevision.document).toMatchObject({ kit: "@alpha/kit", visibility: "public" });
});

it.each([
["a stranger", "someone_else"],
["the owner", OWNER],
])("answers publisher_revoked to %s when the publisher is not active", async (_label, userId) => {
h.kit = kitRow("public");
h.revision = revisionRow("reviewed", "public");
h.publisherState = "revoked";
const { isError, body } = await getKit(userId);
expect(isError).toBe(false);
expect(body).toEqual({ found: false, code: "publisher_revoked", kit: "@alpha/kit" });
});

it("still hides a private kit of a revoked publisher from a stranger as not_found", async () => {
h.kit = kitRow("private");
h.revision = revisionRow("reviewed", "private");
h.publisherState = "suspended";
expect((await getKit("someone_else")).body.code).toBe("not_found");
});

it.each([
["an unknown kit", null, null],
["a private kit", kitRow("private"), revisionRow("reviewed", "private")],
["a public kit with no effective revision", kitRow("public", null), null],
["a public kit whose revision was authored private", kitRow("public"), revisionRow("reviewed", "private")],
["a public kit whose revision is not reviewed", kitRow("public"), revisionRow("draft", "public")],
["a public kit whose revision was never activated", kitRow("public"), revisionRow("reviewed", "public", false)],
])("answers not_found to a stranger for %s", async (_label, kit, revision) => {
h.kit = kit;
h.revision = revision;
const { isError, body } = await getKit("someone_else");
expect(isError).toBe(false);
expect(body).toEqual({ found: false, code: "not_found", kit: "@alpha/kit" });
});

it("lets the owner read their own private kit", async () => {
h.kit = kitRow("private");
h.revision = revisionRow("reviewed", "private");
const { body } = await getKit(OWNER);
expect(body).toMatchObject({ found: true, viewer: "owner", kit: { visibility: "private", localId: "local-1", generation: 3 } });
expect(body.effectiveRevision.digest).toBe("sha256:abc");
});
});
88 changes: 88 additions & 0 deletions apps/web/app/lib/mcp/handlers-kits.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
/**
* The `get_kit` MCP handler: a read of one personal kit's effective revision.
*
* Visibility follows the personal kit API and its parent rule
* (`kit-verify.ts`), so MCP never shows more than that API would:
* - the owner (an unscoped API key or a web session) reads their own kit,
* private or public, with the effective revision's full document, as
* `GET /api/v1/kits/@h/n` would;
* - anyone else sees a kit only when it is public and its effective
* revision was authored public, reviewed and activated by its owner, and
* then only its metadata and revision digest, the same reference a foreign
* ancestor gets in `chainView` and the lock. The document stays with the
* owner. Everything else (unknown, private, never activated) is
* `not_found`, so a kit's existence is not revealed.
* - a kit whose publisher is suspended or revoked answers
* `publisher_revoked`, as `get_release` does for that publisher's packages.
*
* While `SKILLX_PERSONAL_KITS_API` is off the tool fails with
* `feature_disabled` instead of pretending the kit does not exist, so an agent
* can tell "turned off here" from "no such kit".
*/

import { ReleaseHttpError } from "~/lib/releases/release-http";
import { parseKitName, personalKitsApiEnabled } from "~/lib/personal-kits/kit-http";
import { documentOf, findKit, getRevision, revisionView } from "~/lib/personal-kits/kit-store";
import type { RevisionRow } from "~/lib/personal-kits/kit-store";
import { ToolCodedError, ToolInputError, asRecord } from "./protocol";
import { requiredString } from "./args";
import type { ToolContext } from "./handlers";

/** Read by the public: reviewed, activated, and authored public (the foreign-parent rule). */
const publiclyReadable = (row: RevisionRow) =>
row.state === "reviewed" && row.activated_at !== null && documentOf(row).visibility === "public";

async function publisherActive(db: D1Database, publisherId: string): Promise<boolean> {
const row = await db.prepare("SELECT state FROM publishers WHERE id = ?").bind(publisherId).first<{ state: string }>();
return row?.state === "active";
}

/** `@handle/name`, validated by the kit API's own parser. */
function readKitName(args: Record<string, unknown>): string {
const name = requiredString(args, "kit");
const slash = name.indexOf("/");
try {
return parseKitName(slash < 0 ? name : name.slice(0, slash), slash < 0 ? undefined : name.slice(slash + 1));
} catch (error) {
if (error instanceof ReleaseHttpError) throw new ToolInputError(error.message);
throw error;
}
}

export async function getKit(rawArgs: unknown, ctx: ToolContext) {
const name = readKitName(asRecord(rawArgs));
if (!personalKitsApiEnabled(ctx.env as { SKILLX_PERSONAL_KITS_API?: string })) {
throw new ToolCodedError(
"feature_disabled",
"Personal kits are not enabled on this server (SKILLX_PERSONAL_KITS_API is off).",
);
}

const notFound = { found: false, code: "not_found", kit: name };
const kit = await findKit(ctx.env.DB, name);
if (kit === null) return notFound;

const owner = typeof ctx.userId === "string" && kit.owner_user_id === ctx.userId;
if (!owner && kit.visibility !== "public") return notFound;

const effective = kit.effective_revision_id === null ? null : await getRevision(ctx.env.DB, kit.id, kit.effective_revision_id);
if (!owner && (effective === null || !publiclyReadable(effective))) return notFound;
if (!(await publisherActive(ctx.env.DB, kit.publisher_id))) return { found: false, code: "publisher_revoked", kit: name };

return {
found: true,
viewer: owner ? "owner" : "public",
kit: {
kit: kit.name,
visibility: kit.visibility,
// Owner-only bookkeeping stays with the owner.
...(owner ? { localId: kit.local_id, generation: Number(kit.generation) } : {}),
},
effectiveRevision:
effective === null
? null
: owner
? revisionView(effective, true)
: { digest: effective.revision_digest, number: Number(effective.revision_number), activatedAt: revisionView(effective).activatedAt },
};
}
8 changes: 6 additions & 2 deletions apps/web/app/lib/mcp/handlers.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,26 @@
/**
* MCP tool registry.
*
* The handler bodies live in `handlers-packages.ts` and `handlers-releases.ts`; this
* The handler bodies live in `handlers-packages.ts`, `handlers-releases.ts` and `handlers-kits.ts`; this
* module only names them, so `ToolName` is the single list of what the server can
* do. `tools.ts` asserts that every advertised tool has an entry here, which is
* what stops a tool from being listed and then refusing to run.
*/

import { searchPackages, getPackage, checkCompatibility } from "./handlers-packages";
import { getRelease } from "./handlers-releases";
import { getKit } from "./handlers-kits";

export type ToolName =
| "search_packages"
| "get_package"
| "get_release"
| "check_compatibility";
| "check_compatibility"
| "get_kit";

export interface ToolContext {
env: Env;
/** The authenticated caller; the route sets it for `get_kit` only, every other tool runs anonymous. */
userId?: string | null;
}

Expand All @@ -28,4 +31,5 @@ export const HANDLERS: Record<ToolName, ToolHandler> = {
get_package: (args, ctx) => getPackage(args, ctx),
get_release: (args, ctx) => getRelease(args, ctx),
check_compatibility: (args, ctx) => checkCompatibility(args, ctx),
get_kit: (args, ctx) => getKit(args, ctx),
};
3 changes: 2 additions & 1 deletion apps/web/app/lib/mcp/mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,9 +101,10 @@ describe("envelope builders", () => {
});

describe("tool catalog", () => {
it("exposes exactly the four read-only tools", () => {
it("exposes exactly the five read-only tools", () => {
expect([...TOOL_NAMES].sort()).toEqual([
"check_compatibility",
"get_kit",
"get_package",
"get_release",
"search_packages",
Expand Down
13 changes: 13 additions & 0 deletions apps/web/app/lib/mcp/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,19 @@ export interface ToolResult {
/** Thrown by a handler when the caller's arguments are unusable. */
export class ToolInputError extends Error {}

/**
* Thrown by a handler that declines for a stable, machine-readable reason
* (e.g. `feature_disabled`). The code and message reach the caller.
*/
export class ToolCodedError extends Error {
constructor(
readonly code: string,
message: string,
) {
super(message);
}
}

/**
* Narrows `params.arguments` to a plain object. Anything else (a list, a string,
* `null`) becomes `{}`, so handlers validate fields rather than shapes.
Expand Down
21 changes: 20 additions & 1 deletion apps/web/app/lib/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/

import { HANDLERS, type ToolContext, type ToolName } from "./handlers";
import { ToolInputError, asRecord, errorResult, textResult, type ToolResult } from "./protocol";
import { ToolCodedError, ToolInputError, asRecord, errorResult, textResult, type ToolResult } from "./protocol";

export interface McpToolDefinition {
name: ToolName;
Expand Down Expand Up @@ -89,6 +89,24 @@ export const TOOL_DEFINITIONS: McpToolDefinition[] = [
additionalProperties: false,
},
},
{
name: "get_kit",
title: "Get kit",
description:
"Read one personal kit (`kit` is `@handle/name`) and its effective revision. The owner, " +
"authenticated with a web session or an unscoped API key, reads their own kit (private too) " +
"with the full revision document. Anyone else sees a public kit whose effective revision was " +
"authored public and activated by its owner as metadata and revision digest only. Every other " +
"kit, drafts and proposals included, answers `found: false` with code `not_found`; a suspended " +
"or revoked publisher answers `publisher_revoked`. When personal kits are turned off on this " +
"server the call fails with code `feature_disabled`.",
inputSchema: {
type: "object",
properties: { kit: STRING },
required: ["kit"],
additionalProperties: false,
},
},
];

/** The only failure detail a caller sees when a handler throws unexpectedly. */
Expand Down Expand Up @@ -119,6 +137,7 @@ export async function callTool(
return textResult(await HANDLERS[name](asRecord(rawArgs), ctx));
} catch (error) {
if (error instanceof ToolInputError) return errorResult(error.message, { code: "invalid_arguments" });
if (error instanceof ToolCodedError) return errorResult(error.message, { code: error.code });
// The cause stays in the server log: driver and query text never reach the caller.
console.error(`MCP tool ${name} failed:`, error);
return errorResult(`${name} failed`, { code: TOOL_INTERNAL_ERROR });
Expand Down
12 changes: 7 additions & 5 deletions apps/web/app/lib/parity/compatibility-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@
* layer stubbed, so it exercises the handler and the protocol envelope rather
* than a re-implementation of them.
*
* The CLI is not imported here: `apps/web/tsconfig.cloudflare.json` lists its own
* files only, so a cross-package import fails the project boundary. The CLI's
* rule is pinned by value in packages/cli/src/lib/skill-lookup.test.ts, and its
* drift risk is that it is the one surface holding a hand-written mirror of
* `isCompatibilityActionable` instead of calling it.
* The CLI is covered from its own package, because `apps/web/tsconfig.cloudflare.json`
* lists the app's files only. It imports `isCompatibilityActionable` from this
* contract (bundled into the shipped binary), and two tests pin it: one compares
* the CLI decision with the contract for every status in COMPATIBILITY_STATUSES
* (packages/cli/src/lib/skill-lookup.test.ts), and one runs the built
* `bin/skillx.js check --json` against a stub API for all five statuses
* (packages/cli/src/built-cli.test.ts).
*/

import { beforeEach, describe, expect, it, vi } from "vitest";
Expand Down
Loading
Loading