diff --git a/app/api/mcp/route.ts b/app/api/mcp/route.ts index 25efe8b..98d32a7 100644 --- a/app/api/mcp/route.ts +++ b/app/api/mcp/route.ts @@ -5,6 +5,7 @@ import { resolvePandoraMcpPrincipal } from "@/lib/services/mcp-auth"; import { pandoraMcpPublicOrigin } from "@/lib/services/mcp-oauth"; import { createPandoraMcpServer } from "@/lib/services/pandora-mcp-server"; import type { MemoryBridgeDbClient } from "@/lib/services/memory-bridge-service"; +import { getPandoraMcpDbKey, getPandoraSupabaseUrl } from "@/lib/services/pandora-mcp-env"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; @@ -27,7 +28,11 @@ function jsonError(failure: Exclude = process.env): PandoraMcpPrincipal { if (env.PANDORA_ENABLE_MCP !== "true") return { ok: false, status: 403, code: "mcp_disabled", message: "Pandora MCP is disabled." }; - const configuredToken = env.PANDORA_MCP_TOKEN; + const configuredToken = getPandoraMcpBearerSecret(env); const suppliedToken = bearerToken(request); - if (!configuredToken || !suppliedToken) return { ok: false, status: 401, code: "mcp_token_missing", message: "MCP bearer token is required." }; - if (safeEqual(suppliedToken, configuredToken)) { - if (!env.PANDORA_MCP_USER_ID) return { ok: false, status: 403, code: "mcp_user_id_missing", message: "Pandora MCP user id is not configured." }; - if (!env.PANDORA_MCP_DB_KEY) return { ok: false, status: 403, code: "mcp_db_key_missing", message: "Pandora MCP database key is not configured." }; + if (!configuredToken.ok) return { ok: false, status: 403, code: "mcp_token_env_missing", message: configuredToken.message }; + if (!suppliedToken) return { ok: false, status: 401, code: "mcp_token_missing", message: "MCP bearer token is required." }; + if (safeEqual(suppliedToken, configuredToken.value)) { + if (!env.PANDORA_MCP_USER_ID) return { ok: false, status: 403, code: "mcp_user_id_missing", message: "Missing server env: PANDORA_MCP_USER_ID" }; + const dbKey = getPandoraMcpDbKey(env); + if (!dbKey.ok) return { ok: false, status: 403, code: "mcp_db_key_missing", message: dbKey.message }; return { ok: true, authType: "mcp_bearer_token", userId: env.PANDORA_MCP_USER_ID }; } const oauth = verifyPandoraMcpOAuthAccessToken(suppliedToken, env); if (!oauth.ok) return { ok: false, status: 401, code: "mcp_token_invalid", message: "MCP bearer token is invalid." }; - if (!env.PANDORA_MCP_USER_ID) return { ok: false, status: 403, code: "mcp_user_id_missing", message: "Pandora MCP user id is not configured." }; - if (!env.PANDORA_MCP_DB_KEY) return { ok: false, status: 403, code: "mcp_db_key_missing", message: "Pandora MCP database key is not configured." }; + if (!env.PANDORA_MCP_USER_ID) return { ok: false, status: 403, code: "mcp_user_id_missing", message: "Missing server env: PANDORA_MCP_USER_ID" }; + const dbKey = getPandoraMcpDbKey(env); + if (!dbKey.ok) return { ok: false, status: 403, code: "mcp_db_key_missing", message: dbKey.message }; return { ok: true, authType: "mcp_oauth_access_token", userId: oauth.payload.user_id }; } @@ -43,11 +47,11 @@ export function requirePandoraMcpPrincipal(request: Request, env: Partial = process.env) { - if (env.PANDORA_ENABLE_MCP_CAPTURE !== "true") return { ok: false as const, status: 403 as const, code: "mcp_capture_disabled", message: "Pandora MCP capture is disabled." }; + if (env.PANDORA_ENABLE_MCP_CAPTURE !== "true") return { ok: false as const, status: 403 as const, code: "mcp_capture_disabled", message: "capture_disabled: PANDORA_ENABLE_MCP_CAPTURE is not true" }; return { ok: true as const }; } export function requireMcpDistillationEnabled(env: Partial = process.env) { - if (env.PANDORA_ENABLE_MCP_DISTILLATION !== "true") return { ok: false as const, status: 403 as const, code: "mcp_distillation_disabled", message: "Pandora MCP distillation is disabled." }; + if (env.PANDORA_ENABLE_MCP_DISTILLATION !== "true") return { ok: false as const, status: 403 as const, code: "mcp_distillation_disabled", message: "distillation_disabled: PANDORA_ENABLE_MCP_DISTILLATION is not true" }; return { ok: true as const }; } diff --git a/lib/services/pandora-mcp-env.ts b/lib/services/pandora-mcp-env.ts new file mode 100644 index 0000000..a22be09 --- /dev/null +++ b/lib/services/pandora-mcp-env.ts @@ -0,0 +1,23 @@ +export type PandoraMcpEnvStatus = { ok: true; value: string; envVar: string } | { ok: false; envVar: string; aliases: string[]; message: string }; + +function firstPresent(env: Partial, names: string[]): PandoraMcpEnvStatus { + const found = names.find((name) => Boolean(env[name])); + if (found) return { ok: true, value: String(env[found]), envVar: found }; + return { ok: false, envVar: names[0], aliases: names.slice(1), message: `Missing server env: ${names[0]}` }; +} + +export function getPandoraMcpBearerSecret(env: Partial = process.env) { + return firstPresent(env, ["PANDORA_MCP_TOKEN", "PANDORA_MCP_API_KEY", "PANDORA_API_KEY", "MEMORY_API_KEY"]); +} + +export function getPandoraMcpDbKey(env: Partial = process.env) { + return firstPresent(env, ["PANDORA_MCP_DB_KEY", "PANDORA_MEMORY_BRIDGE_DB_KEY", "SUPABASE_SERVICE_ROLE_KEY"]); +} + +export function getPandoraSupabaseUrl(env: Partial = process.env) { + return firstPresent(env, ["NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_URL"]); +} + +export function presentEnvName(env: Partial, names: string[]) { + return names.find((name) => Boolean(env[name])) ?? null; +} diff --git a/package.json b/package.json index cc6e7ef..3f7ce7a 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,9 @@ "db:push": "supabase db push --dry-run", "db:pull": "supabase db pull --schema public", "verify:first-reviewed-memory-fixture": "npx --yes tsx scripts/verify-first-reviewed-memory-fixture.ts", - "env:policy": "npx --yes tsx scripts/check-env-policy.ts" + "env:policy": "npx --yes tsx scripts/check-env-policy.ts", + "memory:smoke": "npx --yes tsx scripts/memory-smoke.ts", + "memory:diagnostics": "npx --yes tsx scripts/memory-smoke.ts" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", diff --git a/scripts/memory-smoke.ts b/scripts/memory-smoke.ts new file mode 100644 index 0000000..57afcb6 --- /dev/null +++ b/scripts/memory-smoke.ts @@ -0,0 +1,37 @@ +import { createClient } from "@supabase/supabase-js"; +import { getPandoraMcpDbKey, getPandoraSupabaseUrl, presentEnvName } from "../lib/services/pandora-mcp-env"; +import { analyzeMemoryCandidatesTool, captureAdaptiveMemoryTool, captureMemoryEventTool, createSessionDigestTool, distillContextPackTool, getAdaptiveContextTool, getLatestContextPackTool, getMemoryContextTool, getOpenLoopsTool } from "../lib/services/pandora-mcp-tools"; +import type { MemoryBridgeDbClient } from "../lib/services/memory-bridge-service"; + +const seed = "Creative workflow preference: preserve continuity and user feedback for future writing."; +const ns = (process.env.PANDORA_MEMORY_SMOKE_NAMESPACE === "au" ? "au" : "real_life") as "real_life" | "au"; +const userId = process.env.PANDORA_MCP_USER_ID ?? process.env.PANDORA_MEMORY_BRIDGE_USER_ID ?? ""; +const env = process.env; +const required = ["PANDORA_ENABLE_MCP", "PANDORA_ENABLE_MCP_CAPTURE", "PANDORA_ENABLE_MCP_DISTILLATION", "PANDORA_ENABLE_EMBEDDINGS", "PANDORA_ENABLE_MODEL_CALLS", "PANDORA_ENABLE_SEMANTIC_RETRIEVAL", "OPENAI_API_KEY", "EMBEDDING_MODEL", "LLM_MODEL", "PANDORA_EMBEDDING_MODEL", "PANDORA_MEMORY_EMBEDDING_MODEL"]; +const aliases = { apiKey: ["PANDORA_MCP_TOKEN", "PANDORA_MCP_API_KEY", "PANDORA_API_KEY", "MEMORY_API_KEY"], dbKey: ["PANDORA_MCP_DB_KEY", "PANDORA_MEMORY_BRIDGE_DB_KEY", "SUPABASE_SERVICE_ROLE_KEY"], url: ["NEXT_PUBLIC_SUPABASE_URL", "SUPABASE_URL"], anon: ["NEXT_PUBLIC_SUPABASE_ANON_KEY", "SUPABASE_ANON_KEY", "NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY"] }; +function flag(name: string) { return env[name] === "true" ? "enabled" : "disabled"; } +function out(name: string, ok: boolean, detail = "") { console.log(`${ok ? "PASS" : "FAIL"} ${name}${detail ? ` - ${detail}` : ""}`); if (!ok) process.exitCode = 1; } +async function step(name: string, fn: () => Promise, optional = false) { try { const r = await fn(); const text = JSON.stringify(r); const blocked = text.includes('"ok":false') || text.includes('Invalid API key'); out(name, optional || !blocked, text.slice(0, 500)); } catch (e) { out(name, false, e instanceof Error ? e.message : String(e)); } } +async function main() { + console.log("Pandora memory diagnostics (secret values redacted)"); + for (const k of required) console.log(`ENV ${k}: ${env[k] ? "present" : "missing"}${k.startsWith("PANDORA_ENABLE") ? ` (${flag(k)})` : ""}`); + for (const [label, names] of Object.entries(aliases)) console.log(`ENV ${label}: ${presentEnvName(env, names) ?? "missing"}`); + console.log(`provider: ${env.OPENAI_API_KEY ? "openai_configured" : "deterministic/no_provider"}`); + console.log(`distillation: ${flag("PANDORA_ENABLE_MCP_DISTILLATION")}; embeddings: ${flag("PANDORA_ENABLE_EMBEDDINGS")}; model_calls: ${flag("PANDORA_ENABLE_MODEL_CALLS")}`); + const url = getPandoraSupabaseUrl(env); const db = getPandoraMcpDbKey(env); + if (!url.ok || !db.ok || !userId) { out("bootstrap", false, [!url.ok && url.message, !db.ok && db.message, !userId && "Missing server env: PANDORA_MCP_USER_ID or PANDORA_MEMORY_BRIDGE_USER_ID"].filter(Boolean).join("; ")); return; } + const client = createClient(url.value, db.value, { auth: { autoRefreshToken: false, persistSession: false } }) as unknown as MemoryBridgeDbClient; + const principal = { ok: true as const, authType: "mcp_bearer_token" as const, userId }; + await step("supabase connection/service-role memory table read", async () => client.from("memory_events").select("id").eq("user_id", userId).eq("namespace", ns).limit(1)); + await step("analyze_memory_candidates", async () => analyzeMemoryCandidatesTool(client, principal, { namespace: ns, text: seed, source: "memory_smoke", mode: "candidate_only" }, env)); + await step("capture_adaptive_memory candidate_only", async () => captureAdaptiveMemoryTool(client, principal, { namespace: ns, text: seed, source: "memory_smoke", mode: "candidate_only" }, env)); + await step("capture_adaptive_memory auto_capture_allowed", async () => captureAdaptiveMemoryTool(client, principal, { namespace: ns, text: seed, source: "memory_smoke", mode: "auto_capture_allowed" }, env)); + await step("capture_memory_event", async () => captureMemoryEventTool(client, principal, { namespace: ns, raw_text: seed, source: "memory_smoke", sensitivity: "low" }, env), env.PANDORA_ENABLE_MCP_CAPTURE !== "true"); + await step("get_memory_context", async () => getMemoryContextTool(client, principal, { namespace: ns, query: seed, max_items: 5 })); + await step("get_adaptive_context", async () => getAdaptiveContextTool(client, principal, { namespace: ns, query: seed, max_items: 5 })); + await step("create_session_digest", async () => createSessionDigestTool(client, principal, { namespace: ns, source: "memory_smoke", transcript_or_summary: seed }, env), env.PANDORA_ENABLE_MCP_CAPTURE !== "true" || env.PANDORA_ENABLE_MCP_DISTILLATION !== "true"); + await step("get_latest_context_pack", async () => getLatestContextPackTool(client, principal, { namespace: ns })); + await step("distill master context pack", async () => distillContextPackTool(client, principal, { namespace: ns, pack_type: "master" }, env), env.PANDORA_ENABLE_MCP_DISTILLATION !== "true"); + await step("retrieve open loops", async () => getOpenLoopsTool(client, principal, { namespace: ns })); +} +main().catch((e) => { out("memory smoke", false, e instanceof Error ? e.message : String(e)); }); diff --git a/tests/unit/pandora-mcp-env.test.ts b/tests/unit/pandora-mcp-env.test.ts new file mode 100644 index 0000000..74a2b0f --- /dev/null +++ b/tests/unit/pandora-mcp-env.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { getPandoraMcpBearerSecret, getPandoraMcpDbKey, getPandoraSupabaseUrl } from "@/lib/services/pandora-mcp-env"; +import { resolvePandoraMcpPrincipal } from "@/lib/services/mcp-auth"; + +function req(token: string) { return new Request("https://example.com/api/mcp", { headers: { authorization: `Bearer ${token}` } }); } + +describe("Pandora MCP environment aliases", () => { + it("accepts deployed API key aliases and service-role db key aliases", () => { + expect(getPandoraMcpBearerSecret({ PANDORA_MCP_API_KEY: "tool-key" })).toMatchObject({ ok: true, envVar: "PANDORA_MCP_API_KEY" }); + expect(getPandoraMcpDbKey({ SUPABASE_SERVICE_ROLE_KEY: "service-role" })).toMatchObject({ ok: true, envVar: "SUPABASE_SERVICE_ROLE_KEY" }); + expect(getPandoraSupabaseUrl({ SUPABASE_URL: "https://example.supabase.co" })).toMatchObject({ ok: true, envVar: "SUPABASE_URL" }); + }); + + it("reports a missing server env instead of generic invalid API key when no MCP token is configured", () => { + const result = resolvePandoraMcpPrincipal(req("tool-key"), { PANDORA_ENABLE_MCP: "true", PANDORA_MCP_USER_ID: "user-1", SUPABASE_SERVICE_ROLE_KEY: "service-role" }); + expect(result).toMatchObject({ ok: false, code: "mcp_token_env_missing", message: "Missing server env: PANDORA_MCP_TOKEN" }); + }); + + it("reports disabled capture and distillation with explicit env names", async () => { + const { requireMcpCaptureEnabled, requireMcpDistillationEnabled } = await import("@/lib/services/mcp-auth"); + expect(requireMcpCaptureEnabled({ PANDORA_ENABLE_MCP_CAPTURE: "false" }).message).toBe("capture_disabled: PANDORA_ENABLE_MCP_CAPTURE is not true"); + expect(requireMcpDistillationEnabled({ PANDORA_ENABLE_MCP_DISTILLATION: "false" }).message).toBe("distillation_disabled: PANDORA_ENABLE_MCP_DISTILLATION is not true"); + }); +});