feat: Phase 1 — Authentik auth, MCP endpoint, persistent memory
End-to-end Phase 1 of shared-memory: a logged-in Authentik user can sign into the Web UI (/me debug page), and an MCP client with an Authentik- issued bearer token can call memory.write / memory.list / memory.get / memory.delete plus project.identify against /api/mcp. Stack: - Next.js 15 (App Router) + React 19 + TypeScript, pnpm workspaces - Drizzle ORM + Postgres 16 + pgvector + pg_trgm - Auth.js v5 with Authentik provider (Web UI) - jose + Authentik JWKS for MCP bearer-token validation - JSON-RPC 2.0 dispatcher implementing the MCP wire protocol over plain HTTP POST (hand-rolled to fit Next.js App Router; switches to SSE in a later phase if server-initiated events are needed) - bge-small embeddings sidecar deferred to Phase 2; the schema already reserves the vector(384) column + IVFFlat index, FTS via a STORED tsvector column, and the visibility enum (private/shared/team) so cross-user memory sharing can be added without a future migration Deployment supports two modes (set in .env, never committed): - Behind an external reverse proxy (HAProxy / nginx / Cloudflare Tunnel / Traefik) — DEFAULT; the app exposes APP_PORT on the host with X-Forwarded-* trusted, no in-container TLS - Built-in TLS via Caddy — opt-in with `docker compose --profile tls up` Discovery endpoint at /.well-known/oauth-protected-resource (RFC 9728) points MCP clients at the Authentik authorization server after a 401. README walks through both Authentik providers (Web UI + MCP resource server), the audience scope mapping, redirect URIs, and includes a worked HAProxy config snippet. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
# -----------------------------------------------------------------------------
|
||||
# Multi-stage build for @shared-memory/web.
|
||||
#
|
||||
# deps — pnpm install with workspace context
|
||||
# builder — next build (standalone) + bundled migrator
|
||||
# runner — minimal Node runtime, non-root, runs server.js
|
||||
#
|
||||
# Build from the repo root:
|
||||
# docker build -t shared-memory-web -f apps/web/Dockerfile .
|
||||
# -----------------------------------------------------------------------------
|
||||
|
||||
FROM node:20-alpine AS base
|
||||
RUN corepack enable
|
||||
WORKDIR /app
|
||||
|
||||
# ---------- deps ----------
|
||||
FROM base AS deps
|
||||
COPY package.json pnpm-workspace.yaml pnpm-lock.yaml .npmrc ./
|
||||
COPY apps/web/package.json ./apps/web/
|
||||
COPY packages/schemas/package.json ./packages/schemas/
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
# ---------- builder ----------
|
||||
FROM base AS builder
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY --from=deps /app/apps/web/node_modules ./apps/web/node_modules
|
||||
COPY . .
|
||||
|
||||
# Build the Next.js standalone bundle. Env validation is bypassed here so
|
||||
# the image can be built without real OIDC/DB secrets baked in; runtime
|
||||
# validation in `env.ts` re-checks all vars on first request.
|
||||
ENV SKIP_ENV_VALIDATION=true \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
RUN pnpm --filter @shared-memory/web build
|
||||
|
||||
# Bundle the migrator into a single ESM file so the runtime image doesn't
|
||||
# need tsx or the rest of devDependencies.
|
||||
RUN pnpm --filter @shared-memory/web exec esbuild apps/web/scripts/migrate.ts \
|
||||
--bundle --platform=node --target=node20 --format=esm \
|
||||
--outfile=apps/web/migrate.mjs
|
||||
|
||||
# ---------- runner ----------
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
HOSTNAME=0.0.0.0 \
|
||||
NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
# `wget` is alpine's tiny default; used by the docker healthcheck.
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 --ingroup nodejs nextjs
|
||||
|
||||
# Standalone bundle includes traced node_modules + server.js.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/.next/static ./apps/web/.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/public ./apps/web/public
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/drizzle ./apps/web/drizzle
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/apps/web/migrate.mjs ./apps/web/migrate.mjs
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
# Default command runs the server. The compose `migrator` service overrides
|
||||
# this to run migrations once before the app comes up.
|
||||
CMD ["node", "apps/web/server.js"]
|
||||
@@ -0,0 +1,22 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* RFC 9728 — OAuth 2.0 Protected Resource Metadata.
|
||||
*
|
||||
* MCP clients discover the authorization server (Authentik) via this
|
||||
* endpoint after receiving a 401 with `WWW-Authenticate: resource_metadata=...`.
|
||||
*/
|
||||
export function GET() {
|
||||
const resource = env().PUBLIC_URL.replace(/\/$/, "");
|
||||
return NextResponse.json({
|
||||
resource,
|
||||
authorization_servers: [env().OIDC_ISSUER],
|
||||
scopes_supported: ["openid", "profile", "email"],
|
||||
bearer_methods_supported: ["header"],
|
||||
resource_documentation: `${resource}/`,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
import { handlers } from "@/auth";
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { pg } from "@/lib/db/client";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Liveness + DB connectivity probe for the docker healthcheck.
|
||||
* Returns 200 only if Postgres responds within the request timeout.
|
||||
*/
|
||||
export async function GET() {
|
||||
try {
|
||||
await pg`SELECT 1`;
|
||||
return NextResponse.json({ status: "ok", db: "up" });
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ status: "degraded", db: "down", error: e instanceof Error ? e.message : "unknown" },
|
||||
{ status: 503 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { authenticateBearer, UnauthorizedError } from "@/lib/auth/jwt";
|
||||
import { userContextFromClaims } from "@/lib/mcp/context";
|
||||
import { dispatchMcpMessage } from "@/lib/mcp/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* MCP streamable-HTTP endpoint.
|
||||
*
|
||||
* Auth: Bearer token (Authentik-issued JWT). Unauthed requests get 401 with
|
||||
* a WWW-Authenticate header pointing at our RFC 9728 resource metadata
|
||||
* so MCP clients can discover the authorization server.
|
||||
*
|
||||
* Body: JSON-RPC 2.0 message (request or notification).
|
||||
*
|
||||
* Reply: For requests, the JSON-RPC response in the body with
|
||||
* `Content-Type: application/json`.
|
||||
* For notifications, HTTP 202 with empty body.
|
||||
*/
|
||||
|
||||
export async function POST(req: Request) {
|
||||
// ---- auth ----
|
||||
let claims;
|
||||
try {
|
||||
claims = await authenticateBearer(req.headers.get("authorization"));
|
||||
} catch (e) {
|
||||
if (e instanceof UnauthorizedError) {
|
||||
return new NextResponse(JSON.stringify({ error: e.reason }), {
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": e.wwwAuthenticate,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
|
||||
// ---- parse body ----
|
||||
let body: unknown;
|
||||
try {
|
||||
body = await req.json();
|
||||
} catch {
|
||||
return NextResponse.json(
|
||||
{ jsonrpc: "2.0", id: null, error: { code: -32700, message: "parse error" } },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// ---- resolve user, dispatch ----
|
||||
const ctx = await userContextFromClaims(claims);
|
||||
|
||||
// MCP supports batched requests (array) and single. Handle both.
|
||||
if (Array.isArray(body)) {
|
||||
const responses = await Promise.all(body.map((m) => dispatchMcpMessage(m, ctx)));
|
||||
const filtered = responses.filter((r) => r !== null);
|
||||
if (filtered.length === 0) {
|
||||
return new NextResponse(null, { status: 202 });
|
||||
}
|
||||
return NextResponse.json(filtered, { status: 200 });
|
||||
}
|
||||
|
||||
const response = await dispatchMcpMessage(body, ctx);
|
||||
if (response === null) {
|
||||
// Notification — no body expected.
|
||||
return new NextResponse(null, { status: 202 });
|
||||
}
|
||||
return NextResponse.json(response, { status: 200 });
|
||||
}
|
||||
|
||||
// MCP clients sometimes probe with GET (for SSE). We don't support
|
||||
// server-initiated events in Phase 1 — return 405 with a discoverable header.
|
||||
export function GET() {
|
||||
return new NextResponse(null, {
|
||||
status: 405,
|
||||
headers: { Allow: "POST" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
--bg: #0b0d10;
|
||||
--fg: #e7e9ec;
|
||||
--muted: #8a9099;
|
||||
--accent: #6ea8fe;
|
||||
--surface: #14181d;
|
||||
--border: #232a31;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
font-size: 15px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
color: var(--fg);
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 0.5rem 0.9rem;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
overflow-x: auto;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
code {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.25rem;
|
||||
}
|
||||
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { Metadata } from "next";
|
||||
import type { ReactNode } from "react";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "shared-memory",
|
||||
description: "Shared persistent memory for Claude Code sessions",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { auth, signOut } from "@/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function MePage() {
|
||||
const session = await auth();
|
||||
if (!session?.user) {
|
||||
redirect("/api/auth/signin");
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<h1>Signed in</h1>
|
||||
<p className="muted">
|
||||
Debug view — confirms the Authentik round-trip and the OIDC claims we
|
||||
received.
|
||||
</p>
|
||||
<h2>Session</h2>
|
||||
<pre>{JSON.stringify(session, null, 2)}</pre>
|
||||
<form
|
||||
action={async () => {
|
||||
"use server";
|
||||
await signOut({ redirectTo: "/" });
|
||||
}}
|
||||
>
|
||||
<button type="submit">Sign out</button>
|
||||
</form>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function HomePage() {
|
||||
const session = await auth();
|
||||
|
||||
return (
|
||||
<main className="container">
|
||||
<h1>shared-memory</h1>
|
||||
<p className="muted">
|
||||
Self-hosted MCP server providing shared persistent memory across Claude Code sessions.
|
||||
</p>
|
||||
|
||||
{session?.user ? (
|
||||
<p>
|
||||
Signed in as <strong>{session.user.email ?? session.user.name ?? session.user.id}</strong>{" "}
|
||||
— <Link href="/me">view session</Link>
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
<Link href="/api/auth/signin">Sign in with Authentik</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<hr style={{ borderColor: "var(--border)", margin: "2rem 0" }} />
|
||||
<h2>MCP endpoint</h2>
|
||||
<p className="muted">
|
||||
Connect a Claude Code session to <code>/api/mcp</code> with a bearer token
|
||||
issued by Authentik for this resource. See the README for setup steps.
|
||||
</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import NextAuth from "next-auth";
|
||||
import Authentik from "next-auth/providers/authentik";
|
||||
import { env } from "@/lib/env";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { users } from "@/lib/db/schema";
|
||||
|
||||
/**
|
||||
* NextAuth (Auth.js v5) configuration.
|
||||
*
|
||||
* Authentik is the OIDC issuer. We store the user's OIDC `sub` + `iss` on
|
||||
* first sign-in, upserting a row in `users`. The internal user UUID lives on
|
||||
* the JWT/session so downstream code never has to re-resolve it.
|
||||
*/
|
||||
export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||
providers: [
|
||||
Authentik({
|
||||
clientId: env().OIDC_CLIENT_ID_WEB,
|
||||
clientSecret: env().OIDC_CLIENT_SECRET_WEB,
|
||||
issuer: env().OIDC_ISSUER,
|
||||
}),
|
||||
],
|
||||
secret: env().NEXTAUTH_SECRET,
|
||||
session: { strategy: "jwt" },
|
||||
// Sign-in page is the default Auth.js form; can be customized later.
|
||||
pages: { signIn: "/api/auth/signin" },
|
||||
callbacks: {
|
||||
async jwt({ token, account, profile }) {
|
||||
// On first call after sign-in, `account` + `profile` are populated.
|
||||
if (account && profile) {
|
||||
const sub = profile.sub;
|
||||
const iss = (profile.iss as string | undefined) ?? env().OIDC_ISSUER;
|
||||
if (!sub) throw new Error("OIDC profile missing `sub` claim");
|
||||
|
||||
const row = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcSub: sub,
|
||||
oidcIss: iss,
|
||||
email: profile.email ?? null,
|
||||
name: profile.name ?? null,
|
||||
picture: (profile.picture as string | undefined) ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [users.oidcIss, users.oidcSub],
|
||||
set: {
|
||||
email: profile.email ?? null,
|
||||
name: profile.name ?? null,
|
||||
picture: (profile.picture as string | undefined) ?? null,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
|
||||
token.userId = row[0]?.id;
|
||||
token.sub = sub;
|
||||
token.iss = iss;
|
||||
}
|
||||
return token;
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (token.userId && typeof token.userId === "string") {
|
||||
session.user = { ...session.user, id: token.userId };
|
||||
}
|
||||
return session;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// ---------- module augmentation: typed session.user.id ----------
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string;
|
||||
name?: string | null;
|
||||
email?: string | null;
|
||||
image?: string | null;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export type { Session } from "next-auth";
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { Config } from "drizzle-kit";
|
||||
|
||||
export default {
|
||||
schema: "./lib/db/schema.ts",
|
||||
out: "./drizzle",
|
||||
dialect: "postgresql",
|
||||
dbCredentials: {
|
||||
url: process.env.DATABASE_URL ?? "postgres://memory:memory@localhost:5432/memory",
|
||||
},
|
||||
strict: true,
|
||||
verbose: true,
|
||||
} satisfies Config;
|
||||
@@ -0,0 +1,151 @@
|
||||
-- Initial migration for shared-memory.
|
||||
-- Sets up extensions, enum types, tables, generated columns, and indexes
|
||||
-- required for memory storage + hybrid search (Phase 2 populates the
|
||||
-- embedding column; FTS works in Phase 1).
|
||||
|
||||
-- =============================================================================
|
||||
-- Extensions
|
||||
-- =============================================================================
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
|
||||
CREATE EXTENSION IF NOT EXISTS "vector"; -- pgvector
|
||||
CREATE EXTENSION IF NOT EXISTS "pg_trgm"; -- trigram index for tag fuzzy match
|
||||
|
||||
-- =============================================================================
|
||||
-- Enums
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TYPE "memory_scope" AS ENUM ('project', 'user');
|
||||
CREATE TYPE "memory_visibility" AS ENUM ('private', 'shared', 'team');
|
||||
CREATE TYPE "audit_actor" AS ENUM ('mcp', 'web', 'system');
|
||||
|
||||
-- =============================================================================
|
||||
-- users
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"oidc_sub" text NOT NULL,
|
||||
"oidc_iss" text NOT NULL,
|
||||
"email" text,
|
||||
"name" text,
|
||||
"picture" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"last_seen_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "users_iss_sub_uq" ON "users" ("oidc_iss", "oidc_sub");
|
||||
|
||||
-- =============================================================================
|
||||
-- projects
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE "projects" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
"key" varchar(200) NOT NULL,
|
||||
"display_name" text,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "projects_user_key_uq" ON "projects" ("user_id", "key");
|
||||
|
||||
-- =============================================================================
|
||||
-- memories
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE "memories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
"project_id" uuid REFERENCES "projects"("id") ON DELETE SET NULL,
|
||||
"scope" memory_scope NOT NULL DEFAULT 'project',
|
||||
"visibility" memory_visibility NOT NULL DEFAULT 'private',
|
||||
"content" text NOT NULL,
|
||||
"tags" text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
"embedding" vector(384),
|
||||
"content_tsv" tsvector GENERATED ALWAYS AS (to_tsvector('english', coalesce("content", ''))) STORED,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"deleted_at" timestamptz,
|
||||
-- Scope/project_id consistency: project scope requires a project_id,
|
||||
-- user scope forbids one.
|
||||
CONSTRAINT "memories_scope_project_chk"
|
||||
CHECK (
|
||||
(scope = 'project' AND project_id IS NOT NULL)
|
||||
OR (scope = 'user' AND project_id IS NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX "memories_user_idx" ON "memories" ("user_id");
|
||||
CREATE INDEX "memories_project_idx" ON "memories" ("project_id");
|
||||
CREATE INDEX "memories_created_idx" ON "memories" ("created_at" DESC);
|
||||
|
||||
-- GIN index for full-text search over the generated tsvector column.
|
||||
CREATE INDEX "memories_content_tsv_idx" ON "memories" USING GIN ("content_tsv");
|
||||
|
||||
-- GIN index on tags for exact-tag filtering, and trigram index for fuzzy matching.
|
||||
CREATE INDEX "memories_tags_idx" ON "memories" USING GIN ("tags");
|
||||
CREATE INDEX "memories_tags_trgm_idx" ON "memories" USING GIN ("tags" gin_trgm_ops);
|
||||
|
||||
-- IVFFlat vector index. Lists=100 is a reasonable starting point; tune later
|
||||
-- once we have real volume. Note: the index requires data to be useful — it's
|
||||
-- created here so embeddings written in Phase 2 are indexed automatically.
|
||||
CREATE INDEX "memories_embedding_idx" ON "memories"
|
||||
USING ivfflat ("embedding" vector_cosine_ops) WITH (lists = 100);
|
||||
|
||||
-- =============================================================================
|
||||
-- snippets
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE "snippets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"user_id" uuid NOT NULL REFERENCES "users"("id") ON DELETE CASCADE,
|
||||
"name" varchar(200) NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"description" text,
|
||||
"tags" text[] NOT NULL DEFAULT ARRAY[]::text[],
|
||||
"created_at" timestamptz NOT NULL DEFAULT now(),
|
||||
"updated_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "snippets_user_name_uq" ON "snippets" ("user_id", "name");
|
||||
CREATE INDEX "snippets_tags_idx" ON "snippets" USING GIN ("tags");
|
||||
|
||||
-- =============================================================================
|
||||
-- audit_log
|
||||
-- =============================================================================
|
||||
|
||||
CREATE TABLE "audit_log" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
"user_id" uuid REFERENCES "users"("id") ON DELETE SET NULL,
|
||||
"actor" audit_actor NOT NULL,
|
||||
"action" text NOT NULL,
|
||||
"entity_type" text,
|
||||
"entity_id" uuid,
|
||||
"payload" jsonb,
|
||||
"created_at" timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE INDEX "audit_user_idx" ON "audit_log" ("user_id");
|
||||
CREATE INDEX "audit_created_idx" ON "audit_log" ("created_at" DESC);
|
||||
|
||||
-- =============================================================================
|
||||
-- updated_at triggers
|
||||
-- =============================================================================
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_updated_at() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER projects_set_updated_at BEFORE UPDATE ON "projects"
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TRIGGER memories_set_updated_at BEFORE UPDATE ON "memories"
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE TRIGGER snippets_set_updated_at BEFORE UPDATE ON "snippets"
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
@@ -0,0 +1,91 @@
|
||||
import { createRemoteJWKSet, jwtVerify, errors as joseErrors } from "jose";
|
||||
import type { JWTPayload } from "jose";
|
||||
import { env } from "@/lib/env";
|
||||
|
||||
/**
|
||||
* Authenticates a bearer token issued by Authentik against the configured
|
||||
* OIDC issuer. Verifies signature (via JWKS), issuer, audience, and expiry.
|
||||
*
|
||||
* Used by the MCP endpoint to authenticate incoming Claude Code requests.
|
||||
* Distinct from the NextAuth session cookie path used by the Web UI.
|
||||
*/
|
||||
|
||||
type GlobalWithJwks = typeof globalThis & {
|
||||
__sharedMemoryJwks?: ReturnType<typeof createRemoteJWKSet>;
|
||||
};
|
||||
const g = globalThis as GlobalWithJwks;
|
||||
|
||||
function jwks() {
|
||||
if (g.__sharedMemoryJwks) return g.__sharedMemoryJwks;
|
||||
// Authentik discovery is at `${issuer}/.well-known/openid-configuration`;
|
||||
// the JWKS URI is normally `${issuer}/jwks/` or `${issuer}/.well-known/jwks.json`.
|
||||
// Authentik canonically serves `${issuer}/jwks/`.
|
||||
const issuer = env().OIDC_ISSUER.replace(/\/$/, "");
|
||||
const url = new URL(`${issuer}/jwks/`);
|
||||
g.__sharedMemoryJwks = createRemoteJWKSet(url, {
|
||||
cacheMaxAge: 10 * 60 * 1000, // 10 min
|
||||
cooldownDuration: 30 * 1000,
|
||||
});
|
||||
return g.__sharedMemoryJwks;
|
||||
}
|
||||
|
||||
export interface AuthenticatedClaims extends JWTPayload {
|
||||
sub: string;
|
||||
iss: string;
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends Error {
|
||||
constructor(
|
||||
public readonly reason: string,
|
||||
public readonly wwwAuthenticate: string,
|
||||
) {
|
||||
super(reason);
|
||||
this.name = "UnauthorizedError";
|
||||
}
|
||||
}
|
||||
|
||||
function buildWwwAuthenticate(error?: string, description?: string): string {
|
||||
const parts: string[] = [`Bearer realm="OAuth"`];
|
||||
// RFC 9728 — point clients at our protected-resource metadata so they can
|
||||
// discover the authorization server.
|
||||
parts.push(`resource_metadata="${env().PUBLIC_URL.replace(/\/$/, "")}/.well-known/oauth-protected-resource"`);
|
||||
if (error) parts.push(`error="${error}"`);
|
||||
if (description) parts.push(`error_description="${description.replace(/"/g, "'")}"`);
|
||||
return parts.join(", ");
|
||||
}
|
||||
|
||||
export async function authenticateBearer(authHeader: string | null): Promise<AuthenticatedClaims> {
|
||||
if (!authHeader || !authHeader.toLowerCase().startsWith("bearer ")) {
|
||||
throw new UnauthorizedError("missing bearer token", buildWwwAuthenticate());
|
||||
}
|
||||
|
||||
const token = authHeader.slice("bearer ".length).trim();
|
||||
if (!token) {
|
||||
throw new UnauthorizedError("empty bearer token", buildWwwAuthenticate("invalid_token"));
|
||||
}
|
||||
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, jwks(), {
|
||||
issuer: env().OIDC_ISSUER,
|
||||
audience: env().OIDC_AUDIENCE,
|
||||
});
|
||||
if (!payload.sub) {
|
||||
throw new UnauthorizedError(
|
||||
"token missing sub claim",
|
||||
buildWwwAuthenticate("invalid_token", "missing sub"),
|
||||
);
|
||||
}
|
||||
return payload as AuthenticatedClaims;
|
||||
} catch (err) {
|
||||
if (err instanceof UnauthorizedError) throw err;
|
||||
const desc =
|
||||
err instanceof joseErrors.JWTExpired
|
||||
? "token expired"
|
||||
: err instanceof joseErrors.JWTInvalid
|
||||
? "token invalid"
|
||||
: err instanceof joseErrors.JWTClaimValidationFailed
|
||||
? `claim invalid: ${err.claim}`
|
||||
: "verification failed";
|
||||
throw new UnauthorizedError(desc, buildWwwAuthenticate("invalid_token", desc));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js";
|
||||
import postgres from "postgres";
|
||||
import { env } from "@/lib/env";
|
||||
import * as schema from "./schema";
|
||||
|
||||
// Reuse a single connection pool across hot reloads in dev.
|
||||
type GlobalWithPg = typeof globalThis & {
|
||||
__sharedMemoryPg?: ReturnType<typeof postgres>;
|
||||
};
|
||||
const g = globalThis as GlobalWithPg;
|
||||
|
||||
function makePool() {
|
||||
return postgres(env().DATABASE_URL, {
|
||||
max: 10,
|
||||
idle_timeout: 30,
|
||||
connect_timeout: 10,
|
||||
prepare: false,
|
||||
});
|
||||
}
|
||||
|
||||
const sql = g.__sharedMemoryPg ?? makePool();
|
||||
if (process.env.NODE_ENV !== "production") g.__sharedMemoryPg = sql;
|
||||
|
||||
export const db = drizzle(sql, { schema, logger: env().LOG_LEVEL === "debug" });
|
||||
export { sql as pg, schema };
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
pgTable,
|
||||
pgEnum,
|
||||
uuid,
|
||||
text,
|
||||
timestamp,
|
||||
jsonb,
|
||||
uniqueIndex,
|
||||
index,
|
||||
customType,
|
||||
vector,
|
||||
varchar,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { sql } from "drizzle-orm";
|
||||
|
||||
// ---------- custom column types ----------
|
||||
|
||||
// Postgres tsvector — generated server-side from `content`, not written by app.
|
||||
const tsvector = customType<{ data: string; driverData: string }>({
|
||||
dataType() {
|
||||
return "tsvector";
|
||||
},
|
||||
});
|
||||
|
||||
// Text array helper (Drizzle's `.array()` works, but this keeps intent explicit).
|
||||
const textArray = customType<{ data: string[]; driverData: string }>({
|
||||
dataType() {
|
||||
return "text[]";
|
||||
},
|
||||
toDriver(value) {
|
||||
return `{${value.map((v) => `"${v.replace(/"/g, '\\"')}"`).join(",")}}`;
|
||||
},
|
||||
});
|
||||
|
||||
// ---------- enums ----------
|
||||
|
||||
export const memoryScope = pgEnum("memory_scope", ["project", "user"]);
|
||||
export const memoryVisibility = pgEnum("memory_visibility", ["private", "shared", "team"]);
|
||||
export const auditActor = pgEnum("audit_actor", ["mcp", "web", "system"]);
|
||||
|
||||
// ---------- tables ----------
|
||||
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
// OIDC `sub` claim from Authentik — stable identifier for this user.
|
||||
oidcSub: text("oidc_sub").notNull(),
|
||||
// OIDC `iss` so we can disambiguate if we ever federate.
|
||||
oidcIss: text("oidc_iss").notNull(),
|
||||
email: text("email"),
|
||||
name: text("name"),
|
||||
picture: text("picture"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
lastSeenAt: timestamp("last_seen_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniqueIss: uniqueIndex("users_iss_sub_uq").on(t.oidcIss, t.oidcSub),
|
||||
}),
|
||||
);
|
||||
|
||||
export const projects = pgTable(
|
||||
"projects",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// Caller-supplied stable identifier (e.g. repo name or any string).
|
||||
key: varchar("key", { length: 200 }).notNull(),
|
||||
displayName: text("display_name"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniqueUserKey: uniqueIndex("projects_user_key_uq").on(t.userId, t.key),
|
||||
}),
|
||||
);
|
||||
|
||||
export const memories = pgTable(
|
||||
"memories",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
// NULL when scope = 'user' (global to the user across all projects).
|
||||
projectId: uuid("project_id").references(() => projects.id, { onDelete: "set null" }),
|
||||
scope: memoryScope("scope").notNull().default("project"),
|
||||
visibility: memoryVisibility("visibility").notNull().default("private"),
|
||||
content: text("content").notNull(),
|
||||
tags: textArray("tags").notNull().default([]),
|
||||
// Populated by Phase 2 once the embedder sidecar is online; NULL in Phase 1.
|
||||
embedding: vector("embedding", { dimensions: 384 }),
|
||||
// Generated column — see migration SQL for definition.
|
||||
contentTsv: tsvector("content_tsv"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
deletedAt: timestamp("deleted_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("memories_user_idx").on(t.userId),
|
||||
projectIdx: index("memories_project_idx").on(t.projectId),
|
||||
createdIdx: index("memories_created_idx").on(t.createdAt),
|
||||
// Vector index, tsvector index, and trigram index for tags are declared
|
||||
// in the SQL migration since drizzle-kit doesn't model them.
|
||||
}),
|
||||
);
|
||||
|
||||
export const snippets = pgTable(
|
||||
"snippets",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
name: varchar("name", { length: 200 }).notNull(),
|
||||
body: text("body").notNull(),
|
||||
description: text("description"),
|
||||
tags: textArray("tags").notNull().default([]),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
uniqueUserName: uniqueIndex("snippets_user_name_uq").on(t.userId, t.name),
|
||||
}),
|
||||
);
|
||||
|
||||
export const auditLog = pgTable(
|
||||
"audit_log",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
actor: auditActor("actor").notNull(),
|
||||
action: text("action").notNull(),
|
||||
entityType: text("entity_type"),
|
||||
entityId: uuid("entity_id"),
|
||||
payload: jsonb("payload"),
|
||||
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("audit_user_idx").on(t.userId),
|
||||
createdIdx: index("audit_created_idx").on(t.createdAt),
|
||||
}),
|
||||
);
|
||||
|
||||
// Re-export sql helper so callers can compose raw fragments without a
|
||||
// second drizzle import.
|
||||
export { sql };
|
||||
|
||||
// ---------- inferred types ----------
|
||||
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
export type Memory = typeof memories.$inferSelect;
|
||||
export type NewMemory = typeof memories.$inferInsert;
|
||||
export type Snippet = typeof snippets.$inferSelect;
|
||||
export type NewSnippet = typeof snippets.$inferInsert;
|
||||
export type AuditEntry = typeof auditLog.$inferSelect;
|
||||
export type NewAuditEntry = typeof auditLog.$inferInsert;
|
||||
@@ -0,0 +1,92 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const Bool = z
|
||||
.union([z.boolean(), z.enum(["true", "false", "1", "0"])])
|
||||
.transform((v) => v === true || v === "true" || v === "1");
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
|
||||
LOG_LEVEL: z.enum(["debug", "info", "warn", "error"]).default("info"),
|
||||
|
||||
// Public URL the app is reached at (used for OIDC redirects + MCP metadata)
|
||||
PUBLIC_URL: z.string().url(),
|
||||
|
||||
// Authentik OIDC
|
||||
OIDC_ISSUER: z.string().url(),
|
||||
OIDC_CLIENT_ID_WEB: z.string().min(1),
|
||||
OIDC_CLIENT_SECRET_WEB: z.string().min(1),
|
||||
OIDC_CLIENT_ID_MCP: z.string().min(1),
|
||||
OIDC_AUDIENCE: z.string().min(1),
|
||||
|
||||
// Database
|
||||
DATABASE_URL: z.string().url(),
|
||||
|
||||
// Embedder (used in Phase 2; present-but-empty allowed in Phase 1)
|
||||
EMBEDDER_URL: z.string().url().optional(),
|
||||
EMBEDDING_MODEL: z.string().default("Xenova/bge-small-en-v1.5"),
|
||||
EMBEDDING_DIM: z.coerce.number().int().positive().default(384),
|
||||
|
||||
// NextAuth
|
||||
NEXTAUTH_SECRET: z.string().min(32, "NEXTAUTH_SECRET must be at least 32 chars"),
|
||||
|
||||
// Behavior flags
|
||||
ALLOW_INSECURE_HTTP: Bool.optional().default(false),
|
||||
});
|
||||
|
||||
export type Env = z.infer<typeof envSchema>;
|
||||
|
||||
function loadEnv(): Env {
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
const issues = parsed.error.issues
|
||||
.map((i) => ` - ${i.path.join(".") || "(root)"}: ${i.message}`)
|
||||
.join("\n");
|
||||
throw new Error(`Invalid environment configuration:\n${issues}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
// During `next build`, Next.js evaluates server modules to collect static
|
||||
// page data — env vars aren't expected to be present then. Honor a build-only
|
||||
// bypass so the image can be assembled without baking secrets in.
|
||||
function isBuildPhase(): boolean {
|
||||
return (
|
||||
process.env.SKIP_ENV_VALIDATION === "true" ||
|
||||
process.env.NEXT_PHASE === "phase-production-build"
|
||||
);
|
||||
}
|
||||
|
||||
function buildPhaseStub(): Env {
|
||||
return {
|
||||
NODE_ENV: "production",
|
||||
LOG_LEVEL: "info",
|
||||
PUBLIC_URL: "https://build-phase.invalid",
|
||||
OIDC_ISSUER: "https://build-phase.invalid",
|
||||
OIDC_CLIENT_ID_WEB: "build",
|
||||
OIDC_CLIENT_SECRET_WEB: "build",
|
||||
OIDC_CLIENT_ID_MCP: "build",
|
||||
OIDC_AUDIENCE: "build",
|
||||
DATABASE_URL: "postgres://build:build@build-phase.invalid:5432/build",
|
||||
EMBEDDER_URL: undefined,
|
||||
EMBEDDING_MODEL: "Xenova/bge-small-en-v1.5",
|
||||
EMBEDDING_DIM: 384,
|
||||
NEXTAUTH_SECRET: "build-phase-secret-not-used-at-runtime-xxxxxxxx",
|
||||
ALLOW_INSECURE_HTTP: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Lazy singleton so importing this module at build time doesn't crash when
|
||||
// env vars are absent (e.g. during `next build` without runtime values).
|
||||
let cached: Env | null = null;
|
||||
|
||||
export function env(): Env {
|
||||
if (cached) return cached;
|
||||
cached = isBuildPhase() ? buildPhaseStub() : loadEnv();
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Convenience getter for code paths that only need a single var without
|
||||
// triggering full validation (rare; prefer `env()`).
|
||||
export function rawEnv(key: keyof Env): string | undefined {
|
||||
return process.env[key];
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { db } from "@/lib/db/client";
|
||||
import { users } from "@/lib/db/schema";
|
||||
import { and, eq } from "drizzle-orm";
|
||||
import type { AuthenticatedClaims } from "@/lib/auth/jwt";
|
||||
|
||||
/**
|
||||
* Per-request user context for MCP tool handlers.
|
||||
*
|
||||
* Resolves (or creates) the internal `users` row from the Authentik OIDC
|
||||
* claims so tools work with stable UUID foreign keys rather than raw `sub`
|
||||
* strings.
|
||||
*/
|
||||
export interface UserContext {
|
||||
/** Internal users.id UUID. */
|
||||
userId: string;
|
||||
/** OIDC sub claim (stable identifier from Authentik). */
|
||||
sub: string;
|
||||
/** OIDC issuer. */
|
||||
iss: string;
|
||||
/** Optional profile fields if present in the access token. */
|
||||
email: string | null;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
export async function userContextFromClaims(claims: AuthenticatedClaims): Promise<UserContext> {
|
||||
const email = (claims.email as string | undefined) ?? null;
|
||||
const name = (claims.name as string | undefined) ?? null;
|
||||
const picture = (claims.picture as string | undefined) ?? null;
|
||||
|
||||
const row = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcSub: claims.sub,
|
||||
oidcIss: claims.iss,
|
||||
email,
|
||||
name,
|
||||
picture,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [users.oidcIss, users.oidcSub],
|
||||
set: {
|
||||
email,
|
||||
name,
|
||||
picture,
|
||||
lastSeenAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning({ id: users.id });
|
||||
|
||||
const userId = row[0]?.id;
|
||||
if (!userId) {
|
||||
// Race against another upsert — fall back to a select.
|
||||
const existing = await db
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(and(eq(users.oidcIss, claims.iss), eq(users.oidcSub, claims.sub)))
|
||||
.limit(1);
|
||||
if (!existing[0]) throw new Error("user upsert failed and not found on re-read");
|
||||
return { userId: existing[0].id, sub: claims.sub, iss: claims.iss, email, name };
|
||||
}
|
||||
|
||||
return { userId, sub: claims.sub, iss: claims.iss, email, name };
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import { tools, toolMap, type ToolResult } from "./tools";
|
||||
import type { UserContext } from "./context";
|
||||
|
||||
/**
|
||||
* Minimal JSON-RPC 2.0 dispatcher that implements the MCP wire protocol over
|
||||
* HTTP. We intentionally don't depend on the SDK's `StreamableHTTPServerTransport`
|
||||
* here because Next.js App Router uses Web `Request`/`Response`, not Node's
|
||||
* `IncomingMessage`/`ServerResponse`, and a hand-rolled handler is simpler than
|
||||
* a Node-stream shim. The protocol surface we cover for Phase 1 is:
|
||||
* - `initialize` — handshake
|
||||
* - `notifications/initialized` — ack (no response)
|
||||
* - `tools/list` — enumerate tools
|
||||
* - `tools/call` — invoke a tool
|
||||
* - `ping` — liveness
|
||||
*
|
||||
* If we later need server-initiated events (notifications, sampling), we'll
|
||||
* graduate to SSE responses; for now the protocol works as plain POST/JSON.
|
||||
*/
|
||||
|
||||
const PROTOCOL_VERSION = "2025-06-18";
|
||||
const SERVER_INFO = {
|
||||
name: "shared-memory",
|
||||
version: "0.1.0",
|
||||
};
|
||||
|
||||
type JsonRpcId = string | number | null;
|
||||
|
||||
interface JsonRpcRequest {
|
||||
jsonrpc: "2.0";
|
||||
id?: JsonRpcId;
|
||||
method: string;
|
||||
params?: unknown;
|
||||
}
|
||||
|
||||
interface JsonRpcSuccess {
|
||||
jsonrpc: "2.0";
|
||||
id: JsonRpcId;
|
||||
result: unknown;
|
||||
}
|
||||
|
||||
interface JsonRpcError {
|
||||
jsonrpc: "2.0";
|
||||
id: JsonRpcId;
|
||||
error: { code: number; message: string; data?: unknown };
|
||||
}
|
||||
|
||||
type JsonRpcResponse = JsonRpcSuccess | JsonRpcError;
|
||||
|
||||
// JSON-RPC standard codes; MCP also defines server-error codes from -32000.
|
||||
const RPC = {
|
||||
PARSE_ERROR: -32700,
|
||||
INVALID_REQUEST: -32600,
|
||||
METHOD_NOT_FOUND: -32601,
|
||||
INVALID_PARAMS: -32602,
|
||||
INTERNAL_ERROR: -32603,
|
||||
} as const;
|
||||
|
||||
function makeError(id: JsonRpcId, code: number, message: string, data?: unknown): JsonRpcError {
|
||||
return { jsonrpc: "2.0", id, error: { code, message, ...(data !== undefined && { data }) } };
|
||||
}
|
||||
|
||||
function makeSuccess(id: JsonRpcId, result: unknown): JsonRpcSuccess {
|
||||
return { jsonrpc: "2.0", id, result };
|
||||
}
|
||||
|
||||
function isNotification(req: JsonRpcRequest): boolean {
|
||||
return req.id === undefined;
|
||||
}
|
||||
|
||||
export async function dispatchMcpMessage(
|
||||
message: unknown,
|
||||
ctx: UserContext,
|
||||
): Promise<JsonRpcResponse | null> {
|
||||
if (!message || typeof message !== "object" || Array.isArray(message)) {
|
||||
return makeError(null, RPC.INVALID_REQUEST, "request must be a JSON object");
|
||||
}
|
||||
|
||||
const req = message as JsonRpcRequest;
|
||||
if (req.jsonrpc !== "2.0" || typeof req.method !== "string") {
|
||||
return makeError(req.id ?? null, RPC.INVALID_REQUEST, "invalid jsonrpc envelope");
|
||||
}
|
||||
|
||||
const id = req.id ?? null;
|
||||
const notification = isNotification(req);
|
||||
|
||||
try {
|
||||
switch (req.method) {
|
||||
case "initialize":
|
||||
return makeSuccess(id, {
|
||||
protocolVersion: PROTOCOL_VERSION,
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: SERVER_INFO,
|
||||
});
|
||||
|
||||
case "notifications/initialized":
|
||||
// No response for notifications.
|
||||
return null;
|
||||
|
||||
case "ping":
|
||||
return makeSuccess(id, {});
|
||||
|
||||
case "tools/list":
|
||||
return makeSuccess(id, {
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
description: t.description,
|
||||
inputSchema: t.inputSchema,
|
||||
})),
|
||||
});
|
||||
|
||||
case "tools/call": {
|
||||
const params = (req.params ?? {}) as { name?: string; arguments?: unknown };
|
||||
if (!params.name) {
|
||||
return makeError(id, RPC.INVALID_PARAMS, "tools/call requires `name`");
|
||||
}
|
||||
const tool = toolMap[params.name];
|
||||
if (!tool) {
|
||||
return makeError(id, RPC.METHOD_NOT_FOUND, `unknown tool: ${params.name}`);
|
||||
}
|
||||
const result: ToolResult = await tool.handler(params.arguments ?? {}, ctx);
|
||||
return makeSuccess(id, result);
|
||||
}
|
||||
|
||||
default:
|
||||
if (notification) return null; // ignore unknown notifications
|
||||
return makeError(id, RPC.METHOD_NOT_FOUND, `unknown method: ${req.method}`);
|
||||
}
|
||||
} catch (e) {
|
||||
const message = e instanceof Error ? e.message : "internal error";
|
||||
return notification ? null : makeError(id, RPC.INTERNAL_ERROR, message);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
import { and, desc, eq, isNull, sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db/client";
|
||||
import { memories, projects, auditLog } from "@/lib/db/schema";
|
||||
import {
|
||||
MemoryIdInput,
|
||||
MemoryListInput,
|
||||
MemoryWriteInput,
|
||||
ProjectIdentifyInput,
|
||||
} from "@shared-memory/schemas";
|
||||
import type { UserContext } from "./context";
|
||||
|
||||
/**
|
||||
* MCP tool definitions for v1 (Phase 1). Each tool has:
|
||||
* - name: dotted identifier exposed to clients
|
||||
* - description: shown to the model
|
||||
* - inputSchema: JSON Schema for the arguments object
|
||||
* - handler: async function that runs the tool
|
||||
*
|
||||
* Search (memory.search) and snippets come in later phases.
|
||||
*/
|
||||
|
||||
export interface ToolResult {
|
||||
content: Array<{ type: "text"; text: string }>;
|
||||
isError?: boolean;
|
||||
structuredContent?: unknown;
|
||||
}
|
||||
|
||||
export interface ToolDef {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: Record<string, unknown>;
|
||||
handler: (args: unknown, ctx: UserContext) => Promise<ToolResult>;
|
||||
}
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function ok(structured: unknown, summary: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: summary }],
|
||||
structuredContent: structured,
|
||||
};
|
||||
}
|
||||
|
||||
function err(message: string): ToolResult {
|
||||
return {
|
||||
content: [{ type: "text", text: `error: ${message}` }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function resolveProjectId(
|
||||
ctx: UserContext,
|
||||
projectKey: string | undefined,
|
||||
): Promise<string | null> {
|
||||
if (!projectKey) return null;
|
||||
const row = await db
|
||||
.select({ id: projects.id })
|
||||
.from(projects)
|
||||
.where(and(eq(projects.userId, ctx.userId), eq(projects.key, projectKey)))
|
||||
.limit(1);
|
||||
return row[0]?.id ?? null;
|
||||
}
|
||||
|
||||
// ---------- tools ----------
|
||||
|
||||
const projectIdentify: ToolDef = {
|
||||
name: "project.identify",
|
||||
description:
|
||||
"Register or look up a project for this user by its stable key. Returns the project's internal ID and display name. Call once per session before writing project-scoped memories.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
key: {
|
||||
type: "string",
|
||||
description:
|
||||
"Stable project identifier. Recommended: repo name, repo URL, or any string the caller can reproduce across sessions.",
|
||||
},
|
||||
display_name: {
|
||||
type: "string",
|
||||
description: "Human-readable name shown in the Web UI. Optional.",
|
||||
},
|
||||
},
|
||||
required: ["key"],
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = ProjectIdentifyInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const row = await db
|
||||
.insert(projects)
|
||||
.values({
|
||||
userId: ctx.userId,
|
||||
key: parsed.data.key,
|
||||
displayName: parsed.data.display_name ?? null,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [projects.userId, projects.key],
|
||||
set: {
|
||||
displayName: parsed.data.display_name ?? sql`${projects.displayName}`,
|
||||
updatedAt: new Date(),
|
||||
},
|
||||
})
|
||||
.returning({
|
||||
id: projects.id,
|
||||
key: projects.key,
|
||||
displayName: projects.displayName,
|
||||
createdAt: projects.createdAt,
|
||||
});
|
||||
|
||||
const p = row[0]!;
|
||||
return ok(p, `project ${p.key} (${p.id})`);
|
||||
},
|
||||
};
|
||||
|
||||
const memoryWrite: ToolDef = {
|
||||
name: "memory.write",
|
||||
description:
|
||||
"Persist a memory for this user. With scope='project' (default), the memory is attached to the named project. With scope='user', it's a user-global memory shared across all projects.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
content: { type: "string", description: "Memory content (1–64,000 chars)." },
|
||||
project: {
|
||||
type: "string",
|
||||
description: "Project key (required when scope='project').",
|
||||
},
|
||||
scope: {
|
||||
type: "string",
|
||||
enum: ["project", "user"],
|
||||
description: "Scope of the memory. Defaults to 'project'.",
|
||||
},
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Optional tags for filtering/grouping.",
|
||||
},
|
||||
},
|
||||
required: ["content"],
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = MemoryWriteInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const scope = parsed.data.scope;
|
||||
let projectId: string | null = null;
|
||||
if (scope === "project") {
|
||||
if (!parsed.data.project) return err("scope=project requires `project` key");
|
||||
projectId = await resolveProjectId(ctx, parsed.data.project);
|
||||
if (!projectId) {
|
||||
return err(`unknown project '${parsed.data.project}'; call project.identify first`);
|
||||
}
|
||||
}
|
||||
|
||||
const inserted = await db
|
||||
.insert(memories)
|
||||
.values({
|
||||
userId: ctx.userId,
|
||||
projectId,
|
||||
scope,
|
||||
content: parsed.data.content,
|
||||
tags: parsed.data.tags ?? [],
|
||||
})
|
||||
.returning({ id: memories.id, createdAt: memories.createdAt });
|
||||
|
||||
const m = inserted[0]!;
|
||||
await db.insert(auditLog).values({
|
||||
userId: ctx.userId,
|
||||
actor: "mcp",
|
||||
action: "memory.write",
|
||||
entityType: "memory",
|
||||
entityId: m.id,
|
||||
payload: { scope, projectKey: parsed.data.project ?? null, tags: parsed.data.tags ?? [] },
|
||||
});
|
||||
|
||||
return ok({ id: m.id, createdAt: m.createdAt }, `wrote memory ${m.id}`);
|
||||
},
|
||||
};
|
||||
|
||||
const memoryList: ToolDef = {
|
||||
name: "memory.list",
|
||||
description:
|
||||
"List memories for this user, most recent first. Filter by project key and/or scope. Phase 2 will add memory.search for semantic + full-text lookup.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
project: { type: "string", description: "Filter by project key." },
|
||||
scope: { type: "string", enum: ["project", "user"], description: "Filter by scope." },
|
||||
tags: {
|
||||
type: "array",
|
||||
items: { type: "string" },
|
||||
description: "Require all of these tags.",
|
||||
},
|
||||
limit: { type: "integer", minimum: 1, maximum: 200, default: 50 },
|
||||
},
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = MemoryListInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const where = [eq(memories.userId, ctx.userId), isNull(memories.deletedAt)];
|
||||
|
||||
if (parsed.data.scope) where.push(eq(memories.scope, parsed.data.scope));
|
||||
|
||||
if (parsed.data.project) {
|
||||
const projectId = await resolveProjectId(ctx, parsed.data.project);
|
||||
if (!projectId) return ok({ items: [], next_cursor: null }, "0 results");
|
||||
where.push(eq(memories.projectId, projectId));
|
||||
}
|
||||
|
||||
if (parsed.data.tags && parsed.data.tags.length > 0) {
|
||||
where.push(sql`${memories.tags} @> ${parsed.data.tags}::text[]`);
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select({
|
||||
id: memories.id,
|
||||
scope: memories.scope,
|
||||
projectId: memories.projectId,
|
||||
content: memories.content,
|
||||
tags: memories.tags,
|
||||
createdAt: memories.createdAt,
|
||||
updatedAt: memories.updatedAt,
|
||||
})
|
||||
.from(memories)
|
||||
.where(and(...where))
|
||||
.orderBy(desc(memories.createdAt))
|
||||
.limit(parsed.data.limit);
|
||||
|
||||
return ok({ items: rows, next_cursor: null }, `${rows.length} result(s)`);
|
||||
},
|
||||
};
|
||||
|
||||
const memoryGet: ToolDef = {
|
||||
name: "memory.get",
|
||||
description: "Fetch a single memory by its UUID.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string", format: "uuid" } },
|
||||
required: ["id"],
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = MemoryIdInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const row = await db
|
||||
.select()
|
||||
.from(memories)
|
||||
.where(
|
||||
and(
|
||||
eq(memories.id, parsed.data.id),
|
||||
eq(memories.userId, ctx.userId),
|
||||
isNull(memories.deletedAt),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
|
||||
if (!row[0]) return err("not found");
|
||||
return ok(row[0], `memory ${row[0].id}`);
|
||||
},
|
||||
};
|
||||
|
||||
const memoryDelete: ToolDef = {
|
||||
name: "memory.delete",
|
||||
description: "Soft-delete a memory (sets deleted_at; preserved for audit).",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { id: { type: "string", format: "uuid" } },
|
||||
required: ["id"],
|
||||
},
|
||||
async handler(args, ctx) {
|
||||
const parsed = MemoryIdInput.safeParse(args);
|
||||
if (!parsed.success) return err(parsed.error.message);
|
||||
|
||||
const updated = await db
|
||||
.update(memories)
|
||||
.set({ deletedAt: new Date() })
|
||||
.where(
|
||||
and(
|
||||
eq(memories.id, parsed.data.id),
|
||||
eq(memories.userId, ctx.userId),
|
||||
isNull(memories.deletedAt),
|
||||
),
|
||||
)
|
||||
.returning({ id: memories.id });
|
||||
|
||||
if (!updated[0]) return err("not found");
|
||||
|
||||
await db.insert(auditLog).values({
|
||||
userId: ctx.userId,
|
||||
actor: "mcp",
|
||||
action: "memory.delete",
|
||||
entityType: "memory",
|
||||
entityId: updated[0].id,
|
||||
});
|
||||
|
||||
return ok({ id: updated[0].id, deleted: true }, `deleted memory ${updated[0].id}`);
|
||||
},
|
||||
};
|
||||
|
||||
export const tools: ToolDef[] = [
|
||||
projectIdentify,
|
||||
memoryWrite,
|
||||
memoryList,
|
||||
memoryGet,
|
||||
memoryDelete,
|
||||
];
|
||||
|
||||
export const toolMap: Record<string, ToolDef> = Object.fromEntries(
|
||||
tools.map((t) => [t.name, t]),
|
||||
);
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const config: NextConfig = {
|
||||
output: "standalone",
|
||||
reactStrictMode: true,
|
||||
poweredByHeader: false,
|
||||
serverExternalPackages: ["postgres"],
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: "/api/mcp/:path*",
|
||||
headers: [
|
||||
{ key: "Cache-Control", value: "no-store" },
|
||||
],
|
||||
},
|
||||
];
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"name": "@shared-memory/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --port 3000",
|
||||
"build": "next build",
|
||||
"start": "next start --port 3000",
|
||||
"lint": "next lint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx ./scripts/migrate.ts",
|
||||
"db:studio": "drizzle-kit studio"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.4",
|
||||
"@shared-memory/schemas": "workspace:*",
|
||||
"drizzle-orm": "^0.36.4",
|
||||
"jose": "^5.9.6",
|
||||
"next": "^15.1.0",
|
||||
"next-auth": "5.0.0-beta.25",
|
||||
"postgres": "^3.4.5",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.2",
|
||||
"@types/react": "^19.0.2",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"drizzle-kit": "^0.30.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-config-next": "^15.1.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Run pending SQL migrations from ./drizzle in lexical filename order.
|
||||
*
|
||||
* Lightweight runner — drizzle-kit's TS migrator doesn't handle the raw SQL
|
||||
* features we need (pgvector, generated columns), so we manage migration
|
||||
* state ourselves in `_migrations` and apply files as plain SQL.
|
||||
*/
|
||||
import { readFile, readdir } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import postgres from "postgres";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const MIGRATIONS_DIR = join(__dirname, "..", "drizzle");
|
||||
|
||||
async function main() {
|
||||
const url = process.env.DATABASE_URL;
|
||||
if (!url) {
|
||||
console.error("DATABASE_URL is not set");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const sql = postgres(url, { max: 1, prepare: false });
|
||||
|
||||
try {
|
||||
await sql`
|
||||
CREATE TABLE IF NOT EXISTS "_migrations" (
|
||||
"id" serial PRIMARY KEY,
|
||||
"name" text NOT NULL UNIQUE,
|
||||
"applied_at" timestamptz NOT NULL DEFAULT now()
|
||||
)
|
||||
`;
|
||||
|
||||
const files = (await readdir(MIGRATIONS_DIR))
|
||||
.filter((f) => f.endsWith(".sql"))
|
||||
.sort();
|
||||
|
||||
const applied = new Set(
|
||||
(await sql<{ name: string }[]>`SELECT name FROM "_migrations"`).map((r) => r.name),
|
||||
);
|
||||
|
||||
for (const file of files) {
|
||||
if (applied.has(file)) {
|
||||
console.log(`✓ ${file} (already applied)`);
|
||||
continue;
|
||||
}
|
||||
const body = await readFile(join(MIGRATIONS_DIR, file), "utf8");
|
||||
console.log(`→ ${file} (applying)`);
|
||||
await sql.begin(async (tx) => {
|
||||
await tx.unsafe(body);
|
||||
await tx`INSERT INTO "_migrations" (name) VALUES (${file})`;
|
||||
});
|
||||
console.log(`✓ ${file}`);
|
||||
}
|
||||
|
||||
console.log("Migrations complete.");
|
||||
} finally {
|
||||
await sql.end({ timeout: 5 });
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Migration failed:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "ES2022"],
|
||||
"jsx": "preserve",
|
||||
"allowJs": true,
|
||||
"incremental": true,
|
||||
"noEmit": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules", ".next"]
|
||||
}
|
||||
Reference in New Issue
Block a user