From 0fd463d2f41a82578364a3a1a38bb36e2be59981 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 21 Oct 2026 02:56:06 +0300 Subject: [PATCH 1/6] feat: add MCP server with personal MCP tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a remote MCP endpoint so MCP clients (Claude Code, Cursor, claude.ai) can read and edit NoteX notes on behalf of a user. - mcp_tokens table, McpTokenService and /user/mcp-tokens routes to create, list and revoke personal tokens. Only the sha256 hash is stored; the plain token is returned once on creation. Tokens have read or write permission and track last usage and tool call count. - POST /mcp serves MCP over Streamable HTTP in stateless mode with `Authorization: Bearer ntx_…`. Tools list_notes, get_note, create_note and update_note call existing routes through fastify.inject() with the token owner's access token, so route policies and validation apply. Read tokens see only list_notes and get_note. - userIdResolver ignores MCP tokens, so other routes don't accept them. - Add @modelcontextprotocol/sdk and bump zod to ^3.25 required by it. - docs/mcp-plan.md tracks the implementation plan and deviations. Co-Authored-By: Claude Opus 5.5 --- app-config.yaml | 2 + docs/mcp-plan.md | 128 +++ .../0036-mcp-tokens@add-mcp-tokens-table.sql | 34 + package.json | 3 +- src/domain/entities/mcpToken.ts | 87 ++ src/domain/index.ts | 8 + src/domain/service/mcpToken.ts | 139 ++++ src/infrastructure/config/index.ts | 13 + src/presentation/http/http-api.ts | 16 + .../http/middlewares/common/userIdResolver.ts | 10 + .../http/router/mcpTokens.test.ts | 208 +++++ src/presentation/http/router/mcpTokens.ts | 173 ++++ src/presentation/http/schema/McpToken.ts | 41 + src/presentation/mcp/content.ts | 108 +++ src/presentation/mcp/index.ts | 122 +++ src/presentation/mcp/mcp.test.ts | 449 +++++++++++ src/presentation/mcp/noteRef.ts | 73 ++ src/presentation/mcp/notexApi.ts | 95 +++ src/presentation/mcp/server.ts | 39 + src/presentation/mcp/tools/createNote.ts | 48 ++ src/presentation/mcp/tools/getNote.ts | 59 ++ src/presentation/mcp/tools/listNotes.ts | 117 +++ src/presentation/mcp/tools/updateNote.ts | 65 ++ src/presentation/mcp/types.ts | 37 + src/repository/index.ts | 11 + src/repository/mcpToken.repository.ts | 66 ++ src/repository/storage/mcpToken.storage.ts | 6 + .../postgres/orm/sequelize/mcpToken.ts | 222 ++++++ yarn.lock | 745 +++++++++++++++++- 29 files changed, 3112 insertions(+), 12 deletions(-) create mode 100644 docs/mcp-plan.md create mode 100644 migrations/tenant/0036-mcp-tokens@add-mcp-tokens-table.sql create mode 100644 src/domain/entities/mcpToken.ts create mode 100644 src/domain/service/mcpToken.ts create mode 100644 src/presentation/http/router/mcpTokens.test.ts create mode 100644 src/presentation/http/router/mcpTokens.ts create mode 100644 src/presentation/http/schema/McpToken.ts create mode 100644 src/presentation/mcp/content.ts create mode 100644 src/presentation/mcp/index.ts create mode 100644 src/presentation/mcp/mcp.test.ts create mode 100644 src/presentation/mcp/noteRef.ts create mode 100644 src/presentation/mcp/notexApi.ts create mode 100644 src/presentation/mcp/server.ts create mode 100644 src/presentation/mcp/tools/createNote.ts create mode 100644 src/presentation/mcp/tools/getNote.ts create mode 100644 src/presentation/mcp/tools/listNotes.ts create mode 100644 src/presentation/mcp/tools/updateNote.ts create mode 100644 src/presentation/mcp/types.ts create mode 100644 src/repository/mcpToken.repository.ts create mode 100644 src/repository/storage/mcpToken.storage.ts create mode 100644 src/repository/storage/postgres/orm/sequelize/mcpToken.ts 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..e8e9ccc5 --- /dev/null +++ b/docs/mcp-plan.md @@ -0,0 +1,128 @@ +# 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 (notes.web) is not started. + +## Decisions + +- **Where it lives.** A remote MCP endpoint `/mcp` inside notes.api. Transport: Streamable HTTP, stateless (no MCP sessions). 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] Dependency `@modelcontextprotocol/sdk` (zod may need a bump from `^3.21`). +- [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. create `McpServer` and `StreamableHTTPServerTransport({ sessionIdGenerator: undefined })`; + 4. `reply.hijack()` and `transport.handleRequest(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 + +- [ ] 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). +- [ ] 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 `^3.25.0`, the SDK (`@modelcontextprotocol/sdk@^1.32.1`) requires `^3.25 || ^4`. +- 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: the transport runs with `enableJsonResponse: true`: responses are plain JSON, no SSE streams. +- 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: tests cover the MCP protocol through `fastify.inject()`; the endpoint has not been tried with a real MCP client yet. + +## Verification + +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/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..01937d25 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,7 @@ "@fastify/oauth2": "^7.2.1", "@fastify/swagger": "^8.8.0", "@fastify/swagger-ui": "^1.9.3", + "@modelcontextprotocol/sdk": "^1.32.1", "@testcontainers/postgresql": "^10.2.1", "arg": "^5.0.2", "fastify": "^4.17.0", @@ -60,7 +61,7 @@ "prom-client": "^14.2.0", "sequelize": "^6.31.1", "testcontainers": "^10.2.1", - "zod": "^3.21.4" + "zod": "^3.25.0" }, "packageManager": "yarn@3.6.4" } 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/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/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..2a2e9251 --- /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.unknown()).describe('Block data, format depends on the block type'), + tunes: z.record(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..5f10bd68 --- /dev/null +++ b/src/presentation/mcp/index.ts @@ -0,0 +1,122 @@ +import type { FastifyPluginCallback, FastifyReply } from 'fastify'; +import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +import { StatusCodes } from 'http-status-codes'; +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'; + +/** + * JSON-RPC error code for internal server errors + */ +const JSON_RPC_INTERNAL_ERROR = -32603; + +/** + * 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; +} + +/** + * MCP router plugin. Serves MCP over Streamable HTTP in stateless mode: + * 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; + + /** + * Handle MCP JSON-RPC messages + */ + fastify.post('/', async (request, reply) => { + const logger = getRequestLogger('appServer').child({ router: 'mcp' }); + 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'); + } + + /** + * Tools call API routes on behalf of the token owner + */ + const api = new NotexApi(fastify, authService.signAccessToken({ id: mcpToken.userId })); + + const server = createMcpServer({ + api, + webUrl, + onToolCall: async () => await mcpTokenService.trackUsage(mcpToken.id), + }, mcpToken.permission); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + + /** + * Transport writes the response itself + */ + void reply.hijack(); + + reply.raw.on('close', () => { + void transport.close(); + void server.close(); + }); + + try { + await server.connect(transport); + await transport.handleRequest(request.raw, reply.raw, request.body); + } catch (error) { + logger.error(error); + + if (!reply.raw.headersSent) { + reply.raw.setHeader('content-type', 'application/json'); + reply.raw.writeHead(StatusCodes.INTERNAL_SERVER_ERROR); + reply.raw.end(JSON.stringify({ + jsonrpc: '2.0', + error: { + code: JSON_RPC_INTERNAL_ERROR, + message: 'Internal server error', + }, + id: null, + })); + } + } + }); + + /** + * 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..62d782a1 --- /dev/null +++ b/src/presentation/mcp/mcp.test.ts @@ -0,0 +1,449 @@ +import { describe, test, expect, beforeEach } from 'vitest'; +import type User from '@domain/entities/user.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 the tool result. Its shape depends on the tool, so tests access it freely + */ +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +type ToolResultData = any; + +/** + * 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: ToolResultData }> { + const response = await sendMcpRequest(token, 'tools/call', { + name, + arguments: args, + }); + + const { result } = response!.json<{ result: { isError?: boolean; content: { text: string }[] } }>(); + const text = result.content[0].text; + let data: ToolResultData = 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('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(response?.json().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 = response?.json().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('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('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..2f694fdb --- /dev/null +++ b/src/presentation/mcp/server.ts @@ -0,0 +1,39 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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..4b759573 --- /dev/null +++ b/src/presentation/mcp/tools/createNote.ts @@ -0,0 +1,48 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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: { + 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..f7455e80 --- /dev/null +++ b/src/presentation/mcp/tools/getNote.ts @@ -0,0 +1,59 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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: { + 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..cc37abb0 --- /dev/null +++ b/src/presentation/mcp/tools/listNotes.ts @@ -0,0 +1,117 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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: { + 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..d136ba87 --- /dev/null +++ b/src/presentation/mcp/tools/updateNote.ts @@ -0,0 +1,65 @@ +import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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'; + +/** + * 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: { + 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'); + + const { updatedAt } = await context.api.patch<{ updatedAt: Note['updatedAt'] }>(`/note/${publicId}`, { + content: { + blocks: contentBlocks, + }, + tools: getToolsForBlocks(contentBlocks, [...noteTools, ...userTools]), + }); + + 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..c550dc9f --- /dev/null +++ b/src/presentation/mcp/types.ts @@ -0,0 +1,37 @@ +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +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/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/yarn.lock b/yarn.lock index 48967d84..a29fe827 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1182,6 +1182,15 @@ __metadata: languageName: node linkType: hard +"@hono/node-server@npm:^1.19.9 || ^2.0.5": + 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,39 @@ __metadata: languageName: node linkType: hard +"@modelcontextprotocol/sdk@npm:^1.32.1": + version: 1.32.1 + resolution: "@modelcontextprotocol/sdk@npm:1.32.1" + dependencies: + "@hono/node-server": ^1.19.9 || ^2.0.5 + ajv: ^8.17.1 + ajv-formats: ^3.0.1 + content-type: ^1.0.5 + cors: ^2.8.5 + cross-spawn: ^7.0.5 + eventsource: ^3.0.2 + eventsource-parser: ^3.0.0 + express: ^5.2.1 + express-rate-limit: ^8.2.1 + hono: ^4.11.4 + jose: ^6.1.3 + json-schema-typed: ^8.0.2 + pkce-challenge: ^5.0.0 + raw-body: ^3.0.0 + zod: ^3.25 || ^4.0 + zod-to-json-schema: ^3.25.1 + peerDependencies: + "@cfworker/json-schema": ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + "@cfworker/json-schema": + optional: true + zod: + optional: false + checksum: c1a5c5773d2353e457d818363cead3dc27289a8d753002d48725e5aecbb109349fdd3df9970c94b663866b332e8a59126a334f3e2d58bb5e2da67fb9580d20eb + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -2692,6 +2734,16 @@ __metadata: languageName: node linkType: hard +"accepts@npm:^2.0.0": + version: 2.0.0 + resolution: "accepts@npm:2.0.0" + dependencies: + mime-types: ^3.0.0 + negotiator: ^1.0.0 + checksum: 49fe6c050cb6f6ff4e771b4d88324fca4d3127865f2473872e818dca127d809ba3aa8fdfc7acb51dd3c5bade7311ca6b8cfff7015ea6db2f7eb9c8444d223a4f + languageName: node + linkType: hard + "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -2775,6 +2827,20 @@ __metadata: languageName: node linkType: hard +"ajv-formats@npm:^3.0.1": + version: 3.0.1 + resolution: "ajv-formats@npm:3.0.1" + dependencies: + ajv: ^8.0.0 + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + checksum: f4e1fe232d67fcafc02eafe373a7a9962351e0439dd0736647ca75c93c3da23b430b6502c255ab4315410ae330d4f3013ac9fe226c40b2524ca93a58e786d086 + languageName: node + linkType: hard + "ajv@npm:^6.12.4": version: 6.12.6 resolution: "ajv@npm:6.12.6" @@ -2799,6 +2865,18 @@ __metadata: languageName: node linkType: hard +"ajv@npm:^8.17.1": + version: 8.20.0 + resolution: "ajv@npm:8.20.0" + dependencies: + fast-deep-equal: ^3.1.3 + fast-uri: ^3.0.1 + json-schema-traverse: ^1.0.0 + require-from-string: ^2.0.2 + checksum: 4f18ca5fcccff8c8b9a4d6f1a6a3a70ffc888e787624ca66f2d0162e04e8f9f2d289d8a1acdb9520d18bffcee975e7a789a9f8292b5c2c577c408cd2c3308cad + languageName: node + linkType: hard + "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -3075,6 +3153,20 @@ __metadata: languageName: node linkType: hard +"async-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-function@npm:1.0.0" + checksum: 9102e246d1ed9b37ac36f57f0a6ca55226876553251a31fc80677e71471f463a54c872dc78d5d7f80740c8ba624395cccbe8b60f7b690c4418f487d8e9fd1106 + languageName: node + linkType: hard + +"async-generator-function@npm:^1.0.0": + version: 1.0.0 + resolution: "async-generator-function@npm:1.0.0" + checksum: 74a71a4a2dd7afd06ebb612f6d612c7f4766a351bedffde466023bf6dae629e46b0d2cd38786239e0fbf245de0c7df76035465e16d1213774a0efb22fec0d713 + languageName: node + linkType: hard + "async-lock@npm:^1.4.0": version: 1.4.0 resolution: "async-lock@npm:1.4.0" @@ -3228,6 +3320,23 @@ __metadata: languageName: node linkType: hard +"body-parser@npm:^2.3.0": + version: 2.3.0 + resolution: "body-parser@npm:2.3.0" + dependencies: + bytes: ^3.1.2 + content-type: ^2.0.0 + debug: ^4.4.3 + http-errors: ^2.0.1 + iconv-lite: ^0.7.2 + on-finished: ^2.4.1 + qs: ^6.15.2 + raw-body: ^3.0.2 + type-is: ^2.1.0 + checksum: 44d681aaa2d6ffe3efb4f6c098e7c4f7beae0d158580f7abea1cd487fc05b485488815f5c6fc71aa05975541c00042b4becdb485c441ddd63ce9c71b63b7ea4d + languageName: node + linkType: hard + "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -3332,6 +3441,13 @@ __metadata: languageName: node linkType: hard +"bytes@npm:^3.1.2, bytes@npm:~3.1.2": + version: 3.1.2 + resolution: "bytes@npm:3.1.2" + checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e + languageName: node + linkType: hard + "cac@npm:^6.7.14": version: 6.7.14 resolution: "cac@npm:6.7.14" @@ -3359,6 +3475,16 @@ __metadata: languageName: node linkType: hard +"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": + version: 1.0.2 + resolution: "call-bind-apply-helpers@npm:1.0.2" + dependencies: + es-errors: ^1.3.0 + function-bind: ^1.1.2 + checksum: b2863d74fcf2a6948221f65d95b91b4b2d90cfe8927650b506141e669f7d5de65cea191bf788838bc40d13846b7886c5bc5c84ab96c3adbcf88ad69a72fcdc6b + languageName: node + linkType: hard + "call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.4": version: 1.0.5 resolution: "call-bind@npm:1.0.5" @@ -3383,6 +3509,16 @@ __metadata: languageName: node linkType: hard +"call-bound@npm:^1.0.2": + version: 1.0.4 + resolution: "call-bound@npm:1.0.4" + dependencies: + call-bind-apply-helpers: ^1.0.2 + get-intrinsic: ^1.3.0 + checksum: 2f6399488d1c272f56306ca60ff696575e2b7f31daf23bc11574798c84d9f2759dceb0cb1f471a85b77f28962a7ac6411f51d283ea2e45319009a19b6ccab3b2 + languageName: node + linkType: hard + "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -3545,6 +3681,27 @@ __metadata: languageName: node linkType: hard +"content-disposition@npm:^2.0.1": + version: 2.0.1 + resolution: "content-disposition@npm:2.0.1" + checksum: 2e25ab5937b5b281aecc44ee28022f2484a577fa32800977dd830c34740fa87c88061cf9fd25c794b0a66879077ca90fe6b73026d775d4b2c5e188b8f0f60f42 + languageName: node + linkType: hard + +"content-type@npm:^1.0.5": + version: 1.0.5 + resolution: "content-type@npm:1.0.5" + checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766 + languageName: node + linkType: hard + +"content-type@npm:^2.0.0, content-type@npm:^2.1.0": + version: 2.1.0 + resolution: "content-type@npm:2.1.0" + checksum: d49ee84ba26900bfd57c95913be9baffdc2b51c4c8cc9bcdfa815d29aa7af6dce547d04007a356519da4b45a75a21fedbaee244aaef84a55e32900fcb79bce0b + languageName: node + linkType: hard + "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -3552,6 +3709,13 @@ __metadata: languageName: node linkType: hard +"cookie-signature@npm:^1.2.1": + version: 1.2.2 + resolution: "cookie-signature@npm:1.2.2" + checksum: 1ad4f9b3907c9f3673a0f0a07c0a23da7909ac6c9204c5d80a0ec102fe50ccc45f27fdf496361840d6c132c5bb0037122c0a381f856d070183d1ebe3e5e041ff + languageName: node + linkType: hard + "cookie@npm:^0.5.0": version: 0.5.0 resolution: "cookie@npm:0.5.0" @@ -3559,6 +3723,13 @@ __metadata: languageName: node linkType: hard +"cookie@npm:^0.7.1": + version: 0.7.2 + resolution: "cookie@npm:0.7.2" + checksum: 9bf8555e33530affd571ea37b615ccad9b9a34febbf2c950c86787088eb00a8973690833b0f8ebd6b69b753c62669ea60cec89178c1fb007bf0749abed74f93e + languageName: node + linkType: hard + "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -3566,6 +3737,16 @@ __metadata: languageName: node linkType: hard +"cors@npm:^2.8.5": + version: 2.8.6 + resolution: "cors@npm:2.8.6" + dependencies: + object-assign: ^4 + vary: ^1 + checksum: a967922b00fd17d836d21308c66ab9081d6c0f7dc019486ba1643a58281b12fc27d8c260471ddca72874b5bfe17a2d471ff8762d34f6009022ff749ec1136220 + languageName: node + linkType: hard + "cpu-features@npm:~0.0.8": version: 0.0.9 resolution: "cpu-features@npm:0.0.9" @@ -3614,6 +3795,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" @@ -3684,6 +3876,18 @@ __metadata: languageName: node linkType: hard +"debug@npm:^4.4.0, debug@npm:^4.4.3": + version: 4.4.3 + resolution: "debug@npm:4.4.3" + dependencies: + ms: ^2.1.3 + peerDependenciesMeta: + supports-color: + optional: true + checksum: 4805abd570e601acdca85b6aa3757186084a45cff9b2fa6eee1f3b173caa776b45f478b2a71a572d616d2010cea9211d0ac4a02a610e4c18ac4324bde3760834 + languageName: node + linkType: hard + "deep-eql@npm:^4.1.3": version: 4.1.3 resolution: "deep-eql@npm:4.1.3" @@ -3740,7 +3944,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0": +"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a @@ -3827,6 +4031,17 @@ __metadata: languageName: node linkType: hard +"dunder-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "dunder-proto@npm:1.0.1" + dependencies: + call-bind-apply-helpers: ^1.0.1 + es-errors: ^1.3.0 + gopd: ^1.2.0 + checksum: 149207e36f07bd4941921b0ca929e3a28f1da7bd6b6ff8ff7f4e2f2e460675af4576eeba359c635723dc189b64cdd4787e0255897d5b135ccc5d15cb8685fc90 + languageName: node + linkType: hard + "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -3843,6 +4058,13 @@ __metadata: languageName: node linkType: hard +"ee-first@npm:1.1.1": + version: 1.1.1 + resolution: "ee-first@npm:1.1.1" + checksum: 1b4cac778d64ce3b582a7e26b218afe07e207a0f9bfe13cc7395a6d307849cfe361e65033c3251e00c27dd060cab43014c2d6b2647676135e18b77d2d05b3f4f + languageName: node + linkType: hard + "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -3857,6 +4079,13 @@ __metadata: languageName: node linkType: hard +"encodeurl@npm:^2.0.0": + version: 2.0.0 + resolution: "encodeurl@npm:2.0.0" + checksum: abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe + languageName: node + linkType: hard + "encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -4009,6 +4238,13 @@ __metadata: languageName: node linkType: hard +"es-define-property@npm:^1.0.1": + version: 1.0.1 + resolution: "es-define-property@npm:1.0.1" + checksum: 0512f4e5d564021c9e3a644437b0155af2679d10d80f21adaf868e64d30efdfbd321631956f20f42d655fedb2e3a027da479fad3fa6048f768eb453a80a5f80a + languageName: node + linkType: hard + "es-errors@npm:^1.2.1, es-errors@npm:^1.3.0": version: 1.3.0 resolution: "es-errors@npm:1.3.0" @@ -4025,6 +4261,15 @@ __metadata: languageName: node linkType: hard +"es-object-atoms@npm:^1.1.1": + version: 1.1.2 + resolution: "es-object-atoms@npm:1.1.2" + dependencies: + es-errors: ^1.3.0 + checksum: b821f39e4f48bd85b13fee80aea9068a674bcb78b95ff01267aa4da1111f83e6944049b3329b2ffdb3acaa266fb5b8b885476fe8cada05034dc7c87c8016c86d + languageName: node + linkType: hard + "es-set-tostringtag@npm:^2.0.1": version: 2.0.1 resolution: "es-set-tostringtag@npm:2.0.1" @@ -4156,7 +4401,7 @@ __metadata: languageName: node linkType: hard -"escape-html@npm:~1.0.3": +"escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" checksum: 6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24 @@ -4537,6 +4782,13 @@ __metadata: languageName: node linkType: hard +"etag@npm:^1.8.1": + version: 1.8.1 + resolution: "etag@npm:1.8.1" + checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff + languageName: node + linkType: hard + "event-target-shim@npm:^5.0.0": version: 5.0.1 resolution: "event-target-shim@npm:5.0.1" @@ -4551,6 +4803,22 @@ __metadata: languageName: node linkType: hard +"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": + 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" @@ -4575,6 +4843,54 @@ __metadata: languageName: node linkType: hard +"express-rate-limit@npm:^8.2.1": + version: 8.7.1 + resolution: "express-rate-limit@npm:8.7.1" + dependencies: + debug: ^4.4.3 + ip-address: ^10.2.0 + peerDependencies: + express: ">= 4.11" + checksum: 67b303e950d34419d79ab03918fbd602c968897a069c74053081e19b698bb98200cfe37fa1fe6040899955aa7aac4c70b0b6f8031cefa3fb78fa71184615f032 + languageName: node + linkType: hard + +"express@npm:^5.2.1": + version: 5.3.0 + resolution: "express@npm:5.3.0" + dependencies: + accepts: ^2.0.0 + body-parser: ^2.3.0 + content-disposition: ^2.0.1 + content-type: ^2.0.0 + cookie: ^0.7.1 + cookie-signature: ^1.2.1 + debug: ^4.4.0 + depd: ^2.0.0 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + etag: ^1.8.1 + finalhandler: ^2.1.0 + fresh: ^2.0.0 + http-errors: ^2.0.0 + merge-descriptors: ^2.0.0 + mime-types: ^3.0.0 + on-finished: ^2.4.1 + once: ^1.4.0 + parseurl: ^1.3.3 + proxy-addr: ^2.0.8 + qs: ^6.16.0 + range-parser: ^1.2.1 + router: ^2.2.0 + send: ^1.1.0 + serve-static: ^2.2.0 + statuses: ^2.0.1 + type-is: ^2.1.0 + vary: ^1.1.2 + checksum: 116f92b78e83de4177a62d05bf998a161dcfe3290f534da4ea66dd68709877281faf37bae42b25816856a236306ed1a0ab7dd815d5272a52bfdafbc8cac1aacc + languageName: node + linkType: hard + "fast-content-type-parse@npm:^1.1.0": version: 1.1.0 resolution: "fast-content-type-parse@npm:1.1.0" @@ -4681,6 +4997,13 @@ __metadata: languageName: node linkType: hard +"fast-uri@npm:^3.0.1": + version: 3.1.9 + resolution: "fast-uri@npm:3.1.9" + checksum: 4f882cff34cb1c4099b07285e5c7a628c62ff06d91f1a5b0aeb12d8ec002080bd2095ce5eec0855949d649dd8aeeff959a6f1db0f584f29b2e3e08fbb99d37fc + languageName: node + linkType: hard + "fast-xml-parser@npm:4.2.5": version: 4.2.5 resolution: "fast-xml-parser@npm:4.2.5" @@ -4750,6 +5073,20 @@ __metadata: languageName: node linkType: hard +"finalhandler@npm:^2.1.0": + version: 2.1.1 + resolution: "finalhandler@npm:2.1.1" + dependencies: + debug: ^4.4.0 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + on-finished: ^2.4.1 + parseurl: ^1.3.3 + statuses: ^2.0.1 + checksum: e5303c4cccce46019cf0f59b07a36cc6d37549f1efe2111c16cd78e6e500d3bfd68d3b45044c9a67a0c75ad3128ee1106fae9a0152ca3c0a8ee3bf3a4a1464bb + languageName: node + linkType: hard + "find-my-way@npm:^7.7.0": version: 7.7.0 resolution: "find-my-way@npm:7.7.0" @@ -4825,6 +5162,13 @@ __metadata: languageName: node linkType: hard +"fresh@npm:^2.0.0": + version: 2.0.0 + resolution: "fresh@npm:2.0.0" + checksum: 38b9828352c6271e2a0dd8bdd985d0100dbbc4eb8b6a03286071dd6f7d96cfaacd06d7735701ad9a95870eb3f4555e67c08db1dcfe24c2e7bb87383c72fae1d2 + languageName: node + linkType: hard + "fs-constants@npm:^1.0.0": version: 1.0.0 resolution: "fs-constants@npm:1.0.0" @@ -4918,6 +5262,13 @@ __metadata: languageName: node linkType: hard +"generator-function@npm:^2.0.0": + version: 2.0.1 + resolution: "generator-function@npm:2.0.1" + checksum: 3bf87f7b0230de5d74529677e6c3ceb3b7b5d9618b5a22d92b45ce3876defbaf5a77791b25a61b0fa7d13f95675b5ff67a7769f3b9af33f096e34653519e873d + languageName: node + linkType: hard + "get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": version: 2.0.2 resolution: "get-func-name@npm:2.0.2" @@ -4950,6 +5301,27 @@ __metadata: languageName: node linkType: hard +"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": + version: 1.3.1 + resolution: "get-intrinsic@npm:1.3.1" + dependencies: + async-function: ^1.0.0 + async-generator-function: ^1.0.0 + call-bind-apply-helpers: ^1.0.2 + es-define-property: ^1.0.1 + es-errors: ^1.3.0 + es-object-atoms: ^1.1.1 + function-bind: ^1.1.2 + generator-function: ^2.0.0 + get-proto: ^1.0.1 + gopd: ^1.2.0 + has-symbols: ^1.1.0 + hasown: ^2.0.2 + math-intrinsics: ^1.1.0 + checksum: c02b3b6a445f9cd53e14896303794ac60f9751f58a69099127248abdb0251957174c6524245fc68579dc8e6a35161d3d94c93e665f808274716f4248b269436a + languageName: node + linkType: hard + "get-port@npm:^5.1.1": version: 5.1.1 resolution: "get-port@npm:5.1.1" @@ -4957,6 +5329,16 @@ __metadata: languageName: node linkType: hard +"get-proto@npm:^1.0.1": + version: 1.0.1 + resolution: "get-proto@npm:1.0.1" + dependencies: + dunder-proto: ^1.0.1 + es-object-atoms: ^1.0.0 + checksum: 4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b + languageName: node + linkType: hard + "get-stream@npm:^8.0.1": version: 8.0.1 resolution: "get-stream@npm:8.0.1" @@ -5109,6 +5491,13 @@ __metadata: languageName: node linkType: hard +"gopd@npm:^1.2.0": + version: 1.2.0 + resolution: "gopd@npm:1.2.0" + checksum: cc6d8e655e360955bdccaca51a12a474268f95bb793fc3e1f2bdadb075f28bfd1fd988dab872daf77a61d78cbaf13744bc8727a17cfb1d150d76047d805375f3 + languageName: node + linkType: hard + "graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -5183,6 +5572,13 @@ __metadata: languageName: node linkType: hard +"has-symbols@npm:^1.1.0": + version: 1.1.0 + resolution: "has-symbols@npm:1.1.0" + checksum: b2316c7302a0e8ba3aaba215f834e96c22c86f192e7310bdf689dd0e6999510c89b00fbc5742571507cebf25764d68c988b3a0da217369a73596191ac0ce694b + languageName: node + linkType: hard + "has-tostringtag@npm:^1.0.0": version: 1.0.0 resolution: "has-tostringtag@npm:1.0.0" @@ -5243,6 +5639,13 @@ __metadata: languageName: node linkType: hard +"hono@npm:^4.11.4": + version: 4.13.13 + resolution: "hono@npm:4.13.13" + checksum: fd60e0f0f5a00fe7ac1a5d853ac14a141e0af396099288d626dff5faadfd5bb43a0ae488af46102a9bb1a1009bd388d07577b78b11ea407d0b7bd501eb0e48c5 + languageName: node + linkType: hard + "html-escaper@npm:^2.0.0": version: 2.0.2 resolution: "html-escaper@npm:2.0.2" @@ -5270,6 +5673,19 @@ __metadata: languageName: node linkType: hard +"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.1": + version: 2.0.1 + resolution: "http-errors@npm:2.0.1" + dependencies: + depd: ~2.0.0 + inherits: ~2.0.4 + setprototypeof: ~1.2.0 + statuses: ~2.0.2 + toidentifier: ~1.0.1 + checksum: 155d1a100a06e4964597013109590b97540a177b69c3600bbc93efc746465a99a2b718f43cdf76b3791af994bbe3a5711002046bf668cdc007ea44cea6df7ccd + languageName: node + linkType: hard + "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -5323,6 +5739,15 @@ __metadata: languageName: node linkType: hard +"iconv-lite@npm:^0.7.2, iconv-lite@npm:~0.7.0": + version: 0.7.3 + resolution: "iconv-lite@npm:0.7.3" + dependencies: + safer-buffer: ">= 2.1.2 < 3.0.0" + checksum: 36b3dc2f5a25c2cac15f7df42a1f23e796f1616b2b4103b205a5c237d9b40483184a8c07bbc2d72b01ca28f4770506fad5a43203adae9c97a5d4733af684b6a8 + languageName: node + linkType: hard + "ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" @@ -5392,7 +5817,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 @@ -5421,6 +5846,13 @@ __metadata: languageName: node linkType: hard +"ip-address@npm:^10.2.0": + version: 10.7.4 + resolution: "ip-address@npm:10.7.4" + checksum: 9caa80552b9ef8cef2d5c194119dcdb4fccaab33cb41074396e4134779ef38451624e30f074364f870f9215c63d29bab33e1198f710cc6539c0879aa2c0d33c9 + languageName: node + linkType: hard + "ip@npm:^2.0.0": version: 2.0.0 resolution: "ip@npm:2.0.0" @@ -5603,6 +6035,13 @@ __metadata: languageName: node linkType: hard +"is-promise@npm:^4.0.0": + version: 4.0.0 + resolution: "is-promise@npm:4.0.0" + checksum: 0b46517ad47b00b6358fd6553c83ec1f6ba9acd7ffb3d30a0bf519c5c69e7147c132430452351b8a9fc198f8dd6c4f76f8e6f5a7f100f8c77d57d9e0f4261a8a + languageName: node + linkType: hard + "is-regex@npm:^1.1.4": version: 1.1.4 resolution: "is-regex@npm:1.1.4" @@ -5769,6 +6208,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" @@ -5833,6 +6279,13 @@ __metadata: languageName: node linkType: hard +"json-schema-typed@npm:^8.0.2": + version: 8.0.2 + resolution: "json-schema-typed@npm:8.0.2" + checksum: 8ddb3c2b1bad406507ea077d4d8cfe6dae5b920f642efab145e4bd6e0d3984f01034ee4467bbeabd51e129b17d7446ceb357a6b1648857fdfaf61ba8ef621ff6 + languageName: node + linkType: hard + "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -6151,6 +6604,27 @@ __metadata: languageName: node linkType: hard +"math-intrinsics@npm:^1.1.0": + version: 1.1.0 + resolution: "math-intrinsics@npm:1.1.0" + checksum: 0e513b29d120f478c85a70f49da0b8b19bc638975eca466f2eeae0071f3ad00454c621bf66e16dd435896c208e719fc91ad79bbfba4e400fe0b372e7c1c9c9a2 + languageName: node + linkType: hard + +"media-typer@npm:^1.1.0": + version: 1.1.1 + resolution: "media-typer@npm:1.1.1" + checksum: ceef972e43912621b846f816e8937c200dcd83d0bdd4fdd19f28be62a9334f4e6605d0c952fe804bfab7f63a07d14c0d1bea1d9357c35ad6d3b04b184f09e397 + languageName: node + linkType: hard + +"merge-descriptors@npm:^2.0.0": + version: 2.0.0 + resolution: "merge-descriptors@npm:2.0.0" + checksum: e383332e700a94682d0125a36c8be761142a1320fc9feeb18e6e36647c9edf064271645f5669b2c21cf352116e561914fd8aa831b651f34db15ef4038c86696a + languageName: node + linkType: hard + "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -6175,6 +6649,22 @@ __metadata: languageName: node linkType: hard +"mime-db@npm:^1.54.0": + version: 1.54.0 + resolution: "mime-db@npm:1.54.0" + checksum: e99aaf2f23f5bd607deb08c83faba5dd25cf2fec90a7cc5b92d8260867ee08dab65312e1a589e60093dc7796d41e5fae013268418482f1db4c7d52d0a0960ac9 + languageName: node + linkType: hard + +"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": + version: 3.0.2 + resolution: "mime-types@npm:3.0.2" + dependencies: + mime-db: ^1.54.0 + checksum: 70b74794f408419e4b6a8e3c93ccbed79b6a6053973a3957c5cc04ff4ad8d259f0267da179e3ecae34c3edfb4bfd7528db23a101e32d21ad8e196178c8b7b75a + languageName: node + linkType: hard + "mime@npm:^3.0.0": version: 3.0.0 resolution: "mime@npm:3.0.0" @@ -6399,7 +6889,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1": +"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -6454,6 +6944,15 @@ __metadata: languageName: node linkType: hard +"negotiator@npm:^1.0.0": + version: 1.1.0 + resolution: "negotiator@npm:1.1.0" + dependencies: + content-type: ^2.1.0 + checksum: 5c779870e2c6c863eed97c136328ee54bbd179bc79c1474415354c11ec343652b3239805fc7d07dec320ee6fa138dd28b864c2a5b9853c350a56622d30a169d5 + languageName: node + linkType: hard + "node-fetch@npm:^2.6.12, node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -6550,6 +7049,7 @@ __metadata: "@fastify/oauth2": ^7.2.1 "@fastify/swagger": ^8.8.0 "@fastify/swagger-ui": ^1.9.3 + "@modelcontextprotocol/sdk": ^1.32.1 "@testcontainers/localstack": ^10.9.0 "@testcontainers/postgresql": ^10.2.1 "@types/jsonwebtoken": ^9.0.2 @@ -6580,7 +7080,7 @@ __metadata: typescript: ^5.0.4 typescript-eslint: ^7.6.0 vitest: ^1.4.0 - zod: ^3.21.4 + zod: ^3.25.0 languageName: unknown linkType: soft @@ -6614,6 +7114,13 @@ __metadata: languageName: node linkType: hard +"object-assign@npm:^4": + version: 4.1.1 + resolution: "object-assign@npm:4.1.1" + checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f + languageName: node + linkType: hard + "object-inspect@npm:^1.12.3, object-inspect@npm:^1.13.1, object-inspect@npm:^1.9.0": version: 1.13.1 resolution: "object-inspect@npm:1.13.1" @@ -6621,6 +7128,13 @@ __metadata: languageName: node linkType: hard +"object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": + version: 1.13.4 + resolution: "object-inspect@npm:1.13.4" + checksum: 582810c6a8d2ef988ea0a39e69e115a138dad8f42dd445383b394877e5816eb4268489f316a6f74ee9c4e0a984b3eab1028e3e79d62b1ed67c726661d55c7a8b + languageName: node + linkType: hard + "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -6707,6 +7221,15 @@ __metadata: languageName: node linkType: hard +"on-finished@npm:^2.4.1": + version: 2.4.1 + resolution: "on-finished@npm:2.4.1" + dependencies: + ee-first: 1.1.1 + checksum: d20929a25e7f0bb62f937a425b5edeb4e4cde0540d77ba146ec9357f00b0d497cdb3b9b05b9c8e46222407d1548d08166bff69cc56dfa55ba0e4469228920ff0 + languageName: node + linkType: hard + "once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -6823,6 +7346,13 @@ __metadata: languageName: node linkType: hard +"parseurl@npm:^1.3.3": + version: 1.3.3 + resolution: "parseurl@npm:1.3.3" + checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 + languageName: node + linkType: hard + "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -6875,6 +7405,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:^8.0.0": + version: 8.4.2 + resolution: "path-to-regexp@npm:8.4.2" + checksum: c30fba443e413cc736b7b28056a4b60b537ae1caa80152da21e8093bd41deba7c408a4ac6f11a1bf594e089d8fd8d87ed31476c55c50983719fb355826370ade + languageName: node + linkType: hard + "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -7100,6 +7637,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" @@ -7315,6 +7859,16 @@ __metadata: languageName: node linkType: hard +"proxy-addr@npm:^2.0.8": + version: 2.0.8 + resolution: "proxy-addr@npm:2.0.8" + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + checksum: 1878fa24a104ac3833f55583918964fbdcc6eedc076450548633dc1b4321e89947a9e93fc61d0c931712d3c46197de18855bc96a4feb73ba7bd4890a45508649 + languageName: node + linkType: hard + "pstree.remy@npm:^1.1.8": version: 1.1.8 resolution: "pstree.remy@npm:1.1.8" @@ -7339,6 +7893,16 @@ __metadata: languageName: node linkType: hard +"qs@npm:^6.15.2, qs@npm:^6.16.0": + version: 6.16.1 + resolution: "qs@npm:6.16.1" + dependencies: + es-define-property: ^1.0.1 + side-channel: ^1.1.1 + checksum: 56d617a5e3ea475eaafd1e860a66c0aa362c0d58c23e787ad71a137d67fb52fd9a9fff6aea2d38612443f24825fb23ea4d01bb7222e9d2ac32a5b6007027747c + languageName: node + linkType: hard + "queue-lit@npm:^1.5.1": version: 1.5.2 resolution: "queue-lit@npm:1.5.2" @@ -7367,6 +7931,25 @@ __metadata: languageName: node linkType: hard +"range-parser@npm:^1.2.1": + version: 1.3.0 + resolution: "range-parser@npm:1.3.0" + checksum: 0277bec278ed3d8f9c27d75d8b1615750768bf984c8fbb04efcc238a705fd0950e3b0c6ec54277106f96ef566d8984f9dd5bf68841943375a2d94069ecdba145 + languageName: node + linkType: hard + +"raw-body@npm:^3.0.0, raw-body@npm:^3.0.2": + version: 3.0.2 + resolution: "raw-body@npm:3.0.2" + dependencies: + bytes: ~3.1.2 + http-errors: ~2.0.1 + iconv-lite: ~0.7.0 + unpipe: ~1.0.0 + checksum: bf8ce8e9734f273f24d81f9fed35609dbd25c2869faa5fb5075f7ee225c0913e2240adda03759d7e72f2a757f8012d58bb7a871a80261d5140ad65844caeb5bd + languageName: node + linkType: hard + "react-is@npm:^18.0.0": version: 18.2.0 resolution: "react-is@npm:18.2.0" @@ -7608,6 +8191,19 @@ __metadata: languageName: node linkType: hard +"router@npm:^2.2.0": + version: 2.2.0 + resolution: "router@npm:2.2.0" + dependencies: + debug: ^4.4.0 + depd: ^2.0.0 + is-promise: ^4.0.0 + parseurl: ^1.3.3 + path-to-regexp: ^8.0.0 + checksum: 4c3bec8011ed10bb07d1ee860bc715f245fff0fdff991d8319741d2932d89c3fe0a56766b4fa78e95444bc323fd2538e09c8e43bfbd442c2a7fab67456df7fa5 + languageName: node + linkType: hard + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -7756,6 +8352,25 @@ __metadata: languageName: node linkType: hard +"send@npm:^1.1.0, send@npm:^1.2.0": + version: 1.2.1 + resolution: "send@npm:1.2.1" + dependencies: + debug: ^4.4.3 + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + etag: ^1.8.1 + fresh: ^2.0.0 + http-errors: ^2.0.1 + mime-types: ^3.0.2 + ms: ^2.1.3 + on-finished: ^2.4.1 + range-parser: ^1.2.1 + statuses: ^2.0.2 + checksum: 5361e3556fbc874c080a4cfbb4541e02c16221ca3c68c4f692320d38ef7e147381f805ce3ac50dfaa2129f07daa81098e2bc567e9a4d13993a92893d59a64d68 + languageName: node + linkType: hard + "sequelize-pool@npm:^7.1.0": version: 7.1.0 resolution: "sequelize-pool@npm:7.1.0" @@ -7806,6 +8421,18 @@ __metadata: languageName: node linkType: hard +"serve-static@npm:^2.2.0": + version: 2.2.1 + resolution: "serve-static@npm:2.2.1" + dependencies: + encodeurl: ^2.0.0 + escape-html: ^1.0.3 + parseurl: ^1.3.3 + send: ^1.2.0 + checksum: dd71e9a316a7d7f726503973c531168cfa6a6a56a98d5c6b279c4d0d41a83a1bc6900495dc0633712b95d88ccbf9ed4f4a780a4c4c00bf84b496e9e710d68825 + languageName: node + linkType: hard + "set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -7869,7 +8496,7 @@ __metadata: languageName: node linkType: hard -"setprototypeof@npm:1.2.0": +"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89 @@ -7892,6 +8519,41 @@ __metadata: languageName: node linkType: hard +"side-channel-list@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-list@npm:1.0.1" + dependencies: + es-errors: ^1.3.0 + object-inspect: ^1.13.4 + checksum: 3499671cd52adaee739eac1e14d07530b8e3530192741aeb05e7fe4ad1b51d1368ceea2cd3c21b0f62b05410a5c70a7c4d997ba4b143303ef73d0c65dfd1c252 + languageName: node + linkType: hard + +"side-channel-map@npm:^1.0.1": + version: 1.0.1 + resolution: "side-channel-map@npm:1.0.1" + dependencies: + call-bound: ^1.0.2 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.5 + object-inspect: ^1.13.3 + checksum: 42501371cdf71f4ccbbc9c9e2eb00aaaab80a4c1c429d5e8da713fd4d39ef3b8d4a4b37ed4f275798a65260a551a7131fd87fe67e922dba4ac18586d6aab8b06 + languageName: node + linkType: hard + +"side-channel-weakmap@npm:^1.0.2": + version: 1.0.2 + resolution: "side-channel-weakmap@npm:1.0.2" + dependencies: + call-bound: ^1.0.2 + es-errors: ^1.3.0 + get-intrinsic: ^1.2.5 + object-inspect: ^1.13.3 + side-channel-map: ^1.0.1 + checksum: a815c89bc78c5723c714ea1a77c938377ea710af20d4fb886d362b0d1f8ac73a17816a5f6640f354017d7e292a43da9c5e876c22145bac00b76cfb3468001736 + languageName: node + linkType: hard + "side-channel@npm:^1.0.4": version: 1.0.4 resolution: "side-channel@npm:1.0.4" @@ -7903,6 +8565,19 @@ __metadata: languageName: node linkType: hard +"side-channel@npm:^1.1.1": + version: 1.1.1 + resolution: "side-channel@npm:1.1.1" + dependencies: + es-errors: ^1.3.0 + object-inspect: ^1.13.4 + side-channel-list: ^1.0.1 + side-channel-map: ^1.0.1 + side-channel-weakmap: ^1.0.2 + checksum: e0f217140c463636ee556260bbc8f47ba2931f1248826f58e1502704135814c763b325dd9ab122a04176aa45b4a4f8cc556415bcab7a0179d85250fb7812f063 + languageName: node + linkType: hard + "siginfo@npm:^2.0.0": version: 2.0.0 resolution: "siginfo@npm:2.0.0" @@ -8098,6 +8773,13 @@ __metadata: languageName: node linkType: hard +"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.2": + version: 2.0.2 + resolution: "statuses@npm:2.0.2" + checksum: 6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 + languageName: node + linkType: hard + "std-env@npm:^3.3.3": version: 3.4.3 resolution: "std-env@npm:3.4.3" @@ -8542,7 +9224,7 @@ __metadata: languageName: node linkType: hard -"toidentifier@npm:1.0.1": +"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 @@ -8695,6 +9377,17 @@ __metadata: languageName: node linkType: hard +"type-is@npm:^2.1.0": + version: 2.1.0 + resolution: "type-is@npm:2.1.0" + dependencies: + content-type: ^2.0.0 + media-typer: ^1.1.0 + mime-types: ^3.0.0 + checksum: 214a44fc635fd1396bb656af1f0392d151e44e31f622ca2d030f4b4bb4f20069dab250c6798a6c6f4b215f87cd6f68099cbfdb0975696c3383f7a4cad26f6174 + languageName: node + linkType: hard + "typed-array-buffer@npm:^1.0.0": version: 1.0.0 resolution: "typed-array-buffer@npm:1.0.0" @@ -8909,6 +9602,13 @@ __metadata: languageName: node linkType: hard +"unpipe@npm:~1.0.0": + version: 1.0.0 + resolution: "unpipe@npm:1.0.0" + checksum: 4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2 + languageName: node + linkType: hard + "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -8968,6 +9668,13 @@ __metadata: languageName: node linkType: hard +"vary@npm:^1, vary@npm:^1.1.2": + version: 1.1.2 + resolution: "vary@npm:1.1.2" + checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b + languageName: node + linkType: hard + "vite-node@npm:1.4.0": version: 1.4.0 resolution: "vite-node@npm:1.4.0" @@ -9289,9 +9996,25 @@ __metadata: languageName: node linkType: hard -"zod@npm:^3.21.4": - version: 3.22.4 - resolution: "zod@npm:3.22.4" - checksum: 80bfd7f8039b24fddeb0718a2ec7c02aa9856e4838d6aa4864335a047b6b37a3273b191ef335bf0b2002e5c514ef261ffcda5a589fb084a48c336ffc4cdbab7f +"zod-to-json-schema@npm:^3.25.1": + version: 3.25.2 + resolution: "zod-to-json-schema@npm:3.25.2" + peerDependencies: + zod: ^3.25.28 || ^4 + checksum: 8b728476374a38711e0ca5c066be16ed8facf5a5eb5a76f371b0062e5b6b11f906f446c616d6879b9a4d373738c74cd26e4dfe1d460e7fb9a155a3fe9d04b690 + languageName: node + linkType: hard + +"zod@npm:^3.25 || ^4.0": + version: 4.6.7 + resolution: "zod@npm:4.6.7" + checksum: 6801f5067dd43204d5ed2b4f2f0d09f891365ecfdd385877901e42f6f6ebf67e8943b385c71eaba53081029dd21f4740f16c91497a06f95195e209bffef92056 + languageName: node + linkType: hard + +"zod@npm:^3.25.0": + version: 3.25.76 + resolution: "zod@npm:3.25.76" + checksum: c9a403a62b329188a5f6bd24d5d935d2bba345f7ab8151d1baa1505b5da9f227fb139354b043711490c798e91f3df75991395e40142e6510a4b16409f302b849 languageName: node linkType: hard From 7b02bbc8c4e1799ef0e8a8f41d5ee3a98b1e2520 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 21 Oct 2026 03:07:33 +0300 Subject: [PATCH 2/6] docs: mark MCP plan step 3 done Step 3 (MCP tokens section in notes.web settings) is done on feat/mcp-tokens in notes.web. Record its deviations and the manual checks of the endpoint over real HTTP and of the settings page. Co-Authored-By: Claude Opus 5.5 --- docs/mcp-plan.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/mcp-plan.md b/docs/mcp-plan.md index e8e9ccc5..7ebb4c0b 100644 --- a/docs/mcp-plan.md +++ b/docs/mcp-plan.md @@ -2,7 +2,7 @@ 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 (notes.web) is not started. +**Status:** steps 1 and 2 are done on `feat/mcp-server`; step 3 is done on `feat/mcp-tokens` in notes.web. ## Decisions @@ -88,11 +88,11 @@ Living document. Update the status checkboxes and the "Deviations" section as th ## Step 3. Settings in notes.web -- [ ] New "MCP" fieldset in `Settings.vue`: +- [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). -- [ ] Layers: entity, notes-api transport methods, service, ru/en texts. +- [x] Layers: entity, notes-api transport methods, service, ru/en texts. Setup snippets for the UI: @@ -117,7 +117,11 @@ Separate fix, not part of this work: `POST /note` with `parentId` does not check - Step 2: the transport runs with `enableJsonResponse: true`: responses are plain JSON, no SSE streams. - 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: tests cover the MCP protocol through `fastify.inject()`; the endpoint has not been tried with a real MCP client yet. +- 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 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 `useMcpTokens` composable; `Settings.vue` only renders it. +- 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 From c3555c255362e27fba5c0b5b349619b6c3abb73e Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 21 Oct 2026 03:24:23 +0300 Subject: [PATCH 3/6] fix: prevent concurrent MCP note updates from overwriting each other - PATCH /note/:notePublicId accepts an optional expectedUpdatedAt. The note is updated only if updated_at still matches, checked in the same UPDATE query (millisecond precision), otherwise the route returns 409. Note history is written after a successful update. - update_note always passes the version it read, so concurrent replaces and appends get a conflict instead of silently losing changes. - /mcp copies headers set by Fastify hooks to the raw response, so CORS headers survive reply.hijack(). Co-Authored-By: Claude Opus 5.5 --- docs/mcp-plan.md | 2 + .../entities/NoteUpdateConflictError.ts | 13 ++++ src/domain/service/note.ts | 25 +++++-- src/presentation/http/router/note.test.ts | 75 +++++++++++++++++++ src/presentation/http/router/note.ts | 33 ++++++-- src/presentation/mcp/index.ts | 9 +++ src/presentation/mcp/mcp.test.ts | 51 +++++++++++++ src/presentation/mcp/tools/updateNote.ts | 29 +++++-- src/repository/note.repository.ts | 7 +- .../storage/postgres/orm/sequelize/note.ts | 14 +++- 10 files changed, 233 insertions(+), 25 deletions(-) create mode 100644 src/domain/entities/NoteUpdateConflictError.ts diff --git a/docs/mcp-plan.md b/docs/mcp-plan.md index 7ebb4c0b..a8182ad9 100644 --- a/docs/mcp-plan.md +++ b/docs/mcp-plan.md @@ -118,6 +118,8 @@ Separate fix, not part of this work: `POST /note` with `parentId` does not check - 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 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 `useMcpTokens` composable; `Settings.vue` only renders it. - Step 3: the "Create token" button sits below the form card, like the "Logout" button in the General section. 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/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/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/mcp/index.ts b/src/presentation/mcp/index.ts index 5f10bd68..6572830c 100644 --- a/src/presentation/mcp/index.ts +++ b/src/presentation/mcp/index.ts @@ -75,6 +75,15 @@ const McpRouter: FastifyPluginCallback = (fastify, opts, done) */ 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); + } + } + reply.raw.on('close', () => { void transport.close(); void server.close(); diff --git a/src/presentation/mcp/mcp.test.ts b/src/presentation/mcp/mcp.test.ts index 62d782a1..7ed77610 100644 --- a/src/presentation/mcp/mcp.test.ts +++ b/src/presentation/mcp/mcp.test.ts @@ -151,6 +151,29 @@ describe('MCP API', () => { 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', @@ -375,6 +398,34 @@ describe('MCP API', () => { 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'); diff --git a/src/presentation/mcp/tools/updateNote.ts b/src/presentation/mcp/tools/updateNote.ts index d136ba87..cff1d38c 100644 --- a/src/presentation/mcp/tools/updateNote.ts +++ b/src/presentation/mcp/tools/updateNote.ts @@ -7,6 +7,8 @@ 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 @@ -49,12 +51,27 @@ export function registerUpdateNoteTool(server: McpServer, context: McpToolContex const contentBlocks = mode === 'append' ? [...note.content.blocks, ...newBlocks] : newBlocks; const { data: userTools } = await context.api.get<{ data: EditorTool[] }>('/user/editor-tools'); - const { updatedAt } = await context.api.patch<{ updatedAt: Note['updatedAt'] }>(`/note/${publicId}`, { - content: { - blocks: contentBlocks, - }, - tools: getToolsForBlocks(contentBlocks, [...noteTools, ...userTools]), - }); + 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, 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/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, }); From 63792829700849c8de6795c110f2020c7cb8162a Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Wed, 21 Oct 2026 03:28:36 +0300 Subject: [PATCH 4/6] docs: update MCP plan with notes.web review renames Co-Authored-By: Claude Opus 5.5 --- docs/mcp-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/mcp-plan.md b/docs/mcp-plan.md index a8182ad9..0afacf58 100644 --- a/docs/mcp-plan.md +++ b/docs/mcp-plan.md @@ -121,7 +121,7 @@ Separate fix, not part of this work: `POST /note` with `parentId` does not check - 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 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 `useMcpTokens` composable; `Settings.vue` only renders it. +- 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. From d9ee5cbe6e9926d66405358bfac74accbedb6733 Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Mon, 26 Oct 2026 23:23:35 +0300 Subject: [PATCH 5/6] feat: serve MCP protocol revision 2026-07-28 with SDK v2 Move /mcp from @modelcontextprotocol/sdk v1 to @modelcontextprotocol/server and @modelcontextprotocol/node. createMcpHandler serves 2026-07-28 clients and falls back to stateless serving for 2025-era clients on the same endpoint. The resolved MCP token reaches the server factory via authInfo. SDK v2 requires zod 4 and Node 20+. Tests connect a real SDK v2 client in both eras. --- eslint.config.mjs | 5 +- package.json | 6 +- src/presentation/mcp/content.ts | 4 +- src/presentation/mcp/index.ts | 105 ++-- src/presentation/mcp/mcp.test.ts | 129 +++- src/presentation/mcp/server.ts | 2 +- src/presentation/mcp/tools/createNote.ts | 6 +- src/presentation/mcp/tools/getNote.ts | 6 +- src/presentation/mcp/tools/listNotes.ts | 6 +- src/presentation/mcp/tools/updateNote.ts | 6 +- src/presentation/mcp/types.ts | 2 +- yarn.lock | 723 ++--------------------- 12 files changed, 257 insertions(+), 743 deletions(-) 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/package.json b/package.json index 01937d25..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,7 +48,8 @@ "@fastify/oauth2": "^7.2.1", "@fastify/swagger": "^8.8.0", "@fastify/swagger-ui": "^1.9.3", - "@modelcontextprotocol/sdk": "^1.32.1", + "@modelcontextprotocol/node": "^2.1.2", + "@modelcontextprotocol/server": "^2.4.0", "@testcontainers/postgresql": "^10.2.1", "arg": "^5.0.2", "fastify": "^4.17.0", @@ -61,7 +63,7 @@ "prom-client": "^14.2.0", "sequelize": "^6.31.1", "testcontainers": "^10.2.1", - "zod": "^3.25.0" + "zod": "^4.2.0" }, "packageManager": "yarn@3.6.4" } diff --git a/src/presentation/mcp/content.ts b/src/presentation/mcp/content.ts index 2a2e9251..2a5ac3c4 100644 --- a/src/presentation/mcp/content.ts +++ b/src/presentation/mcp/content.ts @@ -22,8 +22,8 @@ export const blockInputSchema = z.object({ .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.unknown()).describe('Block data, format depends on the block type'), - tunes: z.record(z.unknown()).optional() + 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'), }); diff --git a/src/presentation/mcp/index.ts b/src/presentation/mcp/index.ts index 6572830c..6244db45 100644 --- a/src/presentation/mcp/index.ts +++ b/src/presentation/mcp/index.ts @@ -1,17 +1,15 @@ import type { FastifyPluginCallback, FastifyReply } from 'fastify'; -import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; +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'; -/** - * JSON-RPC error code for internal server errors - */ -const JSON_RPC_INTERNAL_ERROR = -32603; - /** * Interface for the MCP router */ @@ -33,8 +31,34 @@ interface McpRouterOptions { } /** - * MCP router plugin. Serves MCP over Streamable HTTP in stateless mode: - * every request creates its own MCP server for the owner of the passed MCP token + * 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 @@ -42,16 +66,11 @@ interface McpRouterOptions { const McpRouter: FastifyPluginCallback = (fastify, opts, done) => { const { mcpTokenService, authService, webUrl } = opts; - /** - * Handle MCP JSON-RPC messages - */ - fastify.post('/', async (request, reply) => { - const logger = getRequestLogger('appServer').child({ router: 'mcp' }); - const token = request.headers.authorization?.replace('Bearer ', '') ?? ''; - const mcpToken = await mcpTokenService.resolveToken(token); + const handler = createMcpHandler(({ authInfo }) => { + const mcpToken = authInfo?.extra?.mcpToken as McpToken | undefined; - if (mcpToken === null) { - return await reply.unauthorized('Valid MCP token is required'); + if (mcpToken === undefined) { + throw new Error('MCP token is not resolved'); } /** @@ -59,19 +78,34 @@ const McpRouter: FastifyPluginCallback = (fastify, opts, done) */ const api = new NotexApi(fastify, authService.signAccessToken({ id: mcpToken.userId })); - const server = createMcpServer({ + 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); - const transport = new StreamableHTTPServerTransport({ - sessionIdGenerator: undefined, - enableJsonResponse: true, - }); + if (mcpToken === null) { + return await reply.unauthorized('Valid MCP token is required'); + } /** - * Transport writes the response itself + * MCP handler writes the response itself */ void reply.hijack(); @@ -84,30 +118,7 @@ const McpRouter: FastifyPluginCallback = (fastify, opts, done) } } - reply.raw.on('close', () => { - void transport.close(); - void server.close(); - }); - - try { - await server.connect(transport); - await transport.handleRequest(request.raw, reply.raw, request.body); - } catch (error) { - logger.error(error); - - if (!reply.raw.headersSent) { - reply.raw.setHeader('content-type', 'application/json'); - reply.raw.writeHead(StatusCodes.INTERNAL_SERVER_ERROR); - reply.raw.end(JSON.stringify({ - jsonrpc: '2.0', - error: { - code: JSON_RPC_INTERNAL_ERROR, - message: 'Internal server error', - }, - id: null, - })); - } - } + await handleMcpRequest(Object.assign(request.raw, { auth: toAuthInfo(token, mcpToken) }), reply.raw, request.body); }); /** diff --git a/src/presentation/mcp/mcp.test.ts b/src/presentation/mcp/mcp.test.ts index 7ed77610..c31c89a0 100644 --- a/src/presentation/mcp/mcp.test.ts +++ b/src/presentation/mcp/mcp.test.ts @@ -1,5 +1,7 @@ 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'; /** @@ -53,10 +55,52 @@ async function sendMcpRequest(token: string | undefined, method: string, params: } /** - * Parsed JSON of the tool result. Its shape depends on the tool, so tests access it freely + * 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 ToolResultData = 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 @@ -65,15 +109,30 @@ type ToolResultData = any; * @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: ToolResultData }> { +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 } = response!.json<{ result: { isError?: boolean; content: { text: string }[] } }>(); + 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: ToolResultData = null; + let data: ParsedJson = null; try { data = JSON.parse(text); @@ -199,7 +258,7 @@ describe('MCP API', () => { }); expect(response?.statusCode).toBe(200); - expect(response?.json().result.serverInfo.name).toBe('notex'); + expect(readMcpMessage(response).result.serverInfo.name).toBe('notex'); }); test.each([ @@ -217,7 +276,7 @@ describe('MCP API', () => { const response = await sendMcpRequest(token, 'tools/list'); - const toolNames = response?.json().result.tools.map((tool: { name: string }) => tool.name).sort(); + const toolNames = readMcpMessage(response).result.tools.map((tool: { name: string }) => tool.name).sort(); expect(toolNames).toStrictEqual(expectedTools); }); @@ -260,6 +319,62 @@ describe('MCP API', () => { }); }); + 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(); diff --git a/src/presentation/mcp/server.ts b/src/presentation/mcp/server.ts index 2f694fdb..ba2e7c2b 100644 --- a/src/presentation/mcp/server.ts +++ b/src/presentation/mcp/server.ts @@ -1,4 +1,4 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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'; diff --git a/src/presentation/mcp/tools/createNote.ts b/src/presentation/mcp/tools/createNote.ts index 4b759573..40c270c4 100644 --- a/src/presentation/mcp/tools/createNote.ts +++ b/src/presentation/mcp/tools/createNote.ts @@ -1,4 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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'; @@ -16,11 +16,11 @@ export function registerCreateNoteTool(server: McpServer, context: McpToolContex 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: { + 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, diff --git a/src/presentation/mcp/tools/getNote.ts b/src/presentation/mcp/tools/getNote.ts index f7455e80..564c4de4 100644 --- a/src/presentation/mcp/tools/getNote.ts +++ b/src/presentation/mcp/tools/getNote.ts @@ -1,4 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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'; @@ -29,9 +29,9 @@ export function registerGetNoteTool(server: McpServer, context: McpToolContext): 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: { + inputSchema: z.object({ note: z.string().describe('Note URL (e.g. https://notex.so/note/) or note id'), - }, + }), annotations: { readOnlyHint: true, }, diff --git a/src/presentation/mcp/tools/listNotes.ts b/src/presentation/mcp/tools/listNotes.ts index cc37abb0..a0817283 100644 --- a/src/presentation/mcp/tools/listNotes.ts +++ b/src/presentation/mcp/tools/listNotes.ts @@ -1,4 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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'; @@ -69,7 +69,7 @@ export function registerListNotesTool(server: McpServer, context: McpToolContext 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: { + 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() @@ -79,7 +79,7 @@ export function registerListNotesTool(server: McpServer, context: McpToolContext .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, }, diff --git a/src/presentation/mcp/tools/updateNote.ts b/src/presentation/mcp/tools/updateNote.ts index cff1d38c..e32f529f 100644 --- a/src/presentation/mcp/tools/updateNote.ts +++ b/src/presentation/mcp/tools/updateNote.ts @@ -1,4 +1,4 @@ -import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +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'; @@ -21,14 +21,14 @@ export function registerUpdateNoteTool(server: McpServer, context: McpToolContex 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: { + 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, diff --git a/src/presentation/mcp/types.ts b/src/presentation/mcp/types.ts index c550dc9f..eccb2923 100644 --- a/src/presentation/mcp/types.ts +++ b/src/presentation/mcp/types.ts @@ -1,4 +1,4 @@ -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import type { CallToolResult } from '@modelcontextprotocol/server'; import type NotexApi from './notexApi.js'; /** diff --git a/yarn.lock b/yarn.lock index a29fe827..f09e9208 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1182,7 +1182,7 @@ __metadata: languageName: node linkType: hard -"@hono/node-server@npm:^1.19.9 || ^2.0.5": +"@hono/node-server@npm:^1.19.9 || ^2.1.3": version: 2.1.5 resolution: "@hono/node-server@npm:2.1.5" peerDependencies: @@ -1319,36 +1319,49 @@ __metadata: languageName: node linkType: hard -"@modelcontextprotocol/sdk@npm:^1.32.1": - version: 1.32.1 - resolution: "@modelcontextprotocol/sdk@npm:1.32.1" +"@modelcontextprotocol/client@npm:^2.4.0": + version: 2.4.0 + resolution: "@modelcontextprotocol/client@npm:2.4.0" dependencies: - "@hono/node-server": ^1.19.9 || ^2.0.5 - ajv: ^8.17.1 - ajv-formats: ^3.0.1 - content-type: ^1.0.5 - cors: ^2.8.5 + "@modelcontextprotocol/core": 2.4.0 cross-spawn: ^7.0.5 eventsource: ^3.0.2 - eventsource-parser: ^3.0.0 - express: ^5.2.1 - express-rate-limit: ^8.2.1 - hono: ^4.11.4 + eventsource-parser: ^3.0.8 jose: ^6.1.3 - json-schema-typed: ^8.0.2 pkce-challenge: ^5.0.0 - raw-body: ^3.0.0 - zod: ^3.25 || ^4.0 - zod-to-json-schema: ^3.25.1 + 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: - "@cfworker/json-schema": ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - "@cfworker/json-schema": - optional: true - zod: - optional: false - checksum: c1a5c5773d2353e457d818363cead3dc27289a8d753002d48725e5aecbb109349fdd3df9970c94b663866b332e8a59126a334f3e2d58bb5e2da67fb9580d20eb + "@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 @@ -2734,16 +2747,6 @@ __metadata: languageName: node linkType: hard -"accepts@npm:^2.0.0": - version: 2.0.0 - resolution: "accepts@npm:2.0.0" - dependencies: - mime-types: ^3.0.0 - negotiator: ^1.0.0 - checksum: 49fe6c050cb6f6ff4e771b4d88324fca4d3127865f2473872e818dca127d809ba3aa8fdfc7acb51dd3c5bade7311ca6b8cfff7015ea6db2f7eb9c8444d223a4f - languageName: node - linkType: hard - "acorn-jsx@npm:^5.3.2": version: 5.3.2 resolution: "acorn-jsx@npm:5.3.2" @@ -2827,20 +2830,6 @@ __metadata: languageName: node linkType: hard -"ajv-formats@npm:^3.0.1": - version: 3.0.1 - resolution: "ajv-formats@npm:3.0.1" - dependencies: - ajv: ^8.0.0 - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - checksum: f4e1fe232d67fcafc02eafe373a7a9962351e0439dd0736647ca75c93c3da23b430b6502c255ab4315410ae330d4f3013ac9fe226c40b2524ca93a58e786d086 - languageName: node - linkType: hard - "ajv@npm:^6.12.4": version: 6.12.6 resolution: "ajv@npm:6.12.6" @@ -2865,18 +2854,6 @@ __metadata: languageName: node linkType: hard -"ajv@npm:^8.17.1": - version: 8.20.0 - resolution: "ajv@npm:8.20.0" - dependencies: - fast-deep-equal: ^3.1.3 - fast-uri: ^3.0.1 - json-schema-traverse: ^1.0.0 - require-from-string: ^2.0.2 - checksum: 4f18ca5fcccff8c8b9a4d6f1a6a3a70ffc888e787624ca66f2d0162e04e8f9f2d289d8a1acdb9520d18bffcee975e7a789a9f8292b5c2c577c408cd2c3308cad - languageName: node - linkType: hard - "ansi-regex@npm:^5.0.1": version: 5.0.1 resolution: "ansi-regex@npm:5.0.1" @@ -3153,20 +3130,6 @@ __metadata: languageName: node linkType: hard -"async-function@npm:^1.0.0": - version: 1.0.0 - resolution: "async-function@npm:1.0.0" - checksum: 9102e246d1ed9b37ac36f57f0a6ca55226876553251a31fc80677e71471f463a54c872dc78d5d7f80740c8ba624395cccbe8b60f7b690c4418f487d8e9fd1106 - languageName: node - linkType: hard - -"async-generator-function@npm:^1.0.0": - version: 1.0.0 - resolution: "async-generator-function@npm:1.0.0" - checksum: 74a71a4a2dd7afd06ebb612f6d612c7f4766a351bedffde466023bf6dae629e46b0d2cd38786239e0fbf245de0c7df76035465e16d1213774a0efb22fec0d713 - languageName: node - linkType: hard - "async-lock@npm:^1.4.0": version: 1.4.0 resolution: "async-lock@npm:1.4.0" @@ -3320,23 +3283,6 @@ __metadata: languageName: node linkType: hard -"body-parser@npm:^2.3.0": - version: 2.3.0 - resolution: "body-parser@npm:2.3.0" - dependencies: - bytes: ^3.1.2 - content-type: ^2.0.0 - debug: ^4.4.3 - http-errors: ^2.0.1 - iconv-lite: ^0.7.2 - on-finished: ^2.4.1 - qs: ^6.15.2 - raw-body: ^3.0.2 - type-is: ^2.1.0 - checksum: 44d681aaa2d6ffe3efb4f6c098e7c4f7beae0d158580f7abea1cd487fc05b485488815f5c6fc71aa05975541c00042b4becdb485c441ddd63ce9c71b63b7ea4d - languageName: node - linkType: hard - "boolbase@npm:^1.0.0": version: 1.0.0 resolution: "boolbase@npm:1.0.0" @@ -3441,13 +3387,6 @@ __metadata: languageName: node linkType: hard -"bytes@npm:^3.1.2, bytes@npm:~3.1.2": - version: 3.1.2 - resolution: "bytes@npm:3.1.2" - checksum: e4bcd3948d289c5127591fbedf10c0b639ccbf00243504e4e127374a15c3bc8eed0d28d4aaab08ff6f1cf2abc0cce6ba3085ed32f4f90e82a5683ce0014e1b6e - languageName: node - linkType: hard - "cac@npm:^6.7.14": version: 6.7.14 resolution: "cac@npm:6.7.14" @@ -3475,16 +3414,6 @@ __metadata: languageName: node linkType: hard -"call-bind-apply-helpers@npm:^1.0.1, call-bind-apply-helpers@npm:^1.0.2": - version: 1.0.2 - resolution: "call-bind-apply-helpers@npm:1.0.2" - dependencies: - es-errors: ^1.3.0 - function-bind: ^1.1.2 - checksum: b2863d74fcf2a6948221f65d95b91b4b2d90cfe8927650b506141e669f7d5de65cea191bf788838bc40d13846b7886c5bc5c84ab96c3adbcf88ad69a72fcdc6b - languageName: node - linkType: hard - "call-bind@npm:^1.0.0, call-bind@npm:^1.0.2, call-bind@npm:^1.0.4": version: 1.0.5 resolution: "call-bind@npm:1.0.5" @@ -3509,16 +3438,6 @@ __metadata: languageName: node linkType: hard -"call-bound@npm:^1.0.2": - version: 1.0.4 - resolution: "call-bound@npm:1.0.4" - dependencies: - call-bind-apply-helpers: ^1.0.2 - get-intrinsic: ^1.3.0 - checksum: 2f6399488d1c272f56306ca60ff696575e2b7f31daf23bc11574798c84d9f2759dceb0cb1f471a85b77f28962a7ac6411f51d283ea2e45319009a19b6ccab3b2 - languageName: node - linkType: hard - "callsites@npm:^3.0.0": version: 3.1.0 resolution: "callsites@npm:3.1.0" @@ -3681,27 +3600,6 @@ __metadata: languageName: node linkType: hard -"content-disposition@npm:^2.0.1": - version: 2.0.1 - resolution: "content-disposition@npm:2.0.1" - checksum: 2e25ab5937b5b281aecc44ee28022f2484a577fa32800977dd830c34740fa87c88061cf9fd25c794b0a66879077ca90fe6b73026d775d4b2c5e188b8f0f60f42 - languageName: node - linkType: hard - -"content-type@npm:^1.0.5": - version: 1.0.5 - resolution: "content-type@npm:1.0.5" - checksum: 566271e0a251642254cde0f845f9dd4f9856e52d988f4eb0d0dcffbb7a1f8ec98de7a5215fc628f3bce30fe2fb6fd2bc064b562d721658c59b544e2d34ea2766 - languageName: node - linkType: hard - -"content-type@npm:^2.0.0, content-type@npm:^2.1.0": - version: 2.1.0 - resolution: "content-type@npm:2.1.0" - checksum: d49ee84ba26900bfd57c95913be9baffdc2b51c4c8cc9bcdfa815d29aa7af6dce547d04007a356519da4b45a75a21fedbaee244aaef84a55e32900fcb79bce0b - languageName: node - linkType: hard - "convert-source-map@npm:^2.0.0": version: 2.0.0 resolution: "convert-source-map@npm:2.0.0" @@ -3709,13 +3607,6 @@ __metadata: languageName: node linkType: hard -"cookie-signature@npm:^1.2.1": - version: 1.2.2 - resolution: "cookie-signature@npm:1.2.2" - checksum: 1ad4f9b3907c9f3673a0f0a07c0a23da7909ac6c9204c5d80a0ec102fe50ccc45f27fdf496361840d6c132c5bb0037122c0a381f856d070183d1ebe3e5e041ff - languageName: node - linkType: hard - "cookie@npm:^0.5.0": version: 0.5.0 resolution: "cookie@npm:0.5.0" @@ -3723,13 +3614,6 @@ __metadata: languageName: node linkType: hard -"cookie@npm:^0.7.1": - version: 0.7.2 - resolution: "cookie@npm:0.7.2" - checksum: 9bf8555e33530affd571ea37b615ccad9b9a34febbf2c950c86787088eb00a8973690833b0f8ebd6b69b753c62669ea60cec89178c1fb007bf0749abed74f93e - languageName: node - linkType: hard - "core-util-is@npm:~1.0.0": version: 1.0.3 resolution: "core-util-is@npm:1.0.3" @@ -3737,16 +3621,6 @@ __metadata: languageName: node linkType: hard -"cors@npm:^2.8.5": - version: 2.8.6 - resolution: "cors@npm:2.8.6" - dependencies: - object-assign: ^4 - vary: ^1 - checksum: a967922b00fd17d836d21308c66ab9081d6c0f7dc019486ba1643a58281b12fc27d8c260471ddca72874b5bfe17a2d471ff8762d34f6009022ff749ec1136220 - languageName: node - linkType: hard - "cpu-features@npm:~0.0.8": version: 0.0.9 resolution: "cpu-features@npm:0.0.9" @@ -3876,18 +3750,6 @@ __metadata: languageName: node linkType: hard -"debug@npm:^4.4.0, debug@npm:^4.4.3": - version: 4.4.3 - resolution: "debug@npm:4.4.3" - dependencies: - ms: ^2.1.3 - peerDependenciesMeta: - supports-color: - optional: true - checksum: 4805abd570e601acdca85b6aa3757186084a45cff9b2fa6eee1f3b173caa776b45f478b2a71a572d616d2010cea9211d0ac4a02a610e4c18ac4324bde3760834 - languageName: node - linkType: hard - "deep-eql@npm:^4.1.3": version: 4.1.3 resolution: "deep-eql@npm:4.1.3" @@ -3944,7 +3806,7 @@ __metadata: languageName: node linkType: hard -"depd@npm:2.0.0, depd@npm:^2.0.0, depd@npm:~2.0.0": +"depd@npm:2.0.0": version: 2.0.0 resolution: "depd@npm:2.0.0" checksum: abbe19c768c97ee2eed6282d8ce3031126662252c58d711f646921c9623f9052e3e1906443066beec1095832f534e57c523b7333f8e7e0d93051ab6baef5ab3a @@ -4031,17 +3893,6 @@ __metadata: languageName: node linkType: hard -"dunder-proto@npm:^1.0.1": - version: 1.0.1 - resolution: "dunder-proto@npm:1.0.1" - dependencies: - call-bind-apply-helpers: ^1.0.1 - es-errors: ^1.3.0 - gopd: ^1.2.0 - checksum: 149207e36f07bd4941921b0ca929e3a28f1da7bd6b6ff8ff7f4e2f2e460675af4576eeba359c635723dc189b64cdd4787e0255897d5b135ccc5d15cb8685fc90 - languageName: node - linkType: hard - "eastasianwidth@npm:^0.2.0": version: 0.2.0 resolution: "eastasianwidth@npm:0.2.0" @@ -4058,13 +3909,6 @@ __metadata: languageName: node linkType: hard -"ee-first@npm:1.1.1": - version: 1.1.1 - resolution: "ee-first@npm:1.1.1" - checksum: 1b4cac778d64ce3b582a7e26b218afe07e207a0f9bfe13cc7395a6d307849cfe361e65033c3251e00c27dd060cab43014c2d6b2647676135e18b77d2d05b3f4f - languageName: node - linkType: hard - "emoji-regex@npm:^8.0.0": version: 8.0.0 resolution: "emoji-regex@npm:8.0.0" @@ -4079,13 +3923,6 @@ __metadata: languageName: node linkType: hard -"encodeurl@npm:^2.0.0": - version: 2.0.0 - resolution: "encodeurl@npm:2.0.0" - checksum: abf5cd51b78082cf8af7be6785813c33b6df2068ce5191a40ca8b1afe6a86f9230af9a9ce694a5ce4665955e5c1120871826df9c128a642e09c58d592e2807fe - languageName: node - linkType: hard - "encoding@npm:^0.1.13": version: 0.1.13 resolution: "encoding@npm:0.1.13" @@ -4238,13 +4075,6 @@ __metadata: languageName: node linkType: hard -"es-define-property@npm:^1.0.1": - version: 1.0.1 - resolution: "es-define-property@npm:1.0.1" - checksum: 0512f4e5d564021c9e3a644437b0155af2679d10d80f21adaf868e64d30efdfbd321631956f20f42d655fedb2e3a027da479fad3fa6048f768eb453a80a5f80a - languageName: node - linkType: hard - "es-errors@npm:^1.2.1, es-errors@npm:^1.3.0": version: 1.3.0 resolution: "es-errors@npm:1.3.0" @@ -4261,15 +4091,6 @@ __metadata: languageName: node linkType: hard -"es-object-atoms@npm:^1.1.1": - version: 1.1.2 - resolution: "es-object-atoms@npm:1.1.2" - dependencies: - es-errors: ^1.3.0 - checksum: b821f39e4f48bd85b13fee80aea9068a674bcb78b95ff01267aa4da1111f83e6944049b3329b2ffdb3acaa266fb5b8b885476fe8cada05034dc7c87c8016c86d - languageName: node - linkType: hard - "es-set-tostringtag@npm:^2.0.1": version: 2.0.1 resolution: "es-set-tostringtag@npm:2.0.1" @@ -4401,7 +4222,7 @@ __metadata: languageName: node linkType: hard -"escape-html@npm:^1.0.3, escape-html@npm:~1.0.3": +"escape-html@npm:~1.0.3": version: 1.0.3 resolution: "escape-html@npm:1.0.3" checksum: 6213ca9ae00d0ab8bccb6d8d4e0a98e76237b2410302cf7df70aaa6591d509a2a37ce8998008cbecae8fc8ffaadf3fb0229535e6a145f3ce0b211d060decbb24 @@ -4782,13 +4603,6 @@ __metadata: languageName: node linkType: hard -"etag@npm:^1.8.1": - version: 1.8.1 - resolution: "etag@npm:1.8.1" - checksum: 571aeb3dbe0f2bbd4e4fadbdb44f325fc75335cd5f6f6b6a091e6a06a9f25ed5392f0863c5442acb0646787446e816f13cbfc6edce5b07658541dff573cab1ff - languageName: node - linkType: hard - "event-target-shim@npm:^5.0.0": version: 5.0.1 resolution: "event-target-shim@npm:5.0.1" @@ -4803,7 +4617,7 @@ __metadata: languageName: node linkType: hard -"eventsource-parser@npm:^3.0.0, eventsource-parser@npm:^3.0.1": +"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 @@ -4843,54 +4657,6 @@ __metadata: languageName: node linkType: hard -"express-rate-limit@npm:^8.2.1": - version: 8.7.1 - resolution: "express-rate-limit@npm:8.7.1" - dependencies: - debug: ^4.4.3 - ip-address: ^10.2.0 - peerDependencies: - express: ">= 4.11" - checksum: 67b303e950d34419d79ab03918fbd602c968897a069c74053081e19b698bb98200cfe37fa1fe6040899955aa7aac4c70b0b6f8031cefa3fb78fa71184615f032 - languageName: node - linkType: hard - -"express@npm:^5.2.1": - version: 5.3.0 - resolution: "express@npm:5.3.0" - dependencies: - accepts: ^2.0.0 - body-parser: ^2.3.0 - content-disposition: ^2.0.1 - content-type: ^2.0.0 - cookie: ^0.7.1 - cookie-signature: ^1.2.1 - debug: ^4.4.0 - depd: ^2.0.0 - encodeurl: ^2.0.0 - escape-html: ^1.0.3 - etag: ^1.8.1 - finalhandler: ^2.1.0 - fresh: ^2.0.0 - http-errors: ^2.0.0 - merge-descriptors: ^2.0.0 - mime-types: ^3.0.0 - on-finished: ^2.4.1 - once: ^1.4.0 - parseurl: ^1.3.3 - proxy-addr: ^2.0.8 - qs: ^6.16.0 - range-parser: ^1.2.1 - router: ^2.2.0 - send: ^1.1.0 - serve-static: ^2.2.0 - statuses: ^2.0.1 - type-is: ^2.1.0 - vary: ^1.1.2 - checksum: 116f92b78e83de4177a62d05bf998a161dcfe3290f534da4ea66dd68709877281faf37bae42b25816856a236306ed1a0ab7dd815d5272a52bfdafbc8cac1aacc - languageName: node - linkType: hard - "fast-content-type-parse@npm:^1.1.0": version: 1.1.0 resolution: "fast-content-type-parse@npm:1.1.0" @@ -4997,13 +4763,6 @@ __metadata: languageName: node linkType: hard -"fast-uri@npm:^3.0.1": - version: 3.1.9 - resolution: "fast-uri@npm:3.1.9" - checksum: 4f882cff34cb1c4099b07285e5c7a628c62ff06d91f1a5b0aeb12d8ec002080bd2095ce5eec0855949d649dd8aeeff959a6f1db0f584f29b2e3e08fbb99d37fc - languageName: node - linkType: hard - "fast-xml-parser@npm:4.2.5": version: 4.2.5 resolution: "fast-xml-parser@npm:4.2.5" @@ -5073,20 +4832,6 @@ __metadata: languageName: node linkType: hard -"finalhandler@npm:^2.1.0": - version: 2.1.1 - resolution: "finalhandler@npm:2.1.1" - dependencies: - debug: ^4.4.0 - encodeurl: ^2.0.0 - escape-html: ^1.0.3 - on-finished: ^2.4.1 - parseurl: ^1.3.3 - statuses: ^2.0.1 - checksum: e5303c4cccce46019cf0f59b07a36cc6d37549f1efe2111c16cd78e6e500d3bfd68d3b45044c9a67a0c75ad3128ee1106fae9a0152ca3c0a8ee3bf3a4a1464bb - languageName: node - linkType: hard - "find-my-way@npm:^7.7.0": version: 7.7.0 resolution: "find-my-way@npm:7.7.0" @@ -5162,13 +4907,6 @@ __metadata: languageName: node linkType: hard -"fresh@npm:^2.0.0": - version: 2.0.0 - resolution: "fresh@npm:2.0.0" - checksum: 38b9828352c6271e2a0dd8bdd985d0100dbbc4eb8b6a03286071dd6f7d96cfaacd06d7735701ad9a95870eb3f4555e67c08db1dcfe24c2e7bb87383c72fae1d2 - languageName: node - linkType: hard - "fs-constants@npm:^1.0.0": version: 1.0.0 resolution: "fs-constants@npm:1.0.0" @@ -5262,13 +5000,6 @@ __metadata: languageName: node linkType: hard -"generator-function@npm:^2.0.0": - version: 2.0.1 - resolution: "generator-function@npm:2.0.1" - checksum: 3bf87f7b0230de5d74529677e6c3ceb3b7b5d9618b5a22d92b45ce3876defbaf5a77791b25a61b0fa7d13f95675b5ff67a7769f3b9af33f096e34653519e873d - languageName: node - linkType: hard - "get-func-name@npm:^2.0.1, get-func-name@npm:^2.0.2": version: 2.0.2 resolution: "get-func-name@npm:2.0.2" @@ -5301,27 +5032,6 @@ __metadata: languageName: node linkType: hard -"get-intrinsic@npm:^1.2.5, get-intrinsic@npm:^1.3.0": - version: 1.3.1 - resolution: "get-intrinsic@npm:1.3.1" - dependencies: - async-function: ^1.0.0 - async-generator-function: ^1.0.0 - call-bind-apply-helpers: ^1.0.2 - es-define-property: ^1.0.1 - es-errors: ^1.3.0 - es-object-atoms: ^1.1.1 - function-bind: ^1.1.2 - generator-function: ^2.0.0 - get-proto: ^1.0.1 - gopd: ^1.2.0 - has-symbols: ^1.1.0 - hasown: ^2.0.2 - math-intrinsics: ^1.1.0 - checksum: c02b3b6a445f9cd53e14896303794ac60f9751f58a69099127248abdb0251957174c6524245fc68579dc8e6a35161d3d94c93e665f808274716f4248b269436a - languageName: node - linkType: hard - "get-port@npm:^5.1.1": version: 5.1.1 resolution: "get-port@npm:5.1.1" @@ -5329,16 +5039,6 @@ __metadata: languageName: node linkType: hard -"get-proto@npm:^1.0.1": - version: 1.0.1 - resolution: "get-proto@npm:1.0.1" - dependencies: - dunder-proto: ^1.0.1 - es-object-atoms: ^1.0.0 - checksum: 4fc96afdb58ced9a67558698b91433e6b037aaa6f1493af77498d7c85b141382cf223c0e5946f334fb328ee85dfe6edd06d218eaf09556f4bc4ec6005d7f5f7b - languageName: node - linkType: hard - "get-stream@npm:^8.0.1": version: 8.0.1 resolution: "get-stream@npm:8.0.1" @@ -5491,13 +5191,6 @@ __metadata: languageName: node linkType: hard -"gopd@npm:^1.2.0": - version: 1.2.0 - resolution: "gopd@npm:1.2.0" - checksum: cc6d8e655e360955bdccaca51a12a474268f95bb793fc3e1f2bdadb075f28bfd1fd988dab872daf77a61d78cbaf13744bc8727a17cfb1d150d76047d805375f3 - languageName: node - linkType: hard - "graceful-fs@npm:^4.2.0, graceful-fs@npm:^4.2.4, graceful-fs@npm:^4.2.6": version: 4.2.11 resolution: "graceful-fs@npm:4.2.11" @@ -5572,13 +5265,6 @@ __metadata: languageName: node linkType: hard -"has-symbols@npm:^1.1.0": - version: 1.1.0 - resolution: "has-symbols@npm:1.1.0" - checksum: b2316c7302a0e8ba3aaba215f834e96c22c86f192e7310bdf689dd0e6999510c89b00fbc5742571507cebf25764d68c988b3a0da217369a73596191ac0ce694b - languageName: node - linkType: hard - "has-tostringtag@npm:^1.0.0": version: 1.0.0 resolution: "has-tostringtag@npm:1.0.0" @@ -5640,9 +5326,9 @@ __metadata: linkType: hard "hono@npm:^4.11.4": - version: 4.13.13 - resolution: "hono@npm:4.13.13" - checksum: fd60e0f0f5a00fe7ac1a5d853ac14a141e0af396099288d626dff5faadfd5bb43a0ae488af46102a9bb1a1009bd388d07577b78b11ea407d0b7bd501eb0e48c5 + version: 4.13.14 + resolution: "hono@npm:4.13.14" + checksum: 74cfd182fc94504af75fc1629b0f8e46abc26379f5479f0e69923682399ca45785982a27e373461b47d9b44514e5425aec154ddca47ffe978662b78b37498cac languageName: node linkType: hard @@ -5673,19 +5359,6 @@ __metadata: languageName: node linkType: hard -"http-errors@npm:^2.0.0, http-errors@npm:^2.0.1, http-errors@npm:~2.0.1": - version: 2.0.1 - resolution: "http-errors@npm:2.0.1" - dependencies: - depd: ~2.0.0 - inherits: ~2.0.4 - setprototypeof: ~1.2.0 - statuses: ~2.0.2 - toidentifier: ~1.0.1 - checksum: 155d1a100a06e4964597013109590b97540a177b69c3600bbc93efc746465a99a2b718f43cdf76b3791af994bbe3a5711002046bf668cdc007ea44cea6df7ccd - languageName: node - linkType: hard - "http-proxy-agent@npm:^5.0.0": version: 5.0.0 resolution: "http-proxy-agent@npm:5.0.0" @@ -5739,15 +5412,6 @@ __metadata: languageName: node linkType: hard -"iconv-lite@npm:^0.7.2, iconv-lite@npm:~0.7.0": - version: 0.7.3 - resolution: "iconv-lite@npm:0.7.3" - dependencies: - safer-buffer: ">= 2.1.2 < 3.0.0" - checksum: 36b3dc2f5a25c2cac15f7df42a1f23e796f1616b2b4103b205a5c237d9b40483184a8c07bbc2d72b01ca28f4770506fad5a43203adae9c97a5d4733af684b6a8 - languageName: node - linkType: hard - "ieee754@npm:^1.1.13, ieee754@npm:^1.2.1": version: 1.2.1 resolution: "ieee754@npm:1.2.1" @@ -5817,7 +5481,7 @@ __metadata: languageName: node linkType: hard -"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3, inherits@npm:~2.0.4": +"inherits@npm:2, inherits@npm:2.0.4, inherits@npm:^2.0.3, inherits@npm:^2.0.4, inherits@npm:~2.0.3": version: 2.0.4 resolution: "inherits@npm:2.0.4" checksum: 4a48a733847879d6cf6691860a6b1e3f0f4754176e4d71494c41f3475553768b10f84b5ce1d40fbd0e34e6bfbb864ee35858ad4dd2cf31e02fc4a154b724d7f1 @@ -5846,13 +5510,6 @@ __metadata: languageName: node linkType: hard -"ip-address@npm:^10.2.0": - version: 10.7.4 - resolution: "ip-address@npm:10.7.4" - checksum: 9caa80552b9ef8cef2d5c194119dcdb4fccaab33cb41074396e4134779ef38451624e30f074364f870f9215c63d29bab33e1198f710cc6539c0879aa2c0d33c9 - languageName: node - linkType: hard - "ip@npm:^2.0.0": version: 2.0.0 resolution: "ip@npm:2.0.0" @@ -6035,13 +5692,6 @@ __metadata: languageName: node linkType: hard -"is-promise@npm:^4.0.0": - version: 4.0.0 - resolution: "is-promise@npm:4.0.0" - checksum: 0b46517ad47b00b6358fd6553c83ec1f6ba9acd7ffb3d30a0bf519c5c69e7147c132430452351b8a9fc198f8dd6c4f76f8e6f5a7f100f8c77d57d9e0f4261a8a - languageName: node - linkType: hard - "is-regex@npm:^1.1.4": version: 1.1.4 resolution: "is-regex@npm:1.1.4" @@ -6279,13 +5929,6 @@ __metadata: languageName: node linkType: hard -"json-schema-typed@npm:^8.0.2": - version: 8.0.2 - resolution: "json-schema-typed@npm:8.0.2" - checksum: 8ddb3c2b1bad406507ea077d4d8cfe6dae5b920f642efab145e4bd6e0d3984f01034ee4467bbeabd51e129b17d7446ceb357a6b1648857fdfaf61ba8ef621ff6 - languageName: node - linkType: hard - "json-stable-stringify-without-jsonify@npm:^1.0.1": version: 1.0.1 resolution: "json-stable-stringify-without-jsonify@npm:1.0.1" @@ -6604,27 +6247,6 @@ __metadata: languageName: node linkType: hard -"math-intrinsics@npm:^1.1.0": - version: 1.1.0 - resolution: "math-intrinsics@npm:1.1.0" - checksum: 0e513b29d120f478c85a70f49da0b8b19bc638975eca466f2eeae0071f3ad00454c621bf66e16dd435896c208e719fc91ad79bbfba4e400fe0b372e7c1c9c9a2 - languageName: node - linkType: hard - -"media-typer@npm:^1.1.0": - version: 1.1.1 - resolution: "media-typer@npm:1.1.1" - checksum: ceef972e43912621b846f816e8937c200dcd83d0bdd4fdd19f28be62a9334f4e6605d0c952fe804bfab7f63a07d14c0d1bea1d9357c35ad6d3b04b184f09e397 - languageName: node - linkType: hard - -"merge-descriptors@npm:^2.0.0": - version: 2.0.0 - resolution: "merge-descriptors@npm:2.0.0" - checksum: e383332e700a94682d0125a36c8be761142a1320fc9feeb18e6e36647c9edf064271645f5669b2c21cf352116e561914fd8aa831b651f34db15ef4038c86696a - languageName: node - linkType: hard - "merge-stream@npm:^2.0.0": version: 2.0.0 resolution: "merge-stream@npm:2.0.0" @@ -6649,22 +6271,6 @@ __metadata: languageName: node linkType: hard -"mime-db@npm:^1.54.0": - version: 1.54.0 - resolution: "mime-db@npm:1.54.0" - checksum: e99aaf2f23f5bd607deb08c83faba5dd25cf2fec90a7cc5b92d8260867ee08dab65312e1a589e60093dc7796d41e5fae013268418482f1db4c7d52d0a0960ac9 - languageName: node - linkType: hard - -"mime-types@npm:^3.0.0, mime-types@npm:^3.0.2": - version: 3.0.2 - resolution: "mime-types@npm:3.0.2" - dependencies: - mime-db: ^1.54.0 - checksum: 70b74794f408419e4b6a8e3c93ccbed79b6a6053973a3957c5cc04ff4ad8d259f0267da179e3ecae34c3edfb4bfd7528db23a101e32d21ad8e196178c8b7b75a - languageName: node - linkType: hard - "mime@npm:^3.0.0": version: 3.0.0 resolution: "mime@npm:3.0.0" @@ -6889,7 +6495,7 @@ __metadata: languageName: node linkType: hard -"ms@npm:^2.0.0, ms@npm:^2.1.1, ms@npm:^2.1.3": +"ms@npm:^2.0.0, ms@npm:^2.1.1": version: 2.1.3 resolution: "ms@npm:2.1.3" checksum: aa92de608021b242401676e35cfa5aa42dd70cbdc082b916da7fb925c542173e36bce97ea3e804923fe92c0ad991434e4a38327e15a1b5b5f945d66df615ae6d @@ -6944,15 +6550,6 @@ __metadata: languageName: node linkType: hard -"negotiator@npm:^1.0.0": - version: 1.1.0 - resolution: "negotiator@npm:1.1.0" - dependencies: - content-type: ^2.1.0 - checksum: 5c779870e2c6c863eed97c136328ee54bbd179bc79c1474415354c11ec343652b3239805fc7d07dec320ee6fa138dd28b864c2a5b9853c350a56622d30a169d5 - languageName: node - linkType: hard - "node-fetch@npm:^2.6.12, node-fetch@npm:^2.7.0": version: 2.7.0 resolution: "node-fetch@npm:2.7.0" @@ -7049,7 +6646,9 @@ __metadata: "@fastify/oauth2": ^7.2.1 "@fastify/swagger": ^8.8.0 "@fastify/swagger-ui": ^1.9.3 - "@modelcontextprotocol/sdk": ^1.32.1 + "@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 @@ -7080,7 +6679,7 @@ __metadata: typescript: ^5.0.4 typescript-eslint: ^7.6.0 vitest: ^1.4.0 - zod: ^3.25.0 + zod: ^4.2.0 languageName: unknown linkType: soft @@ -7114,13 +6713,6 @@ __metadata: languageName: node linkType: hard -"object-assign@npm:^4": - version: 4.1.1 - resolution: "object-assign@npm:4.1.1" - checksum: fcc6e4ea8c7fe48abfbb552578b1c53e0d194086e2e6bbbf59e0a536381a292f39943c6e9628af05b5528aa5e3318bb30d6b2e53cadaf5b8fe9e12c4b69af23f - languageName: node - linkType: hard - "object-inspect@npm:^1.12.3, object-inspect@npm:^1.13.1, object-inspect@npm:^1.9.0": version: 1.13.1 resolution: "object-inspect@npm:1.13.1" @@ -7128,13 +6720,6 @@ __metadata: languageName: node linkType: hard -"object-inspect@npm:^1.13.3, object-inspect@npm:^1.13.4": - version: 1.13.4 - resolution: "object-inspect@npm:1.13.4" - checksum: 582810c6a8d2ef988ea0a39e69e115a138dad8f42dd445383b394877e5816eb4268489f316a6f74ee9c4e0a984b3eab1028e3e79d62b1ed67c726661d55c7a8b - languageName: node - linkType: hard - "object-keys@npm:^1.1.1": version: 1.1.1 resolution: "object-keys@npm:1.1.1" @@ -7221,15 +6806,6 @@ __metadata: languageName: node linkType: hard -"on-finished@npm:^2.4.1": - version: 2.4.1 - resolution: "on-finished@npm:2.4.1" - dependencies: - ee-first: 1.1.1 - checksum: d20929a25e7f0bb62f937a425b5edeb4e4cde0540d77ba146ec9357f00b0d497cdb3b9b05b9c8e46222407d1548d08166bff69cc56dfa55ba0e4469228920ff0 - languageName: node - linkType: hard - "once@npm:^1.3.0, once@npm:^1.3.1, once@npm:^1.4.0": version: 1.4.0 resolution: "once@npm:1.4.0" @@ -7346,13 +6922,6 @@ __metadata: languageName: node linkType: hard -"parseurl@npm:^1.3.3": - version: 1.3.3 - resolution: "parseurl@npm:1.3.3" - checksum: 407cee8e0a3a4c5cd472559bca8b6a45b82c124e9a4703302326e9ab60fc1081442ada4e02628efef1eb16197ddc7f8822f5a91fd7d7c86b51f530aedb17dfa2 - languageName: node - linkType: hard - "path-exists@npm:^4.0.0": version: 4.0.0 resolution: "path-exists@npm:4.0.0" @@ -7405,13 +6974,6 @@ __metadata: languageName: node linkType: hard -"path-to-regexp@npm:^8.0.0": - version: 8.4.2 - resolution: "path-to-regexp@npm:8.4.2" - checksum: c30fba443e413cc736b7b28056a4b60b537ae1caa80152da21e8093bd41deba7c408a4ac6f11a1bf594e089d8fd8d87ed31476c55c50983719fb355826370ade - languageName: node - linkType: hard - "path-type@npm:^4.0.0": version: 4.0.0 resolution: "path-type@npm:4.0.0" @@ -7859,16 +7421,6 @@ __metadata: languageName: node linkType: hard -"proxy-addr@npm:^2.0.8": - version: 2.0.8 - resolution: "proxy-addr@npm:2.0.8" - dependencies: - forwarded: 0.2.0 - ipaddr.js: 1.9.1 - checksum: 1878fa24a104ac3833f55583918964fbdcc6eedc076450548633dc1b4321e89947a9e93fc61d0c931712d3c46197de18855bc96a4feb73ba7bd4890a45508649 - languageName: node - linkType: hard - "pstree.remy@npm:^1.1.8": version: 1.1.8 resolution: "pstree.remy@npm:1.1.8" @@ -7893,16 +7445,6 @@ __metadata: languageName: node linkType: hard -"qs@npm:^6.15.2, qs@npm:^6.16.0": - version: 6.16.1 - resolution: "qs@npm:6.16.1" - dependencies: - es-define-property: ^1.0.1 - side-channel: ^1.1.1 - checksum: 56d617a5e3ea475eaafd1e860a66c0aa362c0d58c23e787ad71a137d67fb52fd9a9fff6aea2d38612443f24825fb23ea4d01bb7222e9d2ac32a5b6007027747c - languageName: node - linkType: hard - "queue-lit@npm:^1.5.1": version: 1.5.2 resolution: "queue-lit@npm:1.5.2" @@ -7931,25 +7473,6 @@ __metadata: languageName: node linkType: hard -"range-parser@npm:^1.2.1": - version: 1.3.0 - resolution: "range-parser@npm:1.3.0" - checksum: 0277bec278ed3d8f9c27d75d8b1615750768bf984c8fbb04efcc238a705fd0950e3b0c6ec54277106f96ef566d8984f9dd5bf68841943375a2d94069ecdba145 - languageName: node - linkType: hard - -"raw-body@npm:^3.0.0, raw-body@npm:^3.0.2": - version: 3.0.2 - resolution: "raw-body@npm:3.0.2" - dependencies: - bytes: ~3.1.2 - http-errors: ~2.0.1 - iconv-lite: ~0.7.0 - unpipe: ~1.0.0 - checksum: bf8ce8e9734f273f24d81f9fed35609dbd25c2869faa5fb5075f7ee225c0913e2240adda03759d7e72f2a757f8012d58bb7a871a80261d5140ad65844caeb5bd - languageName: node - linkType: hard - "react-is@npm:^18.0.0": version: 18.2.0 resolution: "react-is@npm:18.2.0" @@ -8191,19 +7714,6 @@ __metadata: languageName: node linkType: hard -"router@npm:^2.2.0": - version: 2.2.0 - resolution: "router@npm:2.2.0" - dependencies: - debug: ^4.4.0 - depd: ^2.0.0 - is-promise: ^4.0.0 - parseurl: ^1.3.3 - path-to-regexp: ^8.0.0 - checksum: 4c3bec8011ed10bb07d1ee860bc715f245fff0fdff991d8319741d2932d89c3fe0a56766b4fa78e95444bc323fd2538e09c8e43bfbd442c2a7fab67456df7fa5 - languageName: node - linkType: hard - "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -8352,25 +7862,6 @@ __metadata: languageName: node linkType: hard -"send@npm:^1.1.0, send@npm:^1.2.0": - version: 1.2.1 - resolution: "send@npm:1.2.1" - dependencies: - debug: ^4.4.3 - encodeurl: ^2.0.0 - escape-html: ^1.0.3 - etag: ^1.8.1 - fresh: ^2.0.0 - http-errors: ^2.0.1 - mime-types: ^3.0.2 - ms: ^2.1.3 - on-finished: ^2.4.1 - range-parser: ^1.2.1 - statuses: ^2.0.2 - checksum: 5361e3556fbc874c080a4cfbb4541e02c16221ca3c68c4f692320d38ef7e147381f805ce3ac50dfaa2129f07daa81098e2bc567e9a4d13993a92893d59a64d68 - languageName: node - linkType: hard - "sequelize-pool@npm:^7.1.0": version: 7.1.0 resolution: "sequelize-pool@npm:7.1.0" @@ -8421,18 +7912,6 @@ __metadata: languageName: node linkType: hard -"serve-static@npm:^2.2.0": - version: 2.2.1 - resolution: "serve-static@npm:2.2.1" - dependencies: - encodeurl: ^2.0.0 - escape-html: ^1.0.3 - parseurl: ^1.3.3 - send: ^1.2.0 - checksum: dd71e9a316a7d7f726503973c531168cfa6a6a56a98d5c6b279c4d0d41a83a1bc6900495dc0633712b95d88ccbf9ed4f4a780a4c4c00bf84b496e9e710d68825 - languageName: node - linkType: hard - "set-blocking@npm:^2.0.0": version: 2.0.0 resolution: "set-blocking@npm:2.0.0" @@ -8496,7 +7975,7 @@ __metadata: languageName: node linkType: hard -"setprototypeof@npm:1.2.0, setprototypeof@npm:~1.2.0": +"setprototypeof@npm:1.2.0": version: 1.2.0 resolution: "setprototypeof@npm:1.2.0" checksum: be18cbbf70e7d8097c97f713a2e76edf84e87299b40d085c6bf8b65314e994cc15e2e317727342fa6996e38e1f52c59720b53fe621e2eb593a6847bf0356db89 @@ -8519,41 +7998,6 @@ __metadata: languageName: node linkType: hard -"side-channel-list@npm:^1.0.1": - version: 1.0.1 - resolution: "side-channel-list@npm:1.0.1" - dependencies: - es-errors: ^1.3.0 - object-inspect: ^1.13.4 - checksum: 3499671cd52adaee739eac1e14d07530b8e3530192741aeb05e7fe4ad1b51d1368ceea2cd3c21b0f62b05410a5c70a7c4d997ba4b143303ef73d0c65dfd1c252 - languageName: node - linkType: hard - -"side-channel-map@npm:^1.0.1": - version: 1.0.1 - resolution: "side-channel-map@npm:1.0.1" - dependencies: - call-bound: ^1.0.2 - es-errors: ^1.3.0 - get-intrinsic: ^1.2.5 - object-inspect: ^1.13.3 - checksum: 42501371cdf71f4ccbbc9c9e2eb00aaaab80a4c1c429d5e8da713fd4d39ef3b8d4a4b37ed4f275798a65260a551a7131fd87fe67e922dba4ac18586d6aab8b06 - languageName: node - linkType: hard - -"side-channel-weakmap@npm:^1.0.2": - version: 1.0.2 - resolution: "side-channel-weakmap@npm:1.0.2" - dependencies: - call-bound: ^1.0.2 - es-errors: ^1.3.0 - get-intrinsic: ^1.2.5 - object-inspect: ^1.13.3 - side-channel-map: ^1.0.1 - checksum: a815c89bc78c5723c714ea1a77c938377ea710af20d4fb886d362b0d1f8ac73a17816a5f6640f354017d7e292a43da9c5e876c22145bac00b76cfb3468001736 - languageName: node - linkType: hard - "side-channel@npm:^1.0.4": version: 1.0.4 resolution: "side-channel@npm:1.0.4" @@ -8565,19 +8009,6 @@ __metadata: languageName: node linkType: hard -"side-channel@npm:^1.1.1": - version: 1.1.1 - resolution: "side-channel@npm:1.1.1" - dependencies: - es-errors: ^1.3.0 - object-inspect: ^1.13.4 - side-channel-list: ^1.0.1 - side-channel-map: ^1.0.1 - side-channel-weakmap: ^1.0.2 - checksum: e0f217140c463636ee556260bbc8f47ba2931f1248826f58e1502704135814c763b325dd9ab122a04176aa45b4a4f8cc556415bcab7a0179d85250fb7812f063 - languageName: node - linkType: hard - "siginfo@npm:^2.0.0": version: 2.0.0 resolution: "siginfo@npm:2.0.0" @@ -8773,13 +8204,6 @@ __metadata: languageName: node linkType: hard -"statuses@npm:^2.0.1, statuses@npm:^2.0.2, statuses@npm:~2.0.2": - version: 2.0.2 - resolution: "statuses@npm:2.0.2" - checksum: 6927feb50c2a75b2a4caab2c565491f7a93ad3d8dbad7b1398d52359e9243a20e2ebe35e33726dee945125ef7a515e9097d8a1b910ba2bbd818265a2f6c39879 - languageName: node - linkType: hard - "std-env@npm:^3.3.3": version: 3.4.3 resolution: "std-env@npm:3.4.3" @@ -9224,7 +8648,7 @@ __metadata: languageName: node linkType: hard -"toidentifier@npm:1.0.1, toidentifier@npm:~1.0.1": +"toidentifier@npm:1.0.1": version: 1.0.1 resolution: "toidentifier@npm:1.0.1" checksum: 952c29e2a85d7123239b5cfdd889a0dde47ab0497f0913d70588f19c53f7e0b5327c95f4651e413c74b785147f9637b17410ac8c846d5d4a20a5a33eb6dc3a45 @@ -9377,17 +8801,6 @@ __metadata: languageName: node linkType: hard -"type-is@npm:^2.1.0": - version: 2.1.0 - resolution: "type-is@npm:2.1.0" - dependencies: - content-type: ^2.0.0 - media-typer: ^1.1.0 - mime-types: ^3.0.0 - checksum: 214a44fc635fd1396bb656af1f0392d151e44e31f622ca2d030f4b4bb4f20069dab250c6798a6c6f4b215f87cd6f68099cbfdb0975696c3383f7a4cad26f6174 - languageName: node - linkType: hard - "typed-array-buffer@npm:^1.0.0": version: 1.0.0 resolution: "typed-array-buffer@npm:1.0.0" @@ -9602,13 +9015,6 @@ __metadata: languageName: node linkType: hard -"unpipe@npm:~1.0.0": - version: 1.0.0 - resolution: "unpipe@npm:1.0.0" - checksum: 4fa18d8d8d977c55cb09715385c203197105e10a6d220087ec819f50cb68870f02942244f1017565484237f1f8c5d3cd413631b1ae104d3096f24fdfde1b4aa2 - languageName: node - linkType: hard - "uri-js@npm:^4.2.2": version: 4.4.1 resolution: "uri-js@npm:4.4.1" @@ -9668,13 +9074,6 @@ __metadata: languageName: node linkType: hard -"vary@npm:^1, vary@npm:^1.1.2": - version: 1.1.2 - resolution: "vary@npm:1.1.2" - checksum: ae0123222c6df65b437669d63dfa8c36cee20a504101b2fcd97b8bf76f91259c17f9f2b4d70a1e3c6bbcee7f51b28392833adb6b2770b23b01abec84e369660b - languageName: node - linkType: hard - "vite-node@npm:1.4.0": version: 1.4.0 resolution: "vite-node@npm:1.4.0" @@ -9996,25 +9395,9 @@ __metadata: languageName: node linkType: hard -"zod-to-json-schema@npm:^3.25.1": - version: 3.25.2 - resolution: "zod-to-json-schema@npm:3.25.2" - peerDependencies: - zod: ^3.25.28 || ^4 - checksum: 8b728476374a38711e0ca5c066be16ed8facf5a5eb5a76f371b0062e5b6b11f906f446c616d6879b9a4d373738c74cd26e4dfe1d460e7fb9a155a3fe9d04b690 - languageName: node - linkType: hard - -"zod@npm:^3.25 || ^4.0": +"zod@npm:^4.2.0": version: 4.6.7 resolution: "zod@npm:4.6.7" checksum: 6801f5067dd43204d5ed2b4f2f0d09f891365ecfdd385877901e42f6f6ebf67e8943b385c71eaba53081029dd21f4740f16c91497a06f95195e209bffef92056 languageName: node linkType: hard - -"zod@npm:^3.25.0": - version: 3.25.76 - resolution: "zod@npm:3.25.76" - checksum: c9a403a62b329188a5f6bd24d5d935d2bba345f7ab8151d1baa1505b5da9f227fb139354b043711490c798e91f3df75991395e40142e6510a4b16409f302b849 - languageName: node - linkType: hard From e0767ede440578fcfa945946fcae0d2792428d1d Mon Sep 17 00:00:00 2001 From: Peter Savchenko Date: Mon, 26 Oct 2026 23:23:35 +0300 Subject: [PATCH 6/6] docs: note MCP SDK v2 migration in the plan --- docs/mcp-plan.md | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/mcp-plan.md b/docs/mcp-plan.md index 0afacf58..a24d4d60 100644 --- a/docs/mcp-plan.md +++ b/docs/mcp-plan.md @@ -2,11 +2,11 @@ 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. +**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, stateless (no MCP sessions). No separate deployment, no npm package. +- **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. @@ -51,7 +51,7 @@ Living document. Update the status checkboxes and the "Deviations" section as th ## Step 2. `/mcp` endpoint (notes.api) -- [x] Dependency `@modelcontextprotocol/sdk` (zod may need a bump from `^3.21`). +- [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; @@ -62,8 +62,8 @@ Living document. Update the status checkboxes and the "Deviations" section as th - [x] Request flow: 1. resolve the token; 2. sign a short access JWT for the owner with `authService.signAccessToken()`; - 3. create `McpServer` and `StreamableHTTPServerTransport({ sessionIdGenerator: undefined })`; - 4. `reply.hijack()` and `transport.handleRequest(request.raw, reply.raw, request.body)`; + 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. @@ -112,14 +112,20 @@ Separate fix, not part of this work: `POST /note` with `parentId` does not check ## 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 `^3.25.0`, the SDK (`@modelcontextprotocol/sdk@^1.32.1`) requires `^3.25 || ^4`. +- 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: the transport runs with `enableJsonResponse: true`: responses are plain JSON, no SSE streams. +- 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. @@ -127,6 +133,8 @@ Separate fix, not part of this work: `POST /note` with `parentId` does not check ## 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`