- 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
21 KiB
MCPedia Implementation Plan
For Hermes: Use subagent-driven-development to implement task-by-task. Phase 1 tasks are bite-sized and ready to execute; Phases 2–4 are outlines to expand later.
Goal: Turn the fresh create-next-app Next.js 16 repo into the MCPedia knowledge base — Git/Markdown content, a Core business-logic layer shared by a Next.js Web UI and an MCP server for AI agents, with content metadata in Postgres (FTS search), and an architecture that can grow to semantic search, a tRPC API, auth, and async indexing without rewrites.
Architecture: Monorepo (bun workspaces + Turborepo). Content lives as Markdown in content/ (Git-managed, human-readable). packages/core holds all business logic (Document/Content/Search services). Web (apps/web) and MCP (apps/mcp) both consume Core — no DB access outside Core. Postgres (imrnes :6432) stores document metadata + a tsvector for keyword search. Phase 1 ships keyword search only; pgvector/semantic, tRPC/Hono API, auth, and BullMQ are later phases behind clean seams already present in Core.
Tech Stack (resolved forks):
- Package manager / monorepo: bun workspaces + Turborepo (honor existing repo; NOT pnpm — user decision).
- DB: imrnes Postgres
100.121.180.82:6432for both dev and deploy (PgBouncer transaction pool → setprepare: falseon the driver). - Phase 1 scope: Core + Web + MCP + Postgres FTS only. tRPC/Hono API, pgvector, auth, Redis/BullMQ deferred.
Environment & preconditions (read first)
- Repo is currently a flat Next.js 16 app, not git-initialized, bun-managed (
packageManager: bun@1.3.14,bun.lockpresent). app/has onlylayout.tsx,page.tsx,globals.css,favicon.ico. Tailwind v4 (@tailwindcss/postcss).- Next.js 16 is "NOT the Next.js you know" (see
AGENTS.md): before writing anyapps/webcode, readnode_modules/next/dist/docs/(app router, server components, route handlers) for breaking changes. Treat deprecation notices as authoritative. - For every library API used (Next 16, Drizzle,
@modelcontextprotocol/sdk, gray-matter) resolve + query Context7 before coding — do not trust training data. - Secrets:
DATABASE_URLlives in BWS projectorangevps(never committed). Local dev reads it from a gitignored.envat repo root. - Seed DB creds: a dedicated
mcpediadatabase/user on imrnes; create viabwssecret or operator.pgvectorextension is NOT required until Phase 2.
Phase 0 — Repo restructure (monorepo scaffold)
Task 0.1: Initialize git + baseline commit
- Files:
.gitignore(already exists; ensure.env,.next/,node_modules/,*.tsbuildinfo,dist/ignored). - Step:
git init && git add -A && git commit -m "chore: baseline create-next-app scaffold"(author = asepharyana, no Co-Authored-By). - Verify:
git log --oneline -1shows the commit.
Task 0.2: Root workspace package.json + Turbo
- Modify:
package.json(root) — strip app deps, add workspaces + turbo scripts. - Create:
turbo.json,tsconfig.base.json. - Root
package.jsonbecomes:
{
"name": "mcpedia",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.3.14",
"workspaces": ["apps/*", "packages/*"],
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"index": "bun run scripts/indexer.ts"
},
"devDependencies": {
"turbo": "^2",
"typescript": "^5",
"prettier": "^3"
}
}
turbo.json:
{
"$schema": "https://turbo.build/schema.json",
"tasks": {
"build": { "dependsOn": ["^build"], "outputs": ["dist/**", ".next/**"] },
"lint": {},
"typecheck": { "dependsOn": ["^build"] },
"dev": { "cache": false, "persistent": true }
}
}
- Verify:
bun installsucceeds;bunx turbo --versionprints a version.
Task 0.3: Move Web app into apps/web
- Move:
app/,public/,next.config.ts,next-env.d.ts,postcss.config.mjs,eslint.config.mjs,tsconfig.json→apps/web/(rename rootapp→apps/web/app). Keep Tailwind v4. Addapps/web/package.json:
{
"name": "@mcpedia/web",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"@mcpedia/core": "workspace:*",
"@mcpedia/config": "workspace:*"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.3.1",
"tailwindcss": "^4",
"typescript": "^5"
}
}
- Add
apps/web/tsconfig.jsonextending../../tsconfig.base.jsonwith@/*→./**/*and@mcpedia/*workspace aliases. - Verify:
cd apps/web && bun install && bun run buildcompiles (may warn about empty content — fine).
Task 0.4: .env (gitignored) for dev
- Create:
.env(gitignored) withDATABASE_URL=postgresql://<user>:<pass>@100.121.180.82:6432/mcpedia(value from BWS;prepare:falseenforced in db client). Add.env.example(committed, placeholder only).
Phase 1 — MVP: Core + Web + MCP + Postgres FTS (detailed)
Task 1.1: packages/types
- Create:
packages/types/package.json(@mcpedia/types, exportssrc/index.ts),packages/types/src/index.ts:
export type DocSection = "docs" | "writeups" | "research" | "notes";
export type DocType = "documentation" | "writeup" | "research" | "note";
export type DocStatus = "published" | "draft";
export interface DocumentMeta {
id: string; // slug
slug: string;
title: string;
type: DocType;
section: DocSection;
status: DocStatus;
author: string;
tags: string[];
path: string; // relative path under content/
createdAt: string;
updatedAt: string;
}
export interface Document extends DocumentMeta {
body: string; // raw markdown (read from disk or stored)
}
export interface SearchHit {
doc: DocumentMeta;
rank: number;
snippet: string;
}
- Verify:
bunx tsc --noEmitin package passes.
Task 1.2: packages/config
- Create:
packages/config/package.json(@mcpedia/config),packages/config/src/index.ts:
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "../../..");
export const CONTENT_ROOT = process.env.CONTENT_ROOT
?? resolve(root, "content");
export const DATABASE_URL = process.env.DATABASE_URL ?? "";
- Verify: import in a scratch
bun -eprints a path.
Task 1.3: packages/db (Drizzle + Postgres, FTS)
- Create:
packages/db/package.json(@mcpedia/db, depsdrizzle-orm,postgres; devdrizzle-kit),packages/db/src/schema.ts:
import { sql } from "drizzle-orm";
import { text, timestamp, integer, index, customType } from "drizzle-orm/pg-core";
// tsvector isn't a first-class drizzle type; wrap raw.
export const documents = pgTable("documents", {
id: text("id").primaryKey(), // slug
slug: text("slug").notNull().unique(),
title: text("title").notNull(),
type: text("type").notNull(),
section: text("section").notNull(),
status: text("status").notNull().default("published"),
author: text("author").notNull().default(""),
tags: text("tags").array().notNull().default(sql`'{}'`),
path: text("path").notNull(),
body: text("body").notNull().default(""),
searchVector: customType<{ data: string }>({
dataType() { return "tsvector"; },
})("search_vector")
.generatedAlwaysAs(sql`to_tsvector('simple', coalesce(title,'') || ' ' || coalesce(body,''))`),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
}, (t) => ({
searchIdx: index("documents_search_idx").using("gin", t.searchVector),
}));
NOTE: document_chunks + embedding vector are Phase 2 — do NOT add now.
- Create:
packages/db/src/client.ts— postgres-js withprepare: false(PgBouncer safe):
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { DATABASE_URL } from "@mcpedia/config";
import * as schema from "./schema";
const client = postgres(DATABASE_URL, { prepare: false, max: 5 });
export const db = drizzle(client, { schema });
export { schema };
- Create:
packages/db/drizzle.config.ts(dialectpostgresql, schema./src/schema.ts, out./drizzle). - Verify:
bunx drizzle-kit generateproduces a migration;bunx drizzle-kit pushapplies it to imrnes (operator supplies DATABASE_URL).bunx drizzle-kit studiooptional.
Task 1.4: packages/parser (frontmatter)
- Create:
packages/parser/package.json(@mcpedia/parser, depgray-matter),packages/parser/src/index.ts:
import matter from "gray-matter";
import { readFileSync } from "node:fs";
import type { DocSection, DocType, DocStatus, DocumentMeta } from "@mcpedia/types";
const SECTIONS: DocSection[] = ["docs", "writeups", "research", "notes"];
export function parseFile(absPath: string, relPath: string): { meta: DocumentMeta; body: string } {
const raw = readFileSync(absPath, "utf8");
const { data, content } = matter(raw);
const section = SECTIONS.find((s) => relPath.startsWith(s + "/")) ?? "docs";
const slug = relPath.replace(/\.mdx?$/, "");
const meta: DocumentMeta = {
id: slug,
slug,
title: data.title ?? slug,
type: (data.type ?? "documentation") as DocType,
section,
status: (data.status ?? "published") as DocStatus,
author: data.author ?? "",
tags: Array.isArray(data.tags) ? dataAtags : [],
path: relPath,
createdAt: data.created_at ?? new Date().toISOString(),
updatedAt: data.updated_at ?? data.created_at ?? new Date().toISOString(),
};
return { meta, body: content };
}
(fix the obvious dataAtags typo to data.tags when implementing.)
- Verify: unit test parsing a sample frontmatter → asserts
slug,section,tags.
Task 1.5: packages/search (FTS query)
- Create:
packages/search/package.json(@mcpedia/search, dep@mcpedia/db,@mcpedia/types),packages/search/src/index.ts:
import { db } from "@mcpedia/db";
import { documents } from "@mcpedia/db/schema";
import { sql, and, eq } from "drizzle-orm";
import type { SearchHit } from "@mcpedia/types";
export function toTsQuery(q: string): string {
// plainto_tsquery-style: AND the terms, escape punctuation.
const terms = q.trim().split(/\s+/).filter(Boolean).map((t) => t.replace(/[^\p{L}\p{N}]/gu, ""));
return terms.map((t) => `${t}:*`).join(" & ");
}
export async function keywordSearch(q: string, limit = 20): Promise<SearchHit[]> {
const query = toTsQuery(q);
if (!query) return [];
const rows = await db
.select({
doc: documents,
rank: sql<number>`ts_rank(${documents.searchVector}, to_tsquery('simple', ${query}))`,
snippet: sql<string>`ts_headline('simple', ${documents.body}, to_tsquery('simple', ${query}), 'MaxWords=20, MinWords=5')`,
})
.from(documents)
.where(and(eq(documents.status, "published"), sql`${documents.searchVector} @@ to_tsquery('simple', ${query})`))
.orderBy(sql`ts_rank(${documents.searchVector}, to_tsquery('simple', ${query})) desc`)
.limit(limit);
return rows.map((r) => ({ doc: r.doc, rank: r.rank, snippet: r.snippet }));
}
- Verify: after seeding,
keywordSearch("websocket")returns hits.
Task 1.6: packages/core (services)
- Create:
packages/core/package.json(@mcpedia/core, deps@mcpedia/db,@mcpedia/parser,@mcpedia/search,@mcpedia/config,@mcpedia/types),packages/core/src/:content.service.ts— read markdown fromCONTENT_ROOTby slug/section; list files viafast-glob.document.service.ts—listDocuments(section?, type?, status?),getDocument(slug)(DB meta + disk body),getRelated(slug)(shared tags, later vector).search.service.ts— wrapskeywordSearch.index.ts— re-export services + types.
// document.service.ts (sketch)
import { db } from "@mcpedia/db";
import { documents } from "@mcpedia/db/schema";
import { eq, and } from "drizzle-orm";
import { CONTENT_ROOT } from "@mcpedia/config";
import { readFileSync, existsSync } from "node:fs";
import { resolve } from "node:path";
import type { Document, DocumentMeta } from "@mcpedia/types";
export async function listDocuments(opts: { section?: string; status?: string } = {}): Promise<DocumentMeta[]> {
const where = [eq(documents.status, opts.status ?? "published")];
if (opts.section) where.push(eq(documents.section, opts.section));
return db.select().from(documents).where(and(...where));
}
export async function getDocument(slug: string): Promise<Document | null> {
const [meta] = await db.select().from(documents).where(eq(documents.slug, slug));
if (!meta) return null;
const abs = resolve(CONTENT_ROOT, meta.path);
const body = existsSync(abs) ? readFileSync(abs, "utf8") : meta.body;
return { ...meta, body };
}
- Verify:
bunx tsc --noEmitpasses across packages.
Task 1.7: scripts/indexer.ts (walk content → upsert)
- Create:
scripts/indexer.ts— walkCONTENT_ROOT(**/*.{md,mdx}),parseFile, upsert intodocuments(slug PK;onConflictDoUpdatetitle/tags/body/updatedAt). Logs count.
import { db } from "@mcpedia/db";
import { documents } from "@mcpedia/db/schema";
import { parseFile } from "@mcpedia/parser";
import { CONTENT_ROOT } from "@mcpedia/config";
import { walk } from "fast-glob";
import { resolve } from "node:path";
const files = await glob("**/*.{md,mdx}", { cwd: CONTENT_ROOT, absolute: true });
let n = 0;
for (const abs of files) {
const rel = resolve(CONTENT_ROOT, abs).replace(CONTENT_ROOT + "/", "");
const { meta, body } = parseFile(abs, rel);
await db.insert(documents).values({ ...meta, body })
.onConflictDoUpdate({ target: documents.slug, set: { title: meta.title, tags: meta.tags, body, updatedAt: new Date().toISOString() } });
n++;
}
console.log(`indexed ${n} documents`);
- Verify:
bun run indexprintsindexed N documents;psql/drizzle-kit studioshows rows + non-emptysearch_vector.
Task 1.8: Seed content
- Create:
content/docs/websocket/contract.md,content/writeups/debugging/websocket-timeout.md,content/research/mcp/architecture.md,content/notes/typescript/patterns.mdwith the spec's frontmatter shape (id/title/type/tags/status/author/created_at/updated_at). Add 2–3 short paragraphs each so FTS has text. - Verify:
bun run indexingests them;keywordSearch("websocket")returns ≥2 hits.
Task 1.9: apps/web — pages (Next 16, read node_modules/next/dist/docs first)
- Read Node_modules Next 16 docs (app router, server components, route handlers) — mandatory.
- Create:
apps/web/app/page.tsx— home: list sections + recent docs (server component callinglistDocuments).apps/web/app/(docs)/[...slug]/page.tsx— document view:getDocument(slug), render markdown (Phase 1: simpledangerouslySetInnerHTMLof a remark→HTML, or raw pre with Shiki later; keep minimal but legible).apps/web/app/search/page.tsx— search box →keywordSearch, render hits + snippets.- Keep Tailwind v4 styling minimal but clean (no shadcn yet — Phase 2 polish).
- Verify:
bun run build(apps/web) passes;bun run devthencurl -s localhost:3000/and/search?q=websocketreturn rendered HTML.
Task 1.10: apps/mcp — MCP server
- Create:
apps/mcp/package.json(@mcpedia/mcp, deps@modelcontextprotocol/sdk,zod,@mcpedia/core),apps/mcp/src/index.ts:
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { listDocuments, getDocument } from "@mcpedia/core";
import { keywordSearch } from "@mcpedia/search";
const server = new McpServer({ name: "mcpedia", version: "0.1.0" });
server.tool("search_documents", { query: z.string(), limit: z.number().optional() },
async ({ query, limit }) => {
const hits = await keywordSearch(query, limit ?? 20);
return { content: [{ type: "text", text: JSON.stringify(hits, null, 2) }] };
});
server.tool("get_document", { slug: z.string() },
async ({ slug }) => {
const doc = await getDocument(slug);
return doc ? { content: [{ type: "text", text: doc.body }] }
: { content: [{ type: "text", text: "not found" }], isError: true };
});
server.tool("list_documents", { section: z.string().optional() },
async ({ section }) => {
const docs = await listDocuments({ section });
return { content: [{ type: "text", text: JSON.stringify(docs, null, 2) }] };
});
// Resource: mcpedia://<section>/<path>
server.resource("doc", "mcpedia://", async (uri) => {
const slug = uri.path.replace(/^\//, "");
const doc = await getDocument(slug);
return { contents: [{ uri: uri.href, text: doc?.body ?? "" }] };
});
const transport = new StdioServerTransport();
await server.connect(transport);
(Exact SDK method names verified via Context7 before coding.)
- Create:
apps/mcp/binorbun runentry; add"start": "bun run src/index.ts"to its package.json. - Verify: smoke test via
scripts/mcp-smoke.tsthat imports the tool handlers and callssearch_documents({query:"websocket"})→ asserts ≥1 hit. (Running a real stdio client in CI is optional; inspector@modelcontextprotocol/inspectorfor manual check.)
Task 1.11: Cross-package verification
- Run:
bun install(root) →bunx turbo run lint→bunx turbo run build→bunx turbo run typecheck. All green. - Run:
bun run indexthenbun run scripts/mcp-smoke.ts. - Verify: no package imports
pg/drizzleoutside@mcpedia/coreor@mcpedia/db(architecture guard). Search the tree:search_files("from \"pg\"", path="apps").
Task 1.12: README + phase markers
- Modify:
README.md— replace create-next-app text with MCPedia overview, monorepo map, "Phase 1 status", how to dev (bun install && bun run index && bun run dev), how to run MCP (bun run --cwd apps/mcp start). - Verify:
cat README.mdreads correctly.
Phase 2 — Semantic search + tRPC/Hono API + auth (outline, expand later)
- Add
document_chunkstable +embedding vector(pgvector extension on imrnes — operator enablesCREATE EXTENSION vector). packages/search:EmbeddingProviderabstraction (OpenAI/Gemini/Ollama/Local) behind an interface (spec principle 6). Chunking inpackages/parser(by heading/section).- Hybrid ranking: fuse FTS
ts_rank+ cosine distance (Reciprocal Rank Fusion). apps/api: Hono + tRPC router exposingdocuments.*,search.*; Web switches from direct Core calls to tRPC client; MCP may also call API or keep Core (decide: keep Core for colocated simplicity, API for external).packages/auth: Auth.js/OIDC adapter;permissionstable; gate write/mutations. Phase 1 documents are public-read.- Categories + tags linking tables (
categories,document_tags,references). - MDX rendering with Shiki + shadcn/ui polish on Web.
Phase 3 — Async indexing + revisions (outline)
- Redis (imrnes or local) + BullMQ workers: parse/chunk/embed/reindex off the request path.
document_revisionstable + revision API; Git sync hook (content/ push → reindex).- Indexer becomes a worker triggered by file watch / webhook, not a manual script.
Phase 4 — Scale (outline, only if needed)
- Dedicated search engine (OpenSearch) behind the same
SearchServiceseam — YAGNI until FTS+pgvector saturates. - Object storage for assets, advanced ranking, distributed workers, observability, multi-tenant. Docker Compose for service orchestration pointing at imrnes Postgres.
Risks / tradeoffs / open questions
- Next 16 breaking changes — biggest unknown; mitigated by reading
node_modules/next/dist/docsbefore Web tasks. If app-router differs enough, adjust Task 1.9 accordingly. - PgBouncer + prepared statements — handled via
prepare: false; if imrnes uses session pool mode, can drop it. - FTS language — using
'simple'config (no stemming) so mixed ID/EN queries match literally; revisit if relevance is poor (could add'english'or a custom ID dict). - pgvector on imrnes — extension may need operator enablement; Phase 2 blocker, not Phase 1.
- MCP resource URI shape —
mcpedia://<section>/<path>is a sketch; finalize against MCP SDK spec in Task 1.10 (Context7). - Monorepo driver — bun workspaces chosen over pnpm per user; Turborepo runs fine under bun. If
turbobin resolution misbehaves under bun, fall back tobunx turbo.
Verification matrix (Phase 1)
| Check | Command | Expect |
|---|---|---|
| Install | bun install |
clean |
| Lint | bunx turbo run lint |
0 errors |
| Typecheck | bunx turbo run typecheck |
0 errors |
| Build | bunx turbo run build |
web + mcp build |
| Index | bun run index |
indexed N documents |
| Web | bun run dev (apps/web) + curl / and /search?q=websocket |
rendered HTML, hits |
| MCP | bun run scripts/mcp-smoke.ts |
≥1 hit for "websocket" |
| Arch guard | search for from "pg" outside packages/db,packages/core |
none |