diff --git a/CLAUDE.md b/CLAUDE.md index b189a22..25e05a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -130,6 +130,7 @@ A `Makefile` wraps all common tasks for tool-agnostic usage. Run `make help` to - **Conventional commits**: Use `feat:`, `fix:`, `chore:`, `docs:`, `refactor:`, `test:` prefixes - **Commit regularly**: Bundle related changes into logical commits after each step or feature slice — don't accumulate a large diff - **Feature branches**: Use `feat/` branches with worktrees for isolation +- **Rebase before PR**: Always `git rebase origin/main` (not merge) before pushing or creating a PR to keep a clean linear history and avoid merge conflicts in the PR ## CI/CD diff --git a/README.md b/README.md index bd77197..5eb71c7 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,8 @@ pnpm build # Production build - Organize with #tags, pinning, archive, and trash - Full-text search across titles, content, and tags - PWA — installable, works offline via IndexedDB + LWW CRDT sync -- Single-user password auth (Argon2) +- MCP server — let AI assistants (Claude Code, etc.) manage your notes +- Single-user password auth (Argon2) + API keys for MCP access - Docker deployment with a single command See [docs/FEATURES.md](docs/FEATURES.md) for detailed feature documentation. diff --git a/docs/API.md b/docs/API.md index 88cf48e..f37fbfe 100644 --- a/docs/API.md +++ b/docs/API.md @@ -111,6 +111,17 @@ List notes "version": 1, "tags": [ "item" + ], + "attachments": [ + { + "id": "id", + "noteId": "noteId", + "filename": "filename", + "mimeType": "mimeType", + "size": 1, + "thumbnailPath": "thumbnailPath", + "createdAt": "2025-01-01T00:00:00.000Z" + } ] } ] @@ -157,6 +168,17 @@ Create a note "version": 1, "tags": [ "item" + ], + "attachments": [ + { + "id": "id", + "noteId": "noteId", + "filename": "filename", + "mimeType": "mimeType", + "size": 1, + "thumbnailPath": "thumbnailPath", + "createdAt": "2025-01-01T00:00:00.000Z" + } ] } ``` @@ -192,6 +214,17 @@ Get a note "version": 1, "tags": [ "item" + ], + "attachments": [ + { + "id": "id", + "noteId": "noteId", + "filename": "filename", + "mimeType": "mimeType", + "size": 1, + "thumbnailPath": "thumbnailPath", + "createdAt": "2025-01-01T00:00:00.000Z" + } ] } ``` @@ -247,6 +280,17 @@ Partial update — only include changed fields. "version": 1, "tags": [ "item" + ], + "attachments": [ + { + "id": "id", + "noteId": "noteId", + "filename": "filename", + "mimeType": "mimeType", + "size": 1, + "thumbnailPath": "thumbnailPath", + "createdAt": "2025-01-01T00:00:00.000Z" + } ] } ``` @@ -291,6 +335,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the |------|----|------|----------|-------------| | `id` | path | string | yes | | | `attachmentId` | query | string | no | | +| `thumb` | query | string | no | Set to "1" to return the thumbnail instead of the full image (requires attachmentId) (1) | **Response:** `200` @@ -302,6 +347,7 @@ Without `attachmentId`, lists all attachments. With `attachmentId`, returns the "filename": "filename", "mimeType": "mimeType", "size": 1, + "thumbnailPath": "thumbnailPath", "createdAt": "2025-01-01T00:00:00.000Z" } ] @@ -330,6 +376,7 @@ Upload an image attachment "filename": "filename", "mimeType": "mimeType", "size": 1, + "thumbnailPath": "thumbnailPath", "createdAt": "2025-01-01T00:00:00.000Z" } ``` @@ -393,6 +440,17 @@ Searches across note titles, content, and tags. Excludes trashed notes. "version": 1, "tags": [ "item" + ], + "attachments": [ + { + "id": "id", + "noteId": "noteId", + "filename": "filename", + "mimeType": "mimeType", + "size": 1, + "thumbnailPath": "thumbnailPath", + "createdAt": "2025-01-01T00:00:00.000Z" + } ] } ] @@ -451,6 +509,17 @@ Pull changes since timestamp "version": 1, "tags": [ "item" + ], + "attachments": [ + { + "id": "id", + "noteId": "noteId", + "filename": "filename", + "mimeType": "mimeType", + "size": 1, + "thumbnailPath": "thumbnailPath", + "createdAt": "2025-01-01T00:00:00.000Z" + } ] } ] @@ -498,3 +567,92 @@ Push local changes **Errors:** `400` Invalid changes payload --- + +## Settings + +### `GET /api/settings/api-keys` + +List API keys + +**Response:** `200` + +```json +[ + { + "id": "uuid", + "name": "name", + "keyPrefix": "keyPrefix", + "createdAt": "2025-01-01T00:00:00.000Z", + "lastUsedAt": "2025-01-01T00:00:00.000Z" + } +] +``` + +--- + +### `POST /api/settings/api-keys` + +Create API key + +**Request:** + +```json +{ + "name": "name" +} +``` + +**Response:** `201` + +```json +{ + "id": "id", + "key": "key", + "name": "name", + "keyPrefix": "keyPrefix" +} +``` + +--- + +### `DELETE /api/settings/api-keys/{id}` + +Revoke API key + +**Parameters:** + +| Name | In | Type | Required | Description | +|------|----|------|----------|-------------| +| `id` | path | string | yes | | + +**Response:** `200` + +```json +{ + "success": true +} +``` + +**Errors:** `404` Key not found + +--- + +## MCP + +### `POST /api/mcp` + +MCP Streamable HTTP endpoint + +Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key). + +**Request:** + +```json +{} +``` + +**Response:** `200` + +**Errors:** `400` Invalid session, `401` Invalid or missing API key + +--- diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 60d319e..952a501 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -98,17 +98,41 @@ Key tables: - `tags` - Unique tag names - `note_tags` - Many-to-many note ↔ tag - `attachments` - Image file metadata (path + thumbnailPath) +- `api_keys` - MCP API keys (SHA-256 hashed, prefix for display) - `sync_log` - Sync operation history +## MCP Server + +Crumbs includes a built-in [Model Context Protocol](https://modelcontextprotocol.io/) server that allows AI assistants (Claude Code, Claude Desktop, etc.) to interact with notes programmatically. + +### Architecture +- **Endpoint**: `POST /api/mcp` (Streamable HTTP transport) +- **Auth**: Bearer token via API keys (generated in Settings) +- **Sessions**: Stateful per MCP spec — session ID in `mcp-session-id` header +- **Transport**: `WebStandardStreamableHTTPServerTransport` (works with SvelteKit's Web Standard Request/Response) + +### Shared Logic +MCP tool handlers and REST API routes both call the same service layer (`src/lib/server/notes-service.ts`). This avoids duplicating business logic: + +``` +REST API routes ──┐ + ├──► notes-service.ts ──► Drizzle DB +MCP tool handlers ┘ +``` + +### Tools (14) +`list_notes`, `get_note`, `create_note`, `update_note`, `trash_note`, `restore_note`, `archive_note`, `unarchive_note`, `delete_note`, `search_notes`, `list_tags`, `pin_note`, `reorder_notes`, `upload_image` + ## File Organization ``` -src/lib/server/ # Server-only code (DB, auth, attachments) +src/lib/server/ # Server-only code (DB, auth, attachments, notes-service) +src/lib/server/mcp/ # MCP server + tool definitions src/lib/sync/ # Sync engine (shared types, server + client implementations) src/lib/stores/ # Svelte reactive stores (notes, theme, auth, search) src/lib/components/ # Svelte UI components src/lib/utils/ # Pure utility functions (markdown, tags, colors) src/lib/types/ # TypeScript type definitions -src/routes/api/ # REST API endpoints +src/routes/api/ # REST API + MCP endpoints src/routes/ # Page routes (SvelteKit file-based routing) ``` diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8a2559e..222c7be 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -116,6 +116,48 @@ The Docker image includes a health check that pings `/login`: docker inspect --format='{{.State.Health.Status}}' crumbs ``` +## MCP Server Configuration + +Crumbs includes a built-in MCP (Model Context Protocol) server that allows AI assistants to interact with your notes. + +### Setup + +1. Open **Settings** in the Crumbs UI +2. Create an API key (give it a name like "Claude Code") +3. Copy the key (shown only once) +4. Configure your MCP client: + +```json +{ + "mcpServers": { + "crumbs": { + "type": "streamable-http", + "url": "https://your-crumbs-instance/api/mcp", + "headers": { + "Authorization": "Bearer YOUR_API_KEY" + } + } + } +} +``` + +### Available Tools + +| Tool | Description | +|------|-------------| +| `list_notes` | List notes (filter by status/tag) | +| `get_note` | Get a note by ID | +| `create_note` | Create a new note | +| `update_note` | Update a note | +| `trash_note` / `restore_note` | Move to/from trash | +| `archive_note` / `unarchive_note` | Archive/unarchive | +| `delete_note` | Permanently delete | +| `search_notes` | Full-text search | +| `list_tags` | List all tags | +| `pin_note` | Pin/unpin a note | +| `reorder_notes` | Set sort orders | +| `upload_image` | Attach image from URL | + ## Security Notes - Always use HTTPS in production (via reverse proxy) @@ -123,3 +165,4 @@ docker inspect --format='{{.State.Health.Status}}' crumbs - The `ORIGIN` variable must match your actual domain for CSRF protection - Sessions expire after 30 days - Passwords are hashed with Argon2 +- API keys are SHA-256 hashed (never stored in plain text) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index 4e2175b..b88afaf 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -102,6 +102,21 @@ - LWW conflict resolution for multi-device use - Sync status indicator (synced/syncing/offline/error) +### MCP Server (Model Context Protocol) +- Built-in MCP endpoint at `POST /api/mcp` (Streamable HTTP transport) +- 14 tools: list, get, create, update, trash, restore, archive, unarchive, delete, search notes; list tags; pin, reorder notes; upload images +- API key authentication (Bearer token) +- Compatible with Claude Code, Claude Desktop, and other MCP clients +- Stateful sessions with automatic cleanup + +### API Key Management +- Generate API keys from the Settings page +- Keys use `crumbs_` prefix for easy identification +- SHA-256 hashed storage (keys shown once at creation) +- Track last-used timestamps +- Revoke keys with confirmation dialog +- MCP client config example shown in Settings + ### Docker Deployment - Multi-stage Dockerfile (build + slim runtime) - docker-compose.yml with persistent volume diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 7990f4d..2167f35 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -325,12 +325,101 @@ paths: '400': description: Invalid changes payload + /api/settings/api-keys: + get: + summary: List API keys + tags: [Settings] + responses: + '200': + description: List of API keys (without hashes) + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ApiKey' + post: + summary: Create API key + tags: [Settings] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [name] + properties: + name: + type: string + responses: + '201': + description: API key created (key shown only once) + content: + application/json: + schema: + type: object + properties: + id: + type: string + key: + type: string + name: + type: string + keyPrefix: + type: string + + /api/settings/api-keys/{id}: + delete: + summary: Revoke API key + tags: [Settings] + parameters: + - name: id + in: path + required: true + schema: + type: string + responses: + '200': + description: Key revoked + content: + application/json: + schema: + $ref: '#/components/schemas/Success' + '404': + description: Key not found + + /api/mcp: + post: + summary: MCP Streamable HTTP endpoint + description: Model Context Protocol endpoint. Accepts JSON-RPC 2.0 messages. Authenticate via Bearer token (API key). + tags: [MCP] + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + type: object + description: JSON-RPC 2.0 message + responses: + '200': + description: JSON-RPC response (may be SSE stream) + '400': + description: Invalid session + '401': + description: Invalid or missing API key + components: securitySchemes: sessionCookie: type: apiKey in: cookie name: session + bearerAuth: + type: http + scheme: bearer + description: API key (crumbs_...) schemas: Success: @@ -452,3 +541,22 @@ components: description: Unix timestamp in milliseconds data: $ref: '#/components/schemas/NoteInput' + + ApiKey: + type: object + properties: + id: + type: string + format: uuid + name: + type: string + keyPrefix: + type: string + description: First 12 characters of the key for display + createdAt: + type: string + format: date-time + lastUsedAt: + type: string + format: date-time + nullable: true