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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions mcpb/remnote-local/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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."
Expand Down
34 changes: 34 additions & 0 deletions mcpb/remnote-local/server/fallback-tools.generated.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

14 changes: 12 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface CliOptions {
logFile?: string;
requestLog?: string;
responseLog?: string;
mediaRoots?: string[];
}

const validLogLevels = ['debug', 'info', 'warn', 'error'];
Expand Down Expand Up @@ -83,11 +84,20 @@ export function parseCliArgs(): CliOptions {
.option('--verbose', 'Shorthand for --log-level debug')
.option('--log-file <path>', 'Log to file (default: console only)')
.option('--request-log <path>', 'Log all WebSocket requests to file (JSON Lines)')
.option('--response-log <path>', 'Log all WebSocket responses to file (JSON Lines)');
.option('--response-log <path>', 'Log all WebSocket responses to file (JSON Lines)')
.option(
'--media-root <path>',
'Allowed RemNote managed-media root (repeatable, env: REMNOTE_MEDIA_ROOTS)',
(value, previous: string[] | undefined) => [...(previous ?? []), value]
);

program.parse();

const options = program.opts<CliOptions>();
const parsedOptions = program.opts<CliOptions & { mediaRoot?: string[] }>();
const { mediaRoot, ...options } = parsedOptions;
if (mediaRoot?.length) {
options.mediaRoots = mediaRoot;
}

// Validate port conflicts
if (options.wsPort && options.httpPort && options.wsPort === options.httpPort) {
Expand Down
31 changes: 29 additions & 2 deletions src/config.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -14,6 +14,7 @@ export interface ServerConfig {
requestLog?: string;
responseLog?: string;
prettyLogs: boolean;
mediaRoots: string[];
}

export interface ConfigFileOptions {
Expand Down Expand Up @@ -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,
Expand All @@ -107,6 +116,7 @@ export function getConfig(
requestLog,
responseLog,
prettyLogs,
mediaRoots,
};
}

Expand Down Expand Up @@ -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;
Expand Down
5 changes: 3 additions & 2 deletions src/http-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,8 @@ async function main() {
name: 'remnote-mcp-server',
version: packageJson.version,
},
logger
logger,
config.mediaRoots
);

await httpServer.start();
Expand Down
168 changes: 168 additions & 0 deletions src/media.ts
Original file line number Diff line number Diff line change
@@ -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<MediaLocator, 'localToken'>;
}

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');
}
}
Comment on lines +28 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Add a defensive type check to ensure token is a string before performing string operations like normalize or includes. Since the input is cast from unknown at the API boundary, runtime type safety is crucial to prevent unexpected TypeErrors.

Suggested change
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 validateLocalToken(token: string): void {
if (
typeof token !== 'string' ||
!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<ResolvedMedia> {
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<string, string>();
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,
};
}
Comment on lines +67 to +168

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

This refactoring addresses two key issues:

  1. Defensive Programming: Adds a runtime check to ensure locator is a valid object before accessing its properties, preventing potential TypeErrors if the bridge returns a malformed payload.
  2. Performance/Efficiency: Avoids a redundant stat call on the matched file. By storing the file size directly in the matches map during the initial check, we eliminate the need to call stat a second time before reading the file.
export async function resolveManagedImage(
  locator: MediaLocator,
  roots: string[],
  maxInlineBytes = DEFAULT_MAX_INLINE_BYTES
): Promise<ResolvedMedia> {
  if (!locator || typeof locator !== 'object') {
    throw new Error('Invalid media locator payload received from bridge');
  }
  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<string, number>();
  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, fileStat.size);
    } 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, fileSize] = [...matches.entries()][0];
  let data: Buffer;
  try {
    if (fileSize > maxInlineBytes) {
      throw new Error(
        'Media file oversized: ' + fileSize + ' 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: fileSize,
    metadata,
  };
}

Loading