+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).
+5
-3
packages/mcp/deno.jsonc
+5
-3
packages/mcp/deno.jsonc
···
7
7
},
8
8
"tasks": {
9
9
"inspector": "npx @modelcontextprotocol/inspector",
10
-
"http": "deno -P --allow-net ./index.ts --http",
11
-
"stdio": "deno -P ./index.ts",
10
+
"http": "deno -P --allow-net --env-file ./index.ts --http",
11
+
"stdio": "deno -P --env-file ./index.ts",
12
12
"stdio:inspect": "npx @modelcontextprotocol/inspector deno task stdio"
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": {
+29
packages/mcp/env.ts
+29
packages/mcp/env.ts
···
1
+
import { getLogger } from "@logtape/logtape";
2
+
import type { ConsumerOptions } from "@cistern/consumer";
3
+
4
+
export function collectOptions(): ConsumerOptions {
5
+
const logger = getLogger(["cistern", "mcp"]);
6
+
const handle = Deno.env.get("CISTERN_MCP_HANDLE");
7
+
const appPassword = Deno.env.get("CISTERN_MCP_APP_PASSWORD");
8
+
9
+
if (!handle || !appPassword) {
10
+
logger.error(
11
+
"CISTERN_MCP_HANDLE or CISTERN_MCP_APP_PASSWORD are not set in the environment",
12
+
);
13
+
return Deno.exit(1);
14
+
}
15
+
16
+
const privateKey = Deno.env.get("CISTERN_MCP_PRIVATE_KEY");
17
+
const publicKeyUri = Deno.env.get("CISTERN_MCP_PUBLIC_KEY_URI");
18
+
19
+
return {
20
+
appPassword,
21
+
handle,
22
+
keypair: privateKey && publicKeyUri
23
+
? {
24
+
privateKey,
25
+
publicKey: publicKeyUri,
26
+
}
27
+
: undefined,
28
+
};
29
+
}
+28
-17
packages/mcp/hono.ts
+28
-17
packages/mcp/hono.ts
···
1
1
import { Hono } from "hono";
2
2
import { cors } from "hono/cors";
3
+
import { bearerAuth } from "hono/bearer-auth";
3
4
import { getLogger, withContext } from "@logtape/logtape";
4
5
import { toFetchResponse, toReqRes } from "fetch-to-node";
5
6
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
7
+
import { collectOptions } from "./env.ts";
6
8
import { createServer } from "./server.ts";
7
9
8
10
export function createApp() {
···
10
12
const logger = getLogger(["cistern", "http"]);
11
13
const sessions = new Map<string, StreamableHTTPServerTransport>();
12
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 }));
13
38
app.use("*", async (c, next) => {
14
39
const requestId = crypto.randomUUID();
15
40
const startTime = Date.now();
···
41
66
});
42
67
43
68
app.onError((err, c) => {
44
-
logger.error("request error", {
69
+
logger.error("request error {error}", {
45
70
error: {
46
71
name: err.name,
47
72
message: err.message,
···
54
79
return c.json({ error: "internal server error" }, 500);
55
80
});
56
81
57
-
app.all(
58
-
"/mcp",
59
-
cors({
60
-
origin: "*",
61
-
allowMethods: ["GET", "POST", "DELETE", "OPTIONS"],
62
-
allowHeaders: [
63
-
"Content-Type",
64
-
"Authorization",
65
-
"Mcp-Session-Id",
66
-
"Mcp-Protocol-Version",
67
-
],
68
-
exposeHeaders: ["Mcp-Session-Id"],
69
-
}),
70
-
);
71
-
72
82
app.post("/mcp", async (ctx) => {
73
83
const sessionId = ctx.req.header("mcp-session-id") ?? crypto.randomUUID();
74
84
let session = sessions.get(sessionId);
···
78
88
} else {
79
89
logger.info("creating new session {sessionId}", { sessionId });
80
90
81
-
const server = createServer();
91
+
const options = collectOptions();
92
+
const server = await createServer(options);
82
93
83
94
session = new StreamableHTTPServerTransport({
84
95
sessionIdGenerator: () => sessionId,
+6
-1
packages/mcp/index.ts
+6
-1
packages/mcp/index.ts
···
5
5
6
6
import { createServer } from "./server.ts";
7
7
import { createApp } from "./hono.ts";
8
+
import { collectOptions } from "./env.ts";
8
9
9
10
async function main() {
10
11
await configure({
···
32
33
if (!args.http) {
33
34
logger.info("starting in stdio mode");
34
35
36
+
const options = collectOptions();
37
+
const server = await createServer(options);
35
38
const transport = new StdioServerTransport();
36
-
const server = createServer();
37
39
38
40
await server.connect(transport);
39
41
} else {
40
42
logger.info("starting in streamable HTTP mode");
43
+
44
+
// Validate environment before starting the server
45
+
collectOptions();
41
46
42
47
const app = createApp();
43
48
+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
+
}
+106
-13
packages/mcp/server.ts
+106
-13
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 {
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";
4
12
5
-
export function createServer() {
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) {
6
51
const logger = getLogger("cistern-mcp");
7
52
const server = new McpServer({
8
53
name: "cistern-mcp",
9
54
version: "1.0.0",
10
55
});
11
56
57
+
let iterator:
58
+
| AsyncGenerator<DecryptedMemo, void, "stop" | undefined>
59
+
| undefined;
60
+
12
61
server.registerTool(
13
-
"add",
62
+
"next_memo",
63
+
{
64
+
title: "Next memo",
65
+
description: "Retrieve the next outstanding memo",
66
+
outputSchema: { key: z.string(), tid: z.string(), text: z.string() },
67
+
},
68
+
async () => {
69
+
if (!iterator) {
70
+
logger.trace("creating iterator");
71
+
iterator ??= consumer.listMemos();
72
+
}
73
+
74
+
const res = await iterator.next();
75
+
76
+
if (res.done) {
77
+
logger.trace("iterator done; cleaning up");
78
+
iterator = undefined;
79
+
}
80
+
81
+
return {
82
+
content: [{
83
+
type: "text",
84
+
text: res.value?.text
85
+
? `key: ${res.value.key}, text: ${res.value.text}`
86
+
: "no memos remaining",
87
+
}],
88
+
structuredContent: {
89
+
key: res.value?.key ?? "",
90
+
tid: res.value?.tid ?? "",
91
+
text: res.value?.text ?? "no memos remaining",
92
+
},
93
+
};
94
+
},
95
+
);
96
+
97
+
server.registerTool(
98
+
"delete_memo",
14
99
{
15
-
title: "Addition Tool",
16
-
description: "Add two numbers",
17
-
inputSchema: { a: z.number(), b: z.number() },
18
-
outputSchema: { result: z.number() },
100
+
title: "Delete memo",
101
+
description:
102
+
"Delete a memo by record key, after it has been handled as instructed by the user",
103
+
inputSchema: { key: z.string() },
104
+
outputSchema: { success: z.boolean() },
19
105
},
20
-
({ a, b }) => {
21
-
logger.trace("Addition Tool called", { a, b });
106
+
async ({ key }) => {
107
+
try {
108
+
await consumer.deleteMemo(key);
22
109
23
-
const output = { result: a + b };
110
+
return {
111
+
content: [{ type: "text", text: "delete successful" }],
112
+
structuredContent: { success: true },
113
+
};
114
+
} catch (error) {
115
+
logger.error("failed to delete memo: {error}", { error });
24
116
25
-
return Promise.resolve({
26
-
content: [{ type: "text", text: JSON.stringify(output) }],
27
-
structuredContent: output,
28
-
});
117
+
return {
118
+
content: [{ type: "text", text: "delete unsuccessful" }],
119
+
structuredContent: { success: false },
120
+
};
121
+
}
29
122
},
30
123
);
31
124