ocean-brain 0.2.2 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/dist/index.js +73 -41
  2. package/dist/mcp.js +587 -26
  3. package/package.json +6 -1
  4. package/scripts/postinstall-prisma.mjs +41 -0
  5. package/server/client/dist/assets/Calendar-DynpmB3D.js +25 -0
  6. package/server/client/dist/assets/Callout-tNHgPhLM.js +1 -0
  7. package/server/client/dist/assets/Graph-DMXLUfCv.js +1 -0
  8. package/server/client/dist/assets/Image-ZxF459sC.js +1 -0
  9. package/server/client/dist/assets/Image.es-Dnn4SMzG.js +1 -0
  10. package/server/client/dist/assets/ModalActionRow-B3vNjcDc.js +1 -0
  11. package/server/client/dist/assets/Note-PDMuj62M.js +1 -0
  12. package/server/client/dist/assets/Note-PEevDKBh.css +1 -0
  13. package/server/client/dist/assets/Reminders-ComtTLuN.js +1 -0
  14. package/server/client/dist/assets/{Search-BANPp2ZV.js → Search-BJeZTbVu.js} +1 -1
  15. package/server/client/dist/assets/SurfaceCard-DAcVMY4f.js +1 -0
  16. package/server/client/dist/assets/Tag-BuNCDGD5.js +1 -0
  17. package/server/client/dist/assets/TagNotes-ajFOP5pZ.js +1 -0
  18. package/server/client/dist/assets/Trash.es-DLiHgjNo.js +1 -0
  19. package/server/client/dist/assets/{image.api-rBH0OSWJ.js → image.api-CjJgNbC0.js} +1 -1
  20. package/server/client/dist/assets/index-CDrZmLC2.js +1 -0
  21. package/server/client/dist/assets/index-DAZMX8bQ.css +1 -0
  22. package/server/client/dist/assets/index-DqFkOWbi.js +205 -0
  23. package/server/client/dist/assets/manage-image-CVyZTDT_.js +1 -0
  24. package/server/client/dist/assets/manage-image-detail-Bp17tylS.js +1 -0
  25. package/server/client/dist/assets/mcp-BpD7VH-d.js +1 -0
  26. package/server/client/dist/assets/placeholder-DSf4QF4v.js +34 -0
  27. package/server/client/dist/assets/trash-Ceb17h_s.js +1 -0
  28. package/server/client/dist/assets/useReminderMutate-CzyOQ_VB.js +82 -0
  29. package/server/client/dist/index.html +2 -2
  30. package/server/dist/app.js +97 -9
  31. package/server/dist/app.js.map +1 -1
  32. package/server/dist/main.js +16 -2
  33. package/server/dist/main.js.map +1 -1
  34. package/server/dist/modules/auth-guard.js +73 -0
  35. package/server/dist/modules/auth-guard.js.map +1 -0
  36. package/server/dist/modules/auth-mode.js +51 -0
  37. package/server/dist/modules/auth-mode.js.map +1 -0
  38. package/server/dist/modules/auth.js +9 -0
  39. package/server/dist/modules/auth.js.map +1 -1
  40. package/server/dist/modules/blocknote.js +154 -2
  41. package/server/dist/modules/blocknote.js.map +1 -1
  42. package/server/dist/modules/error-handler.js +41 -0
  43. package/server/dist/modules/error-handler.js.map +1 -0
  44. package/server/dist/modules/image-upload.js +114 -0
  45. package/server/dist/modules/image-upload.js.map +1 -0
  46. package/server/dist/modules/mcp-admin.js +88 -0
  47. package/server/dist/modules/mcp-admin.js.map +1 -0
  48. package/server/dist/modules/mcp-auth.js +71 -0
  49. package/server/dist/modules/mcp-auth.js.map +1 -0
  50. package/server/dist/modules/mcp-token.js +28 -0
  51. package/server/dist/modules/mcp-token.js.map +1 -0
  52. package/server/dist/modules/note-authoring.js +153 -0
  53. package/server/dist/modules/note-authoring.js.map +1 -0
  54. package/server/dist/modules/note-cleanup.js +207 -0
  55. package/server/dist/modules/note-cleanup.js.map +1 -0
  56. package/server/dist/modules/note-snapshot.js +223 -0
  57. package/server/dist/modules/note-snapshot.js.map +1 -0
  58. package/server/dist/modules/note-trash.js +262 -0
  59. package/server/dist/modules/note-trash.js.map +1 -0
  60. package/server/dist/modules/prisma-runtime.js +46 -0
  61. package/server/dist/modules/prisma-runtime.js.map +1 -0
  62. package/server/dist/modules/recovery-retention.js +26 -0
  63. package/server/dist/modules/recovery-retention.js.map +1 -0
  64. package/server/dist/modules/remote-image.js +176 -0
  65. package/server/dist/modules/remote-image.js.map +1 -0
  66. package/server/dist/modules/tag-organization.js +66 -0
  67. package/server/dist/modules/tag-organization.js.map +1 -0
  68. package/server/dist/modules/use-async.js +2 -4
  69. package/server/dist/modules/use-async.js.map +1 -1
  70. package/server/dist/schema/note/index.js +150 -6
  71. package/server/dist/schema/note/index.js.map +1 -1
  72. package/server/dist/start.js +11 -0
  73. package/server/dist/start.js.map +1 -0
  74. package/server/dist/types/index.js.map +1 -1
  75. package/server/dist/urls.js +3 -2
  76. package/server/dist/urls.js.map +1 -1
  77. package/server/dist/views/auth.js +305 -0
  78. package/server/dist/views/auth.js.map +1 -0
  79. package/server/dist/views/image.js +24 -81
  80. package/server/dist/views/image.js.map +1 -1
  81. package/server/dist/views/index.js +4 -0
  82. package/server/dist/views/index.js.map +1 -1
  83. package/server/dist/views/mcp-admin.js +42 -0
  84. package/server/dist/views/mcp-admin.js.map +1 -0
  85. package/server/dist/views/note.js +124 -0
  86. package/server/dist/views/note.js.map +1 -0
  87. package/server/dist/views/tag.js +23 -0
  88. package/server/dist/views/tag.js.map +1 -0
  89. package/server/prisma/migrations/20260331183000_0010_note_snapshot/migration.sql +13 -0
  90. package/server/prisma/migrations/20260331195000_0011_note_trash_entities/migration.sql +35 -0
  91. package/server/prisma/migrations/20260331213000_0012_image_hash_unique/migration.sql +49 -0
  92. package/server/prisma/migrations/20260404000000_0013_mcp_admin_control/migration.sql +9 -0
  93. package/server/prisma/schema.prisma +66 -1
  94. package/server/client/dist/assets/Calendar-ST5q_8KT.js +0 -48
  95. package/server/client/dist/assets/Callout-CrWW3bg0.js +0 -1
  96. package/server/client/dist/assets/Graph-CzJjt6rP.js +0 -1
  97. package/server/client/dist/assets/Image-Dxg1qqZw.js +0 -1
  98. package/server/client/dist/assets/Image.es-DZ0H6wH1.js +0 -1
  99. package/server/client/dist/assets/Note-Bu7fN3B6.js +0 -6
  100. package/server/client/dist/assets/Note-CCvXqPzv.css +0 -1
  101. package/server/client/dist/assets/Plus.es-CS3WVMDq.js +0 -1
  102. package/server/client/dist/assets/PriorityLegend-D61iBwNW.js +0 -1
  103. package/server/client/dist/assets/Reminders-BjirJMOR.css +0 -1
  104. package/server/client/dist/assets/Reminders-DjWRombU.js +0 -1
  105. package/server/client/dist/assets/Tag-DbKSLTj0.js +0 -1
  106. package/server/client/dist/assets/TagNotes-B6s7jtL7.js +0 -1
  107. package/server/client/dist/assets/index-BzWwEDoL.js +0 -1
  108. package/server/client/dist/assets/index-CF5_ZCYN.js +0 -162
  109. package/server/client/dist/assets/index-DjZk2Gpg.css +0 -1
  110. package/server/client/dist/assets/manage-image-detail-DOx6QsCl.js +0 -1
  111. package/server/client/dist/assets/manage-image-g-MC6Fkc.js +0 -1
  112. package/server/client/dist/assets/placeholder-DV3u6MrA.js +0 -34
  113. package/server/client/dist/assets/useReminderMutate-CU-UinGn.js +0 -82
package/dist/mcp.js CHANGED
@@ -1,20 +1,250 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/mcp.ts
4
- import fs from "fs";
5
- import path from "path";
4
+ import fs2 from "fs";
5
+ import path2 from "path";
6
6
  import { fileURLToPath } from "url";
7
7
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
8
8
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { z as z2 } from "zod";
10
+
11
+ // src/mcp-write-safety.ts
12
+ import crypto from "crypto";
13
+ import fs from "fs";
14
+ import os from "os";
15
+ import path from "path";
9
16
  import { z } from "zod";
10
- var __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ var destructiveMcpWriteFields = {
18
+ dryRun: z.boolean().optional().default(true).describe("Preview the destructive change first. Set dryRun to false only after you receive an operationId and confirmToken."),
19
+ operationId: z.string().optional().describe("Operation id returned by the dry-run response. Required when dryRun is false."),
20
+ confirmToken: z.string().optional().describe("Confirmation token returned by the dry-run response. Required when dryRun is false."),
21
+ force: z.boolean().optional().default(false).describe("Explicitly confirm a bulk destructive change when the preview says force is required.")
22
+ };
23
+ var McpCliWriteSafetyError = class extends Error {
24
+ code;
25
+ constructor(code, message) {
26
+ super(message);
27
+ this.name = "McpCliWriteSafetyError";
28
+ this.code = code;
29
+ }
30
+ };
31
+ var DEFAULT_TTL_MS = 10 * 60 * 1e3;
32
+ var DEFAULT_ROOT_DIR = path.resolve(os.homedir(), ".ocean-brain", "mcp-write-safety");
33
+ var createError = (code, message) => new McpCliWriteSafetyError(code, message);
34
+ var ensureDirExists = (dirPath) => {
35
+ fs.mkdirSync(dirPath, { recursive: true });
36
+ };
37
+ var hashToken = (token) => crypto.createHash("sha256").update(token).digest("hex");
38
+ var toPublicPendingOperation = (operation) => ({
39
+ actor: operation.actor,
40
+ affectedIds: operation.affectedIds,
41
+ createdAt: operation.createdAt,
42
+ estimatedChangeCount: operation.estimatedChangeCount,
43
+ expiresAt: operation.expiresAt,
44
+ force: operation.force,
45
+ operationId: operation.operationId,
46
+ risk: operation.risk,
47
+ summary: operation.summary,
48
+ toolName: operation.toolName
49
+ });
50
+ var parsePendingOperation = (raw) => JSON.parse(raw);
51
+ var defaultMcpWriteSafetyDir = () => {
52
+ return path.resolve(process.env.OCEAN_BRAIN_MCP_WRITE_SAFETY_DIR || DEFAULT_ROOT_DIR);
53
+ };
54
+ var formatMcpGraphqlError = (error) => {
55
+ const suffix = [];
56
+ if (error.extensions?.code) {
57
+ suffix.push(`code=${error.extensions.code}`);
58
+ }
59
+ if (error.extensions?.operationId) {
60
+ suffix.push(`operationId=${error.extensions.operationId}`);
61
+ }
62
+ if (suffix.length === 0) {
63
+ return `GraphQL error: ${error.message}`;
64
+ }
65
+ return `GraphQL error: ${error.message} (${suffix.join(", ")})`;
66
+ };
67
+ var createMcpWriteSafetyCoordinator = (options = {}) => {
68
+ const rootDir = path.resolve(options.rootDir ?? defaultMcpWriteSafetyDir());
69
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
70
+ const now = options.now ?? (() => /* @__PURE__ */ new Date());
71
+ const randomBytes = options.randomBytes ?? ((size) => crypto.randomBytes(size));
72
+ const pendingDir = path.resolve(rootDir, "pending");
73
+ const logPath = path.resolve(rootDir, "operations.jsonl");
74
+ const appendLog = (entry) => {
75
+ ensureDirExists(rootDir);
76
+ fs.appendFileSync(logPath, `${JSON.stringify(entry)}
77
+ `, "utf-8");
78
+ };
79
+ const buildLogEntry = (operation, phase, detail) => ({
80
+ actor: operation.actor,
81
+ affectedIds: operation.affectedIds,
82
+ estimatedChangeCount: operation.estimatedChangeCount,
83
+ force: operation.force,
84
+ operationId: operation.operationId,
85
+ phase,
86
+ risk: operation.risk,
87
+ summary: operation.summary,
88
+ timestamp: now().toISOString(),
89
+ toolName: operation.toolName,
90
+ ...detail ? { detail } : {}
91
+ });
92
+ const getPendingPath = (operationId) => path.resolve(pendingDir, `${operationId}.json`);
93
+ const cleanupExpiredOperations = () => {
94
+ if (!fs.existsSync(pendingDir)) {
95
+ return;
96
+ }
97
+ for (const fileName of fs.readdirSync(pendingDir)) {
98
+ const filePath = path.resolve(pendingDir, fileName);
99
+ const pendingOperation = parsePendingOperation(fs.readFileSync(filePath, "utf-8"));
100
+ if (new Date(pendingOperation.expiresAt).getTime() > now().getTime()) {
101
+ continue;
102
+ }
103
+ fs.rmSync(filePath, { force: true });
104
+ appendLog(buildLogEntry(toPublicPendingOperation(pendingOperation), "expired"));
105
+ }
106
+ };
107
+ const listPendingOperations = () => {
108
+ cleanupExpiredOperations();
109
+ if (!fs.existsSync(pendingDir)) {
110
+ return [];
111
+ }
112
+ return fs.readdirSync(pendingDir).map((fileName) => path.resolve(pendingDir, fileName)).map((filePath) => parsePendingOperation(fs.readFileSync(filePath, "utf-8"))).map(toPublicPendingOperation).sort((left, right) => left.createdAt.localeCompare(right.createdAt));
113
+ };
114
+ const prepareOperation = (input) => {
115
+ ensureDirExists(pendingDir);
116
+ const operationId = `mcpw_${randomBytes(8).toString("hex")}`;
117
+ const confirmToken = `confirm_${randomBytes(12).toString("hex")}`;
118
+ const currentTime = now();
119
+ const createdAt = currentTime.toISOString();
120
+ const expiresAt = new Date(currentTime.getTime() + ttlMs).toISOString();
121
+ const pendingOperation = {
122
+ actor: input.actor,
123
+ affectedIds: input.affectedIds,
124
+ confirmTokenHash: hashToken(confirmToken),
125
+ createdAt,
126
+ estimatedChangeCount: input.estimatedChangeCount,
127
+ expiresAt,
128
+ force: Boolean(input.force),
129
+ operationId,
130
+ risk: input.risk,
131
+ summary: input.summary,
132
+ toolName: input.toolName
133
+ };
134
+ fs.writeFileSync(getPendingPath(operationId), JSON.stringify(pendingOperation, null, 2), "utf-8");
135
+ appendLog(buildLogEntry(toPublicPendingOperation(pendingOperation), "prepared"));
136
+ return {
137
+ ...toPublicPendingOperation(pendingOperation),
138
+ confirmToken
139
+ };
140
+ };
141
+ const requireConfirmedOperation = (request, expected) => {
142
+ if (!request.operationId || !request.confirmToken) {
143
+ throw createError(
144
+ "CONFIRMATION_REQUIRED",
145
+ "A destructive MCP write requires operationId and confirmToken after a dry run."
146
+ );
147
+ }
148
+ const pendingPath = getPendingPath(request.operationId);
149
+ if (!fs.existsSync(pendingPath)) {
150
+ throw createError("INVALID_CONFIRMATION", "The write confirmation was not found or has already been consumed.");
151
+ }
152
+ const pendingOperation = parsePendingOperation(fs.readFileSync(pendingPath, "utf-8"));
153
+ const publicPendingOperation = toPublicPendingOperation(pendingOperation);
154
+ if (new Date(pendingOperation.expiresAt).getTime() <= now().getTime()) {
155
+ fs.rmSync(pendingPath, { force: true });
156
+ appendLog(buildLogEntry(publicPendingOperation, "expired"));
157
+ throw createError("CONFIRMATION_EXPIRED", "The write confirmation expired. Run dryRun again.");
158
+ }
159
+ if (pendingOperation.toolName !== expected.toolName) {
160
+ appendLog(buildLogEntry(publicPendingOperation, "rejected", "tool_mismatch"));
161
+ throw createError("INVALID_CONFIRMATION", "The write confirmation does not match this MCP tool.");
162
+ }
163
+ if (pendingOperation.confirmTokenHash !== hashToken(request.confirmToken)) {
164
+ appendLog(buildLogEntry(publicPendingOperation, "rejected", "token_mismatch"));
165
+ throw createError("INVALID_CONFIRMATION", "The write confirmation token is invalid.");
166
+ }
167
+ fs.rmSync(pendingPath, { force: true });
168
+ appendLog(buildLogEntry(publicPendingOperation, "confirmed"));
169
+ return publicPendingOperation;
170
+ };
171
+ const ensureDestructiveWriteRequest = (request, input) => {
172
+ if (request.dryRun ?? true) {
173
+ return {
174
+ kind: "dry-run",
175
+ operation: prepareOperation({
176
+ ...input,
177
+ force: request.force ?? input.force
178
+ })
179
+ };
180
+ }
181
+ return {
182
+ kind: "confirmed",
183
+ operation: requireConfirmedOperation(request, { toolName: input.toolName })
184
+ };
185
+ };
186
+ const recordWriteResult = (operation, success, detail) => {
187
+ appendLog(buildLogEntry(operation, success ? "executed" : "failed", detail));
188
+ };
189
+ const getStatus = () => {
190
+ const pendingOperations = listPendingOperations();
191
+ return {
192
+ confirmationTtlMs: ttlMs,
193
+ logPath,
194
+ pendingDir,
195
+ pendingOperationCount: pendingOperations.length,
196
+ pendingOperations,
197
+ rootDir,
198
+ writeToolsEnabled: false
199
+ };
200
+ };
201
+ return {
202
+ ensureDestructiveWriteRequest,
203
+ getStatus,
204
+ listPendingOperations,
205
+ prepareOperation,
206
+ recordWriteResult,
207
+ requireConfirmedOperation
208
+ };
209
+ };
210
+
211
+ // src/mcp.ts
212
+ var __dirname = path2.dirname(fileURLToPath(import.meta.url));
11
213
  var pkg = JSON.parse(
12
- fs.readFileSync(path.resolve(__dirname, "..", "package.json"), "utf-8")
214
+ fs2.readFileSync(path2.resolve(__dirname, "..", "package.json"), "utf-8")
13
215
  );
14
- async function graphql(serverUrl, query, variables) {
15
- const response = await fetch(`${serverUrl}/graphql`, {
216
+ var OCEAN_BRAIN_MCP_TOOLS = {
217
+ searchNotes: "ocean_brain_search_notes",
218
+ readNote: "ocean_brain_read_note",
219
+ createNote: "ocean_brain_create_note",
220
+ updateNote: "ocean_brain_update_note",
221
+ listTags: "ocean_brain_list_tags",
222
+ listNotesByTag: "ocean_brain_list_notes_by_tag",
223
+ listRecentNotes: "ocean_brain_list_recent_notes",
224
+ writeSafetyStatus: "ocean_brain_write_safety_status",
225
+ findNoteCleanupCandidates: "ocean_brain_find_note_cleanup_candidates",
226
+ createTag: "ocean_brain_create_tag",
227
+ deleteNote: "ocean_brain_delete_note"
228
+ };
229
+ var noteLayoutSchema = z2.enum(["narrow", "wide", "full"]);
230
+ var normalizeOceanBrainTagName = (name) => {
231
+ const trimmedName = name.trim();
232
+ if (!trimmedName) {
233
+ throw new Error("Tag name is required.");
234
+ }
235
+ const normalizedName = trimmedName.startsWith("@") ? trimmedName : `@${trimmedName}`;
236
+ if (normalizedName === "@" || /\s/.test(normalizedName.slice(1))) {
237
+ throw new Error("Ocean Brain tag names must be a single token. Example: @project");
238
+ }
239
+ return normalizedName;
240
+ };
241
+ async function graphql(serverUrl, token, query, variables) {
242
+ const response = await fetch(`${serverUrl}/graphql/mcp`, {
16
243
  method: "POST",
17
- headers: { "Content-Type": "application/json" },
244
+ headers: {
245
+ "Content-Type": "application/json",
246
+ ...token ? { Authorization: `Bearer ${token}` } : {}
247
+ },
18
248
  body: JSON.stringify({ query, variables })
19
249
  });
20
250
  if (!response.ok) {
@@ -22,24 +252,48 @@ async function graphql(serverUrl, query, variables) {
22
252
  }
23
253
  const result = await response.json();
24
254
  if (result.errors?.length) {
25
- throw new Error(`GraphQL error: ${result.errors[0].message}`);
255
+ throw new Error(formatMcpGraphqlError(result.errors[0]));
26
256
  }
27
257
  return result.data;
28
258
  }
29
- async function startMcpServer(serverUrl) {
259
+ async function jsonRequest(serverUrl, token, pathName, body) {
260
+ const response = await fetch(`${serverUrl}${pathName}`, {
261
+ method: "POST",
262
+ headers: {
263
+ "Content-Type": "application/json",
264
+ ...token ? { Authorization: `Bearer ${token}` } : {}
265
+ },
266
+ body: JSON.stringify(body)
267
+ });
268
+ const result = await response.json();
269
+ if (!response.ok) {
270
+ throw new Error(`Request failed: ${result.code || response.status} ${result.message || response.statusText}`);
271
+ }
272
+ return result;
273
+ }
274
+ var requireWriteToken = (token, toolName) => {
275
+ if (token) {
276
+ return token;
277
+ }
278
+ throw new Error(`${toolName} requires an MCP bearer token. Set --token or --token-file.`);
279
+ };
280
+ async function startMcpServer(serverUrl, token, options = {}) {
30
281
  const server = new McpServer({
31
282
  name: "ocean-brain",
32
283
  version: pkg.version
33
284
  });
285
+ const writeSafety = createMcpWriteSafetyCoordinator({
286
+ rootDir: options.writeSafetyDir ?? defaultMcpWriteSafetyDir()
287
+ });
34
288
  server.tool(
35
- "search_notes",
36
- "Search notes by keyword. Returns a list of matching notes with title and tags. Use read_note to get full content of a specific note.",
289
+ OCEAN_BRAIN_MCP_TOOLS.searchNotes,
290
+ "Search Ocean Brain notes by keyword. Returns matching note titles and tags. Use ocean_brain_read_note to get full content for a specific note.",
37
291
  {
38
- query: z.string().describe("Search keyword"),
39
- limit: z.number().optional().default(10).describe("Max results (default: 10)")
292
+ query: z2.string().describe("Search keyword"),
293
+ limit: z2.number().optional().default(10).describe("Max results (default: 10)")
40
294
  },
41
295
  async ({ query, limit }) => {
42
- const data = await graphql(serverUrl, `
296
+ const data = await graphql(serverUrl, token, `
43
297
  query ($searchFilter: SearchFilterInput, $pagination: PaginationInput) {
44
298
  allNotes(searchFilter: $searchFilter, pagination: $pagination) {
45
299
  totalCount
@@ -73,14 +327,14 @@ async function startMcpServer(serverUrl) {
73
327
  }
74
328
  );
75
329
  server.tool(
76
- "read_note",
77
- "Read content of a note by ID. Returns truncated by default (1000 chars). Set maxLength to 0 for full content only when necessary.",
330
+ OCEAN_BRAIN_MCP_TOOLS.readNote,
331
+ "Read an Ocean Brain note by ID. Returns truncated content by default (1000 chars). Set maxLength to 0 only when full content is necessary.",
78
332
  {
79
- id: z.string().describe("Note ID"),
80
- maxLength: z.number().optional().default(1e3).describe("Max content length in characters. 0 for full content. (default: 1000)")
333
+ id: z2.string().describe("Note ID"),
334
+ maxLength: z2.number().optional().default(1e3).describe("Max content length in characters. 0 for full content. (default: 1000)")
81
335
  },
82
336
  async ({ id, maxLength }) => {
83
- const data = await graphql(serverUrl, `
337
+ const data = await graphql(serverUrl, token, `
84
338
  query ($id: ID!) {
85
339
  note(id: $id) {
86
340
  id
@@ -120,11 +374,59 @@ async function startMcpServer(serverUrl) {
120
374
  }
121
375
  );
122
376
  server.tool(
123
- "list_tags",
124
- "List all tags with their note counts.",
377
+ OCEAN_BRAIN_MCP_TOOLS.createNote,
378
+ "Create an Ocean Brain note from markdown through the MCP write path. This is the preferred way to author new notes without sending internal BlockNote JSON.",
379
+ {
380
+ title: z2.string().describe("Note title"),
381
+ markdown: z2.string().optional().default("").describe("Markdown body for the new note. Defaults to an empty note body."),
382
+ layout: noteLayoutSchema.optional().describe("Optional note layout: narrow, wide, or full.")
383
+ },
384
+ async ({ title, markdown, layout }) => {
385
+ const writeToken = requireWriteToken(token, OCEAN_BRAIN_MCP_TOOLS.createNote);
386
+ const result = await jsonRequest(serverUrl, writeToken, "/api/mcp/notes/create", {
387
+ title,
388
+ markdown,
389
+ ...layout ? { layout } : {}
390
+ });
391
+ return {
392
+ content: [{
393
+ type: "text",
394
+ text: JSON.stringify(result, null, 2)
395
+ }]
396
+ };
397
+ }
398
+ );
399
+ server.tool(
400
+ OCEAN_BRAIN_MCP_TOOLS.updateNote,
401
+ "Update an Ocean Brain note from markdown through the MCP write path. Pass only the fields you want to change.",
402
+ {
403
+ id: z2.string().describe("Note ID to update"),
404
+ title: z2.string().optional().describe("New note title"),
405
+ markdown: z2.string().optional().describe("Replacement markdown body"),
406
+ layout: noteLayoutSchema.optional().describe("Optional note layout: narrow, wide, or full.")
407
+ },
408
+ async ({ id, title, markdown, layout }) => {
409
+ const writeToken = requireWriteToken(token, OCEAN_BRAIN_MCP_TOOLS.updateNote);
410
+ const result = await jsonRequest(serverUrl, writeToken, "/api/mcp/notes/update", {
411
+ id,
412
+ ...title !== void 0 ? { title } : {},
413
+ ...markdown !== void 0 ? { markdown } : {},
414
+ ...layout ? { layout } : {}
415
+ });
416
+ return {
417
+ content: [{
418
+ type: "text",
419
+ text: JSON.stringify(result, null, 2)
420
+ }]
421
+ };
422
+ }
423
+ );
424
+ server.tool(
425
+ OCEAN_BRAIN_MCP_TOOLS.listTags,
426
+ "List Ocean Brain tags with their note counts.",
125
427
  {},
126
428
  async () => {
127
- const data = await graphql(serverUrl, `
429
+ const data = await graphql(serverUrl, token, `
128
430
  query ($searchFilter: SearchFilterInput, $pagination: PaginationInput) {
129
431
  allTags(searchFilter: $searchFilter, pagination: $pagination) {
130
432
  totalCount
@@ -149,13 +451,93 @@ async function startMcpServer(serverUrl) {
149
451
  }
150
452
  );
151
453
  server.tool(
152
- "list_recent_notes",
153
- "List recently updated notes. Returns titles and tags only. Use read_note to get content of a specific note.",
454
+ OCEAN_BRAIN_MCP_TOOLS.listNotesByTag,
455
+ "List Ocean Brain notes for a specific tag name. The tool accepts either @tag or tag and resolves it to the canonical Ocean Brain tag name first.",
456
+ {
457
+ tag: z2.string().describe("Tag name to inspect. You can pass @project or project."),
458
+ limit: z2.number().optional().default(20).describe("Max results (default: 20)"),
459
+ offset: z2.number().optional().default(0).describe("Pagination offset (default: 0)")
460
+ },
461
+ async ({ tag, limit, offset }) => {
462
+ const normalizedTag = normalizeOceanBrainTagName(tag);
463
+ const tagsData = await graphql(serverUrl, token, `
464
+ query ($searchFilter: SearchFilterInput, $pagination: PaginationInput) {
465
+ allTags(searchFilter: $searchFilter, pagination: $pagination) {
466
+ totalCount
467
+ tags {
468
+ id
469
+ name
470
+ referenceCount
471
+ }
472
+ }
473
+ }
474
+ `, {
475
+ searchFilter: { query: normalizedTag },
476
+ pagination: { limit: 100, offset: 0 }
477
+ });
478
+ const tagResult = tagsData?.allTags;
479
+ const exactMatches = tagResult.tags.filter((item) => item.name === normalizedTag);
480
+ if (exactMatches.length === 0) {
481
+ return {
482
+ content: [{
483
+ type: "text",
484
+ text: JSON.stringify({
485
+ requestedTag: tag,
486
+ normalizedTag,
487
+ tagFound: false,
488
+ noteCount: 0,
489
+ notes: []
490
+ }, null, 2)
491
+ }]
492
+ };
493
+ }
494
+ const selectedTag = exactMatches[0];
495
+ const notesData = await graphql(serverUrl, token, `
496
+ query ($searchFilter: SearchFilterInput, $pagination: PaginationInput) {
497
+ tagNotes(searchFilter: $searchFilter, pagination: $pagination) {
498
+ totalCount
499
+ notes {
500
+ id
501
+ title
502
+ updatedAt
503
+ tags { id name }
504
+ }
505
+ }
506
+ }
507
+ `, {
508
+ searchFilter: { query: selectedTag.id },
509
+ pagination: { limit, offset }
510
+ });
511
+ const noteResult = notesData?.tagNotes;
512
+ return {
513
+ content: [{
514
+ type: "text",
515
+ text: JSON.stringify({
516
+ requestedTag: tag,
517
+ normalizedTag,
518
+ tagFound: true,
519
+ duplicateExactMatchCount: exactMatches.length,
520
+ tag: selectedTag,
521
+ totalCount: noteResult.totalCount,
522
+ notes: noteResult.notes.map((note) => ({
523
+ id: note.id,
524
+ title: note.title,
525
+ updatedAt: note.updatedAt,
526
+ tags: note.tags.map((item) => item.name)
527
+ }))
528
+ }, null, 2)
529
+ }]
530
+ };
531
+ }
532
+ );
533
+ server.tool(
534
+ OCEAN_BRAIN_MCP_TOOLS.listRecentNotes,
535
+ "List recently updated Ocean Brain notes. Returns titles and tags only. Use ocean_brain_read_note to get full content for a specific note.",
154
536
  {
155
- limit: z.number().optional().default(10).describe("Max results (default: 10)")
537
+ limit: z2.number().optional().default(10).describe("Max results (default: 10)")
156
538
  },
157
539
  async ({ limit }) => {
158
- const data = await graphql(serverUrl, `
540
+ const data = await graphql(serverUrl, token, `
159
541
  query ($searchFilter: SearchFilterInput, $pagination: PaginationInput) {
160
542
  allNotes(searchFilter: $searchFilter, pagination: $pagination) {
161
543
  totalCount
@@ -186,9 +568,188 @@ async function startMcpServer(serverUrl) {
186
568
  };
187
569
  }
188
570
  );
571
+ server.tool(
572
+ OCEAN_BRAIN_MCP_TOOLS.writeSafetyStatus,
573
+ "Inspect pending destructive write confirmations and local operation log state for enabled Ocean Brain MCP write tools.",
574
+ {},
575
+ async () => {
576
+ return {
577
+ content: [{
578
+ type: "text",
579
+ text: JSON.stringify({
580
+ ...writeSafety.getStatus(),
581
+ destructiveWriteTools: [OCEAN_BRAIN_MCP_TOOLS.deleteNote],
582
+ writeTools: [
583
+ OCEAN_BRAIN_MCP_TOOLS.createNote,
584
+ OCEAN_BRAIN_MCP_TOOLS.updateNote,
585
+ OCEAN_BRAIN_MCP_TOOLS.createTag,
586
+ OCEAN_BRAIN_MCP_TOOLS.deleteNote
587
+ ],
588
+ writeToolsEnabled: true
589
+ }, null, 2)
590
+ }]
591
+ };
592
+ }
593
+ );
594
+ server.tool(
595
+ OCEAN_BRAIN_MCP_TOOLS.findNoteCleanupCandidates,
596
+ "Find Ocean Brain note cleanup candidates for temporary or draft notes before deletion. Use this before ocean_brain_delete_note.",
597
+ {
598
+ keywords: z2.string().optional().default("temp tmp draft test wip").describe("Keywords that mark a note as a cleanup candidate. Comma or space separated."),
599
+ limit: z2.number().optional().default(20).describe("Max results (default: 20)"),
600
+ offset: z2.number().optional().default(0).describe("Pagination offset (default: 0)")
601
+ },
602
+ async ({ keywords, limit, offset }) => {
603
+ const normalizedKeywords = keywords.split(/[,\s]+/).map((keyword) => keyword.trim()).filter(Boolean);
604
+ const data = await graphql(serverUrl, token, `
605
+ query ($query: String, $pagination: PaginationInput) {
606
+ noteCleanupCandidates(query: $query, pagination: $pagination) {
607
+ id
608
+ title
609
+ updatedAt
610
+ pinned
611
+ tagNames
612
+ reminderCount
613
+ backReferenceCount
614
+ matchedTerms
615
+ reasons
616
+ requiresForce
617
+ forceReasons
618
+ }
619
+ }
620
+ `, {
621
+ query: keywords,
622
+ pagination: { limit, offset }
623
+ });
624
+ const result = data?.noteCleanupCandidates;
625
+ return {
626
+ content: [{
627
+ type: "text",
628
+ text: JSON.stringify({
629
+ keywords: normalizedKeywords,
630
+ limit,
631
+ offset,
632
+ candidateCount: result.length,
633
+ notes: result
634
+ }, null, 2)
635
+ }]
636
+ };
637
+ }
638
+ );
639
+ server.tool(
640
+ OCEAN_BRAIN_MCP_TOOLS.createTag,
641
+ "Create an Ocean Brain tag through the MCP write path. The tool accepts either @tag or tag and creates the canonical Ocean Brain tag if it does not already exist.",
642
+ {
643
+ name: z2.string().describe("Tag name to create. You can pass @project or project.")
644
+ },
645
+ async ({ name }) => {
646
+ const writeToken = requireWriteToken(token, OCEAN_BRAIN_MCP_TOOLS.createTag);
647
+ const result = await jsonRequest(serverUrl, writeToken, "/api/mcp/tags/create", {
648
+ name
649
+ });
650
+ return {
651
+ content: [{
652
+ type: "text",
653
+ text: JSON.stringify(result, null, 2)
654
+ }]
655
+ };
656
+ }
657
+ );
658
+ server.tool(
659
+ OCEAN_BRAIN_MCP_TOOLS.deleteNote,
660
+ "Delete an Ocean Brain note safely through a dry-run and confirmation flow. Start with dryRun=true, then call again with dryRun=false, operationId, and confirmToken.",
661
+ {
662
+ id: z2.string().describe("Note ID to delete"),
663
+ ...destructiveMcpWriteFields
664
+ },
665
+ async ({ id, dryRun, operationId, confirmToken, force }) => {
666
+ const writeToken = requireWriteToken(token, OCEAN_BRAIN_MCP_TOOLS.deleteNote);
667
+ const data = await graphql(serverUrl, writeToken, `
668
+ query ($id: ID!) {
669
+ noteCleanupPreview(id: $id) {
670
+ id
671
+ title
672
+ updatedAt
673
+ pinned
674
+ tagNames
675
+ reminderCount
676
+ backReferences {
677
+ id
678
+ title
679
+ }
680
+ orphanedTagNames
681
+ requiresForce
682
+ forceReasons
683
+ }
684
+ }
685
+ `, { id });
686
+ const preview = data?.noteCleanupPreview;
687
+ if (!preview) {
688
+ return {
689
+ content: [{
690
+ type: "text",
691
+ text: JSON.stringify({
692
+ deleted: false,
693
+ reason: "NOTE_NOT_FOUND",
694
+ id
695
+ }, null, 2)
696
+ }]
697
+ };
698
+ }
699
+ const intent = writeSafety.ensureDestructiveWriteRequest(
700
+ { dryRun, operationId, confirmToken, force },
701
+ {
702
+ actor: "mcp-bearer",
703
+ affectedIds: [preview.id],
704
+ estimatedChangeCount: 1,
705
+ force: preview.requiresForce,
706
+ risk: "destructive",
707
+ summary: `Delete note ${preview.id} (${preview.title})`,
708
+ toolName: OCEAN_BRAIN_MCP_TOOLS.deleteNote
709
+ }
710
+ );
711
+ if (intent.kind === "dry-run") {
712
+ return {
713
+ content: [{
714
+ type: "text",
715
+ text: JSON.stringify({
716
+ mode: "dry-run",
717
+ preview,
718
+ operation: intent.operation
719
+ }, null, 2)
720
+ }]
721
+ };
722
+ }
723
+ if (intent.operation.force && !force) {
724
+ throw new Error(`${OCEAN_BRAIN_MCP_TOOLS.deleteNote} requires force=true because ${preview.forceReasons.join(", ")}.`);
725
+ }
726
+ try {
727
+ const result = await jsonRequest(serverUrl, writeToken, "/api/mcp/notes/delete", {
728
+ id: preview.id
729
+ });
730
+ writeSafety.recordWriteResult(intent.operation, true);
731
+ return {
732
+ content: [{
733
+ type: "text",
734
+ text: JSON.stringify({
735
+ mode: "commit",
736
+ preview,
737
+ operation: intent.operation,
738
+ result
739
+ }, null, 2)
740
+ }]
741
+ };
742
+ } catch (error) {
743
+ const message = error instanceof Error ? error.message : "Unknown MCP note delete error";
744
+ writeSafety.recordWriteResult(intent.operation, false, message);
745
+ throw error;
746
+ }
747
+ }
748
+ );
189
749
  const transport = new StdioServerTransport();
190
750
  await server.connect(transport);
191
751
  }
192
752
  export {
753
+ OCEAN_BRAIN_MCP_TOOLS,
193
754
  startMcpServer
194
755
  };