summaryrefslogtreecommitdiff
path: root/src/Agent.Mcp/McpHost.cs
blob: 8ae25d5f2213ea13438d16f8c19519b718fa3b5e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
using System.Text.Json.Nodes;
using System.Reflection;
using Agent.Core.Localization;
using Agent.Core.Sessions;
using Agent.Core.Tasks;
using Agent.Core.Ollama;
using Agent.Core.Runtime;
using Agent.Core.Tools;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using ModelContextProtocol.Server;
using ModelContextProtocol.Extensions.Tasks;
using ModelContextProtocol;
using ModelContextProtocol.Protocol;
using System.Diagnostics;

namespace Agent.Mcp;

public static class McpHost
{
    public static async Task RunStdioAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null, SessionStore? sessionStore = null)
    {
        HostApplicationBuilder builder = Host.CreateApplicationBuilder(new HostApplicationBuilderSettings
        {
            Args = [],
            ContentRootPath = AppContext.BaseDirectory
        });
        builder.Logging.ClearProviders();
        var registry = new TaskRegistry(sessionStore);
        using var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
        var ollama = new OllamaClient(httpClient, ollamaSettings);
        var runtime = new AgentRuntime(registry, ollama, runtimeSettings, toolDispatcher, observer);
        builder.Services.AddSingleton(locale);
        builder.Services.AddSingleton(registry);
        builder.Services.AddSingleton(runtime);
        builder.Services.AddHostedService<QueuedTaskWorker>();

        var taskStore = new PersistentMcpTaskStore(taskStoreSettings ?? DefaultTaskStoreSettings());
        McpServerTool[] tools = CreateTools(registry, locale, visionLimits, taskStore).Concat(additionalTools ?? []).ToArray();

        IMcpServerBuilder mcpBuilder = builder.Services
            .AddMcpServer()
            .WithStdioServerTransport()
            .WithTools(tools);
        ConfigureAgentTaskFilter(mcpBuilder);
        configureBuilder?.Invoke(mcpBuilder);
        mcpBuilder.WithTasks(taskStore, options => ConfigureTasks(options, taskCapableTools, requiredTaskTools));

        using IHost host = builder.Build();
        await host.StartAsync(cancellationToken).ConfigureAwait(false);
        hostObserver?.Ready(new McpReadyInfo("stdio", null, Environment.ProcessId, "2026-07-28", false, true));
        await host.WaitForShutdownAsync(cancellationToken).ConfigureAwait(false);
    }

    public static async Task RunHttpAsync(LocaleCatalog locale, OllamaSettings ollamaSettings, AgentRuntimeSettings runtimeSettings, VisionLimits visionLimits, string hostName, int port, CancellationToken cancellationToken, InternalToolDispatcher? toolDispatcher = null, IAgentRuntimeObserver? observer = null, McpTaskStoreSettings? taskStoreSettings = null, IMcpHostObserver? hostObserver = null, IReadOnlyList<McpServerTool>? additionalTools = null, IReadOnlySet<string>? taskCapableTools = null, IReadOnlySet<string>? requiredTaskTools = null, Action<IMcpServerBuilder>? configureBuilder = null, SessionStore? sessionStore = null)
    {
        if (!System.Net.IPAddress.TryParse(hostName, out System.Net.IPAddress? address) || !System.Net.IPAddress.IsLoopback(address))
            throw new ArgumentException("HTTP transport host must be a loopback IP literal.", nameof(hostName));
        WebApplicationBuilder builder = WebApplication.CreateBuilder(new WebApplicationOptions { Args = [], ContentRootPath = AppContext.BaseDirectory });
        builder.Logging.ClearProviders();
        string listenHost = address.AddressFamily == System.Net.Sockets.AddressFamily.InterNetworkV6 ? $"[{address}]" : address.ToString();
        builder.WebHost.UseUrls($"http://{listenHost}:{port}");
        var registry = new TaskRegistry(sessionStore);
        var httpClient = new HttpClient { Timeout = TimeSpan.FromMinutes(10) };
        var runtime = new AgentRuntime(registry, new OllamaClient(httpClient, ollamaSettings), runtimeSettings, toolDispatcher, observer);
        builder.Services.AddSingleton(locale);
        builder.Services.AddSingleton(registry);
        builder.Services.AddSingleton(runtime);
        builder.Services.AddHostedService<QueuedTaskWorker>();
        var taskStore = new PersistentMcpTaskStore(taskStoreSettings ?? DefaultTaskStoreSettings());
        McpServerTool[] tools = CreateTools(registry, locale, visionLimits, taskStore).Concat(additionalTools ?? []).ToArray();
        IMcpServerBuilder mcpBuilder = builder.Services.AddMcpServer()
            .WithHttpTransport(options => options.Stateless = true)
            .WithTools(tools);
        ConfigureAgentTaskFilter(mcpBuilder);
        configureBuilder?.Invoke(mcpBuilder);
        mcpBuilder.WithTasks(taskStore, options => ConfigureTasks(options, taskCapableTools, requiredTaskTools));
        await using WebApplication app = builder.Build();
        app.Use(async (context, next) =>
        {
            var stopwatch = Stopwatch.StartNew();
            DateTimeOffset timestamp = DateTimeOffset.UtcNow;
            hostObserver?.HttpRequestStarted(new McpHttpRequestStartInfo(context.Connection.Id, context.TraceIdentifier,
                context.Connection.RemoteIpAddress?.ToString(), context.Connection.RemotePort,
                context.Request.Method, context.Request.Path.Value ?? string.Empty));
            try
            {
                if (context.Request.Headers.TryGetValue("Origin", out Microsoft.Extensions.Primitives.StringValues origins) &&
                    origins.Any(origin => !IsLoopbackOrigin(origin)))
                {
                    context.Response.StatusCode = StatusCodes.Status403Forbidden;
                }
                else await next(context).ConfigureAwait(false);
            }
            finally
            {
                stopwatch.Stop();
                hostObserver?.HttpRequest(new McpHttpRequestInfo(timestamp, context.Connection.Id, context.TraceIdentifier,
                    context.Connection.RemoteIpAddress?.ToString(), context.Connection.RemotePort,
                    context.Connection.LocalIpAddress?.ToString(), context.Connection.LocalPort,
                    context.Request.Method, context.Request.Path.Value ?? string.Empty, context.Response.StatusCode,
                    stopwatch.Elapsed.TotalMilliseconds, context.Request.ContentType, context.Request.ContentLength,
                    context.Request.Headers.UserAgent.ToString(), context.Request.Headers["MCP-Protocol-Version"].ToString(),
                    context.Request.Headers["MCP-Method"].ToString(), context.Request.Headers["MCP-Name"].ToString()));
            }
        });
        app.MapMcp("/mcp");
        try
        {
            await app.StartAsync(cancellationToken).ConfigureAwait(false);
            hostObserver?.Ready(new McpReadyInfo("http", $"http://{listenHost}:{port}/mcp", Environment.ProcessId, "2026-07-28", true, true));
            await app.WaitForShutdownAsync(cancellationToken).ConfigureAwait(false);
        }
        finally { httpClient.Dispose(); }
    }

    private static McpTaskStoreSettings DefaultTaskStoreSettings() =>
        new(Path.Combine(Path.GetTempPath(), "agent-mcp", "mcp-tasks"), TimeSpan.FromHours(24), 1000);

    private static void ConfigureTasks(McpTasksOptions options, IReadOnlySet<string>? taskCapableTools, IReadOnlySet<string>? requiredTaskTools)
    {
        IReadOnlySet<string> names = taskCapableTools ?? new HashSet<string>(["agent_run"], StringComparer.Ordinal);
        options.ExecutionModeSelector = context =>
            context.MatchedPrimitive is not McpServerTool tool ? McpTaskExecutionMode.Synchronous :
            requiredTaskTools?.Contains(tool.ProtocolTool.Name) == true ? McpTaskExecutionMode.Required :
            names.Contains(tool.ProtocolTool.Name) ? McpTaskExecutionMode.Optional : McpTaskExecutionMode.Synchronous;
    }

    private static void ConfigureAgentTaskFilter(IMcpServerBuilder builder)
    {
        builder.WithRequestFilters(filters => filters.AddCallToolFilter(next => async (context, cancellationToken) =>
        {
            CallToolResult result = await next(context, cancellationToken).ConfigureAwait(false);
            if (context.Params?.Name == "agent_run" && result.IsError == true)
                throw new McpProtocolException("The Agent task failed.", McpErrorCode.InternalError);
            return result;
        }));
    }

    private static bool IsLoopbackOrigin(string? origin)
    {
        return Uri.TryCreate(origin, UriKind.Absolute, out Uri? uri) &&
            (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps) &&
            (string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) ||
             (System.Net.IPAddress.TryParse(uri.Host, out System.Net.IPAddress? address) && System.Net.IPAddress.IsLoopback(address)));
    }

    private static McpServerTool[] CreateTools(TaskRegistry registry, LocaleCatalog locale, VisionLimits visionLimits, PersistentMcpTaskStore taskStore)
    {
        var serializerOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
        {
            PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
            TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
            UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow
        };
        var target = new AgentTools(registry, locale, visionLimits, taskStore);
        McpServerTool[] tools = typeof(AgentTools)
            .GetMethods(BindingFlags.Instance | BindingFlags.Public | BindingFlags.DeclaredOnly)
            .Where(static method => method.GetCustomAttribute<McpServerToolAttribute>() is not null)
            .Select(method => McpServerTool.Create(method, target, new McpServerToolCreateOptions { SerializerOptions = serializerOptions }))
            .ToArray();
        foreach (McpServerTool tool in tools) MakeSchemaStrict(tool);
        return tools;
    }

    private static void MakeSchemaStrict(McpServerTool tool)
    {
        JsonNode root = JsonNode.Parse(tool.ProtocolTool.InputSchema.GetRawText())
            ?? throw new InvalidOperationException($"Tool '{tool.ProtocolTool.Name}' has no input schema.");
        Visit(root);
        tool.ProtocolTool.InputSchema = JsonSerializer.SerializeToElement(root);

        static void Visit(JsonNode? node)
        {
            if (node is JsonObject obj)
            {
                if (obj["type"] is JsonValue type && type.TryGetValue(out string? typeName) && typeName == "object")
                    obj["additionalProperties"] = false;
                foreach (KeyValuePair<string, JsonNode?> property in obj.ToArray()) Visit(property.Value);
            }
            else if (node is JsonArray array)
            {
                foreach (JsonNode? item in array) Visit(item);
            }
        }
    }
}