File: Agents\McpConfigFileHelper.cs
Web Access
Project: src\src\Aspire.Cli\Aspire.Cli.csproj (aspire)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Globalization;
using System.Text.Json;
using System.Text.Json.Nodes;
using Aspire.Cli.Resources;
 
namespace Aspire.Cli.Agents;
 
/// <summary>
/// Provides shared methods for reading and updating selected MCP configuration files.
/// </summary>
internal static class McpConfigFileHelper
{
    internal sealed record Configuration
    {
        public required string ServersPropertyName { get; init; }
        public JsonDocumentOptions JsonDocumentOptions { get; init; }
        public string? Schema { get; init; }
        public JsonObject ServerConfiguration { get; init; } = new()
        {
            ["command"] = "aspire",
            ["args"] = new JsonArray("agent", "mcp")
        };
        public JsonObject? GlobalServerConfiguration { get; init; }
    }
 
    /// <summary>
    /// Reads an existing MCP config file and parses it into a <see cref="JsonObject"/>, or creates a new one if the file doesn't exist.
    /// </summary>
    /// <param name="configFilePath">The path to the MCP configuration file.</param>
    /// <param name="documentOptions">The JSON parsing options for this configuration format.</param>
    /// <param name="edit">Owns configuration inputs and publication for this update.</param>
    /// <param name="cancellationToken">A cancellation token.</param>
    /// <returns>The parsed <see cref="JsonObject"/> from the file, or a new empty <see cref="JsonObject"/> if the file doesn't exist.</returns>
    /// <exception cref="InvalidOperationException">
    /// Thrown when the file contains malformed JSON or its root is not an object.
    /// Malformed JSON exceptions wrap the underlying <see cref="JsonException"/>.
    /// </exception>
    public static async Task<JsonObject> ReadConfigAsync(
        string configFilePath, JsonDocumentOptions documentOptions,
        AgentConfiguration edit, CancellationToken cancellationToken)
    {
        var content = await edit.ReadBytesForEditAsync(configFilePath, cancellationToken);
        if (content is null)
        {
            return new JsonObject();
        }
        // Keep MCP's existing BOM/encoding detection and per-client JSON parsing rules.
        using var reader = new StreamReader(new MemoryStream(content));
        var text = await reader.ReadToEndAsync(cancellationToken);
 
        JsonNode? root;
        try
        {
            root = JsonNode.Parse(text, documentOptions: documentOptions);
        }
        catch (JsonException ex)
        {
            throw new InvalidOperationException(
                string.Format(CultureInfo.CurrentCulture, AgentCommandStrings.MalformedConfigFileError, configFilePath), ex);
        }
 
        return root as JsonObject
            ?? throw new InvalidOperationException(
                string.Format(CultureInfo.CurrentCulture, ErrorStrings.ConfigurationFileMustBeJsonObject, configFilePath));
    }
 
    public static bool ConfigureMcpServer(
        JsonObject config,
        Configuration configuration,
        AgentConfigurationScope scope,
        string configFilePath)
    {
        var servers = GetServerContainer(config, configuration.ServersPropertyName, configFilePath);
        if (servers?.ContainsKey("aspire") is true)
        {
            return false;
        }
 
        if (configuration.Schema is { } schema && !config.ContainsKey("$schema"))
        {
            config["$schema"] = schema;
        }
 
        if (servers is null)
        {
            servers = new JsonObject();
            config[configuration.ServersPropertyName] = servers;
        }
 
        var serverConfiguration = scope is AgentConfigurationScope.Global
            ? configuration.GlobalServerConfiguration ?? configuration.ServerConfiguration
            : configuration.ServerConfiguration;
        // JsonNodes have single-parent ownership; each destination needs its own copy.
        servers["aspire"] = serverConfiguration.DeepClone();
 
        return true;
    }
 
    public static JsonObject? GetServerContainer(JsonObject config, string serverContainerKey, string configFilePath)
    {
        var serverContainer = config[serverContainerKey];
        if (serverContainer is not null && serverContainer is not JsonObject)
        {
            throw new InvalidOperationException(string.Format(CultureInfo.CurrentCulture,
                AgentCommandStrings.McpServerContainerMustBeJsonObject, serverContainerKey, configFilePath));
        }
 
        return serverContainer as JsonObject;
    }
 
    public static bool UpdateDeprecatedCommand(JsonObject config, string serverContainerKey)
    {
        if (config[serverContainerKey] is not JsonObject servers || servers["aspire"] is not JsonObject aspire)
        {
            return false;
        }
 
        // Legacy MCP entries use command: "aspire", args: ["mcp", "start"], or OpenCode's
        // command: ["aspire", "mcp", "start"]. Only these exact deprecated forms are repaired;
        // custom commands and version-pinned server definitions are left intact.
        if (MatchesArgument(aspire["command"], "aspire") &&
            aspire["args"] is JsonArray { Count: 2 } args &&
            MatchesArgument(args[0], "mcp") && MatchesArgument(args[1], "start"))
        {
            aspire["args"] = new JsonArray("agent", "mcp");
        }
        else if (aspire["command"] is JsonArray { Count: 3 } command &&
            MatchesArgument(command[0], "aspire") &&
            MatchesArgument(command[1], "mcp") && MatchesArgument(command[2], "start"))
        {
            aspire["command"] = new JsonArray("aspire", "agent", "mcp");
        }
        else
        {
            return false;
        }
 
        return true;
    }
 
    private static bool MatchesArgument(JsonNode? argument, string expected)
    {
        return argument is JsonValue value && value.TryGetValue<string>(out var text) && text == expected;
    }
}