memos-mcp 1.0.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/src/index.ts ADDED
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Memos MCP Server
4
+ * A Model Context Protocol server for interacting with Memos instances
5
+ */
6
+
7
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
8
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
9
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
10
+ import type { Tool } from "@modelcontextprotocol/sdk/types.js";
11
+ import { MemosClient } from "./memos-client.ts";
12
+
13
+ // Configuration from environment variables
14
+ const MEMOS_URL = process.env.MEMOS_URL;
15
+ const MEMOS_ACCESS_TOKEN = process.env.MEMOS_ACCESS_TOKEN;
16
+
17
+ // Validate configuration
18
+ function validateConfig(): void {
19
+ if (!MEMOS_URL) {
20
+ console.error("Error: MEMOS_URL environment variable is required");
21
+ console.error("Set it to your Memos instance URL, e.g., https://memos.example.com");
22
+ process.exit(1);
23
+ }
24
+ if (!MEMOS_ACCESS_TOKEN) {
25
+ console.error("Error: MEMOS_ACCESS_TOKEN environment variable is required");
26
+ console.error("Generate a Personal Access Token in your Memos settings");
27
+ process.exit(1);
28
+ }
29
+ }
30
+
31
+ // Tool definitions
32
+ const tools: Tool[] = [
33
+ {
34
+ name: "create_memo",
35
+ description:
36
+ "Create a new memo in Memos. Supports markdown content, visibility settings, and optional location data.",
37
+ inputSchema: {
38
+ type: "object" as const,
39
+ properties: {
40
+ content: {
41
+ type: "string",
42
+ description: "The memo content in markdown format. Can include tags using #tag syntax.",
43
+ },
44
+ visibility: {
45
+ type: "string",
46
+ enum: ["PRIVATE", "PROTECTED", "PUBLIC"],
47
+ description:
48
+ "Visibility of the memo. PRIVATE (default): only you can see it. PROTECTED: visible to authenticated users. PUBLIC: visible to everyone.",
49
+ },
50
+ location: {
51
+ type: "object",
52
+ description: "Optional location data for the memo",
53
+ properties: {
54
+ placeholder: {
55
+ type: "string",
56
+ description: "Location name/description",
57
+ },
58
+ latitude: {
59
+ type: "number",
60
+ description: "Latitude coordinate",
61
+ },
62
+ longitude: {
63
+ type: "number",
64
+ description: "Longitude coordinate",
65
+ },
66
+ },
67
+ },
68
+ },
69
+ required: ["content"],
70
+ },
71
+ },
72
+ {
73
+ name: "search_memos",
74
+ description:
75
+ "Search and list memos from Memos. Supports various filters including content search, tags, visibility, and date ranges.",
76
+ inputSchema: {
77
+ type: "object" as const,
78
+ properties: {
79
+ query: {
80
+ type: "string",
81
+ description: "Search query to find in memo content. Leave empty to list all memos.",
82
+ },
83
+ tag: {
84
+ type: "string",
85
+ description: "Filter memos by a specific tag (without the # prefix)",
86
+ },
87
+ visibility: {
88
+ type: "string",
89
+ enum: ["PRIVATE", "PROTECTED", "PUBLIC"],
90
+ description: "Filter by visibility level",
91
+ },
92
+ state: {
93
+ type: "string",
94
+ enum: ["NORMAL", "ARCHIVED"],
95
+ description: "Filter by memo state. Default is NORMAL.",
96
+ },
97
+ pageSize: {
98
+ type: "number",
99
+ description: "Number of memos to return (default: 20, max: 100)",
100
+ },
101
+ orderBy: {
102
+ type: "string",
103
+ description: 'Sort order, e.g., "display_time desc" or "create_time asc"',
104
+ },
105
+ },
106
+ },
107
+ },
108
+ {
109
+ name: "get_memo",
110
+ description: "Get a single memo by its ID",
111
+ inputSchema: {
112
+ type: "object" as const,
113
+ properties: {
114
+ memoId: {
115
+ type: "string",
116
+ description: "The memo ID (the part after 'memos/' in the resource name)",
117
+ },
118
+ },
119
+ required: ["memoId"],
120
+ },
121
+ },
122
+ ];
123
+
124
+ // Create the MCP server
125
+ const server = new Server(
126
+ {
127
+ name: "memos-mcp",
128
+ version: "1.0.0",
129
+ },
130
+ {
131
+ capabilities: {
132
+ tools: {},
133
+ },
134
+ },
135
+ );
136
+
137
+ // Initialize Memos client (will be set after config validation)
138
+ let memosClient: MemosClient;
139
+
140
+ // Handle tool listing
141
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
142
+ return { tools };
143
+ });
144
+
145
+ // Handle tool calls
146
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
147
+ const { name, arguments: args } = request.params;
148
+
149
+ try {
150
+ switch (name) {
151
+ case "create_memo": {
152
+ const input = args as {
153
+ content: string;
154
+ visibility?: "PRIVATE" | "PROTECTED" | "PUBLIC";
155
+ location?: {
156
+ placeholder?: string;
157
+ latitude?: number;
158
+ longitude?: number;
159
+ };
160
+ };
161
+
162
+ const memo = await memosClient.createMemo({
163
+ content: input.content,
164
+ visibility: input.visibility,
165
+ location: input.location,
166
+ });
167
+
168
+ return {
169
+ content: [
170
+ {
171
+ type: "text" as const,
172
+ text: JSON.stringify(
173
+ {
174
+ success: true,
175
+ memo: {
176
+ name: memo.name,
177
+ content: memo.content,
178
+ visibility: memo.visibility,
179
+ tags: memo.tags,
180
+ createTime: memo.createTime,
181
+ snippet: memo.snippet,
182
+ },
183
+ },
184
+ null,
185
+ 2,
186
+ ),
187
+ },
188
+ ],
189
+ };
190
+ }
191
+
192
+ case "search_memos": {
193
+ const input = args as {
194
+ query?: string;
195
+ tag?: string;
196
+ visibility?: "PRIVATE" | "PROTECTED" | "PUBLIC";
197
+ state?: "NORMAL" | "ARCHIVED";
198
+ pageSize?: number;
199
+ orderBy?: string;
200
+ };
201
+
202
+ // Build filter from inputs
203
+ const filters: string[] = [];
204
+
205
+ if (input.query) {
206
+ filters.push(`content.contains("${input.query.replace(/"/g, '\\"')}")`);
207
+ }
208
+
209
+ if (input.tag) {
210
+ filters.push(`tag == "${input.tag.replace(/"/g, '\\"')}"`);
211
+ }
212
+
213
+ if (input.visibility) {
214
+ filters.push(`visibility == "${input.visibility}"`);
215
+ }
216
+
217
+ const filter = filters.length > 0 ? filters.join(" && ") : undefined;
218
+
219
+ const response = await memosClient.listMemos({
220
+ filter,
221
+ state: input.state,
222
+ pageSize: input.pageSize || 20,
223
+ orderBy: input.orderBy || "display_time desc",
224
+ });
225
+
226
+ const formattedMemos = response.memos.map((memo) => ({
227
+ name: memo.name,
228
+ content: memo.content.length > 200 ? memo.content.substring(0, 200) + "..." : memo.content,
229
+ visibility: memo.visibility,
230
+ tags: memo.tags,
231
+ pinned: memo.pinned,
232
+ createTime: memo.createTime,
233
+ displayTime: memo.displayTime,
234
+ }));
235
+
236
+ return {
237
+ content: [
238
+ {
239
+ type: "text" as const,
240
+ text: JSON.stringify(
241
+ {
242
+ success: true,
243
+ count: formattedMemos.length,
244
+ memos: formattedMemos,
245
+ nextPageToken: response.nextPageToken,
246
+ },
247
+ null,
248
+ 2,
249
+ ),
250
+ },
251
+ ],
252
+ };
253
+ }
254
+
255
+ case "get_memo": {
256
+ const input = args as { memoId: string };
257
+
258
+ const memo = await memosClient.getMemo(input.memoId);
259
+
260
+ return {
261
+ content: [
262
+ {
263
+ type: "text" as const,
264
+ text: JSON.stringify(
265
+ {
266
+ success: true,
267
+ memo: {
268
+ name: memo.name,
269
+ content: memo.content,
270
+ visibility: memo.visibility,
271
+ tags: memo.tags,
272
+ pinned: memo.pinned,
273
+ state: memo.state,
274
+ createTime: memo.createTime,
275
+ updateTime: memo.updateTime,
276
+ displayTime: memo.displayTime,
277
+ location: memo.location,
278
+ },
279
+ },
280
+ null,
281
+ 2,
282
+ ),
283
+ },
284
+ ],
285
+ };
286
+ }
287
+
288
+ default:
289
+ return {
290
+ content: [
291
+ {
292
+ type: "text" as const,
293
+ text: `Unknown tool: ${name}`,
294
+ },
295
+ ],
296
+ isError: true,
297
+ };
298
+ }
299
+ } catch (error) {
300
+ const errorMessage = error instanceof Error ? error.message : "Unknown error occurred";
301
+ return {
302
+ content: [
303
+ {
304
+ type: "text" as const,
305
+ text: JSON.stringify(
306
+ {
307
+ success: false,
308
+ error: errorMessage,
309
+ },
310
+ null,
311
+ 2,
312
+ ),
313
+ },
314
+ ],
315
+ isError: true,
316
+ };
317
+ }
318
+ });
319
+
320
+ // Main entry point
321
+ async function main(): Promise<void> {
322
+ validateConfig();
323
+
324
+ // Initialize the Memos client
325
+ memosClient = new MemosClient({
326
+ baseUrl: MEMOS_URL!,
327
+ accessToken: MEMOS_ACCESS_TOKEN!,
328
+ });
329
+
330
+ // Test connection
331
+ const connected = await memosClient.testConnection();
332
+ if (!connected) {
333
+ console.error("Warning: Could not connect to Memos instance. Check your configuration.");
334
+ }
335
+
336
+ // Start the server
337
+ const transport = new StdioServerTransport();
338
+ await server.connect(transport);
339
+ console.error("Memos MCP server started");
340
+ }
341
+
342
+ main().catch((error) => {
343
+ console.error("Fatal error:", error);
344
+ process.exit(1);
345
+ });
@@ -0,0 +1,312 @@
1
+ /**
2
+ * Tests for Memos API Client
3
+ */
4
+
5
+ import { describe, it, mock, beforeEach } from "node:test";
6
+ import assert from "node:assert";
7
+ import { MemosClient } from "./memos-client.ts";
8
+
9
+ // Mock fetch function - cast to any since undici's fetch has slightly different types
10
+ const mockFetch = mock.fn<typeof globalThis.fetch>();
11
+
12
+ describe("MemosClient", () => {
13
+ let client: MemosClient;
14
+
15
+ beforeEach(() => {
16
+ mockFetch.mock.resetCalls();
17
+ client = new MemosClient({
18
+ baseUrl: "https://memos.example.com",
19
+ accessToken: "test-token",
20
+ // Inject mock fetch via config instead of globalThis
21
+ fetch: mockFetch as unknown as typeof import("undici").fetch,
22
+ });
23
+ });
24
+
25
+ describe("constructor", () => {
26
+ it("should remove trailing slash from baseUrl", () => {
27
+ const clientWithSlash = new MemosClient({
28
+ baseUrl: "https://memos.example.com/",
29
+ accessToken: "test-token",
30
+ fetch: mockFetch as unknown as typeof import("undici").fetch,
31
+ });
32
+ // We can verify this by checking a request URL
33
+ mockFetch.mock.mockImplementationOnce(() =>
34
+ Promise.resolve(new Response(JSON.stringify({ name: "users/1" }), { status: 200 })),
35
+ );
36
+
37
+ clientWithSlash.getCurrentUser();
38
+
39
+ const callArgs = mockFetch.mock.calls[0];
40
+ assert.ok(callArgs);
41
+ assert.strictEqual(callArgs.arguments[0], "https://memos.example.com/api/v1/auth/me");
42
+ });
43
+ });
44
+
45
+ describe("createMemo", () => {
46
+ it("should create a memo with content only", async () => {
47
+ const mockMemo = {
48
+ name: "memos/abc123",
49
+ content: "Test memo",
50
+ visibility: "PRIVATE",
51
+ tags: [],
52
+ createTime: "2024-01-01T00:00:00Z",
53
+ };
54
+
55
+ mockFetch.mock.mockImplementationOnce(() =>
56
+ Promise.resolve(new Response(JSON.stringify(mockMemo), { status: 200 })),
57
+ );
58
+
59
+ const result = await client.createMemo({ content: "Test memo" });
60
+
61
+ assert.strictEqual(result.name, "memos/abc123");
62
+ assert.strictEqual(result.content, "Test memo");
63
+
64
+ const callArgs = mockFetch.mock.calls[0];
65
+ assert.ok(callArgs);
66
+ assert.strictEqual(callArgs.arguments[0], "https://memos.example.com/api/v1/memos");
67
+ assert.strictEqual(callArgs.arguments[1]?.method, "POST");
68
+ assert.strictEqual(
69
+ (callArgs.arguments[1]?.headers as Record<string, string>)?.Authorization,
70
+ "Bearer test-token",
71
+ );
72
+ });
73
+
74
+ it("should create a memo with visibility and location", async () => {
75
+ const mockMemo = {
76
+ name: "memos/xyz789",
77
+ content: "Geo memo",
78
+ visibility: "PUBLIC",
79
+ location: { placeholder: "NYC", latitude: 40.7, longitude: -74.0 },
80
+ };
81
+
82
+ mockFetch.mock.mockImplementationOnce(() =>
83
+ Promise.resolve(new Response(JSON.stringify(mockMemo), { status: 200 })),
84
+ );
85
+
86
+ const result = await client.createMemo({
87
+ content: "Geo memo",
88
+ visibility: "PUBLIC",
89
+ location: { placeholder: "NYC", latitude: 40.7, longitude: -74.0 },
90
+ });
91
+
92
+ assert.strictEqual(result.visibility, "PUBLIC");
93
+ assert.deepStrictEqual(result.location, { placeholder: "NYC", latitude: 40.7, longitude: -74.0 });
94
+
95
+ const callArgs = mockFetch.mock.calls[0];
96
+ const body = JSON.parse(callArgs?.arguments[1]?.body as string);
97
+ assert.strictEqual(body.content, "Geo memo");
98
+ assert.strictEqual(body.visibility, "PUBLIC");
99
+ assert.deepStrictEqual(body.location, { placeholder: "NYC", latitude: 40.7, longitude: -74.0 });
100
+ });
101
+
102
+ it("should throw error on API failure", async () => {
103
+ mockFetch.mock.mockImplementationOnce(() =>
104
+ Promise.resolve(new Response("Unauthorized", { status: 401, statusText: "Unauthorized" })),
105
+ );
106
+
107
+ await assert.rejects(async () => await client.createMemo({ content: "Test" }), {
108
+ message: /Memos API error: 401/,
109
+ });
110
+ });
111
+ });
112
+
113
+ describe("listMemos", () => {
114
+ it("should list memos without filters", async () => {
115
+ const mockResponse = {
116
+ memos: [{ name: "memos/1", content: "Memo 1" }],
117
+ nextPageToken: "token123",
118
+ };
119
+
120
+ mockFetch.mock.mockImplementationOnce(() =>
121
+ Promise.resolve(new Response(JSON.stringify(mockResponse), { status: 200 })),
122
+ );
123
+
124
+ const result = await client.listMemos();
125
+
126
+ assert.strictEqual(result.memos.length, 1);
127
+ assert.strictEqual(result.nextPageToken, "token123");
128
+
129
+ const callArgs = mockFetch.mock.calls[0];
130
+ assert.strictEqual(callArgs?.arguments[0], "https://memos.example.com/api/v1/memos");
131
+ });
132
+
133
+ it("should list memos with all filters", async () => {
134
+ const mockResponse = { memos: [], nextPageToken: undefined };
135
+
136
+ mockFetch.mock.mockImplementationOnce(() =>
137
+ Promise.resolve(new Response(JSON.stringify(mockResponse), { status: 200 })),
138
+ );
139
+
140
+ await client.listMemos({
141
+ filter: 'visibility == "PUBLIC"',
142
+ pageSize: 50,
143
+ pageToken: "abc",
144
+ state: "ARCHIVED",
145
+ orderBy: "create_time desc",
146
+ });
147
+
148
+ const callArgs = mockFetch.mock.calls[0];
149
+ const url = callArgs?.arguments[0] as string;
150
+
151
+ assert.ok(url.includes("filter=visibility"));
152
+ assert.ok(url.includes("pageSize=50"));
153
+ assert.ok(url.includes("pageToken=abc"));
154
+ assert.ok(url.includes("state=ARCHIVED"));
155
+ assert.ok(url.includes("orderBy=create_time"));
156
+ });
157
+ });
158
+
159
+ describe("getMemo", () => {
160
+ it("should get a memo by ID", async () => {
161
+ const mockMemo = {
162
+ name: "memos/abc123",
163
+ content: "My memo",
164
+ visibility: "PRIVATE",
165
+ };
166
+
167
+ mockFetch.mock.mockImplementationOnce(() =>
168
+ Promise.resolve(new Response(JSON.stringify(mockMemo), { status: 200 })),
169
+ );
170
+
171
+ const result = await client.getMemo("abc123");
172
+
173
+ assert.strictEqual(result.name, "memos/abc123");
174
+ assert.strictEqual(result.content, "My memo");
175
+
176
+ const callArgs = mockFetch.mock.calls[0];
177
+ assert.strictEqual(callArgs?.arguments[0], "https://memos.example.com/api/v1/memos/abc123");
178
+ });
179
+
180
+ it("should throw error when memo not found", async () => {
181
+ mockFetch.mock.mockImplementationOnce(() =>
182
+ Promise.resolve(new Response("Not Found", { status: 404, statusText: "Not Found" })),
183
+ );
184
+
185
+ await assert.rejects(async () => await client.getMemo("nonexistent"), {
186
+ message: /Memos API error: 404/,
187
+ });
188
+ });
189
+ });
190
+
191
+ describe("searchMemos", () => {
192
+ it("should search memos by query", async () => {
193
+ const mockResponse = {
194
+ memos: [{ name: "memos/1", content: "Hello world" }],
195
+ };
196
+
197
+ mockFetch.mock.mockImplementationOnce(() =>
198
+ Promise.resolve(new Response(JSON.stringify(mockResponse), { status: 200 })),
199
+ );
200
+
201
+ await client.searchMemos("Hello");
202
+
203
+ const callArgs = mockFetch.mock.calls[0];
204
+ const url = callArgs?.arguments[0] as string;
205
+
206
+ // URL will be encoded, check for the encoded version
207
+ assert.ok(url.includes("filter="));
208
+ assert.ok(decodeURIComponent(url).includes('content.contains("Hello")'));
209
+ });
210
+
211
+ it("should escape quotes in search query", async () => {
212
+ const mockResponse = { memos: [] };
213
+
214
+ mockFetch.mock.mockImplementationOnce(() =>
215
+ Promise.resolve(new Response(JSON.stringify(mockResponse), { status: 200 })),
216
+ );
217
+
218
+ await client.searchMemos('test "quoted" text');
219
+
220
+ const callArgs = mockFetch.mock.calls[0];
221
+ const url = callArgs?.arguments[0] as string;
222
+
223
+ // Check decoded URL contains escaped quotes
224
+ const decodedUrl = decodeURIComponent(url);
225
+ assert.ok(decodedUrl.includes('\\"quoted\\"'));
226
+ });
227
+
228
+ it("should pass additional options to listMemos", async () => {
229
+ const mockResponse = { memos: [] };
230
+
231
+ mockFetch.mock.mockImplementationOnce(() =>
232
+ Promise.resolve(new Response(JSON.stringify(mockResponse), { status: 200 })),
233
+ );
234
+
235
+ await client.searchMemos("test", { pageSize: 10, state: "ARCHIVED" });
236
+
237
+ const callArgs = mockFetch.mock.calls[0];
238
+ const url = callArgs?.arguments[0] as string;
239
+
240
+ assert.ok(url.includes("pageSize=10"));
241
+ assert.ok(url.includes("state=ARCHIVED"));
242
+ });
243
+ });
244
+
245
+ describe("searchByTag", () => {
246
+ it("should search memos by tag", async () => {
247
+ const mockResponse = {
248
+ memos: [{ name: "memos/1", content: "#important task", tags: ["important"] }],
249
+ };
250
+
251
+ mockFetch.mock.mockImplementationOnce(() =>
252
+ Promise.resolve(new Response(JSON.stringify(mockResponse), { status: 200 })),
253
+ );
254
+
255
+ await client.searchByTag("important");
256
+
257
+ const callArgs = mockFetch.mock.calls[0];
258
+ const url = callArgs?.arguments[0] as string;
259
+
260
+ // Check URL contains filter parameter with tag
261
+ assert.ok(url.includes("filter="));
262
+ assert.ok(url.includes("important"));
263
+ });
264
+ });
265
+
266
+ describe("getCurrentUser", () => {
267
+ it("should get current user info", async () => {
268
+ const mockUser = { name: "users/1", username: "testuser" };
269
+
270
+ mockFetch.mock.mockImplementationOnce(() =>
271
+ Promise.resolve(new Response(JSON.stringify(mockUser), { status: 200 })),
272
+ );
273
+
274
+ const result = await client.getCurrentUser();
275
+
276
+ assert.deepStrictEqual(result, { name: "users/1", username: "testuser" });
277
+
278
+ const callArgs = mockFetch.mock.calls[0];
279
+ assert.strictEqual(callArgs?.arguments[0], "https://memos.example.com/api/v1/auth/me");
280
+ });
281
+ });
282
+
283
+ describe("testConnection", () => {
284
+ it("should return true on successful connection", async () => {
285
+ mockFetch.mock.mockImplementationOnce(() =>
286
+ Promise.resolve(new Response(JSON.stringify({ name: "users/1" }), { status: 200 })),
287
+ );
288
+
289
+ const result = await client.testConnection();
290
+
291
+ assert.strictEqual(result, true);
292
+ });
293
+
294
+ it("should return false on failed connection", async () => {
295
+ mockFetch.mock.mockImplementationOnce(() =>
296
+ Promise.resolve(new Response("Unauthorized", { status: 401, statusText: "Unauthorized" })),
297
+ );
298
+
299
+ const result = await client.testConnection();
300
+
301
+ assert.strictEqual(result, false);
302
+ });
303
+
304
+ it("should return false on network error", async () => {
305
+ mockFetch.mock.mockImplementationOnce(() => Promise.reject(new Error("Network error")));
306
+
307
+ const result = await client.testConnection();
308
+
309
+ assert.strictEqual(result, false);
310
+ });
311
+ });
312
+ });