{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "helpbase-mcp",
  "title": "Helpbase MCP Server",
  "description": "Self-hosted Model Context Protocol server for your docs. Drops as source code into your repo (mcp/ directory). Exposes three tools (search_docs, get_doc, list_docs) over stdio so AI agents like Claude Desktop, Cursor, and Zed can query your MDX content. Your docs never leave your filesystem.",
  "dependencies": [
    "@modelcontextprotocol/sdk",
    "gray-matter",
    "zod"
  ],
  "devDependencies": [
    "tsx"
  ],
  "files": [
    {
      "path": "registry/helpbase-mcp/index.ts",
      "content": "#!/usr/bin/env node\n\n/**\n * `@helpbase/mcp` — stdio entry point.\n *\n * Spawned by an MCP client (Claude Desktop, Cursor, Zed, Windsurf, etc.)\n * Talks JSON-RPC over stdin/stdout. All logs go to stderr — writing anything\n * else to stdout corrupts the protocol stream.\n */\n\nimport { StdioServerTransport } from \"@modelcontextprotocol/sdk/server/stdio.js\"\nimport { buildServer } from \"./server.js\"\n\nasync function main() {\n  const { server, deps } = buildServer()\n\n  // Always log the bootstrap summary to stderr so users can see it when they\n  // run the server manually for debugging, without polluting stdout.\n  process.stderr.write(\n    `[helpbase-mcp] Loaded ${deps.docs.length} docs across ${deps.categories.length} categories from ${deps.contentDir}\\n`,\n  )\n\n  const transport = new StdioServerTransport()\n  await server.connect(transport)\n}\n\nmain().catch((err) => {\n  const message = err instanceof Error ? err.message : String(err)\n  process.stderr.write(`[helpbase-mcp] fatal: ${message}\\n`)\n  process.exit(1)\n})\n",
      "type": "registry:file",
      "target": "mcp/index.ts"
    },
    {
      "path": "registry/helpbase-mcp/server.ts",
      "content": "import { McpServer } from \"@modelcontextprotocol/sdk/server/mcp.js\"\nimport {\n  findContentDir,\n  loadCategories,\n  loadDocs,\n  type CategoryMeta,\n  type Doc,\n} from \"./content/loader.js\"\nimport {\n  findSkillsDir,\n  loadSkills,\n  type Skill,\n} from \"./content/skills.js\"\nimport {\n  handleSearchDocs,\n  searchDocsInput,\n} from \"./tools/search-docs.js\"\nimport { getDocInput, handleGetDoc } from \"./tools/get-doc.js\"\nimport { handleListDocs, listDocsInput } from \"./tools/list-docs.js\"\nimport { getSkillInput, handleGetSkill } from \"./tools/get-skill.js\"\nimport { handleListSkills, listSkillsInput } from \"./tools/list-skills.js\"\n\nexport interface ServerDeps {\n  contentDir: string\n  docs: Doc[]\n  categories: CategoryMeta[]\n  /** Resolved .helpbase/skills/ directory, or null when none was found. */\n  skillsDir: string | null\n  /** Loaded skills. Empty when no skills dir is present — not an error. */\n  skills: Skill[]\n}\n\nexport interface BuildServerOptions {\n  name?: string\n  version?: string\n  /** Override the content dir. If omitted, resolves via findContentDir(). */\n  contentDir?: string\n  /**\n   * Override the skills dir. If omitted, resolves via findSkillsDir().\n   * Pass `null` to force skills-off regardless of filesystem state.\n   */\n  skillsDir?: string | null\n}\n\n/**\n * Build a fresh McpServer with three tools wired against a loaded content index.\n *\n * Transport-agnostic: this function does NOT connect a transport. Callers pick\n * stdio (entry point) or HTTP (v2) and connect themselves. Keeping transport\n * out of here is what makes adding HTTP in v2 a 1-file change.\n *\n * CRITICAL: never write to stdout from this file or anything it calls. Under\n * stdio transport, stdout carries the JSON-RPC stream and any stray write\n * corrupts it. Logs go to stderr via console.error (or a dedicated logger).\n */\nexport function buildServer(options: BuildServerOptions = {}): {\n  server: McpServer\n  deps: ServerDeps\n} {\n  const contentDir = options.contentDir ?? findContentDir()\n  const docs = loadDocs(contentDir)\n  const categories = loadCategories(contentDir)\n\n  // Skills are OPTIONAL — no .helpbase/skills/ means an empty list,\n  // not an error. Pass `skillsDir: null` to force skills-off.\n  const skillsDir =\n    options.skillsDir === null\n      ? null\n      : (options.skillsDir ?? findSkillsDir())\n  const skills = loadSkills(skillsDir)\n\n  const server = new McpServer({\n    name: options.name ?? \"helpbase-mcp\",\n    version: options.version ?? \"0.0.1\",\n  })\n\n  server.registerTool(\n    \"search_docs\",\n    {\n      title: \"Search docs\",\n      description:\n        \"Search Helpbase docs by keyword. Matches titles, descriptions, and body. Returns ranked list of matching doc slugs.\",\n      inputSchema: searchDocsInput.shape,\n    },\n    async (input) => handleSearchDocs(docs, input),\n  )\n\n  server.registerTool(\n    \"get_doc\",\n    {\n      title: \"Get doc\",\n      description:\n        \"Fetch the full MDX content of a single doc by slug. Slug format: 'category/slug' or just 'slug' (first match wins).\",\n      inputSchema: getDocInput.shape,\n    },\n    async (input) => handleGetDoc(docs, input),\n  )\n\n  server.registerTool(\n    \"list_docs\",\n    {\n      title: \"List docs\",\n      description:\n        \"List all available docs grouped by category. Optionally filter by a single category slug.\",\n      inputSchema: listDocsInput.shape,\n    },\n    async (input) => handleListDocs(docs, categories, input),\n  )\n\n  // Skills server: agents pull writing-style / tone / formatting rules\n  // from .helpbase/skills/. Empty list when no skills are defined — no\n  // error, just silence. See content/skills.ts.\n  server.registerTool(\n    \"list_skills\",\n    {\n      title: \"List skills\",\n      description:\n        \"List writing-style, tone, and formatting rules the docs team has \" +\n        \"published for this product. Returns an empty list if no skills \" +\n        \"are defined in .helpbase/skills/.\",\n      inputSchema: listSkillsInput.shape,\n    },\n    async () => handleListSkills(skills),\n  )\n\n  server.registerTool(\n    \"get_skill\",\n    {\n      title: \"Get skill\",\n      description:\n        \"Fetch the full content of a single skill (writing-style / tone / \" +\n        \"formatting rule) by name. Use list_skills to discover available \" +\n        \"names.\",\n      inputSchema: getSkillInput.shape,\n    },\n    async (input) => handleGetSkill(skills, input),\n  )\n\n  return {\n    server,\n    deps: { contentDir, docs, categories, skillsDir, skills },\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/server.ts"
    },
    {
      "path": "registry/helpbase-mcp/content/loader.ts",
      "content": "import fs from \"node:fs\"\nimport path from \"node:path\"\nimport matter from \"gray-matter\"\n\nexport interface Doc {\n  slug: string\n  category: string\n  title: string\n  description: string\n  filePath: string\n  content: string\n}\n\nexport interface CategoryMeta {\n  slug: string\n  title: string\n  order: number\n}\n\n// More-specific candidates come first so `content/docs/` wins over a\n// sibling `content/` that might hold non-doc assets (blog posts, changelog\n// entries, marketing copy). `content/docs/` is a common MDX-in-subfolder\n// convention for docs-only content.\nconst CONTENT_DIR_CANDIDATES = [\n  \"apps/web/content\",\n  \"content/docs\",\n  \"content\",\n]\n\n/**\n * Find the content directory.\n *\n * Resolution order:\n *   1. HELPBASE_CONTENT_DIR env var (absolute or relative to cwd)\n *   2. Walk up from cwd trying each candidate in order:\n *      - `apps/web/content/` (monorepo shape)\n *      - `content/docs/`     (MDX-in-subfolder shape)\n *      - `content/`          (flat shape)\n *\n * Returns an absolute path. Throws if nothing is found — callers should let this\n * bubble up with a clear message rather than silently serving an empty index.\n */\nexport function findContentDir(startDir: string = process.cwd()): string {\n  const envOverride = process.env.HELPBASE_CONTENT_DIR\n  if (envOverride && envOverride.length > 0) {\n    const resolved = path.isAbsolute(envOverride)\n      ? envOverride\n      : path.resolve(startDir, envOverride)\n    if (!fs.existsSync(resolved)) {\n      throw new Error(\n        `HELPBASE_CONTENT_DIR points at ${resolved} but that directory does not exist.`,\n      )\n    }\n    return resolved\n  }\n\n  let dir = path.resolve(startDir)\n  const root = path.parse(dir).root\n  while (true) {\n    for (const candidate of CONTENT_DIR_CANDIDATES) {\n      const full = path.join(dir, candidate)\n      if (fs.existsSync(full) && fs.statSync(full).isDirectory()) {\n        return full\n      }\n    }\n    if (dir === root) break\n    dir = path.dirname(dir)\n  }\n\n  throw new Error(\n    `Could not find a content directory. Looked for ${CONTENT_DIR_CANDIDATES.join(\" or \")} walking up from ${startDir}. Set HELPBASE_CONTENT_DIR to point at your docs folder.`,\n  )\n}\n\nfunction deriveTitle(rawTitle: unknown, fallbackSlug: string): string {\n  if (typeof rawTitle === \"string\" && rawTitle.trim().length > 0) {\n    return rawTitle.trim()\n  }\n  return fallbackSlug\n    .split(\"-\")\n    .map((w) => (w.length > 0 ? w[0]!.toUpperCase() + w.slice(1) : w))\n    .join(\" \")\n}\n\nfunction deriveDescription(rawDesc: unknown): string {\n  if (typeof rawDesc === \"string\") return rawDesc.trim()\n  return \"\"\n}\n\n/**\n * Load all docs from the content directory.\n *\n * Shape expected:\n *   <content-dir>/<category-slug>/<doc-slug>.mdx\n *\n * Files prefixed with `_` are skipped (convention for _category.json, etc.).\n * Only .mdx and .md files are loaded.\n */\nexport function loadDocs(contentDir: string): Doc[] {\n  if (!fs.existsSync(contentDir)) return []\n\n  const docs: Doc[] = []\n  const categoryDirs = fs\n    .readdirSync(contentDir, { withFileTypes: true })\n    .filter((d) => d.isDirectory())\n    .filter((d) => !d.name.startsWith(\"_\"))\n\n  for (const dir of categoryDirs) {\n    const categorySlug = dir.name\n    const categoryPath = path.join(contentDir, categorySlug)\n    const files = fs\n      .readdirSync(categoryPath)\n      .filter((f) => !f.startsWith(\"_\"))\n      .filter((f) => f.endsWith(\".mdx\") || f.endsWith(\".md\"))\n\n    for (const file of files) {\n      const filePath = path.join(categoryPath, file)\n      const slug = file.replace(/\\.mdx?$/, \"\")\n\n      // Skip unreadable or malformed files with a stderr warning instead of\n      // crashing the server. A single bad frontmatter should not take down the\n      // MCP stream. Mirrors apps/web/lib/content.ts's lenient-in-dev posture.\n      let raw: string\n      try {\n        raw = fs.readFileSync(filePath, \"utf-8\")\n      } catch (err) {\n        const msg = err instanceof Error ? err.message : String(err)\n        process.stderr.write(\n          `[helpbase-mcp] Skipping ${categorySlug}/${file}: read failed (${msg})\\n`,\n        )\n        continue\n      }\n\n      let parsed: ReturnType<typeof matter>\n      try {\n        parsed = matter(raw)\n      } catch (err) {\n        const msg = err instanceof Error ? err.message : String(err)\n        process.stderr.write(\n          `[helpbase-mcp] Skipping ${categorySlug}/${file}: malformed frontmatter (${msg})\\n`,\n        )\n        continue\n      }\n\n      const { data, content } = parsed\n      docs.push({\n        slug,\n        category: categorySlug,\n        title: deriveTitle(data[\"title\"], slug),\n        description: deriveDescription(data[\"description\"]),\n        filePath: path.relative(contentDir, filePath),\n        content: content.trim(),\n      })\n    }\n  }\n\n  docs.sort((a, b) => {\n    if (a.category !== b.category) return a.category.localeCompare(b.category)\n    return a.slug.localeCompare(b.slug)\n  })\n\n  return docs\n}\n\n/**\n * Load category metadata (title, order) from `_category.json` files.\n * Missing files are fine — we derive a sensible default.\n */\nexport function loadCategories(contentDir: string): CategoryMeta[] {\n  if (!fs.existsSync(contentDir)) return []\n\n  const categories: CategoryMeta[] = []\n  const dirs = fs\n    .readdirSync(contentDir, { withFileTypes: true })\n    .filter((d) => d.isDirectory())\n    .filter((d) => !d.name.startsWith(\"_\"))\n\n  for (const dir of dirs) {\n    const metaPath = path.join(contentDir, dir.name, \"_category.json\")\n    let title = deriveTitle(undefined, dir.name)\n    let order = 999\n    if (fs.existsSync(metaPath)) {\n      try {\n        const parsed = JSON.parse(fs.readFileSync(metaPath, \"utf-8\"))\n        if (typeof parsed.title === \"string\") title = parsed.title\n        if (typeof parsed.order === \"number\") order = parsed.order\n      } catch {\n        // Malformed _category.json — use defaults, don't fail.\n      }\n    }\n    categories.push({ slug: dir.name, title, order })\n  }\n\n  categories.sort((a, b) => {\n    if (a.order !== b.order) return a.order - b.order\n    return a.slug.localeCompare(b.slug)\n  })\n  return categories\n}\n",
      "type": "registry:file",
      "target": "mcp/content/loader.ts"
    },
    {
      "path": "registry/helpbase-mcp/content/index.ts",
      "content": "import type { Doc } from \"./loader.js\"\n\nexport interface SearchHit {\n  doc: Doc\n  score: number\n}\n\n/**\n * Very small keyword-match search. Lowercases both sides, splits the query on\n * whitespace, scores each doc by:\n *   +5 per query term in title\n *   +3 per query term in description\n *   +1 per query term in body (capped at 5 occurrences to avoid term-spam boost)\n *\n * v1 ships with this intentionally simple ranker. Semantic search is TODO-011.\n */\nexport function searchDocs(docs: Doc[], query: string): SearchHit[] {\n  const terms = query\n    .toLowerCase()\n    .split(/\\s+/)\n    .map((t) => t.trim())\n    .filter((t) => t.length > 0)\n\n  if (terms.length === 0) return []\n\n  const hits: SearchHit[] = []\n  for (const doc of docs) {\n    const title = doc.title.toLowerCase()\n    const description = doc.description.toLowerCase()\n    const body = doc.content.toLowerCase()\n    let score = 0\n\n    for (const term of terms) {\n      if (title.includes(term)) score += 5\n      if (description.includes(term)) score += 3\n      const bodyMatches = countOccurrences(body, term)\n      score += Math.min(bodyMatches, 5)\n    }\n\n    if (score > 0) hits.push({ doc, score })\n  }\n\n  hits.sort((a, b) => b.score - a.score)\n  return hits\n}\n\nfunction countOccurrences(haystack: string, needle: string): number {\n  if (needle.length === 0) return 0\n  let count = 0\n  let idx = 0\n  while ((idx = haystack.indexOf(needle, idx)) !== -1) {\n    count += 1\n    idx += needle.length\n  }\n  return count\n}\n",
      "type": "registry:file",
      "target": "mcp/content/index.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/search-docs.ts",
      "content": "import { z } from \"zod\"\nimport { searchDocs } from \"../content/index.js\"\nimport type { Doc } from \"../content/loader.js\"\n\nexport const searchDocsInput = z.object({\n  query: z.string().min(1, \"query must not be empty\"),\n  limit: z.number().int().positive().max(50).optional(),\n})\n\nexport type SearchDocsInput = z.infer<typeof searchDocsInput>\n\nexport function handleSearchDocs(docs: Doc[], input: SearchDocsInput) {\n  const limit = input.limit ?? 10\n  const hits = searchDocs(docs, input.query).slice(0, limit)\n\n  if (hits.length === 0) {\n    return {\n      content: [\n        {\n          type: \"text\" as const,\n          text: `No docs matched \"${input.query}\".`,\n        },\n      ],\n    }\n  }\n\n  const lines = hits.map((hit) => {\n    const d = hit.doc\n    const desc = d.description ? ` — ${d.description}` : \"\"\n    return `- [${d.category}/${d.slug}] ${d.title}${desc}`\n  })\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: `Found ${hits.length} doc(s):\\n${lines.join(\"\\n\")}`,\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/search-docs.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/get-doc.ts",
      "content": "import { z } from \"zod\"\nimport type { Doc } from \"../content/loader.js\"\n\nexport const getDocInput = z.object({\n  slug: z\n    .string()\n    .min(1, \"slug must not be empty\")\n    .describe(\"Either 'category/slug' or just 'slug' (first match wins)\"),\n})\n\nexport type GetDocInput = z.infer<typeof getDocInput>\n\nexport function handleGetDoc(docs: Doc[], input: GetDocInput) {\n  const raw = input.slug.trim()\n  const [categoryPart, slugPart] = raw.includes(\"/\")\n    ? raw.split(\"/\", 2)\n    : [undefined, raw]\n\n  const match = docs.find((d) => {\n    if (categoryPart !== undefined && slugPart !== undefined) {\n      return d.category === categoryPart && d.slug === slugPart\n    }\n    return d.slug === slugPart\n  })\n\n  if (!match) {\n    return {\n      isError: true,\n      content: [\n        {\n          type: \"text\" as const,\n          text: `No doc found for \"${raw}\". Use list_docs to see available slugs.`,\n        },\n      ],\n    }\n  }\n\n  const header = [\n    `# ${match.title}`,\n    match.description ? `> ${match.description}` : \"\",\n    `Path: ${match.category}/${match.slug}`,\n    \"\",\n  ]\n    .filter((l) => l.length > 0)\n    .join(\"\\n\")\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: `${header}\\n\\n${match.content}`,\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/get-doc.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/list-docs.ts",
      "content": "import { z } from \"zod\"\nimport type { Doc, CategoryMeta } from \"../content/loader.js\"\n\nexport const listDocsInput = z.object({\n  category: z\n    .string()\n    .optional()\n    .describe(\"Filter to a single category slug. Omit to list everything.\"),\n})\n\nexport type ListDocsInput = z.infer<typeof listDocsInput>\n\nexport function handleListDocs(\n  docs: Doc[],\n  categories: CategoryMeta[],\n  input: ListDocsInput,\n) {\n  const filter = input.category?.trim()\n  const visible = filter ? docs.filter((d) => d.category === filter) : docs\n\n  if (visible.length === 0) {\n    return {\n      content: [\n        {\n          type: \"text\" as const,\n          text: filter\n            ? `No docs found in category \"${filter}\".`\n            : \"No docs available.\",\n        },\n      ],\n    }\n  }\n\n  const categoryOrder = new Map(categories.map((c, i) => [c.slug, i]))\n  const categoryTitles = new Map(categories.map((c) => [c.slug, c.title]))\n\n  const groups = new Map<string, Doc[]>()\n  for (const d of visible) {\n    const existing = groups.get(d.category) ?? []\n    existing.push(d)\n    groups.set(d.category, existing)\n  }\n\n  const sortedCats = Array.from(groups.keys()).sort((a, b) => {\n    const oa = categoryOrder.get(a) ?? 999\n    const ob = categoryOrder.get(b) ?? 999\n    if (oa !== ob) return oa - ob\n    return a.localeCompare(b)\n  })\n\n  const lines: string[] = []\n  for (const cat of sortedCats) {\n    const title = categoryTitles.get(cat) ?? cat\n    lines.push(`## ${title}`)\n    for (const doc of groups.get(cat)!) {\n      const desc = doc.description ? ` — ${doc.description}` : \"\"\n      lines.push(`- ${cat}/${doc.slug}: ${doc.title}${desc}`)\n    }\n    lines.push(\"\")\n  }\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: lines.join(\"\\n\").trim(),\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/list-docs.ts"
    },
    {
      "path": "registry/helpbase-mcp/content/skills.ts",
      "content": "import fs from \"node:fs\"\nimport path from \"node:path\"\nimport matter from \"gray-matter\"\n\n/**\n * A skill is a markdown file living under `.helpbase/skills/` in the user's\n * repo. It encodes a rule, convention, or style guide that an AI agent\n * should pull when authoring content for this product — tone/voice,\n * formatting standards, terminology, whatever the docs team wants\n * enforced.\n *\n * Served via the MCP tools `list_skills` and `get_skill`. The docs team\n * edits the files in git; downstream agents and tools read them over the\n * wire. No schema migration, no dashboard — markdown wins.\n *\n * This is v1 of the \"skills server\" Shadcn asked about on 2026-04-22:\n * https://x.com/shadcn/... (\"interesting to see if this can also be a\n * skills server... enforcing tone, writing styles... editable by the\n * docs team... pulled by other teams\").\n */\nexport interface Skill {\n  /** Filename without the .md extension. Used as the identifier. */\n  name: string\n  /** Frontmatter `description`, or empty string if absent. */\n  description: string\n  /** Full post-frontmatter body. */\n  content: string\n  /** Absolute path, for diagnostics. */\n  filePath: string\n}\n\n/**\n * Find the `.helpbase/skills/` directory.\n *\n * Resolution order:\n *   1. HELPBASE_SKILLS_DIR env var (absolute or relative to cwd)\n *   2. Walk up from cwd looking for `.helpbase/skills/`\n *\n * Returns `null` when no skills directory is found. Unlike findContentDir\n * (which throws), skills are OPTIONAL — a repo without skills should\n * surface an empty list, not crash the server.\n */\nexport function findSkillsDir(startDir: string = process.cwd()): string | null {\n  const envOverride = process.env.HELPBASE_SKILLS_DIR\n  if (envOverride && envOverride.length > 0) {\n    const resolved = path.isAbsolute(envOverride)\n      ? envOverride\n      : path.resolve(startDir, envOverride)\n    if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {\n      // Explicit override that doesn't exist IS an error — loud signal\n      // that the user's env is misconfigured rather than silently empty.\n      throw new Error(\n        `HELPBASE_SKILLS_DIR points at ${resolved} but that directory does not exist.`,\n      )\n    }\n    return resolved\n  }\n\n  let dir = path.resolve(startDir)\n  const root = path.parse(dir).root\n  while (true) {\n    const candidate = path.join(dir, \".helpbase\", \"skills\")\n    if (fs.existsSync(candidate) && fs.statSync(candidate).isDirectory()) {\n      return candidate\n    }\n    if (dir === root) return null\n    dir = path.dirname(dir)\n  }\n}\n\n/**\n * Load all skills from `.helpbase/skills/*.md`.\n *\n * Files prefixed with `_` are treated as drafts and skipped (matches the\n * content/docs convention). Only top-level `.md` files are loaded — no\n * subdirectories, no grouping. Keep the layout flat.\n *\n * Malformed frontmatter logs to stderr and skips the file rather than\n * crashing the server, mirroring loader.ts's lenient posture.\n */\nexport function loadSkills(skillsDir: string | null): Skill[] {\n  if (!skillsDir) return []\n  if (!fs.existsSync(skillsDir)) return []\n\n  const entries = fs\n    .readdirSync(skillsDir, { withFileTypes: true })\n    .filter((e) => e.isFile())\n    .filter((e) => !e.name.startsWith(\"_\"))\n    .filter((e) => e.name.endsWith(\".md\"))\n\n  const skills: Skill[] = []\n  for (const entry of entries) {\n    const filePath = path.join(skillsDir, entry.name)\n    const name = entry.name.replace(/\\.md$/, \"\")\n\n    let raw: string\n    try {\n      raw = fs.readFileSync(filePath, \"utf-8\")\n    } catch (err) {\n      const msg = err instanceof Error ? err.message : String(err)\n      process.stderr.write(\n        `[helpbase-mcp] Skipping skill ${entry.name}: read failed (${msg})\\n`,\n      )\n      continue\n    }\n\n    let parsed: ReturnType<typeof matter>\n    try {\n      parsed = matter(raw)\n    } catch (err) {\n      const msg = err instanceof Error ? err.message : String(err)\n      process.stderr.write(\n        `[helpbase-mcp] Skipping skill ${entry.name}: malformed frontmatter (${msg})\\n`,\n      )\n      continue\n    }\n\n    const description =\n      typeof parsed.data[\"description\"] === \"string\"\n        ? String(parsed.data[\"description\"]).trim()\n        : \"\"\n\n    skills.push({\n      name,\n      description,\n      content: parsed.content.trim(),\n      filePath,\n    })\n  }\n\n  skills.sort((a, b) => a.name.localeCompare(b.name))\n  return skills\n}\n",
      "type": "registry:file",
      "target": "mcp/content/skills.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/list-skills.ts",
      "content": "import { z } from \"zod\"\nimport type { Skill } from \"../content/skills.js\"\n\nexport const listSkillsInput = z.object({}).describe(\"No inputs.\")\n\nexport type ListSkillsInput = z.infer<typeof listSkillsInput>\n\nexport function handleListSkills(skills: Skill[]) {\n  if (skills.length === 0) {\n    return {\n      content: [\n        {\n          type: \"text\" as const,\n          text:\n            \"No skills defined. Add markdown files to .helpbase/skills/ to \" +\n            \"define writing-style, tone, or formatting rules that agents \" +\n            \"can pull via get_skill.\",\n        },\n      ],\n    }\n  }\n\n  const lines: string[] = [\"## Skills\", \"\"]\n  for (const s of skills) {\n    const desc = s.description ? ` — ${s.description}` : \"\"\n    lines.push(`- ${s.name}${desc}`)\n  }\n  lines.push(\"\", \"Fetch a skill via get_skill({ name }).\")\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: lines.join(\"\\n\"),\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/list-skills.ts"
    },
    {
      "path": "registry/helpbase-mcp/tools/get-skill.ts",
      "content": "import { z } from \"zod\"\nimport type { Skill } from \"../content/skills.js\"\n\nexport const getSkillInput = z.object({\n  name: z\n    .string()\n    .min(1, \"name must not be empty\")\n    .describe(\"Skill name (filename in .helpbase/skills/ without .md extension).\"),\n})\n\nexport type GetSkillInput = z.infer<typeof getSkillInput>\n\nexport function handleGetSkill(skills: Skill[], input: GetSkillInput) {\n  const name = input.name.trim()\n  const match = skills.find((s) => s.name === name)\n\n  if (!match) {\n    const available =\n      skills.length > 0\n        ? ` Available: ${skills.map((s) => s.name).join(\", \")}.`\n        : \" No skills are currently defined.\"\n    return {\n      isError: true,\n      content: [\n        {\n          type: \"text\" as const,\n          text: `No skill found named \"${name}\".${available}`,\n        },\n      ],\n    }\n  }\n\n  const header = [\n    `# ${match.name}`,\n    match.description ? `> ${match.description}` : \"\",\n  ]\n    .filter((l) => l.length > 0)\n    .join(\"\\n\")\n\n  return {\n    content: [\n      {\n        type: \"text\" as const,\n        text: `${header}\\n\\n${match.content}`,\n      },\n    ],\n  }\n}\n",
      "type": "registry:file",
      "target": "mcp/tools/get-skill.ts"
    },
    {
      "path": "registry/helpbase-mcp/README.md",
      "content": "# Helpbase MCP Server\n\nSelf-hosted Model Context Protocol server for your docs. Runs as source code\nin your repo (no vendored npm binary), reads your MDX, exposes five tools\nover stdio to any MCP client:\n\n- `search_docs` / `get_doc` / `list_docs` — docs surface\n- `list_skills` / `get_skill` — writing-style / tone / formatting rules\n  the docs team publishes in `.helpbase/skills/*.md`\n\n## Run it\n\nAfter `npx shadcn add`, you have the server source at `mcp/` and `tsx` in\nyour devDependencies. Set the content directory and start the server.\n\n### Claude Desktop\n\nEdit `~/Library/Application Support/Claude/claude_desktop_config.json`:\n\n```json\n{\n  \"mcpServers\": {\n    \"helpbase\": {\n      \"command\": \"npx\",\n      \"args\": [\"-y\", \"tsx\", \"/absolute/path/to/your/repo/mcp/index.ts\"],\n      \"env\": {\n        \"HELPBASE_CONTENT_DIR\": \"/absolute/path/to/your/repo/apps/web/content\"\n      }\n    }\n  }\n}\n```\n\nRestart Claude Desktop. The three tools appear in the tool picker.\n\n### Cursor / Zed / Windsurf / any MCP client\n\nSame pattern: point the client at `npx tsx <absolute-path>/mcp/index.ts` with\n`HELPBASE_CONTENT_DIR` in the env block.\n\n### Local test\n\n```bash\nHELPBASE_CONTENT_DIR=./apps/web/content npx tsx mcp/index.ts\n# sends bootstrap line to stderr, speaks JSON-RPC over stdin/stdout\n```\n\n## Content discovery\n\nIf `HELPBASE_CONTENT_DIR` is not set, the server walks up from its cwd looking\nfor (in order):\n\n- `apps/web/content/` — monorepo shape\n- `content/docs/` — MDX-in-subfolder shape (docs alongside blog, changelog, etc.)\n- `content/` — flat shape\n\nThe first match wins. If none exists, the server fails on startup with a clear\nerror — no silent empty index.\n\n## Skills discovery\n\nSkills are optional. If you want to publish writing-style, tone, or formatting\nrules that agents can pull on demand, drop markdown files under\n`.helpbase/skills/<name>.md` at your repo root:\n\n```\n.helpbase/skills/\n  voice.md          ← tone + writing style\n  api-reference.md  ← formatting rules for API docs\n  terminology.md    ← preferred terms, avoided words\n```\n\nFrontmatter is optional. A `description` field shows up in `list_skills`\noutput; the body is the full skill content returned by `get_skill`.\n\nOverride the location via `HELPBASE_SKILLS_DIR`. Walks up from cwd to find\n`.helpbase/skills/` otherwise. No skills found → empty list, not an error.\n\n## Why ship as source instead of an npm dep?\n\nBecause it's yours. Your MCP server lives in your repo. No vendor sits between\nyour docs and the agents that read them. Edit it, fork it, extend the tools,\nadd your own — it's code you own.\n\nIf you want the zero-config path instead, `npm i @helpbase/mcp` gets you the\nsame server as a published binary. Both paths are supported; this one just\nkeeps the code in your hands.\n",
      "type": "registry:file",
      "target": "mcp/README.md"
    }
  ],
  "envVars": {
    "HELPBASE_CONTENT_DIR": ""
  },
  "type": "registry:item"
}