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 committed 2026-08-19 17:40:14 +07:00
commit ec3a2867d4
60 files changed
+3723

No files matched your search

+45
View File
@@ -0,0 +1,45 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
**/node_modules/
# testing
/coverage
# next.js
**/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
# monorepo / turbo
.turbo/
dist/
@@ -0,0 +1,435 @@
# 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 seamsLine truncated
**Tech Stack (resolved forks):**
- Package manager / monorepo: **bun workspaces + Turborepo** (honor existing repo; NOT pnpm — user decision).
- DB: **imrnes Postgres `100.121.180.82:6432`** for both dev and deploy (PgBouncer transaction pool → set `prepare: false` on 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.lock` present).
- `app/` has only `layout.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 any `apps/web` code, read `node_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_URL` lives in **BWS project `orangevps`** (never committed). Local dev reads it from a gitignored `.env` at repo root.
- Seed DB creds: a dedicated `mcpedia` database/user on imrnes; create via `bws` secret or operator. `pgvector` extension 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 -1` shows 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.json` becomes:
```json
{
"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`:
```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 install` succeeds; `bunx turbo --version` prints 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 root `app` → `apps/web/app`). Keep Tailwind v4. Add `apps/web/package.json`:
```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.json` extending `../../tsconfig.base.json` with `@/*` → `./**/*` and `@mcpedia/*` workspace aliases.
- **Verify:** `cd apps/web && bun install && bun run build` compiles (may warn about empty content — fine).
### Task 0.4: `.env` (gitignored) for dev
- **Create:** `.env` (gitignored) with `DATABASE_URL=postgresql://<user>:<pass>@100.121.180.82:6432/mcpedia` (value from BWS; `prepare:false` enforced 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`, exports `src/index.ts`), `packages/types/src/index.ts`:
```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 --noEmit` in package passes.
### Task 1.2: `packages/config`
- **Create:** `packages/config/package.json` (`@mcpedia/config`), `packages/config/src/index.ts`:
```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 -e` prints a path.
### Task 1.3: `packages/db` (Drizzle + Postgres, FTS)
- **Create:** `packages/db/package.json` (`@mcpedia/db`, deps `drizzle-orm`, `postgres`; dev `drizzle-kit`), `packages/db/src/schema.ts`:
```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 with `prepare: false` (PgBouncer safe):
```ts
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` (dialect `postgresql`, schema `./src/schema.ts`, out `./drizzle`).
- **Verify:** `bunx drizzle-kit generate` produces a migration; `bunx drizzle-kit push` applies it to imrnes (operator supplies DATABASE_URL). `bunx drizzle-kit studio` optional.
### Task 1.4: `packages/parser` (frontmatter)
- **Create:** `packages/parser/package.json` (`@mcpedia/parser`, dep `gray-matter`), `packages/parser/src/index.ts`:
```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`:
```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 from `CONTENT_ROOT` by slug/section; list files via `fast-glob`.
- `document.service.ts` — `listDocuments(section?, type?, status?)`, `getDocument(slug)` (DB meta + disk body), `getRelated(slug)` (shared tags, later vector).
- `search.service.ts` — wraps `keywordSearch`.
- `index.ts` — re-export services + types.
```ts
// 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 --noEmit` passes across packages.
### Task 1.7: `scripts/indexer.ts` (walk content → upsert)
- **Create:** `scripts/indexer.ts` — walk `CONTENT_ROOT` (`**/*.{md,mdx}`), `parseFile`, upsert into `documents` (slug PK; `onConflictDoUpdate` title/tags/body/updatedAt). Logs count.
```ts
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 index` prints `indexed N documents`; `psql`/`drizzle-kit studio` shows rows + non-empty `search_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.md` with 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 index` ingests 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 calling `listDocuments`).
- `apps/web/app/(docs)/[...slug]/page.tsx` — document view: `getDocument(slug)`, render markdown (Phase 1: simple `dangerouslySetInnerHTML` of 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 dev` then `curl -s localhost:3000/` and `/search?q=websocket` return 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`:
```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/bin` or `bun run` entry; add `"start": "bun run src/index.ts"` to its package.json.
- **Verify:** smoke test via `scripts/mcp-smoke.ts` that imports the tool handlers and calls `search_documents({query:"websocket"})` → asserts ≥1 hit. (Running a real stdio client in CI is optional; inspector `@modelcontextprotocol/inspector` for 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 index` then `bun run scripts/mcp-smoke.ts`.
- **Verify:** no package imports `pg`/`drizzle` outside `@mcpedia/core` or `@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.md` reads correctly.
---
## Phase 2 — Semantic search + tRPC/Hono API + auth (outline, expand later)
- Add `document_chunks` table + `embedding vector` (pgvector extension on imrnes — operator enables `CREATE EXTENSION vector`).
- `packages/search`: `EmbeddingProvider` abstraction (OpenAI/Gemini/Ollama/Local) behind an interface (spec principle 6). Chunking in `packages/parser` (by heading/section).
- Hybrid ranking: fuse FTS `ts_rank` + cosine distance (Reciprocal Rank Fusion).
- `apps/api`: Hono + tRPC router exposing `documents.*`, `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; `permissions` table; 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_revisions` table + 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 `SearchService` seam — 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/docs` before 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 `turbo` bin resolution misbehaves under bun, fall back to `bunx 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 |
+9
View File
@@ -0,0 +1,9 @@
<!-- BEGIN:nextjs-agent-rules -->
# This is NOT the Next.js you know
This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
<!-- END:nextjs-agent-rules -->
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+47
View File
@@ -0,0 +1,47 @@
# MCPedia — Phase Status
Legend: ✅ built · 🟡 partial · ⬜ deferred
## Phase 1 — MVP (✅ DONE)
| Capability | Status | Notes |
| --------------------- | ------ | ----- |
| Monorepo (bun + Turbo)| ✅ | apps/{web,mcp}, packages/{types,config,db,parser,search,core}, scripts |
| Content as Markdown | ✅ | `content/{docs,writeups,research,notes}/`, Git-tracked |
| Frontmatter parsing | ✅ | `@mcpedia/parser` (gray-matter) |
| Postgres metadata | ✅ | `@mcpedia/db` Drizzle, `documents` table |
| Postgres FTS | ✅ | weighted `tsvector` (title A / body B), GIN index, `ts_rank`+`ts_headline` |
| Core services | ✅ | Document / Content / Search — single business-logic layer |
| Indexer | ✅ | `scripts/indexer.ts` walks content/ → upserts |
| Web UI (Next 16) | ✅ | home (list), doc view (SSG), search (dynamic). react-markdown render |
| MCP server (stdio) | ✅ | 4 tools; in-memory smoke test passing |
| Hybrid/semantic search| ⬜ | Phase 2 |
## Phase 2 — Semantic + API
- [ ] `pgvector` + embedding column on `document_chunks`
- [ ] Chunking + embedding provider abstraction (`EmbeddingProvider`: OpenAI/Gemini/Ollama/local)
- [ ] Hybrid search (FTS score + cosine, reciprocal-rank fusion)
- [ ] tRPC + Hono API (`apps/api`) sharing `@mcpedia/core`
- [ ] Tags / Categories / References as first-class tables
- [ ] Auth (Auth.js / OIDC) — public/private/unlisted/admin/owner
- [ ] shadcn/ui components + Shiki syntax highlighting (replace minimal markdown render)
## Phase 3 — Async + Scale
- [ ] Redis + BullMQ background indexing / embedding workers
- [ ] Git synchronization hook (auto-reindex on push)
- [ ] Document revision system (`document_revisions`)
- [ ] MCP Resources (`mcpedia://docs/...`) in addition to tools
## Phase 4 — Scale-out (only if needed)
- [ ] Dedicated search engine (OpenSearch/Elasticsearch) — YAGNI until FTS is insufficient
- [ ] Object storage for assets
- [ ] Advanced ranking, distributed workers, observability, multi-tenant
## Decisions locked (from initial planning)
- **Tooling:** bun workspaces + Turborepo (repo already used bun; pnpm rejected to minimize churn).
- **DB:** imrnes Postgres `100.121.180.82:6432/mcpedia` for both dev and deploy; driver `prepare:false` (PgBouncer). Docker Compose reserved for future prod.
- **Phase 1 scope:** Core + Web + MCP only. tRPC/Hono API, pgvector, auth, BullMQ deferred (YAGNI).
+108
View File
@@ -0,0 +1,108 @@
# MCPedia
> A content-first knowledge base — readable as Markdown/MDX in Git, queryable by
> humans via a Web UI and by AI agents via the Model Context Protocol (MCP).
MCPedia keeps content as plain Markdown files under `content/`. A Git-tracked
source of truth, indexed into PostgreSQL (metadata + a `tsvector` full-text
column) and served through a single **Core** layer that every interface
(Web, MCP) shares — no business logic duplicated per surface.
## Monorepo layout
```
mcpedia/
├── apps/
│ ├── web/ # Next.js 16 (Turbopack) — human-facing docs UI + search
│ └── mcp/ # MCP server (stdio) — AI-agent interface
├── packages/
│ ├── types/ # shared domain types (DocSection, Document, SearchHit, ...)
│ ├── config/ # loads .env (repo root) as authoritative dev config
│ ├── db/ # Drizzle ORM schema + client + drizzle-kit config
│ ├── parser/ # frontmatter (gray-matter) parsing
│ ├── search/ # Postgres FTS query (ts_rank + ts_headline)
│ └── core/ # Document/Content/Search services — the only business logic
├── content/ # docs/ writeups/ research/ notes/ (the knowledge base)
└── scripts/ # indexer.ts (walks content/ -> upserts into Postgres)
```
## Architecture principle
```
Web ─┐
├──► Core ──► Repository (@mcpedia/db) ──► PostgreSQL
MCP ─┘
```
All interfaces go through `@mcpedia/core`. Nothing outside `packages/db` and
`packages/core` touches the database directly.
## Quick start
```bash
bun install # install workspace deps
cp .env.example .env # set DATABASE_URL (dev uses imrnes Postgres :6432)
bunx turbo run build # typecheck + build every package
bun run index # walk content/ -> upsert into Postgres
bun --cwd apps/web run dev # Web UI on :3000
bun run mcp # MCP server on stdio (pipe to an MCP client)
```
### Database
Phase 1 uses Postgres FTS only. Schema is defined in `packages/db/src/schema.ts`
(a `documents` table with a `search_vector` generated `tsvector` column + GIN
index). Apply it with:
```bash
bunx --cwd packages/db drizzle-kit push
```
> Note: on imrnes (PgBouncer `:6432`) a leaked `DATABASE_URL` shell var can
> shadow `.env`. `@mcpedia/config` loads `.env` **last** so the repo config
> always wins for local/dev.
## Content
Each Markdown file carries YAML frontmatter:
```yaml
---
id: websocket-contract
title: WebSocket Contract
type: documentation
tags: [typescript, websocket, rpc]
status: published
author: asep
created_at: 2026-08-19
updated_at: 2026-08-19
---
```
`slug` = relative path under `content/` (e.g. `docs/websocket/contract`). The
`body` shown in the UI is always read from the on-disk file (source of truth);
the DB stores metadata + the search vector.
## MCP tools (Phase 1)
| Tool | Purpose |
| --------------------- | ------------------------------------------------ |
| `search_documents` | Postgres FTS over the corpus (ranked + snippet) |
| `get_document` | Full markdown body by slug |
| `list_documents` | List, optionally filtered by section |
| `get_related_documents` | Docs sharing tags with a given slug |
Smoke test (in-memory transport, real JSON-RPC):
```bash
bun --cwd apps/mcp run smoke
```
## Status
**Phase 1 — MVP (DONE):** monorepo, Core, Web UI (home/doc/search), MCP server,
Postgres FTS keyword search, content indexing.
See `PHASES.md` for Phase 2–4 (pgvector semantic/hybrid search, tRPC/Hono API,
auth, Redis/BullMQ background workers, revisions, scale-out).
+26
View File
@@ -0,0 +1,26 @@
{
"name": "@mcpedia/mcp",
"version": "0.1.0",
"private": true,
"type": "module",
"bin": {
"mcpedia-mcp": "./src/index.ts"
},
"scripts": {
"start": "bun run src/index.ts",
"lint": "tsc --noEmit",
"typecheck": "tsc --noEmit",
"smoke": "bun run src/smoke.test.ts"
},
"dependencies": {
"@mcpedia/config": "workspace:*",
"@mcpedia/core": "workspace:*",
"@mcpedia/search": "workspace:*",
"@modelcontextprotocol/sdk": "^1.29.0",
"zod": "^3.23.8"
},
"devDependencies": {
"@types/node": "^20",
"typescript": "^5.6.0"
}
}
+96
View File
@@ -0,0 +1,96 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { z } from "zod";
import { listDocuments, getDocument, getRelated } from "@mcpedia/core";
import { keywordSearch } from "@mcpedia/search";
export function createMcpServer(): McpServer {
const server = new McpServer({
name: "mcpedia",
version: "0.1.0",
});
server.registerTool(
"search_documents",
{
description:
"Full-text search across the MCPedia knowledge base (Postgres FTS). Returns ranked documents with a headline snippet.",
inputSchema: z.object({
query: z.string().describe("Free-text search query"),
limit: z.number().int().positive().max(50).optional(),
}),
},
async ({ query, limit }) => {
const hits = await keywordSearch(query, limit ?? 20);
return {
content: [{ type: "text", text: JSON.stringify(hits, null, 2) }],
};
},
);
server.registerTool(
"get_document",
{
description:
"Fetch the full markdown body of a document by its slug (e.g. 'docs/websocket/contract').",
inputSchema: z.object({
slug: z.string().describe("Document slug, e.g. 'docs/websocket/contract'"),
}),
},
async ({ slug }) => {
const doc = await getDocument(slug);
if (!doc) {
return {
content: [{ type: "text", text: `Document not found: ${slug}` }],
isError: true,
};
}
return { content: [{ type: "text", text: doc.body }] };
},
);
server.registerTool(
"list_documents",
{
description: "List documents, optionally filtered by section.",
inputSchema: z.object({
section: z
.enum(["docs", "writeups", "research", "notes"])
.optional(),
}),
},
async ({ section }) => {
const docs = await listDocuments({ section });
return {
content: [{ type: "text", text: JSON.stringify(docs, null, 2) }],
};
},
);
server.registerTool(
"get_related_documents",
{
description: "Return documents that share tags with the given slug.",
inputSchema: z.object({
slug: z.string(),
limit: z.number().int().positive().max(20).optional(),
}),
},
async ({ slug, limit }) => {
const related = await getRelated(slug, limit ?? 5);
return {
content: [{ type: "text", text: JSON.stringify(related, null, 2) }],
};
},
);
return server;
}
// When run directly (bun run src/index.ts), serve over stdio.
const isMain =
process.argv[1] && import.meta.url === `file://${process.argv[1]}`;
if (isMain) {
const transport = new StdioServerTransport();
await createMcpServer().connect(transport);
}
+79
View File
@@ -0,0 +1,79 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js";
import { createMcpServer } from "../src/index";
async function main() {
const server = createMcpServer();
const [clientT, serverT] = InMemoryTransport.createLinkedPair();
await server.connect(serverT);
const client = new Client({ name: "smoke", version: "0.0.1" });
await client.connect(clientT);
// 1) tool discovery
const tools = await client.listTools();
const names = tools.tools.map((t) => t.name).sort();
console.log("tools:", names.join(", "));
const expected = [
"get_document",
"get_related_documents",
"list_documents",
"search_documents",
].sort();
if (JSON.stringify(names) !== JSON.stringify(expected)) {
throw new Error(`tool set mismatch: ${names.join(",")}`);
}
// 2) search_documents
const search = await client.callTool({
name: "search_documents",
arguments: { query: "websocket", limit: 10 },
});
const hits = JSON.parse((search.content as any)[0].text);
if (!Array.isArray(hits) || hits.length < 1) {
throw new Error("search_documents returned no hits");
}
console.log(`search_documents("websocket") => ${hits.length} hits`);
console.log(" top:", hits[0].doc.slug, hits[0].doc.title);
// 3) get_document
const get = await client.callTool({
name: "get_document",
arguments: { slug: "docs/websocket/contract" },
});
const body = (get.content as any)[0].text;
if (!body.includes("WebSocket Contract")) {
throw new Error("get_document returned unexpected body");
}
console.log("get_document('docs/websocket/contract') => body ok (len", body.length, ")");
// 4) get_document not found
const missing = await client.callTool({
name: "get_document",
arguments: { slug: "nope/missing" },
});
if (!(missing as any).isError) {
throw new Error("get_document should report isError for missing doc");
}
console.log("get_document('nope/missing') => isError ok");
// 5) list_documents
const list = await client.callTool({
name: "list_documents",
arguments: { section: "docs" },
});
const docs = JSON.parse((list.content as any)[0].text);
if (docs.length !== 1) throw new Error("list_documents docs != 1");
console.log("list_documents(section=docs) =>", docs.length, "doc");
await client.close();
await server.close();
console.log("\nSMOKE OK");
}
main()
.then(() => process.exit(0))
.catch((e) => {
console.error("SMOKE FAIL:", e);
process.exit(1);
});
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"paths": {
"@mcpedia/*": ["../../packages/*"]
}
},
"include": ["src/**/*.ts"]
}
+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>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+29
View File
@@ -0,0 +1,29 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({ children }: LayoutProps<"/">) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// @mcpedia/db pulls in the `postgres` driver + node:fs; keep it external to
// the server bundle so it runs in the Node runtime as-is.
serverExternalPackages: ["@mcpedia/db", "@mcpedia/config", "@mcpedia/parser"],
};
export default nextConfig;
+30
View File
@@ -0,0 +1,30 @@
{
"name": "@mcpedia/web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@mcpedia/config": "workspace:*",
"@mcpedia/core": "workspace:*",
"next": "16.3.1",
"react": "19.2.8",
"react-dom": "19.2.8",
"react-markdown": "^9.0.0"
},
"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.6.0"
}
}
+69
View File
@@ -0,0 +1,69 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert h-5 w-[100px]"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the{" "}
<code className="rounded bg-black/[.06] px-1.5 py-0.5 font-mono text-[0.9em] dark:bg-white/[.08]">
page.tsx
</code>{" "}
file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert h-[14px] w-4"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={14}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0Line truncated

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-Line truncated

After

Width:  |  Height:  |  Size: 1.3 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

+18
View File
@@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./app/*"],
"@mcpedia/*": ["../../packages/*"]
},
"allowJs": true
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts"
],
"exclude": ["node_modules"]
}
+1487
View File
File diff suppressed because it is too large. Load diff
+43
View File
@@ -0,0 +1,43 @@
---
id: websocket-contract
title: WebSocket Contract
type: documentation
tags:
- typescript
- websocket
- rpc
status: published
author: asep
created_at: 2026-08-19
updated_at: 2026-08-19
---
# WebSocket Contract
The WebSocket contract defines how clients establish a bidirectional connection
and exchange RPC-style messages with the server. It is the foundation for the
type-safe API described in the tRPC integration notes.
## Handshake
A client opens a single WebSocket connection and sends an `init` frame携带 an
auth token. The server answers with `ready` or closes the socket with code 4401
if the token is invalid.
## Message envelope
Every frame uses a JSON envelope:
```json
{ "id": "req-1", "method": "echo", "params": { "text": "hi" } }
```
The server replies with a matching `id` and either a `result` or an `error`
field. This request/response correlation is what makes the protocol feel like
RPC even though it rides on a single socket.
## Timeouts
If the server does not answer within the negotiated timeout, the client should
re-send with the same `id` rather than opening a new connection. See the
debugging writeup for a common timeout pitfall when proxies buffer frames.
+36
View File
@@ -0,0 +1,36 @@
---
id: typescript-patterns
title: TypeScript Patterns
type: note
tags:
- typescript
- patterns
status: published
author: asep
created_at: 2026-08-19
updated_at: 2026-08-19
---
# TypeScript Patterns
A notebook of small, reusable TypeScript patterns that keep code honest.
## Branded types for ids
```ts
type DocId = string & { readonly __brand: "DocId" };
const asDocId = (s: string) => s as DocId;
```
Branding prevents passing an arbitrary string where a document id is expected.
## Discriminated unions over inheritance
Prefer a closed set of variants with a `kind` field. Exhaustiveness checks with
`switch` catch missing cases at compile time instead of at runtime.
## Prefer composition
When two modules share behavior, extract a function. Avoid inheritance trees
that couple unrelated code. This matches the MCPedia Core principle: one layer
owns the logic, interfaces only call into it.
+34
View File
@@ -0,0 +1,34 @@
---
id: mcp-architecture
title: MCP Architecture Notes
type: research
tags:
- mcp
- architecture
- ai
status: published
author: asep
created_at: 2026-08-19
updated_at: 2026-08-19
---
# MCP Architecture Notes
The Model Context Protocol (MCP) lets an AI client treat a knowledge base as a
first-class context source instead of yet another REST API. The server exposes
tools and resources; the client decides what to read.
## Tools vs resources
- Tools are actions the model calls (`search_documents`, `get_document`).
- Resources are addressable content the model can pull (`mcpedia://docs/...`).
## Why it matters here
MCPedia exposes both. The Web UI is for humans; the MCP server is for agents.
Both go through the same Core layer, so there is exactly one copy of the
business logic and one search implementation.
## Reference
Related reading: the WebSocket contract and the tRPC type-safe API notes.
@@ -0,0 +1,36 @@
---
id: websocket-timeout
title: Debugging a WebSocket Timeout Behind a Proxy
type: writeup
tags:
- websocket
- debugging
- proxy
status: published
author: asep
created_at: 2026-08-19
updated_at: 2026-08-19
---
# Debugging a WebSocket Timeout Behind a Proxy
A recurring incident: the browser WebSocket connects, sends one frame, then
silently times out. The server logs show no error. Root cause: the reverse
proxy was buffering frames and only flushing on connection close.
## Symptoms
- Connection opens (101 Switching Protocols).
- First message never reaches the upstream.
- Client hits its own 30s timeout and reconnects, creating a storm.
## Fix
Disable proxy buffering for the WebSocket upgrade route and ensure the proxy
does not apply an idle timeout shorter than the application's heartbeat
interval. After that, frames flowed immediately and the timeout disappeared.
## Lesson
Always confirm at the proxy layer whether frames are buffered before assuming
the application server is at fault.
+24
View File
@@ -0,0 +1,24 @@
{
"name": "mcpedia",
"version": "0.1.0",
"private": true,
"packageManager": "bun@1.3.14",
"workspaces": [
"apps/*",
"packages/*",
"scripts"
],
"scripts": {
"dev": "turbo run dev",
"build": "turbo run build",
"lint": "turbo run lint",
"typecheck": "turbo run typecheck",
"index": "bun run scripts/indexer.ts",
"mcp": "bun --cwd apps/mcp run start"
},
"devDependencies": {
"turbo": "^2.5.0",
"typescript": "^5.6.0",
"prettier": "^3.3.0"
}
}
+12
View File
@@ -0,0 +1,12 @@
{
"name": "@mcpedia/config",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts"
},
"dependencies": {
"@mcpedia/types": "workspace:*"
}
}
+44
View File
@@ -0,0 +1,44 @@
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { existsSync, readFileSync } from "node:fs";
const here = dirname(fileURLToPath(import.meta.url));
// packages/config -> repo root (../../..)
export const REPO_ROOT = resolve(here, "../../..");
/**
* Load .env (repo root) as the authoritative dev config and apply it to
* process.env. We intentionally OVERRIDE any inherited DATABASE_URL so a stray
* shell env var can never point the app at the wrong database. .env is
* gitignored; for deploy, set the real vars in the environment and omit .env.
*/
function loadDotEnv() {
const dotEnv = resolve(REPO_ROOT, ".env");
if (!existsSync(dotEnv)) return;
for (const line of readFileSync(dotEnv, "utf8").split("\n")) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (!m) continue;
const key = m[1];
let val = m[2];
if (
(val.startsWith('"') && val.endsWith('"')) ||
(val.startsWith("'") && val.endsWith("'"))
) {
val = val.slice(1, -1);
}
process.env[key] = val;
}
}
loadDotEnv();
export const CONTENT_ROOT =
process.env.CONTENT_ROOT ?? resolve(REPO_ROOT, "content");
export const DATABASE_URL = process.env.DATABASE_URL ?? "";
if (!DATABASE_URL) {
// Fail fast with an explicit message instead of a cryptic driver error.
throw new Error(
"DATABASE_URL is not set. Copy .env.example to .env and set it (dev uses imrnes Postgres :6432).",
);
}
+18
View File
@@ -0,0 +1,18 @@
{
"name": "@mcpedia/core",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/index.ts",
"./row-map": "./src/row-map.ts"
},
"dependencies": {
"@mcpedia/config": "workspace:*",
"@mcpedia/db": "workspace:*",
"@mcpedia/parser": "workspace:*",
"@mcpedia/search": "workspace:*",
"@mcpedia/types": "workspace:*",
"drizzle-orm": "^0.38.0"
}
}
+23
View File
@@ -0,0 +1,23 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { CONTENT_ROOT } from "@mcpedia/config";
/** List all markdown/mdx content files relative to CONTENT_ROOT. */
export function listContentFiles(): string[] {
const out: string[] = [];
const walk = (dir: string) => {
for (const entry of readdirSync(dir)) {
const full = join(dir, entry);
const st = statSync(full);
if (st.isDirectory()) walk(full);
else if (/\.mdx?$/.test(entry)) out.push(relative(CONTENT_ROOT, full));
}
};
walk(CONTENT_ROOT);
return out;
}
/** Read a content file's raw text by relative path. */
export function readContentFile(relPath: string): string {
return readFileSync(join(CONTENT_ROOT, relPath), "utf8");
}
+58
View File
@@ -0,0 +1,58 @@
import { and, eq, sql } from "drizzle-orm";
import { db } from "@mcpedia/db";
import { documents } from "@mcpedia/db/schema";
import { CONTENT_ROOT } from "@mcpedia/config";
import { existsSync, readFileSync } from "node:fs";
import { join } from "node:path";
import type {
Document,
DocumentMeta,
} from "@mcpedia/types";
import { readContentFile } from "./content.service";
import { toMeta } from "./row-map";
export async function listDocuments(opts: {
section?: string;
status?: string;
} = {}): Promise<DocumentMeta[]> {
const status = opts.status ?? "published";
const where = [eq(documents.status, status)];
if (opts.section) where.push(eq(documents.section, opts.section));
const rows = await db
.select()
.from(documents)
.where(and(...where))
.orderBy(documents.updatedAt);
return rows.map(toMeta);
}
export async function getDocument(slug: string): Promise<Document | null> {
const [row] = await db.select().from(documents).where(eq(documents.slug, slug));
if (!row) return null;
// Prefer the on-disk file (source of truth); fall back to stored body.
const abs = join(CONTENT_ROOT, row.path);
const body = existsSync(abs) ? readFileSync(abs, "utf8") : row.body;
return { ...toMeta(row), body };
}
export async function getRelated(slug: string, limit = 5): Promise<DocumentMeta[]> {
const [row] = await db
.select({ tags: documents.tags })
.from(documents)
.where(eq(documents.slug, slug));
if (!row || row.tags.length === 0) return [];
// Build a text[] array literal for the && (overlap) operator, binding each
// tag as a parameter to avoid SQL injection from frontmatter content.
const arrLit = sql`ARRAY[${sql.join(
row.tags.map((t) => sql.param(t)),
sql`, `,
)}]::text[]`;
const related = await db
.select()
.from(documents)
.where(and(eq(documents.status, "published"), sql`${documents.tags} && ${arrLit}`))
.limit(limit + 1);
return related.filter((d) => d.slug !== slug).map(toMeta).slice(0, limit);
}
export { readContentFile };
+13
View File
@@ -0,0 +1,13 @@
export * from "./content.service";
export * from "./document.service";
export * from "./search.service";
export { toMeta } from "./row-map";
export type {
DocSection,
DocStatus,
DocType,
Document,
DocumentMeta,
SearchHit,
} from "@mcpedia/types";
+3
View File
@@ -0,0 +1,3 @@
// Single source of truth for row→domain mapping lives in @mcpedia/search.
export { toMeta } from "@mcpedia/search";
export type { DocumentRow } from "@mcpedia/db/schema";
+1
View File
@@ -0,0 +1 @@
export { keywordSearch, toTsQuery } from "@mcpedia/search";
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "drizzle-kit";
import { DATABASE_URL } from "@mcpedia/config";
export default defineConfig({
dialect: "postgresql",
schema: "./src/schema.ts",
out: "./drizzle",
dbCredentials: { url: DATABASE_URL },
verbose: true,
strict: true,
});
+19
View File
@@ -0,0 +1,19 @@
CREATE TABLE "documents" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"title" text NOT NULL,
"type" text NOT NULL,
"section" text NOT NULL,
"status" text DEFAULT 'published' NOT NULL,
"author" text DEFAULT '' NOT NULL,
"tags" text[] DEFAULT '{}' NOT NULL,
"path" text NOT NULL,
"body" text DEFAULT '' NOT NULL,
"search_vector" "tsvector" GENERATED ALWAYS AS (setweight(to_tsvector('simple', coalesce("documents"."title", '')), 'A') || setweight(to_tsvector('simple', coalesce("documents"."body", '')), 'B')) STORED NOT NULL,
"created_at" timestamp with time zone NOT NULL,
"updated_at" timestamp with time zone NOT NULL,
CONSTRAINT "documents_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE INDEX "documents_search_idx" ON "documents" USING gin ("search_vector");--> statement-breakpoint
CREATE INDEX "documents_section_idx" ON "documents" USING btree ("section");
+157
View File
@@ -0,0 +1,157 @@
{
"id": "249f80c0-d953-42f2-a98b-6701e2115856",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.documents": {
"name": "documents",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"slug": {
"name": "slug",
"type": "text",
"primaryKey": false,
"notNull": true
},
"title": {
"name": "title",
"type": "text",
"primaryKey": false,
"notNull": true
},
"type": {
"name": "type",
"type": "text",
"primaryKey": false,
"notNull": true
},
"section": {
"name": "section",
"type": "text",
"primaryKey": false,
"notNull": true
},
"status": {
"name": "status",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'published'"
},
"author": {
"name": "author",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"tags": {
"name": "tags",
"type": "text[]",
"primaryKey": false,
"notNull": true,
"default": "'{}'"
},
"path": {
"name": "path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"body": {
"name": "body",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "''"
},
"search_vector": {
"name": "search_vector",
"type": "tsvector",
"primaryKey": false,
"notNull": true,
"generated": {
"as": "setweight(to_tsvector('simple', coalesce(\"documents\".\"title\", '')), 'A') || setweight(to_tsvector('simple', coalesce(\"documents\".\"body\", '')), 'B')",
"type": "stored"
}
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true
}
},
"indexes": {
"documents_search_idx": {
"name": "documents_search_idx",
"columns": [
{
"expression": "search_vector",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "gin",
"with": {}
},
"documents_section_idx": {
"name": "documents_section_idx",
"columns": [
{
"expression": "section",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"documents_slug_unique": {
"name": "documents_slug_unique",
"nullsNotDistinct": false,
"columns": [
"slug"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1787133375079,
"tag": "0000_grey_toro",
"breakpoints": true
}
]
}
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@mcpedia/db",
"version": "0.1.0",
"private": true,
"type": "module",
"exports": {
".": "./src/client.ts",
"./schema": "./src/schema.ts"
},
"dependencies": {
"@mcpedia/config": "workspace:*",
"drizzle-orm": "^0.38.0",
"postgres": "^3.4.5"
},
"devDependencies": {
"drizzle-kit": "^0.30.0",
"@types/node": "^20"
}
}
+15
View File
@@ -0,0 +1,15 @@
import { drizzle } from "drizzle-orm/postgres-js";
import postgres from "postgres";
import { DATABASE_URL } from "@mcpedia/config";
import * as schema from "./schema";
// PgBouncer (imrnes :6432) uses transaction pooling, which rejects protocol
//-level prepared statements. prepare:false makes postgres-js use simple queries.
const client = postgres(DATABASE_URL, {
prepare: false,
max: 5,
onnotice: () => {},
});
export const db = drizzle(client, { schema });
export { schema, client };
+59
View File
@@ -0,0 +1,59 @@
import { sql, SQL } from "drizzle-orm";
import {
customType,
index,
integer,
pgTable,
text,
timestamp,
} from "drizzle-orm/pg-core";
// tsvector isn't a first-class drizzle type; wrap the raw Postgres type.
const tsvector = customType<{ data: string }>({
dataType() {
return "tsvector";
},
});
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(""),
// Weighted search vector: title (A) + body (B), using the 'simple' config so
// mixed ID/EN queries match literally without stemming. Lazy closure over
// `documents` (must NOT reference the table eagerly — it is in TDZ here).
searchVector: tsvector("search_vector")
.notNull()
.generatedAlwaysAs(
(): SQL =>
sql`setweight(to_tsvector('simple', coalesce(${documents.title}, '')), 'A') || setweight(to_tsvector('simple', coalesce(${documents.body}, '')), 'B')`,
),
createdAt: timestamp("created_at", { withTimezone: true }).notNull(),
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull(),
},
(t) => ({
searchIdx: index("documents_search_idx").using("gin", t.searchVector),
sectionIdx: index("documents_section_idx").on(t.section),
}),
);
// Phase 2 (semantic search) — defined here for reference, NOT created yet:
// export const documentChunks = pgTable("document_chunks", {
// id: text("id").primaryKey(),
// documentId: text("document_id").notNull().references(() => documents.id, { onDelete: "cascade" }),
// content: text("content").notNull(),
// position: integer("position").notNull(),
// embedding: customType<{ data: number[] }>({ dataType: () => "vector(1536)" })("embedding"),
// });
export type DocumentRow = typeof documents.$inferSelect;
export type NewDocumentRow = typeof documents.$inferInsert;
Loaded 50 of 60 files, more files were not shown because too many files have changed in this diff. Show more