benjivers/notes Public Public: anyone can read this; nobody can change it.

Files 73f5fd914653f166957d8eef6ca6083f84887b4a
219 lines · 6.9 KB · ~2.0k tok Raw History
1
/**
2
 * An MCP server over plain HTTP: `POST /mcp` takes one JSON-RPC 2.0
3
 * message and answers with JSON (streamable HTTP without the stream —
4
 * nothing here needs to push). The tools are the actions, called with the
5
 * same identity the page gets, so an agent speaking MCP through the forge
6
 * is the person whose grant it carries, and a bare one is anonymous.
7
 */
8
9
import type { Database } from "bun:sqlite";
10
import {
11
  addNote,
12
  BODY_MAX,
13
  deleteNote,
14
  findNote,
15
  getNote,
16
  listNotes,
17
  openOrCreate,
18
  searchNotes,
19
  today,
20
  updateNote,
21
  type Result,
22
} from "./actions.ts";
23
import { title } from "./env.ts";
24
import { fields } from "./http.ts";
25
import type { Who } from "./who.ts";
26
27
interface Tool {
28
  readonly name: string;
29
  readonly description: string;
30
  readonly inputSchema: Record<string, unknown>;
31
  readonly run: (
32
    db: Database,
33
    who: Who,
34
    args: Record<string, unknown>,
35
  ) => Result<unknown>;
36
}
37
38
const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
39
40
/** A note by `id`, or by `title` in the caller's vault. */
41
const which = (db: Database, who: Who, args: Record<string, unknown>) =>
42
  args.id !== undefined
43
    ? getNote(db, who, { id: Number(args.id) })
44
    : findNote(db, who, { title: str(args.title) ?? "" });
45
46
const NOTE_REF = {
47
  id: { type: "integer", description: "The note's id." },
48
  title: { type: "string", description: "Or its title (any case)." },
49
};
50
51
export const tools: readonly Tool[] = [
52
  {
53
    name: "list_notes",
54
    description:
55
      "The titles in your vault (id, title, pinned, updated_at), pinned first then most recently changed. Read one with read_note.",
56
    inputSchema: {
57
      type: "object",
58
      properties: {
59
        limit: { type: "integer", minimum: 1, maximum: 5000, description: "At most this many." },
60
      },
61
    },
62
    run: (db, who, args) => {
63
      const listed = listNotes(db, who, args.limit === undefined ? {} : { limit: Number(args.limit) });
64
      return listed.ok
65
        ? {
66
            ok: true,
67
            value: listed.value.map(({ id, title, pinned, updated_at }) => ({ id, title, pinned: pinned === 1, updated_at })),
68
          }
69
        : listed;
70
    },
71
  },
72
  {
73
    name: "search_notes",
74
    description:
75
      'Search your vault: every word must appear; "a phrase" matches whole; #tag (or tag:name) must be on the note. Title matches rank first.',
76
    inputSchema: {
77
      type: "object",
78
      properties: { q: { type: "string", maxLength: 500 }, limit: { type: "integer", minimum: 1, maximum: 500 } },
79
      required: ["q"],
80
    },
81
    run: (db, who, args) =>
82
      searchNotes(db, who, { q: str(args.q) ?? "", ...(args.limit === undefined ? {} : { limit: Number(args.limit) }) }),
83
  },
84
  {
85
    name: "read_note",
86
    description: "One note's markdown, by id or title.",
87
    inputSchema: { type: "object", properties: NOTE_REF },
88
    run: (db, who, args) => which(db, who, args),
89
  },
90
  {
91
    name: "create_note",
92
    description:
93
      "Write a new note in markdown. Link other notes with [[Their title]] and tag with #tags. Titles are unique in the vault.",
94
    inputSchema: {
95
      type: "object",
96
      properties: {
97
        title: { type: "string", maxLength: 200 },
98
        body: { type: "string", maxLength: BODY_MAX },
99
      },
100
      required: ["title"],
101
    },
102
    run: (db, who, args) =>
103
      addNote(db, who, { title: str(args.title) ?? "", body: str(args.body) ?? "" }),
104
  },
105
  {
106
    name: "update_note",
107
    description:
108
      "Replace a note's body, rename it (links to it follow), or pin it. Give only what changes.",
109
    inputSchema: {
110
      type: "object",
111
      properties: {
112
        id: { type: "integer" },
113
        title: { type: "string", maxLength: 200 },
114
        body: { type: "string", maxLength: BODY_MAX },
115
        pinned: { type: "boolean" },
116
      },
117
      required: ["id"],
118
    },
119
    run: (db, who, args) =>
120
      updateNote(db, who, {
121
        id: Number(args.id),
122
        ...(str(args.title) === undefined ? {} : { title: str(args.title)! }),
123
        ...(str(args.body) === undefined ? {} : { body: str(args.body)! }),
124
        ...(typeof args.pinned === "boolean" ? { pinned: args.pinned } : {}),
125
      }),
126
  },
127
  {
128
    name: "append_to_note",
129
    description:
130
      "Add text to the end of a note, made if it does not exist yet. With no title, today's daily note (YYYY-MM-DD, UTC).",
131
    inputSchema: {
132
      type: "object",
133
      properties: {
134
        title: { type: "string", maxLength: 200 },
135
        text: { type: "string", maxLength: BODY_MAX },
136
      },
137
      required: ["text"],
138
    },
139
    run: (db, who, args) => {
140
      const day = today();
141
      const name = str(args.title) ?? day;
142
      const opened = openOrCreate(db, who, { title: name, body: name === day ? `# ${day}\n` : "" });
143
      if (!opened.ok) return opened;
144
      const body = opened.value.body.replace(/\s*$/, "");
145
      return updateNote(db, who, {
146
        id: opened.value.id,
147
        body: `${body}${body === "" ? "" : "\n\n"}${str(args.text) ?? ""}\n`,
148
      });
149
    },
150
  },
151
  {
152
    name: "delete_note",
153
    description: "Remove a note by id. This cannot be undone.",
154
    inputSchema: {
155
      type: "object",
156
      properties: { id: { type: "integer" } },
157
      required: ["id"],
158
    },
159
    run: (db, who, args) => deleteNote(db, who, { id: Number(args.id) }),
160
  },
161
];
162
163
/** One JSON-RPC message in, an HTTP status and a body out (a notification gets 202 and no body). */
164
export function rpc(
165
  db: Database,
166
  who: Who,
167
  message: unknown,
168
): { status: number; body?: unknown } {
169
  const m = fields(message);
170
  const id = m.id ?? null;
171
  const reply = (result: unknown) => ({
172
    status: 200,
173
    body: { jsonrpc: "2.0", id, result },
174
  });
175
  const refuse = (code: number, text: string) => ({
176
    status: 200,
177
    body: { jsonrpc: "2.0", id, error: { code, message: text } },
178
  });
179
  if (m.jsonrpc !== "2.0" || typeof m.method !== "string")
180
    return refuse(-32600, "not a JSON-RPC 2.0 request");
181
  if (m.id === undefined) return { status: 202 };
182
  const params = fields(m.params);
183
  switch (m.method) {
184
    case "initialize":
185
      return reply({
186
        protocolVersion: "2025-06-18",
187
        capabilities: { tools: {} },
188
        serverInfo: { name: title, version: "1" },
189
      });
190
    case "ping":
191
      return reply({});
192
    case "tools/list":
193
      return reply({
194
        tools: tools.map(({ name, description, inputSchema }) => ({
195
          name,
196
          description,
197
          inputSchema,
198
        })),
199
      });
200
    case "tools/call": {
201
      const tool = tools.find((t) => t.name === params.name);
202
      if (tool === undefined)
203
        return refuse(-32602, `no tool named ${String(params.name)}`);
204
      const result = tool.run(db, who, fields(params.arguments));
205
      return reply(
206
        result.ok
207
          ? { content: [{ type: "text", text: JSON.stringify(result.value) }] }
208
          : {
209
              content: [
210
                { type: "text", text: `${result.error}: ${result.message}` },
211
              ],
212
              isError: true,
213
            },
214
      );
215
    }
216
    default:
217
      return refuse(-32601, `no method ${m.method}`);
218
  }
219
}
↑↓ navigate ↵ open esc close
↑↓ navigate ↵ open esc close

Keyboard shortcuts