Inject ToolId, when known, into _meta response#2424
Inject ToolId, when known, into _meta response#2424alzimmermsft wants to merge 5 commits intomicrosoft:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR enhances MCP tool-call traceability by propagating a known ToolId into CallToolResult._meta, and centralizes custom _meta key handling so hint checks don’t rely on scattered string literals.
Changes:
- Added
McpHelperto standardize_metakeys and provide helper methods for hint checks and ToolId injection. - Updated multiple tool loaders to use standardized hint keys and (in some paths) inject ToolId into
CallToolResult._meta. - Updated unit tests to use the new const keys / helper for
SecretHintandLocalRequiredHint.
Reviewed changes
Copilot reviewed 13 out of 13 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| core/Microsoft.Mcp.Core/src/Helpers/McpHelper.cs | Introduces shared constants and helpers for _meta hint checks and ToolId injection. |
| core/Microsoft.Mcp.Core/src/Extensions/McpServerElicitationExtensions.cs | Switches SecretHint lookup to use the shared const key. |
| core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/SingleProxyToolLoader.cs | Uses shared hint helper and injects ToolId on some error paths. |
| core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs | Uses shared hint helper and injects ToolId for a specific response path. |
| core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs | Uses shared hint helper and injects ToolId on some error paths. |
| core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs | Injects ToolId into multiple return paths and writes ToolId into tool _meta. |
| core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs | Injects ToolId into some returns and writes ToolId into tool _meta; switches hint keys to consts. |
| core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Extensions/McpServerElicitationExtensionsTests.cs | Updates tests to use McpHelper.SecretHintMetaKey. |
| core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs | Updates LocalRequiredHint usage to const + helper. |
| core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs | Updates LocalRequiredHint usage to const + helper. |
| core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/RegistryToolLoaderTests.cs | Updates LocalRequiredHint usage to const + helper. |
| core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/NamespaceToolLoaderTests.cs | Updates LocalRequiredHint assertions to use helper. |
| core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoaderTests.cs | Updates LocalRequiredHint/SecretHint checks to use helper. |
Comments suppressed due to low confidence (1)
core/Microsoft.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs:353
InvokeChildToolAsyncinjectsMicrosoftMcpToolIdfor the special “missing required options” response, but then returnstoolCallResponsewithout injection on the normal path. This means successful (and most error) responses won’t include the ToolId even thoughresolvedTool.Metais available. Consider wrapping the final return withInjectToolIdMetadata(toolCallResponse, resolvedTool.Meta)(and similarly for other non-injected returns in this method) to make the behavior consistent.
return McpHelper.InjectToolIdMetadata(finalResponse, resolvedTool.Meta);
}
}
return toolCallResponse;
| @@ -367,12 +359,12 @@ private async Task<CallToolResult> CommandModeAsync(RequestContext<CallToolReque | |||
| } | |||
| ], | |||
| IsError = true, | |||
| }; | |||
| }, resolvedTool.ProtocolTool.Meta); | |||
There was a problem hiding this comment.
ToolId metadata is only injected for the read-only / HTTP-mode rejection paths. The successful proxy call (client.CallToolAsync(...)) returns directly, so callers won’t get MicrosoftMcpToolId in CallToolResult._meta even when resolvedTool.ProtocolTool.Meta contains it. Consider capturing the returned CallToolResult and passing it through InjectToolIdMetadata(result, resolvedTool.ProtocolTool.Meta) before returning.
| return McpHelper.InjectToolIdMetadata(new CallToolResult | ||
| { | ||
| Content = [content], | ||
| IsError = true, | ||
| }; | ||
| }, kvp.Tool.Meta); | ||
| } |
There was a problem hiding this comment.
CallToolHandler injects ToolId into _meta for the mode-restriction error returns, but the normal execution path (kvp.Client.CallToolAsync(...)) returns directly. If kvp.Tool.Meta includes MicrosoftMcpToolId, the success/error result from the downstream client will be missing that metadata. Consider injecting into the returned CallToolResult before returning so the ToolId is consistently present when known.
| return McpHelper.InjectToolIdMetadata(new CallToolResult | ||
| { | ||
| Content = [content], | ||
| IsError = true, | ||
| }; | ||
| }, command.Id); |
There was a problem hiding this comment.
When HandleElicitationAsync(...) returns a CallToolResult, it’s returned as-is (return elicitationResult;) so it won’t include MicrosoftMcpToolId even though command.Id is known. For consistency with the other return paths in this method, consider injecting ToolId into the elicitation result before returning it.
There was a problem hiding this comment.
This one's addressed. The refactor moved elicitation error paths into BaseToolLoader.HandleElicitationAsync(IBaseCommand command) (lines 171-280), which now injects command.Id as the ToolId on all three failure branches: client-unsupported, user-rejected, and exception.
| return McpHelper.InjectToolIdMetadata(new CallToolResult | ||
| { | ||
| Content = [new TextContentBlock { Text = jsonResponse }], | ||
| IsError = isError | ||
| }; | ||
| }, cmd.Id); |
There was a problem hiding this comment.
The success paths now inject MicrosoftMcpToolId, but the catch-all exception path below returns a CallToolResult without ToolId. After cmd has been resolved, exceptions thrown during parsing/execution could still include the ToolId (since it’s known) to keep _meta consistent. Consider narrowing the try/catch scope or otherwise ensuring the error result is passed through InjectToolIdMetadata(..., cmd.Id) when available.
| return McpHelper.InjectToolIdMetadata(new CallToolResult | ||
| { | ||
| Content = [ | ||
| new TextContentBlock { | ||
| Text = jsonResponse | ||
| } | ||
| ], | ||
| IsError = isError | ||
| }; | ||
| }, command.Id); |
There was a problem hiding this comment.
ToolId injection into CallToolResult._meta is new behavior here, but there aren’t corresponding unit tests asserting that successful results (and the various error/elicitation paths) include MicrosoftMcpToolId when command.Id is known. Adding coverage in CommandFactoryToolLoaderTests (and/or the other ToolLoader test suites) would help prevent regressions and ensure consistency across loaders.
There was a problem hiding this comment.
Partially addressed - the new McpHelperTests covers HasHint and both InjectToolIdMetadata overloads (including null / wrong-type guard paths). Loader-level tests asserting the injected _meta on the CallToolResult returned from each loader's call path aren't there yet; worth a follow-up for regression protection on each return branch.
jongio
left a comment
There was a problem hiding this comment.
The approach is solid - centralizing meta keys in McpHelper and providing InjectToolIdMetadata overloads is clean. The HasHint consolidation removes duplication nicely.
The main concern is that ToolId injection is inconsistent across return paths. I mapped every CallToolResult return in each loader:
Paths where ToolId IS known but not injected:
CommandFactoryToolLoader: elicitation result returned as-is (command.Idavailable)NamespaceToolLoader: exception catch block (cmd.Idin scope)ServerToolLoader: normaltoolCallResponsereturn + exception catch (resolvedTool.Metaavailable)SingleProxyToolLoader: success path + exception catchRegistryToolLoader: success path fromCallToolAsync
For the proxy loaders (Server, SingleProxy, Registry), wrapping the success path with the meta-based overload would be a no-op for external tools that don't set MicrosoftMcpToolId - but it keeps the contract consistent: "every CallToolResult returned by our loaders has ToolId when it's knowable."
Other notes:
McpHelper.csis missing a trailing newline- Build Analyze CI check is failing
- No unit tests for
McpHelperitself or asserting ToolId presence in results. TheHasHintand bothInjectToolIdMetadataoverloads could use direct coverage.
jongio
left a comment
There was a problem hiding this comment.
The feedback from the first pass landed:
McpHelper.cstrailing newline is in, and there's a dedicatedMcpHelperTestscoveringHasHintand bothInjectToolIdMetadataoverloads (including the wrong-type guard).- Elicitation error paths inject ToolId consistently now via the
BaseToolLoader.HandleElicitationAsync(IBaseCommand)refactor - client-unsupported, user-rejected, and exception branches all go throughMcpHelper.InjectToolIdMetadata(..., command.Id). - Build Analyze is green.
One consistency question still open on the proxy loaders. Error paths (read-only / HTTP-mode / "Missing required options") inject ToolId via resolvedTool.Meta / kvp.Tool.Meta, but the normal success path still returns the downstream CallToolResult untouched:
ServerToolLoader.cs:353-return toolCallResponse;SingleProxyToolLoader.cs:385-return await client.CallToolAsync(...);RegistryToolLoader.cs:175-return await kvp.Client.CallToolAsync(...);
Is that an intentional "don't rewrite the downstream server's _meta" choice? If the child is another Azure MCP, its own CommandFactoryToolLoader success path now injects the child's ToolId, so wrapping the parent return with InjectToolIdMetadata(result, resolvedTool.Meta) would overwrite the child's ID with the parent listing-side ID. That's a real semantic tradeoff either way; worth a one-line comment in code for future readers.
Same question applies to the catch-all exception blocks in NamespaceToolLoader (line 486), ServerToolLoader (line 355), and SingleProxyToolLoader (line 387). cmd / resolvedTool isn't guaranteed in scope when the exception fires early in tool resolution, but once past that point the known ToolId could still be injected into the error result if that's the contract you want.
McpHelper coverage is solid. No loader-level test yet asserts ToolId is actually present on the returned CallToolResult across all return paths; worth a low-priority follow-up for regression protection.
What does this PR do?
Inject
ToolIdintoCallToolResult._metawhen it's available for MCP clients to consume if needed.Also standardizes handling of our custom
SecretHintandLocalRequiredHintto useconstkeys instead of each location defining the key string individually.GitHub issue number?
[Link to the GitHub issue this PR addresses]Pre-merge Checklist
servers/Azure.Mcp.Server/README.mdand/orservers/Fabric.Mcp.Server/README.mddocumentationREADME.mdchanges running the script./eng/scripts/Process-PackageReadMe.ps1. See Package READMEToolDescriptionEvaluatorand obtained a score of0.4or more and a top 3 ranking for all related test promptsconsolidated-tools.jsonbreaking-changelabelservers/Azure.Mcp.Server/docs/azmcp-commands.md./eng/scripts/Update-AzCommandsMetadata.ps1to update tool metadata inazmcp-commands.md(required for CI)servers/Azure.Mcp.Server/docs/e2eTestPrompts.mdcrypto mining, spam, data exfiltration, etc.)/azp run mcp - pullrequest - liveto run Live Test Pipeline