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,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]),
|
||||
);
|
||||
Reference in New Issue
Block a user