From 5cf0d211e3c7b59a37b27770d66791c859cef773 Mon Sep 17 00:00:00 2001 From: Charles Date: Sun, 12 Jul 2026 20:03:20 -0400 Subject: [PATCH] feat: retrieve managed RemNote images --- CHANGELOG.md | 5 + README.md | 2 + mcpb/remnote-local/manifest.json | 4 + .../server/fallback-tools.generated.js | 34 ++++ src/cli.ts | 14 +- src/config.ts | 31 +++- src/http-server.ts | 5 +- src/index.ts | 3 +- src/media.ts | 168 ++++++++++++++++++ src/schemas/remnote-schemas.ts | 19 ++ src/tools/index.ts | 103 ++++++++++- src/types/bridge.ts | 1 + src/websocket-server.ts | 18 ++ test/unit/cli.test.ts | 15 ++ test/unit/config.test.ts | 29 ++- test/unit/media.test.ts | 89 ++++++++++ test/unit/tools.test.ts | 89 +++++++++- test/unit/websocket-server.test.ts | 12 +- 18 files changed, 628 insertions(+), 13 deletions(-) create mode 100644 src/media.ts create mode 100644 test/unit/media.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 616bd30..7098703 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ Versioning](https://semver.org/spec/v2.0.0.html). ### Added +- Add opt-in image metadata to `remnote_read_note` and capability-gated `remnote_get_media` retrieval for + RemNote-managed PNG, JPEG, GIF, and WebP images using MCP-native image content. +- Add confined media-root discovery/configuration through repeatable `--media-root`, `REMNOTE_MEDIA_ROOTS`, and + `[server].mediaRoots`, with symlink/traversal checks, unique-match resolution, MIME sniffing, and 5 MiB default / + 10 MiB hard inline limits. - Add `parentRemId` to `remnote_search` input schema and `--parent-id` option to `remnote-cli search` to support scoping search within a Rem's subtree. Contributed by Twb06. - Add exact-ID tag/table property writes through `remnote_set_property` and `remnote-cli set-property`, supporting text, Rem-reference/select-option, and clear value payloads. diff --git a/README.md b/README.md index 44a0367..efef557 100644 --- a/README.md +++ b/README.md @@ -198,6 +198,7 @@ After installing the LaunchAgent, `remnote-mcp-server daemon status|start|stop|r | `remnote_search` | Search knowledge base with full-text search, parent context, and optional tag IDs/names | | `remnote_search_by_tag` | Search by exact tag Rem ID with ancestor-context resolution | | `remnote_read_note` | Read note by ID with metadata, optional tag IDs/names, and markdown or structured content | +| `remnote_get_media` | Retrieve one validated RemNote-managed image as MCP-native image content | | `remnote_update_note` | Update title | | `remnote_set_document_status` | Preview or set document status while preserving concept/card status | | `remnote_insert_children` | Insert child Rems at deterministic positions | @@ -266,6 +267,7 @@ See the [Tools Reference](docs/guides/tools-reference.md) for more examples. - `REMNOTE_HTTP_PORT` - HTTP MCP server port (default: 3001) - `REMNOTE_HTTP_HOST` - HTTP server bind address (default: 127.0.0.1) - `REMNOTE_WS_PORT` - WebSocket server port (default: 3002) +- `REMNOTE_MEDIA_ROOTS` - Allowed RemNote media roots, separated by the platform path delimiter; defaults to discovered `~/remnote/remnote-*/files` directories ### Custom Ports diff --git a/mcpb/remnote-local/manifest.json b/mcpb/remnote-local/manifest.json index 9d79498..66dce8e 100644 --- a/mcpb/remnote-local/manifest.json +++ b/mcpb/remnote-local/manifest.json @@ -36,6 +36,10 @@ "name": "remnote_read_note", "description": "Read a specific note from RemNote by its Rem ID. For hierarchy traversal, prefer contentMode=\"structured\" and start shallow (depth=1, childLimit=500), then deepen only selected branches. Request ancestorDepth when placement context matters." }, + { + "name": "remnote_get_media", + "description": "Retrieve a RemNote-managed local image as MCP-native image content. First call remnote_read_note with includeMediaMetadata=true, then pass the returned remId, field, and mediaId. Requires bridge capability media.images.v1. External URL retrieval is not supported." + }, { "name": "remnote_list_children", "description": "List direct child Rems under a parent without rendering a subtree. Use for cheap hierarchy traversal; page with nextCursor when hasMore is true." diff --git a/mcpb/remnote-local/server/fallback-tools.generated.js b/mcpb/remnote-local/server/fallback-tools.generated.js index 1d82429..ebb1b17 100644 --- a/mcpb/remnote-local/server/fallback-tools.generated.js +++ b/mcpb/remnote-local/server/fallback-tools.generated.js @@ -195,10 +195,44 @@ export const FALLBACK_TOOLS = [ type: 'number', description: 'Maximum character length for rendered content (default: 100000)', }, + includeMediaMetadata: { + type: 'boolean', + description: + 'Include ordered image metadata from the root Rem text and backText fields (default: false)', + }, }, required: ['remId'], }, }, + { + name: 'remnote_get_media', + description: + 'Retrieve a RemNote-managed local image as MCP-native image content. First call remnote_read_note with includeMediaMetadata=true, then pass the returned remId, field, and mediaId. Requires bridge capability media.images.v1. External URL retrieval is not supported.', + inputSchema: { + type: 'object', + properties: { + remId: { + type: 'string', + description: 'Root Rem ID containing the image', + }, + field: { + type: 'string', + enum: ['text', 'backText'], + description: 'Rich-text field containing the image', + }, + mediaId: { + type: 'string', + description: 'Stable media ID returned by remnote_read_note', + }, + maxInlineBytes: { + type: 'number', + description: 'Maximum bytes to inline (default 5 MiB, hard maximum 10 MiB)', + }, + }, + required: ['remId', 'field', 'mediaId'], + additionalProperties: false, + }, + }, { name: 'remnote_list_children', description: diff --git a/src/cli.ts b/src/cli.ts index 581fc28..42f1361 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,6 +17,7 @@ export interface CliOptions { logFile?: string; requestLog?: string; responseLog?: string; + mediaRoots?: string[]; } const validLogLevels = ['debug', 'info', 'warn', 'error']; @@ -83,11 +84,20 @@ export function parseCliArgs(): CliOptions { .option('--verbose', 'Shorthand for --log-level debug') .option('--log-file ', 'Log to file (default: console only)') .option('--request-log ', 'Log all WebSocket requests to file (JSON Lines)') - .option('--response-log ', 'Log all WebSocket responses to file (JSON Lines)'); + .option('--response-log ', 'Log all WebSocket responses to file (JSON Lines)') + .option( + '--media-root ', + 'Allowed RemNote managed-media root (repeatable, env: REMNOTE_MEDIA_ROOTS)', + (value, previous: string[] | undefined) => [...(previous ?? []), value] + ); program.parse(); - const options = program.opts(); + const parsedOptions = program.opts(); + const { mediaRoot, ...options } = parsedOptions; + if (mediaRoot?.length) { + options.mediaRoots = mediaRoot; + } // Validate port conflicts if (options.wsPort && options.httpPort && options.wsPort === options.httpPort) { diff --git a/src/config.ts b/src/config.ts index 90ab836..eee813e 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,6 +1,6 @@ -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, readFileSync, readdirSync } from 'node:fs'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { delimiter, join } from 'node:path'; import type { CliOptions } from './cli.js'; export interface ServerConfig { @@ -14,6 +14,7 @@ export interface ServerConfig { requestLog?: string; responseLog?: string; prettyLogs: boolean; + mediaRoots: string[]; } export interface ConfigFileOptions { @@ -95,6 +96,14 @@ export function getConfig( // Pretty logs in development (when using pino-pretty) const prettyLogs = process.stdout.isTTY === true; + const configuredMediaRoots = cliOptions.mediaRoots?.length + ? cliOptions.mediaRoots + : process.env.REMNOTE_MEDIA_ROOTS + ? process.env.REMNOTE_MEDIA_ROOTS.split(delimiter).filter(Boolean) + : configOptions.mediaRoots; + const mediaRoots = ( + configuredMediaRoots?.length ? configuredMediaRoots : discoverDefaultMediaRoots(homeDir) + ).map((entry) => resolveTilde(entry, homeDir)); return { wsPort, @@ -107,6 +116,7 @@ export function getConfig( requestLog, responseLog, prettyLogs, + mediaRoots, }; } @@ -230,11 +240,28 @@ function assignConfigValue( case 'responseLog': config.server.responseLog = expectString(value, filePath, lineNumber, key); return; + case 'mediaRoots': + config.server.mediaRoots = expectString(value, filePath, lineNumber, key) + .split(delimiter) + .filter(Boolean); + return; default: throw new Error(`${filePath}:${lineNumber}: Unknown server config key "${key}"`); } } +export function discoverDefaultMediaRoots(homeDir = homedir()): string[] { + const remnoteRoot = join(homeDir, 'remnote'); + try { + return readdirSync(remnoteRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory() && entry.name.startsWith('remnote-')) + .map((entry) => join(remnoteRoot, entry.name, 'files')) + .filter((entry) => existsSync(entry)); + } catch { + return []; + } +} + function parseOptionalPortEnv(value: string | undefined): number | undefined { if (value === undefined) { return undefined; diff --git a/src/http-server.ts b/src/http-server.ts index 4b079c8..8ba5953 100644 --- a/src/http-server.ts +++ b/src/http-server.ts @@ -29,7 +29,8 @@ export class HttpMcpServer { host: string, wsServer: WebSocketServer, serverInfo: ServerInfo, - logger: Logger + logger: Logger, + private readonly mediaRoots: string[] = [] ) { this.port = port; this.host = host; @@ -184,7 +185,7 @@ export class HttpMcpServer { }); // Register all tools with the shared WebSocket server - registerAllTools(server, this.wsServer, this.logger); + registerAllTools(server, this.wsServer, this.logger, this.mediaRoots); // Connect server to transport await server.connect(transport); diff --git a/src/index.ts b/src/index.ts index 977e500..bdd175b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,7 +73,8 @@ async function main() { name: 'remnote-mcp-server', version: packageJson.version, }, - logger + logger, + config.mediaRoots ); await httpServer.start(); diff --git a/src/media.ts b/src/media.ts new file mode 100644 index 0000000..4ea9da6 --- /dev/null +++ b/src/media.ts @@ -0,0 +1,168 @@ +import { readFile, realpath, stat } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path'; + +export const DEFAULT_MAX_INLINE_BYTES = 5 * 1024 * 1024; +export const HARD_MAX_INLINE_BYTES = 10 * 1024 * 1024; + +export interface MediaLocator { + mediaId: string; + kind: 'image'; + field: 'text' | 'backText'; + elementIndex: number; + imageIndex: number; + imgId?: string; + title?: string; + dimensions?: { width: number; height: number }; + mimeType?: string; + source: 'remnote_managed_local'; + localToken: string; +} + +export interface ResolvedMedia { + data: string; + mimeType: string; + sizeBytes: number; + metadata: Omit; +} + +function validateLocalToken(token: string): void { + if ( + !token || + basename(token) !== token || + token === '.' || + token === '..' || + token.normalize('NFC') !== token || + token.includes('\0') + ) { + throw new Error('Media path traversal rejected: local media token must be a basename'); + } +} + +function detectImageMime(data: Buffer): string | undefined { + if (data.length >= 8 && data.subarray(0, 8).equals(Buffer.from('89504e470d0a1a0a', 'hex'))) { + return 'image/png'; + } + if (data.length >= 3 && data.subarray(0, 3).equals(Buffer.from('ffd8ff', 'hex'))) { + return 'image/jpeg'; + } + if (data.length >= 6) { + const signature = data.subarray(0, 6).toString('ascii'); + if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif'; + } + if ( + data.length >= 12 && + data.subarray(0, 4).toString('ascii') === 'RIFF' && + data.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp'; + } + return undefined; +} + +function isWithinRoot(root: string, candidate: string): boolean { + const pathFromRoot = relative(root, candidate); + return pathFromRoot === '' || (!pathFromRoot.startsWith('..') && !isAbsolute(pathFromRoot)); +} + +export async function resolveManagedImage( + locator: MediaLocator, + roots: string[], + maxInlineBytes = DEFAULT_MAX_INLINE_BYTES +): Promise { + if ( + !Number.isInteger(maxInlineBytes) || + maxInlineBytes < 1 || + maxInlineBytes > HARD_MAX_INLINE_BYTES + ) { + throw new Error(`maxInlineBytes must be an integer between 1 and ${HARD_MAX_INLINE_BYTES}`); + } + validateLocalToken(locator.localToken); + if (locator.kind !== 'image' || locator.source !== 'remnote_managed_local') { + throw new Error('Unsupported MIME: only RemNote-managed local images are supported'); + } + + const matches = new Map(); + for (const configuredRoot of roots) { + let root: string; + try { + root = await realpath(resolve(configuredRoot)); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') continue; + throw new Error( + `Media permission/read failure for configured root: ${code ?? String(error)}`, + { + cause: error, + } + ); + } + + const candidate = resolve(root, locator.localToken); + if (!isWithinRoot(root, candidate) || dirname(candidate) !== root) { + throw new Error('Media path traversal rejected: resolved path escaped the configured root'); + } + + try { + const candidateReal = await realpath(candidate); + if (!isWithinRoot(root, candidateReal) || dirname(candidateReal) !== root) { + throw new Error('Media path traversal rejected: resolved file escaped the configured root'); + } + const fileStat = await stat(candidateReal); + if (fileStat.isFile()) matches.set(candidateReal, candidateReal); + } catch (error) { + if (error instanceof Error && error.message.startsWith('Media path traversal rejected:')) { + throw error; + } + const code = (error as NodeJS.ErrnoException).code; + if (code === 'ENOENT' || code === 'ENOTDIR') continue; + throw new Error( + `Media permission/read failure for ${locator.localToken}: ${code ?? String(error)}`, + { + cause: error, + } + ); + } + } + + if (matches.size === 0) { + throw new Error(`Media file missing: ${locator.localToken}`); + } + if (matches.size > 1) { + throw new Error(`Media file ambiguous: ${locator.localToken} matched ${matches.size} roots`); + } + + const filePath = [...matches.values()][0]; + let fileStat; + let data: Buffer; + try { + fileStat = await stat(filePath); + if (fileStat.size > maxInlineBytes) { + throw new Error( + `Media file oversized: ${fileStat.size} bytes exceeds maxInlineBytes ${maxInlineBytes}` + ); + } + data = await readFile(filePath); + } catch (error) { + if (error instanceof Error && error.message.startsWith('Media file oversized:')) throw error; + const code = (error as NodeJS.ErrnoException).code; + throw new Error( + `Media permission/read failure for ${locator.localToken}: ${code ?? String(error)}`, + { + cause: error, + } + ); + } + + const mimeType = detectImageMime(data); + if (!mimeType) { + throw new Error('Unsupported MIME: allowed image types are png, jpeg, gif, and webp'); + } + + const { localToken: _localToken, ...metadata } = locator; + return { + data: data.toString('base64'), + mimeType, + sizeBytes: fileStat.size, + metadata, + }; +} diff --git a/src/schemas/remnote-schemas.ts b/src/schemas/remnote-schemas.ts index de6613e..f2df8a3 100644 --- a/src/schemas/remnote-schemas.ts +++ b/src/schemas/remnote-schemas.ts @@ -143,6 +143,25 @@ export const ReadNoteSchema = z .max(200000) .default(100000) .describe('Maximum character length for rendered content'), + includeMediaMetadata: z + .boolean() + .default(false) + .describe('Include ordered image metadata from the root Rem text and backText fields'), + }) + .strict(); + +export const GetMediaSchema = z + .object({ + remId: z.string().min(1).describe('Root Rem ID containing the image'), + field: z.enum(['text', 'backText']).describe('Rich-text field containing the image'), + mediaId: z.string().min(1).describe('Stable media ID returned by remnote_read_note'), + maxInlineBytes: z + .number() + .int() + .min(1) + .max(10 * 1024 * 1024) + .default(5 * 1024 * 1024) + .describe('Maximum image bytes to inline (default 5 MiB, hard maximum 10 MiB)'), }) .strict(); diff --git a/src/tools/index.ts b/src/tools/index.ts index 4b8b85a..e2c2aed 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -5,6 +5,7 @@ import { CreateNoteSchema } from '../schemas/remnote-schemas.js'; import { SearchSchema } from '../schemas/remnote-schemas.js'; import { SearchByTagSchema } from '../schemas/remnote-schemas.js'; import { ReadNoteSchema } from '../schemas/remnote-schemas.js'; +import { GetMediaSchema } from '../schemas/remnote-schemas.js'; import { UpdateNoteSchema } from '../schemas/remnote-schemas.js'; import { SetDocumentStatusSchema } from '../schemas/remnote-schemas.js'; import { ListChildrenSchema } from '../schemas/remnote-schemas.js'; @@ -17,6 +18,9 @@ import { AppendJournalSchema } from '../schemas/remnote-schemas.js'; import { ReadTableSchema } from '../schemas/remnote-schemas.js'; import { checkVersionCompatibility } from '../version-compat.js'; import type { Logger } from '../logger.js'; +import { resolveManagedImage, type MediaLocator } from '../media.js'; + +const MEDIA_CAPABILITY = 'media.images.v1'; const NAVIGATION_PRESET = { contentMode: 'structured', @@ -487,6 +491,11 @@ export const READ_NOTE_TOOL = { type: 'number', description: 'Maximum character length for rendered content (default: 100000)', }, + includeMediaMetadata: { + type: 'boolean', + description: + 'Include ordered image metadata from the root Rem text and backText fields (default: false)', + }, }, required: ['remId'], }, @@ -615,7 +624,59 @@ export const READ_NOTE_TOOL = { }, }, }, + media: { + type: 'array', + description: + 'Ordered root-Rem image metadata from text followed by backText when includeMediaMetadata is true', + items: { + type: 'object', + properties: { + mediaId: { type: 'string' }, + kind: { type: 'string', enum: ['image'] }, + field: { type: 'string', enum: ['text', 'backText'] }, + elementIndex: { type: 'number' }, + imageIndex: { type: 'number' }, + imgId: { type: 'string' }, + title: { type: 'string' }, + dimensions: { + type: 'object', + properties: { width: { type: 'number' }, height: { type: 'number' } }, + required: ['width', 'height'], + }, + mimeType: { type: 'string' }, + source: { type: 'string', enum: ['remnote_managed_local'] }, + }, + required: ['mediaId', 'kind', 'field', 'elementIndex', 'imageIndex'], + }, + }, + }, + }, +}; + +export const GET_MEDIA_TOOL = { + name: 'remnote_get_media', + description: + 'Retrieve a RemNote-managed local image as MCP-native image content. First call remnote_read_note with includeMediaMetadata=true, then pass the returned remId, field, and mediaId. Requires bridge capability media.images.v1. External URL retrieval is not supported.', + inputSchema: { + type: 'object' as const, + properties: { + remId: { type: 'string', description: 'Root Rem ID containing the image' }, + field: { + type: 'string', + enum: ['text', 'backText'], + description: 'Rich-text field containing the image', + }, + mediaId: { + type: 'string', + description: 'Stable media ID returned by remnote_read_note', + }, + maxInlineBytes: { + type: 'number', + description: 'Maximum bytes to inline (default 5 MiB, hard maximum 10 MiB)', + }, }, + required: ['remId', 'field', 'mediaId'], + additionalProperties: false, }, }; @@ -1057,6 +1118,11 @@ export const STATUS_TOOL = { connected: { type: 'boolean', description: 'Whether bridge plugin is currently connected' }, serverVersion: { type: 'string', description: 'MCP server version' }, pluginVersion: { type: 'string', description: 'Connected bridge plugin version' }, + capabilities: { + type: 'array', + items: { type: 'string' }, + description: 'Bridge protocol capabilities advertised during handshake', + }, version_warning: { type: 'string', description: 'Compatibility warning when server/bridge versions differ', @@ -1224,6 +1290,7 @@ export const ALL_TOOLS = [ SEARCH_TOOL, SEARCH_BY_TAG_TOOL, READ_NOTE_TOOL, + GET_MEDIA_TOOL, LIST_CHILDREN_TOOL, UPDATE_NOTE_TOOL, SET_DOCUMENT_STATUS_TOOL, @@ -1238,13 +1305,19 @@ export const ALL_TOOLS = [ READ_TABLE_TOOL, ] as const; -export function registerAllTools(server: Server, wsServer: WebSocketServer, logger: Logger) { +export function registerAllTools( + server: Server, + wsServer: WebSocketServer, + logger: Logger, + mediaRoots: string[] = [] +) { const toolLogger = logger.child({ context: 'tools' }); async function buildStatusResult(): Promise> { const connected = wsServer.isConnected(); const serverVersion = wsServer.getServerVersion(); const bridgeVersion = wsServer.getBridgeVersion(); + const bridgeCapabilities = wsServer.getBridgeCapabilities?.() ?? []; if (!connected) { return { connected: false, serverVersion, message: 'RemNote plugin not connected' }; @@ -1266,6 +1339,7 @@ export function registerAllTools(server: Server, wsServer: WebSocketServer, logg connected: true, serverVersion, ...statusObj, + capabilities: bridgeCapabilities, ...(versionWarning ? { version_warning: versionWarning } : {}), }; } @@ -1306,6 +1380,33 @@ export function registerAllTools(server: Server, wsServer: WebSocketServer, logg break; } + case 'remnote_get_media': { + const args = GetMediaSchema.parse(request.params.arguments); + if (!wsServer.hasBridgeCapability(MEDIA_CAPABILITY)) { + throw new Error( + `Bridge capability/version mismatch: connected bridge does not advertise ${MEDIA_CAPABILITY}` + ); + } + const locator = (await wsServer.sendRequest('get_media_locator', { + remId: args.remId, + field: args.field, + mediaId: args.mediaId, + })) as MediaLocator; + const media = await resolveManagedImage(locator, mediaRoots, args.maxInlineBytes); + const metadata = { + ...media.metadata, + mimeType: media.mimeType, + sizeBytes: media.sizeBytes, + }; + return { + structuredContent: metadata, + content: [ + { type: 'image', data: media.data, mimeType: media.mimeType }, + { type: 'text', text: JSON.stringify(metadata) }, + ], + }; + } + case 'remnote_list_children': { const args = ListChildrenSchema.parse(request.params.arguments); result = await wsServer.sendRequest('list_children', args); diff --git a/src/types/bridge.ts b/src/types/bridge.ts index 7699224..1b74d14 100644 --- a/src/types/bridge.ts +++ b/src/types/bridge.ts @@ -22,6 +22,7 @@ export interface BridgeResponse { export interface HelloMessage { type: 'hello'; version: string; + capabilities?: string[]; } export interface CompanionInfoMessage { diff --git a/src/websocket-server.ts b/src/websocket-server.ts index 65c8fe1..c8eff6b 100644 --- a/src/websocket-server.ts +++ b/src/websocket-server.ts @@ -22,6 +22,7 @@ export class WebSocketServer { private responseLogger: Logger | null = null; private serverVersion: string; private bridgeVersion: string | null = null; + private bridgeCapabilities = new Set(); private clientAccepted = false; private helloTimeout: NodeJS.Timeout | null = null; private pendingRequests = new Map< @@ -109,6 +110,7 @@ export class WebSocketServer { if (this.client === ws) { this.client = null; this.bridgeVersion = null; + this.bridgeCapabilities.clear(); this.clientAccepted = false; this.clearHelloTimeout(); } @@ -138,6 +140,7 @@ export class WebSocketServer { this.client.close(); this.client = null; this.bridgeVersion = null; + this.bridgeCapabilities.clear(); this.clientAccepted = false; this.clearHelloTimeout(); } @@ -233,6 +236,14 @@ export class WebSocketServer { return this.bridgeVersion; } + getBridgeCapabilities(): string[] { + return [...this.bridgeCapabilities]; + } + + hasBridgeCapability(capability: string): boolean { + return this.bridgeCapabilities.has(capability); + } + getServerVersion(): string { return this.serverVersion; } @@ -270,6 +281,13 @@ export class WebSocketServer { } this.bridgeVersion = message.version; + this.bridgeCapabilities = new Set( + Array.isArray(message.capabilities) + ? message.capabilities.filter( + (capability): capability is string => typeof capability === 'string' + ) + : [] + ); this.clientAccepted = true; this.clearHelloTimeout(); this.logger.info({ bridgeVersion: message.version }, 'Bridge identified'); diff --git a/test/unit/cli.test.ts b/test/unit/cli.test.ts index 96a3e0d..8d68dfd 100644 --- a/test/unit/cli.test.ts +++ b/test/unit/cli.test.ts @@ -198,6 +198,21 @@ describe('CLI Argument Parsing', () => { }); }); + describe('Media Root Arguments', () => { + it('collects repeatable media roots under the public mediaRoots option', () => { + process.argv = [ + 'node', + 'remnote-mcp-server', + '--media-root', + '/media/one', + '--media-root', + '/media/two', + ]; + + expect(parseCliArgs().mediaRoots).toEqual(['/media/one', '/media/two']); + }); + }); + describe('Combined Arguments', () => { it('should parse multiple arguments together', () => { process.argv = [ diff --git a/test/unit/config.test.ts b/test/unit/config.test.ts index 648f7f5..f010fbb 100644 --- a/test/unit/config.test.ts +++ b/test/unit/config.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { writeFileSync } from 'node:fs'; -import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { getConfig as loadServerConfig, parseConfigToml } from '../../src/config.js'; @@ -15,6 +15,7 @@ describe('Config', () => { delete process.env.REMNOTE_WS_PORT; delete process.env.REMNOTE_HTTP_PORT; delete process.env.REMNOTE_HTTP_HOST; + delete process.env.REMNOTE_MEDIA_ROOTS; tempDir = await mkdtemp(join(tmpdir(), 'remnote-config-test-')); }); @@ -132,6 +133,32 @@ describe('Config', () => { }); }); + describe('Media Root Configuration', () => { + it('auto-discovers conservative macOS RemNote files roots', async () => { + const filesRoot = join(tempDir, 'remnote', 'remnote-kb-123', 'files'); + await mkdir(filesRoot, { recursive: true }); + + const config = getConfig({}); + + expect(config.mediaRoots).toEqual([filesRoot]); + }); + + it('prefers explicit CLI roots over environment roots', () => { + process.env.REMNOTE_MEDIA_ROOTS = `/env/a${process.platform === 'win32' ? ';' : ':'}/env/b`; + + const config = getConfig({ mediaRoots: ['/cli/root'] }); + + expect(config.mediaRoots).toEqual(['/cli/root']); + }); + + it('loads path-delimited media roots from TOML', () => { + const separator = process.platform === 'win32' ? ';' : ':'; + const parsed = parseConfigToml(`[server]\nmediaRoots = "/media/a${separator}/media/b"\n`); + + expect(parsed.server?.mediaRoots).toEqual(['/media/a', '/media/b']); + }); + }); + describe('Pretty Logs Configuration', () => { it('should set prettyLogs based on TTY status', () => { const config = getConfig({}); diff --git a/test/unit/media.test.ts b/test/unit/media.test.ts new file mode 100644 index 0000000..d6a1b99 --- /dev/null +++ b/test/unit/media.test.ts @@ -0,0 +1,89 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdtemp, mkdir, rm, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { HARD_MAX_INLINE_BYTES, resolveManagedImage, type MediaLocator } from '../../src/media.js'; + +const PNG = Buffer.from('89504e470d0a1a0a00000000', 'hex'); + +describe('managed image resolution', () => { + let tempDir: string; + let rootA: string; + let rootB: string; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), 'remnote-media-test-')); + rootA = join(tempDir, 'a'); + rootB = join(tempDir, 'b'); + await mkdir(rootA); + await mkdir(rootB); + }); + + afterEach(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function locator(localToken = 'image.png'): MediaLocator { + return { + mediaId: 'media_1234', + kind: 'image', + field: 'text', + elementIndex: 0, + imageIndex: 0, + source: 'remnote_managed_local', + localToken, + }; + } + + it('resolves one exact filename inside an allowed root', async () => { + await writeFile(join(rootA, 'image.png'), PNG); + + const result = await resolveManagedImage(locator(), [rootA, rootB]); + + expect(result.mimeType).toBe('image/png'); + expect(Buffer.from(result.data, 'base64')).toEqual(PNG); + expect(result.metadata).not.toHaveProperty('localToken'); + }); + + it('rejects traversal tokens and symlinks escaping the root', async () => { + await expect(resolveManagedImage(locator('../image.png'), [rootA])).rejects.toThrow( + 'Media path traversal rejected' + ); + + const outside = join(tempDir, 'outside.png'); + await writeFile(outside, PNG); + await symlink(outside, join(rootA, 'image.png')); + await expect(resolveManagedImage(locator(), [rootA])).rejects.toThrow( + 'Media path traversal rejected' + ); + }); + + it('reports missing and ambiguous exact filename matches distinctly', async () => { + await expect(resolveManagedImage(locator(), [rootA, rootB])).rejects.toThrow( + 'Media file missing' + ); + + await writeFile(join(rootA, 'image.png'), PNG); + await writeFile(join(rootB, 'image.png'), PNG); + await expect(resolveManagedImage(locator(), [rootA, rootB])).rejects.toThrow( + 'Media file ambiguous' + ); + }); + + it('enforces the image MIME allowlist from file bytes', async () => { + await writeFile(join(rootA, 'image.png'), Buffer.from('')); + + await expect(resolveManagedImage(locator(), [rootA])).rejects.toThrow('Unsupported MIME'); + }); + + it('enforces requested and hard inline size limits', async () => { + await writeFile(join(rootA, 'image.png'), Buffer.concat([PNG, Buffer.alloc(32)])); + + await expect(resolveManagedImage(locator(), [rootA], 8)).rejects.toThrow( + 'Media file oversized' + ); + await expect( + resolveManagedImage(locator(), [rootA], HARD_MAX_INLINE_BYTES + 1) + ).rejects.toThrow('maxInlineBytes must be an integer'); + }); +}); diff --git a/test/unit/tools.test.ts b/test/unit/tools.test.ts index e036d9b..a228580 100644 --- a/test/unit/tools.test.ts +++ b/test/unit/tools.test.ts @@ -4,12 +4,16 @@ */ import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { registerAllTools, CREATE_NOTE_TOOL, SEARCH_TOOL, SEARCH_BY_TAG_TOOL, READ_NOTE_TOOL, + GET_MEDIA_TOOL, UPDATE_NOTE_TOOL, SET_DOCUMENT_STATUS_TOOL, LIST_CHILDREN_TOOL, @@ -282,6 +286,11 @@ describe('Tool Definitions', () => { expect(READ_NOTE_TOOL.name).toBe('remnote_read_note'); }); + it('should advertise native image retrieval inputs', () => { + expect(GET_MEDIA_TOOL.name).toBe('remnote_get_media'); + expect(GET_MEDIA_TOOL.inputSchema.required).toEqual(['remId', 'field', 'mediaId']); + }); + it('should have required remId field for READ_NOTE_TOOL', () => { expect(READ_NOTE_TOOL.inputSchema.required).toContain('remId'); }); @@ -435,14 +444,14 @@ describe('Tool Registration', () => { expect(mockServer.hasHandler(ListToolsRequestSchema)).toBe(true); }); - it('should return all 16 tools in list', async () => { + it('should return all 17 tools in list', async () => { registerAllTools(mockServer as never, mockWsServer as never, createMockLogger()); const result = (await mockServer.callHandler(ListToolsRequestSchema, {})) as { tools: unknown[]; }; - expect(result.tools).toHaveLength(16); + expect(result.tools).toHaveLength(17); }); it('should include all tool names in list', async () => { @@ -457,6 +466,7 @@ describe('Tool Registration', () => { expect(names).toContain('remnote_search'); expect(names).toContain('remnote_search_by_tag'); expect(names).toContain('remnote_read_note'); + expect(names).toContain('remnote_get_media'); expect(names).toContain('remnote_update_note'); expect(names).toContain('remnote_set_document_status'); expect(names).toContain('remnote_insert_children'); @@ -470,6 +480,77 @@ describe('Tool Registration', () => { }); }); +describe('Tool Handlers - get_media', () => { + it('returns native image content without duplicating base64 in text or structured content', async () => { + const mediaRoot = await mkdtemp(join(tmpdir(), 'remnote-tool-media-')); + const imageBytes = Buffer.from('89504e470d0a1a0a00000000', 'hex'); + await writeFile(join(mediaRoot, 'opaque.png'), imageBytes); + const base64 = imageBytes.toString('base64'); + const mockServer = new MockMCPServer(); + const mockWsServer = { + hasBridgeCapability: vi.fn().mockReturnValue(true), + sendRequest: vi.fn().mockResolvedValue({ + mediaId: 'media_1234', + kind: 'image', + field: 'text', + elementIndex: 1, + imageIndex: 0, + source: 'remnote_managed_local', + localToken: 'opaque.png', + }), + }; + registerAllTools(mockServer as never, mockWsServer as never, createMockLogger() as never, [ + mediaRoot, + ]); + + try { + const result = (await mockServer.callHandler(CallToolRequestSchema, { + params: { + name: 'remnote_get_media', + arguments: { remId: 'rem-1', field: 'text', mediaId: 'media_1234' }, + }, + })) as { + content: Array<{ type: string; data?: string; mimeType?: string; text?: string }>; + structuredContent: Record; + isError?: boolean; + }; + + expect(result.isError).toBeUndefined(); + expect(result.content[0]).toEqual({ type: 'image', data: base64, mimeType: 'image/png' }); + expect(result.content[1].type).toBe('text'); + expect(result.content[1].text).not.toContain(base64); + expect(JSON.stringify(result.structuredContent)).not.toContain(base64); + expect(mockWsServer.sendRequest).toHaveBeenCalledWith('get_media_locator', { + remId: 'rem-1', + field: 'text', + mediaId: 'media_1234', + }); + } finally { + await rm(mediaRoot, { recursive: true, force: true }); + } + }); + + it('fails loudly when the bridge lacks the image capability', async () => { + const mockServer = new MockMCPServer(); + const mockWsServer = { + hasBridgeCapability: vi.fn().mockReturnValue(false), + sendRequest: vi.fn(), + }; + registerAllTools(mockServer as never, mockWsServer as never, createMockLogger() as never); + + const result = (await mockServer.callHandler(CallToolRequestSchema, { + params: { + name: 'remnote_get_media', + arguments: { remId: 'rem-1', field: 'text', mediaId: 'media_1234' }, + }, + })) as { content: Array<{ text: string }>; isError: boolean }; + + expect(result.isError).toBe(true); + expect(result.content[0].text).toContain('Bridge capability/version mismatch'); + expect(mockWsServer.sendRequest).not.toHaveBeenCalled(); + }); +}); + describe('Tool Handlers - create_note', () => { let mockServer: MockMCPServer; let mockWsServer: { sendRequest: ReturnType }; @@ -791,6 +872,7 @@ describe('Tool Handlers - read_note', () => { maxContentLength: 100000, ancestorDepth: 0, view: 'standard', + includeMediaMetadata: false, }); }); @@ -807,6 +889,7 @@ describe('Tool Handlers - read_note', () => { maxContentLength: 100000, // default ancestorDepth: 0, view: 'standard', + includeMediaMetadata: false, }); }); @@ -826,6 +909,7 @@ describe('Tool Handlers - read_note', () => { maxContentLength: 100000, ancestorDepth: 0, view: 'standard', + includeMediaMetadata: false, }); }); @@ -1247,6 +1331,7 @@ describe('Tool Handlers - status', () => { connected: true, serverVersion: '0.5.1', ...sampleStatusResult, + capabilities: [], }); }); diff --git a/test/unit/websocket-server.test.ts b/test/unit/websocket-server.test.ts index 9808298..2313c31 100644 --- a/test/unit/websocket-server.test.ts +++ b/test/unit/websocket-server.test.ts @@ -89,13 +89,14 @@ async function openWebSocket(port: number): Promise { async function connectAcceptedClient( wsServer: WebSocketServer, port: number, - bridgeVersion = TEST_BRIDGE_VERSION + bridgeVersion = TEST_BRIDGE_VERSION, + capabilities?: string[] ): Promise { const connectPromise = new Promise((resolve) => { wsServer.onClientConnect(() => resolve()); }); const client = await openWebSocket(port); - client.send(JSON.stringify({ type: 'hello', version: bridgeVersion })); + client.send(JSON.stringify({ type: 'hello', version: bridgeVersion, capabilities })); await connectPromise; return client; } @@ -178,6 +179,13 @@ describe('WebSocketServer - Connection State', () => { expect(wsServer.isConnected()).toBe(true); }); + it('records optional bridge capabilities without requiring them from existing clients', async () => { + client = await connectAcceptedClient(wsServer, port, TEST_BRIDGE_VERSION, ['media.images.v1']); + + expect(wsServer.getBridgeCapabilities()).toEqual(['media.images.v1']); + expect(wsServer.hasBridgeCapability('media.images.v1')).toBe(true); + }); + it('should report disconnected after client closes', async () => { const disconnectPromise = new Promise((resolve) => { wsServer.onClientDisconnect(() => resolve());