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:
commit
ec3a2867d4
60 files changed
+3723
No files matched your search
+45
@@ -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 |
|
||||||
@@ -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 -->
|
||||||
@@ -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).
|
||||||
@@ -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).
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"paths": {
|
||||||
|
"@mcpedia/*": ["../../packages/*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 |
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "@mcpedia/config",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"exports": {
|
||||||
|
".": "./src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mcpedia/types": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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).",
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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";
|
||||||
@@ -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";
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { keywordSearch, toTsQuery } from "@mcpedia/search";
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
@@ -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");
|
||||||
@@ -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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"version": "7",
|
||||||
|
"dialect": "postgresql",
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"idx": 0,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1787133375079,
|
||||||
|
"tag": "0000_grey_toro",
|
||||||
|
"breakpoints": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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
Reference in new issue
Block a user