From e4c915b199078e024ad78e405d4defc021cbbb40 Mon Sep 17 00:00:00 2001 From: Daniel Cazzulino Date: Tue, 4 Nov 2025 20:00:01 -0300 Subject: [PATCH] Add compatibility with MCP tools registrations We leverage the same API as MCP server builder by adding our own WithTools for now. The notes agent sample is extended to showcase custom AIContextProvider (that injects the current notes as instructions) and an MCPTool method (save_notes) to mutate them as needed by the agent. --- readme.md | 9 ++++ sample/Server/NotesTools.cs | 29 ++++++++++++ sample/Server/Program.cs | 16 +++++-- sample/Server/Server.csproj | 2 + sample/Server/notes.agent.md | 29 ++++++++++-- src/Agents/Agents.csproj | 19 ++++++-- src/Agents/CompositeAIContextProvider.cs | 3 ++ src/Agents/ConfigurableAIAgent.cs | 25 +++++----- src/Agents/ConfigurableAgentsExtensions.cs | 55 ++++++++++++++++++++-- src/Agents/IAIAgentsBuilder.cs | 29 ++++++++++++ src/Extensions/Extensions.csproj | 14 +++++- src/Tests/ConfigurableAgentTests.cs | 29 +++++++++--- src/Tests/Tests.csproj | 17 +++---- 13 files changed, 234 insertions(+), 42 deletions(-) create mode 100644 sample/Server/NotesTools.cs create mode 100644 src/Agents/IAIAgentsBuilder.cs diff --git a/readme.md b/readme.md index c9a6fc7..8699f68 100644 --- a/readme.md +++ b/readme.md @@ -264,6 +264,15 @@ tools = ["get_date"] This enables a flexible and convenient mix of static and dynamic context for agents, all driven from configuration. +In addition to registering your own tools in DI, you can also use leverage the MCP C# SDK and reuse +the same tool declarations: + +```csharp +builder.Services.AddMcpServer().WithTools(); + +// 👇 Reuse same tool definitions in agents +builder.AddAIAgents().WithTools(); +``` diff --git a/sample/Server/NotesTools.cs b/sample/Server/NotesTools.cs new file mode 100644 index 0000000..f3931c3 --- /dev/null +++ b/sample/Server/NotesTools.cs @@ -0,0 +1,29 @@ +using Microsoft.Agents.AI; +using ModelContextProtocol.Server; + +public class NotesContextProvider(NotesTools notes) : AIContextProvider +{ + public override ValueTask InvokingAsync(InvokingContext context, CancellationToken cancellationToken = default) + => ValueTask.FromResult(new AIContext + { + Instructions = + $""" + Your current state is: + + ${notes.GetNotes()} + + """ + }); +} + +[McpServerToolType] +public class NotesTools +{ + string notes = ""; + + [McpServerTool] + public string GetNotes() => notes; + + [McpServerTool] + public void SaveNotes(string notes) => this.notes = notes; +} diff --git a/sample/Server/Program.cs b/sample/Server/Program.cs index 8e89efd..9050a13 100644 --- a/sample/Server/Program.cs +++ b/sample/Server/Program.cs @@ -1,9 +1,12 @@ -using System.Runtime.InteropServices; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; using System.Text; using Devlooped.Extensions.AI; +using Microsoft.Agents.AI; using Microsoft.Agents.AI.Hosting; using Microsoft.Agents.AI.Hosting.OpenAI; using Microsoft.Extensions.AI; +using ModelContextProtocol.Server; using Spectre.Console; var builder = WebApplication.CreateBuilder(args); @@ -23,10 +26,15 @@ // dummy ones for illustration builder.Services.AddKeyedSingleton("create_order", AIFunctionFactory.Create(() => "OK", "create_order")); builder.Services.AddKeyedSingleton("cancel_order", AIFunctionFactory.Create(() => "OK", "cancel_order")); -builder.Services.AddKeyedSingleton("save_notes", AIFunctionFactory.Create((string notes) => true, "save_notes")); + +builder.Services.AddKeyedSingleton("notes"); + +// 👇 seamless integration of MCP tools +//builder.Services.AddMcpServer().WithTools(); // 👇 implicitly calls AddChatClients -builder.AddAIAgents(); +builder.AddAIAgents() + .WithTools(); var app = builder.Build(); @@ -76,4 +84,4 @@ }); } -app.Run(); +app.Run(); \ No newline at end of file diff --git a/sample/Server/Server.csproj b/sample/Server/Server.csproj index 978e7d6..5183465 100644 --- a/sample/Server/Server.csproj +++ b/sample/Server/Server.csproj @@ -2,10 +2,12 @@ net10.0 + true + diff --git a/sample/Server/notes.agent.md b/sample/Server/notes.agent.md index fe7ac9c..dd07c05 100644 --- a/sample/Server/notes.agent.md +++ b/sample/Server/notes.agent.md @@ -3,7 +3,30 @@ id: ai.agents.notes description: Provides free-form memory client: grok model: grok-4-fast -use: ["tone"] -tools: ["save_notes", "get_date"] +use: ["tone", "notes"] +tools: ["get_notes", "save_notes", "get_date"] --- -You organize and keep notes for the user, using JSON-LD \ No newline at end of file +You organize and keep notes for the user. + +You extract key points from the user's input and store them as notes in the agent +state. You keep track of notes that reference external files too, adding corresponding +notes relating to them if the user sends more information about them. + +You use JSON-LD format to store notes, optimized for easy retrieval, filtering +or reasoning later. This can involve nested structures, lists, tags, categories, timestamps, +inferred relationships between nodes and entities, etc. As you collect more notes, you can +go back and update, merge or reorganize existing notes to improve their structure, cohesion +and usefulness for retrieval and querying. + +When storing relative times (like "last week" or "next week"), always convert them to absolute +dates or timestamps, so you can be precise when responding about them. + +You are NOT a general-purpose assistant that can answer questions or perform tasks unrelated +to note-taking and recalling notes. If the user asks you to do something outside of +note-taking, you should politely decline and remind them of your purpose. + +Never include technical details about the JSON format or the storage mechanism in your +responses. Just focus on the content of the notes and how they can help the user. + +When recalling information from notes, don't ask for follow-up questions or request +any more information. Just provide the information. diff --git a/src/Agents/Agents.csproj b/src/Agents/Agents.csproj index c6c516b..d87faf5 100644 --- a/src/Agents/Agents.csproj +++ b/src/Agents/Agents.csproj @@ -17,18 +17,29 @@ $(NoWarn);CS0436;SYSLIB1100;SYSLIB1101;MEAI001 - + + - + + + + + + + + + + + - - + + diff --git a/src/Agents/CompositeAIContextProvider.cs b/src/Agents/CompositeAIContextProvider.cs index 9597c11..be42e3c 100644 --- a/src/Agents/CompositeAIContextProvider.cs +++ b/src/Agents/CompositeAIContextProvider.cs @@ -62,6 +62,9 @@ public override async ValueTask InvokingAsync(InvokingContext invokin if (staticContext is not null) return staticContext; + if (providers.Count == 1) + return await providers[0].InvokingAsync(invoking, cancellationToken); + var context = new AIContext(); var instructions = new List(); var messages = new List(); diff --git a/src/Agents/ConfigurableAIAgent.cs b/src/Agents/ConfigurableAIAgent.cs index eb71930..869d547 100644 --- a/src/Agents/ConfigurableAIAgent.cs +++ b/src/Agents/ConfigurableAIAgent.cs @@ -1,5 +1,9 @@ -using System.ComponentModel; +using System; +using System.ComponentModel; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; +using System.Linq.Expressions; +using System.Reflection; using System.Text.Json; using Devlooped.Extensions.AI; using Devlooped.Extensions.AI.Grok; @@ -8,6 +12,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using ModelContextProtocol.Server; namespace Devlooped.Agents.AI; @@ -134,21 +139,17 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum if (contextFactory is not null) { - if (options.Use?.Count > 0) - throw new InvalidOperationException($"Invalid simultaneous use of keyed service {nameof(AIContextProviderFactory)} and '{section}:use' in configuration."); + if (options.Use?.Count > 0 || options.Tools?.Count > 0) + throw new InvalidOperationException($"Invalid simultaneous use of keyed service {nameof(AIContextProviderFactory)} and '{section}:use/tools' in configuration."); options.AIContextProviderFactory = contextFactory.CreateProvider; } - else if (services.GetKeyedService(name) is { } contextProvider) - { - if (options.Use?.Count > 0) - throw new InvalidOperationException($"Invalid simultaneous use of keyed service {nameof(AIContextProvider)} and '{section}:use' in configuration."); - - options.AIContextProviderFactory = _ => contextProvider; - } - else if (options.Use?.Count > 0 || options.Tools?.Count > 0) + else { var contexts = new List(); + if (services.GetKeyedService(name) is { } contextProvider) + contexts.Add(contextProvider); + foreach (var use in options.Use ?? []) { if (services.GetKeyedService(use) is { } staticContext) @@ -196,7 +197,7 @@ public override IAsyncEnumerable RunStreamingAsync(IEnum { var tool = services.GetKeyedService(toolName) ?? services.GetKeyedService(toolName) ?? - throw new InvalidOperationException($"Specified tool '{toolName}' for agent '{section}' is not registered as a keyed {nameof(AITool)} or {nameof(AIFunction)}."); + throw new InvalidOperationException($"Specified tool '{toolName}' for agent '{section}' is not registered as a keyed {nameof(AITool)}, {nameof(AIFunction)} or MCP server tools."); contexts.Add(new StaticAIContextProvider(new AIContext { Tools = [tool] })); } diff --git a/src/Agents/ConfigurableAgentsExtensions.cs b/src/Agents/ConfigurableAgentsExtensions.cs index 386b11c..0ab7c00 100644 --- a/src/Agents/ConfigurableAgentsExtensions.cs +++ b/src/Agents/ConfigurableAgentsExtensions.cs @@ -1,4 +1,7 @@ using System.ComponentModel; +using System.Diagnostics.CodeAnalysis; +using System.Reflection; +using System.Text.Json; using Devlooped.Agents.AI; using Devlooped.Extensions.AI; using Microsoft.Agents.AI; @@ -7,6 +10,7 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; +using ModelContextProtocol.Server; namespace Microsoft.Extensions.DependencyInjection; @@ -16,6 +20,52 @@ namespace Microsoft.Extensions.DependencyInjection; [EditorBrowsable(EditorBrowsableState.Never)] public static class ConfigurableAgentsExtensions { + /// Adds instances to the service collection backing . + /// The tool type. + /// The builder instance. + /// The serializer options governing tool parameter marshalling. + /// The builder provided in . + /// is . + /// + /// This method discovers all instance and static methods (public and non-public) on the specified + /// type, where the methods are attributed as , and adds an + /// instance for each. For instance methods, an instance will be constructed for each invocation of the tool. + /// + public static IAIAgentsBuilder WithTools<[DynamicallyAccessedMembers( + DynamicallyAccessedMemberTypes.PublicMethods | + DynamicallyAccessedMemberTypes.NonPublicMethods | + DynamicallyAccessedMemberTypes.PublicConstructors)] TToolType>( + this IAIAgentsBuilder builder, + JsonSerializerOptions? serializerOptions = null, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + Throw.IfNull(builder); + + // Preserve existing registration if any, such as when using Devlooped.Extensions.DependencyInjection + // via [Service] attribute or by convention. + builder.Services.TryAdd(ServiceDescriptor.Describe(typeof(TToolType), typeof(TToolType), lifetime)); + + serializerOptions ??= ToolJsonOptions.Default; + + foreach (var toolMethod in typeof(TToolType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance)) + { + if (toolMethod.GetCustomAttribute() is { } toolAttribute) + { + var function = toolMethod.IsStatic + ? AIFunctionFactory.Create(toolMethod, null, toolAttribute.Name ?? ToolJsonOptions.Default.PropertyNamingPolicy!.ConvertName(toolMethod.Name)) + : AIFunctionFactory.Create(toolMethod, args => args.Services?.GetRequiredService(typeof(TToolType)) ?? + throw new InvalidOperationException("Could not determine target instance for tool."), + new AIFunctionFactoryOptions { Name = toolAttribute.Name ?? ToolJsonOptions.Default.PropertyNamingPolicy!.ConvertName(toolMethod.Name) }); + + builder.Services.TryAdd(ServiceDescriptor.DescribeKeyed( + typeof(AIFunction), function.Name, + (_, _) => function, lifetime)); + } + } + + return builder; + } + /// /// Adds AI agents to the host application builder based on configuration. /// @@ -24,8 +74,7 @@ public static class ConfigurableAgentsExtensions /// Optional action to configure options for each agent. /// The configuration prefix for agents, defaults to "ai:agents". /// The host application builder with AI agents added. - public static TBuilder AddAIAgents(this TBuilder builder, Action? configurePipeline = default, Action? configureOptions = default, string prefix = "ai:agents") - where TBuilder : IHostApplicationBuilder + public static IAIAgentsBuilder AddAIAgents(this IHostApplicationBuilder builder, Action? configurePipeline = default, Action? configureOptions = default, string prefix = "ai:agents") { builder.AddChatClients(); @@ -61,7 +110,7 @@ public static TBuilder AddAIAgents(this TBuilder builder, Action sp.GetRequiredKeyedService(name))); } - return builder; + return new DefaultAIAgentsBuilder(builder); } /// Gets an AI agent by name (case-insensitive) from the service provider. diff --git a/src/Agents/IAIAgentsBuilder.cs b/src/Agents/IAIAgentsBuilder.cs new file mode 100644 index 0000000..c44d02c --- /dev/null +++ b/src/Agents/IAIAgentsBuilder.cs @@ -0,0 +1,29 @@ +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Diagnostics.Metrics; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Devlooped.Agents.AI; + +/// Provides a mechanism to configure AI agents. +public interface IAIAgentsBuilder : IHostApplicationBuilder +{ +} + +class DefaultAIAgentsBuilder(IHostApplicationBuilder builder) : IAIAgentsBuilder +{ + public IDictionary Properties => builder.Properties; + + public IConfigurationManager Configuration => builder.Configuration; + + public IHostEnvironment Environment => builder.Environment; + + public ILoggingBuilder Logging => builder.Logging; + + public IMetricsBuilder Metrics => builder.Metrics; + + public IServiceCollection Services => builder.Services; + + public void ConfigureContainer(IServiceProviderFactory factory, Action? configure = null) where TContainerBuilder : notnull => builder.ConfigureContainer(factory, configure); +} \ No newline at end of file diff --git a/src/Extensions/Extensions.csproj b/src/Extensions/Extensions.csproj index d430668..0928647 100644 --- a/src/Extensions/Extensions.csproj +++ b/src/Extensions/Extensions.csproj @@ -24,12 +24,22 @@ + + + + + - - + + + + + + + diff --git a/src/Tests/ConfigurableAgentTests.cs b/src/Tests/ConfigurableAgentTests.cs index 7d0edd0..7df9f2e 100644 --- a/src/Tests/ConfigurableAgentTests.cs +++ b/src/Tests/ConfigurableAgentTests.cs @@ -281,7 +281,8 @@ public void CanSetOpenAIReasoningAndVerbosity() ["ai:agents:bot:options:verbosity"] = "low", }); - var app = builder.AddAIAgents().Build(); + builder.AddAIAgents(); + var app = builder.Build(); var agent = app.Services.GetRequiredKeyedService("bot"); var options = agent.GetService(); @@ -304,7 +305,8 @@ public void CanSetGrokOptions() ["ai:agents:bot:options:search"] = "auto", }); - var app = builder.AddAIAgents().Build(); + builder.AddAIAgents(); + var app = builder.Build(); var agent = app.Services.GetRequiredKeyedService("bot"); var options = agent.GetService(); @@ -424,7 +426,7 @@ public void UseAndContextProviderFactoryIncompatible() } [Fact] - public void UseAndContextProviderIncompatible() + public async Task UseAndContextProviderCompositeAsync() { var builder = new HostApplicationBuilder(); @@ -448,13 +450,28 @@ public void UseAndContextProviderIncompatible() """ """"); - builder.Services.AddKeyedSingleton("chat", Mock.Of()); + var context = new AIContext { Instructions = "foo" }; + + var provider = new Mock(); + provider + .Setup(x => x.InvokingAsync(It.IsAny(), default(CancellationToken))) + .ReturnsAsync(context); + + builder.Services.AddKeyedSingleton("chat", provider.Object); builder.AddAIAgents(); var app = builder.Build(); - var exception = Assert.ThrowsAny(() => app.Services.GetRequiredKeyedService("chat")); + var agent = app.Services.GetRequiredKeyedService("chat"); - Assert.Contains("ai:agents:chat:use", exception.Message); + var options = agent.GetService(); + Assert.NotNull(options?.AIContextProviderFactory); + + var actualProvider = options?.AIContextProviderFactory?.Invoke(new()); + Assert.NotNull(actualProvider); + + var actualContext = await actualProvider.InvokingAsync(new([]), default); + Assert.Contains("spanish language", actualContext.Instructions); + Assert.Contains("foo", actualContext.Instructions); } [Fact] diff --git a/src/Tests/Tests.csproj b/src/Tests/Tests.csproj index 0244853..0e964fa 100644 --- a/src/Tests/Tests.csproj +++ b/src/Tests/Tests.csproj @@ -6,6 +6,7 @@ Preview true Devlooped + 10.0.0-rc.* @@ -15,16 +16,16 @@ - - - - + + + + - - + + - - + +