@roam-research/roam-mcp 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.
- package/README.md +212 -0
- package/dist/cli/connect.d.ts +10 -0
- package/dist/cli/connect.js +478 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +173 -0
- package/dist/core/client.d.ts +33 -0
- package/dist/core/client.js +275 -0
- package/dist/core/graph-resolver.d.ts +54 -0
- package/dist/core/graph-resolver.js +323 -0
- package/dist/core/operations/blocks.d.ts +119 -0
- package/dist/core/operations/blocks.js +108 -0
- package/dist/core/operations/files.d.ts +42 -0
- package/dist/core/operations/files.js +175 -0
- package/dist/core/operations/graphs.d.ts +25 -0
- package/dist/core/operations/graphs.js +214 -0
- package/dist/core/operations/index.d.ts +5 -0
- package/dist/core/operations/index.js +5 -0
- package/dist/core/operations/navigation.d.ts +31 -0
- package/dist/core/operations/navigation.js +54 -0
- package/dist/core/operations/pages.d.ts +62 -0
- package/dist/core/operations/pages.js +59 -0
- package/dist/core/operations/query.d.ts +33 -0
- package/dist/core/operations/query.js +46 -0
- package/dist/core/operations/search.d.ts +36 -0
- package/dist/core/operations/search.js +33 -0
- package/dist/core/roam-api.d.ts +31 -0
- package/dist/core/roam-api.js +50 -0
- package/dist/core/tools.d.ts +21 -0
- package/dist/core/tools.js +276 -0
- package/dist/core/types.d.ts +229 -0
- package/dist/core/types.js +86 -0
- package/dist/mcp/http.d.ts +5 -0
- package/dist/mcp/http.js +212 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +30 -0
- package/package.json +37 -0
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { RoamClient } from "../client.js";
|
|
3
|
+
import type { CallToolResult } from "../types.js";
|
|
4
|
+
export declare const CreateBlockSchema: z.ZodObject<{
|
|
5
|
+
parentUid: z.ZodString;
|
|
6
|
+
markdown: z.ZodString;
|
|
7
|
+
order: z.ZodOptional<z.ZodUnion<[z.ZodNumber, z.ZodEnum<["first", "last"]>]>>;
|
|
8
|
+
}, "strip", z.ZodTypeAny, {
|
|
9
|
+
markdown: string;
|
|
10
|
+
parentUid: string;
|
|
11
|
+
order?: number | "first" | "last" | undefined;
|
|
12
|
+
}, {
|
|
13
|
+
markdown: string;
|
|
14
|
+
parentUid: string;
|
|
15
|
+
order?: number | "first" | "last" | undefined;
|
|
16
|
+
}>;
|
|
17
|
+
export declare const GetBlockSchema: z.ZodObject<{
|
|
18
|
+
uid: z.ZodString;
|
|
19
|
+
maxDepth: z.ZodOptional<z.ZodNumber>;
|
|
20
|
+
}, "strip", z.ZodTypeAny, {
|
|
21
|
+
uid: string;
|
|
22
|
+
maxDepth?: number | undefined;
|
|
23
|
+
}, {
|
|
24
|
+
uid: string;
|
|
25
|
+
maxDepth?: number | undefined;
|
|
26
|
+
}>;
|
|
27
|
+
export declare const UpdateBlockSchema: z.ZodObject<{
|
|
28
|
+
uid: z.ZodString;
|
|
29
|
+
string: z.ZodOptional<z.ZodString>;
|
|
30
|
+
open: z.ZodOptional<z.ZodBoolean>;
|
|
31
|
+
heading: z.ZodOptional<z.ZodNumber>;
|
|
32
|
+
}, "strip", z.ZodTypeAny, {
|
|
33
|
+
uid: string;
|
|
34
|
+
string?: string | undefined;
|
|
35
|
+
open?: boolean | undefined;
|
|
36
|
+
heading?: number | undefined;
|
|
37
|
+
}, {
|
|
38
|
+
uid: string;
|
|
39
|
+
string?: string | undefined;
|
|
40
|
+
open?: boolean | undefined;
|
|
41
|
+
heading?: number | undefined;
|
|
42
|
+
}>;
|
|
43
|
+
export declare const DeleteBlockSchema: z.ZodObject<{
|
|
44
|
+
uid: z.ZodString;
|
|
45
|
+
}, "strip", z.ZodTypeAny, {
|
|
46
|
+
uid: string;
|
|
47
|
+
}, {
|
|
48
|
+
uid: string;
|
|
49
|
+
}>;
|
|
50
|
+
export declare const MoveBlockSchema: z.ZodObject<{
|
|
51
|
+
uid: z.ZodString;
|
|
52
|
+
parentUid: z.ZodString;
|
|
53
|
+
order: z.ZodUnion<[z.ZodNumber, z.ZodEnum<["first", "last"]>]>;
|
|
54
|
+
}, "strip", z.ZodTypeAny, {
|
|
55
|
+
uid: string;
|
|
56
|
+
parentUid: string;
|
|
57
|
+
order: number | "first" | "last";
|
|
58
|
+
}, {
|
|
59
|
+
uid: string;
|
|
60
|
+
parentUid: string;
|
|
61
|
+
order: number | "first" | "last";
|
|
62
|
+
}>;
|
|
63
|
+
export declare const GetBacklinksSchema: z.ZodObject<{
|
|
64
|
+
uid: z.ZodOptional<z.ZodString>;
|
|
65
|
+
title: z.ZodOptional<z.ZodString>;
|
|
66
|
+
offset: z.ZodOptional<z.ZodNumber>;
|
|
67
|
+
limit: z.ZodOptional<z.ZodNumber>;
|
|
68
|
+
sort: z.ZodOptional<z.ZodEnum<["created-date", "edited-date", "daily-note-date"]>>;
|
|
69
|
+
sortOrder: z.ZodOptional<z.ZodEnum<["asc", "desc"]>>;
|
|
70
|
+
search: z.ZodOptional<z.ZodString>;
|
|
71
|
+
includePath: z.ZodOptional<z.ZodBoolean>;
|
|
72
|
+
maxDepth: z.ZodOptional<z.ZodNumber>;
|
|
73
|
+
}, "strip", z.ZodTypeAny, {
|
|
74
|
+
title?: string | undefined;
|
|
75
|
+
sort?: "created-date" | "edited-date" | "daily-note-date" | undefined;
|
|
76
|
+
search?: string | undefined;
|
|
77
|
+
uid?: string | undefined;
|
|
78
|
+
maxDepth?: number | undefined;
|
|
79
|
+
offset?: number | undefined;
|
|
80
|
+
limit?: number | undefined;
|
|
81
|
+
sortOrder?: "asc" | "desc" | undefined;
|
|
82
|
+
includePath?: boolean | undefined;
|
|
83
|
+
}, {
|
|
84
|
+
title?: string | undefined;
|
|
85
|
+
sort?: "created-date" | "edited-date" | "daily-note-date" | undefined;
|
|
86
|
+
search?: string | undefined;
|
|
87
|
+
uid?: string | undefined;
|
|
88
|
+
maxDepth?: number | undefined;
|
|
89
|
+
offset?: number | undefined;
|
|
90
|
+
limit?: number | undefined;
|
|
91
|
+
sortOrder?: "asc" | "desc" | undefined;
|
|
92
|
+
includePath?: boolean | undefined;
|
|
93
|
+
}>;
|
|
94
|
+
export type CreateBlockParams = z.infer<typeof CreateBlockSchema>;
|
|
95
|
+
export type GetBlockParams = z.infer<typeof GetBlockSchema>;
|
|
96
|
+
export type UpdateBlockParams = z.infer<typeof UpdateBlockSchema>;
|
|
97
|
+
export type DeleteBlockParams = z.infer<typeof DeleteBlockSchema>;
|
|
98
|
+
export type MoveBlockParams = z.infer<typeof MoveBlockSchema>;
|
|
99
|
+
export type GetBacklinksParams = z.infer<typeof GetBacklinksSchema>;
|
|
100
|
+
export interface BacklinkResult {
|
|
101
|
+
uid: string;
|
|
102
|
+
type?: "page";
|
|
103
|
+
markdown: string;
|
|
104
|
+
path?: Array<{
|
|
105
|
+
uid: string;
|
|
106
|
+
title?: string;
|
|
107
|
+
string?: string;
|
|
108
|
+
}>;
|
|
109
|
+
}
|
|
110
|
+
export interface GetBacklinksResponse {
|
|
111
|
+
total: number;
|
|
112
|
+
results: BacklinkResult[];
|
|
113
|
+
}
|
|
114
|
+
export declare function createBlock(client: RoamClient, params: CreateBlockParams): Promise<CallToolResult>;
|
|
115
|
+
export declare function getBlock(client: RoamClient, params: GetBlockParams): Promise<CallToolResult>;
|
|
116
|
+
export declare function updateBlock(client: RoamClient, params: UpdateBlockParams): Promise<CallToolResult>;
|
|
117
|
+
export declare function deleteBlock(client: RoamClient, params: DeleteBlockParams): Promise<CallToolResult>;
|
|
118
|
+
export declare function moveBlock(client: RoamClient, params: MoveBlockParams): Promise<CallToolResult>;
|
|
119
|
+
export declare function getBacklinks(client: RoamClient, params: GetBacklinksParams): Promise<CallToolResult>;
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { textResult } from "../types.js";
|
|
3
|
+
// Schemas
|
|
4
|
+
export const CreateBlockSchema = z.object({
|
|
5
|
+
parentUid: z.string().describe("UID of parent block or page"),
|
|
6
|
+
markdown: z.string().describe("Markdown content for the block"),
|
|
7
|
+
order: z.union([z.coerce.number(), z.enum(["first", "last"])]).optional().describe("Position (number, 'first', or 'last'). Defaults to 'last'"),
|
|
8
|
+
});
|
|
9
|
+
export const GetBlockSchema = z.object({
|
|
10
|
+
uid: z.string().describe("Block UID"),
|
|
11
|
+
maxDepth: z.coerce.number().optional().describe("Max depth of children to include in markdown (omit for full tree)"),
|
|
12
|
+
});
|
|
13
|
+
export const UpdateBlockSchema = z.object({
|
|
14
|
+
uid: z.string().describe("Block UID"),
|
|
15
|
+
string: z.string().optional().describe("New text content"),
|
|
16
|
+
open: z.boolean().optional().describe("Collapse state"),
|
|
17
|
+
heading: z.coerce.number().optional().describe("Heading level (0-3)"),
|
|
18
|
+
});
|
|
19
|
+
export const DeleteBlockSchema = z.object({
|
|
20
|
+
uid: z.string().describe("Block UID to delete"),
|
|
21
|
+
});
|
|
22
|
+
export const MoveBlockSchema = z.object({
|
|
23
|
+
uid: z.string().describe("Block UID to move"),
|
|
24
|
+
parentUid: z.string().describe("UID of the new parent block or page"),
|
|
25
|
+
order: z.union([z.coerce.number(), z.enum(["first", "last"])]).describe("Position in the new parent (number, 'first', or 'last')"),
|
|
26
|
+
});
|
|
27
|
+
export const GetBacklinksSchema = z.object({
|
|
28
|
+
uid: z.string().optional().describe("UID of page or block (required if no title)"),
|
|
29
|
+
title: z.string().optional().describe("Page title (required if no uid)"),
|
|
30
|
+
offset: z.coerce.number().optional().describe("Skip first N results (default: 0)"),
|
|
31
|
+
limit: z.coerce.number().optional().describe("Max results to return (default: 20)"),
|
|
32
|
+
sort: z.enum(["created-date", "edited-date", "daily-note-date"]).optional().describe("Sort order (default: created-date)"),
|
|
33
|
+
sortOrder: z.enum(["asc", "desc"]).optional().describe("Sort direction (default: desc)"),
|
|
34
|
+
search: z.string().optional().describe("Filter results by text match (searches block, parents, children, page title)"),
|
|
35
|
+
includePath: z.boolean().optional().describe("Include breadcrumb path to each result (default: true)"),
|
|
36
|
+
maxDepth: z.coerce.number().optional().describe("Max depth of children to include in markdown (default: 2)"),
|
|
37
|
+
});
|
|
38
|
+
export async function createBlock(client, params) {
|
|
39
|
+
const response = await client.call("data.block.fromMarkdown", [
|
|
40
|
+
{
|
|
41
|
+
location: {
|
|
42
|
+
"parent-uid": params.parentUid,
|
|
43
|
+
order: params.order ?? "last",
|
|
44
|
+
},
|
|
45
|
+
"markdown-string": params.markdown,
|
|
46
|
+
},
|
|
47
|
+
]);
|
|
48
|
+
return textResult(response.result ?? { uids: [] });
|
|
49
|
+
}
|
|
50
|
+
export async function getBlock(client, params) {
|
|
51
|
+
const apiParams = { uid: params.uid };
|
|
52
|
+
if (params.maxDepth !== undefined)
|
|
53
|
+
apiParams.maxDepth = params.maxDepth;
|
|
54
|
+
const response = await client.call("data.ai.getBlock", [apiParams]);
|
|
55
|
+
return textResult(response.result ?? null);
|
|
56
|
+
}
|
|
57
|
+
export async function updateBlock(client, params) {
|
|
58
|
+
const block = { uid: params.uid };
|
|
59
|
+
if (params.string !== undefined)
|
|
60
|
+
block.string = params.string;
|
|
61
|
+
if (params.open !== undefined)
|
|
62
|
+
block.open = params.open;
|
|
63
|
+
if (params.heading !== undefined)
|
|
64
|
+
block.heading = params.heading;
|
|
65
|
+
await client.call("data.block.update", [{ block }]);
|
|
66
|
+
return textResult({ success: true });
|
|
67
|
+
}
|
|
68
|
+
export async function deleteBlock(client, params) {
|
|
69
|
+
await client.call("data.block.delete", [{ block: { uid: params.uid } }]);
|
|
70
|
+
return textResult({ success: true });
|
|
71
|
+
}
|
|
72
|
+
export async function moveBlock(client, params) {
|
|
73
|
+
await client.call("data.block.move", [
|
|
74
|
+
{
|
|
75
|
+
location: {
|
|
76
|
+
"parent-uid": params.parentUid,
|
|
77
|
+
order: params.order,
|
|
78
|
+
},
|
|
79
|
+
block: {
|
|
80
|
+
uid: params.uid,
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
]);
|
|
84
|
+
return textResult({ success: true });
|
|
85
|
+
}
|
|
86
|
+
export async function getBacklinks(client, params) {
|
|
87
|
+
const apiParams = {};
|
|
88
|
+
if (params.uid !== undefined)
|
|
89
|
+
apiParams.uid = params.uid;
|
|
90
|
+
if (params.title !== undefined)
|
|
91
|
+
apiParams.title = params.title;
|
|
92
|
+
if (params.offset !== undefined)
|
|
93
|
+
apiParams.offset = params.offset;
|
|
94
|
+
if (params.limit !== undefined)
|
|
95
|
+
apiParams.limit = params.limit;
|
|
96
|
+
if (params.sort !== undefined)
|
|
97
|
+
apiParams.sort = params.sort;
|
|
98
|
+
if (params.sortOrder !== undefined)
|
|
99
|
+
apiParams.sortOrder = params.sortOrder;
|
|
100
|
+
if (params.search !== undefined)
|
|
101
|
+
apiParams.search = params.search;
|
|
102
|
+
if (params.includePath !== undefined)
|
|
103
|
+
apiParams.includePath = params.includePath;
|
|
104
|
+
if (params.maxDepth !== undefined)
|
|
105
|
+
apiParams.maxDepth = params.maxDepth;
|
|
106
|
+
const response = await client.call("data.ai.getBacklinks", [apiParams]);
|
|
107
|
+
return textResult(response.result ?? { total: 0, results: [] });
|
|
108
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { RoamClient } from "../client.js";
|
|
3
|
+
import type { CallToolResult } from "../types.js";
|
|
4
|
+
export declare const FileGetSchema: z.ZodObject<{
|
|
5
|
+
url: z.ZodString;
|
|
6
|
+
}, "strip", z.ZodTypeAny, {
|
|
7
|
+
url: string;
|
|
8
|
+
}, {
|
|
9
|
+
url: string;
|
|
10
|
+
}>;
|
|
11
|
+
export declare const FileUploadSchema: z.ZodObject<{
|
|
12
|
+
filePath: z.ZodOptional<z.ZodString>;
|
|
13
|
+
url: z.ZodOptional<z.ZodString>;
|
|
14
|
+
base64: z.ZodOptional<z.ZodString>;
|
|
15
|
+
mimetype: z.ZodOptional<z.ZodString>;
|
|
16
|
+
filename: z.ZodOptional<z.ZodString>;
|
|
17
|
+
}, "strip", z.ZodTypeAny, {
|
|
18
|
+
url?: string | undefined;
|
|
19
|
+
base64?: string | undefined;
|
|
20
|
+
filePath?: string | undefined;
|
|
21
|
+
mimetype?: string | undefined;
|
|
22
|
+
filename?: string | undefined;
|
|
23
|
+
}, {
|
|
24
|
+
url?: string | undefined;
|
|
25
|
+
base64?: string | undefined;
|
|
26
|
+
filePath?: string | undefined;
|
|
27
|
+
mimetype?: string | undefined;
|
|
28
|
+
filename?: string | undefined;
|
|
29
|
+
}>;
|
|
30
|
+
export declare const FileDeleteSchema: z.ZodObject<{
|
|
31
|
+
url: z.ZodString;
|
|
32
|
+
}, "strip", z.ZodTypeAny, {
|
|
33
|
+
url: string;
|
|
34
|
+
}, {
|
|
35
|
+
url: string;
|
|
36
|
+
}>;
|
|
37
|
+
export type FileGetParams = z.infer<typeof FileGetSchema>;
|
|
38
|
+
export type FileUploadParams = z.infer<typeof FileUploadSchema>;
|
|
39
|
+
export type FileDeleteParams = z.infer<typeof FileDeleteSchema>;
|
|
40
|
+
export declare function getFile(client: RoamClient, params: FileGetParams): Promise<CallToolResult>;
|
|
41
|
+
export declare function uploadFile(client: RoamClient, params: FileUploadParams): Promise<CallToolResult>;
|
|
42
|
+
export declare function deleteFile(client: RoamClient, params: FileDeleteParams): Promise<CallToolResult>;
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { readFile } from "fs/promises";
|
|
3
|
+
import { basename, extname } from "path";
|
|
4
|
+
import { imageResult, textResult } from "../types.js";
|
|
5
|
+
// Schemas
|
|
6
|
+
export const FileGetSchema = z.object({
|
|
7
|
+
url: z.string().describe("Firebase storage URL of the file"),
|
|
8
|
+
});
|
|
9
|
+
export const FileUploadSchema = z.object({
|
|
10
|
+
filePath: z.string().optional().describe("Local file path (preferred) - server reads the file directly"),
|
|
11
|
+
url: z.string().optional().describe("Remote URL to fetch the image from"),
|
|
12
|
+
base64: z.string().optional().describe("Base64-encoded image data (fallback for sandboxed clients)"),
|
|
13
|
+
mimetype: z.string().optional().describe("MIME type (e.g., image/png, image/jpeg) - auto-detected if not provided"),
|
|
14
|
+
filename: z.string().optional().describe("Original filename for reference - derived from path/url if not provided"),
|
|
15
|
+
});
|
|
16
|
+
export const FileDeleteSchema = z.object({
|
|
17
|
+
url: z.string().describe("Firebase storage URL of the file to delete"),
|
|
18
|
+
});
|
|
19
|
+
// Detect MIME type from base64 image data by checking magic bytes
|
|
20
|
+
function detectMimeTypeFromBase64(base64) {
|
|
21
|
+
// JPEG: FF D8 FF -> /9j/
|
|
22
|
+
if (base64.startsWith("/9j/"))
|
|
23
|
+
return "image/jpeg";
|
|
24
|
+
// PNG: 89 50 4E 47 -> iVBOR
|
|
25
|
+
if (base64.startsWith("iVBOR"))
|
|
26
|
+
return "image/png";
|
|
27
|
+
// GIF: 47 49 46 38 -> R0lG
|
|
28
|
+
if (base64.startsWith("R0lG"))
|
|
29
|
+
return "image/gif";
|
|
30
|
+
// WebP: 52 49 46 46 ... 57 45 42 50 -> UklGR
|
|
31
|
+
if (base64.startsWith("UklGR"))
|
|
32
|
+
return "image/webp";
|
|
33
|
+
// BMP: 42 4D -> Qk
|
|
34
|
+
if (base64.startsWith("Qk"))
|
|
35
|
+
return "image/bmp";
|
|
36
|
+
// PDF: 25 50 44 46 -> JVBE
|
|
37
|
+
if (base64.startsWith("JVBE"))
|
|
38
|
+
return "application/pdf";
|
|
39
|
+
// TIFF (little-endian): 49 49 2A 00 -> SUkq
|
|
40
|
+
if (base64.startsWith("SUkq"))
|
|
41
|
+
return "image/tiff";
|
|
42
|
+
// TIFF (big-endian): 4D 4D 00 2A -> TU0A
|
|
43
|
+
if (base64.startsWith("TU0A"))
|
|
44
|
+
return "image/tiff";
|
|
45
|
+
// ICO: 00 00 01 00 -> AAAB
|
|
46
|
+
if (base64.startsWith("AAAB"))
|
|
47
|
+
return "image/x-icon";
|
|
48
|
+
// AVIF/HEIC: check for ftyp box (base64 varies, check common patterns)
|
|
49
|
+
// These are harder to detect reliably from base64 prefix alone
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
// Detect MIME type from file extension
|
|
53
|
+
function detectMimeTypeFromExtension(filePath) {
|
|
54
|
+
const ext = extname(filePath).toLowerCase();
|
|
55
|
+
const mimeTypes = {
|
|
56
|
+
// Common image formats
|
|
57
|
+
".jpg": "image/jpeg",
|
|
58
|
+
".jpeg": "image/jpeg",
|
|
59
|
+
".png": "image/png",
|
|
60
|
+
".gif": "image/gif",
|
|
61
|
+
".webp": "image/webp",
|
|
62
|
+
".bmp": "image/bmp",
|
|
63
|
+
".svg": "image/svg+xml",
|
|
64
|
+
".ico": "image/x-icon",
|
|
65
|
+
".tiff": "image/tiff",
|
|
66
|
+
".tif": "image/tiff",
|
|
67
|
+
// Modern formats
|
|
68
|
+
".heic": "image/heic",
|
|
69
|
+
".heif": "image/heif",
|
|
70
|
+
".avif": "image/avif",
|
|
71
|
+
// Documents
|
|
72
|
+
".pdf": "application/pdf",
|
|
73
|
+
};
|
|
74
|
+
return mimeTypes[ext] || null;
|
|
75
|
+
}
|
|
76
|
+
// Read file from local path and return base64 + mimetype
|
|
77
|
+
async function readLocalFile(filePath) {
|
|
78
|
+
const buffer = await readFile(filePath);
|
|
79
|
+
const base64 = buffer.toString("base64");
|
|
80
|
+
const filename = basename(filePath);
|
|
81
|
+
// Try extension first, then magic bytes
|
|
82
|
+
let mimetype = detectMimeTypeFromExtension(filePath);
|
|
83
|
+
if (!mimetype) {
|
|
84
|
+
mimetype = detectMimeTypeFromBase64(base64);
|
|
85
|
+
}
|
|
86
|
+
if (!mimetype) {
|
|
87
|
+
throw new Error(`Could not detect MIME type for file: ${filePath}`);
|
|
88
|
+
}
|
|
89
|
+
return { base64, mimetype, filename };
|
|
90
|
+
}
|
|
91
|
+
// Fetch file from URL and return base64 + mimetype
|
|
92
|
+
async function fetchRemoteFile(url) {
|
|
93
|
+
const response = await fetch(url);
|
|
94
|
+
if (!response.ok) {
|
|
95
|
+
throw new Error(`Failed to fetch ${url}: ${response.status} ${response.statusText}`);
|
|
96
|
+
}
|
|
97
|
+
const buffer = await response.arrayBuffer();
|
|
98
|
+
const base64 = Buffer.from(buffer).toString("base64");
|
|
99
|
+
// Extract filename from URL path
|
|
100
|
+
const urlPath = new URL(url).pathname;
|
|
101
|
+
const filename = basename(urlPath) || "image";
|
|
102
|
+
// Try Content-Type header first, then extension, then magic bytes
|
|
103
|
+
let mimetype = response.headers.get("content-type")?.split(";")[0];
|
|
104
|
+
if (!mimetype || mimetype === "application/octet-stream") {
|
|
105
|
+
mimetype = detectMimeTypeFromExtension(urlPath) || detectMimeTypeFromBase64(base64) || undefined;
|
|
106
|
+
}
|
|
107
|
+
if (!mimetype) {
|
|
108
|
+
throw new Error(`Could not detect MIME type for URL: ${url}`);
|
|
109
|
+
}
|
|
110
|
+
return { base64, mimetype, filename };
|
|
111
|
+
}
|
|
112
|
+
export async function getFile(client, params) {
|
|
113
|
+
const response = await client.call("file.get", [
|
|
114
|
+
{ url: params.url, format: "base64" },
|
|
115
|
+
]);
|
|
116
|
+
if (!response.result) {
|
|
117
|
+
throw new Error("No file data returned");
|
|
118
|
+
}
|
|
119
|
+
const { base64, mimetype } = response.result;
|
|
120
|
+
const mimeType = mimetype || detectMimeTypeFromBase64(base64);
|
|
121
|
+
if (mimeType?.startsWith("image/")) {
|
|
122
|
+
return imageResult(base64, mimeType);
|
|
123
|
+
}
|
|
124
|
+
// Non-image file - return as text (base64 encoded)
|
|
125
|
+
return textResult(response.result);
|
|
126
|
+
}
|
|
127
|
+
export async function uploadFile(client, params) {
|
|
128
|
+
// Validate exactly one source is provided
|
|
129
|
+
const sources = [params.filePath, params.url, params.base64].filter(Boolean);
|
|
130
|
+
if (sources.length === 0) {
|
|
131
|
+
throw new Error("One of filePath, url, or base64 must be provided");
|
|
132
|
+
}
|
|
133
|
+
if (sources.length > 1) {
|
|
134
|
+
throw new Error("Only one of filePath, url, or base64 should be provided");
|
|
135
|
+
}
|
|
136
|
+
let base64;
|
|
137
|
+
let mimetype;
|
|
138
|
+
let filename;
|
|
139
|
+
if (params.filePath) {
|
|
140
|
+
// Read from local file system
|
|
141
|
+
const fileData = await readLocalFile(params.filePath);
|
|
142
|
+
base64 = fileData.base64;
|
|
143
|
+
mimetype = params.mimetype || fileData.mimetype;
|
|
144
|
+
filename = params.filename || fileData.filename;
|
|
145
|
+
}
|
|
146
|
+
else if (params.url) {
|
|
147
|
+
// Fetch from remote URL
|
|
148
|
+
const fileData = await fetchRemoteFile(params.url);
|
|
149
|
+
base64 = fileData.base64;
|
|
150
|
+
mimetype = params.mimetype || fileData.mimetype;
|
|
151
|
+
filename = params.filename || fileData.filename;
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
// Use provided base64 directly (params.base64 must be set due to validation above)
|
|
155
|
+
base64 = params.base64;
|
|
156
|
+
mimetype = params.mimetype || detectMimeTypeFromBase64(base64) || "application/octet-stream";
|
|
157
|
+
filename = params.filename;
|
|
158
|
+
}
|
|
159
|
+
const response = await client.call("file.upload", [
|
|
160
|
+
{ base64, mimetype, filename },
|
|
161
|
+
]);
|
|
162
|
+
if (!response.result) {
|
|
163
|
+
throw new Error("No URL returned from upload");
|
|
164
|
+
}
|
|
165
|
+
let url = response.result;
|
|
166
|
+
// Strip markdown image wrapper if present (API currently returns "")
|
|
167
|
+
if (url.startsWith(" && url.endsWith(")")) {
|
|
168
|
+
url = url.slice(4, -1);
|
|
169
|
+
}
|
|
170
|
+
return textResult({ url });
|
|
171
|
+
}
|
|
172
|
+
export async function deleteFile(client, params) {
|
|
173
|
+
await client.call("file.delete", [{ url: params.url }]);
|
|
174
|
+
return textResult({ deleted: true });
|
|
175
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import type { CallToolResult } from "../types.js";
|
|
3
|
+
export declare const ListGraphsSchema: z.ZodObject<{}, "strip", z.ZodTypeAny, {}, {}>;
|
|
4
|
+
export declare const SetupNewGraphSchema: z.ZodObject<{
|
|
5
|
+
graph: z.ZodOptional<z.ZodString>;
|
|
6
|
+
nickname: z.ZodOptional<z.ZodString>;
|
|
7
|
+
}, "strip", z.ZodTypeAny, {
|
|
8
|
+
nickname?: string | undefined;
|
|
9
|
+
graph?: string | undefined;
|
|
10
|
+
}, {
|
|
11
|
+
nickname?: string | undefined;
|
|
12
|
+
graph?: string | undefined;
|
|
13
|
+
}>;
|
|
14
|
+
export type ListGraphsParams = z.infer<typeof ListGraphsSchema>;
|
|
15
|
+
export type SetupNewGraphParams = z.infer<typeof SetupNewGraphSchema>;
|
|
16
|
+
/**
|
|
17
|
+
* List all configured graphs with their nicknames.
|
|
18
|
+
*/
|
|
19
|
+
export declare function listGraphs(): Promise<CallToolResult>;
|
|
20
|
+
/**
|
|
21
|
+
* Set up a new Roam graph connection, or list available graphs.
|
|
22
|
+
* Call without arguments to list available graphs from Roam Desktop.
|
|
23
|
+
* Call with graph + nickname to request a token and save the configuration.
|
|
24
|
+
*/
|
|
25
|
+
export declare function setupNewGraph(args: SetupNewGraphParams): Promise<CallToolResult>;
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
// src/core/operations/graphs.ts
|
|
2
|
+
// Graph management operations
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { textResult, RoamError, ErrorCodes } from "../types.js";
|
|
5
|
+
import { getConfiguredGraphs, getConfiguredGraphsSafe, getPort, saveGraphToConfig, } from "../graph-resolver.js";
|
|
6
|
+
import { fetchAvailableGraphs, requestToken, sleep, openRoamApp, slugify, } from "../roam-api.js";
|
|
7
|
+
// ============================================================================
|
|
8
|
+
// Schemas
|
|
9
|
+
// ============================================================================
|
|
10
|
+
export const ListGraphsSchema = z.object({});
|
|
11
|
+
export const SetupNewGraphSchema = z.object({
|
|
12
|
+
graph: z
|
|
13
|
+
.string()
|
|
14
|
+
.regex(/^[A-Za-z0-9_-]+$/, "Graph name must contain only letters, numbers, hyphens, and underscores")
|
|
15
|
+
.optional()
|
|
16
|
+
.describe("The canonical Roam graph name. Omit graph and nickname to list available graphs."),
|
|
17
|
+
nickname: z
|
|
18
|
+
.string()
|
|
19
|
+
.min(1, "Nickname must not be empty")
|
|
20
|
+
.optional()
|
|
21
|
+
.describe("A short, memorable label describing what this graph is for (e.g. 'my personal graph', 'work notes', 'book club'). " +
|
|
22
|
+
"Ask the user what they call this graph — use their natural language, not hyphenated format. " +
|
|
23
|
+
"Do not just copy the graph name. Required when graph is provided."),
|
|
24
|
+
});
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Operations
|
|
27
|
+
// ============================================================================
|
|
28
|
+
/**
|
|
29
|
+
* List all configured graphs with their nicknames.
|
|
30
|
+
*/
|
|
31
|
+
export async function listGraphs() {
|
|
32
|
+
try {
|
|
33
|
+
const graphs = await getConfiguredGraphs();
|
|
34
|
+
return textResult({
|
|
35
|
+
graphs,
|
|
36
|
+
instruction: "Pass the 'nickname' value as the graph parameter. Before operating on a graph, call get_graph_guidelines to understand its conventions.",
|
|
37
|
+
setup: "To connect additional graphs, use the setup_new_graph tool (call it without arguments to see available graphs).",
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (error instanceof RoamError) {
|
|
42
|
+
return textResult({
|
|
43
|
+
error: {
|
|
44
|
+
code: error.code,
|
|
45
|
+
message: error.message,
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Dedup available graphs: if same name exists as both hosted and offline, keep hosted.
|
|
54
|
+
* Consistent with getMcpConfig() dedup behavior in graph-resolver.ts.
|
|
55
|
+
*/
|
|
56
|
+
function dedupAvailableGraphs(graphs) {
|
|
57
|
+
const byName = new Map();
|
|
58
|
+
for (const g of graphs) {
|
|
59
|
+
const existing = byName.get(g.name);
|
|
60
|
+
if (existing) {
|
|
61
|
+
if (g.type === "hosted")
|
|
62
|
+
byName.set(g.name, g);
|
|
63
|
+
}
|
|
64
|
+
else {
|
|
65
|
+
byName.set(g.name, g);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return Array.from(byName.values());
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Fetch available graphs from Roam Desktop, retrying once if Roam isn't running.
|
|
72
|
+
* Deduplicates by name (hosted takes priority over offline).
|
|
73
|
+
*/
|
|
74
|
+
async function fetchAvailableGraphsWithRetry(port) {
|
|
75
|
+
let raw;
|
|
76
|
+
try {
|
|
77
|
+
raw = await fetchAvailableGraphs(port);
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
const err = error;
|
|
81
|
+
const isConnectionError = err.cause?.code === "ECONNREFUSED" ||
|
|
82
|
+
err.message?.includes("fetch failed");
|
|
83
|
+
if (!isConnectionError) {
|
|
84
|
+
throw error;
|
|
85
|
+
}
|
|
86
|
+
await openRoamApp();
|
|
87
|
+
await sleep(5000);
|
|
88
|
+
try {
|
|
89
|
+
raw = await fetchAvailableGraphs(port);
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
throw new RoamError("Could not connect to Roam Desktop. Make sure it is running and the Local API is enabled in Settings > Local API.", ErrorCodes.CONNECTION_FAILED);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return dedupAvailableGraphs(raw);
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Set up a new Roam graph connection, or list available graphs.
|
|
99
|
+
* Call without arguments to list available graphs from Roam Desktop.
|
|
100
|
+
* Call with graph + nickname to request a token and save the configuration.
|
|
101
|
+
*/
|
|
102
|
+
export async function setupNewGraph(args) {
|
|
103
|
+
const { graph, nickname: rawNickname } = args;
|
|
104
|
+
// List mode: no args → return available graphs from Roam Desktop
|
|
105
|
+
if (!graph) {
|
|
106
|
+
const port = await getPort();
|
|
107
|
+
const availableGraphs = await fetchAvailableGraphsWithRetry(port);
|
|
108
|
+
const configuredGraphs = await getConfiguredGraphsSafe();
|
|
109
|
+
return textResult({
|
|
110
|
+
available_graphs: availableGraphs.map((g) => ({
|
|
111
|
+
name: g.name,
|
|
112
|
+
type: g.type,
|
|
113
|
+
})),
|
|
114
|
+
already_configured: configuredGraphs.map((g) => ({
|
|
115
|
+
name: g.name,
|
|
116
|
+
nickname: g.nickname,
|
|
117
|
+
type: g.type,
|
|
118
|
+
accessLevel: g.accessLevel,
|
|
119
|
+
lastKnownTokenStatus: g.lastKnownTokenStatus,
|
|
120
|
+
})),
|
|
121
|
+
instruction: "Call setup_new_graph with graph and nickname to connect one of the available graphs.",
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
// Setup mode: graph provided, nickname required
|
|
125
|
+
if (!rawNickname) {
|
|
126
|
+
throw new RoamError("nickname is required when graph is provided.", ErrorCodes.VALIDATION_ERROR);
|
|
127
|
+
}
|
|
128
|
+
// 1. Slugify nickname
|
|
129
|
+
const nickname = slugify(rawNickname);
|
|
130
|
+
if (!nickname) {
|
|
131
|
+
throw new RoamError(`Nickname "${rawNickname}" produces an empty result after converting to kebab-case. Use a nickname with at least one letter or number.`, ErrorCodes.VALIDATION_ERROR);
|
|
132
|
+
}
|
|
133
|
+
// 2. Check if graph is already configured
|
|
134
|
+
const existingGraphs = await getConfiguredGraphsSafe();
|
|
135
|
+
const matchingConfigs = existingGraphs.filter((g) => g.name === graph);
|
|
136
|
+
if (matchingConfigs.length > 0) {
|
|
137
|
+
const allRevoked = matchingConfigs.every((g) => g.lastKnownTokenStatus === "revoked");
|
|
138
|
+
if (!allRevoked) {
|
|
139
|
+
// At least one active config — return as already configured
|
|
140
|
+
return textResult({
|
|
141
|
+
status: "already_configured",
|
|
142
|
+
graphs: matchingConfigs.map((g) => ({
|
|
143
|
+
name: g.name,
|
|
144
|
+
nickname: g.nickname,
|
|
145
|
+
type: g.type,
|
|
146
|
+
accessLevel: g.accessLevel,
|
|
147
|
+
lastKnownTokenStatus: g.lastKnownTokenStatus,
|
|
148
|
+
})),
|
|
149
|
+
instruction: "This graph is already configured. Pass the 'nickname' value as the graph parameter. Call get_graph_guidelines before operating on it.",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
// All revoked — fall through to re-request a new token
|
|
153
|
+
}
|
|
154
|
+
// 3. Check nickname collision
|
|
155
|
+
const nicknameCollision = existingGraphs.find((g) => g.nickname.toLowerCase() === nickname.toLowerCase() && g.name !== graph);
|
|
156
|
+
if (nicknameCollision) {
|
|
157
|
+
throw new RoamError(`Nickname "${nickname}" is already used by graph "${nicknameCollision.name}". Please choose a different nickname.`, ErrorCodes.VALIDATION_ERROR);
|
|
158
|
+
}
|
|
159
|
+
// 4. Get port and fetch available graphs
|
|
160
|
+
const port = await getPort();
|
|
161
|
+
const availableGraphs = await fetchAvailableGraphsWithRetry(port);
|
|
162
|
+
// 5. Find graph type
|
|
163
|
+
const graphInfo = availableGraphs.find((g) => g.name === graph);
|
|
164
|
+
if (!graphInfo) {
|
|
165
|
+
throw new RoamError(`Graph "${graph}" was not found in Roam Desktop. Make sure the graph name is correct and that it is available in the app.`, ErrorCodes.VALIDATION_ERROR, {
|
|
166
|
+
available_graphs: availableGraphs.map((g) => g.name),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
// 6. Request token (blocks until user approves/denies in Roam)
|
|
170
|
+
const result = await requestToken(port, graph, graphInfo.type, "full");
|
|
171
|
+
// 7. Handle errors
|
|
172
|
+
if (!result.success || !result.token) {
|
|
173
|
+
const error = result.error;
|
|
174
|
+
const errorCode = error && typeof error === "object" ? error.code : undefined;
|
|
175
|
+
const errorMessage = error
|
|
176
|
+
? typeof error === "string"
|
|
177
|
+
? error
|
|
178
|
+
: error.message || "Unknown error"
|
|
179
|
+
: "Unknown error";
|
|
180
|
+
switch (errorCode) {
|
|
181
|
+
case "USER_REJECTED":
|
|
182
|
+
throw new RoamError("Token request was denied in Roam. The user must approve the request in the Roam desktop app.", ErrorCodes.USER_REJECTED);
|
|
183
|
+
case "GRAPH_BLOCKED":
|
|
184
|
+
throw new RoamError("This graph has blocked token requests. Unblock it in Roam Settings > Graph > Local API Tokens.", ErrorCodes.GRAPH_BLOCKED);
|
|
185
|
+
case "TIMEOUT":
|
|
186
|
+
throw new RoamError("No response after 5 minutes. Please try again — the user needs to approve the request in the Roam desktop app.", ErrorCodes.TIMEOUT);
|
|
187
|
+
case "REQUEST_IN_PROGRESS":
|
|
188
|
+
throw new RoamError("Another token request is already pending for this graph. The user should respond to the existing request in Roam first.", ErrorCodes.REQUEST_IN_PROGRESS);
|
|
189
|
+
default:
|
|
190
|
+
throw new RoamError(`Token request failed: ${errorMessage}`, ErrorCodes.INTERNAL_ERROR);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
// 8. Save to config
|
|
194
|
+
const accessLevel = (result.grantedAccessLevel || "full");
|
|
195
|
+
const graphConfig = {
|
|
196
|
+
name: graph,
|
|
197
|
+
type: graphInfo.type,
|
|
198
|
+
token: result.token,
|
|
199
|
+
nickname,
|
|
200
|
+
accessLevel,
|
|
201
|
+
};
|
|
202
|
+
await saveGraphToConfig(graphConfig);
|
|
203
|
+
// 9. Return success
|
|
204
|
+
return textResult({
|
|
205
|
+
status: "connected",
|
|
206
|
+
graph: {
|
|
207
|
+
name: graph,
|
|
208
|
+
nickname,
|
|
209
|
+
type: graphInfo.type,
|
|
210
|
+
accessLevel,
|
|
211
|
+
},
|
|
212
|
+
instruction: "Graph connected successfully. Call get_graph_guidelines next to understand the graph's conventions before making any changes.",
|
|
213
|
+
});
|
|
214
|
+
}
|