Skip to content

Commit b748b47

Browse files
committed
Make McpServerTool{Collection} extensible
1 parent 5014ce6 commit b748b47

File tree

6 files changed

+362
-267
lines changed

6 files changed

+362
-267
lines changed

src/ModelContextProtocol/Configuration/McpServerOptionsSetup.cs

Lines changed: 25 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,37 @@ public void Configure(McpServerOptions options)
2323
{
2424
Throw.IfNull(options);
2525

26+
// Configure the option's server information based on the current process,
27+
// if it otherwise lacks server information.
2628
var assemblyName = (Assembly.GetEntryAssembly() ?? Assembly.GetCallingAssembly()).GetName();
27-
options.ServerInfo = new()
29+
if (options.ServerInfo is not { } serverInfo ||
30+
serverInfo.Name is null ||
31+
serverInfo.Version is null)
2832
{
29-
Name = assemblyName.Name ?? "McpServer",
30-
Version = assemblyName.Version?.ToString() ?? "1.0.0",
31-
};
33+
options.ServerInfo = options.ServerInfo is null ?
34+
new()
35+
{
36+
Name = assemblyName.Name ?? "McpServer",
37+
Version = assemblyName.Version?.ToString() ?? "1.0.0",
38+
} :
39+
options.ServerInfo with
40+
{
41+
Name = options.ServerInfo.Name ?? assemblyName.Name ?? "McpServer",
42+
Version = options.ServerInfo.Version ?? assemblyName.Version?.ToString() ?? "1.0.0",
43+
};
44+
}
3245

33-
McpServerToolCollection toolsCollection = new();
46+
// Collect all of the provided tools into a tools collection. If the options already has
47+
// a collection, add to it, otherwise create a new one. We want to maintain the identity
48+
// of an existing collection in case someone has provided their own derived type, wants
49+
// change notifications, etc.
50+
McpServerToolCollection toolsCollection = options.Capabilities?.Tools?.ToolCollection ?? [];
3451
foreach (var tool in serverTools)
3552
{
36-
toolsCollection.Add(tool);
53+
toolsCollection.TryAdd(tool);
3754
}
3855

39-
if (options.Capabilities?.Tools?.ToolCollection is { } existingToolsCollection)
40-
{
41-
existingToolsCollection.AddRange(toolsCollection);
42-
}
43-
else if (!toolsCollection.IsEmpty)
56+
if (!toolsCollection.IsEmpty)
4457
{
4558
options.Capabilities = options.Capabilities is null ?
4659
new() { Tools = new() { ToolCollection = toolsCollection } } :
@@ -52,7 +65,7 @@ options.Capabilities with
5265
};
5366
}
5467

55-
// Apply custom server handlers
68+
// Apply custom server handlers.
5669
serverHandlers.Value.OverwriteWithSetHandlers(options);
5770
}
5871
}

src/ModelContextProtocol/Protocol/Types/Tool.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ namespace ModelContextProtocol.Protocol.Types;
1010
/// </summary>
1111
public class Tool
1212
{
13+
private JsonElement _inputSchema = McpJsonUtilities.DefaultMcpToolSchema;
14+
1315
/// <summary>
1416
/// The name of the tool.
1517
/// </summary>
@@ -42,6 +44,4 @@ public JsonElement InputSchema
4244
_inputSchema = value;
4345
}
4446
}
45-
46-
private JsonElement _inputSchema = McpJsonUtilities.DefaultMcpToolSchema;
4747
}
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
using Microsoft.Extensions.AI;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using ModelContextProtocol.Protocol.Types;
4+
using ModelContextProtocol.Server;
5+
using ModelContextProtocol.Utils;
6+
using ModelContextProtocol.Utils.Json;
7+
using System.Reflection;
8+
using System.Text.Json;
9+
10+
namespace ModelContextProtocol;
11+
12+
/// <summary>Provides an <see cref="McpServerTool"/> that's implemented via an <see cref="AIFunction"/>.</summary>
13+
internal sealed class AIFunctionMcpServerTool : McpServerTool
14+
{
15+
/// <summary>Key used temporarily for flowing request context into an AIFunction.</summary>
16+
/// <remarks>This will be replaced with use of AIFunctionArguments.Context.</remarks>
17+
private const string RequestContextKey = "__temporary_RequestContext";
18+
19+
/// <summary>
20+
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
21+
/// </summary>
22+
public static new AIFunctionMcpServerTool Create(Delegate method, IServiceProvider? services = null)
23+
{
24+
Throw.IfNull(method);
25+
26+
return Create(method.Method, method.Target, services);
27+
}
28+
29+
/// <summary>
30+
/// Creates an <see cref="McpServerTool"/> instance for a method, specified via a <see cref="Delegate"/> instance.
31+
/// </summary>
32+
public static new AIFunctionMcpServerTool Create(MethodInfo method, object? target = null, IServiceProvider? services = null)
33+
{
34+
Throw.IfNull(method);
35+
36+
// TODO: Once this repo consumes a new build of Microsoft.Extensions.AI containing
37+
// https://github.com/dotnet/extensions/pull/6158,
38+
// https://github.com/dotnet/extensions/pull/6162, and
39+
// https://github.com/dotnet/extensions/pull/6175, switch over to using the real
40+
// AIFunctionFactory, delete the TemporaryXx types, and fix-up the mechanism by
41+
// which the arguments are passed.
42+
43+
return Create(TemporaryAIFunctionFactory.Create(method, target, new TemporaryAIFunctionFactoryOptions()
44+
{
45+
Name = method.GetCustomAttribute<McpServerToolAttribute>()?.Name,
46+
MarshalResult = static (result, _, cancellationToken) => Task.FromResult(result),
47+
ConfigureParameterBinding = pi =>
48+
{
49+
if (pi.ParameterType == typeof(RequestContext<CallToolRequestParams>))
50+
{
51+
return new()
52+
{
53+
ExcludeFromSchema = true,
54+
BindParameter = (pi, args) => GetRequestContext(args),
55+
};
56+
}
57+
58+
if (pi.ParameterType == typeof(IMcpServer))
59+
{
60+
return new()
61+
{
62+
ExcludeFromSchema = true,
63+
BindParameter = (pi, args) => GetRequestContext(args)?.Server,
64+
};
65+
}
66+
67+
// We assume that if the services used to create the tool support a particular type,
68+
// so too do the services associated with the server. This is the same basic assumption
69+
// made in ASP.NET.
70+
if (services is not null &&
71+
services.GetService<IServiceProviderIsService>() is { } ispis &&
72+
ispis.IsService(pi.ParameterType))
73+
{
74+
return new()
75+
{
76+
ExcludeFromSchema = true,
77+
BindParameter = (pi, args) =>
78+
GetRequestContext(args)?.Server?.Services?.GetService(pi.ParameterType) ??
79+
(pi.HasDefaultValue ? null :
80+
throw new ArgumentException("No service of the requested type was found.")),
81+
};
82+
}
83+
84+
if (pi.GetCustomAttribute<FromKeyedServicesAttribute>() is { } keyedAttr)
85+
{
86+
return new()
87+
{
88+
ExcludeFromSchema = true,
89+
BindParameter = (pi, args) =>
90+
(GetRequestContext(args)?.Server?.Services as IKeyedServiceProvider)?.GetKeyedService(pi.ParameterType, keyedAttr.Key) ??
91+
(pi.HasDefaultValue ? null :
92+
throw new ArgumentException("No service of the requested type was found.")),
93+
};
94+
}
95+
96+
return default;
97+
98+
static RequestContext<CallToolRequestParams>? GetRequestContext(IReadOnlyDictionary<string, object?> args)
99+
{
100+
if (args.TryGetValue(RequestContextKey, out var orc) &&
101+
orc is RequestContext<CallToolRequestParams> requestContext)
102+
{
103+
return requestContext;
104+
}
105+
106+
return null;
107+
}
108+
},
109+
}));
110+
}
111+
112+
/// <summary>Creates an <see cref="McpServerTool"/> that wraps the specified <see cref="AIFunction"/>.</summary>
113+
public static new AIFunctionMcpServerTool Create(AIFunction function)
114+
{
115+
Throw.IfNull(function);
116+
117+
return new AIFunctionMcpServerTool(function);
118+
}
119+
120+
/// <summary>Gets the <see cref="AIFunction"/> wrapped by this tool.</summary>
121+
internal AIFunction AIFunction { get; }
122+
123+
/// <summary>Initializes a new instance of the <see cref="McpServerTool"/> class.</summary>
124+
private AIFunctionMcpServerTool(AIFunction function)
125+
{
126+
AIFunction = function;
127+
ProtocolTool = new()
128+
{
129+
Name = function.Name,
130+
Description = function.Description,
131+
InputSchema = function.JsonSchema,
132+
};
133+
}
134+
135+
/// <inheritdoc />
136+
public override string ToString() => AIFunction.ToString();
137+
138+
/// <inheritdoc />
139+
public override Tool ProtocolTool { get; }
140+
141+
/// <inheritdoc />
142+
public override async Task<CallToolResponse> InvokeAsync(
143+
RequestContext<CallToolRequestParams> request, CancellationToken cancellationToken = default)
144+
{
145+
Throw.IfNull(request);
146+
147+
cancellationToken.ThrowIfCancellationRequested();
148+
149+
// TODO: Once we shift to the real AIFunctionFactory, the request should be passed via AIFunctionArguments.Context.
150+
Dictionary<string, object?> arguments = request.Params?.Arguments is IDictionary<string, object?> existingArgs ?
151+
new(existingArgs) :
152+
[];
153+
arguments[RequestContextKey] = request;
154+
155+
object? result;
156+
try
157+
{
158+
result = await AIFunction.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false);
159+
}
160+
catch (Exception e) when (e is not OperationCanceledException)
161+
{
162+
return new CallToolResponse()
163+
{
164+
IsError = true,
165+
Content = [new() { Text = e.Message, Type = "text" }],
166+
};
167+
}
168+
169+
switch (result)
170+
{
171+
case null:
172+
return new()
173+
{
174+
Content = []
175+
};
176+
177+
case string text:
178+
return new()
179+
{
180+
Content = [new() { Text = text, Type = "text" }]
181+
};
182+
183+
case TextContent textContent:
184+
return new()
185+
{
186+
Content = [new() { Text = textContent.Text, Type = "text" }]
187+
};
188+
189+
case DataContent dataContent:
190+
return new()
191+
{
192+
Content = [new()
193+
{
194+
Data = dataContent.GetBase64Data(),
195+
MimeType = dataContent.MediaType,
196+
Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource",
197+
}]
198+
};
199+
200+
case string[] texts:
201+
return new()
202+
{
203+
Content = texts
204+
.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })
205+
.ToList()
206+
};
207+
208+
// TODO https://github.com/modelcontextprotocol/csharp-sdk/issues/69:
209+
// Add specialization for annotations.
210+
211+
default:
212+
return new()
213+
{
214+
Content = [new()
215+
{
216+
Text = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))),
217+
Type = "text"
218+
}]
219+
};
220+
}
221+
}
222+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using ModelContextProtocol.Protocol.Types;
2+
using ModelContextProtocol.Server;
3+
using ModelContextProtocol.Utils;
4+
5+
namespace ModelContextProtocol;
6+
7+
/// <summary>Provides an <see cref="McpServerTool"/> that delegates all operations to an inner <see cref="McpServerTool"/>.</summary>
8+
/// <remarks>
9+
/// This is recommended as a base type when building tools that can be chained around an underlying <see cref="McpServerTool"/>.
10+
/// The default implementation simply passes each call to the inner tool instance.
11+
/// </remarks>
12+
public abstract class DelegatingMcpServerTool : McpServerTool
13+
{
14+
private readonly McpServerTool _innerTool;
15+
16+
/// <summary>Initializes a new instance of the <see cref="DelegatingMcpServerTool"/> class around the specified <paramref name="innerTool"/>.</summary>
17+
/// <param name="innerTool">The inner tool wrapped by this delegating tool.</param>
18+
protected DelegatingMcpServerTool(McpServerTool innerTool)
19+
{
20+
Throw.IfNull(innerTool);
21+
_innerTool = innerTool;
22+
}
23+
24+
/// <inheritdoc />
25+
public override Tool ProtocolTool => _innerTool.ProtocolTool;
26+
27+
/// <inheritdoc />
28+
public override Task<CallToolResponse> InvokeAsync(
29+
RequestContext<CallToolRequestParams> request,
30+
CancellationToken cancellationToken = default) =>
31+
_innerTool.InvokeAsync(request, cancellationToken);
32+
33+
/// <inheritdoc />
34+
public override string ToString() => _innerTool.ToString();
35+
}

0 commit comments

Comments
 (0)