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>
133 lines
4.0 KiB
TypeScript
133 lines
4.0 KiB
TypeScript
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);
|
|
}
|
|
}
|