diff --git a/deno.jsonc b/deno.jsonc index 1f45644..ae342bc 100644 --- a/deno.jsonc +++ b/deno.jsonc @@ -2,6 +2,7 @@ "workspace": [ "packages/*" ], + "unstable": ["kv"], "imports": { "@std/expect": "jsr:@std/expect@^1.0.17", "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2" diff --git a/packages/mcp/.gitignore b/packages/mcp/.gitignore index 4c49bd7..d0e5570 100644 --- a/packages/mcp/.gitignore +++ b/packages/mcp/.gitignore @@ -1 +1,2 @@ .env +cistern-mcp.db* diff --git a/packages/mcp/deno.jsonc b/packages/mcp/deno.jsonc index 8eda6dc..a4a56b0 100644 --- a/packages/mcp/deno.jsonc +++ b/packages/mcp/deno.jsonc @@ -13,7 +13,9 @@ }, "permissions": { "default": { - "env": true + "env": true, + "read": ["./cistern-mcp.db"], + "write": ["./cistern-mcp.db"] } }, "imports": { diff --git a/packages/mcp/hono.ts b/packages/mcp/hono.ts index 378eb9e..bfd48c3 100644 --- a/packages/mcp/hono.ts +++ b/packages/mcp/hono.ts @@ -1,7 +1,6 @@ import { Hono } from "hono"; import { cors } from "hono/cors"; import { bearerAuth } from "hono/bearer-auth"; -import { createConsumer } from "@cistern/consumer"; import { getLogger, withContext } from "@logtape/logtape"; import { toFetchResponse, toReqRes } from "fetch-to-node"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; @@ -20,7 +19,22 @@ export function createApp() { return Deno.exit(1); } - app.use("*", bearerAuth({ token })); + app.all( + "/mcp", + cors({ + origin: "*", + allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], + allowHeaders: [ + "Content-Type", + "Authorization", + "Mcp-Session-Id", + "Mcp-Protocol-Version", + ], + exposeHeaders: ["Mcp-Session-Id"], + }), + ); + + app.use("/mcp", bearerAuth({ token })); app.use("*", async (c, next) => { const requestId = crypto.randomUUID(); const startTime = Date.now(); @@ -52,7 +66,7 @@ export function createApp() { }); app.onError((err, c) => { - logger.error("request error", { + logger.error("request error {error}", { error: { name: err.name, message: err.message, @@ -65,21 +79,6 @@ export function createApp() { return c.json({ error: "internal server error" }, 500); }); - app.all( - "/mcp", - cors({ - origin: "*", - allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], - allowHeaders: [ - "Content-Type", - "Authorization", - "Mcp-Session-Id", - "Mcp-Protocol-Version", - ], - exposeHeaders: ["Mcp-Session-Id"], - }), - ); - app.post("/mcp", async (ctx) => { const sessionId = ctx.req.header("mcp-session-id") ?? crypto.randomUUID(); let session = sessions.get(sessionId); @@ -90,8 +89,7 @@ export function createApp() { logger.info("creating new session {sessionId}", { sessionId }); const options = collectOptions(); - const consumer = await createConsumer(options); - const server = createServer(consumer); + const server = await createServer(options); session = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId, diff --git a/packages/mcp/index.ts b/packages/mcp/index.ts index 0e9a0c3..443d37a 100644 --- a/packages/mcp/index.ts +++ b/packages/mcp/index.ts @@ -1,5 +1,4 @@ import { parseArgs } from "@std/cli"; -import { createConsumer } from "@cistern/consumer"; import { AsyncLocalStorage } from "node:async_hooks"; import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; @@ -35,9 +34,8 @@ async function main() { logger.info("starting in stdio mode"); const options = collectOptions(); - const consumer = await createConsumer(options); + const server = await createServer(options); const transport = new StdioServerTransport(); - const server = createServer(consumer); await server.connect(transport); } else { diff --git a/packages/mcp/kv.ts b/packages/mcp/kv.ts new file mode 100644 index 0000000..83147a2 --- /dev/null +++ b/packages/mcp/kv.ts @@ -0,0 +1,45 @@ +import type { InputLocalKeyPair } from "@cistern/consumer"; +import { getLogger } from "@logtape/logtape"; + +const KV_PATH = "./cistern-mcp.db"; + +let kv: Deno.Kv | undefined; + +async function getKv(): Promise { + if (!kv) { + kv = await Deno.openKv(KV_PATH); + } + return kv; +} + +export async function getStoredKeypair( + handle: string, +): Promise { + const logger = getLogger(["cistern", "mcp"]); + const db = await getKv(); + const result = await db.get([ + "cistern", + "keypairs", + handle, + ]); + + if (result.value) { + logger.debug("found stored keypair for {handle}", { handle }); + return result.value; + } + + logger.debug("no stored keypair found for {handle}", { handle }); + return null; +} + +export async function storeKeypair( + handle: string, + keypair: InputLocalKeyPair, +): Promise { + const logger = getLogger(["cistern", "mcp"]); + const db = await getKv(); + + await db.set(["cistern", "keypairs", handle], keypair); + + logger.info("stored keypair for {handle}", { handle }); +} diff --git a/packages/mcp/server.ts b/packages/mcp/server.ts index eb7459d..8e01870 100644 --- a/packages/mcp/server.ts +++ b/packages/mcp/server.ts @@ -1,9 +1,53 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getLogger } from "@logtape/logtape"; import { z } from "zod"; -import type { Consumer, DecryptedMemo } from "@cistern/consumer"; +import type { + Consumer, + ConsumerOptions, + DecryptedMemo, +} from "@cistern/consumer"; +import { createConsumer } from "@cistern/consumer"; +import { serializeKey } from "@cistern/crypto"; +import { getStoredKeypair, storeKeypair } from "./kv.ts"; -export function createServer(consumer: Consumer) { +export async function createServer(options: ConsumerOptions) { + const logger = getLogger(["cistern", "mcp"]); + + if (!options.keypair) { + const storedKeypair = await getStoredKeypair(options.handle); + if (storedKeypair) { + logger.info("using stored keypair for {handle}", { + handle: options.handle, + }); + options.keypair = storedKeypair; + } + } else { + logger.info("using keypair from environment variables"); + } + + const consumer = await createConsumer(options); + + if (!consumer.keypair) { + logger.info("no keypair found; generating new keypair for {handle}", { + handle: options.handle, + }); + + const keypair = await consumer.generateKeyPair(); + + logger.info("generated new keypair with public key URI: {publicKey}", { + publicKey: keypair.publicKey, + }); + + await storeKeypair(options.handle, { + privateKey: serializeKey(keypair.privateKey), + publicKey: keypair.publicKey, + }); + } + + return _createServerWithConsumer(consumer); +} + +function _createServerWithConsumer(consumer: Consumer) { const logger = getLogger("cistern-mcp"); const server = new McpServer({ name: "cistern-mcp",