Encrypted, ephemeral, private memos on atproto

feat(mcp): create key pair if not provided

stores the generated keypair in Deno KV for future launches

graham.systems a69cdd48 1fd4fe70

verified
Changed files
+115 -26
packages
+1
deno.jsonc
··· 2 "workspace": [ 3 "packages/*" 4 ], 5 "imports": { 6 "@std/expect": "jsr:@std/expect@^1.0.17", 7 "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2"
··· 2 "workspace": [ 3 "packages/*" 4 ], 5 + "unstable": ["kv"], 6 "imports": { 7 "@std/expect": "jsr:@std/expect@^1.0.17", 8 "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2"
+1
packages/mcp/.gitignore
··· 1 .env
··· 1 .env 2 + cistern-mcp.db*
+3 -1
packages/mcp/deno.jsonc
··· 13 }, 14 "permissions": { 15 "default": { 16 - "env": true 17 } 18 }, 19 "imports": {
··· 13 }, 14 "permissions": { 15 "default": { 16 + "env": true, 17 + "read": ["./cistern-mcp.db"], 18 + "write": ["./cistern-mcp.db"] 19 } 20 }, 21 "imports": {
+18 -20
packages/mcp/hono.ts
··· 1 import { Hono } from "hono"; 2 import { cors } from "hono/cors"; 3 import { bearerAuth } from "hono/bearer-auth"; 4 - import { createConsumer } from "@cistern/consumer"; 5 import { getLogger, withContext } from "@logtape/logtape"; 6 import { toFetchResponse, toReqRes } from "fetch-to-node"; 7 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; ··· 20 return Deno.exit(1); 21 } 22 23 - app.use("*", bearerAuth({ token })); 24 app.use("*", async (c, next) => { 25 const requestId = crypto.randomUUID(); 26 const startTime = Date.now(); ··· 52 }); 53 54 app.onError((err, c) => { 55 - logger.error("request error", { 56 error: { 57 name: err.name, 58 message: err.message, ··· 65 return c.json({ error: "internal server error" }, 500); 66 }); 67 68 - app.all( 69 - "/mcp", 70 - cors({ 71 - origin: "*", 72 - allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], 73 - allowHeaders: [ 74 - "Content-Type", 75 - "Authorization", 76 - "Mcp-Session-Id", 77 - "Mcp-Protocol-Version", 78 - ], 79 - exposeHeaders: ["Mcp-Session-Id"], 80 - }), 81 - ); 82 - 83 app.post("/mcp", async (ctx) => { 84 const sessionId = ctx.req.header("mcp-session-id") ?? crypto.randomUUID(); 85 let session = sessions.get(sessionId); ··· 90 logger.info("creating new session {sessionId}", { sessionId }); 91 92 const options = collectOptions(); 93 - const consumer = await createConsumer(options); 94 - const server = createServer(consumer); 95 96 session = new StreamableHTTPServerTransport({ 97 sessionIdGenerator: () => sessionId,
··· 1 import { Hono } from "hono"; 2 import { cors } from "hono/cors"; 3 import { bearerAuth } from "hono/bearer-auth"; 4 import { getLogger, withContext } from "@logtape/logtape"; 5 import { toFetchResponse, toReqRes } from "fetch-to-node"; 6 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; ··· 19 return Deno.exit(1); 20 } 21 22 + app.all( 23 + "/mcp", 24 + cors({ 25 + origin: "*", 26 + allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], 27 + allowHeaders: [ 28 + "Content-Type", 29 + "Authorization", 30 + "Mcp-Session-Id", 31 + "Mcp-Protocol-Version", 32 + ], 33 + exposeHeaders: ["Mcp-Session-Id"], 34 + }), 35 + ); 36 + 37 + app.use("/mcp", bearerAuth({ token })); 38 app.use("*", async (c, next) => { 39 const requestId = crypto.randomUUID(); 40 const startTime = Date.now(); ··· 66 }); 67 68 app.onError((err, c) => { 69 + logger.error("request error {error}", { 70 error: { 71 name: err.name, 72 message: err.message, ··· 79 return c.json({ error: "internal server error" }, 500); 80 }); 81 82 app.post("/mcp", async (ctx) => { 83 const sessionId = ctx.req.header("mcp-session-id") ?? crypto.randomUUID(); 84 let session = sessions.get(sessionId); ··· 89 logger.info("creating new session {sessionId}", { sessionId }); 90 91 const options = collectOptions(); 92 + const server = await createServer(options); 93 94 session = new StreamableHTTPServerTransport({ 95 sessionIdGenerator: () => sessionId,
+1 -3
packages/mcp/index.ts
··· 1 import { parseArgs } from "@std/cli"; 2 - import { createConsumer } from "@cistern/consumer"; 3 import { AsyncLocalStorage } from "node:async_hooks"; 4 import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; 5 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; ··· 35 logger.info("starting in stdio mode"); 36 37 const options = collectOptions(); 38 - const consumer = await createConsumer(options); 39 const transport = new StdioServerTransport(); 40 - const server = createServer(consumer); 41 42 await server.connect(transport); 43 } else {
··· 1 import { parseArgs } from "@std/cli"; 2 import { AsyncLocalStorage } from "node:async_hooks"; 3 import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; 4 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; ··· 34 logger.info("starting in stdio mode"); 35 36 const options = collectOptions(); 37 + const server = await createServer(options); 38 const transport = new StdioServerTransport(); 39 40 await server.connect(transport); 41 } else {
+45
packages/mcp/kv.ts
···
··· 1 + import type { InputLocalKeyPair } from "@cistern/consumer"; 2 + import { getLogger } from "@logtape/logtape"; 3 + 4 + const KV_PATH = "./cistern-mcp.db"; 5 + 6 + let kv: Deno.Kv | undefined; 7 + 8 + async function getKv(): Promise<Deno.Kv> { 9 + if (!kv) { 10 + kv = await Deno.openKv(KV_PATH); 11 + } 12 + return kv; 13 + } 14 + 15 + export async function getStoredKeypair( 16 + handle: string, 17 + ): Promise<InputLocalKeyPair | null> { 18 + const logger = getLogger(["cistern", "mcp"]); 19 + const db = await getKv(); 20 + const result = await db.get<InputLocalKeyPair>([ 21 + "cistern", 22 + "keypairs", 23 + handle, 24 + ]); 25 + 26 + if (result.value) { 27 + logger.debug("found stored keypair for {handle}", { handle }); 28 + return result.value; 29 + } 30 + 31 + logger.debug("no stored keypair found for {handle}", { handle }); 32 + return null; 33 + } 34 + 35 + export async function storeKeypair( 36 + handle: string, 37 + keypair: InputLocalKeyPair, 38 + ): Promise<void> { 39 + const logger = getLogger(["cistern", "mcp"]); 40 + const db = await getKv(); 41 + 42 + await db.set(["cistern", "keypairs", handle], keypair); 43 + 44 + logger.info("stored keypair for {handle}", { handle }); 45 + }
+46 -2
packages/mcp/server.ts
··· 1 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 import { getLogger } from "@logtape/logtape"; 3 import { z } from "zod"; 4 - import type { Consumer, DecryptedMemo } from "@cistern/consumer"; 5 6 - export function createServer(consumer: Consumer) { 7 const logger = getLogger("cistern-mcp"); 8 const server = new McpServer({ 9 name: "cistern-mcp",
··· 1 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 import { getLogger } from "@logtape/logtape"; 3 import { z } from "zod"; 4 + import type { 5 + Consumer, 6 + ConsumerOptions, 7 + DecryptedMemo, 8 + } from "@cistern/consumer"; 9 + import { createConsumer } from "@cistern/consumer"; 10 + import { serializeKey } from "@cistern/crypto"; 11 + import { getStoredKeypair, storeKeypair } from "./kv.ts"; 12 13 + export async function createServer(options: ConsumerOptions) { 14 + const logger = getLogger(["cistern", "mcp"]); 15 + 16 + if (!options.keypair) { 17 + const storedKeypair = await getStoredKeypair(options.handle); 18 + if (storedKeypair) { 19 + logger.info("using stored keypair for {handle}", { 20 + handle: options.handle, 21 + }); 22 + options.keypair = storedKeypair; 23 + } 24 + } else { 25 + logger.info("using keypair from environment variables"); 26 + } 27 + 28 + const consumer = await createConsumer(options); 29 + 30 + if (!consumer.keypair) { 31 + logger.info("no keypair found; generating new keypair for {handle}", { 32 + handle: options.handle, 33 + }); 34 + 35 + const keypair = await consumer.generateKeyPair(); 36 + 37 + logger.info("generated new keypair with public key URI: {publicKey}", { 38 + publicKey: keypair.publicKey, 39 + }); 40 + 41 + await storeKeypair(options.handle, { 42 + privateKey: serializeKey(keypair.privateKey), 43 + publicKey: keypair.publicKey, 44 + }); 45 + } 46 + 47 + return _createServerWithConsumer(consumer); 48 + } 49 + 50 + function _createServerWithConsumer(consumer: Consumer) { 51 const logger = getLogger("cistern-mcp"); 52 const server = new McpServer({ 53 name: "cistern-mcp",