Skip to content

fix: return MCP-compliant content[] from tool handlers (clients show "no output") - #27

Open
ChrisbyChA wants to merge 1 commit into
nloui:mainfrom
ChrisbyChA:fix/mcp-tool-result-content
Open

fix: return MCP-compliant content[] from tool handlers (clients show "no output")#27
ChrisbyChA wants to merge 1 commit into
nloui:mainfrom
ChrisbyChA:fix/mcp-tool-result-content

Conversation

@ChrisbyChA

@ChrisbyChA ChrisbyChA commented Jun 27, 2026

Copy link
Copy Markdown

Problem

When used with a strict MCP client (e.g. Claude Code), every tool (search_documents, list_tags, list_correspondents, get_document, …) returns "completed with no output" — even though the Paperless instance, token and routing are all fine and the same requests work via curl.

Root cause

The tool handlers return the raw Paperless REST object directly, e.g. in src/tools/tags.js:

server.tool("list_tags", "...", {}, async () => {
  if (!api) throw new Error("Please configure API connection first");
  return api.getTags();          // -> { count, next, previous, all, results }
});

But the MCP spec requires a tools/call result to be a CallToolResult, i.e. { content: [{ type: "text", text }] }. A raw object has no content[], so spec-compliant clients render nothing. Captured tools/call result for list_tags:

{ "count": 14, "next": null, "previous": null, "all": [...], "results": [...] }   // no content[] -> "no output"

(Some lenient clients happen to display the raw object, which is why this can go unnoticed; Claude Code does not.)

Fix

A single central shim in src/index.ts (right after new McpServer(...)) that wraps every registered tool handler and normalizes its return value into MCP content[], while passing through results that are already MCP-compliant. One place, covers all current and future tools, no change to the individual tool files:

const _origTool = (server.tool as any).bind(server);
(server as any).tool = (...toolArgs: any[]) => {
  const lastIdx = toolArgs.length - 1;
  if (typeof toolArgs[lastIdx] === "function") {
    const origHandler = toolArgs[lastIdx];
    toolArgs[lastIdx] = async (...hArgs: any[]) => {
      const result = await origHandler(...hArgs);
      if (result && typeof result === "object" && Array.isArray((result as any).content)) {
        return result; // already MCP-compliant
      }
      const text = typeof result === "string" ? result : JSON.stringify(result, null, 2);
      return { content: [{ type: "text", text }] };
    };
  }
  return _origTool(...toolArgs);
};

Testing

Built with tsc and exercised over a raw stdio handshake (initializetools/call):

  • list_tags and search_documents now return result.content[0].type === "text" with the JSON payload.
  • Verified against a live Paperless-ngx 2.20.x instance (690 documents); searches return the expected hits.

Source-only change (build/ is gitignored).

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes
    • Improved compatibility for server tools so responses are consistently returned in the expected format.
    • Helped ensure tool results display correctly even when handlers return plain values instead of structured content.
    • Reduced the chance of malformed or missing output from tool actions.

…put')

Tool-Handler gaben rohe Paperless-API-Objekte zurueck; MCP-Clients erwarten
{content:[{type:text,text}]}. Zentraler Shim in src/index.ts normalisiert jeden
registrierten Handler. Build (tsc) regeneriert. Lokaler Patch (kein Upstream-Push).
@coderabbitai

coderabbitai Bot commented Jun 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Startup now installs a compatibility shim around server.tool so registered tool handlers return MCP-formatted content objects. Existing { content: [...] } results pass through unchanged, while other results are converted to text using string, JSON, or fallback String() conversion.

Changes

MCP tool response normalization

Layer / File(s) Summary
Tool handler compatibility shim
src/index.ts
Monkey-patches server.tool, wraps the final handler argument, and normalizes handler results into MCP content objects when they are not already content-formatted.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

I hopped through the startup glow, 🐇
And wrapped each tool to ebb and flow.
Text or JSON, all neat and right,
Now MCP snacks are served just right.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main fix: wrapping tool handlers to return MCP-compliant content[] instead of raw outputs.
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

🤖 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 `@src/index.ts`:
- Around line 75-84: The text normalization in the response builder can still
produce a non-string when `JSON.stringify` returns undefined for top-level
undefined, functions, or symbols. Update the `text` assignment in `src/index.ts`
so the `return { content: [{ type: "text", text }] }` path always receives a
string, by adding a fallback to `String(result)` whenever the stringify result
is not a string. Keep the fix localized to the `text` handling block around the
`result` serialization logic.
🪄 Autofix (Beta)

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: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e2f9fe07-68b0-4ffd-abb3-da7bf48cc28e

📥 Commits

Reviewing files that changed from the base of the PR and between 4ba7457 and c75b4ae.

📒 Files selected for processing (1)
  • src/index.ts

Comment thread src/index.ts
Comment on lines +75 to +84
let text: string;
try {
text =
typeof result === "string"
? result
: JSON.stringify(result, null, 2);
} catch {
text = String(result);
}
return { content: [{ type: "text", text }] };

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the changed area and surrounding context in src/index.ts
git diff -- src/index.ts | sed -n '1,220p'
printf '\n---\n'
sed -n '1,140p' src/index.ts
printf '\n---\n'

# Read-only probe for JSON.stringify top-level edge cases and String() fallback
node - <<'JS'
const cases = [undefined, function f(){}, Symbol('s'), 1, null, {a:1}, [1,2]];
for (const value of cases) {
  let serialized;
  try {
    serialized = JSON.stringify(value, null, 2);
  } catch (e) {
    serialized = `THREW: ${e && e.message}`;
  }
  console.log({
    type: typeof value,
    value: String(value),
    json: serialized,
    jsonType: typeof serialized,
  });
}
JS

Repository: nloui/paperless-mcp

Length of output: 5337


Ensure text always resolves to a string.

JSON.stringify(result, null, 2) returns undefined for top-level undefined, functions, and symbols, so this path can still emit { content: [{ type: "text", text: undefined }] } and break the MCP content contract.

🤖 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 `@src/index.ts` around lines 75 - 84, The text normalization in the response
builder can still produce a non-string when `JSON.stringify` returns undefined
for top-level undefined, functions, or symbols. Update the `text` assignment in
`src/index.ts` so the `return { content: [{ type: "text", text }] }` path always
receives a string, by adding a fallback to `String(result)` whenever the
stringify result is not a string. Keep the fix localized to the `text` handling
block around the `result` serialization logic.

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