Compare commits

..

20 Commits

Author SHA1 Message Date
Elian Doran
f0c93cd06e feat(llm): improve display of blocks while streaming 2026-04-01 15:38:23 +03:00
Elian Doran
14e0507689 fix(llm): web search not translated 2026-04-01 15:28:49 +03:00
Elian Doran
393b90f7be feat(llm): display skill read 2026-04-01 15:27:31 +03:00
Elian Doran
47ee5c1d84 feat(llm): display affected note in read current note 2026-04-01 15:11:34 +03:00
Elian Doran
1cb6f2d351 chore(llm): improve layout for tool card 2026-04-01 15:09:45 +03:00
Elian Doran
bb72b0cdfc refactor(llm): proper translation use for element interpolation 2026-04-01 15:04:07 +03:00
Elian Doran
ab2467b074 feat(llm): display note creation result 2026-04-01 14:57:45 +03:00
Elian Doran
2d652523bb feat(llm): display a reference to the affected note in tool calls 2026-04-01 14:55:18 +03:00
Elian Doran
55df50253f feat(llm): improve tool call style slightly 2026-04-01 14:51:17 +03:00
Elian Doran
d009914ff9 chore(llm): update system prompt for tool creation 2026-04-01 14:48:13 +03:00
Elian Doran
5e97222206 feat(llm): display friendly tool names 2026-04-01 14:47:17 +03:00
Elian Doran
038705483b refactor(llm): integrate tools requiring context 2026-04-01 12:34:14 +03:00
Elian Doran
10c9ba5783 refactor(llm): different way to register tools 2026-04-01 12:20:08 +03:00
Elian Doran
a1d008688b chore(llm): harden MCP against uninitialized database 2026-04-01 11:56:46 +03:00
Elian Doran
78a043c536 test(llm): test MCP using supertest 2026-04-01 11:52:49 +03:00
Elian Doran
acdc840f17 feat(llm): improve MCP settings card 2026-04-01 11:46:54 +03:00
Elian Doran
63d4b8894b feat(llm): gate MCP access behind option 2026-04-01 11:44:01 +03:00
Elian Doran
23ccbf9642 chore(llm): add instructions for MCP use 2026-04-01 11:30:47 +03:00
Elian Doran
a5793ff768 chore(mcp): add MCP config for localhost 2026-04-01 11:29:29 +03:00
Elian Doran
a84e2f72c3 feat(llm/mcp): first implementation 2026-04-01 11:19:10 +03:00
30 changed files with 1115 additions and 547 deletions

View File

@@ -186,6 +186,14 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main
**Auth note**: ETAPI uses basic auth with tokens. Internal API endpoints trust the frontend.
### Adding New LLM Tools
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `hierarchy_tools.ts`) or create a new module
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations or `needsContext: true` for tools that need the current note context
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
### Database Migrations
- Add scripts in `apps/server/src/migrations/YYMMDD_HHMM__description.sql`
- Update schema in `apps/server/src/assets/db/schema.sql`
@@ -213,6 +221,12 @@ When adding query parameters to ETAPI endpoints (`apps/server/src/etapi/`), main
10. **Attribute inheritance can be complex** - When checking for labels/relations, use `note.getOwnedAttribute()` for direct attributes or `note.getAttribute()` for inherited ones. Don't assume attributes are directly on the note.
## MCP Server
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
- Use it to interact with actual note data when developing or debugging note-related features
## TypeScript Configuration
- **Project references**: Monorepo uses TypeScript project references (`tsconfig.json`)
@@ -299,6 +313,7 @@ Trilium provides powerful user scripting capabilities:
- Translation files in `apps/client/src/translations/`
- Use translation system via `t()` function
- Automatic pluralization: Add `_other` suffix to translation keys (e.g., `item` and `item_other` for singular/plural)
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
## Testing Conventions

8
.mcp.json Normal file
View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"trilium": {
"type": "http",
"url": "http://localhost:8080/mcp"
}
}
}

View File

@@ -120,6 +120,7 @@ Trilium provides powerful user scripting capabilities:
- Supported languages: English, German, Spanish, French, Romanian, Chinese
- **Only add new translation keys to `en/translation.json`** — translations for other languages are managed via Weblate and will be contributed by the community
- Third-party components (e.g., mind-map context menu) should use i18next `t()` for their labels, with the English strings added to `en/translation.json` under a dedicated namespace (e.g., `"mind-map"`)
- When a translated string contains **interpolated components** (e.g. links, note references) whose order may vary across languages, use `<Trans>` from `react-i18next` instead of `t()`. This lets translators reorder components freely (e.g. `"<Note/> in <Parent/>"` vs `"in <Parent/>, <Note/>"`)
### Security Considerations
- Per-note encryption with granular protected sessions
@@ -151,6 +152,14 @@ Trilium provides powerful user scripting capabilities:
- Create new package in `packages/` following existing plugin structure
- Register in `packages/ckeditor5/src/plugins.ts`
### Adding New LLM Tools
Tools are defined using `defineTools()` in `apps/server/src/services/llm/tools/` and automatically registered for both the LLM chat and MCP server.
1. Add the tool definition in the appropriate module (`note_tools.ts`, `attribute_tools.ts`, `hierarchy_tools.ts`) or create a new module
2. Each tool needs: `description`, `inputSchema` (Zod), `execute` function, and optionally `mutates: true` for write operations or `needsContext: true` for tools that need the current note context
3. If creating a new module, wrap tools in `defineTools({...})` and add the registry to `allToolRegistries` in `tools/index.ts`
4. Add a client-side friendly name in `apps/client/src/translations/en/translation.json` under `llm.tools.<tool_name>` — use **imperative tense** (e.g. "Search notes", "Create note", "Get attributes"), not present continuous
### Database Migrations
- Add migration scripts in `apps/server/src/migrations/`
- Update schema in `apps/server/src/assets/db/schema.sql`
@@ -161,6 +170,12 @@ Trilium provides powerful user scripting capabilities:
- **Do not use `import.meta.url`/`fileURLToPath`** to resolve file paths — the server is bundled into CJS for production, so `import.meta.url` will not point to the source directory
- **Do not use `__dirname` with relative paths** from source files — after bundling, `__dirname` points to the bundle output, not the original source tree
## MCP Server
- Trilium exposes an MCP (Model Context Protocol) server at `http://localhost:8080/mcp`, configured in `.mcp.json`
- The MCP server is **only available when the Trilium server is running** (`pnpm run server:start`)
- It provides tools for reading, searching, and modifying notes directly from the AI assistant
- Use it to interact with actual note data when developing or debugging note-related features
## Build System Notes
- Uses pnpm for monorepo management
- Vite for fast development builds

View File

@@ -54,7 +54,7 @@
"draggabilly": "3.0.0",
"force-graph": "1.51.2",
"globals": "17.4.0",
"i18next": "26.0.1",
"i18next": "25.10.10",
"i18next-http-backend": "3.0.2",
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",

View File

@@ -24,7 +24,8 @@ export async function initLocale() {
backend: {
loadPath: `${window.glob.assetPath}/translations/{{lng}}/{{ns}}.json`
},
returnEmptyString: false
returnEmptyString: false,
showSupportNotice: false
});
await setDayjsLocale(locale);

View File

@@ -2305,6 +2305,26 @@
"delete_provider_confirmation": "Are you sure you want to delete the provider \"{{name}}\"?",
"api_key": "API Key",
"api_key_placeholder": "Enter your API key",
"cancel": "Cancel"
"cancel": "Cancel",
"mcp_title": "MCP (Model Context Protocol)",
"mcp_enabled": "Enable MCP server",
"mcp_enabled_description": "Expose a Model Context Protocol (MCP) endpoint so that AI coding assistants (e.g. Claude Code, GitHub Copilot) can read and modify your notes. The endpoint is only accessible from localhost.",
"tools": {
"search_notes": "Search notes",
"read_note": "Read note",
"update_note_content": "Update note content",
"append_to_note": "Append to note",
"create_note": "Create note",
"get_current_note": "Read current note",
"get_attributes": "Get attributes",
"get_attribute": "Get attribute",
"set_attribute": "Set attribute",
"delete_attribute": "Delete attribute",
"get_child_notes": "Get child notes",
"get_subtree": "Get subtree",
"load_skill": "Load skill",
"web_search": "Web search",
"note_in_parent": "<Note/> in <Parent/>"
}
}
}

View File

@@ -3,8 +3,10 @@ import "./LlmChat.css";
import { Marked } from "marked";
import { useMemo } from "preact/hooks";
import { Trans } from "react-i18next";
import { t } from "../../../services/i18n.js";
import utils from "../../../services/utils.js";
import { NewNoteLink } from "../../react/NoteLink.js";
import { SanitizedHtml } from "../../react/RawHtml.js";
import { type ContentBlock, getMessageText, type StoredMessage, type ToolCall } from "./llm_chat_types.js";
@@ -30,17 +32,86 @@ interface Props {
isStreaming?: boolean;
}
interface ToolCallContext {
/** The primary note the tool operates on or created. */
noteId: string | null;
/** The parent note, shown as "in <parent>" for creation tools. */
parentNoteId: string | null;
/** Plain-text detail (e.g. skill name, search query) when no note ref is available. */
detailText: string | null;
}
/** Try to extract a noteId from the tool call's result JSON. */
function parseResultNoteId(toolCall: ToolCall): string | null {
if (!toolCall.result) return null;
try {
const result = typeof toolCall.result === "string"
? JSON.parse(toolCall.result)
: toolCall.result;
return result?.noteId || null;
} catch {
return null;
}
}
/** Extract contextual info from a tool call for display in the summary. */
function getToolCallContext(toolCall: ToolCall): ToolCallContext {
const input = toolCall.input;
const parentNoteId = (input?.parentNoteId as string) || null;
// For creation tools, the created note ID is in the result.
if (parentNoteId) {
const createdNoteId = parseResultNoteId(toolCall);
if (createdNoteId) {
return { noteId: createdNoteId, parentNoteId, detailText: null };
}
}
const noteId = (input?.noteId as string) || parentNoteId || parseResultNoteId(toolCall);
if (noteId) {
return { noteId, parentNoteId: null, detailText: null };
}
const detailText = (input?.name ?? input?.query) as string | undefined;
return { noteId: null, parentNoteId: null, detailText: detailText || null };
}
function toolCallIcon(toolCall: ToolCall): string {
if (toolCall.isError) return "bx bx-error-circle";
if (toolCall.result) return "bx bx-check";
return "bx bx-loader-alt bx-spin";
}
function ToolCallCard({ toolCall }: { toolCall: ToolCall }) {
const classes = [
"llm-chat-tool-call-inline",
toolCall.isError && "llm-chat-tool-call-error"
].filter(Boolean).join(" ");
const { noteId: refNoteId, parentNoteId: refParentId, detailText } = getToolCallContext(toolCall);
return (
<details className={classes}>
<summary className="llm-chat-tool-call-inline-summary">
<span className={toolCall.isError ? "bx bx-error-circle" : "bx bx-wrench"} />
{toolCall.toolName}
<span className={toolCallIcon(toolCall)} />
{t(`llm.tools.${toolCall.toolName}`, { defaultValue: toolCall.toolName })}
{detailText && (
<span className="llm-chat-tool-call-detail">{detailText}</span>
)}
{refNoteId && (
<span className="llm-chat-tool-call-note-ref">
{refParentId ? (
<Trans
i18nKey="llm.tools.note_in_parent"
components={{
Note: <NewNoteLink notePath={refNoteId} showNoteIcon noPreview />,
Parent: <NewNoteLink notePath={refParentId} showNoteIcon noPreview />
} as any}
/>
) : (
<NewNoteLink notePath={refNoteId} showNoteIcon noPreview />
)}
</span>
)}
{toolCall.isError && <span className="llm-chat-tool-call-error-badge">{t("llm_chat.tool_error")}</span>}
</summary>
<div className="llm-chat-tool-call-inline-body">

View File

@@ -514,7 +514,6 @@
font-weight: 600;
margin-bottom: 0.5rem;
color: var(--main-text-color);
font-family: var(--monospace-font-family, monospace);
}
.llm-chat-tool-call-input,
@@ -542,43 +541,57 @@
overflow-y: auto;
}
/* Inline tool call cards (timeline style) */
/* Inline tool call cards */
.llm-chat-tool-call-inline {
margin: 0.5rem 0;
background: var(--accented-background-color);
border-radius: 6px;
border-left: 3px solid var(--muted-text-color);
border: 1px solid var(--main-border-color);
border-radius: 8px;
font-size: 0.85rem;
}
.llm-chat-tool-call-inline-summary {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
align-items: baseline;
gap: 0.25rem;
padding: 0.5rem 0.75rem;
cursor: pointer;
list-style: none;
font-weight: 500;
color: var(--muted-text-color);
font-family: var(--monospace-font-family, monospace);
}
.llm-chat-tool-call-inline-summary::-webkit-details-marker {
display: none;
}
.llm-chat-tool-call-inline-summary::before {
content: "";
font-size: 0.7em;
.llm-chat-tool-call-inline-summary::after {
content: "";
margin-left: auto;
font-size: 1em;
transition: transform 0.2s ease;
}
.llm-chat-tool-call-inline[open] .llm-chat-tool-call-inline-summary::before {
transform: rotate(90deg);
.llm-chat-tool-call-inline[open] .llm-chat-tool-call-inline-summary::after {
transform: rotate(180deg);
}
.llm-chat-tool-call-inline-summary .bx {
.llm-chat-tool-call-inline-summary > .bx {
font-size: 1rem;
margin-right: 0.15rem;
}
.llm-chat-tool-call-detail,
.llm-chat-tool-call-note-ref {
font-weight: 400;
color: var(--main-text-color);
}
.llm-chat-tool-call-detail::before,
.llm-chat-tool-call-note-ref::before {
content: "—";
margin-right: 0.35rem;
color: var(--muted-text-color);
}
.llm-chat-tool-call-inline-body {
@@ -610,7 +623,7 @@
/* Tool call error styling */
.llm-chat-tool-call-error {
border-left-color: var(--danger-color, #dc3545);
border-color: var(--danger-color, #dc3545);
}
.llm-chat-tool-call-error .llm-chat-tool-call-inline-summary {
@@ -620,7 +633,6 @@
.llm-chat-tool-call-error-badge {
font-size: 0.75rem;
font-weight: 400;
font-family: var(--main-font-family);
color: var(--danger-color, #dc3545);
opacity: 0.8;
}

View File

@@ -83,12 +83,12 @@ export default function LlmChat({ note, ntxId, noteContext }: TypeWidgetProps) {
isStreaming
/>
)}
{chat.isStreaming && chat.streamingContent && (
{chat.isStreaming && chat.streamingBlocks.length > 0 && (
<ChatMessage
message={{
id: "streaming",
role: "assistant",
content: chat.streamingContent,
content: chat.streamingBlocks,
createdAt: new Date().toISOString(),
citations: chat.pendingCitations.length > 0 ? chat.pendingCitations : undefined
}}

View File

@@ -28,6 +28,7 @@ export interface UseLlmChatReturn {
input: string;
isStreaming: boolean;
streamingContent: string;
streamingBlocks: ContentBlock[];
streamingThinking: string;
toolActivity: string | null;
pendingCitations: LlmCitation[];
@@ -75,6 +76,7 @@ export function useLlmChat(
const [input, setInput] = useState("");
const [isStreaming, setIsStreaming] = useState(false);
const [streamingContent, setStreamingContent] = useState("");
const [streamingBlocks, setStreamingBlocks] = useState<ContentBlock[]>([]);
const [streamingThinking, setStreamingThinking] = useState("");
const [toolActivity, setToolActivity] = useState<string | null>(null);
const [pendingCitations, setPendingCitations] = useState<LlmCitation[]>([]);
@@ -213,6 +215,7 @@ export function useLlmChat(
setInput("");
setIsStreaming(true);
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
let thinkingContent = "";
@@ -262,6 +265,7 @@ export function useLlmChat(
.filter((b): b is ContentBlock & { type: "text" } => b.type === "text")
.map(b => b.content)
.join(""));
setStreamingBlocks([...contentBlocks]);
setToolActivity(null);
},
onThinking: (text) => {
@@ -282,6 +286,7 @@ export function useLlmChat(
input: toolInput
}
});
setStreamingBlocks([...contentBlocks]);
},
onToolResult: (toolName, result, isError) => {
// Find the most recent tool_call block for this tool without a result
@@ -293,6 +298,7 @@ export function useLlmChat(
break;
}
}
setStreamingBlocks([...contentBlocks]);
},
onCitation: (citation) => {
citations.push(citation);
@@ -314,6 +320,7 @@ export function useLlmChat(
const finalMessages = [...newMessages, errorMessage];
setMessages(finalMessages);
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
setIsStreaming(false);
setToolActivity(null);
@@ -348,6 +355,7 @@ export function useLlmChat(
}
setStreamingContent("");
setStreamingBlocks([]);
setStreamingThinking("");
setPendingCitations([]);
setIsStreaming(false);
@@ -370,6 +378,7 @@ export function useLlmChat(
input,
isStreaming,
streamingContent,
streamingBlocks,
streamingThinking,
toolActivity,
pendingCitations,

View File

@@ -1,11 +1,12 @@
import { useCallback, useMemo, useState } from "preact/hooks";
import { t } from "../../../services/i18n";
import Button from "../../react/Button";
import FormCheckbox from "../../react/FormCheckbox";
import OptionsSection from "./components/OptionsSection";
import AddProviderModal, { type LlmProviderConfig, PROVIDER_TYPES } from "./llm/AddProviderModal";
import ActionButton from "../../react/ActionButton";
import dialog from "../../../services/dialog";
import { useTriliumOption } from "../../react/hooks";
import { useTriliumOption, useTriliumOptionBool } from "../../react/hooks";
export default function LlmSettings() {
const [providersJson, setProvidersJson] = useTriliumOption("llmProviders");
@@ -33,28 +34,48 @@ export default function LlmSettings() {
}, [providers, setProviders]);
return (
<OptionsSection title={t("llm.settings_title")}>
<p>{t("llm.settings_description")}</p>
<>
<OptionsSection title={t("llm.settings_title")}>
<p className="form-text">{t("llm.settings_description")}</p>
<Button
size="small"
icon="bx bx-plus"
text={t("llm.add_provider")}
onClick={() => setShowAddModal(true)}
/>
<Button
size="small"
icon="bx bx-plus"
text={t("llm.add_provider")}
onClick={() => setShowAddModal(true)}
/>
<hr />
<hr />
<h5>{t("llm.configured_providers")}</h5>
<ProviderList
providers={providers}
onDelete={handleDeleteProvider}
/>
<h5>{t("llm.configured_providers")}</h5>
<ProviderList
providers={providers}
onDelete={handleDeleteProvider}
/>
<AddProviderModal
show={showAddModal}
onHidden={() => setShowAddModal(false)}
onSave={handleAddProvider}
<AddProviderModal
show={showAddModal}
onHidden={() => setShowAddModal(false)}
onSave={handleAddProvider}
/>
</OptionsSection>
<McpSettings />
</>
);
}
function McpSettings() {
const [mcpEnabled, setMcpEnabled] = useTriliumOptionBool("mcpEnabled");
return (
<OptionsSection title={t("llm.mcp_title")}>
<p className="form-text">{t("llm.mcp_enabled_description")}</p>
<FormCheckbox
name="mcp-enabled"
label={t("llm.mcp_enabled")}
currentValue={mcpEnabled}
onChange={setMcpEnabled}
/>
</OptionsSection>
);

View File

@@ -33,6 +33,7 @@
"@ai-sdk/anthropic": "3.0.64",
"@ai-sdk/google": "3.0.54",
"@ai-sdk/openai": "3.0.49",
"@modelcontextprotocol/sdk": "^1.12.1",
"ai": "6.0.142",
"better-sqlite3": "12.8.0",
"html-to-text": "9.0.5",
@@ -103,7 +104,7 @@
"html2plaintext": "2.1.4",
"http-proxy-agent": "8.0.0",
"https-proxy-agent": "8.0.0",
"i18next": "26.0.1",
"i18next": "25.10.10",
"i18next-fs-backend": "2.6.1",
"image-type": "6.1.0",
"ini": "6.0.0",

View File

@@ -0,0 +1,160 @@
import { Application } from "express";
import { beforeAll, describe, expect, it } from "vitest";
import supertest from "supertest";
import { createNote, login } from "./utils.js";
import config from "../../src/services/config.js";
import becca from "../../src/becca/becca.js";
import optionService from "../../src/services/options.js";
import cls from "../../src/services/cls.js";
let app: Application;
let token: string;
const USER = "etapi";
const MCP_ACCEPT = "application/json, text/event-stream";
/** Builds a JSON-RPC 2.0 request body for MCP. */
function jsonRpc(method: string, params?: Record<string, unknown>, id: number = 1) {
return { jsonrpc: "2.0", id, method, params };
}
/** Parses the JSON-RPC response from an SSE response text. */
function parseSseResponse(text: string) {
const dataLine = text.split("\n").find(line => line.startsWith("data: "));
if (!dataLine) {
throw new Error(`No SSE data line found in response: ${text}`);
}
return JSON.parse(dataLine.slice("data: ".length));
}
function mcpPost(app: Application) {
return supertest(app)
.post("/mcp")
.set("Accept", MCP_ACCEPT)
.set("Content-Type", "application/json");
}
function setOption(name: Parameters<typeof optionService.setOption>[0], value: string) {
cls.init(() => optionService.setOption(name, value));
}
describe("mcp", () => {
beforeAll(async () => {
config.General.noAuthentication = false;
const buildApp = (await (import("../../src/app.js"))).default;
app = await buildApp();
token = await login(app);
});
describe("option gate", () => {
it("rejects requests when mcpEnabled is false", async () => {
setOption("mcpEnabled", "false");
const response = await mcpPost(app)
.send(jsonRpc("initialize"))
.expect(403);
expect(response.body.error).toContain("disabled");
});
it("rejects requests when mcpEnabled option does not exist", async () => {
const saved = becca.options["mcpEnabled"];
delete becca.options["mcpEnabled"];
try {
const response = await mcpPost(app)
.send(jsonRpc("initialize"))
.expect(403);
expect(response.body.error).toContain("disabled");
} finally {
becca.options["mcpEnabled"] = saved;
}
});
it("accepts requests when mcpEnabled is true", async () => {
setOption("mcpEnabled", "true");
const response = await mcpPost(app)
.send(jsonRpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" }
}));
expect(response.status).not.toBe(403);
});
});
describe("protocol", () => {
beforeAll(() => {
setOption("mcpEnabled", "true");
});
it("initializes and returns server capabilities", async () => {
const response = await mcpPost(app)
.send(jsonRpc("initialize", {
protocolVersion: "2025-03-26",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" }
}))
.expect(200);
const body = parseSseResponse(response.text);
expect(body.result.serverInfo.name).toBe("trilium-notes");
expect(body.result.capabilities.tools).toBeDefined();
});
it("lists available tools", async () => {
const response = await mcpPost(app)
.send(jsonRpc("tools/list"))
.expect(200);
const body = parseSseResponse(response.text);
const toolNames: string[] = body.result.tools.map((t: { name: string }) => t.name);
expect(toolNames).toContain("search_notes");
expect(toolNames).toContain("read_note");
expect(toolNames).toContain("create_note");
expect(toolNames).not.toContain("get_current_note");
});
});
describe("tools", () => {
let noteId: string;
beforeAll(async () => {
setOption("mcpEnabled", "true");
noteId = await createNote(app, token, "MCP test note content");
});
it("searches for notes", async () => {
const response = await mcpPost(app)
.send(jsonRpc("tools/call", {
name: "search_notes",
arguments: { query: "MCP test note content" }
}))
.expect(200);
const body = parseSseResponse(response.text);
expect(body.result).toBeDefined();
const content = body.result.content;
expect(content.length).toBeGreaterThan(0);
expect(content[0].text).toContain(noteId);
});
it("reads a note by ID", async () => {
const response = await mcpPost(app)
.send(jsonRpc("tools/call", {
name: "read_note",
arguments: { noteId }
}))
.expect(200);
const body = parseSseResponse(response.text);
expect(body.result).toBeDefined();
const parsed = JSON.parse(body.result.content[0].text);
expect(parsed.noteId).toBe(noteId);
expect(parsed.content).toContain("MCP test note content");
});
});
});

View File

@@ -14,6 +14,7 @@ import favicon from "serve-favicon";
import assets from "./routes/assets.js";
import custom from "./routes/custom.js";
import error_handlers from "./routes/error_handlers.js";
import mcpRoutes from "./routes/mcp.js";
import routes from "./routes/routes.js";
import config from "./services/config.js";
import { startScheduledCleanup } from "./services/erase.js";
@@ -58,8 +59,8 @@ export default async function buildApp() {
app.use(compression({
// Skip compression for SSE endpoints to enable real-time streaming
filter: (req, res) => {
// Skip compression for LLM chat streaming endpoint
if (req.path === "/api/llm-chat/stream") {
// Skip compression for SSE-capable endpoints
if (req.path === "/api/llm-chat/stream" || req.path === "/mcp") {
return false;
}
return compression.filter(req, res);
@@ -90,6 +91,10 @@ export default async function buildApp() {
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
// MCP is registered before session/auth middleware — it uses its own
// localhost-only guard and does not require Trilium authentication.
mcpRoutes.register(app);
app.use(express.static(path.join(publicDir, "root")));
app.use(`/manifest.webmanifest`, express.static(path.join(publicAssetsDir, "manifest.webmanifest")));
app.use(`/robots.txt`, express.static(path.join(publicAssetsDir, "robots.txt")));

View File

@@ -105,7 +105,8 @@ const ALLOWED_OPTIONS = new Set<OptionNames>([
"newLayout",
"mfaEnabled",
"mfaMethod",
"llmProviders"
"llmProviders",
"mcpEnabled"
]);
function getOptions() {

View File

@@ -0,0 +1,62 @@
/**
* MCP (Model Context Protocol) HTTP route handler.
*
* Mounts the Streamable HTTP transport at `/mcp` with a localhost-only guard.
* No authentication is required — access is restricted to loopback addresses.
*/
import type express from "express";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import { createMcpServer } from "../services/mcp/mcp_server.js";
import log from "../services/log.js";
import optionService from "../services/options.js";
const LOCALHOST_ADDRESSES = new Set(["127.0.0.1", "::1", "::ffff:127.0.0.1"]);
function mcpGuard(req: express.Request, res: express.Response, next: express.NextFunction) {
if (optionService.getOptionOrNull("mcpEnabled") !== "true") {
res.status(403).json({ error: "MCP server is disabled. Enable it in Options > AI / LLM." });
return;
}
if (!LOCALHOST_ADDRESSES.has(req.socket.remoteAddress ?? "")) {
res.status(403).json({ error: "MCP is only available from localhost" });
return;
}
next();
}
async function handleMcpRequest(req: express.Request, res: express.Response) {
try {
const server = createMcpServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined // stateless
});
res.on("close", () => {
transport.close();
server.close();
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
log.error(`MCP request error: ${err}`);
if (!res.headersSent) {
res.status(500).json({ error: "Internal MCP error" });
}
}
}
export function register(app: express.Application) {
app.post("/mcp", mcpGuard, handleMcpRequest);
app.get("/mcp", mcpGuard, handleMcpRequest);
app.delete("/mcp", mcpGuard, handleMcpRequest);
log.info("MCP server registered at /mcp (localhost only)");
}
export default { register };

View File

@@ -18,7 +18,8 @@ export async function initializeTranslations() {
ns: "server",
backend: {
loadPath: join(resourceDir, "assets/translations/{{lng}}/{{ns}}.json")
}
},
showSupportNotice: false
});
// Initialize dayjs locale.

View File

@@ -9,7 +9,8 @@ import type { LlmMessage } from "@triliumnext/commons";
import becca from "../../../becca/becca.js";
import { getSkillsSummary } from "../skills/index.js";
import { noteTools, attributeTools, hierarchyTools, skillTools, currentNoteTools } from "../tools/index.js";
import { allToolRegistries } from "../tools/index.js";
import type { ToolContext } from "../tools/tool_registry.js";
import type { LlmProvider, LlmProviderConfig, ModelInfo, ModelPricing, StreamResult } from "../types.js";
const DEFAULT_MAX_TOKENS = 8096;
@@ -128,15 +129,13 @@ export abstract class BaseProvider implements LlmProvider {
this.addWebSearchTool(tools);
}
if (config.contextNoteId) {
Object.assign(tools, currentNoteTools(config.contextNoteId));
}
if (config.enableNoteTools) {
Object.assign(tools, noteTools);
Object.assign(tools, attributeTools);
Object.assign(tools, hierarchyTools);
Object.assign(tools, skillTools);
const context: ToolContext | undefined = config.contextNoteId
? { contextNoteId: config.contextNoteId }
: undefined;
for (const registry of allToolRegistries) {
Object.assign(tools, registry.toToolSet(context));
}
}
return tools;

View File

@@ -7,10 +7,10 @@
import { readFile } from "fs/promises";
import { join } from "path";
import { tool } from "ai";
import { z } from "zod";
import resourceDir from "../../resource_dir.js";
import { defineTools } from "../tools/tool_registry.js";
const SKILLS_DIR = join(resourceDir.RESOURCE_DIR, "llm", "skills");
@@ -55,24 +55,19 @@ export function getSkillsSummary(): string {
.join("\n");
}
/**
* The load_skill tool — lets the LLM fetch full instructions on demand.
*/
export const loadSkill = tool({
description: "Load a skill to get specialized instructions. Available skills:\n"
+ SKILLS.map((s) => `- ${s.name}: ${s.description}`).join("\n"),
inputSchema: z.object({
name: z.string().describe("The skill name to load")
}),
execute: async ({ name }) => {
const content = await loadSkillContent(name);
if (!content) {
return { error: `Unknown skill: '${name}'. Available: ${SKILLS.map((s) => s.name).join(", ")}` };
export const skillTools = defineTools({
load_skill: {
description: "Load a skill to get specialized instructions. Available skills:\n"
+ SKILLS.map((s) => `- ${s.name}: ${s.description}`).join("\n"),
inputSchema: z.object({
name: z.string().describe("The skill name to load")
}),
execute: async ({ name }) => {
const content = await loadSkillContent(name);
if (!content) {
return { error: `Unknown skill: '${name}'. Available: ${SKILLS.map((s) => s.name).join(", ")}` };
}
return { skill: name, instructions: content };
}
return { skill: name, instructions: content };
}
});
export const skillTools = {
load_skill: loadSkill
};

View File

@@ -2,136 +2,121 @@
* LLM tools for attribute operations (get, set, delete labels/relations).
*/
import { tool } from "ai";
import { z } from "zod";
import becca from "../../../becca/becca.js";
import attributeService from "../../attributes.js";
import { defineTools } from "./tool_registry.js";
/**
* Get all owned attributes (labels/relations) of a note.
*/
export const getAttributes = tool({
description: "Get all attributes (labels and relations) of a note. Labels store text values; relations link to other notes by ID.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
export const attributeTools = defineTools({
get_attributes: {
description: "Get all attributes (labels and relations) of a note. Labels store text values; relations link to other notes by ID.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
return note.getOwnedAttributes()
.filter((attr) => !attr.isAutoLink())
.map((attr) => ({
attributeId: attr.attributeId,
type: attr.type,
name: attr.name,
value: attr.value,
isInheritable: attr.isInheritable
}));
}
},
return note.getOwnedAttributes()
.filter((attr) => !attr.isAutoLink())
.map((attr) => ({
attributeId: attr.attributeId,
type: attr.type,
name: attr.name,
value: attr.value,
isInheritable: attr.isInheritable
}));
get_attribute: {
description: "Get a single attribute by its ID.",
inputSchema: z.object({
attributeId: z.string().describe("The ID of the attribute")
}),
execute: async ({ attributeId }) => {
const attribute = becca.getAttribute(attributeId);
if (!attribute) {
return { error: "Attribute not found" };
}
return {
attributeId: attribute.attributeId,
noteId: attribute.noteId,
type: attribute.type,
name: attribute.name,
value: attribute.value,
isInheritable: attribute.isInheritable
};
}
},
set_attribute: {
description: "Add or update an attribute on a note. If an attribute with the same type and name exists, it is updated; otherwise a new one is created. Use type 'label' for text values, 'relation' for linking to another note (value must be a noteId).",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note"),
type: z.enum(["label", "relation"]).describe("The attribute type"),
name: z.string().describe("The attribute name"),
value: z.string().optional().describe("The attribute value (for relations, this must be a target noteId)")
}),
mutates: true,
execute: async ({ noteId, type, name, value = "" }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (note.isProtected) {
return { error: "Note is protected and cannot be modified" };
}
if (attributeService.isAttributeDangerous(type, name)) {
return { error: `Attribute '${name}' is potentially dangerous and cannot be set by the LLM` };
}
if (type === "relation" && value && !becca.getNote(value)) {
return { error: "Target note not found for relation" };
}
note.setAttribute(type, name, value);
return {
success: true,
noteId: note.noteId,
type,
name,
value
};
}
},
delete_attribute: {
description: "Remove an attribute from a note by its attribute ID.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note that owns the attribute"),
attributeId: z.string().describe("The ID of the attribute to delete")
}),
mutates: true,
execute: async ({ noteId, attributeId }) => {
const attribute = becca.getAttribute(attributeId);
if (!attribute) {
return { error: "Attribute not found" };
}
if (attribute.noteId !== noteId) {
return { error: "Attribute does not belong to the specified note" };
}
const note = becca.getNote(noteId);
if (note?.isProtected) {
return { error: "Note is protected and cannot be modified" };
}
attribute.markAsDeleted();
return {
success: true,
attributeId
};
}
}
});
/**
* Get a single attribute by its ID.
*/
export const getAttribute = tool({
description: "Get a single attribute by its ID.",
inputSchema: z.object({
attributeId: z.string().describe("The ID of the attribute")
}),
execute: async ({ attributeId }) => {
const attribute = becca.getAttribute(attributeId);
if (!attribute) {
return { error: "Attribute not found" };
}
return {
attributeId: attribute.attributeId,
noteId: attribute.noteId,
type: attribute.type,
name: attribute.name,
value: attribute.value,
isInheritable: attribute.isInheritable
};
}
});
/**
* Add or update an attribute on a note.
*/
export const setAttribute = tool({
description: "Add or update an attribute on a note. If an attribute with the same type and name exists, it is updated; otherwise a new one is created. Use type 'label' for text values, 'relation' for linking to another note (value must be a noteId).",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note"),
type: z.enum(["label", "relation"]).describe("The attribute type"),
name: z.string().describe("The attribute name"),
value: z.string().optional().describe("The attribute value (for relations, this must be a target noteId)")
}),
execute: async ({ noteId, type, name, value = "" }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (note.isProtected) {
return { error: "Note is protected and cannot be modified" };
}
if (attributeService.isAttributeDangerous(type, name)) {
return { error: `Attribute '${name}' is potentially dangerous and cannot be set by the LLM` };
}
if (type === "relation" && value && !becca.getNote(value)) {
return { error: "Target note not found for relation" };
}
note.setAttribute(type, name, value);
return {
success: true,
noteId: note.noteId,
type,
name,
value
};
}
});
/**
* Remove an attribute from a note.
*/
export const deleteAttribute = tool({
description: "Remove an attribute from a note by its attribute ID.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note that owns the attribute"),
attributeId: z.string().describe("The ID of the attribute to delete")
}),
execute: async ({ noteId, attributeId }) => {
const attribute = becca.getAttribute(attributeId);
if (!attribute) {
return { error: "Attribute not found" };
}
if (attribute.noteId !== noteId) {
return { error: "Attribute does not belong to the specified note" };
}
const note = becca.getNote(noteId);
if (note?.isProtected) {
return { error: "Note is protected and cannot be modified" };
}
attribute.markAsDeleted();
return {
success: true,
attributeId
};
}
});
export const attributeTools = {
get_attributes: getAttributes,
get_attribute: getAttribute,
set_attribute: setAttribute,
delete_attribute: deleteAttribute
};

View File

@@ -2,34 +2,11 @@
* LLM tools for navigating the note hierarchy (tree structure, branches).
*/
import { tool } from "ai";
import { z } from "zod";
import becca from "../../../becca/becca.js";
import type BNote from "../../../becca/entities/bnote.js";
/**
* Get the child notes of a given note.
*/
export const getChildNotes = tool({
description: "Get the immediate child notes of a note. Returns each child's ID, title, type, and whether it has children of its own. Use noteId 'root' to list top-level notes.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the parent note (use 'root' for top-level)")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
return note.getChildNotes().map((child) => ({
noteId: child.noteId,
title: child.getTitleOrProtected(),
type: child.type,
childCount: child.getChildNotes().length
}));
}
});
import { defineTools } from "./tool_registry.js";
//#region Subtree tool implementation
const MAX_DEPTH = 5;
@@ -75,28 +52,42 @@ function buildSubtree(note: BNote, depth: number, maxDepth: number): SubtreeNode
return node;
}
/**
* Get a subtree of notes up to a specified depth.
*/
export const getSubtree = tool({
description: "Get a nested subtree of notes starting from a given note, traversing multiple levels deep. Useful for understanding the structure of a section of the note tree. Each level shows up to 10 children.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the root note for the subtree (use 'root' for the entire tree)"),
depth: z.number().min(1).max(MAX_DEPTH).optional().describe(`How many levels deep to traverse (1-${MAX_DEPTH}). Defaults to 2.`)
}),
execute: async ({ noteId, depth = 2 }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
return buildSubtree(note, 0, depth);
}
});
//#endregion
export const hierarchyTools = {
get_child_notes: getChildNotes,
get_subtree: getSubtree
};
export const hierarchyTools = defineTools({
get_child_notes: {
description: "Get the immediate child notes of a note. Returns each child's ID, title, type, and whether it has children of its own. Use noteId 'root' to list top-level notes.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the parent note (use 'root' for top-level)")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
return note.getChildNotes().map((child) => ({
noteId: child.noteId,
title: child.getTitleOrProtected(),
type: child.type,
childCount: child.getChildNotes().length
}));
}
},
get_subtree: {
description: "Get a nested subtree of notes starting from a given note, traversing multiple levels deep. Useful for understanding the structure of a section of the note tree. Each level shows up to 10 children.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the root note for the subtree (use 'root' for the entire tree)"),
depth: z.number().min(1).max(MAX_DEPTH).optional().describe(`How many levels deep to traverse (1-${MAX_DEPTH}). Defaults to 2.`)
}),
execute: async ({ noteId, depth = 2 }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
return buildSubtree(note, 0, depth);
}
}
});

View File

@@ -3,7 +3,23 @@
* These reuse the same logic as ETAPI without any HTTP overhead.
*/
export { noteTools, currentNoteTools } from "./note_tools.js";
export { noteTools } from "./note_tools.js";
export { attributeTools } from "./attribute_tools.js";
export { hierarchyTools } from "./hierarchy_tools.js";
export { skillTools } from "../skills/index.js";
export type { ToolDefinition } from "./tool_registry.js";
export { ToolRegistry } from "./tool_registry.js";
import { noteTools } from "./note_tools.js";
import { attributeTools } from "./attribute_tools.js";
import { hierarchyTools } from "./hierarchy_tools.js";
import { skillTools } from "../skills/index.js";
import type { ToolRegistry } from "./tool_registry.js";
/** All tool registries, for consumers that need to iterate every tool (e.g. MCP). */
export const allToolRegistries: ToolRegistry[] = [
noteTools,
attributeTools,
hierarchyTools,
skillTools
];

View File

@@ -2,7 +2,6 @@
* LLM tools for note operations (search, read, create, update, append).
*/
import { tool } from "ai";
import { z } from "zod";
import becca from "../../../becca/becca.js";
@@ -11,6 +10,7 @@ import markdownImport from "../../import/markdown.js";
import noteService from "../../notes.js";
import SearchContext from "../../search/search_context.js";
import searchService from "../../search/services/search.js";
import { defineTools, type ToolContext } from "./tool_registry.js";
/**
* Convert note content to a format suitable for LLM consumption.
@@ -39,240 +39,215 @@ function setNoteContentFromLlm(note: { type: string; title: string; setContent:
}
}
/**
* Search for notes in the knowledge base.
*/
export const searchNotes = tool({
description: [
"Search for notes in the user's knowledge base using Trilium search syntax.",
"For complex queries (boolean logic, relations, regex, ordering), load the 'search_syntax' skill first via load_skill.",
"Common patterns:",
"- Full-text: 'rings tolkien' (notes containing both words)",
"- By label: '#book', '#status = done', '#year >= 2000'",
"- By type: 'note.type = code'",
"- By relation: '~author', '~author.title *= Tolkien'",
"- Combined: 'tolkien #book' (full-text + label filter)",
"- Negation: '#!archived' (notes WITHOUT label)"
].join(" "),
inputSchema: z.object({
query: z.string().describe("Search query in Trilium search syntax"),
fastSearch: z.boolean().optional().describe("If true, skip content search (only titles and attributes). Faster for large databases."),
includeArchivedNotes: z.boolean().optional().describe("If true, include archived notes in results."),
ancestorNoteId: z.string().optional().describe("Limit search to a subtree rooted at this note ID."),
limit: z.number().optional().describe("Maximum number of results to return. Defaults to 10.")
}),
execute: async ({ query, fastSearch, includeArchivedNotes, ancestorNoteId, limit = 10 }) => {
const searchContext = new SearchContext({
fastSearch,
includeArchivedNotes,
ancestorNoteId
});
const results = searchService.findResultsWithQuery(query, searchContext);
return results.slice(0, limit).map(sr => {
const note = becca.notes[sr.noteId];
if (!note) return null;
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type
};
}).filter(Boolean);
}
});
/**
* Read the content of a specific note.
*/
export const readNote = tool({
description: "Read the full content of a note by its ID. Use search_notes first to find relevant note IDs. Text notes are returned as Markdown.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to read")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected" };
}
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type,
content: getNoteContentForLlm(note)
};
}
});
/**
* Update the content of a note.
*/
export const updateNoteContent = tool({
description: "Replace the entire content of a note. Use this to completely rewrite a note's content. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to update"),
content: z.string().describe("The new content for the note (Markdown for text notes, plain text for code notes)")
}),
execute: async ({ noteId, content }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected and cannot be modified" };
}
if (!note.hasStringContent()) {
return { error: `Cannot update content for note type: ${note.type}` };
}
note.saveRevision();
setNoteContentFromLlm(note, content);
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected()
};
}
});
/**
* Append content to a note.
*/
export const appendToNote = tool({
description: "Append content to the end of an existing note. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to append to"),
content: z.string().describe("The content to append (Markdown for text notes, plain text for code notes)")
}),
execute: async ({ noteId, content }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected and cannot be modified" };
}
if (!note.hasStringContent()) {
return { error: `Cannot update content for note type: ${note.type}` };
}
const existingContent = note.getContent();
if (typeof existingContent !== "string") {
return { error: "Note has binary content" };
}
let newContent: string;
if (note.type === "text") {
const htmlToAppend = markdownImport.renderToHtml(content, note.getTitleOrProtected());
newContent = existingContent + htmlToAppend;
} else {
newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content;
}
note.saveRevision();
note.setContent(newContent);
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected()
};
}
});
/**
* Create a new note.
*/
export const createNote = tool({
description: [
"Create a new note in the user's knowledge base. Returns the created note's ID and title.",
"Set type to 'text' for rich text notes (content in Markdown) or 'code' for code notes (must also set mime).",
"Common mime values for code notes:",
"'application/javascript;env=frontend' (JS frontend),",
"'application/javascript;env=backend' (JS backend),",
"'text/jsx' (Preact JSX, preferred for frontend widgets),",
"'text/css', 'text/html', 'application/json', 'text/x-python', 'text/x-sh'."
].join(" "),
inputSchema: z.object({
parentNoteId: z.string().describe("The ID of the parent note. Use 'root' for top-level notes."),
title: z.string().describe("The title of the new note"),
content: z.string().describe("The content of the note (Markdown for text notes, plain text for code notes)"),
type: z.enum(["text", "code"]).describe("The type of note to create."),
mime: z.string().optional().describe("MIME type, REQUIRED for code notes (e.g. 'application/javascript;env=backend', 'text/jsx'). Ignored for text notes.")
}),
execute: async ({ parentNoteId, title, content, type, mime }) => {
if (type === "code" && !mime) {
return { error: "mime is required when creating code notes" };
}
const parentNote = becca.getNote(parentNoteId);
if (!parentNote) {
return { error: "Parent note not found" };
}
if (!parentNote.isContentAvailable()) {
return { error: "Cannot create note under a protected parent" };
}
const htmlContent = type === "text"
? markdownImport.renderToHtml(content, title)
: content;
try {
const { note } = noteService.createNewNote({
parentNoteId,
title,
content: htmlContent,
type,
...(mime ? { mime } : {})
export const noteTools = defineTools({
search_notes: {
description: [
"Search for notes in the user's knowledge base using Trilium search syntax.",
"For complex queries (boolean logic, relations, regex, ordering), load the 'search_syntax' skill first via load_skill.",
"Common patterns:",
"- Full-text: 'rings tolkien' (notes containing both words)",
"- By label: '#book', '#status = done', '#year >= 2000'",
"- By type: 'note.type = code'",
"- By relation: '~author', '~author.title *= Tolkien'",
"- Combined: 'tolkien #book' (full-text + label filter)",
"- Negation: '#!archived' (notes WITHOUT label)"
].join(" "),
inputSchema: z.object({
query: z.string().describe("Search query in Trilium search syntax"),
fastSearch: z.boolean().optional().describe("If true, skip content search (only titles and attributes). Faster for large databases."),
includeArchivedNotes: z.boolean().optional().describe("If true, include archived notes in results."),
ancestorNoteId: z.string().optional().describe("Limit search to a subtree rooted at this note ID."),
limit: z.number().optional().describe("Maximum number of results to return. Defaults to 10.")
}),
execute: async ({ query, fastSearch, includeArchivedNotes, ancestorNoteId, limit = 10 }) => {
const searchContext = new SearchContext({
fastSearch,
includeArchivedNotes,
ancestorNoteId
});
const results = searchService.findResultsWithQuery(query, searchContext);
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type
};
} catch (err) {
return { error: err instanceof Error ? err.message : "Failed to create note" };
}
}
});
/**
* Read the content of the note the user is currently viewing.
* Created dynamically so it captures the contextNoteId.
*/
export function currentNoteTools(contextNoteId: string) {
return {
get_current_note: tool({
description: "Read the content of the note the user is currently viewing. Call this when the user asks about or refers to their current note.",
inputSchema: z.object({}),
execute: async () => {
const note = becca.getNote(contextNoteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected" };
}
return results.slice(0, limit).map(sr => {
const note = becca.notes[sr.noteId];
if (!note) return null;
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type,
content: getNoteContentForLlm(note)
type: note.type
};
}
})
};
}
}).filter(Boolean);
}
},
export const noteTools = {
search_notes: searchNotes,
read_note: readNote,
update_note_content: updateNoteContent,
append_to_note: appendToNote,
create_note: createNote
};
read_note: {
description: "Read the full content of a note by its ID. Use search_notes first to find relevant note IDs. Text notes are returned as Markdown.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to read")
}),
execute: async ({ noteId }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected" };
}
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type,
content: getNoteContentForLlm(note)
};
}
},
update_note_content: {
description: "Replace the entire content of a note. Use this to completely rewrite a note's content. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to update"),
content: z.string().describe("The new content for the note (Markdown for text notes, plain text for code notes)")
}),
mutates: true,
execute: async ({ noteId, content }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected and cannot be modified" };
}
if (!note.hasStringContent()) {
return { error: `Cannot update content for note type: ${note.type}` };
}
note.saveRevision();
setNoteContentFromLlm(note, content);
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected()
};
}
},
append_to_note: {
description: "Append content to the end of an existing note. For text notes, provide Markdown content.",
inputSchema: z.object({
noteId: z.string().describe("The ID of the note to append to"),
content: z.string().describe("The content to append (Markdown for text notes, plain text for code notes)")
}),
mutates: true,
execute: async ({ noteId, content }) => {
const note = becca.getNote(noteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected and cannot be modified" };
}
if (!note.hasStringContent()) {
return { error: `Cannot update content for note type: ${note.type}` };
}
const existingContent = note.getContent();
if (typeof existingContent !== "string") {
return { error: "Note has binary content" };
}
let newContent: string;
if (note.type === "text") {
const htmlToAppend = markdownImport.renderToHtml(content, note.getTitleOrProtected());
newContent = existingContent + htmlToAppend;
} else {
newContent = existingContent + (existingContent.endsWith("\n") ? "" : "\n") + content;
}
note.saveRevision();
note.setContent(newContent);
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected()
};
}
},
create_note: {
description: [
"Create a new note in the user's knowledge base. Returns the created note's ID and title.",
"Set type to 'text' for rich text notes (content in Markdown) or 'code' for code notes (must also set mime).",
"Common mime values for code notes:",
"'application/javascript;env=frontend' (JS frontend),",
"'application/javascript;env=backend' (JS backend),",
"'text/jsx' (Preact JSX, preferred for frontend widgets),",
"'text/css', 'text/html', 'application/json', 'text/x-python', 'text/x-sh'."
].join(" "),
inputSchema: z.object({
parentNoteId: z.string().describe("The ID of the parent note. Use 'root' for top-level notes."),
title: z.string().describe("The title of the new note"),
content: z.string().describe("The content of the note (Markdown for text notes, plain text for code notes)"),
type: z.enum(["text", "code"]).describe("The type of note to create."),
mime: z.string().optional().describe("MIME type, REQUIRED for code notes (e.g. 'application/javascript;env=backend', 'text/jsx'). Ignored for text notes.")
}),
mutates: true,
execute: async ({ parentNoteId, title, content, type, mime }) => {
if (type === "code" && !mime) {
return { error: "mime is required when creating code notes" };
}
const parentNote = becca.getNote(parentNoteId);
if (!parentNote) {
return { error: "Parent note not found" };
}
if (!parentNote.isContentAvailable()) {
return { error: "Cannot create note under a protected parent" };
}
const htmlContent = type === "text"
? markdownImport.renderToHtml(content, title)
: content;
try {
const { note } = noteService.createNewNote({
parentNoteId,
title,
content: htmlContent,
type,
...(mime ? { mime } : {})
});
return {
success: true,
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type
};
} catch (err) {
return { error: err instanceof Error ? err.message : "Failed to create note" };
}
}
},
get_current_note: {
description: "Read the content of the note the user is currently viewing. Call this when the user asks about or refers to their current note.",
inputSchema: z.object({}),
needsContext: true as const,
execute: async (_args: Record<string, never>, { contextNoteId }: ToolContext) => {
const note = becca.getNote(contextNoteId);
if (!note) {
return { error: "Note not found" };
}
if (!note.isContentAvailable()) {
return { error: "Note is protected" };
}
return {
noteId: note.noteId,
title: note.getTitleOrProtected(),
type: note.type,
content: getNoteContentForLlm(note)
};
}
}
});

View File

@@ -0,0 +1,95 @@
/**
* Lightweight wrapper around AI tool definitions that carries extra metadata
* (e.g. `mutates`, `needsContext`) while remaining compatible with the Vercel
* AI SDK ToolSet.
*
* Each tool module calls `defineTools({ ... })` to declare its tools.
* Consumers can then:
* - iterate over entries with `for (const [name, def] of registry)` (MCP)
* - convert to an AI SDK ToolSet with `registry.toToolSet()` (LLM chat)
*/
import { tool } from "ai";
import type { z } from "zod";
import type { ToolSet } from "ai";
/** Context passed to tools that declare `needsContext: true`. */
export interface ToolContext {
contextNoteId: string;
}
interface ToolDefinitionBase {
description: string;
inputSchema: z.ZodType;
/** Whether this tool modifies data (needs CLS + transaction wrapping). */
mutates?: boolean;
}
/** A tool that does not require a note context. */
export interface StaticToolDefinition extends ToolDefinitionBase {
needsContext?: false;
execute: (args: any) => Promise<unknown>;
}
/** A tool that requires a note context (e.g. "current note"). */
export interface ContextToolDefinition extends ToolDefinitionBase {
needsContext: true;
execute: (args: any, context: ToolContext) => Promise<unknown>;
}
export type ToolDefinition = StaticToolDefinition | ContextToolDefinition;
/**
* A named collection of tool definitions that can be iterated or converted
* to an AI SDK ToolSet.
*/
export class ToolRegistry implements Iterable<[string, ToolDefinition]> {
constructor(private readonly tools: Record<string, ToolDefinition>) {}
/** Iterate over `[name, definition]` pairs. */
[Symbol.iterator](): Iterator<[string, ToolDefinition]> {
return Object.entries(this.tools)[Symbol.iterator]();
}
/**
* Convert to an AI SDK ToolSet for use with the LLM chat providers.
*
* If `context` is provided, context-aware tools are included with the
* context bound into their execute function. Otherwise they are skipped.
*/
toToolSet(context?: ToolContext): ToolSet {
const set: ToolSet = {};
for (const [name, def] of this) {
if (def.needsContext) {
if (!context) continue;
const boundExecute = (args: any) => def.execute(args, context);
set[name] = tool({
description: def.description,
inputSchema: def.inputSchema,
execute: boundExecute
});
} else {
set[name] = tool({
description: def.description,
inputSchema: def.inputSchema,
execute: def.execute
});
}
}
return set;
}
}
/**
* Define a group of tools with metadata.
*
* ```ts
* export const noteTools = defineTools({
* search_notes: { description: "...", inputSchema: z.object({...}), execute: async (args) => {...} },
* get_current_note: { description: "...", inputSchema: z.object({}), execute: async (args, ctx) => {...}, needsContext: true },
* });
* ```
*/
export function defineTools(tools: Record<string, ToolDefinition>): ToolRegistry {
return new ToolRegistry(tools);
}

View File

@@ -0,0 +1,51 @@
/**
* MCP (Model Context Protocol) server for Trilium Notes.
*
* Exposes existing LLM tools via the MCP protocol so external AI agents
* (e.g. Claude Desktop) can interact with Trilium.
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import appInfo from "../app_info.js";
import cls from "../cls.js";
import sql from "../sql.js";
import { allToolRegistries } from "../llm/tools/index.js";
import type { ToolDefinition } from "../llm/tools/index.js";
import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js";
/**
* Register a tool definition on the MCP server.
*
* Write operations are wrapped in CLS + transaction context so that
* Becca entity tracking works correctly.
*/
function registerTool(server: McpServer, name: string, def: ToolDefinition) {
server.registerTool(name, {
description: def.description,
inputSchema: def.inputSchema
}, async (args: any): Promise<CallToolResult> => {
const run = () => def.execute(args);
const result = def.mutates
? await cls.init(() => sql.transactional(run))
: await run();
return { content: [{ type: "text", text: JSON.stringify(result) }] };
});
}
export function createMcpServer(): McpServer {
const server = new McpServer({
name: "trilium-notes",
version: appInfo.appVersion
});
for (const registry of allToolRegistries) {
for (const [name, def] of registry) {
if (def.needsContext) continue;
registerTool(server, name, def);
}
}
return server;
}

View File

@@ -212,7 +212,8 @@ const defaultOptions: DefaultOption[] = [
{ name: "experimentalFeatures", value: "[]", isSynced: true },
// AI / LLM
{ name: "llmProviders", value: "[]", isSynced: false }
{ name: "llmProviders", value: "[]", isSynced: false },
{ name: "mcpEnabled", value: "false", isSynced: false }
];
/**

View File

@@ -9,7 +9,7 @@
"preview": "pnpm build && vite preview"
},
"dependencies": {
"i18next": "26.0.1",
"i18next": "25.10.10",
"i18next-http-backend": "3.0.2",
"preact": "10.29.0",
"preact-iso": "2.11.1",

View File

@@ -27,7 +27,8 @@ export function initTranslations(lng: string) {
initAsync: false,
react: {
useSuspense: false
}
},
showSupportNotice: false
});
}

View File

@@ -144,6 +144,8 @@ export interface OptionDefinitions extends KeyboardShortcutsOptions<KeyboardActi
// AI / LLM
/** JSON array of configured LLM providers with their API keys */
llmProviders: string;
/** Whether the MCP (Model Context Protocol) server endpoint is enabled. */
mcpEnabled: boolean;
}
export type OptionNames = keyof OptionDefinitions;

187
pnpm-lock.yaml generated
View File

@@ -301,8 +301,8 @@ importers:
specifier: 17.4.0
version: 17.4.0
i18next:
specifier: 26.0.1
version: 26.0.1(typescript@6.0.2)
specifier: 25.10.10
version: 25.10.10(typescript@6.0.2)
i18next-http-backend:
specifier: 3.0.2
version: 3.0.2(encoding@0.1.13)
@@ -347,7 +347,7 @@ importers:
version: 10.29.0
react-i18next:
specifier: 17.0.1
version: 17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2)
version: 17.0.1(i18next@25.10.10(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2)
react-window:
specifier: 2.2.7
version: 2.2.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -565,6 +565,9 @@ importers:
'@ai-sdk/openai':
specifier: 3.0.49
version: 3.0.49(zod@4.3.6)
'@modelcontextprotocol/sdk':
specifier: ^1.12.1
version: 1.29.0(zod@4.3.6)
ai:
specifier: 6.0.142
version: 6.0.142(zod@4.3.6)
@@ -771,8 +774,8 @@ importers:
specifier: 8.0.0
version: 8.0.0
i18next:
specifier: 26.0.1
version: 26.0.1(typescript@6.0.2)
specifier: 25.10.10
version: 25.10.10(typescript@6.0.2)
i18next-fs-backend:
specifier: 2.6.1
version: 2.6.1
@@ -886,8 +889,8 @@ importers:
apps/website:
dependencies:
i18next:
specifier: 26.0.1
version: 26.0.1(typescript@6.0.2)
specifier: 25.10.10
version: 25.10.10(typescript@6.0.2)
i18next-http-backend:
specifier: 3.0.2
version: 3.0.2(encoding@0.1.13)
@@ -902,7 +905,7 @@ importers:
version: 6.6.7(preact@10.29.0)
react-i18next:
specifier: 17.0.1
version: 17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2)
version: 17.0.1(i18next@25.10.10(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2)
devDependencies:
'@preact/preset-vite':
specifier: 2.10.5
@@ -3414,6 +3417,12 @@ packages:
'@hapi/topo@5.1.0':
resolution: {integrity: sha512-foQZKJig7Ob0BMAYBfcJk8d77QtOe7Wo4ox7ff1lQYoNNAb6jwcY1ncdoy2e9wQZzvNy7ODZCYJkK8kzmcAnAg==}
'@hono/node-server@1.19.12':
resolution: {integrity: sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw==}
engines: {node: '>=18.14.1'}
peerDependencies:
hono: ^4
'@humanfs/core@0.19.1':
resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==}
engines: {node: '>=18.18.0'}
@@ -4018,6 +4027,16 @@ packages:
'@mixmark-io/domino@2.2.0':
resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==}
'@modelcontextprotocol/sdk@1.29.0':
resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==}
engines: {node: '>=18'}
peerDependencies:
'@cfworker/json-schema': ^4.1.1
zod: '>=4.0.0'
peerDependenciesMeta:
'@cfworker/json-schema':
optional: true
'@mswjs/interceptors@0.37.6':
resolution: {integrity: sha512-wK+5pLK5XFmgtH3aQ2YVvA3HohS3xqV/OxuVOdNx9Wpnz7VE/fnC+e1A7ln6LFYeck7gOJ/dsZV6OLplOtAJ2w==}
engines: {node: '>=18'}
@@ -9546,6 +9565,10 @@ packages:
resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==}
engines: {node: '>=18.0.0'}
eventsource@3.0.7:
resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==}
engines: {node: '>=18.0.0'}
execa@1.0.0:
resolution: {integrity: sha512-adbxcyWV46qiHyvSp50TKt05tB4tK3HcmF7/nxfAdhnox83seTDbwnaqKO4sXRy7roHAIFqJP/Rw/AuEbX61LA==}
engines: {node: '>=6'}
@@ -10280,6 +10303,10 @@ packages:
hoist-non-react-statics@2.5.5:
resolution: {integrity: sha512-rqcy4pJo55FTTLWt+bU8ukscqHeE/e9KWvsOW2b/a3afxQZhwkQdT1rPPCJ0rYXdj4vNcasY8zHTH+jF/qStxw==}
hono@4.12.9:
resolution: {integrity: sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA==}
engines: {node: '>=16.9.0'}
hookable@6.0.1:
resolution: {integrity: sha512-uKGyY8BuzN/a5gvzvA+3FVWo0+wUjgtfSdnmjtrOVwQCZPHpHDH2WRO3VZSOeluYrHoDCiXFffZXs8Dj1ULWtw==}
@@ -10449,8 +10476,8 @@ packages:
i18next-http-backend@3.0.2:
resolution: {integrity: sha512-PdlvPnvIp4E1sYi46Ik4tBYh/v/NbYfFFgTjkwFl0is8A18s7/bx9aXqsrOax9WUbeNS6mD2oix7Z0yGGf6m5g==}
i18next@26.0.1:
resolution: {integrity: sha512-vtz5sXU4+nkCm8yEU+JJ6yYIx0mkg9e68W0G0PXpnOsmzLajNsW5o28DJMqbajxfsfq0gV3XdrBudsDQnwxfsQ==}
i18next@25.10.10:
resolution: {integrity: sha512-cqUW2Z3EkRx7NqSyywjkgCLK7KLCL6IFVFcONG7nVYIJ3ekZ1/N5jUsihHV6Bq37NfhgtczxJcxduELtjTwkuQ==}
peerDependencies:
typescript: ^5 || ^6
peerDependenciesMeta:
@@ -10918,10 +10945,6 @@ packages:
isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
isexe@3.1.1:
resolution: {integrity: sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==}
engines: {node: '>=16'}
isexe@3.1.5:
resolution: {integrity: sha512-6B3tLtFqtQS4ekarvLVMZ+X+VlvQekbe4taUkf/rhVO3d/h0M2rfARm/pXLcPEsjjMsFgrFgSrhQIxcSVrBz8w==}
engines: {node: '>=18'}
@@ -10975,6 +10998,9 @@ packages:
resolution: {integrity: sha512-5hFWIigKqC+e/lRyQhfnirrAqUdIPMB7SJRqflJaO29dW7q5DFvH1XCSTmv6PQ6pb++0k6MJlLRoS0Wv4s38Wg==}
engines: {node: '>=10.13.0 < 13 || >=13.7.0'}
jose@6.2.2:
resolution: {integrity: sha512-d7kPDd34KO/YnzaDOlikGpOurfF0ByC2sEV4cANCtdqLlTfBlw2p14O/5d/zv40gJPbIQxfES3nSx1/oYNyuZQ==}
jotai-scope@0.7.2:
resolution: {integrity: sha512-Gwed97f3dDObrO43++2lRcgOqw4O2sdr4JCjP/7eHK1oPACDJ7xKHGScpJX9XaflU+KBHXF+VhwECnzcaQiShg==}
peerDependencies:
@@ -11082,6 +11108,9 @@ packages:
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json-schema-typed@8.0.2:
resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==}
json-schema@0.4.0:
resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==}
@@ -12792,6 +12821,10 @@ packages:
resolution: {integrity: sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==}
hasBin: true
pkce-challenge@5.0.1:
resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==}
engines: {node: '>=16.20.0'}
pkg-types@1.3.1:
resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==}
@@ -16033,6 +16066,11 @@ packages:
resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==}
engines: {node: '>= 14'}
zod-to-json-schema@3.25.2:
resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==}
peerDependencies:
zod: '>=4.0.0'
zod@4.1.12:
resolution: {integrity: sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==}
@@ -16906,6 +16944,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.6.1
'@ckeditor/ckeditor5-upload': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-ai@47.6.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
dependencies:
@@ -17053,6 +17093,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-code-block@47.6.1(patch_hash=2361d8caad7d6b5bddacc3a3b4aa37dbfba260b1c1b22a450413a79c1bb1ce95)':
dependencies:
@@ -17239,6 +17281,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-classic@47.6.1':
dependencies:
@@ -17248,6 +17292,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-decoupled@47.6.1':
dependencies:
@@ -17257,6 +17303,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-inline@47.6.1':
dependencies:
@@ -17266,6 +17314,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-editor-multi-root@47.6.1':
dependencies:
@@ -17300,8 +17350,6 @@ snapshots:
ckeditor5: 47.6.1
es-toolkit: 1.39.5
fuzzysort: 3.1.0
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-engine@47.6.1':
dependencies:
@@ -17313,8 +17361,6 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.6.1
'@ckeditor/ckeditor5-engine': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-essentials@47.6.1':
dependencies:
@@ -17379,8 +17425,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-footnotes@47.6.1':
dependencies:
@@ -17411,8 +17455,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-heading@47.6.1':
dependencies:
@@ -17423,8 +17465,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-highlight@47.6.1':
dependencies:
@@ -17434,8 +17474,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-horizontal-line@47.6.1':
dependencies:
@@ -17454,8 +17492,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-html-support@47.6.1':
dependencies:
@@ -17471,8 +17507,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-icons@47.6.1': {}
@@ -17513,8 +17547,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-inspector@5.0.0': {}
@@ -17525,8 +17557,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-line-height@47.6.1':
dependencies:
@@ -17551,8 +17581,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-list-multi-level@47.6.1':
dependencies:
@@ -17577,8 +17605,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-markdown-gfm@47.6.1':
dependencies:
@@ -17616,8 +17642,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-mention@47.6.1(patch_hash=5981fb59ba35829e4dff1d39cf771000f8a8fdfa7a34b51d8af9549541f2d62d)':
dependencies:
@@ -17627,6 +17651,8 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-merge-fields@47.6.1':
dependencies:
@@ -17701,8 +17727,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-pagination@47.6.1':
dependencies:
@@ -17729,6 +17753,8 @@ snapshots:
'@ckeditor/ckeditor5-paste-from-office': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-paste-from-office@47.6.1':
dependencies:
@@ -17736,6 +17762,8 @@ snapshots:
'@ckeditor/ckeditor5-core': 47.6.1
'@ckeditor/ckeditor5-engine': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-real-time-collaboration@47.6.1(bufferutil@4.0.9)(utf-8-validate@6.0.5)':
dependencies:
@@ -17766,8 +17794,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-restricted-editing@47.6.1':
dependencies:
@@ -17812,8 +17838,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-slash-command@47.6.1':
dependencies:
@@ -17826,8 +17850,6 @@ snapshots:
'@ckeditor/ckeditor5-ui': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-source-editing-enhanced@47.6.1':
dependencies:
@@ -17875,8 +17897,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-table@47.6.1':
dependencies:
@@ -17889,8 +17909,6 @@ snapshots:
'@ckeditor/ckeditor5-widget': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-template@47.6.1':
dependencies:
@@ -18000,8 +18018,6 @@ snapshots:
'@ckeditor/ckeditor5-engine': 47.6.1
'@ckeditor/ckeditor5-utils': 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@ckeditor/ckeditor5-widget@47.6.1':
dependencies:
@@ -18021,8 +18037,6 @@ snapshots:
'@ckeditor/ckeditor5-utils': 47.6.1
ckeditor5: 47.6.1
es-toolkit: 1.39.5
transitivePeerDependencies:
- supports-color
'@codemirror/autocomplete@6.18.6':
dependencies:
@@ -19317,6 +19331,10 @@ snapshots:
dependencies:
'@hapi/hoek': 9.3.0
'@hono/node-server@1.19.12(hono@4.12.9)':
dependencies:
hono: 4.12.9
'@humanfs/core@0.19.1': {}
'@humanfs/node@0.16.7':
@@ -20030,6 +20048,28 @@ snapshots:
'@mixmark-io/domino@2.2.0': {}
'@modelcontextprotocol/sdk@1.29.0(zod@4.3.6)':
dependencies:
'@hono/node-server': 1.19.12(hono@4.12.9)
ajv: 8.18.0
ajv-formats: 3.0.1(ajv@8.18.0)
content-type: 1.0.5
cors: 2.8.5
cross-spawn: 7.0.6
eventsource: 3.0.7
eventsource-parser: 3.0.6
express: 5.2.1
express-rate-limit: 8.3.2(express@5.2.1)
hono: 4.12.9
jose: 6.2.2
json-schema-typed: 8.0.2
pkce-challenge: 5.0.1
raw-body: 3.0.2
zod: 4.3.6
zod-to-json-schema: 3.25.2(zod@4.3.6)
transitivePeerDependencies:
- supports-color
'@mswjs/interceptors@0.37.6':
dependencies:
'@open-draft/deferred-promise': 2.2.0
@@ -24639,6 +24679,10 @@ snapshots:
optionalDependencies:
ajv: 8.13.0
ajv-formats@3.0.1(ajv@8.18.0):
optionalDependencies:
ajv: 8.18.0
ajv-keywords@3.5.2(ajv@6.14.0):
dependencies:
ajv: 6.14.0
@@ -25462,8 +25506,6 @@ snapshots:
ckeditor5-collaboration@47.6.1:
dependencies:
'@ckeditor/ckeditor5-collaboration-core': 47.6.1
transitivePeerDependencies:
- supports-color
ckeditor5-premium-features@47.6.1(bufferutil@4.0.9)(ckeditor5@47.6.1)(utf-8-validate@6.0.5):
dependencies:
@@ -27363,6 +27405,10 @@ snapshots:
eventsource-parser@3.0.6: {}
eventsource@3.0.7:
dependencies:
eventsource-parser: 3.0.6
execa@1.0.0:
dependencies:
cross-spawn: 6.0.6
@@ -28343,6 +28389,8 @@ snapshots:
hoist-non-react-statics@2.5.5: {}
hono@4.12.9: {}
hookable@6.0.1: {}
hookified@1.15.0: {}
@@ -28587,7 +28635,7 @@ snapshots:
transitivePeerDependencies:
- encoding
i18next@26.0.1(typescript@6.0.2):
i18next@25.10.10(typescript@6.0.2):
dependencies:
'@babel/runtime': 7.29.2
optionalDependencies:
@@ -28608,7 +28656,6 @@ snapshots:
iconv-lite@0.7.2:
dependencies:
safer-buffer: 2.1.2
optional: true
icss-utils@5.1.0(postcss@8.5.8):
dependencies:
@@ -28967,8 +29014,6 @@ snapshots:
isexe@2.0.0: {}
isexe@3.1.1: {}
isexe@3.1.5: {}
isexe@4.0.0: {}
@@ -29051,6 +29096,8 @@ snapshots:
dependencies:
'@panva/asn1.js': 1.0.0
jose@6.2.2: {}
jotai-scope@0.7.2(jotai@2.11.0(@types/react@19.1.7)(react@19.2.4))(react@19.2.4):
dependencies:
jotai: 2.11.0(@types/react@19.1.7)(react@19.2.4)
@@ -29173,6 +29220,8 @@ snapshots:
json-schema-traverse@1.0.0: {}
json-schema-typed@8.0.2: {}
json-schema@0.4.0: {}
json-stable-stringify-without-jsonify@1.0.1: {}
@@ -31294,6 +31343,8 @@ snapshots:
dependencies:
pngjs: 6.0.0
pkce-challenge@5.0.1: {}
pkg-types@1.3.1:
dependencies:
confbox: 0.1.8
@@ -31835,7 +31886,7 @@ snapshots:
dependencies:
bytes: 3.1.2
http-errors: 2.0.1
iconv-lite: 0.7.0
iconv-lite: 0.7.2
unpipe: 1.0.0
raw-loader@0.5.1: {}
@@ -31875,11 +31926,11 @@ snapshots:
react: 19.2.4
scheduler: 0.27.0
react-i18next@17.0.1(i18next@26.0.1(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2):
react-i18next@17.0.1(i18next@25.10.10(typescript@6.0.2))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@6.0.2):
dependencies:
'@babel/runtime': 7.29.2
html-parse-stringify: 3.0.1
i18next: 26.0.1(typescript@6.0.2)
i18next: 25.10.10(typescript@6.0.2)
react: 19.2.4
use-sync-external-store: 1.6.0(react@19.2.4)
optionalDependencies:
@@ -34839,7 +34890,7 @@ snapshots:
which@5.0.0:
dependencies:
isexe: 3.1.1
isexe: 3.1.5
which@6.0.1:
dependencies:
@@ -35118,6 +35169,10 @@ snapshots:
compress-commons: 6.0.2
readable-stream: 4.7.0
zod-to-json-schema@3.25.2(zod@4.3.6):
dependencies:
zod: 4.3.6
zod@4.1.12: {}
zod@4.3.6: {}