Skip to content
Open
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
Expand Up @@ -25,6 +25,13 @@ This sample deploys to Foundry **directly from source (code / ZIP upload)**: the
| `.env.example` | Template for local configuration. |
| `../../scripts/Add-LocalFrameworkFeed.ps1`, `../../scripts/add-local-framework-feed.sh` | Contributor-only helpers, see [Deploy your local framework changes](#deploy-your-local-framework-changes-contributors). |

## Responses tool contract

After consent and any required approval, the hosted container executes the Toolbox tool through MCP.
Responses callers receive one completed `mcp_call` item with inline output rather than a
`function_call` plus `function_call_output` pair. Approval-required Toolbox tools use
`mcp_approval_request`, and delegated OAuth continues to use `oauth_consent_request`.

## Configuration

Copy the template and fill in your project endpoint:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,14 @@ This sample deploys to Foundry **directly from source (code / ZIP upload)**: the
| `.env.example` | Template for local configuration. |
| `../../scripts/Add-LocalFrameworkFeed.ps1`, `../../scripts/add-local-framework-feed.sh` | Contributor-only helpers, see [Deploy your local framework changes](#deploy-your-local-framework-changes-contributors). |

## Responses tool contract

The hosted container executes Toolbox tools through MCP. Responses callers see each execution as one
`mcp_call` item with the Toolbox name in `server_label`, the remote tool name, its arguments, and the
result in the item's `output`. The caller does not execute the tool and does not send a
`function_call_output`. A Toolbox tool that requires approval is surfaced as an
`mcp_approval_request` before execution.

## Configuration

Copy the template and fill in your project endpoint:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,11 @@ conversation id, so the approval request is sent with `conversation`, not
`previous_response_id`. Sending only `previous_response_id` selects the separate implicit-chain
path; sending both fields is rejected by the Responses API.

After approval, `load_skill` is returned as `function_call` plus `function_call_output`, followed by
the assistant message. These are Agent Framework's built-in skill functions. The Toolbox MCP
connection supplies the skill source; it is not the tool call exposed to the model, so this path does
not produce an `mcp_call`.

### Step 4: clean up

```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,12 @@ Type a message or 'quit' to exit.

List<ChatMessage> decisions = approvals.ConvertAll(approval =>
{
string name = (approval.ToolCall as FunctionCallContent)?.Name ?? "tool";
string name = approval.ToolCall switch
{
FunctionCallContent functionCall => functionCall.Name,
McpServerToolCallContent mcpCall => mcpCall.Name,
_ => "tool",
};
Console.ForegroundColor = ConsoleColor.Yellow;
Console.Write($"Approve tool call '{name}'? [Y/N] ");
Console.ResetColor();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -254,8 +254,7 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
agentOptions?.ChatOptions?.RawRepresentationFactory,
this._options);
chatOptions.Instructions = request.Instructions;

// Inject pre-registered Foundry Toolbox tools when the toolbox service is available.
// Inject pre-registered Foundry Toolbox tools and collect local MCP execution metadata.
if (this._toolboxService is not null)
{
// Re-apply the call id: the EmitCreated/EmitInProgress yields above reverted the ambient
Expand Down Expand Up @@ -296,6 +295,8 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
ConsentBlockingFunctionInvoker.EnsureInstalled(functionInvokingClient);
}

IReadOnlyDictionary<string, LocalMcpExecutionContract> localMcpFunctions =
CreateLocalMcpFunctionContracts(agentOptions?.ChatOptions?.Tools, chatOptions.Tools);
var options = new ChatClientAgentRunOptions(chatOptions);

// We only use a volatile provider for the conversation history if the agent is a
Expand Down Expand Up @@ -361,6 +362,7 @@ public override async IAsyncEnumerable<ResponseStreamEvent> CreateAsync(
agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token),
stream,
session?.StateBag,
localMcpFunctions,
cancellationToken: cancellationToken).GetAsyncEnumerator(cancellationToken);
var pendingEvents = new Queue<ResponseStreamEvent>();
try
Expand Down Expand Up @@ -752,6 +754,58 @@ await toolboxService
Consents: []);
}

private static Dictionary<string, LocalMcpExecutionContract> CreateLocalMcpFunctionContracts(
params IEnumerable<AITool>?[] toolSets)
{
var contracts = new Dictionary<string, LocalMcpExecutionContract>(StringComparer.Ordinal);
var functions = new Dictionary<string, (AIFunction Function, LocalMcpExecutionContract? Contract)>(
StringComparer.Ordinal);
var observedTools = new HashSet<AITool>(ReferenceEqualityComparer.Instance);
foreach (var toolSet in toolSets)
{
if (toolSet is null)
{
continue;
}

foreach (var tool in toolSet)
{
if (!observedTools.Add(tool) || tool is not AIFunction function)
{
continue;
}

LocalMcpExecutionContract? contract = function.GetService<LocalMcpExecutionContract>();
if (contract is not null)
{
contract = contract with
{
RequiresApproval = function.GetService<ApprovalRequiredAIFunction>() is not null,
};
}

if (functions.TryGetValue(function.Name, out var existing)
&& (existing.Contract is not null || contract is not null))
{
throw new InvalidOperationException(
$"Local MCP function name '{function.Name}' collides with another agent function.");
}

functions[function.Name] = (function, contract);
}
}

foreach (var (functionName, entry) in functions)
{
if (entry.Contract is not null)
{
contracts.Add(functionName, entry.Contract);
}
}

return contracts;
}

/// <summary>
/// Emits the terminal events for a turn that stops on OAuth consent: one <c>oauth_consent_request</c>
/// item per consent, then <c>response.incomplete</c>.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Copyright (c) Microsoft. All rights reserved.

using System;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Threading;
Expand All @@ -11,6 +12,52 @@

namespace Microsoft.Agents.AI.Foundry.Hosting;

/// <summary>
/// Describes how one locally executed function-shaped MCP tool is projected onto the caller-facing MCP contract.
/// </summary>
internal sealed record LocalMcpExecutionContract(
string ServerLabel,
string ToolName,
bool RequiresApproval,
bool DeferProjectionUntilResult = false);

/// <summary>
/// Adds caller-facing MCP execution metadata to an arbitrary locally executed function.
/// </summary>
internal sealed class LocalMcpAIFunction : DelegatingAIFunction
{
private readonly LocalMcpExecutionContract _contract;

internal LocalMcpAIFunction(AIFunction innerFunction, string serverLabel, string toolName)
: base(innerFunction)
{
this._contract = new(serverLabel, toolName, innerFunction.GetService<ApprovalRequiredAIFunction>() is not null);
}

public override object? GetService(Type serviceType, object? serviceKey = null) =>
serviceKey is null && serviceType == typeof(LocalMcpExecutionContract)
? this._contract
: base.GetService(serviceType, serviceKey);

protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken cancellationToken)
{
LocalMcpInvocationTracker.Record(this._contract);
Comment thread
rogerbarreto marked this conversation as resolved.
Resolved
try
{
return await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Cancellation produces no FunctionResultContent, so OutputConverter cannot consume
// the temporary invocation marker. Ordinary failures still need the marker for projection.
LocalMcpInvocationTracker.RemoveCurrent();
throw;
}
}
}

/// <summary>
/// An <see cref="AIFunction"/> wrapper around <see cref="McpClientTool"/> that intercepts
/// JSON-RPC error -32006 (OAuth consent required) from the Foundry Toolsets proxy and
Expand All @@ -31,12 +78,17 @@ namespace Microsoft.Agents.AI.Foundry.Hosting;
internal sealed class ConsentAwareMcpClientAIFunction : AIFunction
{
private readonly McpClientTool _inner;
private readonly string _toolboxName;
private readonly LocalMcpExecutionContract _contract;

internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName)
{
this._inner = inner;
this._toolboxName = toolboxName;
this.ToolboxName = toolboxName;
this._contract = new(
toolboxName,
inner.Name,
RequiresApproval: false,
DeferProjectionUntilResult: true);
}

public override string Name => this._inner.Name;
Expand All @@ -49,16 +101,27 @@ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName

public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions;

internal string ToolboxName { get; }

internal string ToolName => this._inner.Name;

public override object? GetService(Type serviceType, object? serviceKey = null) =>
serviceKey is null && serviceType == typeof(LocalMcpExecutionContract)
? this._contract
: base.GetService(serviceType, serviceKey);

protected override async ValueTask<object?> InvokeCoreAsync(
AIFunctionArguments arguments,
CancellationToken cancellationToken)
{
LocalMcpInvocationTracker.Record(this._contract);
try
{
return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
}
catch (McpProtocolException ex) when ((int)ex.ErrorCode == -32006)
{
LocalMcpInvocationTracker.RemoveCurrent();
var state = McpConsentContext.Current.Value;
if (state is not null)
{
Expand All @@ -74,7 +137,7 @@ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName
// handler rejects this consent response instead of falling back to the message.
_ => string.Empty,
};
state.AddPendingConsent(new McpConsentInfo(this._toolboxName, this._inner.Name, consentUrl));
state.AddPendingConsent(new McpConsentInfo(this.ToolboxName, this._inner.Name, consentUrl));
if (FunctionInvokingChatClient.CurrentContext is { } invocation)
{
if (state.ReconcileDownstreamHistory)
Expand All @@ -98,6 +161,49 @@ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName
cancellationToken.ThrowIfCancellationRequested();
throw; // fallback if the CT wasn't cancelled for some reason
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
// Cancellation produces no FunctionResultContent, so OutputConverter cannot consume
// the temporary invocation marker. Ordinary failures still need the marker for projection.
LocalMcpInvocationTracker.RemoveCurrent();
throw;
}
}
}

internal static class LocalMcpInvocationTracker
{
[UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")]
[UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")]
public static void Record(LocalMcpExecutionContract contract)
{
FunctionCallContent? call = FunctionInvokingChatClient.CurrentContext?.CallContent;
AgentSessionStateBag? stateBag = AIAgent.CurrentRunContext?.Session?.StateBag;
if (call?.CallId is not { Length: > 0 } callId || stateBag is null)
{
return;
}

string arguments = call.Arguments is not null
? JsonSerializer.Serialize(call.Arguments)
: "{}";
LocalMcpInvocationMap.Record(
stateBag,
callId,
call.Name,
contract.ToolName,
contract.ServerLabel,
arguments);
}

public static void RemoveCurrent()
{
FunctionCallContent? call = FunctionInvokingChatClient.CurrentContext?.CallContent;
AgentSessionStateBag? stateBag = AIAgent.CurrentRunContext?.Session?.StateBag;
if (call?.CallId is { Length: > 0 } callId)
{
_ = LocalMcpInvocationMap.Consume(stateBag, callId);
}
}
}

Expand Down
Loading
Loading