ocean-brain 0.2.1 → 0.3.0
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.
- package/dist/index.js +73 -41
- package/dist/mcp.js +587 -26
- package/package.json +6 -1
- package/scripts/postinstall-prisma.mjs +41 -0
- package/server/client/dist/assets/Calendar-DRv2aEl8.js +28 -0
- package/server/client/dist/assets/Callout-B2kPcDrq.js +1 -0
- package/server/client/dist/assets/Graph-DotbTrZR.js +1 -0
- package/server/client/dist/assets/Image-B5R7gF2P.js +1 -0
- package/server/client/dist/assets/Image.es-Dld9r9MJ.js +1 -0
- package/server/client/dist/assets/ModalActionRow-CES9L_Gy.js +1 -0
- package/server/client/dist/assets/Note-SK4hDhEM.css +1 -0
- package/server/client/dist/assets/Note-cIbXJc1v.js +1 -0
- package/server/client/dist/assets/Reminders-4LunlZRb.js +1 -0
- package/server/client/dist/assets/Search-Cp17DGR6.js +1 -0
- package/server/client/dist/assets/SurfaceCard-yzVU4Qkx.js +1 -0
- package/server/client/dist/assets/Tag-CwmuMPmo.js +1 -0
- package/server/client/dist/assets/TagNotes-gGroWMtT.js +1 -0
- package/server/client/dist/assets/Trash.es-B3KWD8_C.js +1 -0
- package/server/client/dist/assets/color-wpu7wT_s.js +1 -0
- package/server/client/dist/assets/graph-vendor-CUxe67Lr.js +46 -0
- package/server/client/dist/assets/image.api-DJgOBt8S.js +17 -0
- package/server/client/dist/assets/index-40Y2UsVr.js +205 -0
- package/server/client/dist/assets/index-BnxpTwtL.css +1 -0
- package/server/client/dist/assets/index-DYUg2N-6.js +1 -0
- package/server/client/dist/assets/manage-image-ByY1mRZx.js +1 -0
- package/server/client/dist/assets/manage-image-detail-BVaDZHRu.js +1 -0
- package/server/client/dist/assets/mcp-Jj3061aI.js +1 -0
- package/server/client/dist/assets/{note-core-DTIu0anW.js → note-core-BCgMq5QA.js} +7 -7
- package/server/client/dist/assets/note-vendor-BofYbzmZ.js +54 -0
- package/server/client/dist/assets/placeholder-4P_GK7fV.js +34 -0
- package/server/client/dist/assets/trash-CPBwY07S.js +1 -0
- package/server/client/dist/assets/useReminderMutate-1YVndM2T.js +82 -0
- package/server/client/dist/index.html +5 -4
- package/server/dist/app.js +97 -9
- package/server/dist/app.js.map +1 -1
- package/server/dist/main.js +16 -2
- package/server/dist/main.js.map +1 -1
- package/server/dist/modules/auth-guard.js +73 -0
- package/server/dist/modules/auth-guard.js.map +1 -0
- package/server/dist/modules/auth-mode.js +51 -0
- package/server/dist/modules/auth-mode.js.map +1 -0
- package/server/dist/modules/auth.js +9 -0
- package/server/dist/modules/auth.js.map +1 -1
- package/server/dist/modules/blocknote.js +7 -1
- package/server/dist/modules/blocknote.js.map +1 -1
- package/server/dist/modules/error-handler.js +41 -0
- package/server/dist/modules/error-handler.js.map +1 -0
- package/server/dist/modules/image-upload.js +114 -0
- package/server/dist/modules/image-upload.js.map +1 -0
- package/server/dist/modules/mcp-admin.js +88 -0
- package/server/dist/modules/mcp-admin.js.map +1 -0
- package/server/dist/modules/mcp-auth.js +71 -0
- package/server/dist/modules/mcp-auth.js.map +1 -0
- package/server/dist/modules/mcp-token.js +28 -0
- package/server/dist/modules/mcp-token.js.map +1 -0
- package/server/dist/modules/note-authoring.js +140 -0
- package/server/dist/modules/note-authoring.js.map +1 -0
- package/server/dist/modules/note-cleanup.js +207 -0
- package/server/dist/modules/note-cleanup.js.map +1 -0
- package/server/dist/modules/note-snapshot.js +223 -0
- package/server/dist/modules/note-snapshot.js.map +1 -0
- package/server/dist/modules/note-trash.js +262 -0
- package/server/dist/modules/note-trash.js.map +1 -0
- package/server/dist/modules/prisma-runtime.js +46 -0
- package/server/dist/modules/prisma-runtime.js.map +1 -0
- package/server/dist/modules/recovery-retention.js +26 -0
- package/server/dist/modules/recovery-retention.js.map +1 -0
- package/server/dist/modules/remote-image.js +176 -0
- package/server/dist/modules/remote-image.js.map +1 -0
- package/server/dist/modules/tag-organization.js +66 -0
- package/server/dist/modules/tag-organization.js.map +1 -0
- package/server/dist/modules/use-async.js +2 -4
- package/server/dist/modules/use-async.js.map +1 -1
- package/server/dist/schema/note/index.js +150 -6
- package/server/dist/schema/note/index.js.map +1 -1
- package/server/dist/start.js +11 -0
- package/server/dist/start.js.map +1 -0
- package/server/dist/types/index.js.map +1 -1
- package/server/dist/urls.js +3 -2
- package/server/dist/urls.js.map +1 -1
- package/server/dist/views/auth.js +305 -0
- package/server/dist/views/auth.js.map +1 -0
- package/server/dist/views/image.js +24 -81
- package/server/dist/views/image.js.map +1 -1
- package/server/dist/views/index.js +4 -0
- package/server/dist/views/index.js.map +1 -1
- package/server/dist/views/mcp-admin.js +42 -0
- package/server/dist/views/mcp-admin.js.map +1 -0
- package/server/dist/views/note.js +124 -0
- package/server/dist/views/note.js.map +1 -0
- package/server/dist/views/tag.js +23 -0
- package/server/dist/views/tag.js.map +1 -0
- package/server/prisma/migrations/20260331183000_0010_note_snapshot/migration.sql +13 -0
- package/server/prisma/migrations/20260331195000_0011_note_trash_entities/migration.sql +35 -0
- package/server/prisma/migrations/20260331213000_0012_image_hash_unique/migration.sql +49 -0
- package/server/prisma/migrations/20260404000000_0013_mcp_admin_control/migration.sql +9 -0
- package/server/prisma/schema.prisma +66 -1
- package/server/client/dist/assets/index-BJgBFWwL.css +0 -1
- package/server/client/dist/assets/index-DvNRv9YC.js +0 -391
- package/server/client/dist/assets/note-vendor-CUVHa9LU.js +0 -54
package/dist/mcp.js
CHANGED
|
@@ -1,20 +1,250 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/mcp.ts
|
|
4
|
-
import
|
|
5
|
-
import
|
|
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
|
|
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
|
-
|
|
214
|
+
fs2.readFileSync(path2.resolve(__dirname, "..", "package.json"), "utf-8")
|
|
13
215
|
);
|
|
14
|
-
|
|
15
|
-
|
|
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: {
|
|
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(
|
|
255
|
+
throw new Error(formatMcpGraphqlError(result.errors[0]));
|
|
26
256
|
}
|
|
27
257
|
return result.data;
|
|
28
258
|
}
|
|
29
|
-
async function
|
|
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
|
-
|
|
36
|
-
"Search notes by keyword. Returns
|
|
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:
|
|
39
|
-
limit:
|
|
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
|
-
|
|
77
|
-
"Read
|
|
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:
|
|
80
|
-
maxLength:
|
|
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
|
-
|
|
124
|
-
"
|
|
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
|
-
|
|
153
|
-
"List
|
|
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:
|
|
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
|
};
|