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/.vscode/settings.json +19 -0
- package/AGENTS.md +96 -0
- package/Jenkinsfile +80 -0
- package/README.md +164 -0
- package/RELEASE.md +25 -0
- package/eslint.config.js +34 -0
- package/memory.md +51 -0
- package/package.json +43 -0
- package/src/cert-manager.test.ts +322 -0
- package/src/cert-manager.ts +346 -0
- package/src/index.ts +345 -0
- package/src/memos-client.test.ts +312 -0
- package/src/memos-client.ts +253 -0
- package/tsconfig.json +25 -0
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
|
+
});
|