@nexus-lab/create-mcp-server 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/generator.js +16 -0
- package/dist/index.js +1 -1
- package/dist/prompts.d.ts +1 -1
- package/dist/prompts.js +4 -0
- package/package.json +1 -1
- package/templates/database/.env.example +1 -0
- package/templates/database/README.md +110 -0
- package/templates/database/_gitignore +7 -0
- package/templates/database/drizzle.config.ts +11 -0
- package/templates/database/package.json +30 -0
- package/templates/database/src/db.ts +51 -0
- package/templates/database/src/index.ts +30 -0
- package/templates/database/src/resources.ts +68 -0
- package/templates/database/src/schema.ts +26 -0
- package/templates/database/src/tools.ts +304 -0
- package/templates/database/tests/tools.test.ts +197 -0
- package/templates/database/tsconfig.json +20 -0
- package/templates/database/vitest.config.ts +14 -0
package/dist/generator.js
CHANGED
|
@@ -10,7 +10,23 @@ function getTemplatesDir() {
|
|
|
10
10
|
const srcPath = path.resolve(__dirname, "templates");
|
|
11
11
|
return fs.existsSync(devPath) ? devPath : srcPath;
|
|
12
12
|
}
|
|
13
|
+
const PREMIUM_TEMPLATES = new Set(["database"]);
|
|
13
14
|
export async function generateProject(config) {
|
|
15
|
+
// Premium templates are not bundled — redirect to purchase page
|
|
16
|
+
if (PREMIUM_TEMPLATES.has(config.template)) {
|
|
17
|
+
console.log();
|
|
18
|
+
console.log(chalk.yellow.bold(" ★ Premium Template"));
|
|
19
|
+
console.log();
|
|
20
|
+
console.log(` The ${chalk.bold(config.template)} template is a premium template.`);
|
|
21
|
+
console.log(` It includes production-ready database integration with:`);
|
|
22
|
+
console.log(` • SQLite + Drizzle ORM`);
|
|
23
|
+
console.log(` • Full CRUD tools (create, list, get, update, delete)`);
|
|
24
|
+
console.log(` • Test suite + migration support`);
|
|
25
|
+
console.log();
|
|
26
|
+
console.log(` ${chalk.cyan("Get it here:")} https://nexus-lab.gumroad.com/l/mcp-database`);
|
|
27
|
+
console.log();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
14
30
|
const targetDir = path.resolve(process.cwd(), config.projectName);
|
|
15
31
|
if (await fs.pathExists(targetDir)) {
|
|
16
32
|
const files = await fs.readdir(targetDir);
|
package/dist/index.js
CHANGED
|
@@ -7,7 +7,7 @@ const program = new Command();
|
|
|
7
7
|
program
|
|
8
8
|
.name("create-mcp-server")
|
|
9
9
|
.description("Scaffold a new MCP server project with TypeScript and secure defaults")
|
|
10
|
-
.version("0.
|
|
10
|
+
.version("0.2.0")
|
|
11
11
|
.argument("[project-name]", "Name of the project to create")
|
|
12
12
|
.option("-t, --template <template>", "Template to use (minimal, full, http)", "minimal")
|
|
13
13
|
.option("--no-install", "Skip npm install")
|
package/dist/prompts.d.ts
CHANGED
package/dist/prompts.js
CHANGED
|
@@ -13,6 +13,10 @@ const TEMPLATES = [
|
|
|
13
13
|
title: `${chalk.bold("http")} ${chalk.dim("— Streamable HTTP transport with Express")}`,
|
|
14
14
|
value: "http",
|
|
15
15
|
},
|
|
16
|
+
{
|
|
17
|
+
title: `${chalk.bold.yellow("database")} ${chalk.dim("— SQLite + Drizzle ORM + CRUD")} ${chalk.yellow("★ Premium")}`,
|
|
18
|
+
value: "database",
|
|
19
|
+
},
|
|
16
20
|
];
|
|
17
21
|
export async function runPrompts(projectName, options) {
|
|
18
22
|
const questions = [];
|
package/package.json
CHANGED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
DATABASE_URL=./data.db
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# MCP Server — Database Template
|
|
2
|
+
|
|
3
|
+
A production-ready [Model Context Protocol](https://modelcontextprotocol.io/) server with built-in SQLite database connectivity, powered by [Drizzle ORM](https://orm.drizzle.team/).
|
|
4
|
+
|
|
5
|
+
## Quick Start
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
# Install dependencies
|
|
9
|
+
npm install
|
|
10
|
+
|
|
11
|
+
# Copy environment config
|
|
12
|
+
cp .env.example .env
|
|
13
|
+
|
|
14
|
+
# Build the project
|
|
15
|
+
npm run build
|
|
16
|
+
|
|
17
|
+
# Start the server
|
|
18
|
+
npm start
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Available Tools
|
|
22
|
+
|
|
23
|
+
| Tool | Description |
|
|
24
|
+
| --------------- | ---------------------------------------------------- |
|
|
25
|
+
| `create-note` | Create a new note with a title and optional content |
|
|
26
|
+
| `list-notes` | List all notes, with optional search query filtering |
|
|
27
|
+
| `get-note` | Retrieve a single note by its ID |
|
|
28
|
+
| `update-note` | Update a note's title and/or content by ID |
|
|
29
|
+
| `delete-note` | Delete a note by its ID |
|
|
30
|
+
|
|
31
|
+
## Available Resources
|
|
32
|
+
|
|
33
|
+
| URI | Description |
|
|
34
|
+
| -------------- | ------------------------------------ |
|
|
35
|
+
| `notes://list` | All notes in the database as JSON |
|
|
36
|
+
| `db://schema` | Database schema documentation |
|
|
37
|
+
|
|
38
|
+
## Configuration
|
|
39
|
+
|
|
40
|
+
### Environment Variables
|
|
41
|
+
|
|
42
|
+
| Variable | Default | Description |
|
|
43
|
+
| -------------- | ------------ | --------------------------- |
|
|
44
|
+
| `DATABASE_URL` | `./data.db` | Path to the SQLite database |
|
|
45
|
+
|
|
46
|
+
### Claude Desktop Integration
|
|
47
|
+
|
|
48
|
+
Add this to your Claude Desktop config (`claude_desktop_config.json`):
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"mcpServers": {
|
|
53
|
+
"my-mcp-server": {
|
|
54
|
+
"command": "node",
|
|
55
|
+
"args": ["/absolute/path/to/dist/index.js"]
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
## Database Management
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
# Generate migration files from schema changes
|
|
65
|
+
npm run db:generate
|
|
66
|
+
|
|
67
|
+
# Apply migrations
|
|
68
|
+
npm run db:migrate
|
|
69
|
+
|
|
70
|
+
# Open Drizzle Studio (visual DB browser)
|
|
71
|
+
npm run db:studio
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## Development
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
# Watch mode — recompiles on file changes
|
|
78
|
+
npm run dev
|
|
79
|
+
|
|
80
|
+
# Run tests
|
|
81
|
+
npm test
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Extending the Schema
|
|
85
|
+
|
|
86
|
+
1. Edit `src/schema.ts` to add new tables or columns.
|
|
87
|
+
2. Run `npm run db:generate` to create a migration.
|
|
88
|
+
3. Run `npm run db:migrate` to apply it.
|
|
89
|
+
4. Add corresponding tools in `src/tools.ts` and resources in `src/resources.ts`.
|
|
90
|
+
|
|
91
|
+
## Project Structure
|
|
92
|
+
|
|
93
|
+
```
|
|
94
|
+
├── src/
|
|
95
|
+
│ ├── index.ts # Entry point — server setup and transport
|
|
96
|
+
│ ├── db.ts # Database connection and initialization
|
|
97
|
+
│ ├── schema.ts # Drizzle ORM table definitions
|
|
98
|
+
│ ├── tools.ts # MCP tool handlers (CRUD)
|
|
99
|
+
│ └── resources.ts # MCP resource handlers
|
|
100
|
+
├── tests/
|
|
101
|
+
│ └── tools.test.ts # CRUD operation tests
|
|
102
|
+
├── drizzle.config.ts # Drizzle Kit configuration
|
|
103
|
+
├── vitest.config.ts # Test runner configuration
|
|
104
|
+
├── tsconfig.json # TypeScript configuration
|
|
105
|
+
└── package.json
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## License
|
|
109
|
+
|
|
110
|
+
MIT
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "my-mcp-server",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "MCP server with database connectivity powered by Drizzle ORM",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"build": "tsc",
|
|
9
|
+
"dev": "tsc --watch",
|
|
10
|
+
"start": "node dist/index.js",
|
|
11
|
+
"test": "vitest run",
|
|
12
|
+
"db:generate": "drizzle-kit generate",
|
|
13
|
+
"db:migrate": "drizzle-kit migrate",
|
|
14
|
+
"db:studio": "drizzle-kit studio"
|
|
15
|
+
},
|
|
16
|
+
"dependencies": {
|
|
17
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
18
|
+
"better-sqlite3": "^11.8.1",
|
|
19
|
+
"dotenv": "^16.5.0",
|
|
20
|
+
"drizzle-orm": "^0.39.3",
|
|
21
|
+
"zod": "^3.24.4"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/better-sqlite3": "^7.6.13",
|
|
25
|
+
"@types/node": "^22.15.3",
|
|
26
|
+
"drizzle-kit": "^0.31.1",
|
|
27
|
+
"typescript": "^5.8.3",
|
|
28
|
+
"vitest": "^3.1.2"
|
|
29
|
+
}
|
|
30
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { drizzle, BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
|
3
|
+
import * as schema from "./schema.js";
|
|
4
|
+
|
|
5
|
+
const DATABASE_URL = process.env.DATABASE_URL ?? "./data.db";
|
|
6
|
+
|
|
7
|
+
const sqlite = new Database(DATABASE_URL);
|
|
8
|
+
|
|
9
|
+
// Enable WAL mode for better concurrent read performance
|
|
10
|
+
sqlite.pragma("journal_mode = WAL");
|
|
11
|
+
sqlite.pragma("foreign_keys = ON");
|
|
12
|
+
|
|
13
|
+
export const db: BetterSQLite3Database<typeof schema> = drizzle(sqlite, {
|
|
14
|
+
schema,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Initialize the database by creating tables if they don't exist.
|
|
19
|
+
* Called once at server startup.
|
|
20
|
+
*/
|
|
21
|
+
export function setupDatabase(): void {
|
|
22
|
+
sqlite.exec(`
|
|
23
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
24
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
25
|
+
title TEXT NOT NULL,
|
|
26
|
+
content TEXT NOT NULL DEFAULT '',
|
|
27
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
28
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
29
|
+
);
|
|
30
|
+
`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Create a database instance from a custom SQLite connection.
|
|
35
|
+
* Useful for testing with in-memory databases.
|
|
36
|
+
*/
|
|
37
|
+
export function createDatabase(
|
|
38
|
+
connection: Database.Database,
|
|
39
|
+
): BetterSQLite3Database<typeof schema> {
|
|
40
|
+
connection.pragma("foreign_keys = ON");
|
|
41
|
+
connection.exec(`
|
|
42
|
+
CREATE TABLE IF NOT EXISTS notes (
|
|
43
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
44
|
+
title TEXT NOT NULL,
|
|
45
|
+
content TEXT NOT NULL DEFAULT '',
|
|
46
|
+
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
47
|
+
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
48
|
+
);
|
|
49
|
+
`);
|
|
50
|
+
return drizzle(connection, { schema });
|
|
51
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
3
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
4
|
+
import { setupDatabase } from "./db.js";
|
|
5
|
+
import { registerTools } from "./tools.js";
|
|
6
|
+
import { registerResources } from "./resources.js";
|
|
7
|
+
|
|
8
|
+
const server = new McpServer({
|
|
9
|
+
name: "my-mcp-server",
|
|
10
|
+
version: "0.1.0",
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
// Initialize the database (creates tables if they don't exist)
|
|
14
|
+
setupDatabase();
|
|
15
|
+
|
|
16
|
+
// Register all tools and resources
|
|
17
|
+
registerTools(server);
|
|
18
|
+
registerResources(server);
|
|
19
|
+
|
|
20
|
+
// Start the server with stdio transport
|
|
21
|
+
async function main(): Promise<void> {
|
|
22
|
+
const transport = new StdioServerTransport();
|
|
23
|
+
await server.connect(transport);
|
|
24
|
+
console.error("MCP server running on stdio");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
main().catch((error: unknown) => {
|
|
28
|
+
console.error("Fatal error starting server:", error);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
});
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
|
|
3
|
+
import { db as defaultDb } from "./db.js";
|
|
4
|
+
import { notes } from "./schema.js";
|
|
5
|
+
import type * as schema from "./schema.js";
|
|
6
|
+
|
|
7
|
+
type Db = BetterSQLite3Database<typeof schema>;
|
|
8
|
+
|
|
9
|
+
/** Database schema description exposed as a resource. */
|
|
10
|
+
const SCHEMA_DESCRIPTION = `# Database Schema
|
|
11
|
+
|
|
12
|
+
## Table: notes
|
|
13
|
+
|
|
14
|
+
| Column | Type | Constraints |
|
|
15
|
+
|------------|---------|-------------------------------------|
|
|
16
|
+
| id | INTEGER | PRIMARY KEY, AUTOINCREMENT |
|
|
17
|
+
| title | TEXT | NOT NULL |
|
|
18
|
+
| content | TEXT | NOT NULL, DEFAULT '' |
|
|
19
|
+
| created_at | TEXT | NOT NULL, DEFAULT datetime('now') |
|
|
20
|
+
| updated_at | TEXT | NOT NULL, DEFAULT datetime('now') |
|
|
21
|
+
|
|
22
|
+
### Notes
|
|
23
|
+
- Timestamps are stored as ISO 8601 strings (SQLite has no native datetime type).
|
|
24
|
+
- WAL journal mode is enabled for better concurrent read performance.
|
|
25
|
+
- Foreign keys are enforced.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Register all resources on the given MCP server.
|
|
30
|
+
* Accepts an optional database override for testing.
|
|
31
|
+
*/
|
|
32
|
+
export function registerResources(server: McpServer, database?: Db): void {
|
|
33
|
+
const db = database ?? defaultDb;
|
|
34
|
+
|
|
35
|
+
// ── notes://list ─────────────────────────────────────────────────────
|
|
36
|
+
server.resource("notes-list", "notes://list", {
|
|
37
|
+
description: "List all notes in the database as JSON",
|
|
38
|
+
mimeType: "application/json",
|
|
39
|
+
}, async () => {
|
|
40
|
+
const allNotes = db.select().from(notes).all();
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
contents: [
|
|
44
|
+
{
|
|
45
|
+
uri: "notes://list",
|
|
46
|
+
mimeType: "application/json",
|
|
47
|
+
text: JSON.stringify(allNotes, null, 2),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// ── db://schema ──────────────────────────────────────────────────────
|
|
54
|
+
server.resource("db-schema", "db://schema", {
|
|
55
|
+
description: "Database schema documentation",
|
|
56
|
+
mimeType: "text/markdown",
|
|
57
|
+
}, async () => {
|
|
58
|
+
return {
|
|
59
|
+
contents: [
|
|
60
|
+
{
|
|
61
|
+
uri: "db://schema",
|
|
62
|
+
mimeType: "text/markdown",
|
|
63
|
+
text: SCHEMA_DESCRIPTION,
|
|
64
|
+
},
|
|
65
|
+
],
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
|
|
2
|
+
import { sql } from "drizzle-orm";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Notes table — stores user notes with title, content, and timestamps.
|
|
6
|
+
*
|
|
7
|
+
* Timestamps are stored as ISO 8601 strings (SQLite has no native date type).
|
|
8
|
+
* Defaults are handled at the database level via SQL expressions.
|
|
9
|
+
*/
|
|
10
|
+
export const notes = sqliteTable("notes", {
|
|
11
|
+
id: integer("id").primaryKey({ autoIncrement: true }),
|
|
12
|
+
title: text("title").notNull(),
|
|
13
|
+
content: text("content").notNull().default(""),
|
|
14
|
+
createdAt: text("created_at")
|
|
15
|
+
.notNull()
|
|
16
|
+
.default(sql`(datetime('now'))`),
|
|
17
|
+
updatedAt: text("updated_at")
|
|
18
|
+
.notNull()
|
|
19
|
+
.default(sql`(datetime('now'))`),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
/** TypeScript type for a note row returned from the database. */
|
|
23
|
+
export type Note = typeof notes.$inferSelect;
|
|
24
|
+
|
|
25
|
+
/** TypeScript type for inserting a new note. */
|
|
26
|
+
export type NewNote = typeof notes.$inferInsert;
|
|
@@ -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
|
+
});
|