@nexus-lab/create-mcp-server 0.1.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.
@@ -0,0 +1,304 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { z } from "zod";
3
+ import { eq, like, or, sql } from "drizzle-orm";
4
+ import { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
5
+ import { db as defaultDb } from "./db.js";
6
+ import { notes } from "./schema.js";
7
+ import type * as schema from "./schema.js";
8
+
9
+ type Db = BetterSQLite3Database<typeof schema>;
10
+
11
+ /**
12
+ * Register all CRUD tools on the given MCP server.
13
+ * Accepts an optional database override for testing.
14
+ */
15
+ export function registerTools(server: McpServer, database?: Db): void {
16
+ const db = database ?? defaultDb;
17
+
18
+ // ── create-note ──────────────────────────────────────────────────────
19
+ server.tool(
20
+ "create-note",
21
+ "Create a new note with a title and optional content",
22
+ {
23
+ title: z.string().min(1).max(500).describe("Title of the note"),
24
+ content: z
25
+ .string()
26
+ .max(50_000)
27
+ .default("")
28
+ .describe("Body content of the note"),
29
+ },
30
+ async ({ title, content }) => {
31
+ try {
32
+ const result = db
33
+ .insert(notes)
34
+ .values({ title, content })
35
+ .returning()
36
+ .get();
37
+
38
+ return {
39
+ content: [
40
+ {
41
+ type: "text" as const,
42
+ text: JSON.stringify(result, null, 2),
43
+ },
44
+ ],
45
+ };
46
+ } catch (error: unknown) {
47
+ const message =
48
+ error instanceof Error ? error.message : String(error);
49
+ return {
50
+ content: [
51
+ {
52
+ type: "text" as const,
53
+ text: `Failed to create note: ${message}`,
54
+ },
55
+ ],
56
+ isError: true,
57
+ };
58
+ }
59
+ },
60
+ );
61
+
62
+ // ── list-notes ───────────────────────────────────────────────────────
63
+ server.tool(
64
+ "list-notes",
65
+ "List all notes, optionally filtered by a search query on title or content",
66
+ {
67
+ query: z
68
+ .string()
69
+ .optional()
70
+ .describe("Search term to filter notes by title or content"),
71
+ },
72
+ async ({ query }) => {
73
+ try {
74
+ let result;
75
+
76
+ if (query) {
77
+ const pattern = `%${query}%`;
78
+ result = db
79
+ .select()
80
+ .from(notes)
81
+ .where(
82
+ or(like(notes.title, pattern), like(notes.content, pattern)),
83
+ )
84
+ .all();
85
+ } else {
86
+ result = db.select().from(notes).all();
87
+ }
88
+
89
+ return {
90
+ content: [
91
+ {
92
+ type: "text" as const,
93
+ text: JSON.stringify(result, null, 2),
94
+ },
95
+ ],
96
+ };
97
+ } catch (error: unknown) {
98
+ const message =
99
+ error instanceof Error ? error.message : String(error);
100
+ return {
101
+ content: [
102
+ {
103
+ type: "text" as const,
104
+ text: `Failed to list notes: ${message}`,
105
+ },
106
+ ],
107
+ isError: true,
108
+ };
109
+ }
110
+ },
111
+ );
112
+
113
+ // ── get-note ─────────────────────────────────────────────────────────
114
+ server.tool(
115
+ "get-note",
116
+ "Retrieve a single note by its ID",
117
+ {
118
+ id: z.number().int().positive().describe("ID of the note to retrieve"),
119
+ },
120
+ async ({ id }) => {
121
+ try {
122
+ const result = db
123
+ .select()
124
+ .from(notes)
125
+ .where(eq(notes.id, id))
126
+ .get();
127
+
128
+ if (!result) {
129
+ return {
130
+ content: [
131
+ {
132
+ type: "text" as const,
133
+ text: `Note with ID ${id} not found`,
134
+ },
135
+ ],
136
+ isError: true,
137
+ };
138
+ }
139
+
140
+ return {
141
+ content: [
142
+ {
143
+ type: "text" as const,
144
+ text: JSON.stringify(result, null, 2),
145
+ },
146
+ ],
147
+ };
148
+ } catch (error: unknown) {
149
+ const message =
150
+ error instanceof Error ? error.message : String(error);
151
+ return {
152
+ content: [
153
+ {
154
+ type: "text" as const,
155
+ text: `Failed to get note: ${message}`,
156
+ },
157
+ ],
158
+ isError: true,
159
+ };
160
+ }
161
+ },
162
+ );
163
+
164
+ // ── update-note ──────────────────────────────────────────────────────
165
+ server.tool(
166
+ "update-note",
167
+ "Update an existing note by ID. Provide at least one of title or content.",
168
+ {
169
+ id: z.number().int().positive().describe("ID of the note to update"),
170
+ title: z
171
+ .string()
172
+ .min(1)
173
+ .max(500)
174
+ .optional()
175
+ .describe("New title for the note"),
176
+ content: z
177
+ .string()
178
+ .max(50_000)
179
+ .optional()
180
+ .describe("New content for the note"),
181
+ },
182
+ async ({ id, title, content }) => {
183
+ try {
184
+ if (title === undefined && content === undefined) {
185
+ return {
186
+ content: [
187
+ {
188
+ type: "text" as const,
189
+ text: "At least one of 'title' or 'content' must be provided",
190
+ },
191
+ ],
192
+ isError: true,
193
+ };
194
+ }
195
+
196
+ // Check existence first
197
+ const existing = db
198
+ .select()
199
+ .from(notes)
200
+ .where(eq(notes.id, id))
201
+ .get();
202
+
203
+ if (!existing) {
204
+ return {
205
+ content: [
206
+ {
207
+ type: "text" as const,
208
+ text: `Note with ID ${id} not found`,
209
+ },
210
+ ],
211
+ isError: true,
212
+ };
213
+ }
214
+
215
+ const updates: Record<string, unknown> = {
216
+ updatedAt: sql`datetime('now')`,
217
+ };
218
+ if (title !== undefined) updates.title = title;
219
+ if (content !== undefined) updates.content = content;
220
+
221
+ const result = db
222
+ .update(notes)
223
+ .set(updates)
224
+ .where(eq(notes.id, id))
225
+ .returning()
226
+ .get();
227
+
228
+ return {
229
+ content: [
230
+ {
231
+ type: "text" as const,
232
+ text: JSON.stringify(result, null, 2),
233
+ },
234
+ ],
235
+ };
236
+ } catch (error: unknown) {
237
+ const message =
238
+ error instanceof Error ? error.message : String(error);
239
+ return {
240
+ content: [
241
+ {
242
+ type: "text" as const,
243
+ text: `Failed to update note: ${message}`,
244
+ },
245
+ ],
246
+ isError: true,
247
+ };
248
+ }
249
+ },
250
+ );
251
+
252
+ // ── delete-note ──────────────────────────────────────────────────────
253
+ server.tool(
254
+ "delete-note",
255
+ "Delete a note by its ID",
256
+ {
257
+ id: z.number().int().positive().describe("ID of the note to delete"),
258
+ },
259
+ async ({ id }) => {
260
+ try {
261
+ const existing = db
262
+ .select()
263
+ .from(notes)
264
+ .where(eq(notes.id, id))
265
+ .get();
266
+
267
+ if (!existing) {
268
+ return {
269
+ content: [
270
+ {
271
+ type: "text" as const,
272
+ text: `Note with ID ${id} not found`,
273
+ },
274
+ ],
275
+ isError: true,
276
+ };
277
+ }
278
+
279
+ db.delete(notes).where(eq(notes.id, id)).run();
280
+
281
+ return {
282
+ content: [
283
+ {
284
+ type: "text" as const,
285
+ text: `Note with ID ${id} deleted successfully`,
286
+ },
287
+ ],
288
+ };
289
+ } catch (error: unknown) {
290
+ const message =
291
+ error instanceof Error ? error.message : String(error);
292
+ return {
293
+ content: [
294
+ {
295
+ type: "text" as const,
296
+ text: `Failed to delete note: ${message}`,
297
+ },
298
+ ],
299
+ isError: true,
300
+ };
301
+ }
302
+ },
303
+ );
304
+ }
@@ -0,0 +1,197 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "vitest";
2
+ import Database from "better-sqlite3";
3
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
4
+ import { createDatabase } from "../src/db.js";
5
+ import { notes } from "../src/schema.js";
6
+ import { eq } from "drizzle-orm";
7
+
8
+ // We test the database operations directly rather than going through MCP
9
+ // protocol to keep tests fast and focused on business logic.
10
+
11
+ function createTestDb() {
12
+ const sqlite = new Database(":memory:");
13
+ const db = createDatabase(sqlite);
14
+ return { sqlite, db };
15
+ }
16
+
17
+ describe("Notes CRUD operations", () => {
18
+ let sqlite: Database.Database;
19
+ let db: ReturnType<typeof createDatabase>;
20
+
21
+ beforeEach(() => {
22
+ const testDb = createTestDb();
23
+ sqlite = testDb.sqlite;
24
+ db = testDb.db;
25
+ });
26
+
27
+ afterEach(() => {
28
+ sqlite.close();
29
+ });
30
+
31
+ describe("create", () => {
32
+ it("should create a note with title and content", () => {
33
+ const result = db
34
+ .insert(notes)
35
+ .values({ title: "Test Note", content: "Hello world" })
36
+ .returning()
37
+ .get();
38
+
39
+ expect(result).toBeDefined();
40
+ expect(result.id).toBe(1);
41
+ expect(result.title).toBe("Test Note");
42
+ expect(result.content).toBe("Hello world");
43
+ expect(result.createdAt).toBeDefined();
44
+ expect(result.updatedAt).toBeDefined();
45
+ });
46
+
47
+ it("should create a note with default empty content", () => {
48
+ const result = db
49
+ .insert(notes)
50
+ .values({ title: "Title Only" })
51
+ .returning()
52
+ .get();
53
+
54
+ expect(result.content).toBe("");
55
+ });
56
+
57
+ it("should auto-increment IDs", () => {
58
+ const first = db
59
+ .insert(notes)
60
+ .values({ title: "First" })
61
+ .returning()
62
+ .get();
63
+ const second = db
64
+ .insert(notes)
65
+ .values({ title: "Second" })
66
+ .returning()
67
+ .get();
68
+
69
+ expect(second.id).toBe(first.id + 1);
70
+ });
71
+ });
72
+
73
+ describe("read", () => {
74
+ beforeEach(() => {
75
+ db.insert(notes)
76
+ .values([
77
+ { title: "Note A", content: "Alpha content" },
78
+ { title: "Note B", content: "Beta content" },
79
+ { title: "Note C", content: "Gamma content" },
80
+ ])
81
+ .run();
82
+ });
83
+
84
+ it("should list all notes", () => {
85
+ const result = db.select().from(notes).all();
86
+
87
+ expect(result).toHaveLength(3);
88
+ expect(result.map((n) => n.title)).toEqual([
89
+ "Note A",
90
+ "Note B",
91
+ "Note C",
92
+ ]);
93
+ });
94
+
95
+ it("should get a note by ID", () => {
96
+ const result = db.select().from(notes).where(eq(notes.id, 2)).get();
97
+
98
+ expect(result).toBeDefined();
99
+ expect(result!.title).toBe("Note B");
100
+ });
101
+
102
+ it("should return undefined for a non-existent ID", () => {
103
+ const result = db.select().from(notes).where(eq(notes.id, 999)).get();
104
+
105
+ expect(result).toBeUndefined();
106
+ });
107
+
108
+ it("should search notes by title pattern", () => {
109
+ const result = db
110
+ .select()
111
+ .from(notes)
112
+ .where(eq(notes.title, "Note A"))
113
+ .all();
114
+
115
+ expect(result).toHaveLength(1);
116
+ expect(result[0].title).toBe("Note A");
117
+ });
118
+ });
119
+
120
+ describe("update", () => {
121
+ beforeEach(() => {
122
+ db.insert(notes)
123
+ .values({ title: "Original Title", content: "Original Content" })
124
+ .run();
125
+ });
126
+
127
+ it("should update the title of a note", () => {
128
+ const result = db
129
+ .update(notes)
130
+ .set({ title: "Updated Title" })
131
+ .where(eq(notes.id, 1))
132
+ .returning()
133
+ .get();
134
+
135
+ expect(result.title).toBe("Updated Title");
136
+ expect(result.content).toBe("Original Content");
137
+ });
138
+
139
+ it("should update the content of a note", () => {
140
+ const result = db
141
+ .update(notes)
142
+ .set({ content: "Updated Content" })
143
+ .where(eq(notes.id, 1))
144
+ .returning()
145
+ .get();
146
+
147
+ expect(result.title).toBe("Original Title");
148
+ expect(result.content).toBe("Updated Content");
149
+ });
150
+
151
+ it("should update both title and content", () => {
152
+ const result = db
153
+ .update(notes)
154
+ .set({ title: "New Title", content: "New Content" })
155
+ .where(eq(notes.id, 1))
156
+ .returning()
157
+ .get();
158
+
159
+ expect(result.title).toBe("New Title");
160
+ expect(result.content).toBe("New Content");
161
+ });
162
+ });
163
+
164
+ describe("delete", () => {
165
+ beforeEach(() => {
166
+ db.insert(notes)
167
+ .values([
168
+ { title: "Keep", content: "Stay" },
169
+ { title: "Remove", content: "Gone" },
170
+ ])
171
+ .run();
172
+ });
173
+
174
+ it("should delete a note by ID", () => {
175
+ db.delete(notes).where(eq(notes.id, 2)).run();
176
+
177
+ const remaining = db.select().from(notes).all();
178
+ expect(remaining).toHaveLength(1);
179
+ expect(remaining[0].title).toBe("Keep");
180
+ });
181
+
182
+ it("should not affect other notes when deleting", () => {
183
+ db.delete(notes).where(eq(notes.id, 1)).run();
184
+
185
+ const remaining = db.select().from(notes).all();
186
+ expect(remaining).toHaveLength(1);
187
+ expect(remaining[0].title).toBe("Remove");
188
+ });
189
+
190
+ it("should handle deleting a non-existent ID gracefully", () => {
191
+ db.delete(notes).where(eq(notes.id, 999)).run();
192
+
193
+ const remaining = db.select().from(notes).all();
194
+ expect(remaining).toHaveLength(2);
195
+ });
196
+ });
197
+ });
@@ -0,0 +1,20 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "outDir": "./dist",
11
+ "rootDir": "./src",
12
+ "declaration": true,
13
+ "declarationMap": true,
14
+ "sourceMap": true,
15
+ "resolveJsonModule": true,
16
+ "isolatedModules": true
17
+ },
18
+ "include": ["src/**/*"],
19
+ "exclude": ["node_modules", "dist", "tests"]
20
+ }
@@ -0,0 +1,14 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ environment: "node",
7
+ include: ["tests/**/*.test.ts"],
8
+ coverage: {
9
+ provider: "v8",
10
+ include: ["src/**/*.ts"],
11
+ exclude: ["src/index.ts"],
12
+ },
13
+ },
14
+ });