Skip to content

Inject ToolId, when known, into _meta response#2424

Open
alzimmermsft wants to merge 5 commits intomicrosoft:mainfrom
alzimmermsft:SendToolIdBackInMetaWhenPossible
Open

Inject ToolId, when known, into _meta response#2424
alzimmermsft wants to merge 5 commits intomicrosoft:mainfrom
alzimmermsft:SendToolIdBackInMetaWhenPossible

Conversation

@alzimmermsft
Copy link
Copy Markdown
Contributor

What does this PR do?

Inject ToolId into CallToolResult._meta when it's available for MCP clients to consume if needed.

Also standardizes handling of our custom SecretHint and LocalRequiredHint to use const keys instead of each location defining the key string individually.

GitHub issue number?

[Link to the GitHub issue this PR addresses]

Pre-merge Checklist

  • Required for All PRs
    • Read contribution guidelines
    • PR title clearly describes the change
    • Commit history is clean with descriptive messages (cleanup guide)
    • Added comprehensive tests for new/modified functionality
    • Created a changelog entry if the change falls among the following: new feature, bug fix, UI/UX update, breaking change, or updated dependencies. Follow the changelog entry guide
  • For MCP tool changes:
    • One tool per PR: This PR adds or modifies only one MCP tool for faster review cycles
    • Updated servers/Azure.Mcp.Server/README.md and/or servers/Fabric.Mcp.Server/README.md documentation
    • Validate README.md changes running the script ./eng/scripts/Process-PackageReadMe.ps1. See Package README
    • For new or modified tool descriptions, ran ToolDescriptionEvaluator and obtained a score of 0.4 or more and a top 3 ranking for all related test prompts
    • For tools with new names, including new tools or renamed tools, update consolidated-tools.json
    • For renamed tools, follow the Tool Rename Checklist and tag the PR with the breaking-change label
    • For new tools associated with Azure services or publicly available tools/APIs/products, add URL to documentation in the PR description
  • Extra steps for Azure MCP Server tool changes:
    • Updated command list in servers/Azure.Mcp.Server/docs/azmcp-commands.md
    • Ran ./eng/scripts/Update-AzCommandsMetadata.ps1 to update tool metadata in azmcp-commands.md (required for CI)
    • Updated test prompts in servers/Azure.Mcp.Server/docs/e2eTestPrompts.md
    • 👉 For Community (non-Microsoft team member) PRs:
      • Security review: Reviewed code for security vulnerabilities, malicious code, or suspicious activities before running tests (crypto mining, spam, data exfiltration, etc.)
      • Manual tests run: added comment /azp run mcp - pullrequest - live to run Live Test Pipeline

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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 McpHelper to standardize _meta keys 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 SecretHint and LocalRequiredHint.

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

  • InvokeChildToolAsync injects MicrosoftMcpToolId for the special “missing required options” response, but then returns toolCallResponse without injection on the normal path. This means successful (and most error) responses won’t include the ToolId even though resolvedTool.Meta is available. Consider wrapping the final return with InjectToolIdMetadata(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;

Comment on lines 352 to +362
@@ -367,12 +359,12 @@ private async Task<CallToolResult> CommandModeAsync(RequestContext<CallToolReque
}
],
IsError = true,
};
}, resolvedTool.ProtocolTool.Meta);
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +162 to 167
return McpHelper.InjectToolIdMetadata(new CallToolResult
{
Content = [content],
IsError = true,
};
}, kvp.Tool.Meta);
}
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +158 to +162
return McpHelper.InjectToolIdMetadata(new CallToolResult
{
Content = [content],
IsError = true,
};
}, command.Id);
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Comment on lines +481 to +485
return McpHelper.InjectToolIdMetadata(new CallToolResult
{
Content = [new TextContentBlock { Text = jsonResponse }],
IsError = isError
};
}, cmd.Id);
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +224 to +232
return McpHelper.InjectToolIdMetadata(new CallToolResult
{
Content = [
new TextContentBlock {
Text = jsonResponse
}
],
IsError = isError
};
}, command.Id);
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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.

Copy link
Copy Markdown
Contributor

@jongio jongio left a comment

Choose a reason for hiding this comment

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

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.Id available)
  • NamespaceToolLoader: exception catch block (cmd.Id in scope)
  • ServerToolLoader: normal toolCallResponse return + exception catch (resolvedTool.Meta available)
  • SingleProxyToolLoader: success path + exception catch
  • RegistryToolLoader: success path from CallToolAsync

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.cs is missing a trailing newline
  • Build Analyze CI check is failing
  • No unit tests for McpHelper itself or asserting ToolId presence in results. The HasHint and both InjectToolIdMetadata overloads could use direct coverage.

Copy link
Copy Markdown
Contributor

@jongio jongio left a comment

Choose a reason for hiding this comment

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

The feedback from the first pass landed:

  • McpHelper.cs trailing newline is in, and there's a dedicated McpHelperTests covering HasHint and both InjectToolIdMetadata overloads (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 through McpHelper.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.

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

Labels

None yet

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

3 participants