diff --git a/app-config.yaml b/app-config.yaml index 888ae411..b8d355e4 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -11,6 +11,8 @@ httpApi: clientSecret: 'client_secret' redirectUrl: '/oauth/google/login' callbackUrl: 'http://localhost:1337/oauth/google/callback' + mcp: + webUrl: 'http://localhost:5173' auth: accessSecret: 'secret' diff --git a/docs/mcp-plan.md b/docs/mcp-plan.md new file mode 100644 index 00000000..a24d4d60 --- /dev/null +++ b/docs/mcp-plan.md @@ -0,0 +1,142 @@ +# MCP server: implementation plan + +Living document. Update the status checkboxes and the "Deviations" section as the work goes. + +**Status:** steps 1 and 2 are done on `feat/mcp-server`; step 3 is done on `feat/mcp-tokens` in notes.web. Following review, `/mcp` runs on MCP SDK v2 and serves both protocol revisions. + +## Decisions + +- **Where it lives.** A remote MCP endpoint `/mcp` inside notes.api. Transport: Streamable HTTP without MCP sessions. Protocol revision 2026-07-28 and 2025-era clients are served on the same endpoint. No separate deployment, no npm package. +- **Auth.** Personal MCP tokens sent as `Authorization: Bearer ntx_…`. A user can have several tokens, each with `read` or `write` permission. No OAuth for now. +- **Content format.** Editor.js JSON as stored in the DB. The server generates ids for new blocks and builds `tools[]` itself. +- **Tools.** `list_notes`, `get_note`, `create_note`, `update_note`. A `read` token sees only the first two. No delete, no search. +- **Permissions.** Tools call existing HTTP routes through `fastify.inject()` with a short-lived access JWT of the token owner, so all route policies and validation apply without duplication. + +## Step 1. Tokens (notes.api) + +- [x] Migration `migrations/tenant/0036-mcp-tokens@add-mcp-tokens-table.sql`: + ```sql + CREATE TABLE IF NOT EXISTS public.mcp_tokens ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES public.users(id) ON DELETE CASCADE, + token_hash CHAR(64) NOT NULL UNIQUE, + preview VARCHAR(4) NOT NULL, + permission VARCHAR(5) NOT NULL CHECK (permission IN ('read', 'write')), + label VARCHAR(255) NOT NULL, + last_used_at TIMESTAMPTZ, + request_count INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() + ); + ``` +- [x] Entity `src/domain/entities/mcpToken.ts`: `McpToken` and `McpTokenPublic` (without `tokenHash` and `userId`). +- [x] Storage and repository: + - sequelize model `src/repository/storage/postgres/orm/sequelize/mcpToken.ts`; + - `src/repository/storage/mcpToken.storage.ts`; + - `src/repository/mcpToken.repository.ts`; + - wiring in `src/repository/index.ts`. +- [x] Service `src/domain/service/mcpToken.ts`: + - `createToken(userId, label, permission)` generates `ntx_` + `nanoid(32)`, stores its sha256 and last 4 chars, returns the plain token once; + - `getTokensByUserId(userId)`; + - `revokeToken(userId, id)` revokes only the user's own tokens; + - `resolveToken(token)` returns the token record or `null`; + - `trackUsage(id)` atomically increments `request_count` and sets `last_used_at`. +- [x] Router `src/presentation/http/router/mcpTokens.ts` with prefix `/user/mcp-tokens`, all routes `authRequired`: + - `GET /` lists tokens without hashes; + - `POST /` with `{ label, permission }` returns `{ token, item }`; + - `DELETE /:id`. +- [x] Tests: + - the token is returned only on creation; + - the list has no hashes; + - a user cannot revoke someone else's token. + +## Step 2. `/mcp` endpoint (notes.api) + +- [x] Dependencies `@modelcontextprotocol/server` and `@modelcontextprotocol/node` (MCP SDK v2), zod `^4.2`. +- [x] `src/presentation/mcp/`: + - `index.ts` — fastify plugin: `POST /mcp`; `GET` and `DELETE` answer 405; + - `auth.ts` — Bearer token → `resolveToken()`, 401 without a valid token; + - `server.ts` — `createMcpServer({ userId, permission })` registers tools by permission; + - `noteRef.ts` — parses a note URL or id: `notex.so/note/`, `/note//…`, a bare 10-char id, a custom hostname via `GET /note/resolve-hostname/:hostname`; + - `content.ts` — fills block ids, builds `tools[]`; + - `tools/listNotes.ts`, `tools/getNote.ts`, `tools/createNote.ts`, `tools/updateNote.ts`. +- [x] Request flow: + 1. resolve the token; + 2. sign a short access JWT for the owner with `authService.signAccessToken()`; + 3. `createMcpHandler()`, created once per router, passes the token to its factory through `authInfo`; the factory creates `McpServer` for the request; + 4. `reply.hijack()` and `toNodeHandler(handler)(request.raw, reply.raw, request.body)`; + 5. tools call the API through `fastify.inject()` with that JWT; + 6. `trackUsage` only on `tools/call`. +- [x] `userIdResolver` skips `ntx_…` tokens instead of logging a JWT error on every request. +- [x] Tools: + + | Tool | Params | API | Returns | + |---|---|---|---| + | `list_notes` | `source: recent \| created` (default `recent`), `page` (1–30), `parent?` | `GET /notes`, `GET /notes/created`; with `parent` — `GET /note/note-hierarchy/:id`, subtree of the parent | `[{ id, title, url, updatedAt }]` or a tree `{ id, title, url, children }` | + | `get_note` | `note` (URL or id) | `GET /note/:id` | `{ id, url, title, updatedAt, canEdit, parents, content: { blocks } }` | + | `create_note` | `blocks`, `parent?` (URL or id) | `POST /note` | `{ id, url }` | + | `update_note` | `note`, `blocks`, `mode: replace \| append`, `expectedUpdatedAt?` | `GET /note/:id`, compare `updatedAt`, merge, `PATCH /note/:id` | `{ id, url, updatedAt }` | + +- [x] `tools[]` lookup order for each block type: tools of the note, then user tools (`GET /user/editor-tools`), then default tools (`GET /editor-tools/all`). Unknown type → error listing the available tools. +- [x] Tool descriptions document the default block formats: `paragraph {text}` with inline HTML, `header {text, level}`, `list {style, items: string[]}` (list@1.9). +- [x] Note links are built from a new config value `mcp.webUrl` (default `https://notex.so`). +- [x] Tests `src/presentation/mcp/mcp.test.ts`: + - 401 without a token; + - a `read` token doesn't see write tools in `tools/list`; + - create → get → update round trip; + - `expectedUpdatedAt` conflict; + - `request_count` grows. + +## Step 3. Settings in notes.web + +- [x] New "MCP" fieldset in `Settings.vue`: + - token list: label, permission, `ntx_…a1b2`, created, last used, request count, "Revoke"; + - new token form: label and an "Allow editing" checkbox (off by default); + - after creation the token is shown once with a copy button and ready-made setup instructions for Claude Code, Cursor, and claude.ai (custom connector → Request headers). +- [x] Layers: entity, notes-api transport methods, service, ru/en texts. + +Setup snippets for the UI: + +- Claude Code: `claude mcp add --transport http notex https://api.notex.so/mcp --header "Authorization: Bearer ntx_…"` +- Cursor: `{ "mcpServers": { "notex": { "url": "https://api.notex.so/mcp", "headers": { "Authorization": "Bearer ntx_…" } } } }` + +## Order + +Step 1 → step 2. Step 3 depends only on the step 1 routes and can go in parallel with step 2. + +## Out of scope for v1 + +OAuth, search, deleting notes, Markdown format, MCP resources. + +Separate fix, not part of this work: `POST /note` with `parentId` does not check that the user can edit the parent note, so any logged-in user can attach a note to someone else's tree. `create_note` goes through the same route and has the same gap until it is fixed. + +## Deviations + +- Step 1: the migration declares the FK, the permission check and the indexes as named constraints (same structure as other migrations) instead of inline column constraints. +- Step 2: zod is bumped to `^4.2.0`, MCP SDK v2 doesn't support zod 3. Outside MCP zod is used only in the config schema, which works on zod 4 unchanged. +- Step 2: the web url lives in `httpApi.mcp.webUrl` (default `https://notex.so`, `http://localhost:5173` in `app-config.yaml`), so `HttpApi` keeps getting only its own config section. +- Step 2: 2025-era requests are answered with a short SSE stream carrying one message, since the SDK's stateless fallback has no JSON-only option. 2026-07-28 requests get plain JSON. +- Step 2: `GET /user/editor-tools` already includes the default tools, so `tools[]` is built from the note tools and the user tools; `GET /editor-tools/all` is not called. +- Step 2: two helper modules were added: `notexApi.ts` (API client over `fastify.inject()`) and `types.ts` (tool context and result helper). +- Step 2: besides the tests through `fastify.inject()`, the endpoint was checked over real HTTP with curl against a local server: `initialize`, `tools/list`, `create_note`, `list_notes`, 405 on `GET`, 401 for a revoked token, two tools for a read token. It has not been tried with Claude Code or Cursor yet. +- Step 2 (review): `PATCH /note/:notePublicId` accepts an optional `expectedUpdatedAt`. The note is updated only if its `updated_at` still matches, checked in the same `UPDATE` query; otherwise the route returns 409. `update_note` always passes the version it read, so concurrent updates and appends can't overwrite each other. Note history is now written after a successful update, not before. +- Step 2 (review): `/mcp` copies headers set by Fastify hooks (CORS) to the raw response, since the hijacked reply doesn't flush them. +- Step 2 (review): `/mcp` moved from `@modelcontextprotocol/sdk` v1 to SDK v2 to serve protocol revision 2026-07-28 next to 2025-era clients: + - `createMcpHandler` with the default stateless legacy fallback, wired into Fastify with `toNodeHandler`; the `@modelcontextprotocol/fastify` adapter requires Fastify 5; + - tool input schemas are wrapped in `z.object()`; + - unknown tools, e.g. write tools for a read token, are rejected with a JSON-RPC error instead of a tool result with `isError`; + - SDK v2 needs Node 20+, CI and Docker already use Node 20; + - tests connect a real SDK v2 client (`@modelcontextprotocol/client`, dev dependency) through `fastify.inject()` in both eras. The same client was checked over real HTTP against a local server: the legacy client negotiates 2025-11-25, the pinned one 2026-07-28. +- Step 3: notes.web has only the English locale, so texts are added to `en.json` only. +- Step 3: the section is a separate component `src/presentation/components/mcp/McpTokens.vue` with the `useUserMcpSettings` composable; `Settings.vue` only renders it. Following review, the domain layer is named after user settings: `UserSettingsService` and `UserSettingsRepository` with `getMcpTokens`, `createMcpToken`, `revokeMcpToken`. Snippets use the `text-monospaced` class from the codex-ui typescale. +- Step 3: the "Create token" button sits below the form card, like the "Logout" button in the General section. +- Step 3: checked in the browser against a local API: creating read and write tokens, the one-time token with setup snippets, the list with usage, revoking with confirmation, no horizontal scroll at 375px width. + +## Verification + +MCP SDK v2 needs Node 20+, so switch to the `.nvmrc` version first (`nvm use`). + +The repo may contain other sessions' worktrees in `.claude/worktrees`, which ESLint and Vitest pick up. Run checks on this checkout only: + +- `yarn typecheck` +- `yarn eslint src` +- `yarn vitest run --exclude '.claude/**'` diff --git a/eslint.config.mjs b/eslint.config.mjs index 8ae2562c..3deeed9a 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -36,9 +36,12 @@ export default [ */ 'n/no-missing-import': ['off'], 'n/no-unpublished-import': ['error', { - allowModules: ['vitest', 'postgres-migrations', '@testcontainers/localstack'], + allowModules: ['vitest', 'postgres-migrations', '@testcontainers/localstack', '@modelcontextprotocol/client'], ignoreTypeImport: true, }], + 'n/no-unsupported-features/es-builtins': ['error', { + version: '>=22.1.0', + }], /** * @todo get rid of this rule ignores and solve all eslint errors occured */ diff --git a/migrations/tenant/0036-mcp-tokens@add-mcp-tokens-table.sql b/migrations/tenant/0036-mcp-tokens@add-mcp-tokens-table.sql new file mode 100644 index 00000000..3d462747 --- /dev/null +++ b/migrations/tenant/0036-mcp-tokens@add-mcp-tokens-table.sql @@ -0,0 +1,34 @@ +-- +-- Name: mcp_tokens; Type: TABLE; Schema: public; Owner: codex +-- Personal tokens used by MCP clients to access user notes +-- + +CREATE TABLE IF NOT EXISTS public.mcp_tokens ( + id SERIAL PRIMARY KEY, + user_id integer NOT NULL, + token_hash character(64) NOT NULL, + preview character varying(4) NOT NULL, + permission character varying(5) NOT NULL, + label character varying(255) NOT NULL, + last_used_at timestamp with time zone, + request_count integer NOT NULL DEFAULT 0, + created_at timestamp with time zone NOT NULL DEFAULT now() +); + +-- +-- Name: mcp_tokens user_id_fkey; Type: FK CONSTRAINT; Schema: public; Owner: codex +-- +ALTER TABLE public.mcp_tokens DROP CONSTRAINT IF EXISTS mcp_tokens_user_id_fkey; +ALTER TABLE public.mcp_tokens + ADD CONSTRAINT mcp_tokens_user_id_fkey FOREIGN KEY (user_id) REFERENCES public.users(id) + ON UPDATE CASCADE ON DELETE CASCADE; + +-- +-- Name: mcp_tokens permission_check; Type: CHECK CONSTRAINT; Schema: public; Owner: codex +-- +ALTER TABLE public.mcp_tokens DROP CONSTRAINT IF EXISTS mcp_tokens_permission_check; +ALTER TABLE public.mcp_tokens + ADD CONSTRAINT mcp_tokens_permission_check CHECK (permission IN ('read', 'write')); + +CREATE UNIQUE INDEX IF NOT EXISTS mcp_tokens_token_hash_unique_idx ON public.mcp_tokens (token_hash); +CREATE INDEX IF NOT EXISTS mcp_tokens_user_id_idx ON public.mcp_tokens (user_id); diff --git a/package.json b/package.json index 98070799..a1dada49 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "author": "", "license": "ISC", "devDependencies": { + "@modelcontextprotocol/client": "^2.4.0", "@testcontainers/localstack": "^10.9.0", "@types/jsonwebtoken": "^9.0.2", "@types/node": "^20.2.3", @@ -47,6 +48,8 @@ "@fastify/oauth2": "^7.2.1", "@fastify/swagger": "^8.8.0", "@fastify/swagger-ui": "^1.9.3", + "@modelcontextprotocol/node": "^2.1.2", + "@modelcontextprotocol/server": "^2.4.0", "@testcontainers/postgresql": "^10.2.1", "arg": "^5.0.2", "fastify": "^4.17.0", @@ -60,7 +63,7 @@ "prom-client": "^14.2.0", "sequelize": "^6.31.1", "testcontainers": "^10.2.1", - "zod": "^3.21.4" + "zod": "^4.2.0" }, "packageManager": "yarn@3.6.4" } diff --git a/src/domain/entities/NoteUpdateConflictError.ts b/src/domain/entities/NoteUpdateConflictError.ts new file mode 100644 index 00000000..e8128c98 --- /dev/null +++ b/src/domain/entities/NoteUpdateConflictError.ts @@ -0,0 +1,13 @@ +import { DomainError } from './DomainError.js'; + +/** + * Thrown when the note was changed after the version the update is based on + */ +export class NoteUpdateConflictError extends DomainError { + /** + * @param message - description of the conflict + */ + constructor(message = 'Note was changed after the expected version') { + super(message); + } +} diff --git a/src/domain/entities/mcpToken.ts b/src/domain/entities/mcpToken.ts new file mode 100644 index 00000000..f2427f64 --- /dev/null +++ b/src/domain/entities/mcpToken.ts @@ -0,0 +1,87 @@ +import type User from './user.js'; + +/** + * Prefix of every MCP token. Allows to tell MCP tokens apart from access tokens + */ +export const MCP_TOKEN_PREFIX = 'ntx_'; + +/** + * What MCP client can do with notes of the token owner + */ +export type McpTokenPermission = 'read' | 'write'; + +/** + * Personal token used by MCP clients to access user notes + */ +export default interface McpToken { + /** + * Token id + */ + id: number; + + /** + * Token owner id + */ + userId: User['id']; + + /** + * Sha256 hash of the token. Token itself is not stored + */ + tokenHash: string; + + /** + * Last characters of the token, used to tell tokens apart in user settings + */ + preview: string; + + /** + * What MCP client can do with notes of the token owner + */ + permission: McpTokenPermission; + + /** + * Label set by user, e.g. "Claude Code on laptop" + */ + label: string; + + /** + * Last time token was used to call MCP tool + */ + lastUsedAt: Date | null; + + /** + * How many times token was used to call MCP tools + */ + requestCount: number; + + /** + * When token was created + */ + createdAt: Date; +} + +/** + * Attributes required to create MCP token + */ +export type McpTokenCreationAttributes = Pick; + +/** + * MCP token without secret and owner fields, sent to user + */ +export type McpTokenPublic = Omit; + +/** + * Change McpToken to McpTokenPublic + * @param mcpToken - MCP token data to compose a public token + */ +export function definePublicMcpToken(mcpToken: McpToken): McpTokenPublic { + return { + id: mcpToken.id, + preview: mcpToken.preview, + permission: mcpToken.permission, + label: mcpToken.label, + lastUsedAt: mcpToken.lastUsedAt, + requestCount: mcpToken.requestCount, + createdAt: mcpToken.createdAt, + }; +} diff --git a/src/domain/index.ts b/src/domain/index.ts index 7d1fdb85..0bc07679 100644 --- a/src/domain/index.ts +++ b/src/domain/index.ts @@ -8,6 +8,7 @@ import AIService from './service/ai.js'; import EditorToolsService from '@domain/service/editorTools.js'; import FileUploaderService from './service/fileUploader.service.js'; import NoteVisitsService from './service/noteVisits.js'; +import McpTokenService from './service/mcpToken.js'; import { PinoDomainLoggerAdapter } from '@infrastructure/logging/pinoDomainLoggerAdapter.js'; /** @@ -53,6 +54,11 @@ export interface DomainServices { * Note visits service instance */ noteVisitsService: NoteVisitsService; + + /** + * MCP token service instance + */ + mcpTokenService: McpTokenService; } /** @@ -91,6 +97,7 @@ export function init(repositories: Repositories, appConfig: AppConfig): DomainSe const userService = new UserService(repositories.userRepository, sharedServices, domainLogger); const noteSettingsService = new NoteSettingsService(repositories.noteSettingsRepository, repositories.teamRepository, sharedServices, domainLogger); const aiService = new AIService(repositories.aiRepository); + const mcpTokenService = new McpTokenService(repositories.mcpTokenRepository, domainLogger); return { fileUploaderService, @@ -101,5 +108,6 @@ export function init(repositories: Repositories, appConfig: AppConfig): DomainSe aiService, editorToolsService, noteVisitsService, + mcpTokenService, }; } diff --git a/src/domain/service/mcpToken.ts b/src/domain/service/mcpToken.ts new file mode 100644 index 00000000..6f3cc269 --- /dev/null +++ b/src/domain/service/mcpToken.ts @@ -0,0 +1,139 @@ +import { createHash } from 'crypto'; +import { nanoid } from 'nanoid'; +import type McpTokenRepository from '@repository/mcpToken.repository.js'; +import type McpToken from '@domain/entities/mcpToken.js'; +import type { McpTokenPermission } from '@domain/entities/mcpToken.js'; +import { MCP_TOKEN_PREFIX } from '@domain/entities/mcpToken.js'; +import type User from '@domain/entities/user.js'; +import type { DomainLogger } from '@infrastructure/logging/domainLoggerInterface.js'; + +/** + * MCP token service. Issues, revokes and resolves personal tokens used by MCP clients + */ +export default class McpTokenService { + /** + * MCP token repository instance + */ + private readonly repository: McpTokenRepository; + + /** + * Logger instance + */ + private readonly logger: DomainLogger; + + /** + * Length of the random part of the token + */ + private readonly tokenSize = 32; + + /** + * Number of last characters of the token shown to user as a preview + */ + private readonly previewLength = 4; + + /** + * MCP token service constructor + * @param repository - MCP token repository + * @param logger - domain logger + */ + constructor(repository: McpTokenRepository, logger: DomainLogger) { + this.repository = repository; + this.logger = logger; + } + + /** + * Checks if passed string looks like MCP token + * @param token - string to check + */ + public static isMcpToken(token: string): boolean { + return token.startsWith(MCP_TOKEN_PREFIX); + } + + /** + * Creates new MCP token. Plain token is returned only once and is not stored + * @param userId - token owner id + * @param label - label set by user + * @param permission - what MCP client can do with notes of the token owner + * @returns plain token and created token record + */ + public async createToken(userId: User['id'], label: string, permission: McpTokenPermission): Promise<{ token: string; mcpToken: McpToken }> { + const token = MCP_TOKEN_PREFIX + nanoid(this.tokenSize); + + const mcpToken = await this.repository.addToken({ + userId, + tokenHash: this.hashToken(token), + preview: token.slice(-this.previewLength), + permission, + label, + }); + + this.logger.info('MCP token created', { + userId, + tokenId: mcpToken.id, + permission, + }); + + return { + token, + mcpToken, + }; + } + + /** + * Returns all MCP tokens of the user + * @param userId - token owner id + */ + public async getTokensByUserId(userId: User['id']): Promise { + return await this.repository.getTokensByUserId(userId); + } + + /** + * Revokes MCP token. User can revoke only own tokens + * @param userId - token owner id + * @param tokenId - token id + * @returns true if token was revoked + */ + public async revokeToken(userId: User['id'], tokenId: McpToken['id']): Promise { + const isRevoked = await this.repository.removeToken(tokenId, userId); + + if (isRevoked) { + this.logger.info('MCP token revoked', { + userId, + tokenId, + }); + } + + return isRevoked; + } + + /** + * Finds MCP token record by plain token + * @param token - plain token passed by MCP client + * @returns token record or null if token is unknown + */ + public async resolveToken(token: string): Promise { + if (!McpTokenService.isMcpToken(token)) { + return null; + } + + return await this.repository.getTokenByHash(this.hashToken(token)); + } + + /** + * Saves one more usage of the token + * @param tokenId - token id + */ + public async trackUsage(tokenId: McpToken['id']): Promise { + await this.repository.trackUsage(tokenId); + } + + /** + * Hashes token to store and search it in database + * @param token - plain token + */ + private hashToken(token: string): string { + return createHash('sha256') + .update(token) + .digest('hex'); + } +} diff --git a/src/domain/service/note.ts b/src/domain/service/note.ts index 6e56591f..3bfabe1c 100644 --- a/src/domain/service/note.ts +++ b/src/domain/service/note.ts @@ -3,6 +3,7 @@ import type NoteRepository from '@repository/note.repository.js'; import type NoteVisitsRepository from '@repository/noteVisits.repository.js'; import { createPublicId } from '@infrastructure/utils/id.js'; import { DomainError } from '@domain/entities/DomainError.js'; +import { NoteUpdateConflictError } from '@domain/entities/NoteUpdateConflictError.js'; import type NoteRelationsRepository from '@repository/noteRelations.repository.js'; import type EditorToolsRepository from '@repository/editorTools.repository.js'; import type User from '@domain/entities/user.js'; @@ -167,12 +168,26 @@ export default class NoteService { * @param content - new content * @param noteTools - tools which are used in note * @param userId - id of the user that made changes + * @param expectedUpdatedAt - update the note only if it was last updated at this time + * @throws NoteUpdateConflictError if the note was changed after the expected version */ - public async updateNoteContentAndToolsById(id: NoteInternalId, content: Note['content'], noteTools: Note['tools'], userId: User['id']): Promise { + public async updateNoteContentAndToolsById(id: NoteInternalId, content: Note['content'], noteTools: Note['tools'], userId: User['id'], expectedUpdatedAt?: Date): Promise { + const areChangesSignificant = await this.areContentChangesSignificant(id, content); + + const updatedNote = await this.noteRepository.updateNoteContentAndToolsById(id, content, noteTools, expectedUpdatedAt); + + if (updatedNote === null) { + if (expectedUpdatedAt !== undefined) { + throw new NoteUpdateConflictError(); + } + + throw new DomainError(`Note with id ${id} was not updated`); + } + /** * If content changes are valuable, they will be saved to note history */ - if (await this.areContentChangesSignificant(id, content)) { + if (areChangesSignificant) { await this.noteHistoryRepository.createNoteHistoryRecord({ content, userId: userId, @@ -181,12 +196,6 @@ export default class NoteService { }); }; - const updatedNote = await this.noteRepository.updateNoteContentAndToolsById(id, content, noteTools); - - if (updatedNote === null) { - throw new DomainError(`Note with id ${id} was not updated`); - } - this.logger.debug('Note updated', { noteId: id, userId, diff --git a/src/infrastructure/config/index.ts b/src/infrastructure/config/index.ts index 7cc301d6..0a36f5e5 100644 --- a/src/infrastructure/config/index.ts +++ b/src/infrastructure/config/index.ts @@ -98,6 +98,15 @@ export const LoggingConfig = z.object({ export type LoggingConfig = z.infer; +/** + * MCP endpoint configuration + */ +const McpConfig = z.object({ + webUrl: z.string(), // NoteX web app url, used to build links to notes +}); + +export type McpConfig = z.infer; + /** * Http API configuration */ @@ -110,6 +119,7 @@ const HttpApiConfig = z.object({ accessTokenSecret: z.string(), allowedOrigins: z.union([z.array(z.string()), z.literal('*')]), oauth2: OAuth2Config, + mcp: McpConfig, }); export type HttpApiConfig = z.infer; @@ -146,6 +156,9 @@ const defaultConfig: AppConfig = { callbackUrl: '/oauth/google/callback', }, }, + mcp: { + webUrl: 'https://notex.so', + }, }, auth: { accessSecret: 'accessSecret', diff --git a/src/presentation/http/http-api.ts b/src/presentation/http/http-api.ts index 0505fe6a..ac57e80f 100644 --- a/src/presentation/http/http-api.ts +++ b/src/presentation/http/http-api.ts @@ -18,6 +18,8 @@ import AuthRouter from '@presentation/http/router/auth.js'; import UserRouter from '@presentation/http/router/user.js'; import AIRouter from '@presentation/http/router/ai.js'; import EditorToolsRouter from './router/editorTools.js'; +import McpTokensRouter from './router/mcpTokens.js'; +import McpRouter from '@presentation/mcp/index.js'; import { UserSchema } from './schema/User.js'; import { NoteSchema } from './schema/Note.js'; import { HistotyRecordShema, HistoryMetaSchema } from './schema/History.js'; @@ -35,6 +37,7 @@ import UploadRouter from './router/upload.js'; import { ajvFilePlugin } from '@fastify/multipart'; import { UploadSchema } from './schema/Upload.js'; import { NoteHierarchySchema } from './schema/NoteHierarchy.js'; +import { McpTokenSchema } from './schema/McpToken.js'; import { StatusCodes } from 'http-status-codes'; interface FastifyError extends Error { @@ -245,6 +248,18 @@ export default class HttpApi implements Api { editorToolsService: domainServices.editorToolsService, }); + await this.server?.register(McpTokensRouter, { + prefix: '/user/mcp-tokens', + mcpTokenService: domainServices.mcpTokenService, + }); + + await this.server?.register(McpRouter, { + prefix: '/mcp', + mcpTokenService: domainServices.mcpTokenService, + authService: domainServices.authService, + webUrl: this.config.mcp.webUrl, + }); + await this.server?.register(AIRouter, { prefix: '/ai', aiService: domainServices.aiService, @@ -308,6 +323,7 @@ export default class HttpApi implements Api { this.server?.addSchema(OauthSchema); this.server?.addSchema(UploadSchema); this.server?.addSchema(NoteHierarchySchema); + this.server?.addSchema(McpTokenSchema); } /** diff --git a/src/presentation/http/middlewares/common/userIdResolver.ts b/src/presentation/http/middlewares/common/userIdResolver.ts index 8035e09a..83973aa0 100644 --- a/src/presentation/http/middlewares/common/userIdResolver.ts +++ b/src/presentation/http/middlewares/common/userIdResolver.ts @@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'; import type AuthService from '@domain/service/auth.js'; import { notEmpty } from '@infrastructure/utils/empty.js'; import { getRequestLogger } from '@infrastructure/logging/index.js'; +import { MCP_TOKEN_PREFIX } from '@domain/entities/mcpToken.js'; /** * Add middleware for resolve userId from Access Token and add it to request @@ -24,6 +25,15 @@ export default function addUserIdResolver(server: FastifyInstance, authService: if (notEmpty(authorizationHeader)) { const token = authorizationHeader.replace('Bearer ', ''); + /** + * MCP tokens are resolved by the MCP endpoint itself and are not accepted by other routes + */ + if (token.startsWith(MCP_TOKEN_PREFIX)) { + done(); + + return; + } + try { request.userId = authService.verifyAccessToken(token)['id']; logger.debug('User ID resolved from Access Token'); diff --git a/src/presentation/http/router/mcpTokens.test.ts b/src/presentation/http/router/mcpTokens.test.ts new file mode 100644 index 00000000..dd089397 --- /dev/null +++ b/src/presentation/http/router/mcpTokens.test.ts @@ -0,0 +1,208 @@ +import { createHash } from 'crypto'; +import { describe, test, expect, beforeEach } from 'vitest'; +import type User from '@domain/entities/user.js'; + +/** + * Creates MCP token for the user via API + * @param user - user who will own the token + * @param permission - what MCP client can do with notes of the user + * @param label - text shown in user settings + */ +async function createToken(user: User, permission: 'read' | 'write' = 'read', label = 'Claude Code'): Promise<{ token: string; item: Record }> { + const response = await global.api?.fakeRequest({ + method: 'POST', + headers: { + authorization: `Bearer ${global.auth(user.id)}`, + }, + url: '/user/mcp-tokens', + body: { + label, + permission, + }, + }); + + return response!.json(); +} + +describe('MCP tokens API', () => { + beforeEach(async () => { + await global.db.truncateTables(); + }); + + describe('POST /user/mcp-tokens', () => { + test('Returns created token once and stores only its hash', async () => { + const user = await global.db.insertUser(); + + const response = await global.api?.fakeRequest({ + method: 'POST', + headers: { + authorization: `Bearer ${global.auth(user.id)}`, + }, + url: '/user/mcp-tokens', + body: { + label: 'Claude Code on laptop', + permission: 'write', + }, + }); + + expect(response?.statusCode).toBe(200); + + const body = response?.json(); + + expect(body.token).toMatch(/^ntx_[\w-]{32}$/); + expect(body.item).toMatchObject({ + preview: body.token.slice(-4), + permission: 'write', + label: 'Claude Code on laptop', + lastUsedAt: null, + requestCount: 0, + }); + expect(body.item).not.toHaveProperty('tokenHash'); + expect(body.item).not.toHaveProperty('userId'); + + const [rows] = await global.db.query(`SELECT token_hash AS "tokenHash" FROM public.mcp_tokens WHERE id = ${body.item.id}`) as [{ tokenHash: string }[]]; + const expectedHash = createHash('sha256') + .update(body.token as string) + .digest('hex'); + + expect(rows[0].tokenHash).toBe(expectedHash); + }); + + test.each([ + { + body: { + label: 'Claude Code', + permission: 'admin', + }, + }, + { + body: { + label: '', + permission: 'read', + }, + }, + { + body: { + permission: 'read', + }, + }, + ])('Returns 400 when body is invalid: $body', async ({ body }) => { + const user = await global.db.insertUser(); + + const response = await global.api?.fakeRequest({ + method: 'POST', + headers: { + authorization: `Bearer ${global.auth(user.id)}`, + }, + url: '/user/mcp-tokens', + body, + }); + + expect(response?.statusCode).toBe(400); + }); + + test('Returns 401 when user is not authorized', async () => { + const response = await global.api?.fakeRequest({ + method: 'POST', + url: '/user/mcp-tokens', + body: { + label: 'Claude Code', + permission: 'read', + }, + }); + + expect(response?.statusCode).toBe(401); + }); + }); + + describe('GET /user/mcp-tokens', () => { + test('Returns only tokens of the current user without secrets', async () => { + const user = await global.db.insertUser(); + const anotherUser = await global.db.insertUser(); + + await createToken(user, 'read', 'First'); + await createToken(user, 'write', 'Second'); + await createToken(anotherUser, 'write', 'Foreign'); + + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${global.auth(user.id)}`, + }, + url: '/user/mcp-tokens', + }); + + expect(response?.statusCode).toBe(200); + + const { items } = response?.json(); + + expect(items.map((item: { label: string }) => item.label)).toStrictEqual(['Second', 'First']); + + for (const item of items) { + expect(item).not.toHaveProperty('token'); + expect(item).not.toHaveProperty('tokenHash'); + expect(item).not.toHaveProperty('userId'); + } + }); + + test('Returns 401 when user is not authorized', async () => { + const response = await global.api?.fakeRequest({ + method: 'GET', + url: '/user/mcp-tokens', + }); + + expect(response?.statusCode).toBe(401); + }); + }); + + describe('DELETE /user/mcp-tokens/:tokenId', () => { + test('Revokes token of the current user', async () => { + const user = await global.db.insertUser(); + const { item } = await createToken(user); + + const response = await global.api?.fakeRequest({ + method: 'DELETE', + headers: { + authorization: `Bearer ${global.auth(user.id)}`, + }, + url: `/user/mcp-tokens/${item.id}`, + }); + + expect(response?.statusCode).toBe(200); + expect(response?.json()).toStrictEqual({ isDeleted: true }); + + const [rows] = await global.db.query(`SELECT id FROM public.mcp_tokens WHERE id = ${item.id}`) as [unknown[]]; + + expect(rows).toHaveLength(0); + }); + + test('Returns 404 and keeps the token when user tries to revoke token of another user', async () => { + const owner = await global.db.insertUser(); + const anotherUser = await global.db.insertUser(); + const { item } = await createToken(owner); + + const response = await global.api?.fakeRequest({ + method: 'DELETE', + headers: { + authorization: `Bearer ${global.auth(anotherUser.id)}`, + }, + url: `/user/mcp-tokens/${item.id}`, + }); + + expect(response?.statusCode).toBe(404); + + const [rows] = await global.db.query(`SELECT id FROM public.mcp_tokens WHERE id = ${item.id}`) as [unknown[]]; + + expect(rows).toHaveLength(1); + }); + + test('Returns 401 when user is not authorized', async () => { + const response = await global.api?.fakeRequest({ + method: 'DELETE', + url: '/user/mcp-tokens/1', + }); + + expect(response?.statusCode).toBe(401); + }); + }); +}); diff --git a/src/presentation/http/router/mcpTokens.ts b/src/presentation/http/router/mcpTokens.ts new file mode 100644 index 00000000..e40c6104 --- /dev/null +++ b/src/presentation/http/router/mcpTokens.ts @@ -0,0 +1,173 @@ +import type { FastifyPluginCallback } from 'fastify'; +import type McpTokenService from '@domain/service/mcpToken.js'; +import type McpToken from '@domain/entities/mcpToken.js'; +import type { McpTokenPermission, McpTokenPublic } from '@domain/entities/mcpToken.js'; +import { definePublicMcpToken } from '@domain/entities/mcpToken.js'; +import type { ErrorResponse } from '@presentation/http/types/HttpResponse.js'; + +/** + * Interface for the MCP tokens router + */ +interface McpTokensRouterOptions { + /** + * MCP token service instance + */ + mcpTokenService: McpTokenService; +} + +/** + * MCP tokens router plugin. Allows user to manage personal tokens for MCP clients + * @param fastify - fastify instance + * @param opts - router options + * @param done - callback + */ +const McpTokensRouter: FastifyPluginCallback = (fastify, opts, done) => { + const { mcpTokenService } = opts; + + /** + * Get all MCP tokens of the current user + */ + fastify.get<{ + Reply: { + items: McpTokenPublic[]; + }; + }>('/', { + config: { + policy: [ + 'authRequired', + ], + }, + schema: { + response: { + '2xx': { + type: 'object', + description: 'MCP tokens of the current user', + properties: { + items: { + type: 'array', + items: { + $ref: 'McpTokenSchema', + }, + }, + }, + }, + }, + }, + }, async (request, reply) => { + const userId = request.userId as number; + + const mcpTokens = await mcpTokenService.getTokensByUserId(userId); + + return reply.send({ + items: mcpTokens.map(definePublicMcpToken), + }); + }); + + /** + * Create new MCP token. Plain token is returned only in this response + */ + fastify.post<{ + Body: { + label: string; + permission: McpTokenPermission; + }; + Reply: { + token: string; + item: McpTokenPublic; + }; + }>('/', { + config: { + policy: [ + 'authRequired', + ], + }, + schema: { + body: { + type: 'object', + required: ['label', 'permission'], + properties: { + label: { + $ref: 'McpTokenSchema#/properties/label', + }, + permission: { + $ref: 'McpTokenSchema#/properties/permission', + }, + }, + }, + response: { + '2xx': { + type: 'object', + description: 'Created MCP token', + properties: { + token: { + type: 'string', + description: 'Plain token. It is not stored and can not be shown again', + }, + item: { + $ref: 'McpTokenSchema', + }, + }, + }, + }, + }, + }, async (request, reply) => { + const userId = request.userId as number; + const { label, permission } = request.body; + + const { token, mcpToken } = await mcpTokenService.createToken(userId, label, permission); + + return reply.send({ + token, + item: definePublicMcpToken(mcpToken), + }); + }); + + /** + * Revoke MCP token of the current user + */ + fastify.delete<{ + Params: { + tokenId: McpToken['id']; + }; + Reply: { + isDeleted: boolean; + } | ErrorResponse; + }>('/:tokenId', { + config: { + policy: [ + 'authRequired', + ], + }, + schema: { + params: { + tokenId: { + $ref: 'McpTokenSchema#/properties/id', + }, + }, + response: { + '2xx': { + type: 'object', + properties: { + isDeleted: { + type: 'boolean', + }, + }, + }, + }, + }, + }, async (request, reply) => { + const userId = request.userId as number; + + const isDeleted = await mcpTokenService.revokeToken(userId, request.params.tokenId); + + if (!isDeleted) { + return reply.notFound('Token not found'); + } + + return reply.send({ isDeleted }); + }); + + done(); +}; + +export default McpTokensRouter; diff --git a/src/presentation/http/router/note.test.ts b/src/presentation/http/router/note.test.ts index 575932ac..02d3f3ca 100644 --- a/src/presentation/http/router/note.test.ts +++ b/src/presentation/http/router/note.test.ts @@ -2034,6 +2034,81 @@ describe('Note API', () => { expect(response?.json().message).toBe(expectedMessage); } }); + + test('Updates note only while expectedUpdatedAt matches the current version', async () => { + const user = await global.db.insertUser(); + const accessToken = global.auth(user.id); + const note = await global.db.insertNote({ creatorId: user.id }); + + await global.db.insertNoteSetting({ + noteId: note.id, + isPublic: true, + }); + + const currentNote = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/${note.publicId}`, + }); + + const expectedUpdatedAt = currentNote?.json().note.updatedAt; + + /** + * Updates the note with the expected version + * @param text - header text of the new content + */ + const updateNote = async (text: string): ReturnType['fakeRequest']> => { + return await global.api?.fakeRequest({ + method: 'PATCH', + headers: { + authorization: `Bearer ${accessToken}`, + }, + body: { + content: { + blocks: [ + { + id: 'mJDq8YbvqO', + type: headerTool.name, + data: { text }, + }, + ], + }, + tools: [ + { + name: headerTool.name, + id: headerTool.id, + }, + ], + expectedUpdatedAt, + }, + url: `/note/${note.publicId}`, + }); + }; + + const firstUpdate = await updateNote('First update'); + + expect(firstUpdate?.statusCode).toBe(200); + + /** + * The same version is stale after the first update + */ + const secondUpdate = await updateNote('Second update'); + + expect(secondUpdate?.statusCode).toBe(409); + expect(secondUpdate?.json().message).toBe('Note was changed after the expected version'); + + const updatedNote = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${accessToken}`, + }, + url: `/note/${note.publicId}`, + }); + + expect(updatedNote?.json().note.content.blocks[0].data.text).toBe('First update'); + }); }); describe('GET /note/:notePublicId/history', () => { diff --git a/src/presentation/http/router/note.ts b/src/presentation/http/router/note.ts index f6cb0492..0db8617d 100644 --- a/src/presentation/http/router/note.ts +++ b/src/presentation/http/router/note.ts @@ -13,6 +13,8 @@ import type EditorToolsService from '@domain/service/editorTools.js'; import type EditorTool from '@domain/entities/editorTools.js'; import type { NoteHistoryMeta, NoteHistoryPublic, NoteHistoryRecord } from '@domain/entities/noteHistory.js'; import type { NoteHierarchy } from '@domain/entities/NoteHierarchy.js'; +import { NoteUpdateConflictError } from '@domain/entities/NoteUpdateConflictError.js'; +import { StatusCodes } from 'http-status-codes'; /** * Interface for the note router. @@ -337,10 +339,15 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don Body: { content: Note['content']; tools: Note['tools']; + + /** + * If passed, the note is updated only if it was last updated at this time + */ + expectedUpdatedAt?: string; }; Reply: { updatedAt: Note['updatedAt']; - }; + } | ErrorResponse; }>('/:notePublicId', { config: { policy: [ @@ -359,6 +366,10 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don content: { type: 'object', }, + expectedUpdatedAt: { + type: 'string', + format: 'date-time', + }, }, response: { @@ -382,13 +393,25 @@ const NoteRouter: FastifyPluginCallback = (fastify, opts, don const noteTools = request.body.tools; const { userId } = request; + const expectedUpdatedAt = request.body.expectedUpdatedAt !== undefined ? new Date(request.body.expectedUpdatedAt) : undefined; + await noteService.validateNoteTools(noteTools, content); - const note = await noteService.updateNoteContentAndToolsById(noteId, content, noteTools, userId!); + try { + const note = await noteService.updateNoteContentAndToolsById(noteId, content, noteTools, userId!, expectedUpdatedAt); - return reply.send({ - updatedAt: note.updatedAt, - }); + return await reply.send({ + updatedAt: note.updatedAt, + }); + } catch (error) { + if (error instanceof NoteUpdateConflictError) { + return await reply + .code(StatusCodes.CONFLICT) + .send({ message: error.message }); + } + + throw error; + } }); /** diff --git a/src/presentation/http/schema/McpToken.ts b/src/presentation/http/schema/McpToken.ts new file mode 100644 index 00000000..39b1fca6 --- /dev/null +++ b/src/presentation/http/schema/McpToken.ts @@ -0,0 +1,41 @@ +/** + * MCP token entity used for validation and serialization + */ +export const McpTokenSchema = { + $id: 'McpTokenSchema', + type: 'object', + properties: { + id: { + type: 'integer', + description: 'Token id', + }, + preview: { + type: 'string', + description: 'Last characters of the token', + }, + permission: { + type: 'string', + enum: ['read', 'write'], + description: 'What MCP client can do with notes of the token owner', + }, + label: { + type: 'string', + minLength: 1, + maxLength: 255, + description: 'Label set by user, e.g. "Claude Code on laptop"', + }, + lastUsedAt: { + type: 'string', + nullable: true, + description: 'Last time token was used to call MCP tool', + }, + requestCount: { + type: 'integer', + description: 'How many times token was used to call MCP tools', + }, + createdAt: { + type: 'string', + description: 'When token was created', + }, + }, +}; diff --git a/src/presentation/mcp/content.ts b/src/presentation/mcp/content.ts new file mode 100644 index 00000000..2a5ac3c4 --- /dev/null +++ b/src/presentation/mcp/content.ts @@ -0,0 +1,108 @@ +import { nanoid } from 'nanoid'; +import { z } from 'zod'; +import type { NoteContent, ToolUsedInNoteContent } from '@domain/entities/note.js'; +import type EditorTool from '@domain/entities/editorTools.js'; + +/** + * Editor.js block of the note content + */ +export type NoteBlock = NoteContent['blocks'][number]; + +/** + * Length of the Editor.js block id + */ +const BLOCK_ID_LENGTH = 10; + +/** + * Block passed by MCP client. Id is optional for new blocks + */ +export const blockInputSchema = z.object({ + id: z.string().min(1) + .optional() + .describe('Block id. Keep it for existing blocks, omit for new ones'), + type: z.string().min(1) + .describe('Editor tool name, e.g. paragraph, header, list'), + data: z.record(z.string(), z.unknown()).describe('Block data, format depends on the block type'), + tunes: z.record(z.string(), z.unknown()).optional() + .describe('Block tunes, keep them as is for existing blocks'), +}); + +export type NoteBlockInput = z.infer; + +/** + * Description of the content format for MCP tools that write notes + */ +export const BLOCKS_FORMAT_DESCRIPTION = `Note content is an array of Editor.js blocks { id?, type, data }. +Keep "id" of existing blocks, omit it for new ones. +Block "type" must be an editor tool available to the user. Default tools: +- paragraph: { "text": "Text with inline HTML: , , , , " } +- header: { "text": "Heading", "level": 2 } (level from 1 to 6) +- list: { "style": "unordered" | "ordered", "items": ["First item", "Second item"] } +Keep blocks of other types unchanged, as returned by get_note. +The first block is used as the note title.`; + +/** + * Generates ids for blocks passed without them + * @param blocks - blocks passed by MCP client + */ +export function fillBlockIds(blocks: NoteBlockInput[]): NoteBlock[] { + return blocks.map(block => ({ + ...block, + id: block.id ?? nanoid(BLOCK_ID_LENGTH), + })); +} + +/** + * Lists tools used in blocks in the format required by notes API + * @param blocks - note content blocks + * @param availableTools - tools that can be used, first match by name wins + * @throws Error if some block type is not an available tool + */ +export function getToolsForBlocks(blocks: NoteBlock[], availableTools: Pick[]): ToolUsedInNoteContent[] { + const toolIdsByName = new Map(); + + for (const tool of availableTools) { + if (!toolIdsByName.has(tool.name)) { + toolIdsByName.set(tool.name, tool.id); + } + } + + const usedToolNames = Array.from(new Set(blocks.map(block => block.type))); + const unknownToolNames = usedToolNames.filter(name => !toolIdsByName.has(name)); + + if (unknownToolNames.length > 0) { + throw new Error(`Unknown block types: ${unknownToolNames.join(', ')}. Available block types: ${Array.from(toolIdsByName.keys()).join(', ')}`); + } + + return usedToolNames.map(name => ({ + name, + id: toolIdsByName.get(name)!, + })); +} + +/** + * Removes HTML tags and decodes basic entities + * @param html - string with inline HTML + */ +export function stripHtml(html: string): string { + return html + .replace(/<[^>]*>/g, '') + .replace(/ /g, ' ') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '\'') + .replace(/&/g, '&') + .trim(); +} + +/** + * Gets note title from the text of the first block + * @param content - note content + */ +export function getTitle(content: NoteContent): string { + const text = (content.blocks[0]?.data as { text?: unknown } | undefined)?.text; + const title = typeof text === 'string' ? stripHtml(text) : ''; + + return title === '' ? 'Untitled' : title; +} diff --git a/src/presentation/mcp/index.ts b/src/presentation/mcp/index.ts new file mode 100644 index 00000000..6244db45 --- /dev/null +++ b/src/presentation/mcp/index.ts @@ -0,0 +1,142 @@ +import type { FastifyPluginCallback, FastifyReply } from 'fastify'; +import type { AuthInfo } from '@modelcontextprotocol/server'; +import { createMcpHandler } from '@modelcontextprotocol/server'; +import { toNodeHandler } from '@modelcontextprotocol/node'; +import { StatusCodes } from 'http-status-codes'; +import type McpToken from '@domain/entities/mcpToken.js'; +import type McpTokenService from '@domain/service/mcpToken.js'; +import type AuthService from '@domain/service/auth.js'; +import { getRequestLogger } from '@infrastructure/logging/index.js'; +import NotexApi from './notexApi.js'; +import { createMcpServer } from './server.js'; + +/** + * Interface for the MCP router + */ +interface McpRouterOptions { + /** + * MCP token service instance + */ + mcpTokenService: McpTokenService; + + /** + * Auth service instance + */ + authService: AuthService; + + /** + * Web app url, used to build links to notes + */ + webUrl: string; +} + +/** + * Passes the resolved MCP token to the MCP server factory + * @param token - plain MCP token from the request + * @param mcpToken - resolved MCP token + */ +function toAuthInfo(token: string, mcpToken: McpToken): AuthInfo { + return { + token, + clientId: String(mcpToken.id), + scopes: [mcpToken.permission], + extra: { + mcpToken, + }, + }; +} + +/** + * Logs MCP errors that do not reach the client + * @param error - error thrown while serving MCP request + */ +function logMcpError(error: Error): void { + getRequestLogger('appServer').child({ router: 'mcp' }) + .error(error); +} + +/** + * MCP router plugin. Serves MCP over Streamable HTTP without sessions: + * protocol revision 2026-07-28 and 2025-era clients on the same endpoint. + * Every request creates its own MCP server for the owner of the passed MCP token + * @param fastify - fastify instance + * @param opts - router options + * @param done - callback + */ +const McpRouter: FastifyPluginCallback = (fastify, opts, done) => { + const { mcpTokenService, authService, webUrl } = opts; + + const handler = createMcpHandler(({ authInfo }) => { + const mcpToken = authInfo?.extra?.mcpToken as McpToken | undefined; + + if (mcpToken === undefined) { + throw new Error('MCP token is not resolved'); + } + + /** + * Tools call API routes on behalf of the token owner + */ + const api = new NotexApi(fastify, authService.signAccessToken({ id: mcpToken.userId })); + + return createMcpServer({ + api, + webUrl, + onToolCall: async () => await mcpTokenService.trackUsage(mcpToken.id), + }, mcpToken.permission); + }, { + onerror: logMcpError, + }); + + const handleMcpRequest = toNodeHandler(handler, { onerror: logMcpError }); + + fastify.addHook('onClose', async () => { + await handler.close(); + }); + + /** + * Handle MCP JSON-RPC messages + */ + fastify.post('/', async (request, reply) => { + const token = request.headers.authorization?.replace('Bearer ', '') ?? ''; + const mcpToken = await mcpTokenService.resolveToken(token); + + if (mcpToken === null) { + return await reply.unauthorized('Valid MCP token is required'); + } + + /** + * MCP handler writes the response itself + */ + void reply.hijack(); + + /** + * Hijacked reply does not flush headers set by hooks (e.g. CORS), so copy them to the raw response + */ + for (const [name, value] of Object.entries(reply.getHeaders())) { + if (value !== undefined) { + reply.raw.setHeader(name, value); + } + } + + await handleMcpRequest(Object.assign(request.raw, { auth: toAuthInfo(token, mcpToken) }), reply.raw, request.body); + }); + + /** + * Server does not open SSE streams and does not keep sessions + * @param _request - fastify request + * @param reply - fastify reply + */ + const methodNotAllowed = async (_request: unknown, reply: FastifyReply): Promise => { + await reply + .code(StatusCodes.METHOD_NOT_ALLOWED) + .header('allow', 'POST') + .send({ message: 'Method not allowed' }); + }; + + fastify.get('/', methodNotAllowed); + fastify.delete('/', methodNotAllowed); + + done(); +}; + +export default McpRouter; diff --git a/src/presentation/mcp/mcp.test.ts b/src/presentation/mcp/mcp.test.ts new file mode 100644 index 00000000..c31c89a0 --- /dev/null +++ b/src/presentation/mcp/mcp.test.ts @@ -0,0 +1,615 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client'; +import type User from '@domain/entities/user.js'; +import type { RequestParams } from '@presentation/api.interface.js'; +import config from '@infrastructure/config/index.js'; + +/** + * Web app url used in links to notes + */ +const webUrl = config.httpApi.mcp.webUrl; + +/** + * Creates MCP token for the user via API + * @param user - user who will own the token + * @param permission - what MCP client can do with notes of the user + * @returns plain token + */ +async function createMcpToken(user: User, permission: 'read' | 'write'): Promise { + const response = await global.api?.fakeRequest({ + method: 'POST', + headers: { + authorization: `Bearer ${global.auth(user.id)}`, + }, + url: '/user/mcp-tokens', + body: { + label: 'Test client', + permission, + }, + }); + + return response!.json<{ token: string }>().token; +} + +/** + * Sends JSON-RPC request to the MCP endpoint + * @param token - authorization token + * @param method - JSON-RPC method + * @param params - JSON-RPC params + */ +async function sendMcpRequest(token: string | undefined, method: string, params: object = {}): ReturnType['fakeRequest']> { + return await global.api?.fakeRequest({ + method: 'POST', + url: '/mcp', + headers: { + ...(token !== undefined ? { authorization: `Bearer ${token}` } : {}), + accept: 'application/json, text/event-stream', + }, + body: { + jsonrpc: '2.0', + id: 1, + method, + params, + }, + }); +} + +/** + * Parsed JSON of MCP messages and tool results. Its shape depends on the method and the tool, so tests access it freely + */ +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +type ParsedJson = any; + +/** + * Reads JSON-RPC message from the MCP endpoint response. + * 2025-era requests are answered with an SSE stream carrying the message + * @param response - MCP endpoint response + */ +function readMcpMessage(response: Awaited>): ParsedJson { + if (!String(response?.headers['content-type']).startsWith('text/event-stream')) { + return response?.json(); + } + + const dataLine = response?.body.split('\n').find(line => line.startsWith('data:')); + + return JSON.parse(dataLine?.slice('data:'.length) ?? 'null'); +} + +/** + * Fetch for MCP SDK client that sends requests to the API without network + * @param url - request url + * @param init - request options + */ +async function fakeFetch(url: string | URL, init?: RequestInit): Promise { + const response = await global.api?.fakeRequest({ + method: (init?.method ?? 'GET') as RequestParams['method'], + url: new URL(url).pathname, + headers: Object.fromEntries(new Headers(init?.headers)), + body: typeof init?.body === 'string' ? init.body : undefined, + }); + + const headers = new Headers(); + + for (const [name, value] of Object.entries(response?.headers ?? {})) { + if (value !== undefined) { + headers.set(name, String(value)); + } + } + + return new Response(response?.body === '' ? null : response?.body, { + status: response?.statusCode, + headers, + }); +} + +/** + * Calls MCP tool and returns its result + * @param token - MCP token + * @param name - name of the MCP tool to call + * @param args - tool arguments + * @returns error flag and text of the tool result, parsed as JSON when possible + */ +async function callTool(token: string, name: string, args: object): Promise<{ isError: boolean; text: string; data: ParsedJson }> { + const response = await sendMcpRequest(token, 'tools/call', { + name, + arguments: args, + }); + + const { result, error } = readMcpMessage(response) as { + result?: { isError?: boolean; content: { text: string }[] }; + error?: { message: string }; + }; + + /** + * Unknown tools are rejected with JSON-RPC error instead of a tool result + */ + if (result === undefined) { + return { + isError: true, + text: error?.message ?? '', + data: null, + }; + } + + const text = result.content[0].text; + let data: ParsedJson = null; + + try { + data = JSON.parse(text); + } catch { + data = null; + } + + return { + isError: result.isError === true, + text, + data, + }; +} + +/** + * Builds paragraph block + * @param text - paragraph content with inline HTML + */ +function paragraph(text: string): object { + return { + type: 'paragraph', + data: { text }, + }; +} + +describe('MCP API', () => { + beforeEach(async () => { + await global.db.truncateTables(); + }); + + describe('Authorization', () => { + test('Returns 401 without token', async () => { + const response = await sendMcpRequest(undefined, 'tools/list'); + + expect(response?.statusCode).toBe(401); + }); + + test('Returns 401 for unknown MCP token', async () => { + const response = await sendMcpRequest('ntx_unknownunknownunknownunknownun', 'tools/list'); + + expect(response?.statusCode).toBe(401); + }); + + test('Returns 401 for access token', async () => { + const user = await global.db.insertUser(); + + const response = await sendMcpRequest(global.auth(user.id), 'tools/list'); + + expect(response?.statusCode).toBe(401); + }); + + test('Returns 401 for revoked token', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'read'); + + await global.db.query('DELETE FROM public.mcp_tokens'); + + const response = await sendMcpRequest(token, 'tools/list'); + + expect(response?.statusCode).toBe(401); + }); + + test('MCP token is not accepted by other API routes', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'write'); + + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${token}`, + }, + url: '/user/myself', + }); + + expect(response?.statusCode).toBe(401); + }); + + test('Keeps CORS headers on MCP responses', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'read'); + + const response = await global.api?.fakeRequest({ + method: 'POST', + url: '/mcp', + headers: { + authorization: `Bearer ${token}`, + accept: 'application/json, text/event-stream', + origin: 'https://client.example', + }, + body: { + jsonrpc: '2.0', + id: 1, + method: 'tools/list', + }, + }); + + expect(response?.statusCode).toBe(200); + expect(response?.headers['access-control-allow-origin']).toBeDefined(); + }); + + test('Returns 405 for GET', async () => { + const response = await global.api?.fakeRequest({ + method: 'GET', + url: '/mcp', + }); + + expect(response?.statusCode).toBe(405); + }); + }); + + describe('Server', () => { + test('Responds to initialize', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'read'); + + const response = await sendMcpRequest(token, 'initialize', { + protocolVersion: '2025-06-18', + capabilities: {}, + clientInfo: { + name: 'test', + version: '1.0.0', + }, + }); + + expect(response?.statusCode).toBe(200); + expect(readMcpMessage(response).result.serverInfo.name).toBe('notex'); + }); + + test.each([ + { + permission: 'read' as const, + expectedTools: ['get_note', 'list_notes'], + }, + { + permission: 'write' as const, + expectedTools: ['create_note', 'get_note', 'list_notes', 'update_note'], + }, + ])('Lists tools available for $permission token', async ({ permission, expectedTools }) => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, permission); + + const response = await sendMcpRequest(token, 'tools/list'); + + const toolNames = readMcpMessage(response).result.tools.map((tool: { name: string }) => tool.name).sort(); + + expect(toolNames).toStrictEqual(expectedTools); + }); + + test('Read token can not call write tools', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'read'); + + const result = await callTool(token, 'create_note', { + blocks: [paragraph('Hello')], + }); + + expect(result.isError).toBe(true); + + const [rows] = await global.db.query('SELECT id FROM public.notes') as [unknown[]]; + + expect(rows).toHaveLength(0); + }); + + test('Counts tool calls of the token', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'read'); + + await sendMcpRequest(token, 'tools/list'); + await callTool(token, 'list_notes', {}); + await callTool(token, 'list_notes', { source: 'created' }); + + const response = await global.api?.fakeRequest({ + method: 'GET', + headers: { + authorization: `Bearer ${global.auth(user.id)}`, + }, + url: '/user/mcp-tokens', + }); + + const [item] = response?.json().items; + + expect(item.requestCount).toBe(2); + expect(item.lastUsedAt).not.toBeNull(); + }); + }); + + describe('Protocol revisions', () => { + test.each([ + { + era: 'legacy', + mode: 'legacy' as const, + }, + { + era: 'modern', + mode: { pin: '2026-07-28' }, + }, + ])('Serves $era clients of MCP SDK', async ({ era, mode }) => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'write'); + const client = new Client({ + name: 'test', + version: '1.0.0', + }, { + versionNegotiation: { mode }, + }); + + await client.connect(new StreamableHTTPClientTransport(new URL('http://localhost/mcp'), { + fetch: fakeFetch, + requestInit: { + headers: { + authorization: `Bearer ${token}`, + }, + }, + })); + + expect(client.getProtocolEra()).toBe(era); + + const { tools } = await client.listTools(); + + expect(tools.map(tool => tool.name).sort()).toStrictEqual(['create_note', 'get_note', 'list_notes', 'update_note']); + + const created = await client.callTool({ + name: 'create_note', + arguments: { + blocks: [paragraph('Created over MCP')], + }, + }); + const { id } = JSON.parse((created.content as { text: string }[])[0].text); + + const received = await client.callTool({ + name: 'get_note', + arguments: { + note: id, + }, + }); + + expect(JSON.parse((received.content as { text: string }[])[0].text).title).toBe('Created over MCP'); + + await client.close(); + }); + }); + + describe('Tools', () => { + test('Creates, reads, lists and updates notes', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'write'); + + /** + * Create root note and a note nested in it + */ + const parent = await callTool(token, 'create_note', { + blocks: [ + { + type: 'header', + data: { + text: 'Project X', + level: 1, + }, + }, + ], + }); + + expect(parent.isError).toBe(false); + expect(parent.data.url).toBe(`${webUrl}/note/${parent.data.id}`); + + const child = await callTool(token, 'create_note', { + blocks: [paragraph('Meeting notes'), paragraph('First item')], + parent: parent.data.url, + }); + + expect(child.isError).toBe(false); + + /** + * Get note by URL + */ + const note = await callTool(token, 'get_note', { + note: `${child.data.url}/settings`, + }); + + expect(note.data).toMatchObject({ + id: child.data.id, + title: 'Meeting notes', + canEdit: true, + parents: [ + { + id: parent.data.id, + title: 'Project X', + }, + ], + }); + expect(note.data.content.blocks).toHaveLength(2); + expect(note.data.content.blocks[0].id).toHaveLength(10); + + /** + * List notes + */ + const recent = await callTool(token, 'list_notes', {}); + + expect(recent.data.items.map((item: { id: string }) => item.id).sort()).toStrictEqual([parent.data.id, child.data.id].sort()); + + const tree = await callTool(token, 'list_notes', { + parent: parent.data.id, + }); + + expect(tree.data).toMatchObject({ + id: parent.data.id, + title: 'Project X', + children: [ + { + id: child.data.id, + title: 'Meeting notes', + children: [], + }, + ], + }); + + /** + * Append a list block, then replace the content keeping the first block + */ + const appended = await callTool(token, 'update_note', { + note: child.data.id, + mode: 'append', + blocks: [ + { + type: 'list', + data: { + style: 'unordered', + items: ['One', 'Two'], + }, + }, + ], + expectedUpdatedAt: note.data.updatedAt, + }); + + expect(appended.isError).toBe(false); + + let updated = await callTool(token, 'get_note', { note: child.data.id }); + + expect(updated.data.content.blocks.map((block: { type: string }) => block.type)).toStrictEqual(['paragraph', 'paragraph', 'list']); + + const replaced = await callTool(token, 'update_note', { + note: child.data.id, + blocks: [updated.data.content.blocks[0], paragraph('Only item')], + }); + + expect(replaced.isError).toBe(false); + + updated = await callTool(token, 'get_note', { note: child.data.id }); + + expect(updated.data.content.blocks).toMatchObject([ + { id: note.data.content.blocks[0].id }, + { + type: 'paragraph', + data: { text: 'Only item' }, + }, + ]); + }); + + test('Rejects update of the note changed after expectedUpdatedAt', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'write'); + + const created = await callTool(token, 'create_note', { + blocks: [paragraph('Draft')], + }); + + const result = await callTool(token, 'update_note', { + note: created.data.id, + blocks: [paragraph('Changed')], + expectedUpdatedAt: '2020-01-01T00:00:00.000Z', + }); + + expect(result.isError).toBe(true); + expect(result.text).toContain('Note was changed'); + + const note = await callTool(token, 'get_note', { note: created.data.id }); + + expect(note.data.title).toBe('Draft'); + }); + + test('Does not lose blocks appended concurrently', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'write'); + + const created = await callTool(token, 'create_note', { + blocks: [paragraph('Shopping list')], + }); + + const results = await Promise.all(['Milk', 'Bread', 'Eggs'].map(async item => await callTool(token, 'update_note', { + note: created.data.id, + mode: 'append', + blocks: [paragraph(item)], + }))); + + const succeeded = results.filter(result => !result.isError); + const note = await callTool(token, 'get_note', { note: created.data.id }); + + /** + * Every successful append is in the note, failed ones report the conflict + */ + expect(succeeded.length).toBeGreaterThanOrEqual(1); + expect(note.data.content.blocks).toHaveLength(1 + succeeded.length); + + for (const result of results.filter(r => r.isError)) { + expect(result.text).toContain('Note was changed'); + } + }); + + test('Rejects unknown block types and lists available ones', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'write'); + + const result = await callTool(token, 'create_note', { + blocks: [ + { + type: 'checklist', + data: { items: [] }, + }, + ], + }); + + expect(result.isError).toBe(true); + expect(result.text).toContain('Unknown block types: checklist'); + expect(result.text).toContain('paragraph'); + }); + + test('Does not update note the user can not edit', async () => { + const owner = await global.db.insertUser(); + const user = await global.db.insertUser(); + const note = await global.db.insertNote({ creatorId: owner.id }); + + await global.db.insertNoteSetting({ + noteId: note.id, + isPublic: true, + }); + + const token = await createMcpToken(user, 'write'); + + const read = await callTool(token, 'get_note', { note: note.publicId }); + + expect(read.isError).toBe(false); + expect(read.data.canEdit).toBe(false); + + const result = await callTool(token, 'update_note', { + note: note.publicId, + blocks: [paragraph('Hacked')], + }); + + expect(result.isError).toBe(true); + expect(result.text).toContain('You can not edit this note'); + }); + + test('Returns API error for private note of another user', async () => { + const owner = await global.db.insertUser(); + const user = await global.db.insertUser(); + const note = await global.db.insertNote({ creatorId: owner.id }); + + await global.db.insertNoteSetting({ + noteId: note.id, + isPublic: false, + }); + + const token = await createMcpToken(user, 'read'); + + const result = await callTool(token, 'get_note', { note: note.publicId }); + + expect(result.isError).toBe(true); + }); + + test('Returns error for a reference that is not a note', async () => { + const user = await global.db.insertUser(); + const token = await createMcpToken(user, 'read'); + + const result = await callTool(token, 'get_note', { note: 'not a note' }); + + expect(result.isError).toBe(true); + expect(result.text).toContain('is neither a note URL nor a note id'); + }); + }); +}); diff --git a/src/presentation/mcp/noteRef.ts b/src/presentation/mcp/noteRef.ts new file mode 100644 index 00000000..db3d4c8f --- /dev/null +++ b/src/presentation/mcp/noteRef.ts @@ -0,0 +1,73 @@ +import type { NotePublicId } from '@domain/entities/note.js'; +import type { NotePublic } from '@domain/entities/notePublic.js'; +import type NotexApi from './notexApi.js'; + +/** + * Note public id is a 10 characters nanoid + */ +const NOTE_PUBLIC_ID_PATTERN = /^[\w-]{10}$/; + +/** + * Note page path in the web app, e.g. /note/, /note//settings, /note//history + */ +const NOTE_PATH_PATTERN = /^\/note\/([\w-]{10})(?:\/|$)/; + +/** + * Parses string as URL, scheme is optional + * @param value - string to parse + * @returns parsed URL or null if string is not a URL + */ +function parseUrl(value: string): URL | null { + try { + return new URL(/^https?:\/\//.test(value) ? value : `https://${value}`); + } catch { + return null; + } +} + +/** + * Builds link to the note page in the web app + * @param webUrl - web app url + * @param publicId - note public id + */ +export function getNoteUrl(webUrl: string, publicId: NotePublicId): string { + return new URL(`/note/${publicId}`, webUrl).toString(); +} + +/** + * Gets note public id from the note reference passed by MCP client. + * Reference can be a note id, a link to the note in the web app or a link to the note published on a custom hostname + * @param ref - note id or URL + * @param api - NoteX API client + * @param webUrl - web app url + */ +export async function resolveNotePublicId(ref: string, api: NotexApi, webUrl: string): Promise { + const value = ref.trim(); + + if (NOTE_PUBLIC_ID_PATTERN.test(value)) { + return value; + } + + const url = parseUrl(value); + + if (url === null) { + throw new Error(`"${ref}" is neither a note URL nor a note id`); + } + + const match = NOTE_PATH_PATTERN.exec(url.pathname); + + if (match !== null) { + return match[1]; + } + + /** + * Note can be published on a custom hostname + */ + if (url.hostname !== new URL(webUrl).hostname) { + const { note } = await api.get<{ note: NotePublic }>(`/note/resolve-hostname/${encodeURIComponent(url.hostname)}`); + + return note.id; + } + + throw new Error(`Can not find note id in "${ref}"`); +} diff --git a/src/presentation/mcp/notexApi.ts b/src/presentation/mcp/notexApi.ts new file mode 100644 index 00000000..cc5d87a0 --- /dev/null +++ b/src/presentation/mcp/notexApi.ts @@ -0,0 +1,95 @@ +import type { FastifyInstance } from 'fastify'; +import { StatusCodes } from 'http-status-codes'; + +/** + * Error response of NoteX API passed to MCP client + */ +export class NotexApiError extends Error { + /** + * HTTP status code of the API response + */ + public readonly statusCode: number; + + /** + * Creates API error + * @param statusCode - HTTP status code of the API response + * @param message - error message from the API response + */ + constructor(statusCode: number, message: string) { + super(message); + this.statusCode = statusCode; + } +} + +/** + * Calls NoteX HTTP API on behalf of the MCP token owner. + * Requests go through the same routes, policies and validation as requests from the web app + */ +export default class NotexApi { + /** + * Creates API client + * @param server - fastify instance to inject requests into + * @param accessToken - access token of the MCP token owner + */ + constructor(private readonly server: FastifyInstance, private readonly accessToken: string) {} + + /** + * Sends GET request + * @param url - API route with query string + */ + public async get(url: string): Promise { + return await this.request('GET', url); + } + + /** + * Sends POST request + * @param url - API route + * @param body - request body + */ + public async post(url: string, body: object): Promise { + return await this.request('POST', url, body); + } + + /** + * Sends PATCH request + * @param url - API route + * @param body - request body + */ + public async patch(url: string, body: object): Promise { + return await this.request('PATCH', url, body); + } + + /** + * Injects request into the API and returns parsed response body + * @param method - HTTP method + * @param url - API route + * @param body - request body + * @throws NotexApiError if API responded with an error + */ + private async request(method: 'GET' | 'POST' | 'PATCH', url: string, body?: object): Promise { + const response = await this.server.inject({ + method, + url, + headers: { + authorization: `Bearer ${this.accessToken}`, + }, + payload: body, + }); + + if (response.statusCode >= Number(StatusCodes.BAD_REQUEST)) { + let message = `NoteX API responded with status ${response.statusCode}`; + + try { + message = response.json<{ message?: string }>().message ?? message; + } catch { + /** + * Response body is not JSON, keep the default message + */ + } + + throw new NotexApiError(response.statusCode, message); + } + + return response.json(); + } +} diff --git a/src/presentation/mcp/server.ts b/src/presentation/mcp/server.ts new file mode 100644 index 00000000..ba2e7c2b --- /dev/null +++ b/src/presentation/mcp/server.ts @@ -0,0 +1,39 @@ +import { McpServer } from '@modelcontextprotocol/server'; +import type { McpTokenPermission } from '@domain/entities/mcpToken.js'; +import type { McpToolContext } from './types.js'; +import { registerListNotesTool } from './tools/listNotes.js'; +import { registerGetNoteTool } from './tools/getNote.js'; +import { registerCreateNoteTool } from './tools/createNote.js'; +import { registerUpdateNoteTool } from './tools/updateNote.js'; + +/** + * Instructions for MCP clients describing the server + */ +const INSTRUCTIONS = 'NoteX is a note-taking app built on Editor.js. ' + + 'Notes are referenced by URL (https://notex.so/note/) or by 10-character id, and can be nested in other notes. ' + + 'Note content is stored as Editor.js blocks, the first block is the note title.'; + +/** + * Creates MCP server with tools available for the token permission. + * Tokens with "read" permission see only tools that do not change notes + * @param context - tool context + * @param permission - MCP token permission + */ +export function createMcpServer(context: McpToolContext, permission: McpTokenPermission): McpServer { + const server = new McpServer({ + name: 'notex', + version: '1.0.0', + }, { + instructions: INSTRUCTIONS, + }); + + registerListNotesTool(server, context); + registerGetNoteTool(server, context); + + if (permission === 'write') { + registerCreateNoteTool(server, context); + registerUpdateNoteTool(server, context); + } + + return server; +} diff --git a/src/presentation/mcp/tools/createNote.ts b/src/presentation/mcp/tools/createNote.ts new file mode 100644 index 00000000..40c270c4 --- /dev/null +++ b/src/presentation/mcp/tools/createNote.ts @@ -0,0 +1,48 @@ +import type { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod'; +import type { NotePublicId } from '@domain/entities/note.js'; +import type EditorTool from '@domain/entities/editorTools.js'; +import type { McpToolContext } from '../types.js'; +import { jsonResult } from '../types.js'; +import { getNoteUrl, resolveNotePublicId } from '../noteRef.js'; +import { BLOCKS_FORMAT_DESCRIPTION, blockInputSchema, fillBlockIds, getToolsForBlocks } from '../content.js'; + +/** + * Registers "create_note" tool + * @param server - MCP server + * @param context - tool context + */ +export function registerCreateNoteTool(server: McpServer, context: McpToolContext): void { + server.registerTool('create_note', { + title: 'Create note', + description: 'Creates a new note at the root or inside the parent note.\n\n' + BLOCKS_FORMAT_DESCRIPTION, + inputSchema: z.object({ + blocks: z.array(blockInputSchema).describe('Note content blocks'), + parent: z.string().optional() + .describe('URL or id of the parent note. Omit to create the note at the root'), + }), + annotations: { + readOnlyHint: false, + destructiveHint: false, + }, + }, async ({ blocks, parent }) => { + await context.onToolCall(); + + const parentId = parent === undefined ? undefined : await resolveNotePublicId(parent, context.api, context.webUrl); + const contentBlocks = fillBlockIds(blocks); + const { data: userTools } = await context.api.get<{ data: EditorTool[] }>('/user/editor-tools'); + + const { id } = await context.api.post<{ id: NotePublicId }>('/note', { + content: { + blocks: contentBlocks, + }, + parentId, + tools: getToolsForBlocks(contentBlocks, userTools), + }); + + return jsonResult({ + id, + url: getNoteUrl(context.webUrl, id), + }); + }); +} diff --git a/src/presentation/mcp/tools/getNote.ts b/src/presentation/mcp/tools/getNote.ts new file mode 100644 index 00000000..564c4de4 --- /dev/null +++ b/src/presentation/mcp/tools/getNote.ts @@ -0,0 +1,59 @@ +import type { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod'; +import type { NotePublic } from '@domain/entities/notePublic.js'; +import type EditorTool from '@domain/entities/editorTools.js'; +import type { McpToolContext } from '../types.js'; +import { jsonResult } from '../types.js'; +import { getNoteUrl, resolveNotePublicId } from '../noteRef.js'; +import { getTitle } from '../content.js'; + +/** + * Response of the GET /note/:notePublicId route + */ +export interface GetNoteResponse { + note: NotePublic; + accessRights: { + canEdit: boolean; + }; + tools: EditorTool[]; + parents: NotePublic[]; +} + +/** + * Registers "get_note" tool + * @param server - MCP server + * @param context - tool context + */ +export function registerGetNoteTool(server: McpServer, context: McpToolContext): void { + server.registerTool('get_note', { + title: 'Get note', + description: 'Returns the note with its content in Editor.js format, parent notes and edit rights. ' + + 'Pass "updatedAt" from the result to update_note as "expectedUpdatedAt" to avoid overwriting newer changes.', + inputSchema: z.object({ + note: z.string().describe('Note URL (e.g. https://notex.so/note/) or note id'), + }), + annotations: { + readOnlyHint: true, + }, + }, async ({ note: noteRef }) => { + await context.onToolCall(); + + const publicId = await resolveNotePublicId(noteRef, context.api, context.webUrl); + const { note, accessRights, parents } = await context.api.get(`/note/${publicId}`); + + return jsonResult({ + id: note.id, + url: getNoteUrl(context.webUrl, note.id), + title: getTitle(note.content), + createdAt: note.createdAt, + updatedAt: note.updatedAt, + canEdit: accessRights.canEdit, + parents: parents.map(parent => ({ + id: parent.id, + title: getTitle(parent.content), + url: getNoteUrl(context.webUrl, parent.id), + })), + content: note.content, + }); + }); +} diff --git a/src/presentation/mcp/tools/listNotes.ts b/src/presentation/mcp/tools/listNotes.ts new file mode 100644 index 00000000..a0817283 --- /dev/null +++ b/src/presentation/mcp/tools/listNotes.ts @@ -0,0 +1,117 @@ +import type { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod'; +import type { NoteListPublic } from '@domain/entities/noteList.js'; +import type { NoteHierarchy } from '@domain/entities/NoteHierarchy.js'; +import type { NotePublicId } from '@domain/entities/note.js'; +import type { McpToolContext } from '../types.js'; +import { jsonResult } from '../types.js'; +import { getNoteUrl, resolveNotePublicId } from '../noteRef.js'; +import { getTitle, stripHtml } from '../content.js'; + +/** + * Max page number supported by note list routes + */ +const MAX_PAGE = 30; + +/** + * Nested note returned by the tool + */ +interface NoteTreeItem { + id: NotePublicId; + title: string; + url: string; + children: NoteTreeItem[]; +} + +/** + * Finds the note in the hierarchy + * @param node - hierarchy node to search in + * @param publicId - public id of the note to find + */ +function findHierarchyNode(node: NoteHierarchy, publicId: NotePublicId): NoteHierarchy | null { + if (node.noteId === publicId) { + return node; + } + + for (const child of node.childNotes ?? []) { + const found = findHierarchyNode(child, publicId); + + if (found !== null) { + return found; + } + } + + return null; +} + +/** + * Converts hierarchy node to the tool result format + * @param node - hierarchy node + * @param webUrl - web app url + */ +function toTreeItem(node: NoteHierarchy, webUrl: string): NoteTreeItem { + return { + id: node.noteId, + title: stripHtml(node.noteTitle), + url: getNoteUrl(webUrl, node.noteId), + children: (node.childNotes ?? []).map(child => toTreeItem(child, webUrl)), + }; +} + +/** + * Registers "list_notes" tool + * @param server - MCP server + * @param context - tool context + */ +export function registerListNotesTool(server: McpServer, context: McpToolContext): void { + server.registerTool('list_notes', { + title: 'List notes', + description: 'Lists notes of the user. ' + + 'Without "parent" returns a page of 30 notes: recently opened ones ("recent") or the ones created by the user ("created"). ' + + 'With "parent" returns the tree of notes nested in the given note.', + inputSchema: z.object({ + source: z.enum(['recent', 'created']).default('recent') + .describe('"recent" — notes ordered by the last visit, "created" — notes created by the user, newest first'), + page: z.number().int() + .min(1) + .max(MAX_PAGE) + .default(1) + .describe('Page number, 30 notes per page'), + parent: z.string().optional() + .describe('URL or id of the note whose nested notes should be listed'), + }), + annotations: { + readOnlyHint: true, + }, + }, async ({ source, page, parent }) => { + await context.onToolCall(); + + if (parent !== undefined) { + const parentId = await resolveNotePublicId(parent, context.api, context.webUrl); + const { noteHierarchy } = await context.api.get<{ noteHierarchy: NoteHierarchy | null }>(`/note/note-hierarchy/${parentId}`); + const parentNode = noteHierarchy === null ? null : findHierarchyNode(noteHierarchy, parentId); + + if (parentNode === null) { + return jsonResult({ + id: parentId, + url: getNoteUrl(context.webUrl, parentId), + children: [], + }); + } + + return jsonResult(toTreeItem(parentNode, context.webUrl)); + } + + const route = source === 'created' ? '/notes/created' : '/notes'; + const { items } = await context.api.get(`${route}?page=${page}`); + + return jsonResult({ + items: items.map(note => ({ + id: note.id, + title: getTitle(note.content), + url: getNoteUrl(context.webUrl, note.id), + updatedAt: note.updatedAt, + })), + }); + }); +} diff --git a/src/presentation/mcp/tools/updateNote.ts b/src/presentation/mcp/tools/updateNote.ts new file mode 100644 index 00000000..e32f529f --- /dev/null +++ b/src/presentation/mcp/tools/updateNote.ts @@ -0,0 +1,82 @@ +import type { McpServer } from '@modelcontextprotocol/server'; +import { z } from 'zod'; +import type { Note } from '@domain/entities/note.js'; +import type EditorTool from '@domain/entities/editorTools.js'; +import type { McpToolContext } from '../types.js'; +import { jsonResult } from '../types.js'; +import { getNoteUrl, resolveNotePublicId } from '../noteRef.js'; +import { BLOCKS_FORMAT_DESCRIPTION, blockInputSchema, fillBlockIds, getToolsForBlocks } from '../content.js'; +import type { GetNoteResponse } from './getNote.js'; +import { NotexApiError } from '../notexApi.js'; +import { StatusCodes } from 'http-status-codes'; + +/** + * Registers "update_note" tool + * @param server - MCP server + * @param context - tool context + */ +export function registerUpdateNoteTool(server: McpServer, context: McpToolContext): void { + server.registerTool('update_note', { + title: 'Update note', + description: 'Updates note content. ' + + '"replace" mode replaces all blocks of the note with the passed ones, so pass the whole content: get it with get_note first. ' + + '"append" mode adds the passed blocks to the end of the note.\n\n' + BLOCKS_FORMAT_DESCRIPTION, + inputSchema: z.object({ + note: z.string().describe('Note URL (e.g. https://notex.so/note/) or note id'), + blocks: z.array(blockInputSchema).describe('Blocks to replace the content with or to append'), + mode: z.enum(['replace', 'append']).default('replace') + .describe('"replace" — replace all blocks, "append" — add blocks to the end'), + expectedUpdatedAt: z.string().optional() + .describe('"updatedAt" returned by get_note. If the note was changed after that, the update is rejected'), + }), + annotations: { + readOnlyHint: false, + destructiveHint: true, + }, + }, async ({ note: noteRef, blocks, mode, expectedUpdatedAt }) => { + await context.onToolCall(); + + const publicId = await resolveNotePublicId(noteRef, context.api, context.webUrl); + const { note, accessRights, tools: noteTools } = await context.api.get(`/note/${publicId}`); + + if (!accessRights.canEdit) { + throw new Error('You can not edit this note'); + } + + if (expectedUpdatedAt !== undefined && Date.parse(expectedUpdatedAt) !== Date.parse(note.updatedAt)) { + throw new Error(`Note was changed at ${note.updatedAt}, after ${expectedUpdatedAt}. Get the note again and repeat the update`); + } + + const newBlocks = fillBlockIds(blocks); + const contentBlocks = mode === 'append' ? [...note.content.blocks, ...newBlocks] : newBlocks; + const { data: userTools } = await context.api.get<{ data: EditorTool[] }>('/user/editor-tools'); + + let updatedAt: Note['updatedAt']; + + try { + ({ updatedAt } = await context.api.patch<{ updatedAt: Note['updatedAt'] }>(`/note/${publicId}`, { + content: { + blocks: contentBlocks, + }, + tools: getToolsForBlocks(contentBlocks, [...noteTools, ...userTools]), + + /** + * Content is built from the version read above, so the update must not overwrite newer changes + */ + expectedUpdatedAt: note.updatedAt, + })); + } catch (error) { + if (error instanceof NotexApiError && error.statusCode === Number(StatusCodes.CONFLICT)) { + throw new Error('Note was changed by someone else during the update. Get the note again and repeat the update'); + } + + throw error; + } + + return jsonResult({ + id: publicId, + url: getNoteUrl(context.webUrl, publicId), + updatedAt, + }); + }); +} diff --git a/src/presentation/mcp/types.ts b/src/presentation/mcp/types.ts new file mode 100644 index 00000000..eccb2923 --- /dev/null +++ b/src/presentation/mcp/types.ts @@ -0,0 +1,37 @@ +import type { CallToolResult } from '@modelcontextprotocol/server'; +import type NotexApi from './notexApi.js'; + +/** + * Everything MCP tools need to handle a call + */ +export interface McpToolContext { + /** + * NoteX API client acting on behalf of the MCP token owner + */ + api: NotexApi; + + /** + * Web app url, used to build links to notes + */ + webUrl: string; + + /** + * Called on every tool call, used to track MCP token usage + */ + onToolCall: () => Promise; +} + +/** + * Wraps tool result to the MCP tool response + * @param data - tool result, serialized as JSON text + */ +export function jsonResult(data: unknown): CallToolResult { + return { + content: [ + { + type: 'text', + text: JSON.stringify(data, null, 2), + }, + ], + }; +} diff --git a/src/repository/index.ts b/src/repository/index.ts index ed40d01e..c25a8fbf 100644 --- a/src/repository/index.ts +++ b/src/repository/index.ts @@ -25,6 +25,8 @@ import NoteVisitsRepository from './noteVisits.repository.js'; import NoteVisitsStorage from './storage/noteVisits.storage.js'; import NoteHistoryStorage from './storage/noteHistory.storage.js'; import NoteHistoryRepository from './noteHistory.repository.js'; +import McpTokenStorage from './storage/mcpToken.storage.js'; +import McpTokenRepository from './mcpToken.repository.js'; /** * Interface for initiated repositories @@ -86,6 +88,11 @@ export interface Repositories { noteVisitsRepository: NoteVisitsRepository; noteHistoryRepository: NoteHistoryRepository; + + /** + * MCP tokens repository instance + */ + mcpTokenRepository: McpTokenRepository; } /** @@ -123,6 +130,7 @@ export async function init(orm: Orm, s3Config: S3StorageConfig): Promise { + return await this.storage.create(attributes); + } + + /** + * Gets all MCP tokens of the user + * @param userId - token owner id + * @returns list of MCP tokens + */ + public async getTokensByUserId(userId: McpToken['userId']): Promise { + return await this.storage.findByUserId(userId); + } + + /** + * Gets MCP token by its hash + * @param tokenHash - sha256 hash of the token + * @returns found MCP token + */ + public async getTokenByHash(tokenHash: McpToken['tokenHash']): Promise { + return await this.storage.findByTokenHash(tokenHash); + } + + /** + * Removes MCP token of the user + * @param id - token id + * @param userId - token owner id + * @returns true if token was removed + */ + public async removeToken(id: McpToken['id'], userId: McpToken['userId']): Promise { + return await this.storage.removeById(id, userId); + } + + /** + * Saves one more usage of the token + * @param id - token id + */ + public async trackUsage(id: McpToken['id']): Promise { + await this.storage.trackUsage(id); + } +} diff --git a/src/repository/note.repository.ts b/src/repository/note.repository.ts index c78d826f..fdadbab2 100644 --- a/src/repository/note.repository.ts +++ b/src/repository/note.repository.ts @@ -32,10 +32,11 @@ export default class NoteRepository { * @param id - note internal id * @param content - new content * @param noteTools - tools which are used in note - * @returns Note on success, null on failure + * @param expectedUpdatedAt - update the note only if it was last updated at this time + * @returns Note on success, null if the note does not exist or its version differs from the expected one */ - public async updateNoteContentAndToolsById(id: NoteInternalId, content: Note['content'], noteTools: Note['tools']): Promise { - return await this.storage.updateNoteContentAndToolsById(id, content, noteTools); + public async updateNoteContentAndToolsById(id: NoteInternalId, content: Note['content'], noteTools: Note['tools'], expectedUpdatedAt?: Date): Promise { + return await this.storage.updateNoteContentAndToolsById(id, content, noteTools, expectedUpdatedAt); } /** diff --git a/src/repository/storage/mcpToken.storage.ts b/src/repository/storage/mcpToken.storage.ts new file mode 100644 index 00000000..21e15048 --- /dev/null +++ b/src/repository/storage/mcpToken.storage.ts @@ -0,0 +1,6 @@ +import McpTokenSequelizeStorage from './postgres/orm/sequelize/mcpToken.js'; + +/** + * Current MCP tokens storage + */ +export default McpTokenSequelizeStorage; diff --git a/src/repository/storage/postgres/orm/sequelize/mcpToken.ts b/src/repository/storage/postgres/orm/sequelize/mcpToken.ts new file mode 100644 index 00000000..1e866a52 --- /dev/null +++ b/src/repository/storage/postgres/orm/sequelize/mcpToken.ts @@ -0,0 +1,222 @@ +import type { Sequelize, InferAttributes, InferCreationAttributes, CreationOptional } from 'sequelize'; +import { Model, DataTypes, literal } from 'sequelize'; +import type Orm from '@repository/storage/postgres/orm/sequelize/index.js'; +import { UserModel } from '@repository/storage/postgres/orm/sequelize/user.js'; +import type McpToken from '@domain/entities/mcpToken.js'; +import type { McpTokenCreationAttributes, McpTokenPermission } from '@domain/entities/mcpToken.js'; + +/** + * Length of sha256 hex digest + */ +const TOKEN_HASH_LENGTH = 64; + +/** + * Max length of the token preview + */ +const PREVIEW_MAX_LENGTH = 4; + +/** + * Max length of the permission name + */ +const PERMISSION_MAX_LENGTH = 5; + +/** + * Max length of the token label + */ +const LABEL_MAX_LENGTH = 255; + +/** + * Class representing an MCP token model in database + */ +class McpTokenModel extends Model, InferCreationAttributes> { + /** + * Token id + */ + public declare id: CreationOptional; + + /** + * Token owner id + */ + public declare userId: McpToken['userId']; + + /** + * Sha256 hash of the token + */ + public declare tokenHash: string; + + /** + * Last characters of the token + */ + public declare preview: string; + + /** + * What MCP client can do with notes of the token owner + */ + public declare permission: McpTokenPermission; + + /** + * Label set by user + */ + public declare label: string; + + /** + * Last time token was used + */ + public declare lastUsedAt: CreationOptional; + + /** + * How many times token was used + */ + public declare requestCount: CreationOptional; + + /** + * When token was created + */ + public declare createdAt: CreationOptional; +} + +/** + * Class representing a table storing MCP tokens + */ +export default class McpTokenSequelizeStorage { + /** + * MCP tokens model in database + */ + public model: typeof McpTokenModel; + + /** + * Database instance + */ + private readonly database: Sequelize; + + /** + * Table name + */ + private readonly tableName = 'mcp_tokens'; + + /** + * Constructor for MCP tokens storage + * @param ormInstance - ORM instance + */ + constructor({ connection }: Orm) { + this.database = connection; + + /** + * Initiate MCP tokens model + */ + this.model = McpTokenModel.init({ + id: { + type: DataTypes.INTEGER, + autoIncrement: true, + primaryKey: true, + }, + userId: { + type: DataTypes.INTEGER, + allowNull: false, + references: { + model: UserModel, + key: 'id', + }, + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }, + tokenHash: { + type: DataTypes.CHAR(TOKEN_HASH_LENGTH), + allowNull: false, + unique: true, + }, + preview: { + type: DataTypes.STRING(PREVIEW_MAX_LENGTH), + allowNull: false, + }, + permission: { + type: DataTypes.STRING(PERMISSION_MAX_LENGTH), + allowNull: false, + }, + label: { + type: DataTypes.STRING(LABEL_MAX_LENGTH), + allowNull: false, + }, + lastUsedAt: { + type: DataTypes.DATE, + allowNull: true, + }, + requestCount: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0, + }, + createdAt: { + type: DataTypes.DATE, + allowNull: false, + defaultValue: DataTypes.NOW, + }, + }, { + tableName: this.tableName, + sequelize: this.database, + timestamps: false, + }); + } + + /** + * Creates MCP token + * @param attributes - token owner, hash, preview, permission and label + * @returns created MCP token + */ + public async create(attributes: McpTokenCreationAttributes): Promise { + return await this.model.create(attributes); + } + + /** + * Finds all MCP tokens of the user, newest first + * @param userId - token owner id + * @returns list of MCP tokens + */ + public async findByUserId(userId: McpToken['userId']): Promise { + return await this.model.findAll({ + where: { userId }, + order: [['createdAt', 'DESC']], + }); + } + + /** + * Finds MCP token by its hash + * @param tokenHash - sha256 hash of the token + * @returns found MCP token + */ + public async findByTokenHash(tokenHash: McpToken['tokenHash']): Promise { + return await this.model.findOne({ + where: { tokenHash }, + }); + } + + /** + * Removes MCP token of the user + * @param id - token id + * @param userId - token owner id + * @returns true if token was removed + */ + public async removeById(id: McpToken['id'], userId: McpToken['userId']): Promise { + const removedCount = await this.model.destroy({ + where: { + id, + userId, + }, + }); + + return removedCount > 0; + } + + /** + * Increments request counter and updates last usage time of the token in one query + * @param id - token id + */ + public async trackUsage(id: McpToken['id']): Promise { + await this.model.update({ + requestCount: literal('request_count + 1'), + lastUsedAt: literal('NOW()'), + }, { + where: { id }, + }); + } +} diff --git a/src/repository/storage/postgres/orm/sequelize/note.ts b/src/repository/storage/postgres/orm/sequelize/note.ts index 48138345..4dd69737 100644 --- a/src/repository/storage/postgres/orm/sequelize/note.ts +++ b/src/repository/storage/postgres/orm/sequelize/note.ts @@ -1,5 +1,5 @@ import type { CreationOptional, InferAttributes, InferCreationAttributes, ModelStatic, NonAttribute, Sequelize } from 'sequelize'; -import { DataTypes, Model, Op, QueryTypes } from 'sequelize'; +import { DataTypes, Model, Op, QueryTypes, col, fn, where as whereCondition } from 'sequelize'; import type Orm from '@repository/storage/postgres/orm/sequelize/index.js'; import type { Note, NoteCreationAttributes, NoteInternalId, NotePublicId, NotePreview } from '@domain/entities/note.js'; import { UserModel } from '@repository/storage/postgres/orm/sequelize/user.js'; @@ -174,15 +174,23 @@ export default class NoteSequelizeStorage { * @param id - note internal id * @param content - new content * @param tools - tools which are used in note - * @returns Note on success, null on failure + * @param expectedUpdatedAt - update the note only if it was last updated at this time + * @returns Note on success, null if the note does not exist or its version differs from the expected one */ - public async updateNoteContentAndToolsById(id: NoteInternalId, content: Note['content'], tools: Note['tools']): Promise { + public async updateNoteContentAndToolsById(id: NoteInternalId, content: Note['content'], tools: Note['tools'], expectedUpdatedAt?: Date): Promise { const [affectedRowsCount, affectedRows] = await this.model.update({ content, tools, }, { where: { id, + /** + * Update only the expected version of the note in the same query, so concurrent updates can not overwrite each other. + * Versions are compared with milliseconds precision, as updatedAt is passed through the API + */ + ...(expectedUpdatedAt !== undefined && { + [Op.and]: whereCondition(fn('date_trunc', 'milliseconds', col('updated_at')), expectedUpdatedAt), + }), }, returning: true, }); diff --git a/yarn.lock b/yarn.lock index 48967d84..f09e9208 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1182,6 +1182,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9 || ^2.1.3": + version: 2.1.5 + resolution: "@hono/node-server@npm:2.1.5" + peerDependencies: + hono: ^4 || ^5.0.0-0 + checksum: 38cae5ef670853dff2e783e4fe1b2650ea0a58b8602fa70a931ea4a8a1579d3bd1b12fe2dc78f53ea6b83f3ec0e6138c8613a46905205659b3b6bf371a6321f4 + languageName: node + linkType: hard + "@humanwhocodes/config-array@npm:^0.13.0": version: 0.13.0 resolution: "@humanwhocodes/config-array@npm:0.13.0" @@ -1310,6 +1319,52 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/client@npm:^2.4.0": + version: 2.4.0 + resolution: "@modelcontextprotocol/client@npm:2.4.0" + dependencies: + "@modelcontextprotocol/core": 2.4.0 + cross-spawn: ^7.0.5 + eventsource: ^3.0.2 + eventsource-parser: ^3.0.8 + jose: ^6.1.3 + pkce-challenge: ^5.0.0 + zod: ^4.2.0 + checksum: 53bd2caa4ce9781df2483efb15219defb87d5988ea00277250ea1468b9df5f57d7fb19ceef8129130720c686947414d813f3bcc2b464dda57ac107bc79122f54 + languageName: node + linkType: hard + +"@modelcontextprotocol/core@npm:2.4.0": + version: 2.4.0 + resolution: "@modelcontextprotocol/core@npm:2.4.0" + dependencies: + zod: ^4.2.0 + checksum: 9ab9182854906b0752965027ced44df5e814014f9692e3075ecb8c587bbae97fd325d064c4b16a4be4539d76c6678d456566df621be01483aca5e54e580fd5c0 + languageName: node + linkType: hard + +"@modelcontextprotocol/node@npm:^2.1.2": + version: 2.1.2 + resolution: "@modelcontextprotocol/node@npm:2.1.2" + dependencies: + "@hono/node-server": ^1.19.9 || ^2.1.3 + hono: ^4.11.4 + peerDependencies: + "@modelcontextprotocol/server": ^2.4.0 + checksum: 3ef7decd0bdbf31689d7a9ac7eb9926ef51316a080742ab07585395c380e886a1b94440ee6e5b3dd1df9d8b221bfc564f34724150972ff65990498c921484efa + languageName: node + linkType: hard + +"@modelcontextprotocol/server@npm:^2.4.0": + version: 2.4.0 + resolution: "@modelcontextprotocol/server@npm:2.4.0" + dependencies: + "@modelcontextprotocol/core": 2.4.0 + zod: ^4.2.0 + checksum: 95c90ec06a1ca8e889cef85d21e39973b96a57dfd3b537b6562f96f297825912e7c0221066a82fcce5530fa013b4f67a7179c760b4f351d95ea3c2b8e894c463 + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -3614,6 +3669,17 @@ __metadata: languageName: node linkType: hard +"cross-spawn@npm:^7.0.5": + version: 7.0.6 + resolution: "cross-spawn@npm:7.0.6" + dependencies: + path-key: ^3.1.0 + shebang-command: ^2.0.0 + which: ^2.0.1 + checksum: 8d306efacaf6f3f60e0224c287664093fa9185680b2d195852ba9a863f85d02dcc737094c6e512175f8ee0161f9b87c73c6826034c2422e39de7d6569cf4503b + languageName: node + linkType: hard + "cssesc@npm:^3.0.0": version: 3.0.0 resolution: "cssesc@npm:3.0.0" @@ -4551,6 +4617,22 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.1, eventsource-parser@npm:^3.0.8": + version: 3.1.1 + resolution: "eventsource-parser@npm:3.1.1" + checksum: ab220722d2ae82818b92e0d92a2fd333102f384a0314190ad1d469dc9598ade8047bb83644e3b09f7766becbda9b84a265508b66272aa57d8c76d2afd5c29fa8 + languageName: node + linkType: hard + +"eventsource@npm:^3.0.2": + version: 3.0.7 + resolution: "eventsource@npm:3.0.7" + dependencies: + eventsource-parser: ^3.0.1 + checksum: cd8cbc3418238b9d751b6652edf442d4b869829fbc3b73444abca1816fe3d23dc707130dd9a990360bc27c281d986f2f62059d870921173425c3ac28d20a8414 + languageName: node + linkType: hard + "execa@npm:^8.0.1": version: 8.0.1 resolution: "execa@npm:8.0.1" @@ -5243,6 +5325,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.13.14 + resolution: "hono@npm:4.13.14" + checksum: 74cfd182fc94504af75fc1629b0f8e46abc26379f5479f0e69923682399ca45785982a27e373461b47d9b44514e5425aec154ddca47ffe978662b78b37498cac + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -5769,6 +5858,13 @@ __metadata: languageName: node linkType: hard +"jose@npm:^6.1.3": + version: 6.2.13 + resolution: "jose@npm:6.2.13" + checksum: 14d95b97a5575e662f020a96295527620bec7c221623f6dcd7280653ef94f287beef5f54f4970670bc6f55223800808b1d89b6e61d59cc8e2c3464e86288a902 + languageName: node + linkType: hard + "joycon@npm:^3.1.1": version: 3.1.1 resolution: "joycon@npm:3.1.1" @@ -6550,6 +6646,9 @@ __metadata: "@fastify/oauth2": ^7.2.1 "@fastify/swagger": ^8.8.0 "@fastify/swagger-ui": ^1.9.3 + "@modelcontextprotocol/client": ^2.4.0 + "@modelcontextprotocol/node": ^2.1.2 + "@modelcontextprotocol/server": ^2.4.0 "@testcontainers/localstack": ^10.9.0 "@testcontainers/postgresql": ^10.2.1 "@types/jsonwebtoken": ^9.0.2 @@ -6580,7 +6679,7 @@ __metadata: typescript: ^5.0.4 typescript-eslint: ^7.6.0 vitest: ^1.4.0 - zod: ^3.21.4 + zod: ^4.2.0 languageName: unknown linkType: soft @@ -7100,6 +7199,13 @@ __metadata: languageName: node linkType: hard +"pkce-challenge@npm:^5.0.0": + version: 5.0.1 + resolution: "pkce-challenge@npm:5.0.1" + checksum: 6079ee7520592f827cb78c397a1d2ff0a90a1952756b2efb893bf884524b7d2930dea82d92fec21bb3af99aba558d13553d9da87d5bd9f844b6c4cb3bc8f1ce0 + languageName: node + linkType: hard + "pkg-types@npm:^1.0.3": version: 1.0.3 resolution: "pkg-types@npm:1.0.3" @@ -9289,9 +9395,9 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.21.4": - version: 3.22.4 - resolution: "zod@npm:3.22.4" - checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f +"zod@npm:^4.2.0": + version: 4.6.7 + resolution: "zod@npm:4.6.7" + checksum: 6801f5067dd43204d5ed2b4f2f0d09f891365ecfdd385877901e42f6f6ebf67e8943b385c71eaba53081029dd21f4740f16c91497a06f95195e209bffef92056 languageName: node linkType: hard