Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions app-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
142 changes: 142 additions & 0 deletions docs/mcp-plan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
# MCP server: implementation plan

Living document. Update the status checkboxes and the "Deviations" section as the work goes.

**Status:** steps 1 and 2 are done on `feat/mcp-server`; step 3 is done on `feat/mcp-tokens` in notes.web. Following review, `/mcp` runs on MCP SDK v2 and serves both protocol revisions.

## Decisions

- **Where it lives.** A remote MCP endpoint `/mcp` inside notes.api. Transport: Streamable HTTP without MCP sessions. Protocol revision 2026-07-28 and 2025-era clients are served on the same endpoint. No separate deployment, no npm package.
- **Auth.** Personal MCP tokens sent as `Authorization: Bearer ntx_…`. A user can have several tokens, each with `read` or `write` permission. No OAuth for now.
- **Content format.** Editor.js JSON as stored in the DB. The server generates ids for new blocks and builds `tools[]` itself.
- **Tools.** `list_notes`, `get_note`, `create_note`, `update_note`. A `read` token sees only the first two. No delete, no search.
- **Permissions.** Tools call existing HTTP routes through `fastify.inject()` with a short-lived access JWT of the token owner, so all route policies and validation apply without duplication.

## Step 1. Tokens (notes.api)

- [x] Migration `migrations/tenant/0036-mcp-tokens@add-mcp-tokens-table.sql`:
```sql
CREATE TABLE IF NOT EXISTS public.mcp_tokens (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
token_hash CHAR(64) NOT NULL UNIQUE,
preview VARCHAR(4) NOT NULL,
permission VARCHAR(5) NOT NULL CHECK (permission IN ('read', 'write')),
label VARCHAR(255) NOT NULL,
last_used_at TIMESTAMPTZ,
request_count INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
```
- [x] Entity `src/domain/entities/mcpToken.ts`: `McpToken` and `McpTokenPublic` (without `tokenHash` and `userId`).
- [x] Storage and repository:
- sequelize model `src/repository/storage/postgres/orm/sequelize/mcpToken.ts`;
- `src/repository/storage/mcpToken.storage.ts`;
- `src/repository/mcpToken.repository.ts`;
- wiring in `src/repository/index.ts`.
- [x] Service `src/domain/service/mcpToken.ts`:
- `createToken(userId, label, permission)` generates `ntx_` + `nanoid(32)`, stores its sha256 and last 4 chars, returns the plain token once;
- `getTokensByUserId(userId)`;
- `revokeToken(userId, id)` revokes only the user's own tokens;
- `resolveToken(token)` returns the token record or `null`;
- `trackUsage(id)` atomically increments `request_count` and sets `last_used_at`.
- [x] Router `src/presentation/http/router/mcpTokens.ts` with prefix `/user/mcp-tokens`, all routes `authRequired`:
- `GET /` lists tokens without hashes;
- `POST /` with `{ label, permission }` returns `{ token, item }`;
- `DELETE /:id`.
- [x] Tests:
- the token is returned only on creation;
- the list has no hashes;
- a user cannot revoke someone else's token.

## Step 2. `/mcp` endpoint (notes.api)

- [x] Dependencies `@modelcontextprotocol/server` and `@modelcontextprotocol/node` (MCP SDK v2), zod `^4.2`.
- [x] `src/presentation/mcp/`:
- `index.ts` — fastify plugin: `POST /mcp`; `GET` and `DELETE` answer 405;
- `auth.ts` — Bearer token → `resolveToken()`, 401 without a valid token;
- `server.ts` — `createMcpServer({ userId, permission })` registers tools by permission;
- `noteRef.ts` — parses a note URL or id: `notex.so/note/<id>`, `/note/<id>/…`, a bare 10-char id, a custom hostname via `GET /note/resolve-hostname/:hostname`;
- `content.ts` — fills block ids, builds `tools[]`;
- `tools/listNotes.ts`, `tools/getNote.ts`, `tools/createNote.ts`, `tools/updateNote.ts`.
- [x] Request flow:
1. resolve the token;
2. sign a short access JWT for the owner with `authService.signAccessToken()`;
3. `createMcpHandler()`, created once per router, passes the token to its factory through `authInfo`; the factory creates `McpServer` for the request;
4. `reply.hijack()` and `toNodeHandler(handler)(request.raw, reply.raw, request.body)`;
5. tools call the API through `fastify.inject()` with that JWT;
6. `trackUsage` only on `tools/call`.
- [x] `userIdResolver` skips `ntx_…` tokens instead of logging a JWT error on every request.
- [x] Tools:

| Tool | Params | API | Returns |
|---|---|---|---|
| `list_notes` | `source: recent \| created` (default `recent`), `page` (1–30), `parent?` | `GET /notes`, `GET /notes/created`; with `parent` — `GET /note/note-hierarchy/:id`, subtree of the parent | `[{ id, title, url, updatedAt }]` or a tree `{ id, title, url, children }` |
| `get_note` | `note` (URL or id) | `GET /note/:id` | `{ id, url, title, updatedAt, canEdit, parents, content: { blocks } }` |
| `create_note` | `blocks`, `parent?` (URL or id) | `POST /note` | `{ id, url }` |
| `update_note` | `note`, `blocks`, `mode: replace \| append`, `expectedUpdatedAt?` | `GET /note/:id`, compare `updatedAt`, merge, `PATCH /note/:id` | `{ id, url, updatedAt }` |

- [x] `tools[]` lookup order for each block type: tools of the note, then user tools (`GET /user/editor-tools`), then default tools (`GET /editor-tools/all`). Unknown type → error listing the available tools.
- [x] Tool descriptions document the default block formats: `paragraph {text}` with inline HTML, `header {text, level}`, `list {style, items: string[]}` (list@1.9).
- [x] Note links are built from a new config value `mcp.webUrl` (default `https://notex.so`).
- [x] Tests `src/presentation/mcp/mcp.test.ts`:
- 401 without a token;
- a `read` token doesn't see write tools in `tools/list`;
- create → get → update round trip;
- `expectedUpdatedAt` conflict;
- `request_count` grows.

## Step 3. Settings in notes.web

- [x] New "MCP" fieldset in `Settings.vue`:
- token list: label, permission, `ntx_…a1b2`, created, last used, request count, "Revoke";
- new token form: label and an "Allow editing" checkbox (off by default);
- after creation the token is shown once with a copy button and ready-made setup instructions for Claude Code, Cursor, and claude.ai (custom connector → Request headers).
- [x] Layers: entity, notes-api transport methods, service, ru/en texts.

Setup snippets for the UI:

- Claude Code: `claude mcp add --transport http notex https://api.notex.so/mcp --header "Authorization: Bearer ntx_…"`
- Cursor: `{ "mcpServers": { "notex": { "url": "https://api.notex.so/mcp", "headers": { "Authorization": "Bearer ntx_…" } } } }`

## Order

Step 1 → step 2. Step 3 depends only on the step 1 routes and can go in parallel with step 2.

## Out of scope for v1

OAuth, search, deleting notes, Markdown format, MCP resources.

Separate fix, not part of this work: `POST /note` with `parentId` does not check that the user can edit the parent note, so any logged-in user can attach a note to someone else's tree. `create_note` goes through the same route and has the same gap until it is fixed.

## Deviations

- Step 1: the migration declares the FK, the permission check and the indexes as named constraints (same structure as other migrations) instead of inline column constraints.
- Step 2: zod is bumped to `^4.2.0`, MCP SDK v2 doesn't support zod 3. Outside MCP zod is used only in the config schema, which works on zod 4 unchanged.
- Step 2: the web url lives in `httpApi.mcp.webUrl` (default `https://notex.so`, `http://localhost:5173` in `app-config.yaml`), so `HttpApi` keeps getting only its own config section.
- Step 2: 2025-era requests are answered with a short SSE stream carrying one message, since the SDK's stateless fallback has no JSON-only option. 2026-07-28 requests get plain JSON.
- Step 2: `GET /user/editor-tools` already includes the default tools, so `tools[]` is built from the note tools and the user tools; `GET /editor-tools/all` is not called.
- Step 2: two helper modules were added: `notexApi.ts` (API client over `fastify.inject()`) and `types.ts` (tool context and result helper).
- Step 2: besides the tests through `fastify.inject()`, the endpoint was checked over real HTTP with curl against a local server: `initialize`, `tools/list`, `create_note`, `list_notes`, 405 on `GET`, 401 for a revoked token, two tools for a read token. It has not been tried with Claude Code or Cursor yet.
- Step 2 (review): `PATCH /note/:notePublicId` accepts an optional `expectedUpdatedAt`. The note is updated only if its `updated_at` still matches, checked in the same `UPDATE` query; otherwise the route returns 409. `update_note` always passes the version it read, so concurrent updates and appends can't overwrite each other. Note history is now written after a successful update, not before.
- Step 2 (review): `/mcp` copies headers set by Fastify hooks (CORS) to the raw response, since the hijacked reply doesn't flush them.
- Step 2 (review): `/mcp` moved from `@modelcontextprotocol/sdk` v1 to SDK v2 to serve protocol revision 2026-07-28 next to 2025-era clients:
- `createMcpHandler` with the default stateless legacy fallback, wired into Fastify with `toNodeHandler`; the `@modelcontextprotocol/fastify` adapter requires Fastify 5;
- tool input schemas are wrapped in `z.object()`;
- unknown tools, e.g. write tools for a read token, are rejected with a JSON-RPC error instead of a tool result with `isError`;
- SDK v2 needs Node 20+, CI and Docker already use Node 20;
- tests connect a real SDK v2 client (`@modelcontextprotocol/client`, dev dependency) through `fastify.inject()` in both eras. The same client was checked over real HTTP against a local server: the legacy client negotiates 2025-11-25, the pinned one 2026-07-28.
- Step 3: notes.web has only the English locale, so texts are added to `en.json` only.
- Step 3: the section is a separate component `src/presentation/components/mcp/McpTokens.vue` with the `useUserMcpSettings` composable; `Settings.vue` only renders it. Following review, the domain layer is named after user settings: `UserSettingsService` and `UserSettingsRepository` with `getMcpTokens`, `createMcpToken`, `revokeMcpToken`. Snippets use the `text-monospaced` class from the codex-ui typescale.
- Step 3: the "Create token" button sits below the form card, like the "Logout" button in the General section.
- Step 3: checked in the browser against a local API: creating read and write tokens, the one-time token with setup snippets, the list with usage, revoking with confirmation, no horizontal scroll at 375px width.

## Verification

MCP SDK v2 needs Node 20+, so switch to the `.nvmrc` version first (`nvm use`).

The repo may contain other sessions' worktrees in `.claude/worktrees`, which ESLint and Vitest pick up. Run checks on this checkout only:

- `yarn typecheck`
- `yarn eslint src`
- `yarn vitest run --exclude '.claude/**'`
5 changes: 4 additions & 1 deletion eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
34 changes: 34 additions & 0 deletions migrations/tenant/0036-mcp-tokens@add-mcp-tokens-table.sql
Original file line number Diff line number Diff line change
@@ -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);
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -47,6 +48,8 @@
"@fastify/oauth2": "^7.2.1",
"@fastify/swagger": "^8.8.0",
"@fastify/swagger-ui": "^1.9.3",
"@modelcontextprotocol/node": "^2.1.2",
"@modelcontextprotocol/server": "^2.4.0",
"@testcontainers/postgresql": "^10.2.1",
"arg": "^5.0.2",
"fastify": "^4.17.0",
Expand All @@ -60,7 +63,7 @@
"prom-client": "^14.2.0",
"sequelize": "^6.31.1",
"testcontainers": "^10.2.1",
"zod": "^3.21.4"
"zod": "^4.2.0"
},
"packageManager": "yarn@3.6.4"
}
13 changes: 13 additions & 0 deletions src/domain/entities/NoteUpdateConflictError.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
87 changes: 87 additions & 0 deletions src/domain/entities/mcpToken.ts
Original file line number Diff line number Diff line change
@@ -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<McpToken, 'userId' | 'tokenHash' | 'preview' | 'permission' | 'label'>;

/**
* MCP token without secret and owner fields, sent to user
*/
export type McpTokenPublic = Omit<McpToken, 'userId' | 'tokenHash'>;

/**
* 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,
};
}
8 changes: 8 additions & 0 deletions src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

/**
Expand Down Expand Up @@ -53,6 +54,11 @@ export interface DomainServices {
* Note visits service instance
*/
noteVisitsService: NoteVisitsService;

/**
* MCP token service instance
*/
mcpTokenService: McpTokenService;
}

/**
Expand Down Expand Up @@ -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,
Expand All @@ -101,5 +108,6 @@ export function init(repositories: Repositories, appConfig: AppConfig): DomainSe
aiService,
editorToolsService,
noteVisitsService,
mcpTokenService,
};
}
Loading
Loading