feat(mcpedia): Phase 1 MVP — monorepo, Core, Web UI, MCP server, Postgres FTS

- bun workspaces + Turborepo monorepo (apps/web, apps/mcp; packages/*, scripts)
- @mcpedia/core single business-logic layer (Document/Content/Search services)
- @mcpedia/db Drizzle schema: documents + weighted tsvector (GIN) for FTS
- @mcpedia/parser frontmatter, @mcpedia/search Postgres FTS (ts_rank+ts_headline)
- Next.js 16 Web UI (home/doc SSG, search dynamic) + react-markdown render
- MCP server (stdio) with 4 tools + in-memory smoke test
- scripts/indexer walks content/ -> upserts into Postgres
- 4 seed docs; README + PHASES status
This commit is contained in:
asepharyana
2026-08-19 17:40:14 +07:00
commit ec3a2867d4
60 changed files with 3723 additions and 0 deletions
+61
View File
@@ -0,0 +1,61 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getDocument, listDocuments, getRelated } from "@mcpedia/core";
import Markdown from "@/components/Markdown";
export default async function DocPage({
params,
}: {
params: Promise<{ section: string; slug: string[] }>;
}) {
const { section, slug } = await params;
const fullSlug = `${section}/${slug.join("/")}`;
const doc = await getDocument(fullSlug);
if (!doc) notFound();
const related = await getRelated(fullSlug, 5);
return (
<article className="space-y-4">
<div>
<Link href="/" className="text-sm text-zinc-500 hover:underline">
← Back
</Link>
<h1 className="text-2xl font-semibold tracking-tight mt-2">
{doc.title}
</h1>
<div className="text-xs text-zinc-500 mt-1">
{doc.tags.map((t) => `#${t}`).join(" ")} · {doc.author || "unknown"}
</div>
</div>
<Markdown source={doc.body} />
{related.length > 0 && (
<aside className="mt-8 border-t border-zinc-200 dark:border-zinc-800 pt-4">
<h2 className="text-sm font-medium mb-2">Related</h2>
<ul className="text-sm space-y-1">
{related.map((r) => (
<li key={r.slug}>
<Link
href={`/${r.section}/${r.slug}`}
className="hover:underline"
>
{r.title}
</Link>
</li>
))}
</ul>
</aside>
)}
</article>
);
}
export async function generateStaticParams() {
const docs = await listDocuments();
return docs.map((d) => ({
section: d.section,
slug: d.slug.split("/").slice(1),
}));
}
+9
View File
@@ -0,0 +1,9 @@
import ReactMarkdown from "react-markdown";
export default function Markdown({ source }: { source: string }) {
return (
<div className="prose prose-zinc dark:prose-invert max-w-none [&_pre]:bg-zinc-100 dark:[&_pre]:bg-zinc-900 [&_pre]:p-3 [&_pre]:rounded [&_code]:font-mono [&_a]:underline">
<ReactMarkdown>{source}</ReactMarkdown>
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
@import "tailwindcss";
:root {
--background: #ffffff;
--foreground: #171717;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
}
+39
View File
@@ -0,0 +1,39 @@
import type { Metadata } from "next";
import Link from "next/link";
import "./globals.css";
export const metadata: Metadata = {
title: "MCPedia",
description: "Knowledge base for humans and AI agents via MCP.",
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en" className="h-full antialiased">
<body className="min-h-full flex flex-col bg-zinc-50 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-100">
<header className="border-b border-zinc-200 dark:border-zinc-800">
<div className="mx-auto max-w-5xl px-4 py-3 flex items-center justify-between">
<Link href="/" className="font-semibold tracking-tight">
MCPedia
</Link>
<nav className="flex gap-4 text-sm">
<Link href="/" className="hover:underline">Home</Link>
<Link href="/search" className="hover:underline">Search</Link>
<Link
href="/docs"
className="hover:underline"
>
Docs
</Link>
</nav>
</div>
</header>
<main className="mx-auto max-w-5xl w-full flex-1 px-4 py-8">{children}</main>
</body>
</html>
);
}
+50
View File
@@ -0,0 +1,50 @@
import Link from "next/link";
import { listDocuments } from "@mcpedia/core";
import type { DocumentMeta } from "@mcpedia/core";
const SECTIONS = ["docs", "writeups", "research", "notes"] as const;
export default async function Home() {
const all = await listDocuments();
const bySection = SECTIONS.map((section) => ({
section,
docs: all.filter((d) => d.section === section),
}));
return (
<div className="space-y-8">
<section>
<h1 className="text-2xl font-semibold tracking-tight">MCPedia</h1>
<p className="mt-2 text-zinc-600 dark:text-zinc-400">
A content-first knowledge base. Humans read the Web UI; AI agents use
the MCP server. Both share one Core.
</p>
</section>
{bySection.map(({ section, docs }) => (
<section key={section}>
<h2 className="text-lg font-medium capitalize mb-2">{section}</h2>
{docs.length === 0 ? (
<p className="text-sm text-zinc-500">No documents yet.</p>
) : (
<ul className="divide-y divide-zinc-200 dark:divide-zinc-800">
{docs.map((d: DocumentMeta) => (
<li key={d.slug} className="py-2">
<Link
href={`/${d.section}/${d.slug}`}
className="hover:underline font-medium"
>
{d.title}
</Link>
<div className="text-xs text-zinc-500 mt-0.5">
{d.tags.map((t) => `#${t}`).join(" ")}
</div>
</li>
))}
</ul>
)}
</section>
))}
</div>
);
}
+56
View File
@@ -0,0 +1,56 @@
import Link from "next/link";
import { keywordSearch } from "@mcpedia/core";
export default async function SearchPage({
searchParams,
}: {
searchParams: Promise<{ q?: string }>;
}) {
const { q } = await searchParams;
const query = q?.trim() ?? "";
const hits = query ? await keywordSearch(query, 30) : [];
return (
<div className="space-y-4">
<h1 className="text-2xl font-semibold tracking-tight">Search</h1>
<form method="get" className="flex gap-2">
<input
name="q"
defaultValue={query}
placeholder="e.g. websocket contract typescript"
className="flex-1 rounded border border-zinc-300 dark:border-zinc-700 bg-white dark:bg-zinc-900 px-3 py-2 text-sm"
/>
<button
type="submit"
className="rounded bg-zinc-900 text-white dark:bg-zinc-100 dark:text-zinc-900 px-4 py-2 text-sm font-medium"
>
Search
</button>
</form>
{query && hits.length === 0 && (
<p className="text-sm text-zinc-500">No results for “{query}”.</p>
)}
<ul className="space-y-3">
{hits.map((h) => (
<li
key={h.doc.slug}
className="rounded border border-zinc-200 dark:border-zinc-800 p-3"
>
<Link
href={`/${h.doc.section}/${h.doc.slug}`}
className="font-medium hover:underline"
>
{h.doc.title}
</Link>
<p
className="text-sm text-zinc-600 dark:text-zinc-400 mt-1"
dangerouslySetInnerHTML={{ __html: h.snippet }}
/>
</li>
))}
</ul>
</div>
);
}