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