Encrypted, ephemeral, private memos on atproto
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2import { getLogger } from "@logtape/logtape";
3import { z } from "zod";
4import type {
5 Consumer,
6 ConsumerOptions,
7 DecryptedMemo,
8} from "@cistern/consumer";
9import { createConsumer } from "@cistern/consumer";
10import { serializeKey } from "@cistern/crypto";
11import { getStoredKeypair, storeKeypair } from "./kv.ts";
12
13export 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
50function _createServerWithConsumer(consumer: Consumer) {
51 const logger = getLogger("cistern-mcp");
52 const server = new McpServer({
53 name: "cistern-mcp",
54 version: "1.0.0",
55 });
56
57 let iterator:
58 | AsyncGenerator<DecryptedMemo, void, "stop" | undefined>
59 | undefined;
60
61 server.registerTool(
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",
99 {
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() },
105 },
106 async ({ key }) => {
107 try {
108 await consumer.deleteMemo(key);
109
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 });
116
117 return {
118 content: [{ type: "text", text: "delete unsuccessful" }],
119 structuredContent: { success: false },
120 };
121 }
122 },
123 );
124
125 return server;
126}