+36
-1
README.md
+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
+1
deno.jsonc
+3
-2
deno.lock
+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
+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
packages/consumer/deno.jsonc
+4
-1
packages/consumer/types.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
+182
packages/mcp/README.md
+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
+3
-1
packages/mcp/deno.jsonc
+26
-19
packages/mcp/hono.ts
+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
-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
+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
+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
},