Skip to content

MCP server: return images from read_file and read text without the plugin - #951

Open
chesterXalan wants to merge 2 commits into
mattermost:masterfrom
chesterXalan:feat/mcp-read-file-images
Open

MCP server: return images from read_file and read text without the plugin#951
chesterXalan wants to merge 2 commits into
mattermost:masterfrom
chesterXalan:feat/mcp-read-file-images

Conversation

@chesterXalan

@chesterXalan chesterXalan commented Aug 5, 2026

Copy link
Copy Markdown

Summary

Split from #888 to keep reviews small and focused. This part makes read_file useful for images and for servers without the Agents plugin:

  • Adds an optional RichResolver to MCPTool so tools can return non-text MCP content blocks. read_file now returns JPEG/PNG/GIF/WebP attachments as inline image content (5 MB cap; larger images get a metadata-only reply pointing at get_file_link).
  • When the plugin's extraction endpoint is unreachable (e.g. a standalone MCP server pointed at a Mattermost without the plugin), read_file falls back to reading plain-text files directly through the user's client with the same rune-windowed paging.
  • format.WritePost lists file attachments as metadata (name, type, size, File ID) so models can discover File IDs from read_post/read_channel and fetch content on demand via read_file.

QA test steps:

  1. Attach an image to a post and call read_post → the attachment metadata includes its File ID. Call read_file with that ID → an inline image content block plus a short text description is returned.
  2. Call read_file on an image larger than 5 MB → a metadata-only text reply suggesting get_file_link.
  3. With a standalone MCP server pointed at a Mattermost without the Agents plugin, call read_file on a .txt attachment → raw text is returned with paging info.

Ticket Link

None. Supersedes part of #888.

Release Note

The MCP server read_file tool now returns image attachments (JPEG/PNG/GIF/WebP) as inline image content, and falls back to reading plain-text files directly when the Agents plugin's extraction endpoint is unavailable. Post output now lists attachment metadata including File IDs.

Summary by CodeRabbit

  • New Features
    • File reading now supports inline JPEG, PNG, GIF, and WebP images up to 5 MB.
    • File results include metadata such as name, type, size, and ID.
    • Posts display attached file details when available.
  • Bug Fixes
    • Improved fallback handling for text extraction and file downloads.
    • Clearer errors are shown for unsupported, oversized, inaccessible, or unreadable files.
    • File links are provided when images exceed the inline size limit.

- add an optional RichResolver to MCPTool so tools can return non-text
  MCP content blocks; read_file now returns JPEG/PNG/GIF/WebP
  attachments as inline image content (5 MB cap, metadata-only reply
  above it)
- when the plugin's extraction endpoint is unreachable (e.g. a
  standalone server pointed at a Mattermost without the plugin),
  read_file falls back to reading plain-text files directly through
  the user's client with the same rune-windowed paging
- WritePost lists file attachments as metadata (name, type, size,
  File ID) so models can discover File IDs from read_post/read_channel
  and fetch content on demand via read_file
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex usage limits have been reached for code reviews. Please check with the admins of this repo to increase the limits by adding credits.
Credits must be used to enable repository wide code reviews.

@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: d62425ff-bc03-4603-bb0a-bea80ac6f845

📥 Commits

Reviewing files that changed from the base of the PR and between 158f03c and c8c1a09.

📒 Files selected for processing (2)
  • mcpserver/tools/files.go
  • mcpserver/tools/files_test.go
🚧 Files skipped from review as they are similar to previous changes (2)
  • mcpserver/tools/files.go
  • mcpserver/tools/files_test.go

📝 Walkthrough

Walkthrough

The PR formats attached file metadata in posts and adds structured MCP file responses. The read_file tool supports inline images, text extraction, direct-download fallbacks, file links, and explicit errors.

Changes

Post attachment formatting

Layer / File(s) Summary
Format attached file details
format/format.go, format/format_test.go
WritePost renders file names, MIME types, sizes, and IDs. It falls back to bare file IDs when metadata is absent or contains only nil entries. Tests cover each output mode.

MCP file reading

Layer / File(s) Summary
Add rich MCP resolver support
mcpserver/tools/provider.go
MCPTool accepts RichResolver functions that return MCP content blocks. Argument decoding and error handling are included.
Implement structured file responses
mcpserver/tools/files.go
read_file classifies files, returns supported images inline up to 5 MB, applies text extraction and 10 MB direct-download fallbacks, and reports unsupported or failed reads explicitly.
Validate file response behaviour
mcpserver/tools/files_test.go
HTTP-backed tests cover image responses, size limits, extraction failures, permission errors, binary files, fallback reads, and user-context propagation.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant MCPClient
  participant read_file
  participant MattermostFileClient
  participant FileContentService
  MCPClient->>read_file: Request file content
  read_file->>MattermostFileClient: Fetch file metadata
  alt Supported image within 5 MB
    read_file->>MattermostFileClient: Download image bytes
    read_file-->>MCPClient: Return image content
  else Text-like file
    read_file->>FileContentService: Extract text
    FileContentService-->>read_file: Return text or error
    read_file-->>MCPClient: Return text or download fallback
  else Unsupported or oversized file
    read_file-->>MCPClient: Return error or file link
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main changes: returning images from read_file and adding fallback text reading without the plugin dependency. It addresses the primary objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai 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.

Actionable comments posted: 1

🧹 Nitpick comments (5)
mcpserver/tools/files.go (3)

108-131: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Consider lowering the direct-read download bound.

maxStandaloneTextBytes allows a 10 MB download. files.Slice then converts the body to a []rune, which allocates up to 4 bytes per rune. A 10 MB text file therefore costs about 10 MB for the body plus up to 40 MB for the rune slice, per concurrent call, while the tool returns at most MaxReadRunes characters.

The plugin extraction path has the same cost, so this is not a regression. Consider a smaller bound for the direct path, or slice by byte offset before the rune conversion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpserver/tools/files.go` around lines 108 - 131, Reduce the direct-read size
limit used by readFileContentStandalone to avoid unnecessary memory allocation
before files.Slice converts the entire body to runes; update
maxStandaloneTextBytes to a smaller bound appropriate for the tool’s
MaxReadRunes output while preserving the existing size validation and error
behavior.

84-106: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Extract the shared MIME normalization.

Both helpers repeat strings.ToLower(strings.TrimSpace(strings.SplitN(mimeType, ";", 2)[0])). Extract a small baseMimeType helper. This keeps the two classifiers consistent if the normalization changes later.

♻️ Proposed refactor
+// baseMimeType returns the lower-cased media type without parameters.
+func baseMimeType(mimeType string) string {
+	return strings.ToLower(strings.TrimSpace(strings.SplitN(mimeType, ";", 2)[0]))
+}
+
 func isInlineImageMimeType(mimeType string) bool {
-	switch strings.ToLower(strings.TrimSpace(strings.SplitN(mimeType, ";", 2)[0])) {
+	switch baseMimeType(mimeType) {
 	case "image/jpeg", "image/png", "image/gif", "image/webp":
 		return true
 	}
 	return false
 }
 
 func isTextLikeMimeType(mimeType string) bool {
-	base := strings.ToLower(strings.TrimSpace(strings.SplitN(mimeType, ";", 2)[0]))
+	base := baseMimeType(mimeType)
 	if strings.HasPrefix(base, "text/") {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpserver/tools/files.go` around lines 84 - 106, Extract the repeated MIME
normalization into a shared baseMimeType helper, then update
isInlineImageMimeType and isTextLikeMimeType to use it instead of performing
their own strings.ToLower/TrimSpace/SplitN logic. Preserve both classifiers’
existing matching behavior.

176-178: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Preserve the sentinel error on the forbidden path.

Line 177 builds a new error with fmt.Errorf and a constant string. This drops the files.ErrForbidden sentinel, so no caller can use errors.Is. It also uses fmt.Errorf without a format verb, which some linters flag.

Wrap the sentinel instead.

♻️ Proposed refactor
 		if errors.Is(svcErr, files.ErrForbidden) {
-			return nil, fmt.Errorf("you do not have permission to read this file")
+			return nil, fmt.Errorf("you do not have permission to read this file: %w", files.ErrForbidden)
 		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpserver/tools/files.go` around lines 176 - 178, Update the forbidden-error
branch in the surrounding file-read method to wrap and preserve
files.ErrForbidden while retaining the user-facing permission message. Replace
the constant-string fmt.Errorf usage with an error construction that supports
errors.Is checks against the sentinel.
mcpserver/tools/files_test.go (1)

100-179: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add cases for media types that carry parameters.

Mattermost frequently stores media types with parameters, for example text/plain; charset=utf-8. Every case in the table uses a bare media type. The parameter-stripping logic in isInlineImageMimeType and isTextLikeMimeType is therefore untested through the resolver.

Add two cases: a text file with text/plain; charset=utf-8, and an image with image/PNG or image/png; charset=binary. The image case also pins the media type that the resolver puts in the ImageContent block, which relates to the normalization issue raised on mcpserver/tools/files.go line 161.

💚 Proposed test cases
 		{
 			name:     "nil service falls back to direct read for text files",
 			fileID:   fileID,
 			info:     &model.FileInfo{Id: fileID, Name: "notes.txt", MimeType: "text/plain"},
 			fileData: []byte("plain text body"),
 			service:  nil,
 			wantText: []string{"File: notes.txt (text/plain)", "plain text body"},
 		},
+		{
+			name:     "text media type with parameters falls back to direct read",
+			fileID:   fileID,
+			info:     &model.FileInfo{Id: fileID, Name: "notes.txt", MimeType: "text/plain; charset=utf-8"},
+			fileData: []byte("plain text body"),
+			service:  nil,
+			wantText: []string{"plain text body"},
+		},
+		{
+			name:      "image media type with parameters is returned inline",
+			fileID:    fileID,
+			info:      &model.FileInfo{Id: fileID, Name: "photo.png", MimeType: "image/PNG", Size: 4},
+			fileData:  []byte{0x89, 0x50, 0x4E, 0x47},
+			service:   &fakeFileContentService{},
+			wantImage: true,
+		},

The image case will fail the assert.Equal(t, tt.info.MimeType, image.MIMEType) assertion at line 207 until the resolver normalizes the media type. Adjust that assertion to the expected normalized value once the resolver change lands.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpserver/tools/files_test.go` around lines 100 - 179, Extend the resolver
table tests with a text file using a parameterized media type such as
text/plain; charset=utf-8 and an image using image/PNG or image/png;
charset=binary. Verify both are classified correctly, and update the image MIME
type assertion to expect the resolver’s normalized media type in the
ImageContent block rather than the original input.
mcpserver/tools/provider.go (1)

314-333: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Normalize nil resolver contents before returning the MCP result.

CallToolResult.content is required by MCP. A future rich resolver returning nil, nil would omit the content array if the SDK treats nil slices as absent, so return [] for nil contents or a placeholder text block.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@mcpserver/tools/provider.go` around lines 314 - 333, Update the rich-resolver
branch around mcpTool.RichResolver to normalize a nil contents slice after a
successful resolve and before constructing mcp.CallToolResult. Return an empty
content slice (or the established placeholder text block) when contents is nil,
while preserving non-nil resolver results unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@mcpserver/tools/files.go`:
- Around line 149-162: Use the normalized base media type when constructing the
mcp.ImageContent in the inline-image branch, reusing the existing baseMimeType
helper used by isInlineImageMimeType. Keep info.MimeType unchanged in the
descriptive TextContent if desired, and preserve the allowlist and download
behavior.

---

Nitpick comments:
In `@mcpserver/tools/files_test.go`:
- Around line 100-179: Extend the resolver table tests with a text file using a
parameterized media type such as text/plain; charset=utf-8 and an image using
image/PNG or image/png; charset=binary. Verify both are classified correctly,
and update the image MIME type assertion to expect the resolver’s normalized
media type in the ImageContent block rather than the original input.

In `@mcpserver/tools/files.go`:
- Around line 108-131: Reduce the direct-read size limit used by
readFileContentStandalone to avoid unnecessary memory allocation before
files.Slice converts the entire body to runes; update maxStandaloneTextBytes to
a smaller bound appropriate for the tool’s MaxReadRunes output while preserving
the existing size validation and error behavior.
- Around line 84-106: Extract the repeated MIME normalization into a shared
baseMimeType helper, then update isInlineImageMimeType and isTextLikeMimeType to
use it instead of performing their own strings.ToLower/TrimSpace/SplitN logic.
Preserve both classifiers’ existing matching behavior.
- Around line 176-178: Update the forbidden-error branch in the surrounding
file-read method to wrap and preserve files.ErrForbidden while retaining the
user-facing permission message. Replace the constant-string fmt.Errorf usage
with an error construction that supports errors.Is checks against the sentinel.

In `@mcpserver/tools/provider.go`:
- Around line 314-333: Update the rich-resolver branch around
mcpTool.RichResolver to normalize a nil contents slice after a successful
resolve and before constructing mcp.CallToolResult. Return an empty content
slice (or the established placeholder text block) when contents is nil, while
preserving non-nil resolver results unchanged.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Repository UI (base), Organization UI (inherited)

Review profile: CHILL

Plan: Pro Plus

Run ID: cf2be9e8-1cd9-49bb-8207-d02f84a45a78

📥 Commits

Reviewing files that changed from the base of the PR and between f4e52d2 and 158f03c.

📒 Files selected for processing (5)
  • format/format.go
  • format/format_test.go
  • mcpserver/tools/files.go
  • mcpserver/tools/files_test.go
  • mcpserver/tools/provider.go

Comment thread mcpserver/tools/files.go
@mattermost-build

Copy link
Copy Markdown
Collaborator

This PR has been automatically labelled "stale" because it hasn't had recent activity.
A core team member will check in on the status of the PR to help with questions.
Thank you for your contribution!

@chesterXalan

Copy link
Copy Markdown
Author

Still active and green — awaiting review.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants