Skip to content

feat: retrieve RemNote-managed images#27

Open
charleseze356 wants to merge 1 commit into
robert7:mainfrom
charleseze356:feat/media-images-mvp
Open

feat: retrieve RemNote-managed images#27
charleseze356 wants to merge 1 commit into
robert7:mainfrom
charleseze356:feat/media-images-mvp

Conversation

@charleseze356

@charleseze356 charleseze356 commented Jul 13, 2026

Copy link
Copy Markdown

Retrieve RemNote-managed images

What
Adds remnote_get_media to fetch a specific image attachment on demand, returning MCP-native image content. New src/media.ts (retrieval + size cap + stale-ID handling), config.ts (default 20 MB limit, configurable up to 100 MB), plus type and transport wiring.

Failure behavior

  • Missing/deleted media → actionable error.
  • Oversized media → metadata + instructions to raise the limit.
  • Stale/invalid attachment ID → clear error.

Scope

  • Images, RemNote-managed, read-only.
  • PDFs / audio / video / external fetch deferred (images-first MVP).

Testing

  • 566 unit tests pass; typecheck, lint, and production build pass.
  • Live 1.31 MB PNG round-trip verified.

Sync
Ships with bridge PR #44 (capability media.images.v1).


Original generated description

Summary

  • add remnote_get_media with MCP-native image content
  • add opt-in media metadata to remnote_read_note
  • negotiate the bridge media.images.v1 capability
  • resolve opaque local filenames only inside configured or auto-discovered RemNote media roots

Safety

  • confines real paths and symlinks to exact media roots
  • rejects missing and ambiguous matches
  • sniffs PNG, JPEG, GIF, and WebP signatures
  • enforces a 5 MiB default and 10 MiB hard inline limit
  • does not duplicate base64 in text or structured metadata

Validation

  • npm run typecheck
  • npm test (566 tests)
  • npm run lint
  • npm run format:check
  • npm run build
  • live-tested a 1,310,293-byte pasted PNG through HTTP MCP; verified native image content, metadata, stale-ID rejection, and size-limit rejection

Companion PR

Requires the synchronized remnote-mcp-bridge media metadata PR.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Code Review

This pull request introduces the remnote_get_media tool, allowing the retrieval of RemNote-managed local images as MCP-native image content. It adds support for configuring allowed media roots via CLI, environment variables, or TOML, and implements robust path traversal checks, MIME sniffing, and size limits. The review feedback suggests enhancing runtime safety in src/media.ts by adding defensive type checks for the media locator and token inputs, as well as optimizing performance in resolveManagedImage by caching file sizes to avoid redundant stat calls.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread src/media.ts
Comment on lines +67 to +168
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,
};
}

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,
  };
}

Comment thread src/media.ts
Comment on lines +28 to +39
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');
}
}

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');
}
}

@charleseze356
charleseze356 marked this pull request as ready for review July 13, 2026 01:09
robert7 added a commit that referenced this pull request Jul 17, 2026
Persistent exact-title fixtures derive Rem, property, field, and media IDs without per-machine test configuration.

Refs #27
@robert7

robert7 commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Thanks for the contribution. I pulled the changes from this PR and the companion bridge PR, remnote-mcp-bridge#44, into these local feature branches for validation:

  • remnote-mcp-server: feature/pr-27-managed-image-retrieval
  • remnote-mcp-bridge: feature/pr-44-managed-image-metadata

On top of the submitted changes, I addressed the review findings: hardened locator validation and file access, bound locators to the requested Rem, added CLI parity for managed-media retrieval, improved error handling, and expanded unit/integration coverage.

I also replaced the media/table test environment variables and Rem-ID configuration with convention-based fixtures:

  • Automation Bridge Test Tag, containing the automation-level property
  • Automation Bridge Test Media, documented as a flashcard with an exact text-only front and a locally imported image on its back

The fixture setup and test behavior are documented in:

Missing fixtures now skip only their dependent workflows, and direct MCP, MCPB, and CLI suites continue running so all missing or malformed fixtures are reported together.

From my side, both PRs would otherwise be ready to merge. However, I have not been able to make the managed-media integration test run successfully. The fixture is found by name, but the bridge-serialized text/backText never contains an image with source: "remnote_managed_local".

Could you please clarify:

  1. The exact RemNote steps required to create an image that is serialized as remnote_managed_local.
  2. Whether this feature is expected to work with a synced/remote KB, a local-only KB, or both.
  3. Whether pasting or uploading a local PNG/JPEG/GIF/WebP onto the flashcard back should be sufficient, and how a developer can verify that RemNote stored it in the expected form.

This distinction is currently unclear from the PR, and it blocks final end-to-end validation of the media path.

robert7 added a commit that referenced this pull request Jul 17, 2026
Run all transports and skip only fixture-dependent workflows so one missing fixture does not hide unrelated coverage.

Refs #27
@robert7

robert7 commented Jul 17, 2026

Copy link
Copy Markdown
Owner

=> I suggest you to checkout my mentioned feature branches, thy to make integration tests work and explain what is missing to make it work on my side...

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants