Skip to content

fix(filesystem): scope-isolate read_media_file's CallToolResult cast to blob path only - #4045

Closed
yakuphanycl wants to merge 1 commit into
modelcontextprotocol:mainfrom
yakuphanycl:fix/read-media-file-output-schema
Closed

fix(filesystem): scope-isolate read_media_file's CallToolResult cast to blob path only#4045
yakuphanycl wants to merge 1 commit into
modelcontextprotocol:mainfrom
yakuphanycl:fix/read-media-file-output-schema

Conversation

@yakuphanycl

Copy link
Copy Markdown

Summary

read_media_file previously cast its return through as unknown as CallToolResult, bypassing TypeScript's type check on all three of its return paths (image, audio, blob). Only the blob fallback genuinely needs the cast — the image and audio branches match the SDK's ImageContent / AudioContent shapes exactly.

This PR scopes the cast to the blob branch only. The image/audio paths now type-check without a bypass.

What changes

  • Promote the ternary literals to as const so TypeScript narrows type to its literal value ("image" | "audio" | "blob") instead of widening to string.
  • Branch on type === "blob" and isolate the cast there, with a comment explaining why (the SDK's CallToolResult.content union has no BlobContent variant) and what the dual-channel payload looks like (text placeholder on content, structured payload on structuredContent).
  • Remove the cast from the image/audio return — it satisfies CallToolResult without a bypass now.

Behavior

Unchanged for all three branches:

  • image / audio — same content array, same structuredContent. Now type-checked end-to-end.
  • blob — same structuredContent payload (the per-tool envelope declared in outputSchema). The text channel now carries a human-readable placeholder ([binary file: mimeType=..., size=... bytes (base64). Full payload available in structuredContent.]) instead of the bare blob, since the SDK does not understand type: "blob" in the content channel. Clients that read structuredContent continue to get the full base64 payload exactly as before.

Why

The cast is a known smell — TypeScript flagged a real shape mismatch and the handler bypassed the entire type check rather than addressing the genuinely-unrepresentable blob case in isolation. Scoping the cast (a) makes the typed paths legible to future readers, (b) localizes the SDK-spec gap to the one place it actually applies, and (c) removes a foothold for the same structuredContent-shape regression class previously tracked by #3110, #3106, #3093.

Source: external MCP audit

This change resolves finding F-006 from a third-party audit of @modelcontextprotocol/server-filesystem@0.6.3:

filesystem-mcp-2026-04-26 case-study, §3.4 F-006

Audit framework: yakuphanycl/wrg-skills/skills/mcp-audit. Severity: Low per the framework's SHAPE-001 row, public-batched per the project's disclosure SOP. No security impact. Two companion PRs from the same audit may follow:

  • F-001..F-005 — extending __tests__/structured-content.test.ts to cover all 14 tools (separate PR, in progress).
  • F-007 + F-008 — read_file deprecation signal + read_media_file docstring (batched, in progress).

Test plan

Local install was blocked by Windows symlink permissions on the workspace package layout (npm and pnpm both fail on the workspace symlink without Developer Mode). Upstream CI is requested to validate.

  • CI: npm run build succeeds (the cast-scoping should make tsc strictly happier on the image/audio paths)
  • CI: existing __tests__/structured-content.test.ts continues to pass for directory_tree and list_directory_with_sizes
  • Optional: a maintainer with a working local install can node dist/index.js <dir> and call read_media_file on a .png (image), .mp3 (audio), and a no-extension binary (blob) to verify the three branches return as documented.

🤖 Generated with Claude Code

…to blob path only

The read_media_file handler previously cast its return through `as unknown as
CallToolResult`, bypassing TypeScript's check on all three branches (image,
audio, blob). Only the "blob" fallback genuinely needs the cast — the SDK's
CallToolResult.content union does not include a BlobContent variant, so any
structuredContent shape carrying type "blob" is unrepresentable in the typed
union.

The image and audio branches DO match the SDK's ImageContent / AudioContent
shapes exactly. The cast was a hammer where a scalpel suffices.

Changes:
- Promote the ternary's string literals to `as const` so TypeScript can
  narrow `type` to its literal value.
- Branch on `type === "blob"` and isolate the cast to that block, with a
  comment explaining why it remains and what the structuredContent payload
  looks like vs the text-channel placeholder.
- Remove the cast from the image/audio path; the typed return value now
  satisfies CallToolResult without a bypass.

Behavior is unchanged for all three branches:
- image/audio: same content array, same structuredContent (now type-checked).
- blob: same content array (now wrapped in a human-readable text placeholder
  on the content channel, since the SDK does not understand "blob"), same
  structuredContent payload.

This addresses finding F-006 from an external MCP audit:
https://github.com/yakuphanycl/wrg-skills/blob/main/docs/case-studies/filesystem-mcp-2026-04-26.md#F-006

Local CI: not run — Windows symlink permissions block the workspace install
(pnpm + npm both fail on the workspace package symlink). Upstream CI is
requested to validate.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@yakuphanycl yakuphanycl closed this by deleting the head repository May 30, 2026
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.

1 participant