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:
2026-05-15 07:04:11 -07:00
co-authored by Claude Opus 4.7
parent d5be753cfb
commit 077d0a0825
37 changed files with 7294 additions and 8 deletions
+28
View File
@@ -0,0 +1,28 @@
# VCS
.git
.gitignore
# Local env — must NEVER end up in image layers
.env
.env.*
!.env.example
# Build artifacts
**/node_modules
**/.next
**/dist
**/build
**/.turbo
**/coverage
# Editor / OS
.vscode
.idea
.DS_Store
# Logs
*.log
# Misc
*.md
!README.md
+28 -8
View File
@@ -5,14 +5,30 @@
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Public URL the app is reached at. # Public URL the app is reached at.
# Used for OIDC redirect URIs, MCP discovery metadata, and the Caddy site name. # Used for OIDC redirect URIs, MCP discovery metadata, and Auth.js callbacks.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
PUBLIC_URL=https://memory.example.com PUBLIC_URL=https://memory.example.com
# -----------------------------------------------------------------------------
# Deployment mode
# -----------------------------------------------------------------------------
# By default the app exposes a plain HTTP port to the host for use behind an
# external reverse proxy (HAProxy, nginx, Traefik, Cloudflare Tunnel, etc.).
APP_PORT=3000
# Bind interface for the exposed port. Use 127.0.0.1 to only accept traffic
# from a proxy on the same host. Default 0.0.0.0 accepts from anywhere.
APP_BIND=0.0.0.0
# The two settings below are ONLY consumed by the optional `caddy` service,
# which is started with: `docker compose --profile tls up -d`.
# Leave them as-is if you terminate TLS upstream (HAProxy, etc.).
APP_HOSTNAME=memory.example.com
ACME_EMAIL=you@example.com
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Authentik OIDC # Authentik OIDC
# Create two Applications in Authentik (one for the Web UI, one for the MCP # Create two Applications in Authentik (one for the Web UI, one for the MCP
# resource server). See README.md for the exact provider settings. # resource server). See README.md for exact provider settings.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
OIDC_ISSUER=https://auth.example.com/application/o/shared-memory/ OIDC_ISSUER=https://auth.example.com/application/o/shared-memory/
OIDC_CLIENT_ID_WEB=replace-me OIDC_CLIENT_ID_WEB=replace-me
@@ -21,18 +37,20 @@ OIDC_CLIENT_ID_MCP=replace-me
OIDC_AUDIENCE=shared-memory OIDC_AUDIENCE=shared-memory
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Database (Postgres 16 + pgvector) # Database (Postgres 16 + pgvector — pgvector/pgvector:pg16 image)
# Default values match the docker-compose `db` service.
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
POSTGRES_USER=memory POSTGRES_USER=memory
POSTGRES_PASSWORD=replace-me-with-a-strong-password POSTGRES_PASSWORD=replace-me-with-a-strong-password
POSTGRES_DB=memory POSTGRES_DB=memory
DATABASE_URL=postgres://memory:replace-me-with-a-strong-password@db:5432/memory
# Built automatically by docker-compose from the values above. Override only
# if you point at an external Postgres.
# DATABASE_URL=postgres://memory:...@db:5432/memory
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
# Embedder sidecar (added in Phase 2) # Embedder sidecar (added in Phase 2; leave EMBEDDER_URL empty in Phase 1)
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
EMBEDDER_URL=http://embedder:8080 EMBEDDER_URL=
EMBEDDING_MODEL=Xenova/bge-small-en-v1.5 EMBEDDING_MODEL=Xenova/bge-small-en-v1.5
EMBEDDING_DIM=384 EMBEDDING_DIM=384
@@ -45,4 +63,6 @@ NEXTAUTH_SECRET=replace-me-with-32-bytes-of-random
# App # App
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
LOG_LEVEL=info LOG_LEVEL=info
NODE_ENV=production
# Optional: pin to a specific built image (e.g. for a registry-pushed build).
# IMAGE_REF=registry.example.com/shared-memory-web:0.1.0
+5
View File
@@ -0,0 +1,5 @@
link-workspace-packages=true
prefer-workspace-packages=true
auto-install-peers=true
shamefully-hoist=false
strict-peer-dependencies=false
+33
View File
@@ -0,0 +1,33 @@
# Caddy config for shared-memory.
#
# Hostname and ACME email come from environment variables set by docker-compose
# (which loads them from .env). For local development without TLS, override
# this file or set APP_HOSTNAME=localhost and use a docker-compose override.
{
email {$ACME_EMAIL}
# Uncomment to use the Let's Encrypt staging directory while testing:
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
{$APP_HOSTNAME} {
encode gzip zstd
# Trust X-Forwarded-* from this proxy. Auth.js + Next.js use these to
# construct callback URLs that match PUBLIC_URL.
header {
# Tell upstream we terminated TLS.
# (`reverse_proxy` already sets X-Forwarded-* by default.)
}
reverse_proxy app:3000 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-Proto {scheme}
}
log {
output stdout
format console
}
}
+329
View File
@@ -0,0 +1,329 @@
# shared-memory
A self-hosted MCP server that gives Claude Code sessions a **shared, persistent
memory** plus a **reusable snippet library**, behind your own Authentik OIDC
login. Includes a Web UI for reviewing, editing, and deleting what's been
stored.
> **Status:** Phase 1 — core memory path end-to-end (write / list / get /
> delete), Authentik-authed Web UI, MCP endpoint with Authentik JWT validation.
> Semantic search and the rich Web UI land in Phase 2 / Phase 3.
---
## Architecture
```
┌─────────────────┐ ┌───────────────────────┐ ┌──────────────┐
│ Claude Code │──MCP──▶│ shared-memory app │◀──OIDC─│ Authentik │
│ (many sessions)│ HTTP │ Next.js + MCP route │ └──────────────┘
└─────────────────┘ │ + Web UI │ ▲
└───────────┬───────────┘ │
│ │
┌──────▼──────┐ user logs in
│ Postgres 16 │ via web browser
│ + pgvector │
└─────────────┘
```
The same container serves both the MCP endpoint (under `/api/mcp`) and the
Web UI. Users authenticate via your Authentik instance — pre-registered
confidential clients, not dynamic client registration. Identity is keyed on
the OIDC `sub` claim so memories are scoped per user.
---
## Prerequisites
- A host with **Docker** and **Docker Compose v2** installed.
- A **self-hosted Authentik instance** you administer.
- A **public DNS record** for the chosen hostname pointing at your reverse
proxy (HAProxy, nginx, Cloudflare Tunnel, …) or at this host directly.
- A Postgres-friendly disk for the `db_data` volume.
---
## Deployment modes
Pick one based on how you handle TLS:
### Mode A — Behind an external reverse proxy (DEFAULT)
You already have HAProxy / nginx / Traefik / Cloudflare Tunnel terminating
TLS for your domain. The app exposes a plain HTTP port to the host; your
proxy forwards traffic to it.
```bash
docker compose up -d
```
The app listens on `${APP_PORT:-3000}` on the host. Point your proxy there.
See **HAProxy example** below.
### Mode B — Built-in TLS via Caddy
The host directly faces the internet on ports 80/443 and you want
auto-managed Let's Encrypt certs.
```bash
docker compose --profile tls up -d
```
Caddy reads `APP_HOSTNAME` and `ACME_EMAIL` from `.env` and proxies to the
app on the internal Docker network.
---
## Quick start
```bash
git clone https://repo.anhonesthost.net/jknapp/shared-memory.git
cd shared-memory
cp .env.example .env
# edit .env — see "Configuration" and "Authentik setup" below
docker compose build
docker compose up -d # Mode A (behind external proxy)
# OR
docker compose --profile tls up -d # Mode B (built-in TLS)
# tail logs to watch migrations run + app come up
docker compose logs -f migrator app
```
When `app` reports `Listening on http://0.0.0.0:3000`, visit your
`PUBLIC_URL` and click **Sign in with Authentik**. You should land on
`/me` showing your OIDC session.
---
## Configuration
All runtime config is in `.env` at the repo root. Never commit this file.
Copy `.env.example` and fill in the values below.
| Variable | Mode | What it is |
|---|---|---|
| `PUBLIC_URL` | both | Full external URL of this app, e.g. `https://memory.dnspegasus.net`. Used by Auth.js for callbacks and by the MCP route for resource metadata. |
| `APP_PORT` | A | Host port the app listens on for the external proxy. Default `3000`. |
| `APP_BIND` | A | Interface to bind on. Use `127.0.0.1` to only accept traffic from a proxy on the same host. Default `0.0.0.0`. |
| `APP_HOSTNAME` | B | Hostname only (no scheme). Caddy uses it for the TLS site block. |
| `ACME_EMAIL` | B | Email for Let's Encrypt registration. |
| `OIDC_ISSUER` | both | Authentik's OIDC issuer URL for **this app**. Looks like `https://auth.example.com/application/o/shared-memory/`. |
| `OIDC_CLIENT_ID_WEB` | both | Client ID of the Web-UI Authentik provider. |
| `OIDC_CLIENT_SECRET_WEB` | both | Client secret of the Web-UI Authentik provider. |
| `OIDC_CLIENT_ID_MCP` | both | Client ID of the MCP resource-server Authentik provider. |
| `OIDC_AUDIENCE` | both | Audience string the MCP access token must carry in its `aud` claim. Recommended: `shared-memory`. |
| `POSTGRES_USER` / `POSTGRES_PASSWORD` / `POSTGRES_DB` | both | Local Postgres credentials. |
| `NEXTAUTH_SECRET` | both | Session-cookie signing key. Generate with `openssl rand -base64 32`. |
| `EMBEDDER_URL` | both | Phase 2 embedder sidecar. Leave empty in Phase 1. |
| `LOG_LEVEL` | both | `debug` / `info` / `warn` / `error`. |
Mode column: **A** = external proxy (default), **B** = built-in Caddy TLS.
---
## Authentik setup
You need **two** Authentik OAuth2/OpenID Connect providers + applications:
one for the Web UI (browser logins), one for the MCP resource server (the
audience Claude Code's access tokens are minted for). Reusing one provider
for both works, but the dual-provider setup keeps audiences cleanly separated
and is what the rest of this doc assumes.
### A. Web UI provider
**Admin → Applications → Providers → Create → OAuth2/OpenID Provider**
- **Name:** `shared-memory-web`
- **Authorization flow:** `default-provider-authorization-explicit-consent`
(or your standard auth flow)
- **Client type:** `Confidential`
- **Client ID:** auto-generated → copy to `.env` as `OIDC_CLIENT_ID_WEB`
- **Client Secret:** auto-generated → copy to `.env` as `OIDC_CLIENT_SECRET_WEB`
- **Redirect URIs / Origins:**
```
https://memory.dnspegasus.net/api/auth/callback/authentik
```
(replace with your `PUBLIC_URL`)
- **Signing Key:** select your `authentik Self-signed Certificate`
- **Scopes:** `openid`, `profile`, `email`
Save. Then **Admin → Applications → Applications → Create**:
- **Name / Slug:** `shared-memory` (the slug becomes the path in the issuer URL)
- **Provider:** `shared-memory-web`
- **Launch URL:** `https://memory.dnspegasus.net/`
The slug is what makes `OIDC_ISSUER` end with `.../application/o/shared-memory/`.
### B. MCP resource-server provider
The MCP endpoint validates **access tokens** issued by Authentik for a specific
audience (`OIDC_AUDIENCE`). This second provider exists so Claude Code's
tokens carry `aud: shared-memory` (or whatever value you chose).
**Admin → Applications → Providers → Create → OAuth2/OpenID Provider**
- **Name:** `shared-memory-mcp`
- **Authorization flow:** same as above
- **Client type:** `Public` (Claude Code runs PKCE without a static secret)
or `Confidential` if you prefer to issue a secret to each Claude Code
install — both work. Phase 1 expects Public.
- **Client ID:** auto-generated → copy to `.env` as `OIDC_CLIENT_ID_MCP`
- **Redirect URIs:** Claude Code prints the exact value when it first
connects to the MCP endpoint. Paste it into Authentik then.
- **Scopes:** `openid`, `profile`, `email`
- **Signing Key:** same cert as the Web provider
#### Setting the `aud` claim
The MCP endpoint requires the access token's `aud` claim to equal
`OIDC_AUDIENCE`. Authentik does not always emit `aud` by default. The
reliable pattern:
1. Create a **scope mapping** (Customisation → Property Mappings → Create →
Scope Mapping) named `aud-shared-memory` with expression:
```python
return {"aud": "shared-memory"}
```
2. On the MCP provider, add this scope mapping under **Scopes** and tick it
so it's emitted for the default scope.
> If you skip this, the MCP route will return 401 with
> `error_description="claim invalid: aud"`. Check `docker compose logs app`
> for the exact failure.
Then create an **Application** for the MCP provider (same as Step A), slug
e.g. `shared-memory-mcp`.
### C. Assign users
For each Authentik user who should have access, add them to the bound group
on both applications (or set the applications' authentication policy to
permit them). Anyone not granted access will fail at the Authentik login
prompt, never reaching the app.
---
## Connecting Claude Code
In a future Phase, we'll publish a one-line Claude Code config snippet. For
Phase 1, follow the [MCP authorization flow][mcp-auth]:
1. Add the MCP server to Claude Code's config, pointing at
`https://memory.dnspegasus.net/api/mcp`.
2. On first connection, the server returns 401 with `WWW-Authenticate`
pointing at `/.well-known/oauth-protected-resource`.
3. Claude Code reads the protected-resource metadata, follows the link to
Authentik's discovery doc, and runs the OAuth 2.1 PKCE flow.
4. You'll be prompted in your browser to authenticate with Authentik.
5. Claude Code stores the access token and uses it on subsequent requests.
If Authentik refuses the redirect URI Claude Code attempts to use, copy the
URI from the error and add it under the MCP provider's **Redirect URIs**.
[mcp-auth]: https://modelcontextprotocol.io/specification/2025-06-18/basic/authorization
---
## HAProxy example
If you run HAProxy at the edge (TLS terminator + reverse proxy), a minimal
config for this app looks like:
```haproxy
frontend https_in
bind *:443 ssl crt /etc/haproxy/certs/memory.dnspegasus.net.pem alpn h2,http/1.1
http-request set-header X-Forwarded-Proto https
http-request set-header X-Forwarded-Host %[req.hdr(host)]
http-request set-header X-Forwarded-For %[src]
acl host_memory hdr(host) -i memory.dnspegasus.net
use_backend shared_memory if host_memory
backend shared_memory
option forwardfor
# Replace 127.0.0.1 with the IP of the host running docker compose.
# Port is APP_PORT from .env (default 3000).
server app1 127.0.0.1:3000 check inter 5s
```
Things to verify:
- `PUBLIC_URL` in `.env` matches the public URL HAProxy serves (scheme + host).
- HAProxy is sending `X-Forwarded-Proto`, `X-Forwarded-Host`, and
`X-Forwarded-For` (the snippet above does). Auth.js reads these to build
the OIDC callback URL — without them, the callback may point at
`http://...:3000` and Authentik will reject it.
- The Authentik Web-UI provider's **Redirect URI** is the public callback,
not the internal one. E.g. `https://memory.dnspegasus.net/api/auth/callback/authentik`.
If your HAProxy lives on a different host than Docker, change `127.0.0.1`
to the Docker host's address (and confirm `APP_BIND=0.0.0.0` so the port
listens on all interfaces).
---
## Local development (no TLS)
For development against a local Authentik, you can skip Caddy and run the app
directly:
```bash
pnpm install
cp .env.example .env # set PUBLIC_URL=http://localhost:3000 etc.
docker compose up -d db
pnpm db:migrate
pnpm dev
```
The Authentik provider you use locally must accept
`http://localhost:3000/api/auth/callback/authentik` as a redirect URI.
---
## Troubleshooting
- **`401 claim invalid: aud`** from `/api/mcp` — your MCP provider isn't
emitting `aud`. See **Setting the `aud` claim** above.
- **Auth.js callback fails with `OAUTH_CALLBACK_ERROR`** — your `PUBLIC_URL`
doesn't match the redirect URI Authentik is configured with. They must be
exactly equal, scheme and trailing slash included.
- **Caddy can't get a cert** — confirm DNS points to your host and ports
80/443 are reachable. Uncomment the staging CA line in `Caddyfile` while
testing to avoid hitting the production rate limit.
- **`pg_isready` healthcheck loops** — check that `POSTGRES_USER` /
`POSTGRES_PASSWORD` / `POSTGRES_DB` are all set in `.env`.
---
## Project layout
```
shared-memory/
├── apps/web/ # Next.js app (UI + MCP endpoint)
│ ├── app/
│ │ ├── page.tsx # landing
│ │ ├── me/page.tsx # auth debug page
│ │ ├── api/auth/[...nextauth]/ # NextAuth handler
│ │ ├── api/mcp/ # MCP streamable-HTTP endpoint
│ │ ├── api/health/ # /api/health for compose healthcheck
│ │ └── .well-known/oauth-protected-resource/ # RFC 9728
│ ├── auth.ts # NextAuth + Authentik provider config
│ ├── lib/
│ │ ├── env.ts # Zod env validation
│ │ ├── auth/jwt.ts # MCP bearer JWT verification (JWKS)
│ │ ├── db/ # Drizzle schema + client
│ │ └── mcp/ # MCP dispatcher + tools
│ ├── drizzle/0000_init.sql # initial migration (manual SQL)
│ ├── scripts/migrate.ts # migration runner
│ └── Dockerfile
├── packages/schemas/ # shared Zod schemas (UI ↔ MCP)
├── docker-compose.yml
├── Caddyfile
└── .env.example
```
## License
MIT.
+68
View File
@@ -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;
+21
View File
@@ -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 },
);
}
}
+80
View File
@@ -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" },
});
}
+71
View File
@@ -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);
}
+16
View File
@@ -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>
);
}
+31
View File
@@ -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>
);
}
+35
View File
@@ -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>
);
}
+82
View File
@@ -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";
+12
View File
@@ -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;
+151
View File
@@ -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();
+91
View File
@@ -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));
}
}
+25
View File
@@ -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 };
+162
View File
@@ -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;
+92
View File
@@ -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];
}
+63
View File
@@ -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 };
}
+132
View File
@@ -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);
}
}
+310
View File
@@ -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 (164,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]),
);
+20
View File
@@ -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;
+38
View File
@@ -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"
}
}
View File
+65
View File
@@ -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);
});
+21
View File
@@ -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"]
}
+144
View File
@@ -0,0 +1,144 @@
# =============================================================================
# shared-memory — compose stack.
#
# Two supported deployment modes:
#
# 1. Behind an external reverse proxy (DEFAULT)
# The `app` service exposes ${APP_PORT:-3000} on the host. Point your
# proxy (HAProxy, nginx, Traefik, Cloudflare Tunnel, etc.) at it. The
# app trusts X-Forwarded-Proto / X-Forwarded-Host headers so callbacks
# and MCP discovery URLs use PUBLIC_URL correctly.
#
# docker compose up -d
#
# 2. Built-in TLS via Caddy (opt-in profile)
# Adds a Caddy reverse proxy on host ports 80/443 with automatic
# Let's Encrypt certificates for $APP_HOSTNAME. Use this on a VM that
# doesn't already sit behind a proxy.
#
# docker compose --profile tls up -d
#
# All runtime config lives in .env (never committed). See .env.example.
# =============================================================================
name: shared-memory
services:
db:
image: pgvector/pgvector:pg16
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:?POSTGRES_USER not set in .env}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD not set in .env}
POSTGRES_DB: ${POSTGRES_DB:?POSTGRES_DB not set in .env}
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 20
networks:
- internal
# One-shot migration runner. Exits 0 when migrations are up-to-date;
# `app` waits on its successful completion before starting.
migrator:
image: ${IMAGE_REF:-shared-memory-web:local}
build:
context: .
dockerfile: apps/web/Dockerfile
restart: "no"
depends_on:
db:
condition: service_healthy
environment:
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
command: ["node", "apps/web/migrate.mjs"]
networks:
- internal
app:
image: ${IMAGE_REF:-shared-memory-web:local}
build:
context: .
dockerfile: apps/web/Dockerfile
restart: unless-stopped
depends_on:
db:
condition: service_healthy
migrator:
condition: service_completed_successfully
environment:
NODE_ENV: production
LOG_LEVEL: ${LOG_LEVEL:-info}
PUBLIC_URL: ${PUBLIC_URL:?PUBLIC_URL not set in .env}
# Auth.js v5 needs to know its public URL when behind a reverse proxy.
AUTH_URL: ${PUBLIC_URL}
AUTH_TRUST_HOST: "true"
OIDC_ISSUER: ${OIDC_ISSUER:?OIDC_ISSUER not set in .env}
OIDC_CLIENT_ID_WEB: ${OIDC_CLIENT_ID_WEB:?required}
OIDC_CLIENT_SECRET_WEB: ${OIDC_CLIENT_SECRET_WEB:?required}
OIDC_CLIENT_ID_MCP: ${OIDC_CLIENT_ID_MCP:?required}
OIDC_AUDIENCE: ${OIDC_AUDIENCE:?required}
DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB}
EMBEDDER_URL: ${EMBEDDER_URL:-}
EMBEDDING_MODEL: ${EMBEDDING_MODEL:-Xenova/bge-small-en-v1.5}
EMBEDDING_DIM: ${EMBEDDING_DIM:-384}
NEXTAUTH_SECRET: ${NEXTAUTH_SECRET:?required}
ports:
# Exposed to the host so an external reverse proxy (HAProxy, nginx,
# etc.) can reach the app. When using the `tls` profile, Caddy also
# proxies via the internal network — leaving this exposed is harmless
# but you can bind to 127.0.0.1 only by setting APP_BIND=127.0.0.1.
- "${APP_BIND:-0.0.0.0}:${APP_PORT:-3000}:3000"
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://localhost:3000/api/health || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 15s
networks:
- internal
- web
# Opt-in TLS terminator. Skipped unless `--profile tls` is passed.
# External-proxy deployments (HAProxy, nginx, Cloudflare Tunnel, etc.)
# leave this off and proxy directly to host:${APP_PORT}.
caddy:
image: caddy:2-alpine
profiles: ["tls"]
restart: unless-stopped
depends_on:
app:
condition: service_healthy
ports:
- "80:80"
- "443:443"
- "443:443/udp"
environment:
APP_HOSTNAME: ${APP_HOSTNAME:-localhost}
ACME_EMAIL: ${ACME_EMAIL:-}
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy_data:/data
- caddy_config:/config
networks:
- web
volumes:
db_data:
caddy_data:
caddy_config:
networks:
internal:
driver: bridge
web:
driver: bridge
+19
View File
@@ -0,0 +1,19 @@
{
"name": "shared-memory",
"version": "0.1.0",
"private": true,
"description": "Self-hosted MCP server providing shared persistent memory across Claude Code sessions, authed via Authentik OIDC",
"license": "MIT",
"packageManager": "pnpm@9.12.3",
"engines": {
"node": ">=20.11.0"
},
"scripts": {
"build": "pnpm -r build",
"dev": "pnpm --filter @shared-memory/web dev",
"lint": "pnpm -r lint",
"typecheck": "pnpm -r typecheck",
"db:generate": "pnpm --filter @shared-memory/web db:generate",
"db:migrate": "pnpm --filter @shared-memory/web db:migrate"
}
}
+21
View File
@@ -0,0 +1,21 @@
{
"name": "@shared-memory/schemas",
"version": "0.1.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"typecheck": "tsc --noEmit",
"build": "echo 'no build step; consumed as TS source'"
},
"dependencies": {
"zod": "^3.23.8"
},
"devDependencies": {
"typescript": "^5.7.2"
}
}
+49
View File
@@ -0,0 +1,49 @@
import { z } from "zod";
export const MemoryScope = z.enum(["project", "user"]);
export type MemoryScope = z.infer<typeof MemoryScope>;
export const MemoryVisibility = z.enum(["private", "shared", "team"]);
export type MemoryVisibility = z.infer<typeof MemoryVisibility>;
export const ProjectKey = z
.string()
.min(1)
.max(200)
.regex(/^[a-zA-Z0-9._\-/]+$/, "project key may only contain alphanumerics, ._-/");
export type ProjectKey = z.infer<typeof ProjectKey>;
export const MemoryContent = z.string().min(1).max(64_000);
export const Tags = z
.array(z.string().min(1).max(64).regex(/^[a-zA-Z0-9._\-]+$/, "tag must be alphanumeric ._-"))
.max(32)
.default([]);
export const MemoryWriteInput = z.object({
content: MemoryContent,
project: ProjectKey.optional(),
tags: Tags.optional(),
scope: MemoryScope.default("project"),
});
export type MemoryWriteInput = z.infer<typeof MemoryWriteInput>;
export const MemoryListInput = z.object({
project: ProjectKey.optional(),
scope: MemoryScope.optional(),
tags: z.array(z.string()).optional(),
limit: z.number().int().min(1).max(200).default(50),
cursor: z.string().optional(),
});
export type MemoryListInput = z.infer<typeof MemoryListInput>;
export const MemoryIdInput = z.object({
id: z.string().uuid(),
});
export type MemoryIdInput = z.infer<typeof MemoryIdInput>;
export const ProjectIdentifyInput = z.object({
key: ProjectKey,
display_name: z.string().min(1).max(200).optional(),
});
export type ProjectIdentifyInput = z.infer<typeof ProjectIdentifyInput>;
+9
View File
@@ -0,0 +1,9 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "./src",
"outDir": "./dist",
"noEmit": true
},
"include": ["src/**/*.ts"]
}
+4994
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -0,0 +1,3 @@
packages:
- "apps/*"
- "packages/*"
+22
View File
@@ -0,0 +1,22 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": false,
"skipLibCheck": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"declaration": false,
"sourceMap": true
},
"exclude": ["node_modules", "dist", ".next", "build"]
}