Encrypted, ephemeral, private memos on atproto

Compare changes

Choose any two refs to compare.

+36 -1
README.md
··· 11 11 12 12 ## Architecture 13 13 14 - Cistern is a Deno monorepo consisting of five packages: 14 + Cistern is a Deno monorepo consisting of six packages: 15 15 16 16 ### `@cistern/crypto` 17 17 ··· 42 42 locally, retrieves memos via polling or real-time streaming (Jetstream), and 43 43 handles memo deletion after consumption. 44 44 45 + ### `@cistern/mcp` 46 + 47 + Model Context Protocol server that exposes Cistern as MCP tools for AI 48 + assistants. Supports stdio transport for local integrations (Claude Desktop) and 49 + HTTP transport for remote deployments. Automatically generates and persists 50 + keypairs in Deno KV. 51 + 45 52 ## Security Model 46 53 47 54 Private keys never leave the consumer device. Public keys are stored in the PDS 48 55 as records, while private keys remain off-protocol. Only the holder of the 49 56 matching private key can decrypt memos encrypted with the corresponding public 50 57 key. 58 + 59 + ## Quick Start 60 + 61 + ### Using the MCP Server with Claude Desktop 62 + 63 + 1. Generate an [app password](https://bsky.app/settings/app-passwords) for your Bluesky account 64 + 2. Add to Claude Desktop config (`~/Library/Application Support/Claude/claude_desktop_config.json`): 65 + 66 + ```json 67 + { 68 + "mcpServers": { 69 + "cistern": { 70 + "command": "deno", 71 + "args": ["task", "--cwd", "/path/to/cistern/packages/mcp", "stdio"], 72 + "env": { 73 + "CISTERN_MCP_HANDLE": "yourname.bsky.social", 74 + "CISTERN_MCP_APP_PASSWORD": "xxxx-xxxx-xxxx-xxxx" 75 + } 76 + } 77 + } 78 + } 79 + ``` 80 + 81 + 3. Restart Claude Desktop 82 + 4. Create memos using the Cistern Producer from any device 83 + 5. Ask Claude to retrieve and process your memos 84 + 85 + See [`packages/mcp/README.md`](./packages/mcp/README.md) for detailed usage. 51 86 52 87 ## Testing 53 88
+1
deno.jsonc
··· 2 2 "workspace": [ 3 3 "packages/*" 4 4 ], 5 + "unstable": ["kv"], 5 6 "imports": { 6 7 "@std/expect": "jsr:@std/expect@^1.0.17", 7 8 "@atcute/lexicons": "npm:@atcute/lexicons@^1.2.2"
+3 -2
deno.lock
··· 23 23 "npm:@atcute/lexicons@^1.2.2": "1.2.2", 24 24 "npm:@atcute/tid@^1.0.3": "1.0.3", 25 25 "npm:@atproto/lexicon@~0.5.1": "0.5.1", 26 - "npm:@modelcontextprotocol/inspector@*": "0.15.0", 26 + "npm:@modelcontextprotocol/inspector@*": "0.15.0_@types+node@24.2.0", 27 27 "npm:@modelcontextprotocol/sdk@^1.21.1": "1.21.1_ajv@8.17.1_express@5.1.0_zod@3.25.76", 28 + "npm:@types/node@*": "24.2.0", 28 29 "npm:fetch-to-node@^2.1.0": "2.1.0", 29 30 "npm:zod@^3.25.76": "3.25.76" 30 31 }, ··· 272 273 ], 273 274 "bin": true 274 275 }, 275 - "@modelcontextprotocol/inspector@0.15.0": { 276 + "@modelcontextprotocol/inspector@0.15.0_@types+node@24.2.0": { 276 277 "integrity": "sha512-PN1R7InR48Y6wU8s/vHWc0KOYAjlYQkgCpjUQsNFB078ebdv+empkMI6d1Gg+UIRx8mTrwtbBgv0A6ookGG+0w==", 277 278 "dependencies": [ 278 279 "@modelcontextprotocol/inspector-cli",
+6 -1
packages/consumer/client.ts
··· 134 134 }); 135 135 136 136 yield { 137 + key: record.uri.split("/").pop() as RecordKey, 137 138 tid: memo.tid, 138 139 text: decrypted, 139 140 }; ··· 182 183 length: record.contentLength, 183 184 }); 184 185 185 - const command = yield { tid: record.tid, text: decrypted }; 186 + const command = yield { 187 + key: event.commit.rkey, 188 + tid: record.tid, 189 + text: decrypted, 190 + }; 186 191 187 192 if (command === "stop") return; 188 193 }
+1 -1
packages/consumer/deno.jsonc
··· 1 1 { 2 2 "name": "@cistern/consumer", 3 - "version": "1.0.2", 3 + "version": "1.0.3", 4 4 "license": "MIT", 5 5 "exports": { 6 6 ".": "./mod.ts"
+4 -1
packages/consumer/types.ts
··· 1 1 import type { BaseClientOptions, ClientRequirements } from "@cistern/shared"; 2 - import type { ResourceUri, Tid } from "@atcute/lexicons"; 2 + import type { RecordKey, ResourceUri, Tid } from "@atcute/lexicons"; 3 3 4 4 /** 5 5 * A locally-stored key pair suitable for storage ··· 34 34 35 35 /** A simplified, encrypted memo */ 36 36 export interface DecryptedMemo { 37 + /** Record key of this memo */ 38 + key: RecordKey; 39 + 37 40 /** TID for when the memo was created */ 38 41 tid: Tid; 39 42
+1
packages/mcp/.gitignore
··· 1 1 .env 2 + cistern-mcp.db*
+182
packages/mcp/README.md
··· 1 + # @cistern/mcp 2 + 3 + Model Context Protocol (MCP) server for Cistern, enabling AI assistants to retrieve and manage encrypted memos. 4 + 5 + ## Features 6 + 7 + - **Dual Transport Support**: stdio for local integrations (Claude Desktop) and HTTP for remote deployments 8 + - **Automatic Keypair Management**: Generates and persists keypairs in Deno KV on first launch 9 + - **Two MCP Tools**: 10 + - `next_memo`: Retrieve the next outstanding memo 11 + - `delete_memo`: Delete a memo after handling it 12 + 13 + ## Installation 14 + 15 + ### Prerequisites 16 + 17 + - Deno 2.0+ 18 + - AT Protocol account with app password 19 + - Bluesky handle (e.g., `yourname.bsky.social`) 20 + 21 + ### Environment Variables 22 + 23 + **Required:** 24 + ```bash 25 + CISTERN_MCP_HANDLE=yourname.bsky.social 26 + CISTERN_MCP_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx 27 + ``` 28 + 29 + **Optional (for existing keypair):** 30 + ```bash 31 + CISTERN_MCP_PRIVATE_KEY=base64-encoded-private-key 32 + CISTERN_MCP_PUBLIC_KEY_URI=at://did:plc:abc.../app.cistern.pubkey/xyz 33 + ``` 34 + 35 + **Required for HTTP mode:** 36 + ```bash 37 + CISTERN_MCP_BEARER_TOKEN=your-secret-bearer-token 38 + ``` 39 + 40 + ## Usage 41 + 42 + ### stdio Mode (Claude Desktop) 43 + 44 + Run the server in stdio mode for local integrations: 45 + 46 + ```bash 47 + cd packages/mcp 48 + deno task stdio 49 + ``` 50 + 51 + Add to Claude Desktop configuration (`~/Library/Application Support/Claude/claude_desktop_config.json`): 52 + 53 + ```json 54 + { 55 + "mcpServers": { 56 + "cistern": { 57 + "command": "deno", 58 + "args": [ 59 + "task", 60 + "--cwd", 61 + "/path/to/cistern/packages/mcp", 62 + "stdio" 63 + ], 64 + "env": { 65 + "CISTERN_MCP_HANDLE": "yourname.bsky.social", 66 + "CISTERN_MCP_APP_PASSWORD": "xxxx-xxxx-xxxx-xxxx" 67 + } 68 + } 69 + } 70 + } 71 + ``` 72 + 73 + ### HTTP Mode (Remote Deployment) 74 + 75 + Run the server in HTTP mode for remote access: 76 + 77 + ```bash 78 + cd packages/mcp 79 + deno task http 80 + ``` 81 + 82 + The server listens on port 8000 by default. Configure your MCP client to connect via HTTP: 83 + 84 + ```json 85 + { 86 + "url": "http://localhost:8000/mcp", 87 + "headers": { 88 + "Authorization": "Bearer your-secret-bearer-token" 89 + } 90 + } 91 + ``` 92 + 93 + ## Keypair Management 94 + 95 + On first launch without `CISTERN_MCP_PRIVATE_KEY` and `CISTERN_MCP_PUBLIC_KEY_URI`, the server will: 96 + 97 + 1. Check Deno KV for a stored keypair (keyed by handle) 98 + 2. If not found, generate a new X-Wing keypair 99 + 3. Upload the public key to your PDS as an `app.cistern.pubkey` record 100 + 4. Store the keypair in Deno KV at `./cistern-mcp.db` 101 + 5. Log the public key URI for reference 102 + 103 + The keypair persists across restarts and is isolated per handle. 104 + 105 + ### Example First Launch Log 106 + 107 + ``` 108 + [cistern:mcp] starting in stdio mode 109 + [cistern:mcp] no keypair found; generating new keypair for yourname.bsky.social 110 + [cistern:mcp] generated new keypair with public key URI: at://did:plc:abc123.../app.cistern.pubkey/xyz789 111 + [cistern:mcp] stored keypair for yourname.bsky.social 112 + ``` 113 + 114 + ### Example Subsequent Launch Log 115 + 116 + ``` 117 + [cistern:mcp] starting in stdio mode 118 + [cistern:mcp] using stored keypair for yourname.bsky.social 119 + ``` 120 + 121 + ## MCP Tools 122 + 123 + ### `next_memo` 124 + 125 + Retrieves the next outstanding memo from your PDS. 126 + 127 + **Output:** 128 + ```json 129 + { 130 + "key": "3kbxyz789abc", 131 + "tid": "3kbxyz789abc", 132 + "text": "Remember to buy milk" 133 + } 134 + ``` 135 + 136 + Returns `"no memos remaining"` when all memos have been retrieved. 137 + 138 + ### `delete_memo` 139 + 140 + Deletes a memo by record key after it has been handled. 141 + 142 + **Input:** 143 + ```json 144 + { 145 + "key": "3kbxyz789abc" 146 + } 147 + ``` 148 + 149 + **Output:** 150 + ```json 151 + { 152 + "success": true 153 + } 154 + ``` 155 + 156 + ## Development 157 + 158 + ### Testing with MCP Inspector 159 + 160 + ```bash 161 + deno task stdio:inspect 162 + ``` 163 + 164 + This launches the MCP Inspector UI for interactive testing of the stdio server. 165 + 166 + ### Logs 167 + 168 + The server uses LogTape for structured logging: 169 + 170 + - **`[cistern:mcp]`**: Server lifecycle, keypair operations 171 + - **`[cistern:http]`**: HTTP request/response logs (HTTP mode only) 172 + 173 + ## Security 174 + 175 + - **Bearer Authentication**: Required for HTTP mode 176 + - **Private Keys**: Never transmitted; stored locally in Deno KV 177 + - **Session Isolation**: Each HTTP session gets its own Consumer instance 178 + - **CORS**: Configured for MCP protocol headers 179 + 180 + ## Limitations 181 + 182 + - **No Keypair Deletion**: The Consumer SDK doesn't currently support deleting public keys from the PDS. If you want to use a different keypair, you can either set `CISTERN_MCP_PRIVATE_KEY` and `CISTERN_MCP_PUBLIC_KEY_URI` environment variables, or delete the `cistern-mcp.db` SQLite files to force regeneration. You'll need to manually delete the old public key record from your PDS using a tool like [pdsls.dev](https://pdsls.dev).
+3 -1
packages/mcp/deno.jsonc
··· 13 13 }, 14 14 "permissions": { 15 15 "default": { 16 - "env": true 16 + "env": true, 17 + "read": ["./cistern-mcp.db"], 18 + "write": ["./cistern-mcp.db"] 17 19 } 18 20 }, 19 21 "imports": {
+26 -19
packages/mcp/hono.ts
··· 1 1 import { Hono } from "hono"; 2 2 import { cors } from "hono/cors"; 3 - import { createConsumer } from "@cistern/consumer"; 3 + import { bearerAuth } from "hono/bearer-auth"; 4 4 import { getLogger, withContext } from "@logtape/logtape"; 5 5 import { toFetchResponse, toReqRes } from "fetch-to-node"; 6 6 import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; ··· 12 12 const logger = getLogger(["cistern", "http"]); 13 13 const sessions = new Map<string, StreamableHTTPServerTransport>(); 14 14 15 + const token = Deno.env.get("CISTERN_MCP_BEARER_TOKEN"); 16 + 17 + if (!token) { 18 + logger.error("http mode requires CISTERN_MCP_BEARER_TOKEN to be set"); 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 })); 15 38 app.use("*", async (c, next) => { 16 39 const requestId = crypto.randomUUID(); 17 40 const startTime = Date.now(); ··· 43 66 }); 44 67 45 68 app.onError((err, c) => { 46 - logger.error("request error", { 69 + logger.error("request error {error}", { 47 70 error: { 48 71 name: err.name, 49 72 message: err.message, ··· 56 79 return c.json({ error: "internal server error" }, 500); 57 80 }); 58 81 59 - app.all( 60 - "/mcp", 61 - cors({ 62 - origin: "*", 63 - allowMethods: ["GET", "POST", "DELETE", "OPTIONS"], 64 - allowHeaders: [ 65 - "Content-Type", 66 - "Authorization", 67 - "Mcp-Session-Id", 68 - "Mcp-Protocol-Version", 69 - ], 70 - exposeHeaders: ["Mcp-Session-Id"], 71 - }), 72 - ); 73 - 74 82 app.post("/mcp", async (ctx) => { 75 83 const sessionId = ctx.req.header("mcp-session-id") ?? crypto.randomUUID(); 76 84 let session = sessions.get(sessionId); ··· 81 89 logger.info("creating new session {sessionId}", { sessionId }); 82 90 83 91 const options = collectOptions(); 84 - const consumer = await createConsumer(options); 85 - const server = createServer(consumer); 92 + const server = await createServer(options); 86 93 87 94 session = new StreamableHTTPServerTransport({ 88 95 sessionIdGenerator: () => sessionId,
+1 -3
packages/mcp/index.ts
··· 1 1 import { parseArgs } from "@std/cli"; 2 - import { createConsumer } from "@cistern/consumer"; 3 2 import { AsyncLocalStorage } from "node:async_hooks"; 4 3 import { configure, getConsoleSink, getLogger } from "@logtape/logtape"; 5 4 import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; ··· 35 34 logger.info("starting in stdio mode"); 36 35 37 36 const options = collectOptions(); 38 - const consumer = await createConsumer(options); 37 + const server = await createServer(options); 39 38 const transport = new StdioServerTransport(); 40 - const server = createServer(consumer); 41 39 42 40 await server.connect(transport); 43 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 + }
+51 -4
packages/mcp/server.ts
··· 1 1 import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2 2 import { getLogger } from "@logtape/logtape"; 3 3 import { z } from "zod"; 4 - import type { Consumer, DecryptedMemo } from "@cistern/consumer"; 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 + } 5 46 6 - export function createServer(consumer: Consumer) { 47 + return _createServerWithConsumer(consumer); 48 + } 49 + 50 + function _createServerWithConsumer(consumer: Consumer) { 7 51 const logger = getLogger("cistern-mcp"); 8 52 const server = new McpServer({ 9 53 name: "cistern-mcp", ··· 19 63 { 20 64 title: "Next memo", 21 65 description: "Retrieve the next outstanding memo", 22 - outputSchema: { tid: z.string(), text: z.string() }, 66 + outputSchema: { key: z.string(), tid: z.string(), text: z.string() }, 23 67 }, 24 68 async () => { 25 69 if (!iterator) { ··· 37 81 return { 38 82 content: [{ 39 83 type: "text", 40 - text: res.value?.text ?? "no memos remaining", 84 + text: res.value?.text 85 + ? `key: ${res.value.key}, text: ${res.value.text}` 86 + : "no memos remaining", 41 87 }], 42 88 structuredContent: { 89 + key: res.value?.key ?? "", 43 90 tid: res.value?.tid ?? "", 44 91 text: res.value?.text ?? "no memos remaining", 45 92 },