Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
---
status: proposed
contact: baywet
date: 2026-11-10
deciders: [baywet, westey-m]
informed: [rhurey]
---

# Separate MCP discovery consent from tool invocation approval

## Context and Problem Statement

An approval-gated declarative MCP action needs the current tool definition before
it can bind an invocation approval to that definition. Obtaining the definition
is itself a network operation using the configured endpoint and transport
settings. Approval to invoke a tool and permission to contact its server are
different decisions.

The executor cannot determine whether discovery will carry credentials merely by
checking workflow headers: managed connections, custom handlers, and configured
HTTP clients may also supply them. Workflow expressions can change while an
approval is pending, and headers must not become durable checkpoint state.

## Decision Drivers

- Obtain consent before contacting a server for an approval-gated action.
- Bind each consent to its captured destination, workflow headers, and connection reference.
- Require invocation approval after discovering the current published definition.
- Preserve request isolation, replay handling, and credential nonpersistence.
- Support custom handlers and all supported .NET targets.

## Considered Options

- Discover with configured transport settings before requesting tool approval.
Good: one approval interaction and discovery works with authenticated servers.
Neutral: metadata discovery remains a separate handler invocation.
Bad: discovery contacts the destination before any consent.
- Discover without workflow headers before requesting tool approval.
Good: explicit workflow headers are not sent during discovery.
Neutral: the executor still performs a network operation before consent.
Bad: authenticated discovery can fail, and other credential sources remain.
- Obtain discovery consent, then obtain a definition-bound invocation approval.
Good: no handler invocation occurs before discovery consent, including when
authentication is supplied outside workflow headers.
Neutral: discovery and invocation remain separate, invocation-owned MCP calls.
Bad: approval-gated tool calls require two interactions rather than one.

## Decision Outcome

Chosen option: "Obtain discovery consent, then obtain a definition-bound invocation
approval", because it does not depend on the executor detecting every possible
credential source.

For approval-gated actions, the initial request authorizes `tools/list` against
the displayed destination using the configured transport settings. The destination,
workflow headers, and connection reference are captured; host-managed transport
credentials remain subject to the handler's lifecycle. Discovery consent does not
authorize the requested tool invocation. After consent, discovery produces a
separate approval for the captured tool name and arguments. That approval includes
a fingerprint of the complete published tool definition in its internal snapshot.
Before invocation, the executor discovers the definition again and requests fresh
invocation approval if it differs or the previous snapshot lacks a fingerprint.
Changed metadata does not authorize an invocation.

The reserved `tools/list` action is discovery itself: its single approval
authorizes that operation, without requiring it to appear in the tool catalog.
Actions without approval retain their existing direct-invocation behavior.

Each stage uses a distinct request identifier. Responses use captured invocation
parameters rather than reevaluating them. Headers remain in memory only. If a
restore loses required headers, the executor reevaluates the configuration and
starts a fresh discovery approval before contacting the newly evaluated
destination. Denied or consumed requests cannot authorize a later stage.

Handlers must return structured `tools/list` output containing complete protocol
tool definitions, including annotations and metadata. Object properties are
compared in ordinally sorted order recursively; array order and scalar values are
preserved. Missing or ambiguous definitions stop the action rather than bypassing
approval. This compares published metadata, not server implementation identity,
and does not eliminate the interval between discovery and invocation.

Existing approval UIs must handle the additional discovery request and its notice.
Older snapshots without discovery consent restart discovery approval, as do
snapshots requiring unavailable headers. The ADR remains
proposed pending the listed deciders' PR review.
Original file line number Diff line number Diff line change
Expand Up @@ -350,22 +350,7 @@ private static string SerializeToolsList(IEnumerable<Tool> tools)

foreach (Tool tool in tools)
{
writer.WriteStartObject();
writer.WriteString("name", tool.Name);
writer.WriteString("description", tool.Description);
writer.WritePropertyName("inputSchema");
tool.InputSchema.WriteTo(writer);
writer.WritePropertyName("outputSchema");
if (tool.OutputSchema is JsonElement outputSchema)
{
outputSchema.WriteTo(writer);
}
else
{
writer.WriteNullValue();
}

writer.WriteEndObject();
JsonSerializer.Serialize(writer, tool, McpJsonUtilities.DefaultOptions.GetTypeInfo<Tool>());
}

writer.WriteEndArray();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ namespace Microsoft.Agents.AI.Workflows.Declarative;
/// <remarks>
/// This interface allows the MCP tool invocation to be abstracted, enabling
/// different implementations for local development, hosted workflows, and testing scenarios.
/// Implementations used by approval-gated MCP actions must support the reserved <c>tools/list</c> operation
Comment thread
baywet marked this conversation as resolved.
Resolved
/// and return a text output containing a structured tools array with complete published tool definitions,
/// including annotations and metadata. The requested tool must include an <c>inputSchema</c> object whose
/// <c>type</c> is <c>object</c>. Discovery is invoked only after explicit discovery approval; a separate
/// approval binds the tool invocation to its definition. Approval fails closed when the definition cannot be verified.
/// </remarks>
public interface IMcpToolHandler
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
Expand Down Expand Up @@ -32,6 +34,7 @@ internal sealed class InvokeMcpToolExecutor(
{
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshots);
private const string LegacyApprovalSnapshotStateKey = "_approvalSnapshot";
private const string ListToolsToolName = "tools/list";

/// <summary>
/// Snapshots of evaluated parameters captured at approval-request time, keyed by
Expand Down Expand Up @@ -191,7 +194,61 @@ public async ValueTask CaptureResponseAsync(
headers = null;
}

if (!snapshot.IsDiscoveryApproval &&
!snapshot.DiscoveryApproved &&
!string.Equals(snapshot.ToolName, ListToolsToolName, StringComparison.Ordinal))
{
string reapprovalNotice = snapshot.ToolDefinitionFingerprint is null
? "The previous approval did not include the MCP tool definition. Review the request before approving."
: "The previous approval did not include separate MCP tool discovery consent. Review the request before approving.";
await this.RequestApprovalAsync(
context,
snapshot.ServerUrl,
snapshot.ServerLabel,
snapshot.ToolName,
snapshot.Arguments,
headers,
snapshot.ConnectionName,
snapshot.AutoSend ?? this.GetAutoSendValue(),
cancellationToken,
reapprovalNotice).ConfigureAwait(false);
return;
}

string? conversationId = this.GetConversationId();
if (!string.Equals(snapshot.ToolName, ListToolsToolName, StringComparison.Ordinal))
{
string currentToolDefinitionFingerprint = await this.GetToolDefinitionFingerprintAsync(
snapshot.ServerUrl,
snapshot.ServerLabel,
snapshot.ToolName,
headers,
snapshot.ConnectionName,
cancellationToken).ConfigureAwait(false);
if (snapshot.IsDiscoveryApproval ||
!string.Equals(snapshot.ToolDefinitionFingerprint, currentToolDefinitionFingerprint, StringComparison.Ordinal))
{
string? reapprovalNotice = snapshot.IsDiscoveryApproval
? null
: snapshot.ToolDefinitionFingerprint is null
? "The previous approval did not include the MCP tool definition. Review the request before approving."
: "The MCP tool definition changed since the previous approval. Review the request before approving.";
await this.RequestApprovalAsync(
context,
snapshot.ServerUrl,
snapshot.ServerLabel,
snapshot.ToolName,
snapshot.Arguments,
headers,
snapshot.ConnectionName,
snapshot.AutoSend ?? this.GetAutoSendValue(),
cancellationToken,
reapprovalNotice,
currentToolDefinitionFingerprint).ConfigureAwait(false);
return;
}
}

bool autoSend = snapshot.AutoSend ?? this.GetAutoSendValue();
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
snapshot.ServerUrl,
Expand Down Expand Up @@ -289,29 +346,170 @@ private async ValueTask RequestApprovalAsync(
Dictionary<string, string>? headers,
string? connectionName,
bool autoSend,
CancellationToken cancellationToken)
CancellationToken cancellationToken,
string? reapprovalNotice = null,
string? toolDefinitionFingerprint = null)
{
bool isListTools = string.Equals(toolName, ListToolsToolName, StringComparison.Ordinal);
bool isDiscoveryApproval = toolDefinitionFingerprint is null && !isListTools;
string requestId = Guid.NewGuid().ToString("N");

this._approvalSnapshots[requestId] = new ApprovalSnapshot(serverUrl, serverLabel, toolName, arguments, connectionName)
{
ConnectionNameValidated = true,
RequiresHeaderReapprovalAfterRestore = this.Model.Headers is { Count: > 0 },
AutoSend = autoSend,
ToolDefinitionFingerprint = toolDefinitionFingerprint,
IsDiscoveryApproval = isDiscoveryApproval,
DiscoveryApproved = toolDefinitionFingerprint is not null,
};
this._approvalHeaderSnapshots[requestId] = new HeaderSnapshot(headers);

// Transport headers are intentionally excluded from externally-surfaced approval content.
McpServerToolCallContent toolCall = new(requestId, toolName, serverLabel ?? serverUrl)
string approvalServerName = isDiscoveryApproval || isListTools ? serverUrl : serverLabel ?? serverUrl;
McpServerToolCallContent toolCall = new(requestId, isDiscoveryApproval ? ListToolsToolName : toolName, approvalServerName)
{
Arguments = arguments
Arguments = isDiscoveryApproval ? null : arguments
};

ToolApprovalRequestContent approvalRequest = new(requestId, toolCall);
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
if (isDiscoveryApproval || isListTools)
{
string discoveryNotice = $"Allow MCP tool discovery at '{serverUrl}' using the configured transport settings.";
if (isDiscoveryApproval)
{
discoveryNotice += $" This does not approve invoking '{toolName}'; a separate tool approval will follow discovery.";
}

reapprovalNotice = reapprovalNotice is null ? discoveryNotice : $"{reapprovalNotice} {discoveryNotice}";
}

ChatMessage requestMessage = reapprovalNotice is not null
? new ChatMessage(ChatRole.Assistant, [new TextContent(reapprovalNotice), approvalRequest])
: new ChatMessage(ChatRole.Assistant, [approvalRequest]);
await context.SendMessageAsync(new ExternalInputRequest(new AgentResponse([requestMessage])), cancellationToken).ConfigureAwait(false);
}

private async ValueTask<string> GetToolDefinitionFingerprintAsync(
string serverUrl,
string? serverLabel,
string toolName,
IDictionary<string, string>? headers,
string? connectionName,
CancellationToken cancellationToken)
{
McpServerToolResultContent toolList = await mcpToolHandler.InvokeToolAsync(
serverUrl,
serverLabel,
ListToolsToolName,
null,
Comment thread
baywet marked this conversation as resolved.
Resolved
headers,
connectionName,
cancellationToken).ConfigureAwait(false);

string? matchingToolFingerprint = null;
bool foundToolsArray = false;
if (toolList.Outputs is not null)
{
foreach (string serialized in toolList.Outputs.OfType<TextContent>().Select(textContent => textContent.Text.TrimStart()))
{
if (serialized.Length == 0 || serialized[0] != '{')
{
continue;
}

using JsonDocument document = JsonDocument.Parse(serialized);
if (!document.RootElement.TryGetProperty("tools", out JsonElement tools) ||
tools.ValueKind != JsonValueKind.Array)
{
continue;
}

foundToolsArray = true;
foreach (JsonElement tool in tools.EnumerateArray().Where(tool =>
tool.TryGetProperty("name", out JsonElement name) &&
name.ValueKind == JsonValueKind.String &&
string.Equals(name.GetString(), toolName, StringComparison.Ordinal)))
{
if (matchingToolFingerprint is not null)
{
throw this.Exception($"The MCP server returned multiple definitions for tool '{toolName}'.");
}

if (!tool.TryGetProperty("inputSchema", out JsonElement inputSchema) ||
inputSchema.ValueKind != JsonValueKind.Object ||
!inputSchema.TryGetProperty("type", out JsonElement schemaType) ||
schemaType.ValueKind != JsonValueKind.String ||
!string.Equals(schemaType.GetString(), "object", StringComparison.Ordinal))
{
throw this.Exception($"The MCP server returned an invalid input schema for tool '{toolName}'.");
}

matchingToolFingerprint = ComputeToolDefinitionFingerprint(tool);
}
}
}

if (!foundToolsArray)
{
throw this.Exception($"The MCP server did not return a valid tool list while checking approval for tool '{toolName}'.");
}

if (matchingToolFingerprint is null)
{
throw this.Exception($"The MCP server did not return a definition for tool '{toolName}'.");
}

return matchingToolFingerprint;
}

private static string ComputeToolDefinitionFingerprint(JsonElement tool)
{
using MemoryStream stream = new();
using (Utf8JsonWriter writer = new(stream))
{
WriteCanonicalJson(writer, tool);
}

#if NET
return Convert.ToHexString(SHA256.HashData(stream.ToArray()));
#else
using SHA256 sha256 = SHA256.Create();
return BitConverter.ToString(sha256.ComputeHash(stream.ToArray())).Replace("-", string.Empty);
#endif
}

private static void WriteCanonicalJson(Utf8JsonWriter writer, JsonElement value)
{
switch (value.ValueKind)
{
case JsonValueKind.Object:
writer.WriteStartObject();
foreach (JsonProperty property in value.EnumerateObject().OrderBy(property => property.Name, StringComparer.Ordinal))
{
writer.WritePropertyName(property.Name);
WriteCanonicalJson(writer, property.Value);
}

writer.WriteEndObject();
break;

case JsonValueKind.Array:
writer.WriteStartArray();
foreach (JsonElement element in value.EnumerateArray())
{
WriteCanonicalJson(writer, element);
}

writer.WriteEndArray();
break;

default:
value.WriteTo(writer);
break;
}
}

private async ValueTask ProcessResultAsync(
IWorkflowContext context,
McpServerToolResultContent resultContent,
Expand Down Expand Up @@ -543,6 +741,12 @@ internal sealed record ApprovalSnapshot(
public bool ConnectionNameValidated { get; init; }

public bool RequiresHeaderReapprovalAfterRestore { get; init; }

public string? ToolDefinitionFingerprint { get; init; }

public bool IsDiscoveryApproval { get; init; }

public bool DiscoveryApproved { get; init; }
}

private sealed record HeaderSnapshot(Dictionary<string, string>? Headers);
Expand Down
Loading
Loading