diff --git a/src/SlimFaasMcp/Models/SwaggerDocument.cs b/src/SlimFaasMcp/Models/SwaggerDocument.cs index 32d35446e..450a2fce5 100644 --- a/src/SlimFaasMcp/Models/SwaggerDocument.cs +++ b/src/SlimFaasMcp/Models/SwaggerDocument.cs @@ -6,7 +6,7 @@ public class Endpoint public string? Url { get; set; } public string? Verb { get; set; } public string? Summary { get; set; } - public List? Parameters { get; set; } + public List Parameters { get; set; } public string? ContentType { get; set; } } @@ -21,4 +21,6 @@ public class Parameter public string? Format { get; set; } public object? Schema { get; set; } + + public List Children { get; set; } = new(); } diff --git a/src/SlimFaasMcp/Program.cs b/src/SlimFaasMcp/Program.cs index 43104e39f..439d245ac 100644 --- a/src/SlimFaasMcp/Program.cs +++ b/src/SlimFaasMcp/Program.cs @@ -16,6 +16,10 @@ }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddKeyedSingleton("openapi"); +builder.Services.AddKeyedSingleton("graphql"); builder.Services.AddSingleton(); builder.Services.AddMemoryCache(); @@ -40,11 +44,13 @@ using var jsonDocument = await JsonDocument.ParseAsync(httpRequest.Body); var root = jsonDocument.RootElement; + // --- query-string -------------------------------------------------- var qs = httpRequest.Query; var openapiUrl = qs.TryGetValue("openapi_url", out var qurl) ? qurl.ToString() : ""; var baseUrl = qs.TryGetValue("base_url", out var qb) ? qb.ToString() : ""; var mcpPromptB64 = qs.TryGetValue("mcp_prompt", out var qp) ? qp.ToString() : null; + var contract = qs.TryGetValue("contract", out var c) ? c.ToString() : "openapi"; // --- champs JSON-RPC ---------------------------------------------- JsonNode response = new JsonObject { @@ -71,7 +77,7 @@ case "tools/list": { var tools = await toolProxyService.GetToolsAsync( - openapiUrl, baseUrl, authHeader, mcpPromptB64); + openapiUrl, baseUrl, authHeader, mcpPromptB64, contract); response["result"] = new JsonObject { ["tools"] = new JsonArray(tools.Select(t => new JsonObject { @@ -99,7 +105,8 @@ p.GetProperty("name").GetString()!, p.GetProperty("arguments"), baseUrl, - authHeader); + authHeader, + contract); // Tout est déjà string → aucun YAML, aucune réflexion response["result"] = new JsonObject { @@ -135,25 +142,27 @@ string openapi_url, [FromQuery] string? base_url, [FromQuery] string? mcp_prompt, + [FromQuery] string? contract, HttpRequest req, - IToolProxyService proxy) + IToolProxyService proxy + ) => TypedResults.Ok(await proxy.GetToolsAsync( openapi_url, base_url, req.Headers.Authorization.FirstOrDefault(), - mcp_prompt))); + mcp_prompt, contract ?? "openapi"))); grp.MapPost("/{toolName}", async Task> ( string toolName, string openapi_url, [FromBody] JsonElement arguments, [FromQuery] string? base_url, - [FromQuery] string? mcp_prompt, + [FromQuery] string? contract, HttpRequest req, IToolProxyService proxy) => TypedResults.Ok(await proxy.ExecuteToolAsync( openapi_url, toolName, arguments, base_url, - req.Headers.Authorization.FirstOrDefault()))); + req.Headers.Authorization.FirstOrDefault(), contract ?? "openapi"))); app.MapGet("/health", () => Results.Text("OK")); diff --git a/src/SlimFaasMcp/Services/GraphQLQueryBuilder.cs b/src/SlimFaasMcp/Services/GraphQLQueryBuilder.cs new file mode 100644 index 000000000..5c9d3a773 --- /dev/null +++ b/src/SlimFaasMcp/Services/GraphQLQueryBuilder.cs @@ -0,0 +1,232 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; + +public static class GraphQLQueryBuilder +{ + /* ════════════════════════════════════════════════════════════════ + * Helpers + * ═════════════════════════════════════════════════════════════ */ + + private static JsonElement Unwrap(JsonElement node) + { + while (node.ValueKind == JsonValueKind.Object && + node.TryGetProperty("kind", out var k) && + (k.GetString() is "NON_NULL" or "LIST") && + node.TryGetProperty("ofType", out var inner) && + inner.ValueKind == JsonValueKind.Object) + { + node = inner; + } + return node; + } + + /// + /// Retourne le JsonElement décrivant un type nommé . + /// + private static bool TryFindTypeByName(JsonElement schemaRoot, string name, out JsonElement type) + { + if (schemaRoot.TryGetProperty("types", out var typesArr) && typesArr.ValueKind == JsonValueKind.Array) + { + type = typesArr.EnumerateArray() + .FirstOrDefault(t => + t.TryGetProperty("name", out var n) && + n.GetString() == name); + return type.ValueKind != JsonValueKind.Undefined; + } + + type = default; + return false; + } + + private static string BuildSelection(JsonElement typeNode, + JsonElement schemaRoot, + HashSet? visited = null) + { + visited ??= new HashSet(); + + // Accepte soit : + // • le nœud « type » classique (kind/name/ofType…) + // • le nœud d’un type complet (OBJECT, SCALAR…) déjà issu de schema.types + var candidate = typeNode; + + // Cas 1 : nœud classique → unwrap + if (candidate.TryGetProperty("kind", out _)) + candidate = Unwrap(candidate); + + // Cas 2 : pas de "kind" mais on est déjà sur un type complet (as-is) + + if (!candidate.TryGetProperty("kind", out var kindProp)) + return string.Empty; + + var kind = kindProp.GetString(); + if (kind is "SCALAR" or "ENUM" or null) + return string.Empty; + + if (!candidate.TryGetProperty("name", out var nameProp)) + return string.Empty; + + string typeName = nameProp.GetString() ?? string.Empty; + if (typeName.Length == 0 || !visited.Add(typeName)) + return string.Empty; // boucle ou nom absent + + // S’assurer de disposer d’un objet complet (avec "fields") + if (candidate.ValueKind != JsonValueKind.Object || + !candidate.TryGetProperty("fields", out var fieldsArr) || + fieldsArr.ValueKind != JsonValueKind.Array) + { + // Peut‑être qu’on n’a qu’une référence → aller chercher dans types + if (!TryFindTypeByName(schemaRoot, typeName, out var complete) || + !complete.TryGetProperty("fields", out fieldsArr) || + fieldsArr.ValueKind != JsonValueKind.Array) + return "{ __typename }"; // impossible → fallback + } + + var sb = new StringBuilder("{ "); + foreach (var field in fieldsArr.EnumerateArray()) + { + if (!field.TryGetProperty("name", out var fnProp) || + fnProp.GetString() is not { Length: >0 } fieldName) + continue; + + sb.Append(fieldName); + + // Descente récursive uniquement si on connaît le type du sous‑champ + if (field.TryGetProperty("type", out var subType)) + { + string subSel = BuildSelection(subType, schemaRoot, visited); + if (!string.IsNullOrEmpty(subSel)) + sb.Append(' ').Append(subSel); + } + + sb.Append(' '); + } + + if (sb.Length <= 2) sb.Append("__typename "); // sécurité mini + sb.Append('}'); + return sb.ToString(); + } + + /* ════════════════════════════════════════════════════════════════ + * Public API + * ═════════════════════════════════════════════════════════════ */ + + public static string BuildQuery(string toolName, + IReadOnlyDictionary variables, + JsonDocument schema) + { + bool isMutation = toolName.StartsWith("mutation_", StringComparison.Ordinal); + string opKind = isMutation ? "mutation" : "query"; + string fieldName = toolName[(opKind.Length + 1)..]; // après « query_ » / « mutation_ » + + var root = schema.RootElement.GetProperty("data").GetProperty("__schema"); + + /* ---------- TYPE RACINE (queryType / mutationType) ---------- */ + + if (!root.TryGetProperty(isMutation ? "mutationType" : "queryType", out var opElem) || + opElem.ValueKind != JsonValueKind.Object || + !opElem.TryGetProperty("name", out var opNameProp)) + return $"{opKind} {{ {fieldName} }}"; + + string opTypeName = opNameProp.GetString() ?? string.Empty; + + if (!TryFindTypeByName(root, opTypeName, out var opType)) + return $"{opKind} {{ {fieldName} }}"; + + /* -------------------- CHAMP RACINE ------------------------- */ + + var field = opType.GetProperty("fields").EnumerateArray() + .FirstOrDefault(f => + f.TryGetProperty("name", out var n) && + string.Equals(n.GetString(), fieldName, StringComparison.OrdinalIgnoreCase)); + + if (field.ValueKind == JsonValueKind.Undefined) + return $"{opKind} {{ {fieldName} }}"; + + /* --------------- VARIABLES & ARGUMENTS --------------------- */ + + var defList = new List(); + var argList = new List(); + + if (field.TryGetProperty("args", out var argsArr) && argsArr.ValueKind == JsonValueKind.Array) + { + foreach (var arg in argsArr.EnumerateArray()) + { + if (!arg.TryGetProperty("name", out var aNameProp)) + continue; + + string argName = aNameProp.GetString() ?? string.Empty; + if (argName.Length == 0) continue; + + string gqlType; + bool nonNull = false; + + // ⇒ Cas idéal : présente dans le schéma + if (arg.TryGetProperty("type", out var aTypeProp)) + { + var unwrapped = Unwrap(aTypeProp); + gqlType = unwrapped.TryGetProperty("name", out var tn) && + tn.GetString() is { Length: >0 } s ? s : "String"; + nonNull = aTypeProp.TryGetProperty("kind", out var k) && + k.GetString() == "NON_NULL"; + } + else + { + // ⇒ Métadonnées manquantes : heuristique + gqlType = argName.Equals("code", StringComparison.OrdinalIgnoreCase) || + argName.EndsWith("id", StringComparison.OrdinalIgnoreCase) + ? "ID" + : "String"; + } + + defList.Add($"${argName}: {gqlType}{(nonNull ? "!" : "")}"); + argList.Add($"{argName}: ${argName}"); + } + } + + // Aucun argument dans le schéma → mais des variables fournies par l’appelant ? + foreach (var kvp in variables) + { + if (defList.Any(d => d.StartsWith($"${kvp.Key}:"))) continue; // déjà ajouté + string guessedType = kvp.Value.ValueKind switch + { + JsonValueKind.Number => "Int", + JsonValueKind.True or JsonValueKind.False => "Boolean", + _ => "String" + }; + defList.Add($"${kvp.Key}: {guessedType}"); + argList.Add($"{kvp.Key}: ${kvp.Key}"); + } + + string headerPart = defList.Count > 0 ? $"({string.Join(", ", defList)})" : string.Empty; + string argsPart = argList.Count > 0 ? $"({string.Join(", ", argList)})" : string.Empty; + + /* ------------------- SÉLECTION RETOUR ---------------------- */ + + string selection = "{ __typename }"; // valeur sûre + JsonElement fieldType; + + if (field.TryGetProperty("type", out fieldType) || + // metadata manquante → on devine le type « PascalCase(fieldName) » + TryFindTypeByName(root, + char.ToUpper(fieldName[0]) + fieldName[1..], out fieldType)) + { + string sel = BuildSelection(fieldType, root); + if (!string.IsNullOrWhiteSpace(sel)) + selection = sel; + } + + /* -------------------- REQUÊTE FINALE ----------------------- */ + + string opName = $"{char.ToUpper(fieldName[0])}{fieldName[1..]}Op"; + + var sb = new StringBuilder(); + sb.Append(opKind).Append(' ').Append(opName); + if (headerPart.Length > 0) sb.Append(' ').Append(headerPart); + sb.Append(" { ").Append(fieldName).Append(argsPart).Append(' ').Append(selection).Append(" }"); + + return sb.ToString(); + } +} diff --git a/src/SlimFaasMcp/Services/GraphQLService.cs b/src/SlimFaasMcp/Services/GraphQLService.cs new file mode 100644 index 000000000..9ecb0e1f2 --- /dev/null +++ b/src/SlimFaasMcp/Services/GraphQLService.cs @@ -0,0 +1,227 @@ +using System.IO.Compression; +using System.Net.Http.Json; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using Microsoft.Extensions.Caching.Memory; +using SlimFaasMcp.Models; +using Endpoint = SlimFaasMcp.Models.Endpoint; + +namespace SlimFaasMcp.Services; + + +public class GraphQlService(IHttpClientFactory factory, IMemoryCache cache) : IRemoteSchemaService +{ + private readonly HttpClient _http = factory.CreateClient("InsecureHttpClient"); + + private const string INTROSPECTION_QUERY = @" +query Introspection { + __schema { + queryType { name } + mutationType { name } + types { + kind + name + description + fields(includeDeprecated: true) { + name + description + args { + name + description + type { kind name ofType { kind name ofType { kind name } } } + } + type { kind name ofType { kind name ofType { kind name } } } + } + inputFields { # ← ajouté + name + description + type { kind name ofType { kind name ofType { kind name } } } + } + } + } +}"; + + public async Task GetSchemaAsync(string url, string? baseUrl = null, string? auth = null) + { + var key = $"graphql::{url}"; + if (cache.TryGetValue(key, out var doc)) return doc; + + // Construction JSON sans réflexion + var payload = new System.Text.Json.Nodes.JsonObject + { + ["query"] = INTROSPECTION_QUERY, + ["variables"] = new JsonObject() + }; + var payloadStr = payload.ToJsonString(AppJsonContext.Default.Options); + + using var resp = await _http.PostAsync(url, new StringContent(payloadStr, System.Text.Encoding.UTF8, "application/json")); + resp.EnsureSuccessStatusCode(); + + var json = await resp.Content.ReadAsStringAsync(); + doc = JsonDocument.Parse(json); + + // ⚠️ Si l’API renvoie errors[], on lève une exception explicite + if (doc.RootElement.TryGetProperty("errors", out var errs) && + errs.ValueKind == JsonValueKind.Array && errs.GetArrayLength() > 0) + { + throw new InvalidOperationException( + "GraphQL introspection failed: " + errs[0].GetProperty("message").GetString()); + } + + + cache.Set(key, doc, TimeSpan.FromMinutes(20)); + return doc; + } + +/* helper commun — mettez‑le en début de classe */ +private static JsonElement Unwrap(JsonElement node) +{ + while (node.ValueKind == JsonValueKind.Object && + node.TryGetProperty("kind", out var k) && + (k.GetString() is "NON_NULL" or "LIST") && + node.TryGetProperty("ofType", out var inner) && + inner.ValueKind == JsonValueKind.Object) + { + node = inner; + } + return node; +} + +private static string MapScalar(string? gql) => gql switch +{ + "Int" => "integer", + "Float" => "number", + "Boolean" => "boolean", + "ID" => "string", + "String" => "string", + _ => "string" +}; + +/* ---- nouvelle fonction récursive : construit 1 Parameter (et ses enfants) */ +private static Parameter BuildParameter(string name, + JsonElement typeElem, + bool nonNull, + string? description, + IReadOnlyDictionary typesByName) +{ + var unwrapped = Unwrap(typeElem); + string kind = unwrapped.GetProperty("kind").GetString()!; + string? gqlName = unwrapped.TryGetProperty("name", out var tn) ? tn.GetString() : null; + + var param = new Parameter + { + Name = name, + Required = nonNull, + Description = description, + SchemaType = kind switch + { + "SCALAR" => MapScalar(gqlName), + "ENUM" => "string", + "LIST" => "array", + "INPUT_OBJECT" => "object", + _ => "object" + } + }; + + // DESCENTE RÉCURSIVE + if (kind == "INPUT_OBJECT" && gqlName != null && typesByName.TryGetValue(gqlName, out var def) && + def.TryGetProperty("inputFields", out var inFields) && inFields.ValueKind == JsonValueKind.Array) + { + foreach (var fld in inFields.EnumerateArray()) + { + var fldName = fld.GetProperty("name").GetString()!; + var fldType = fld.GetProperty("type"); + bool fldNonNull = fldType.GetProperty("kind").GetString() == "NON_NULL"; + string? fldDesc = fld.TryGetProperty("description", out var fd) ? fd.GetString() : null; + + param.Children.Add( + BuildParameter(fldName, fldType, fldNonNull, fldDesc, typesByName)); + } + } + else if (kind == "LIST") // LIST<…INPUT_OBJECT…> + { + // on regarde le type de l’élément + var elemType = Unwrap(typeElem.GetProperty("ofType")); + if (elemType.GetProperty("kind").GetString() == "INPUT_OBJECT" && elemType.TryGetProperty("name", out var en)) + { + string? elemName = en.GetString(); + if (elemName != null && typesByName.TryGetValue(elemName, out var inObj)) + { + param.Children.AddRange( + BuildParameter("[]", elemType, false, null, typesByName).Children); + } + } + } + + return param; +} + + public IEnumerable ParseEndpoints(JsonDocument schema) +{ + var root = schema.RootElement.GetProperty("data").GetProperty("__schema"); + + // Index rapide "nom → type" + var typesByName = root.GetProperty("types") + .EnumerateArray() + .Where(t => t.TryGetProperty("name", out var n) && + n.GetString() is { Length: > 0 }) + .ToDictionary(t => t.GetProperty("name").GetString()!, + t => t); + + foreach (var (opKind, opTypeElem) in new[] + { + ("query", root.GetProperty("queryType")), + ("mutation", root.GetProperty("mutationType")) + }) + { + if (opTypeElem.ValueKind != JsonValueKind.Object || + !opTypeElem.TryGetProperty("name", out var opNameProp)) + continue; // Pas de type pour cette op + + var opTypeName = opNameProp.GetString(); + if (opTypeName is null || !typesByName.TryGetValue(opTypeName, out var opType)) + continue; // Incohérence schéma + + if (!opType.TryGetProperty("fields", out var fieldsArr) || + fieldsArr.ValueKind != JsonValueKind.Array) + continue; + + foreach (var field in fieldsArr.EnumerateArray()) + { + if (!field.TryGetProperty("name", out var fNameProp)) continue; + string fieldName = fNameProp.GetString() ?? string.Empty; + if (fieldName.Length == 0) continue; + + string? desc = field.TryGetProperty("description", out var d) ? d.GetString() : null; + + /* ---------- remplace ENTIEREMENT le calcul de `parameters` ------ */ + var parameters = + field.TryGetProperty("args", out var argsArr) && argsArr.ValueKind == JsonValueKind.Array + ? argsArr.EnumerateArray() + .Select(a => + { + string argName = a.GetProperty("name").GetString()!; + var argType = a.GetProperty("type"); + bool nonNull = argType.GetProperty("kind").GetString() == "NON_NULL"; + string? argDesc = a.TryGetProperty("description", out var ad) ? ad.GetString() : null; + + return BuildParameter(argName, argType, nonNull, argDesc, typesByName); + }) + .ToList() + : new List(); + + yield return new Endpoint + { + Name = $"{opKind}_{fieldName}", + Url = "", + Verb = "POST", + Summary = desc, + Parameters = parameters, + ContentType = "application/json" + }; + } + } +} + +} diff --git a/src/SlimFaasMcp/Services/IRemoteSchemaService.cs b/src/SlimFaasMcp/Services/IRemoteSchemaService.cs new file mode 100644 index 000000000..b7f3519f0 --- /dev/null +++ b/src/SlimFaasMcp/Services/IRemoteSchemaService.cs @@ -0,0 +1,11 @@ +using System.Text.Json; +using SlimFaasMcp.Models; +using Endpoint = SlimFaasMcp.Models.Endpoint; + +namespace SlimFaasMcp.Services; + +public interface IRemoteSchemaService +{ + Task GetSchemaAsync(string url, string? baseUrl = null, string? authHeader = null); + IEnumerable ParseEndpoints(JsonDocument schema); +} diff --git a/src/SlimFaasMcp/Services/SwaggerService.cs b/src/SlimFaasMcp/Services/SwaggerService.cs index f8605da75..5c53e25ae 100644 --- a/src/SlimFaasMcp/Services/SwaggerService.cs +++ b/src/SlimFaasMcp/Services/SwaggerService.cs @@ -11,7 +11,7 @@ public interface ISwaggerService IEnumerable ParseEndpoints(JsonDocument swagger); } -public class SwaggerService(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache) : ISwaggerService +public class SwaggerService(IHttpClientFactory httpClientFactory, IMemoryCache memoryCache) : ISwaggerService, IRemoteSchemaService { private readonly HttpClient _httpClient = httpClientFactory.CreateClient("InsecureHttpClient"); @@ -55,6 +55,9 @@ public async Task GetSwaggerAsync(string swaggerUrl, string? baseU return swaggerJson; } + + public Task GetSchemaAsync(string url, string? baseUrl = null, string? authHeader = null) => GetSwaggerAsync(url, baseUrl, authHeader); + public IEnumerable ParseEndpoints(JsonDocument swagger) { var root = swagger.RootElement; @@ -145,6 +148,7 @@ public IEnumerable ParseEndpoints(JsonDocument swagger) { var requiredFields = schema.TryGetProperty("required", out var reqArr) ? reqArr.EnumerateArray().Select(x => x.GetString()).ToHashSet()! + : new HashSet(); foreach (var prop in props.EnumerateObject()) { diff --git a/src/SlimFaasMcp/Services/ToolProxyService.cs b/src/SlimFaasMcp/Services/ToolProxyService.cs index 566159173..a8ed65f3e 100644 --- a/src/SlimFaasMcp/Services/ToolProxyService.cs +++ b/src/SlimFaasMcp/Services/ToolProxyService.cs @@ -8,19 +8,22 @@ namespace SlimFaasMcp.Services; public interface IToolProxyService { - Task> GetToolsAsync(string swaggerUrl, string? baseUrl, string? authHeader, string? mcpPromptB64); + Task> GetToolsAsync(string swaggerUrl, string? baseUrl, string? authHeader, string? mcpPromptB64, string contract = "openapi"); Task ExecuteToolAsync( string swaggerUrl, string toolName, JsonElement input, // ← plus "object" string? baseUrl = null, - string? authHeader = null); + string? authHeader = null, + string contract = "openapi"); } -public class ToolProxyService(ISwaggerService swaggerService, IHttpClientFactory httpClientFactory) : IToolProxyService +public class ToolProxyService([FromKeyedServices("openapi")] IRemoteSchemaService swaggerService, [FromKeyedServices("graphql")]IRemoteSchemaService graphqlService, IHttpClientFactory httpClientFactory) : IToolProxyService { + + private IRemoteSchemaService Select(string contract) => contract == "graphql" ? graphqlService : swaggerService; private readonly HttpClient _httpClient = httpClientFactory.CreateClient("InsecureHttpClient"); private static string CombineBaseUrl(string? baseUrl, string endpointUrl) { @@ -41,10 +44,11 @@ private static string GetContentType(Endpoint endpoint) return contentType; } - public async Task> GetToolsAsync(string swaggerUrl, string? baseUrl, string? authHeader, string? mcpPromptB64) + public async Task> GetToolsAsync(string swaggerUrl, string? baseUrl, string? authHeader, string? mcpPromptB64, string contract = "openapi") { - var swagger = await swaggerService.GetSwaggerAsync(swaggerUrl, baseUrl, authHeader); - var endpoints = swaggerService.ParseEndpoints(swagger); + var svc = Select(contract); + var schema = await svc.GetSchemaAsync(swaggerUrl, baseUrl, authHeader); + var endpoints = svc.ParseEndpoints(schema); var tools = endpoints.Select(e => new McpTool { @@ -142,24 +146,44 @@ public async Task ExecuteToolAsync( string toolName, JsonElement input, // ← plus "object" string? baseUrl = null, - string? authHeader = null) + string? authHeader = null, string contract = "openapi") { - var swagger = await swaggerService.GetSwaggerAsync(swaggerUrl, baseUrl, authHeader); - var endpoints = swaggerService.ParseEndpoints(swagger); + var svc = Select(contract); + var schema = await svc.GetSchemaAsync(swaggerUrl, baseUrl, authHeader); + var endpoints = svc.ParseEndpoints(schema); var endpoint = endpoints.FirstOrDefault(e => e.Name == toolName); if (endpoint is null) return "{\"error\":\"Tool not found\"}"; - baseUrl ??= ExtractBaseUrl(swagger) - ?? throw new ArgumentException("No baseUrl provided or found in Swagger"); - baseUrl = baseUrl.TrimEnd('/'); + //baseUrl ??= ExtractBaseUrl(swagger) + // ?? throw new ArgumentException("No baseUrl provided or found in Swagger"); + baseUrl = baseUrl?.TrimEnd('/'); // input → dictionnaire JSON var inputDict = JsonSerializer.Deserialize>( input.GetRawText(), AppJsonContext.Default.DictionaryStringJsonElement)!; + if (contract == "graphql") + { + // Variables du body → dictionnaire + var varsDict = inputDict as IReadOnlyDictionary; + + // Génère dynamiquement la requête complète + var queryStr = GraphQLQueryBuilder.BuildQuery(toolName, varsDict, schema); + + // Construit le payload JSON (query + variables) + var payload = new JsonObject { + ["query"] = queryStr, + ["variables"] = JsonSerializer.SerializeToNode(varsDict, AppJsonContext.Default.DictionaryStringJsonElement)! + }; + var response = await _httpClient.PostAsync( + baseUrl, + new StringContent(payload.ToJsonString(AppJsonContext.Default.Options), Encoding.UTF8, "application/json")); + return await response.Content.ReadAsStringAsync(); + } + // Path params var callUrl = endpoint.Url; foreach (var parameter in endpoint.Parameters.Where(p => p.In == "path")) @@ -204,5 +228,4 @@ public async Task ExecuteToolAsync( return await resp.Content.ReadAsStringAsync(); // on renvoie **toujours** un string JSON } - } diff --git a/src/SlimFaasMcp/wwwroot/index.html b/src/SlimFaasMcp/wwwroot/index.html index 8cc542000..3a68b6a10 100644 --- a/src/SlimFaasMcp/wwwroot/index.html +++ b/src/SlimFaasMcp/wwwroot/index.html @@ -16,6 +16,14 @@

MCP Swagger Proxy Web UI


+ + +

@@ -46,6 +54,11 @@

📝 MCP Prompt (YAML)

let mcpPromptJson = null; let currentB64 = ""; + function getContractQuery() { + const c = document.getElementById("contractType").value; + return c !== "openapi" ? `&contract=${c}` : ""; + } + function updateShareUrl(b64) { currentB64 = b64 || currentB64; // mémorise @@ -90,6 +103,7 @@

📝 MCP Prompt (YAML)

const b64 = document.getElementById("mcpPromptbase64input").value?.trim(); let endpoint = `/tools?openapi_url=${encodeURIComponent(url)}`; if (baseUrl) endpoint += `&base_url=${encodeURIComponent(baseUrl)}`; + endpoint += getContractQuery(); const toolsRes = await fetch(endpoint); const tools = await toolsRes.json(); loadedTools = tools; @@ -174,6 +188,7 @@

📝 MCP Prompt (YAML)

let endpoint = `/tools/${name}?openapi_url=${encodeURIComponent(url)}`; if (baseUrl) endpoint += `&base_url=${encodeURIComponent(baseUrl)}`; endpoint += getMcpPromptQuery(); + endpoint += getContractQuery(); const contentType = tool.endpoint.contentType || "application/json"; let body, headers = {}; diff --git a/tests/SlimFaasMcp.Tests/Api/McpEndpointTests.cs b/tests/SlimFaasMcp.Tests/Api/McpEndpointTests.cs index 5c4e34838..83fbce78d 100644 --- a/tests/SlimFaasMcp.Tests/Api/McpEndpointTests.cs +++ b/tests/SlimFaasMcp.Tests/Api/McpEndpointTests.cs @@ -61,7 +61,7 @@ public async Task ToolsList_ReturnsToolsFromProxy() new() { Name = "getPets", Description = "Get all pets", InputSchema = new JsonObject() } }; _toolProxyMock.Setup(p => p.GetToolsAsync(It.IsAny(), It.IsAny(), - It.IsAny(), It.IsAny())) + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(tools); var rpc = JsonSerializer.Deserialize("""{"jsonrpc":"2.0","id":2,"method":"tools/list"}""")!; @@ -97,7 +97,7 @@ public async Task ToolsCall_HappyPath_WrapsProxyResponse() "getPets", It.IsAny(), It.IsAny(), - It.IsAny())) + It.IsAny(), It.IsAny())) .ReturnsAsync("""{ "pets": [] }"""); var rpc = JsonSerializer.Deserialize("""{"jsonrpc":"2.0","id":"abc","method":"tools/call","params":{"name":"getPets","arguments":{}}}""")!; diff --git a/tests/SlimFaasMcp.Tests/Api/MinimalApiTests.cs b/tests/SlimFaasMcp.Tests/Api/MinimalApiTests.cs index 8bab17396..208136883 100644 --- a/tests/SlimFaasMcp.Tests/Api/MinimalApiTests.cs +++ b/tests/SlimFaasMcp.Tests/Api/MinimalApiTests.cs @@ -70,11 +70,11 @@ private sealed class FakeToolProxyService : IToolProxyService } ]; - public Task> GetToolsAsync(string s1,string? s2,string? s3,string? s4) + public Task> GetToolsAsync(string s1,string? s2,string? s3,string? s4, string contract = "openapi") => Task.FromResult(_tools); public Task ExecuteToolAsync(string s1,string s2, - System.Text.Json.JsonElement e,string? s3,string? s4) + System.Text.Json.JsonElement e,string? s3,string? s4, string contract = "openapi") => Task.FromResult(@"{""status"":""ok""}"); } } diff --git a/tests/SlimFaasMcp.Tests/Services/ToolProxyServiceMoreTests.cs b/tests/SlimFaasMcp.Tests/Services/ToolProxyServiceMoreTests.cs index 572652d78..e5446a33a 100644 --- a/tests/SlimFaasMcp.Tests/Services/ToolProxyServiceMoreTests.cs +++ b/tests/SlimFaasMcp.Tests/Services/ToolProxyServiceMoreTests.cs @@ -10,7 +10,8 @@ namespace SlimFaasMcp.Tests.Services; public class ToolProxyServiceMoreTests { - private readonly Mock _swaggerMock = new(); + private readonly Mock _swaggerMock = new(); + private readonly Mock _graphQLMock = new(); private readonly Mock _factoryMock = new(); private readonly TestHandler _handler = new(); private readonly ToolProxyService _sut; @@ -21,13 +22,13 @@ public ToolProxyServiceMoreTests() _factoryMock.Setup(f => f.CreateClient("InsecureHttpClient")) .Returns(client); - _sut = new ToolProxyService(_swaggerMock.Object, _factoryMock.Object); + _sut = new ToolProxyService(_swaggerMock.Object, _graphQLMock.Object ,_factoryMock.Object); } private void SetupSwaggerAndEndpoints(IEnumerable endpoints) { var dummyDoc = JsonDocument.Parse("{}"); - _swaggerMock.Setup(s => s.GetSwaggerAsync(It.IsAny(), It.IsAny(), It.IsAny())) + _swaggerMock.Setup(s => s.GetSchemaAsync(It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync(dummyDoc); _swaggerMock.Setup(s => s.ParseEndpoints(dummyDoc)) .Returns(endpoints); diff --git a/tests/SlimFaasMcp.Tests/Services/ToolProxyServiceTests.cs b/tests/SlimFaasMcp.Tests/Services/ToolProxyServiceTests.cs index d1fd874ff..8f2720e11 100644 --- a/tests/SlimFaasMcp.Tests/Services/ToolProxyServiceTests.cs +++ b/tests/SlimFaasMcp.Tests/Services/ToolProxyServiceTests.cs @@ -38,7 +38,8 @@ private static (ToolProxyService svc, StubHttpMessageHandler stub) Create() var factory = new FakeHttpClientFactory(stub); var swagger = new SwaggerService(factory, new MemoryCache(new MemoryCacheOptions())); - return (new ToolProxyService(swagger, factory), stub); + var graphQl = new GraphQlService(factory, new MemoryCache(new MemoryCacheOptions())); + return (new ToolProxyService(swagger, graphQl, factory), stub); } [Fact]