Skip to content

feat(core): keep the MCP tool display title reachable - #3330

Open
King52HerTz wants to merge 2 commits into
agentscope-ai:mainfrom
King52HerTz:feat/mcp-tool-display-title
Open

King52HerTz wants to merge 2 commits into
agentscope-ai:mainfrom
King52HerTz:feat/mcp-tool-display-title

Conversation

@King52HerTz

Copy link
Copy Markdown

Problem

MCP defines Tool.title as the human-readable name for display purposes, and allows the same value under ToolAnnotations.title. McpClientManager built each McpTool from name, description, inputSchema, outputSchema and annotations.readOnlyHint — the title was dropped at registration and is not reachable from AgentTool/Toolkit afterwards.

name (repair__create_ticket) is a programmatic identifier and description is model-facing guidance, so a caller rendering a permission confirmation has nothing suitable to show an end user.

Change

  • AgentTool.getTitle() — a default method returning null, following the same shape as the existing getStrict() and getOutputSchema() defaults, so it is additive and no implementer must change.
  • ToolBase carries the value; ToolBase.Builder.title(...) sets it.
  • McpTool.resolveDisplayTitle(McpSchema.Tool) prefers Tool.title and falls back to ToolAnnotations.title, ignoring blank values.
  • McpClientManager resolves it at registration.

The existing McpTool constructors stay and delegate with a null title, so a new 10-argument constructor carries it instead — servers that send no title, and callers that pre-date this, behave exactly as before.

Deliberately not included

The issue also floats adding toolTitle to the AG-UI permission interrupt next to toolName/toolInput. That is a second wire format and a second set of consumers, so I left it out to keep this reviewable; once getTitle() exists on the tool the AG-UI side is a one-line addition if maintainers want it.

Local @Tool methods return null for the title, as the issue suggested they may.

Testing

McpToolTitleTest covers title precedence, the annotation fallback, blank values, a null tool definition, reachability through the AgentTool interface (which is the width Toolkit hands to callers), and that a title-less registration does not throw.

mvn test -pl :agentscope-core,:agentscope-harness,:agentscope-extensions-agui -am
# core 2492 / harness 509 / agui 1062 tests, 0 failures

Partially addresses #3316

MCP defines Tool.title as the human-readable name for display purposes, with
ToolAnnotations.title as an alternative. McpClientManager built each McpTool
from name, description, inputSchema, outputSchema and readOnlyHint, so the
title was dropped at registration and no caller could recover it without a
second listTools round trip through framework internals.

Surface it as AgentTool.getTitle(), defaulting to null the way getStrict() and
getOutputSchema() already do, carry it on ToolBase, and resolve
title-or-annotation-fallback during registration. The existing McpTool
constructors delegate with a null title, so servers that send none and callers
that pre-date this are unchanged.
@CLAassistant

CLAassistant commented Sep 28, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@codecov

codecov Bot commented Sep 28, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.74468% with 2 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
...c/main/java/io/agentscope/core/tool/AgentTool.java 0.00% 1 Missing ⚠️
...main/java/io/agentscope/core/tool/mcp/McpTool.java 97.56% 0 Missing and 1 partial ⚠️

📢 Thoughts on this report? Let us know!

@oss-maintainer oss-maintainer left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Summary

PR #3330 — feat(core): keep the MCP tool display title reachable. Nice catch: the MCP tool title was parsed but never surfaced, so permission prompts fall back to the namespaced id. Two things to address: the one production line that wires the title is untested, and the title is an untrusted server-provided string that is about to be shown to a human approving a tool call.

Findings

  • [Warning] agentscope-core/src/main/java/io/agentscope/core/tool/McpClientManager.java:326 — the only line that actually delivers the title has no coverage; registration tests mock title() unstubbed.
  • [Warning] agentscope-core/src/main/java/io/agentscope/core/tool/mcp/McpTool.java:408 — server-controlled display label passed through verbatim; needs control-character stripping + length cap before it reaches a permission prompt.
  • [Info] agentscope-core/src/test/java/io/agentscope/core/tool/mcp/McpToolTitleTest.java:51 — non-English fixture literals against the repo's code-review convention.

CLA note

license/cla is pending on this head commit, so this is a comment-only review (no approval) — @King52HerTz please sign the CLA.


Automated review by github-manager-bot

mcpClientWrapper.getName(),
readOnly);
readOnly,
McpTool.resolveDisplayTitle(mcpTool));

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Warning] This is the only production line that actually delivers the title, but no test covers it: McpToolTitleTest exercises resolveDisplayTitle(...) and the new constructor directly, while the existing registration tests mock McpSchema.Tool with title()/annotations() unstubbed. A regression here (title dropped, or passed into the wrong positional parameter) would leave getTitle() null with the whole suite green. Suggest stubbing tool.title() in McpNamespacedToolTest, which already registers through this exact path (toolkit.registration().mcpClient(client).mcpToolNamePrefix(...).apply()), and asserting toolkit.getTool("crm__search").getTitle().

}
String title = tool.title();
if (title != null && !title.isBlank()) {
return title;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Warning] resolveDisplayTitle returns the server-supplied string verbatim. Per the javadoc above it, this value is rendered to an end user (e.g. as the label on a permission confirmation), and MCP servers are third-party/untrusted: ANSI/terminal control sequences or bidi overrides in a title can spoof or hide the operation being approved, and the MCP spec sets no length limit, so an oversized title can flood the prompt. Please harden at this single trust boundary — strip Character.isISOControl characters, collapse whitespace, and cap the length (~120 chars) — so AgentTool.getTitle() only ever hands out a display-safe label.

McpSchema.Tool tool =
new McpSchema.Tool(
"create_ticket",
"提交报修工单",

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

[Info] Minor repo-convention nit: the new fixture hardcodes non-English literals (提交报修工单), and the project's code-review instructions disallow non-English content outside i18n resources. The behaviour under test (prefer Tool.title, fall back to ToolAnnotations.title, reject blanks) is charset-independent, so ASCII literals would keep the test aligned with the convention. If non-ASCII pass-through is genuinely part of the contract, please say so in the test name/comment so the exception is explicit.

@oss-maintainer

Copy link
Copy Markdown
Collaborator

CLA Not Signed

The Contributor License Agreement (CLA) check is currently pending on this PR (license/cla: Contributor License Agreement is not signed yet.). This PR cannot be merged until the CLA is signed.

@King52HerTz please sign the CLA via the CLA assistant badge in the comment above, or visit https://cla-assistant.io/agentscope-ai/agentscope-java. Once signed, the license/cla status will turn green.


Automated check by github-manager-bot

…path

Review follow-up for agentscope-ai#3330.

The title is a string the MCP server chose and we show it to a human who is
about to approve an action, so it now passes one normalisation step wherever it
enters a McpTool: zero-width and directional-format characters are dropped,
whitespace and C0/C1 controls collapse to a single space, and the value is capped
at 120 characters (the width already used for one-line tool labels in
AgentTraceMiddleware) without ever cutting a surrogate pair in half. Nothing
printable left means null, so callers fall back to getName() rather than showing
an empty label. Both entry points are guarded, not just registration, because
extensions construct McpTool directly.

The registration tests mocked McpSchema.Tool and stubbed only name(),
description() and inputSchema(), so title() sat at null and the line that
actually delivers the title never ran with a value under test. Four cases now
drive a real tool definition through registerMcpClient and assert on the
registered AgentTool: tool title, annotation fallback, hostile title, and a
server that sends none. Verified by mutation: dropping resolveDisplayTitle from
registration fails three of them, and removing either sanitise call site fails
the ones that guard it.
@King52HerTz

Copy link
Copy Markdown
Author

Both points are addressed in 1b6c235, plus the [Info].

1. McpClientManager.java:326 — the line that delivers the title had no coverage.

You were right about the cause: every registration test mocked McpSchema.Tool and stubbed only name() / description() / inputSchema(), so title() sat at Mockito's default null and that line never executed with a value to deliver. Four new cases in McpClientManagerTest now drive a real McpSchema.Tool through registerMcpClient(...) and assert on the registered AgentTool:

  • Tool.title reaches getTitle(), with getName() untouched;
  • ToolAnnotations.title is reached through the fallback;
  • a hostile title arrives already sanitised;
  • a server that sends no title still yields null.

2. McpTool.java:408 — server-controlled label passed through verbatim.

sanitizeDisplayTitle() normalises the value before it can reach a prompt:

  • Cf characters (bidi overrides U+202E/U+202D, ZWNJ U+200C, soft hyphen U+00AD) are dropped without inserting a space — they are zero-width, so replacing one with a space would silently change the word;
  • whitespace and C0/C1 controls collapse to a single space, so a label cannot inject extra lines into a confirmation dialog;
  • the result is capped at 120 characters, reusing the width this codebase already applies to one-line tool labels in AgentTraceMiddleware; the ellipsis is reserved inside the cap, and a cut that would land between a surrogate pair backs up one character instead of emitting a lone surrogate;
  • if nothing printable survives, the result is null so callers fall back to getName() rather than rendering an empty label.

The guard sits in both entry points — resolveDisplayTitle (registration) and the 10-argument constructor — because extensions construct McpTool directly, so registration is not the only producer.

Also took the [Info]: the non-English fixture literals in McpToolTitleTest are English now.

Evidence. Mutation, not just green-on-first-run: replacing the registration call with null fails three of the new tests; removing the sanitise call from the constructor fails one; removing it from resolveDisplayTitle fails five. Full reactor mvn test and spotless:check are green locally — CI will confirm on the new head.

This branch has not been deployed

No deployments
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.

3 participants