@handamade/psi-mcp 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,21 @@
1
+ # @handamade/psi-mcp
2
+
3
+ MCP server and agent-context generator for the Psi (Ψ) design system. Documents the system — the 11 React 19 components, every semantic token with its OKLCH derivation formula resolved across all four themes, and the usage guidance — through two read-only tools. It does not write code.
4
+
5
+ ## Connect
6
+
7
+ - **Hosted (no install):** Streamable HTTP at `https://psi.kurkin.de/mcp`
8
+ - **Local (stdio):** `npx @handamade/psi-mcp`
9
+
10
+ Tools: `search(query)` → compact briefs with ids · `get(id)` → full detail (`component:Button`, `token:bgPrimary`, `topic:variants`, or a bare name).
11
+
12
+ ## Generate agent context (D44)
13
+
14
+ npx @handamade/psi-mcp init # writes/updates AGENTS.md
15
+ npx @handamade/psi-mcp init --claude # also CLAUDE.md
16
+
17
+ Content is generated from the same baked index as the MCP tools, inside `<!-- psi:agents:start/end -->` markers — re-run after upgrades; edits outside the markers are preserved.
18
+
19
+ ## How it stays honest
20
+
21
+ The index is baked at publish time from the sibling packages' generated artifacts (`manifest.json`, `guidance.json`, `resolved/<theme>.json`, per-component docs) and versioned in lockstep with them — the server never answers from prose.
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env node
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { loadIndex } from "./index.js";
4
+ import { createStore } from "./store.js";
5
+ import { createPsiServer } from "./server.js";
6
+ const [command] = process.argv.slice(2);
7
+ if (command === "init") {
8
+ const { runInit } = await import("./init.js");
9
+ await runInit({ cwd: process.cwd(), claude: process.argv.includes("--claude") });
10
+ }
11
+ else {
12
+ const index = await loadIndex();
13
+ const server = createPsiServer(createStore(index), index.version);
14
+ await server.connect(new StdioServerTransport());
15
+ }
package/dist/http.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import type { IncomingMessage, ServerResponse } from "node:http";
2
+ import type { PsiIndex } from "./types.js";
3
+ /**
4
+ * Stateless Streamable HTTP handler: a fresh server + transport per request,
5
+ * no sessions, JSON responses. Suitable for a Vercel function (req.body may
6
+ * already be parsed) and for plain node:http (body read from the stream).
7
+ */
8
+ export declare function createMcpHandler(index: PsiIndex): (req: IncomingMessage & {
9
+ body?: unknown;
10
+ }, res: ServerResponse) => Promise<void>;
package/dist/http.js ADDED
@@ -0,0 +1,24 @@
1
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
2
+ import { createStore } from "./store.js";
3
+ import { createPsiServer } from "./server.js";
4
+ /**
5
+ * Stateless Streamable HTTP handler: a fresh server + transport per request,
6
+ * no sessions, JSON responses. Suitable for a Vercel function (req.body may
7
+ * already be parsed) and for plain node:http (body read from the stream).
8
+ */
9
+ export function createMcpHandler(index) {
10
+ const store = createStore(index);
11
+ return async function handleMcpRequest(req, res) {
12
+ const server = createPsiServer(store, index.version);
13
+ const transport = new StreamableHTTPServerTransport({
14
+ sessionIdGenerator: undefined,
15
+ enableJsonResponse: true,
16
+ });
17
+ res.on("close", () => {
18
+ void transport.close();
19
+ void server.close();
20
+ });
21
+ await server.connect(transport);
22
+ await transport.handleRequest(req, res, req.body);
23
+ };
24
+ }
@@ -0,0 +1,15 @@
1
+ import type { PsiIndex } from "./types.js";
2
+ export interface BuildInputs {
3
+ tokensDist: string;
4
+ reactDist: string;
5
+ reactDocs: string;
6
+ version: string;
7
+ }
8
+ /** Release-versioned onboarding facts; travels with the index (spec: topics). */
9
+ export declare const GETTING_STARTED: {
10
+ install: string;
11
+ cssImports: string[];
12
+ themeAttribute: string;
13
+ rules: string;
14
+ };
15
+ export declare function buildIndex(inputs: BuildInputs): Promise<PsiIndex>;
@@ -0,0 +1,55 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ const THEMES = ["light", "dark", "acme", "ember"];
4
+ /** Release-versioned onboarding facts; travels with the index (spec: topics). */
5
+ export const GETTING_STARTED = {
6
+ install: "npm install @handamade/psi-tokens @handamade/psi-react",
7
+ cssImports: [
8
+ "@handamade/psi-tokens/base.css",
9
+ "@handamade/psi-tokens/<theme>.css — one per theme you use (light|dark|acme|ember)",
10
+ "@handamade/psi-tokens/components.css",
11
+ "@handamade/psi-tokens/utilities.css — REQUIRED: .psi-container + reduced-motion zeroing",
12
+ ],
13
+ themeAttribute: 'Set data-psi-theme="light|dark|acme|ember" on <html> or a subtree root.',
14
+ rules: "Sizes are px numbers (24 | 32 | 40 | 48), never S/M/L. Variants are flat " +
15
+ "(accent, accent-subtle, neutral, neutral-subtle, ghost, danger, danger-subtle, outline); " +
16
+ "one accent per visual group (Tags exempt, D40); danger only for destructive actions. " +
17
+ "Never hardcode colors — bind var(--psi-*).",
18
+ };
19
+ async function readJson(path) {
20
+ return JSON.parse(await readFile(path, "utf8"));
21
+ }
22
+ export async function buildIndex(inputs) {
23
+ const manifest = (await readJson(join(inputs.reactDist, "manifest.json")));
24
+ const guidance = (await readJson(join(inputs.tokensDist, "guidance.json")));
25
+ const resolved = {};
26
+ for (const theme of THEMES) {
27
+ resolved[theme] = (await readJson(join(inputs.tokensDist, "resolved", `${theme}.json`)));
28
+ }
29
+ const components = [];
30
+ for (const c of manifest.components) {
31
+ components.push({
32
+ name: c.name,
33
+ description: c.description,
34
+ props: c.props,
35
+ doc: await readFile(join(inputs.reactDocs, `${c.name}.md`), "utf8"),
36
+ });
37
+ }
38
+ const light = resolved.light;
39
+ const tokens = Object.keys(light.tokens).map((name) => ({
40
+ name,
41
+ formula: light.tokens[name].formula,
42
+ values: Object.fromEntries(THEMES.map((t) => [
43
+ t,
44
+ { oklch: resolved[t].tokens[name].oklch, hex: resolved[t].tokens[name].hex },
45
+ ])),
46
+ }));
47
+ return {
48
+ version: inputs.version,
49
+ themes: [...THEMES],
50
+ components,
51
+ tokens,
52
+ scales: light.scales,
53
+ topics: { ...guidance, "getting-started": GETTING_STARTED },
54
+ };
55
+ }
@@ -0,0 +1,8 @@
1
+ import type { PsiIndex } from "./types.js";
2
+ export type { PsiIndex, ComponentEntry, TokenEntry, PropDoc } from "./types.js";
3
+ export { buildIndex, GETTING_STARTED } from "./index-builder.js";
4
+ export { createStore } from "./store.js";
5
+ export type { Store, Brief, Detail } from "./store.js";
6
+ export { createPsiServer } from "./server.js";
7
+ /** Loads the index baked next to the compiled module at publish time. */
8
+ export declare function loadIndex(): Promise<PsiIndex>;
package/dist/index.js ADDED
@@ -0,0 +1,9 @@
1
+ import { readFile } from "node:fs/promises";
2
+ export { buildIndex, GETTING_STARTED } from "./index-builder.js";
3
+ export { createStore } from "./store.js";
4
+ export { createPsiServer } from "./server.js";
5
+ /** Loads the index baked next to the compiled module at publish time. */
6
+ export async function loadIndex() {
7
+ const url = new URL("./psi-index.json", import.meta.url);
8
+ return JSON.parse(await readFile(url, "utf8"));
9
+ }
package/dist/init.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ import type { PsiIndex } from "./types.js";
2
+ export declare const START_MARKER = "<!-- psi:agents:start -->";
3
+ export declare const END_MARKER = "<!-- psi:agents:end -->";
4
+ export declare function renderAgentsBlock(index: PsiIndex): string;
5
+ export declare function applyBlock(existing: string | null, block: string): string;
6
+ export declare function runInit(opts: {
7
+ cwd: string;
8
+ claude: boolean;
9
+ }): Promise<void>;
package/dist/init.js ADDED
@@ -0,0 +1,60 @@
1
+ import { readFile, writeFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { loadIndex } from "./index.js";
5
+ export const START_MARKER = "<!-- psi:agents:start -->";
6
+ export const END_MARKER = "<!-- psi:agents:end -->";
7
+ function axes(c) {
8
+ const type = (n) => c.props.find((p) => p.name === n)?.type;
9
+ const parts = [];
10
+ const variants = type("variant");
11
+ const sizes = type("size");
12
+ if (variants)
13
+ parts.push(`variants: ${variants.replaceAll('"', "")}`);
14
+ if (sizes)
15
+ parts.push(`sizes: ${sizes}`);
16
+ return parts.length ? ` — ${parts.join("; ")}` : "";
17
+ }
18
+ export function renderAgentsBlock(index) {
19
+ return [
20
+ START_MARKER,
21
+ `# Psi (Ψ) design system — agent guide`,
22
+ "",
23
+ `Generated by \`npx @handamade/psi-mcp init\` for Psi v${index.version}. Re-run after upgrades; do not edit inside the markers (D44).`,
24
+ "",
25
+ "## House rules",
26
+ "- Sizes are px numbers (24 | 32 | 40 | 48), never S/M/L.",
27
+ "- Variants are flat: accent | accent-subtle | neutral | neutral-subtle | ghost | danger | danger-subtle | outline. One accent per visual group (Tags exempt, D40); danger only for destructive actions.",
28
+ "- Never hardcode colors — bind var(--psi-*) custom properties.",
29
+ "- Import all four token CSS files: base.css, one theme css, components.css, utilities.css (utilities is REQUIRED — .psi-container + reduced-motion zeroing).",
30
+ `- Themes: ${index.themes.join(" | ")} — set data-psi-theme on <html> or a subtree root.`,
31
+ "",
32
+ `## Components (${index.components.length})`,
33
+ ...index.components.map((c) => `- ${c.name}${axes(c)}`),
34
+ "",
35
+ "## Live documentation (MCP, tools: search/get)",
36
+ "- Hosted: https://psi.kurkin.de/mcp (Streamable HTTP)",
37
+ "- Local: `npx @handamade/psi-mcp` (stdio)",
38
+ END_MARKER,
39
+ ].join("\n");
40
+ }
41
+ export function applyBlock(existing, block) {
42
+ if (existing === null || existing.trim() === "")
43
+ return block + "\n";
44
+ if (existing.includes(START_MARKER) && existing.includes(END_MARKER)) {
45
+ const re = new RegExp(`${START_MARKER}[\\s\\S]*?${END_MARKER}`);
46
+ return existing.replace(re, () => block);
47
+ }
48
+ return existing.trimEnd() + "\n\n" + block + "\n";
49
+ }
50
+ export async function runInit(opts) {
51
+ const index = await loadIndex();
52
+ const block = renderAgentsBlock(index);
53
+ const files = ["AGENTS.md", ...(opts.claude ? ["CLAUDE.md"] : [])];
54
+ for (const name of files) {
55
+ const path = join(opts.cwd, name);
56
+ const existing = existsSync(path) ? await readFile(path, "utf8") : null;
57
+ await writeFile(path, applyBlock(existing, block));
58
+ console.log(`${existing === null ? "created" : "updated"} ${name}`);
59
+ }
60
+ }