From bfbc5f1e54fda2b0e1f2c9505a3964845d3a7ae5 Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Wed, 5 Aug 2026 16:59:54 +0100 Subject: [PATCH 01/11] feat(config): add declarative YAML/JSON configuration model, loader, and fail-closed validator --- .../AzureCosmosDB.MCP.Toolkit.csproj | 2 + .../Configuration/ConfigurationLoader.cs | 117 ++++++++ .../Configuration/ConfigurationValidator.cs | 275 ++++++++++++++++++ .../Configuration/EnvironmentSubstitution.cs | 60 ++++ .../Configuration/InputSchemaConfiguration.cs | 110 +++++++ .../Configuration/ToolConfiguration.cs | 174 +++++++++++ .../Configuration/ToolkitConfiguration.cs | 127 ++++++++ 7 files changed, 865 insertions(+) create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationValidator.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Configuration/EnvironmentSubstitution.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Configuration/InputSchemaConfiguration.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolConfiguration.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolkitConfiguration.cs diff --git a/src/AzureCosmosDB.MCP.Toolkit/AzureCosmosDB.MCP.Toolkit.csproj b/src/AzureCosmosDB.MCP.Toolkit/AzureCosmosDB.MCP.Toolkit.csproj index f7a1e6a..714bc7a 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/AzureCosmosDB.MCP.Toolkit.csproj +++ b/src/AzureCosmosDB.MCP.Toolkit/AzureCosmosDB.MCP.Toolkit.csproj @@ -26,6 +26,8 @@ + + diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs new file mode 100644 index 0000000..a76b6c9 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs @@ -0,0 +1,117 @@ +using System.Text.Json; +using YamlDotNet.Serialization; + +namespace AzureCosmosDB.MCP.Toolkit.Configuration; + +/// Outcome of loading and validating a declarative configuration document. +public sealed class ConfigurationLoadResult +{ + public ToolkitConfiguration? Configuration { get; init; } + public IReadOnlyList Errors { get; init; } = Array.Empty(); + public IReadOnlyList Warnings { get; init; } = Array.Empty(); + public bool IsValid => Errors.Count == 0 && Configuration is not null; +} + +/// +/// Loads the additive declarative configuration from YAML or JSON text. +/// Fails closed: any parse or validation error yields an invalid result with diagnostics. +/// +public sealed class ConfigurationLoader +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true, + }; + + private readonly IReadOnlyDictionary _environment; + + public ConfigurationLoader(IReadOnlyDictionary? environment = null) + { + _environment = environment ?? EnvironmentSubstitution.CurrentEnvironment(); + } + + /// Load configuration from a file path (format inferred from extension/content). + public ConfigurationLoadResult LoadFromFile(string path) + { + if (!File.Exists(path)) + { + return new ConfigurationLoadResult { Errors = new[] { $"Configuration file not found: {path}" } }; + } + + var isJson = path.EndsWith(".json", StringComparison.OrdinalIgnoreCase); + string text; + try + { + text = File.ReadAllText(path); + } + catch (Exception ex) + { + return new ConfigurationLoadResult { Errors = new[] { $"Failed to read configuration file '{path}': {ex.Message}" } }; + } + + return LoadFromText(text, isJson); + } + + /// Load configuration from raw text. When is null the format is auto-detected. + public ConfigurationLoadResult LoadFromText(string text, bool? isJson = null) + { + if (string.IsNullOrWhiteSpace(text)) + { + return new ConfigurationLoadResult { Errors = new[] { "Configuration document is empty." } }; + } + + var substitutionErrors = new List(); + var substituted = EnvironmentSubstitution.Apply(text, _environment, substitutionErrors); + if (substitutionErrors.Count > 0) + { + return new ConfigurationLoadResult { Errors = substitutionErrors }; + } + + var treatAsJson = isJson ?? substituted.TrimStart().StartsWith('{'); + + string json; + if (treatAsJson) + { + json = substituted; + } + else + { + try + { + var deserializer = new DeserializerBuilder().Build(); + var yamlObject = deserializer.Deserialize(substituted); + var serializer = new SerializerBuilder().JsonCompatible().Build(); + json = serializer.Serialize(yamlObject); + } + catch (Exception ex) + { + return new ConfigurationLoadResult { Errors = new[] { $"YAML parse error: {ex.Message}" } }; + } + } + + ToolkitConfiguration? config; + try + { + config = JsonSerializer.Deserialize(json, JsonOptions); + } + catch (Exception ex) + { + return new ConfigurationLoadResult { Errors = new[] { $"Configuration deserialization error: {ex.Message}" } }; + } + + if (config is null) + { + return new ConfigurationLoadResult { Errors = new[] { "Configuration document produced no content." } }; + } + + var validation = ConfigurationValidator.Validate(config); + return new ConfigurationLoadResult + { + Configuration = validation.Errors.Count == 0 ? config : null, + Errors = validation.Errors, + Warnings = validation.Warnings, + }; + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationValidator.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationValidator.cs new file mode 100644 index 0000000..e6d2287 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationValidator.cs @@ -0,0 +1,275 @@ +namespace AzureCosmosDB.MCP.Toolkit.Configuration; + +/// Validation diagnostics. +public sealed class ConfigurationValidationResult +{ + public List Errors { get; } = new(); + public List Warnings { get; } = new(); +} + +/// +/// Startup/schema validator. Fails closed: unknown operation types, missing required fields, +/// and write operations that are not explicitly enabled all produce errors. +/// +public static class ConfigurationValidator +{ + private static readonly HashSet SupportedVersions = new(StringComparer.Ordinal) { "1.0" }; + + private static readonly HashSet ReadOperations = new(StringComparer.OrdinalIgnoreCase) + { + "point-read", "query", "text-search", "vector-search", "hybrid-search", + }; + + private static readonly HashSet WriteOperations = new(StringComparer.OrdinalIgnoreCase) + { + "create", "replace", "patch", "delete", "transactional-batch", "sequence", + }; + + private static readonly HashSet PatchOps = new(StringComparer.OrdinalIgnoreCase) + { + "set", "replace", "add", "remove", "increment", + }; + + public static ConfigurationValidationResult Validate(ToolkitConfiguration config) + { + var result = new ConfigurationValidationResult(); + + if (string.IsNullOrWhiteSpace(config.Version)) + { + result.Errors.Add("Top-level 'version' is required."); + } + else if (!SupportedVersions.Contains(config.Version)) + { + result.Errors.Add($"Unsupported configuration version '{config.Version}'. Supported: {string.Join(", ", SupportedVersions)}."); + } + + if (config.Sources.Count == 0) + { + result.Errors.Add("At least one entry under 'sources' is required."); + } + + foreach (var (name, source) in config.Sources) + { + if (!string.Equals(source.Type, "cosmos", StringComparison.OrdinalIgnoreCase)) + { + result.Errors.Add($"Source '{name}': unsupported type '{source.Type}'. Only 'cosmos' is supported."); + } + + if (string.IsNullOrWhiteSpace(source.Endpoint) && string.IsNullOrWhiteSpace(source.ConnectionString)) + { + result.Errors.Add($"Source '{name}': either 'endpoint' or 'connectionString' is required."); + } + + if (string.IsNullOrWhiteSpace(source.Database)) + { + result.Warnings.Add($"Source '{name}': no default 'database' set; tools must resolve a database another way."); + } + } + + if (config.Tools.Count == 0) + { + result.Warnings.Add("No tools are defined; the declarative layer will register nothing."); + } + + foreach (var (key, tool) in config.Tools) + { + ValidateTool(key, tool, config, result); + } + + return result; + } + + private static void ValidateTool(string key, ToolConfiguration tool, ToolkitConfiguration config, ConfigurationValidationResult result) + { + var name = tool.Name ?? key; + + if (string.IsNullOrWhiteSpace(tool.Description)) + { + result.Warnings.Add($"Tool '{name}': no description provided."); + } + + var sourceName = tool.Source ?? config.Defaults?.Source; + if (string.IsNullOrWhiteSpace(sourceName)) + { + result.Errors.Add($"Tool '{name}': no 'source' specified and no default source configured."); + } + else if (!config.Sources.ContainsKey(sourceName)) + { + result.Errors.Add($"Tool '{name}': references unknown source '{sourceName}'."); + } + + if (tool.Operation is null) + { + result.Errors.Add($"Tool '{name}': 'operation' is required."); + return; + } + + var opType = tool.Operation.Type; + if (string.IsNullOrWhiteSpace(opType)) + { + result.Errors.Add($"Tool '{name}': 'operation.type' is required."); + return; + } + + var isRead = ReadOperations.Contains(opType); + var isWrite = WriteOperations.Contains(opType); + if (!isRead && !isWrite) + { + result.Errors.Add($"Tool '{name}': unknown operation type '{opType}'."); + return; + } + + // Effective governance: tool over defaults, with readOnly defaulting to true. + var governance = (tool.Governance ?? new GovernanceConfiguration()).MergedOver(config.Defaults?.Governance); + var readOnly = governance.ReadOnly ?? true; + + if (isWrite && readOnly) + { + result.Errors.Add( + $"Tool '{name}': operation '{opType}' performs writes but governance.readOnly is not disabled. " + + "Set governance.readOnly: false to explicitly enable writes (fail-closed default)."); + } + + if (string.Equals(opType, "delete", StringComparison.OrdinalIgnoreCase) && governance.AllowDelete != true) + { + result.Errors.Add($"Tool '{name}': delete requires governance.allowDelete: true."); + } + + ValidateOperationShape(name, tool.Operation, governance, result); + } + + private static void ValidateOperationShape(string name, OperationConfiguration op, GovernanceConfiguration governance, ConfigurationValidationResult result) + { + switch (op.Type.ToLowerInvariant()) + { + case "point-read": + Require(op.Container, $"Tool '{name}': point-read requires 'container'.", result); + Require(op.Id, $"Tool '{name}': point-read requires 'id'.", result); + Require(op.PartitionKey, $"Tool '{name}': point-read requires 'partitionKey'.", result); + break; + case "query": + Require(op.Container, $"Tool '{name}': query requires 'container'.", result); + Require(op.Statement, $"Tool '{name}': query requires 'statement'.", result); + break; + case "text-search": + Require(op.Container, $"Tool '{name}': text-search requires 'container'.", result); + Require(op.Property, $"Tool '{name}': text-search requires 'property'.", result); + Require(op.SearchText, $"Tool '{name}': text-search requires 'searchText'.", result); + break; + case "vector-search": + Require(op.Container, $"Tool '{name}': vector-search requires 'container'.", result); + Require(op.VectorPath, $"Tool '{name}': vector-search requires 'vectorPath'.", result); + Require(op.SearchText, $"Tool '{name}': vector-search requires 'searchText'.", result); + RequireSelect(name, op, result); + break; + case "hybrid-search": + Require(op.Container, $"Tool '{name}': hybrid-search requires 'container'.", result); + Require(op.VectorPath, $"Tool '{name}': hybrid-search requires 'vectorPath'.", result); + Require(op.TextPath, $"Tool '{name}': hybrid-search requires 'textPath'.", result); + Require(op.SearchText, $"Tool '{name}': hybrid-search requires 'searchText'.", result); + RequireSelect(name, op, result); + break; + case "create": + Require(op.Container, $"Tool '{name}': create requires 'container'.", result); + Require(op.PartitionKey, $"Tool '{name}': create requires 'partitionKey'.", result); + if (op.Document is null || op.Document.Count == 0) + { + result.Errors.Add($"Tool '{name}': create requires a non-empty 'document'."); + } + break; + case "replace": + Require(op.Container, $"Tool '{name}': replace requires 'container'.", result); + Require(op.Id, $"Tool '{name}': replace requires 'id'.", result); + Require(op.PartitionKey, $"Tool '{name}': replace requires 'partitionKey'.", result); + if (op.Document is null || op.Document.Count == 0) + { + result.Errors.Add($"Tool '{name}': replace requires a non-empty 'document'."); + } + break; + case "patch": + Require(op.Container, $"Tool '{name}': patch requires 'container'.", result); + Require(op.Id, $"Tool '{name}': patch requires 'id'.", result); + Require(op.PartitionKey, $"Tool '{name}': patch requires 'partitionKey'.", result); + ValidatePatchOperations(name, op.Operations, governance, result); + break; + case "delete": + Require(op.Container, $"Tool '{name}': delete requires 'container'.", result); + Require(op.Id, $"Tool '{name}': delete requires 'id'.", result); + Require(op.PartitionKey, $"Tool '{name}': delete requires 'partitionKey'.", result); + break; + case "transactional-batch": + Require(op.Container, $"Tool '{name}': transactional-batch requires 'container'.", result); + Require(op.PartitionKey, $"Tool '{name}': transactional-batch requires 'partitionKey'.", result); + if (op.Steps is null || op.Steps.Count == 0) + { + result.Errors.Add($"Tool '{name}': transactional-batch requires at least one step."); + } + else + { + foreach (var step in op.Steps) + { + if (string.Equals(step.EffectiveType, "patch", StringComparison.OrdinalIgnoreCase)) + { + ValidatePatchOperations(name, step.Operations, governance, result); + } + } + } + break; + case "sequence": + if (op.Steps is null || op.Steps.Count == 0) + { + result.Errors.Add($"Tool '{name}': sequence requires at least one step."); + } + break; + } + } + + private static void ValidatePatchOperations(string name, List? operations, GovernanceConfiguration governance, ConfigurationValidationResult result) + { + if (operations is null || operations.Count == 0) + { + result.Errors.Add($"Tool '{name}': patch requires at least one operation."); + return; + } + + foreach (var patch in operations) + { + if (!PatchOps.Contains(patch.Op)) + { + result.Errors.Add($"Tool '{name}': unsupported patch op '{patch.Op}'. Supported: {string.Join(", ", PatchOps)}."); + } + + if (string.IsNullOrWhiteSpace(patch.Path) || !patch.Path.StartsWith('/')) + { + result.Errors.Add($"Tool '{name}': patch path '{patch.Path}' must be a JSON pointer beginning with '/'."); + } + else if (governance.AllowedPatchPaths is { Count: > 0 } allowed && + !allowed.Contains(patch.Path, StringComparer.Ordinal)) + { + result.Errors.Add($"Tool '{name}': patch path '{patch.Path}' is not in governance.allowedPatchPaths."); + } + } + } + + private static void RequireSelect(string name, OperationConfiguration op, ConfigurationValidationResult result) + { + if (op.Select is null || op.Select.Count == 0) + { + result.Errors.Add($"Tool '{name}': {op.Type} requires an explicit 'select' list (wildcard projection is not permitted)."); + return; + } + + if (op.Select.Any(s => s.Contains('*'))) + { + result.Errors.Add($"Tool '{name}': 'select' may not contain '*' wildcards."); + } + } + + private static void Require(string? value, string error, ConfigurationValidationResult result) + { + if (string.IsNullOrWhiteSpace(value)) + { + result.Errors.Add(error); + } + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/EnvironmentSubstitution.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/EnvironmentSubstitution.cs new file mode 100644 index 0000000..904d3a0 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/EnvironmentSubstitution.cs @@ -0,0 +1,60 @@ +using System.Text.RegularExpressions; + +namespace AzureCosmosDB.MCP.Toolkit.Configuration; + +/// +/// Resolves ${VAR} and ${env:VAR} tokens against environment variables. +/// +/// +/// Two forms are supported: +/// +/// ${env:NAME} — always an environment reference; missing values are reported as errors. +/// ${NAME} — substituted only when an environment variable NAME exists. +/// Otherwise the token is left intact so it can be used as a runtime parameter binding +/// (for example ${accountId}). +/// +/// This keeps environment substitution (load time) and parameter binding (invocation time) +/// unambiguous while matching the documented specification examples. +/// +public static partial class EnvironmentSubstitution +{ + [GeneratedRegex(@"\$\{(env:)?([A-Za-z_][A-Za-z0-9_]*)\}")] + private static partial Regex TokenRegex(); + + public static string Apply(string input, IReadOnlyDictionary environment, IList errors) + { + ArgumentNullException.ThrowIfNull(input); + + return TokenRegex().Replace(input, match => + { + var explicitEnv = match.Groups[1].Success; + var name = match.Groups[2].Value; + var hasValue = environment.TryGetValue(name, out var value) && value is not null; + + if (hasValue) + { + return value!; + } + + if (explicitEnv) + { + errors.Add($"Environment variable '{name}' referenced by '${{env:{name}}}' is not set."); + return match.Value; + } + + // Bare token with no matching environment variable: preserve as a runtime binding. + return match.Value; + }); + } + + public static IReadOnlyDictionary CurrentEnvironment() + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (System.Collections.DictionaryEntry entry in Environment.GetEnvironmentVariables()) + { + result[entry.Key.ToString()!] = entry.Value?.ToString(); + } + + return result; + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/InputSchemaConfiguration.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/InputSchemaConfiguration.cs new file mode 100644 index 0000000..8dcdbc0 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/InputSchemaConfiguration.cs @@ -0,0 +1,110 @@ +using System.Text.Json.Serialization; + +namespace AzureCosmosDB.MCP.Toolkit.Configuration; + +/// Declarative input schema (a constrained subset of JSON Schema). +public sealed class InputSchemaConfiguration +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "object"; + + [JsonPropertyName("required")] + public List? Required { get; set; } + + [JsonPropertyName("properties")] + public Dictionary? Properties { get; set; } +} + +/// A single input property definition with validation constraints. +public sealed class PropertySchema +{ + /// string, integer, number, boolean, object, array. + [JsonPropertyName("type")] + public string Type { get; set; } = "string"; + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("default")] + public object? Default { get; set; } + + [JsonPropertyName("enum")] + public List? Enum { get; set; } + + [JsonPropertyName("minimum")] + public double? Minimum { get; set; } + + [JsonPropertyName("maximum")] + public double? Maximum { get; set; } + + [JsonPropertyName("minLength")] + public int? MinLength { get; set; } + + [JsonPropertyName("maxLength")] + public int? MaxLength { get; set; } + + [JsonPropertyName("pattern")] + public string? Pattern { get; set; } + + [JsonPropertyName("minItems")] + public int? MinItems { get; set; } + + [JsonPropertyName("maxItems")] + public int? MaxItems { get; set; } + + /// Item schema for array types. + [JsonPropertyName("items")] + public PropertySchema? Items { get; set; } + + /// Nested properties for object types. + [JsonPropertyName("properties")] + public Dictionary? Properties { get; set; } + + [JsonPropertyName("required")] + public List? Required { get; set; } +} + +/// Output shaping definition (projection, renaming, redaction, limits). +public sealed class OutputConfiguration +{ + /// Map of output field name to source field path (projection + rename). + [JsonPropertyName("select")] + public Dictionary? Select { get; set; } + + /// Fields to remove from output entirely (redaction). + [JsonPropertyName("redact")] + public List? Redact { get; set; } + + /// Optional hard cap on the number of returned items. + [JsonPropertyName("maxItems")] + public int? MaxItems { get; set; } +} + +/// Per-tool authorization policy. +public sealed class AuthorizationConfiguration +{ + [JsonPropertyName("requiredScopes")] + public List? RequiredScopes { get; set; } + + [JsonPropertyName("requiredRoles")] + public List? RequiredRoles { get; set; } + + /// Claim type that carries the tenant identifier. + [JsonPropertyName("tenantClaim")] + public string? TenantClaim { get; set; } + + /// Document/partition field that must equal the caller tenant claim. + [JsonPropertyName("tenantField")] + public string? TenantField { get; set; } + + /// Additional claim equality rules (claim type => required value). + [JsonPropertyName("claims")] + public Dictionary? Claims { get; set; } + + /// + /// Input parameter whose value must be derived from identity (never trusted from the model) + /// mapped to the claim it must equal. Used for partition-key restriction. + /// + [JsonPropertyName("partitionKeyFromClaim")] + public Dictionary? PartitionKeyFromClaim { get; set; } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolConfiguration.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolConfiguration.cs new file mode 100644 index 0000000..0deb675 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolConfiguration.cs @@ -0,0 +1,174 @@ +using System.Text.Json.Serialization; + +namespace AzureCosmosDB.MCP.Toolkit.Configuration; + +/// A single business-facing tool definition. +public sealed class ToolConfiguration +{ + /// Optional explicit name. Defaults to the dictionary key. + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("description")] + public string? Description { get; set; } + + [JsonPropertyName("version")] + public string? Version { get; set; } + + /// When false, the tool is not registered. Defaults to true. + [JsonPropertyName("enabled")] + public bool? Enabled { get; set; } + + [JsonPropertyName("tags")] + public List? Tags { get; set; } + + [JsonPropertyName("examples")] + public List? Examples { get; set; } + + /// Name of the source this tool binds to. + [JsonPropertyName("source")] + public string? Source { get; set; } + + [JsonPropertyName("operation")] + public OperationConfiguration? Operation { get; set; } + + [JsonPropertyName("input")] + public InputSchemaConfiguration? Input { get; set; } + + [JsonPropertyName("output")] + public OutputConfiguration? Output { get; set; } + + [JsonPropertyName("authorization")] + public AuthorizationConfiguration? Authorization { get; set; } + + [JsonPropertyName("governance")] + public GovernanceConfiguration? Governance { get; set; } +} + +/// Declarative operation definition. The selects the provider. +public sealed class OperationConfiguration +{ + /// + /// One of: point-read, query, text-search, vector-search, hybrid-search, + /// create, replace, patch, delete, transactional-batch, sequence. + /// + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + [JsonPropertyName("container")] + public string? Container { get; set; } + + // point-read / patch / replace / delete + [JsonPropertyName("id")] + public string? Id { get; set; } + + [JsonPropertyName("partitionKey")] + public string? PartitionKey { get; set; } + + // query + [JsonPropertyName("statement")] + public string? Statement { get; set; } + + [JsonPropertyName("parameters")] + public Dictionary? Parameters { get; set; } + + // text-search + [JsonPropertyName("property")] + public string? Property { get; set; } + + [JsonPropertyName("searchText")] + public string? SearchText { get; set; } + + [JsonPropertyName("limit")] + public string? Limit { get; set; } + + // vector-search / hybrid-search + [JsonPropertyName("vectorPath")] + public string? VectorPath { get; set; } + + [JsonPropertyName("textPath")] + public string? TextPath { get; set; } + + [JsonPropertyName("select")] + public List? Select { get; set; } + + [JsonPropertyName("topK")] + public string? TopK { get; set; } + + // create / replace + [JsonPropertyName("document")] + public Dictionary? Document { get; set; } + + // patch + [JsonPropertyName("operations")] + public List? Operations { get; set; } + + [JsonPropertyName("concurrency")] + public ConcurrencyConfiguration? Concurrency { get; set; } + + // transactional-batch / sequence + [JsonPropertyName("steps")] + public List? Steps { get; set; } +} + +/// A JSON Patch style operation. +public sealed class PatchOperationConfiguration +{ + /// One of: set, replace, add, remove, increment. + [JsonPropertyName("op")] + public string Op { get; set; } = string.Empty; + + [JsonPropertyName("path")] + public string Path { get; set; } = string.Empty; + + [JsonPropertyName("value")] + public object? Value { get; set; } +} + +/// Optimistic concurrency control. +public sealed class ConcurrencyConfiguration +{ + /// ETag value or binding expression for If-Match. + [JsonPropertyName("ifMatch")] + public string? IfMatch { get; set; } +} + +/// A step within a transactional-batch or bounded sequence. +public sealed class StepConfiguration +{ + [JsonPropertyName("id")] + public string Id { get; set; } = string.Empty; + + /// Operation kind for the step (patch, create, replace, delete, point-read, assert). + [JsonPropertyName("type")] + public string Type { get; set; } = string.Empty; + + /// Alias used by some examples ("use") — treated as a synonym for type. + [JsonPropertyName("use")] + public string? Use { get; set; } + + [JsonPropertyName("itemId")] + public string? ItemId { get; set; } + + [JsonPropertyName("partitionKey")] + public string? PartitionKey { get; set; } + + [JsonPropertyName("operations")] + public List? Operations { get; set; } + + [JsonPropertyName("document")] + public Dictionary? Document { get; set; } + + /// Boolean expression for assert steps. Fails fast when false. + [JsonPropertyName("expression")] + public string? Expression { get; set; } + + /// Error message surfaced when an assert step fails. + [JsonPropertyName("message")] + public string? Message { get; set; } + + [JsonPropertyName("concurrency")] + public ConcurrencyConfiguration? Concurrency { get; set; } + + public string EffectiveType => string.IsNullOrWhiteSpace(Use) ? Type : Use!; +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolkitConfiguration.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolkitConfiguration.cs new file mode 100644 index 0000000..b735dcc --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolkitConfiguration.cs @@ -0,0 +1,127 @@ +using System.Text.Json.Serialization; + +namespace AzureCosmosDB.MCP.Toolkit.Configuration; + +/// +/// Root of the additive, opt-in declarative configuration model (vNext). +/// Deserialized from YAML or JSON. Existing GA deployments never require this model. +/// +public sealed class ToolkitConfiguration +{ + /// Schema version. Currently only "1.0" is supported. + [JsonPropertyName("version")] + public string? Version { get; set; } + + /// Named Cosmos DB sources referenced by tools. + [JsonPropertyName("sources")] + public Dictionary Sources { get; set; } = new(StringComparer.Ordinal); + + /// Global defaults applied to every tool unless overridden. + [JsonPropertyName("defaults")] + public DefaultsConfiguration? Defaults { get; set; } + + /// Business-facing tool definitions keyed by tool name. + [JsonPropertyName("tools")] + public Dictionary Tools { get; set; } = new(StringComparer.Ordinal); +} + +/// A named Cosmos DB data source. +public sealed class SourceConfiguration +{ + /// Source type. Only "cosmos" is supported. + [JsonPropertyName("type")] + public string Type { get; set; } = "cosmos"; + + /// Account endpoint. Supports ${ENV} substitution. + [JsonPropertyName("endpoint")] + public string? Endpoint { get; set; } + + /// Optional connection string (emulator/local). Supports ${ENV} substitution. + [JsonPropertyName("connectionString")] + public string? ConnectionString { get; set; } + + /// Default database for tools using this source. + [JsonPropertyName("database")] + public string? Database { get; set; } + + /// Authentication settings for this source. + [JsonPropertyName("authentication")] + public AuthenticationConfiguration? Authentication { get; set; } + + /// Optional connection mode: "gateway" or "direct". + [JsonPropertyName("connectionMode")] + public string? ConnectionMode { get; set; } +} + +/// Authentication configuration for a source. +public sealed class AuthenticationConfiguration +{ + /// Authentication type: "managed-identity" (default), "default-azure-credential", or "connection-string". + [JsonPropertyName("type")] + public string Type { get; set; } = "managed-identity"; +} + +/// Global defaults. +public sealed class DefaultsConfiguration +{ + [JsonPropertyName("governance")] + public GovernanceConfiguration? Governance { get; set; } + + /// Default source name applied to tools that do not specify one. + [JsonPropertyName("source")] + public string? Source { get; set; } +} + +/// Per-tool (or default) governance controls. Fail-closed for writes. +public sealed class GovernanceConfiguration +{ + [JsonPropertyName("timeoutMs")] + public int? TimeoutMs { get; set; } + + [JsonPropertyName("maxItems")] + public int? MaxItems { get; set; } + + [JsonPropertyName("maxRequestUnits")] + public double? MaxRequestUnits { get; set; } + + /// When true (the default), only read operations are permitted. + [JsonPropertyName("readOnly")] + public bool? ReadOnly { get; set; } + + /// Explicit opt-in required to permit delete operations. + [JsonPropertyName("allowDelete")] + public bool? AllowDelete { get; set; } + + /// Explicit opt-in required to permit cross-partition queries. + [JsonPropertyName("allowCrossPartition")] + public bool? AllowCrossPartition { get; set; } + + /// Upper bound for vector/hybrid topK. + [JsonPropertyName("maxTopK")] + public int? MaxTopK { get; set; } + + /// Allow-list of JSON patch paths. When set, patch ops must target one of these. + [JsonPropertyName("allowedPatchPaths")] + public List? AllowedPatchPaths { get; set; } + + /// Merge this instance over a lower-precedence one (this wins where set). + public GovernanceConfiguration MergedOver(GovernanceConfiguration? baseline) + { + if (baseline is null) + { + return this; + } + + return new GovernanceConfiguration + { + TimeoutMs = TimeoutMs ?? baseline.TimeoutMs, + MaxItems = MaxItems ?? baseline.MaxItems, + MaxRequestUnits = MaxRequestUnits ?? baseline.MaxRequestUnits, + ReadOnly = ReadOnly ?? baseline.ReadOnly, + AllowDelete = AllowDelete ?? baseline.AllowDelete, + AllowCrossPartition = AllowCrossPartition ?? baseline.AllowCrossPartition, + MaxTopK = MaxTopK ?? baseline.MaxTopK, + AllowedPatchPaths = AllowedPatchPaths ?? baseline.AllowedPatchPaths, + }; + } +} From 0aafb6727d37c6921fa60d35f98c828f054144a2 Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Wed, 5 Aug 2026 16:59:54 +0100 Subject: [PATCH 02/11] feat(providers): add shared ICosmosGateway abstraction and Cosmos implementation --- .../Providers/CosmosGateway.cs | 326 ++++++++++++++++++ .../Providers/ICosmosGateway.cs | 63 ++++ 2 files changed, 389 insertions(+) create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Providers/ICosmosGateway.cs diff --git a/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs b/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs new file mode 100644 index 0000000..6d10acf --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs @@ -0,0 +1,326 @@ +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Services; +using Microsoft.Azure.Cosmos; + +namespace AzureCosmosDB.MCP.Toolkit.Providers; + +/// +/// Cosmos DB implementation of . Uses stream APIs for reads/writes to +/// avoid POCO coupling, binds all caller-derived values as parameters, and never concatenates input +/// into SQL. Vector/hybrid SQL is built only from configuration-controlled (validated) paths. +/// +public sealed class CosmosGateway : ICosmosGateway +{ + private static readonly JsonSerializerOptions JsonOptions = new(); + + private readonly CosmosClient _client; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public CosmosGateway(CosmosClient client, IConfiguration configuration, ILogger logger) + { + _client = client; + _configuration = configuration; + _logger = logger; + } + + private Container GetContainer(string database, string container) => _client.GetContainer(database, container); + + private static PartitionKey ToPartitionKey(string value) => new(value); + + public async Task PointReadAsync(string database, string container, string id, string partitionKey, CancellationToken cancellationToken) + { + var c = GetContainer(database, container); + using var response = await c.ReadItemStreamAsync(id, ToPartitionKey(partitionKey), cancellationToken: cancellationToken); + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + return await ParseStreamAsync(response.Content, cancellationToken); + } + + public async Task QueryAsync(QueryRequest request, CancellationToken cancellationToken) + { + var c = GetContainer(request.Database, request.Container); + var query = new QueryDefinition(request.Statement); + foreach (var (name, value) in request.Parameters) + { + query.WithParameter(name.StartsWith('@') ? name : "@" + name, ToParameterValue(value)); + } + + var options = new QueryRequestOptions { MaxItemCount = request.MaxItems }; + if (!request.AllowCrossPartition && !string.IsNullOrEmpty(request.PartitionKey)) + { + options.PartitionKey = ToPartitionKey(request.PartitionKey); + } + + using var iterator = c.GetItemQueryStreamIterator(query, requestOptions: options); + return await DrainAsync(iterator, request.MaxItems, cancellationToken); + } + + public async Task TextSearchAsync(string database, string container, string property, string searchText, int maxItems, CancellationToken cancellationToken) + { + var c = GetContainer(database, container); + // 'property' originates from configuration and is validated as a safe identifier path. + var statement = $"SELECT TOP {maxItems} * FROM c WHERE FullTextContains(c.{property}, @searchPhrase)"; + var query = new QueryDefinition(statement).WithParameter("@searchPhrase", searchText); + using var iterator = c.GetItemQueryStreamIterator(query, requestOptions: new QueryRequestOptions { MaxItemCount = maxItems }); + return await DrainAsync(iterator, maxItems, cancellationToken); + } + + public async Task VectorSearchAsync(SearchRequest request, CancellationToken cancellationToken) + { + var embedding = await GenerateEmbeddingAsync(request.SearchText, cancellationToken); + var c = GetContainer(request.Database, request.Container); + var select = string.Join(", ", request.Select.Select(p => $"c.{p}")); + var statement = $"SELECT TOP @topK {select}, VectorDistance(c.{request.VectorPath}, @embedding) AS score " + + $"FROM c ORDER BY VectorDistance(c.{request.VectorPath}, @embedding)"; + var query = new QueryDefinition(statement) + .WithParameter("@topK", request.TopK) + .WithParameter("@embedding", embedding); + using var iterator = c.GetItemQueryStreamIterator(query, requestOptions: new QueryRequestOptions { MaxItemCount = request.TopK }); + return await DrainAsync(iterator, request.TopK, cancellationToken); + } + + public async Task HybridSearchAsync(SearchRequest request, CancellationToken cancellationToken) + { + var embedding = await GenerateEmbeddingAsync(request.SearchText, cancellationToken); + var c = GetContainer(request.Database, request.Container); + var select = string.Join(", ", request.Select.Select(p => $"c.{p}")); + var statement = $"SELECT TOP @topK {select} FROM c " + + $"ORDER BY RANK RRF(VectorDistance(c.{request.VectorPath}, @embedding), FullTextScore(c.{request.TextPath}, @searchText))"; + var query = new QueryDefinition(statement) + .WithParameter("@topK", request.TopK) + .WithParameter("@embedding", embedding) + .WithParameter("@searchText", request.SearchText); + using var iterator = c.GetItemQueryStreamIterator(query, requestOptions: new QueryRequestOptions { MaxItemCount = request.TopK }); + return await DrainAsync(iterator, request.TopK, cancellationToken); + } + + public async Task CreateAsync(string database, string container, JsonObject document, string partitionKey, CancellationToken cancellationToken) + { + var c = GetContainer(database, container); + using var stream = ToStream(document); + using var response = await c.CreateItemStreamAsync(stream, ToPartitionKey(partitionKey), cancellationToken: cancellationToken); + response.EnsureSuccessStatusCode(); + return await ParseStreamAsync(response.Content, cancellationToken) ?? document.DeepClone(); + } + + public async Task ReplaceAsync(string database, string container, string id, JsonObject document, string partitionKey, string? ifMatch, CancellationToken cancellationToken) + { + var c = GetContainer(database, container); + using var stream = ToStream(document); + var options = ifMatch is null ? null : new ItemRequestOptions { IfMatchEtag = ifMatch }; + using var response = await c.ReplaceItemStreamAsync(stream, id, ToPartitionKey(partitionKey), options, cancellationToken); + response.EnsureSuccessStatusCode(); + return await ParseStreamAsync(response.Content, cancellationToken) ?? document.DeepClone(); + } + + public async Task PatchAsync(string database, string container, string id, string partitionKey, IReadOnlyList operations, string? ifMatch, CancellationToken cancellationToken) + { + var c = GetContainer(database, container); + var patchOps = operations.Select(ToCosmosPatch).ToList(); + var options = new PatchItemRequestOptions(); + if (ifMatch is not null) + { + options.IfMatchEtag = ifMatch; + } + + using var response = await c.PatchItemStreamAsync(id, ToPartitionKey(partitionKey), patchOps, options, cancellationToken); + response.EnsureSuccessStatusCode(); + return await ParseStreamAsync(response.Content, cancellationToken); + } + + public async Task DeleteAsync(string database, string container, string id, string partitionKey, string? ifMatch, CancellationToken cancellationToken) + { + var c = GetContainer(database, container); + var options = ifMatch is null ? null : new ItemRequestOptions { IfMatchEtag = ifMatch }; + using var response = await c.DeleteItemStreamAsync(id, ToPartitionKey(partitionKey), options, cancellationToken); + if (response.StatusCode is not System.Net.HttpStatusCode.NoContent and not System.Net.HttpStatusCode.OK) + { + response.EnsureSuccessStatusCode(); + } + + return new JsonObject { ["id"] = id, ["deleted"] = true }; + } + + public async Task TransactionalBatchAsync(string database, string container, string partitionKey, IReadOnlyList steps, CancellationToken cancellationToken) + { + var c = GetContainer(database, container); + var batch = c.CreateTransactionalBatch(ToPartitionKey(partitionKey)); + + foreach (var step in steps) + { + switch (step.Type.ToLowerInvariant()) + { + case "create": + if (step.Document is null) + { + throw new InvalidOperationException($"Batch step '{step.Id}' (create) requires a document."); + } + + batch.CreateItemStream(ToStream(step.Document)); + break; + case "replace": + if (step.Document is null || step.ItemId is null) + { + throw new InvalidOperationException($"Batch step '{step.Id}' (replace) requires itemId and document."); + } + + batch.ReplaceItemStream(step.ItemId, ToStream(step.Document)); + break; + case "patch": + if (step.ItemId is null || step.Operations is null) + { + throw new InvalidOperationException($"Batch step '{step.Id}' (patch) requires itemId and operations."); + } + + batch.PatchItem(step.ItemId, step.Operations.Select(ToCosmosPatch).ToList()); + break; + case "delete": + if (step.ItemId is null) + { + throw new InvalidOperationException($"Batch step '{step.Id}' (delete) requires itemId."); + } + + batch.DeleteItem(step.ItemId); + break; + default: + throw new InvalidOperationException($"Unsupported batch step type '{step.Type}'."); + } + } + + using var response = await batch.ExecuteAsync(cancellationToken); + if (!response.IsSuccessStatusCode) + { + throw new CosmosBatchException(response.StatusCode, response.ErrorMessage); + } + + var results = new JsonArray(); + for (var i = 0; i < response.Count; i++) + { + var stepResult = response[i]; + results.Add(new JsonObject + { + ["stepId"] = steps[i].Id, + ["statusCode"] = (int)stepResult.StatusCode, + }); + } + + return results; + } + + private async Task GenerateEmbeddingAsync(string text, CancellationToken cancellationToken) + { + var deployment = _configuration["OPENAI_EMBEDDING_DEPLOYMENT"] + ?? Environment.GetEnvironmentVariable("OPENAI_EMBEDDING_DEPLOYMENT"); + if (string.IsNullOrWhiteSpace(deployment)) + { + throw new InvalidOperationException("OPENAI_EMBEDDING_DEPLOYMENT is required for vector/hybrid search."); + } + + var embeddingClient = EmbeddingClientFactory.CreateEmbeddingClient(_configuration, _logger); + return await embeddingClient.GenerateEmbeddingAsync(text, deployment, cancellationToken); + } + + private static PatchOperation ToCosmosPatch(ResolvedPatchOperation op) + { + var value = op.Value; + return op.Op.ToLowerInvariant() switch + { + "set" => PatchOperation.Set(op.Path, ToPatchValue(value)), + "replace" => PatchOperation.Replace(op.Path, ToPatchValue(value)), + "add" => PatchOperation.Add(op.Path, ToPatchValue(value)), + "remove" => PatchOperation.Remove(op.Path), + "increment" => PatchOperation.Increment(op.Path, ToDouble(value)), + _ => throw new InvalidOperationException($"Unsupported patch op '{op.Op}'."), + }; + } + + private static object? ToPatchValue(JsonNode? node) + => node is null ? null : JsonSerializer.Deserialize(node.ToJsonString()); + + private static double ToDouble(JsonNode? node) + { + if (node is System.Text.Json.Nodes.JsonValue jv) + { + if (jv.TryGetValue(out var d)) + { + return d; + } + + if (jv.TryGetValue(out var s) && double.TryParse(s, System.Globalization.NumberStyles.Any, System.Globalization.CultureInfo.InvariantCulture, out var parsed)) + { + return parsed; + } + } + + throw new InvalidOperationException("Increment patch value must be numeric."); + } + + private static object ToParameterValue(JsonNode? node) + { + if (node is null) + { + return null!; + } + + return JsonSerializer.Deserialize(node.ToJsonString()); + } + + private static MemoryStream ToStream(JsonObject document) + { + var bytes = Encoding.UTF8.GetBytes(document.ToJsonString(JsonOptions)); + return new MemoryStream(bytes); + } + + private static async Task ParseStreamAsync(Stream stream, CancellationToken cancellationToken) + { + if (stream is null || stream.CanSeek && stream.Length == 0) + { + return null; + } + + using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken); + return JsonNode.Parse(doc.RootElement.GetRawText()); + } + + private static async Task DrainAsync(FeedIterator iterator, int maxItems, CancellationToken cancellationToken) + { + var results = new JsonArray(); + while (iterator.HasMoreResults && results.Count < maxItems) + { + using var response = await iterator.ReadNextAsync(cancellationToken); + using var doc = await JsonDocument.ParseAsync(response.Content, cancellationToken: cancellationToken); + if (doc.RootElement.TryGetProperty("Documents", out var documents)) + { + foreach (var item in documents.EnumerateArray()) + { + results.Add(JsonNode.Parse(item.GetRawText())); + if (results.Count >= maxItems) + { + break; + } + } + } + } + + return results; + } +} + +/// Raised when a transactional batch fails, carrying the Cosmos status code. +public sealed class CosmosBatchException : Exception +{ + public CosmosBatchException(System.Net.HttpStatusCode statusCode, string? message) + : base($"Transactional batch failed with status {(int)statusCode}: {message}") + { + StatusCode = statusCode; + } + + public System.Net.HttpStatusCode StatusCode { get; } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Providers/ICosmosGateway.cs b/src/AzureCosmosDB.MCP.Toolkit/Providers/ICosmosGateway.cs new file mode 100644 index 0000000..ae58d97 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Providers/ICosmosGateway.cs @@ -0,0 +1,63 @@ +using System.Text.Json.Nodes; + +namespace AzureCosmosDB.MCP.Toolkit.Providers; + +/// A patch operation with its value already resolved to a concrete node. +public sealed record ResolvedPatchOperation(string Op, string Path, JsonNode? Value); + +/// A single resolved step within a transactional batch. +public sealed record ResolvedBatchStep( + string Id, + string Type, + string? ItemId, + JsonObject? Document, + IReadOnlyList? Operations); + +/// Parameters for a query execution. +public sealed record QueryRequest( + string Database, + string Container, + string Statement, + IReadOnlyDictionary Parameters, + string? PartitionKey, + int MaxItems, + bool AllowCrossPartition); + +/// Parameters for a vector or hybrid search execution. +public sealed record SearchRequest( + string Database, + string Container, + string SearchText, + string VectorPath, + string? TextPath, + IReadOnlyList Select, + int TopK, + string? PartitionKey); + +/// +/// Shared abstraction over Cosmos DB data operations. Both the (existing) built-in tools' logic +/// and the (new) configured tools execute through the same provider surface. +/// The interface is fully mockable so the configuration runtime can be unit tested without a live account. +/// +public interface ICosmosGateway +{ + Task PointReadAsync(string database, string container, string id, string partitionKey, CancellationToken cancellationToken); + + Task QueryAsync(QueryRequest request, CancellationToken cancellationToken); + + Task TextSearchAsync(string database, string container, string property, string searchText, int maxItems, CancellationToken cancellationToken); + + Task VectorSearchAsync(SearchRequest request, CancellationToken cancellationToken); + + Task HybridSearchAsync(SearchRequest request, CancellationToken cancellationToken); + + Task CreateAsync(string database, string container, JsonObject document, string partitionKey, CancellationToken cancellationToken); + + Task ReplaceAsync(string database, string container, string id, JsonObject document, string partitionKey, string? ifMatch, CancellationToken cancellationToken); + + Task PatchAsync(string database, string container, string id, string partitionKey, IReadOnlyList operations, string? ifMatch, CancellationToken cancellationToken); + + Task DeleteAsync(string database, string container, string id, string partitionKey, string? ifMatch, CancellationToken cancellationToken); + + Task TransactionalBatchAsync(string database, string container, string partitionKey, IReadOnlyList steps, CancellationToken cancellationToken); +} From e09d3246ddccd893b4955d193f29a2062db4e97a Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Wed, 5 Aug 2026 16:59:55 +0100 Subject: [PATCH 03/11] feat(runtime): add input validation, safe binding, expression eval, projection, authorization, and operation execution pipeline --- .../Runtime/AuthorizationEvaluator.cs | 212 +++++++++++++ .../Runtime/BindingContext.cs | 222 +++++++++++++ .../Runtime/ConfiguredTool.cs | 66 ++++ .../Runtime/ConfiguredToolExecutor.cs | 160 ++++++++++ .../Runtime/InputValidator.cs | 293 ++++++++++++++++++ .../Runtime/JsonSchemaGenerator.cs | 129 ++++++++ .../Runtime/OperationExecutor.cs | 230 ++++++++++++++ .../Runtime/OutputProjector.cs | 102 ++++++ .../Runtime/SafeExpressionEvaluator.cs | 178 +++++++++++ 9 files changed, 1592 insertions(+) create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Runtime/AuthorizationEvaluator.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Runtime/BindingContext.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredTool.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredToolExecutor.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Runtime/InputValidator.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Runtime/JsonSchemaGenerator.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Runtime/OperationExecutor.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Runtime/OutputProjector.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Runtime/SafeExpressionEvaluator.cs diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/AuthorizationEvaluator.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/AuthorizationEvaluator.cs new file mode 100644 index 0000000..590bb63 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/AuthorizationEvaluator.cs @@ -0,0 +1,212 @@ +using System.Security.Claims; +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Configuration; + +namespace AzureCosmosDB.MCP.Toolkit.Runtime; + +/// Identity facts about the caller, sourced from validated claims (never from the model). +public sealed class CallerContext +{ + public bool IsAuthenticated { get; init; } + public bool AuthenticationBypassed { get; init; } + public IReadOnlyCollection Scopes { get; init; } = Array.Empty(); + public IReadOnlyCollection Roles { get; init; } = Array.Empty(); + public IReadOnlyDictionary Claims { get; init; } = new Dictionary(StringComparer.Ordinal); + + public string? GetClaim(string type) => Claims.TryGetValue(type, out var value) ? value : null; + + public static CallerContext FromPrincipal(ClaimsPrincipal? principal, bool authenticationBypassed) + { + if (principal is null) + { + return new CallerContext { AuthenticationBypassed = authenticationBypassed }; + } + + var scopes = new HashSet(StringComparer.Ordinal); + foreach (var claim in principal.FindAll("scp").Concat(principal.FindAll("http://schemas.microsoft.com/identity/claims/scope"))) + { + foreach (var scope in claim.Value.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + scopes.Add(scope); + } + } + + var roles = new HashSet(StringComparer.Ordinal); + foreach (var claim in principal.FindAll("roles").Concat(principal.FindAll(ClaimTypes.Role))) + { + roles.Add(claim.Value); + } + + var claims = new Dictionary(StringComparer.Ordinal); + foreach (var claim in principal.Claims) + { + claims[claim.Type] = claim.Value; + } + + return new CallerContext + { + IsAuthenticated = principal.Identity?.IsAuthenticated ?? false, + AuthenticationBypassed = authenticationBypassed, + Scopes = scopes, + Roles = roles, + Claims = claims, + }; + } +} + +/// Result of an authorization decision. +public sealed record AuthorizationResult(bool Allowed, string? Error) +{ + public static readonly AuthorizationResult Success = new(true, null); + + public static AuthorizationResult Deny(string error) => new(false, error); +} + +/// +/// Evaluates per-tool authorization: scopes, roles, claim rules, and tenant/partition isolation. +/// Tenant identity is always taken from validated claims and enforced against caller-supplied +/// input, so a model cannot spoof another tenant. +/// +public static class AuthorizationEvaluator +{ + public static AuthorizationResult Authorize( + AuthorizationConfiguration? authorization, + CallerContext caller, + IReadOnlyDictionary input) + { + if (authorization is null) + { + return AuthorizationResult.Success; + } + + if (caller.AuthenticationBypassed) + { + // Local development bypass mirrors the server's existing DEV_BYPASS_AUTH behavior. + return AuthorizationResult.Success; + } + + var requiresIdentity = authorization.RequiredScopes is { Count: > 0 } + || authorization.RequiredRoles is { Count: > 0 } + || authorization.Claims is { Count: > 0 } + || !string.IsNullOrWhiteSpace(authorization.TenantClaim) + || authorization.PartitionKeyFromClaim is { Count: > 0 }; + + if (requiresIdentity && !caller.IsAuthenticated) + { + return AuthorizationResult.Deny("Authentication is required to invoke this tool."); + } + + if (authorization.RequiredScopes is { Count: > 0 } scopes) + { + var missing = scopes.Where(s => !caller.Scopes.Contains(s)).ToList(); + if (missing.Count > 0) + { + return AuthorizationResult.Deny($"Missing required scope(s): {string.Join(", ", missing)}."); + } + } + + if (authorization.RequiredRoles is { Count: > 0 } roles) + { + var missing = roles.Where(r => !caller.Roles.Contains(r)).ToList(); + if (missing.Count > 0) + { + return AuthorizationResult.Deny($"Missing required role(s): {string.Join(", ", missing)}."); + } + } + + if (authorization.Claims is { Count: > 0 } claimRules) + { + foreach (var (type, expected) in claimRules) + { + if (!string.Equals(caller.GetClaim(type), expected, StringComparison.Ordinal)) + { + return AuthorizationResult.Deny($"Claim '{type}' does not satisfy the required policy."); + } + } + } + + if (!string.IsNullOrWhiteSpace(authorization.TenantClaim) && !string.IsNullOrWhiteSpace(authorization.TenantField)) + { + var tenant = caller.GetClaim(authorization.TenantClaim!); + if (string.IsNullOrEmpty(tenant)) + { + return AuthorizationResult.Deny($"Tenant claim '{authorization.TenantClaim}' is missing."); + } + + var supplied = ReadString(input, authorization.TenantField!); + if (supplied is not null && !string.Equals(supplied, tenant, StringComparison.Ordinal)) + { + return AuthorizationResult.Deny("Tenant isolation violation: supplied tenant does not match the caller identity."); + } + } + + if (authorization.PartitionKeyFromClaim is { Count: > 0 } pkRules) + { + foreach (var (inputName, claimType) in pkRules) + { + var claimValue = caller.GetClaim(claimType); + if (string.IsNullOrEmpty(claimValue)) + { + return AuthorizationResult.Deny($"Required identity claim '{claimType}' is missing."); + } + + var supplied = ReadString(input, inputName); + if (supplied is not null && !string.Equals(supplied, claimValue, StringComparison.Ordinal)) + { + return AuthorizationResult.Deny($"Partition restriction violation for '{inputName}'."); + } + } + } + + return AuthorizationResult.Success; + } + + /// + /// Overlays identity-derived values onto the input so downstream binding always uses the trusted + /// tenant/partition value regardless of what the model supplied. + /// + public static Dictionary ApplyIdentityDerivedInputs( + AuthorizationConfiguration? authorization, + CallerContext caller, + IReadOnlyDictionary input) + { + var result = new Dictionary(input, StringComparer.Ordinal); + if (authorization is null || caller.AuthenticationBypassed || !caller.IsAuthenticated) + { + return result; + } + + if (!string.IsNullOrWhiteSpace(authorization.TenantClaim) && !string.IsNullOrWhiteSpace(authorization.TenantField)) + { + var tenant = caller.GetClaim(authorization.TenantClaim!); + if (!string.IsNullOrEmpty(tenant)) + { + result[authorization.TenantField!] = JsonValue.Create(tenant); + } + } + + if (authorization.PartitionKeyFromClaim is { Count: > 0 } pkRules) + { + foreach (var (inputName, claimType) in pkRules) + { + var claimValue = caller.GetClaim(claimType); + if (!string.IsNullOrEmpty(claimValue)) + { + result[inputName] = JsonValue.Create(claimValue); + } + } + } + + return result; + } + + private static string? ReadString(IReadOnlyDictionary input, string name) + { + if (input.TryGetValue(name, out var node) && node is JsonValue value && value.TryGetValue(out var s)) + { + return s; + } + + return null; + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/BindingContext.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/BindingContext.cs new file mode 100644 index 0000000..efeb8f9 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/BindingContext.cs @@ -0,0 +1,222 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +namespace AzureCosmosDB.MCP.Toolkit.Runtime; + +/// +/// Resolves runtime ${...} binding tokens against validated input, generated identifiers, +/// system values, and prior step outputs. +/// +/// +/// Bound values are always produced as typed values that are passed to +/// Cosmos DB as query parameters, ids, or partition keys — never concatenated into SQL text. +/// This is the core of the toolkit's injection resistance. +/// +public sealed partial class BindingContext +{ + [GeneratedRegex(@"\$\{([A-Za-z_][A-Za-z0-9_.\[\]]*)\}")] + private static partial Regex TokenRegex(); + + private readonly IReadOnlyDictionary _input; + private readonly Dictionary _generated = new(StringComparer.Ordinal); + private readonly Dictionary _steps = new(StringComparer.Ordinal); + private readonly string _utcNow; + + public BindingContext(IReadOnlyDictionary input, DateTimeOffset? now = null) + { + _input = input; + _utcNow = (now ?? DateTimeOffset.UtcNow).UtcDateTime.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ", CultureInfo.InvariantCulture); + } + + /// Records the output of a named step for later ${steps.id.path} references. + public void SetStepOutput(string stepId, JsonNode? output) => _steps[stepId] = output; + + /// Gets (or lazily creates) a stable generated identifier for the current invocation. + public string GetGenerated(string key) + { + if (!_generated.TryGetValue(key, out var value)) + { + value = Guid.NewGuid().ToString(); + _generated[key] = value; + } + + return value; + } + + /// Binds an arbitrary configuration value (string/number/object/array) into a resolved node. + public JsonNode? Bind(object? configValue) + { + var node = configValue switch + { + null => null, + JsonNode n => n.DeepClone(), + JsonElement e => JsonSerializer.SerializeToNode(e), + _ => JsonSerializer.SerializeToNode(configValue), + }; + + return BindNode(node); + } + + /// Binds a single template string, preserving type when it is exactly one token. + public JsonNode? BindTemplate(string? template) + { + if (template is null) + { + return null; + } + + var wholeMatch = TokenRegex().Match(template); + if (wholeMatch.Success && wholeMatch.Value.Length == template.Length) + { + return Resolve(wholeMatch.Groups[1].Value, out var found) + ?? (found ? null : JsonValue.Create(template)); + } + + var sb = new StringBuilder(); + var last = 0; + foreach (Match m in TokenRegex().Matches(template)) + { + sb.Append(template, last, m.Index - last); + var resolved = Resolve(m.Groups[1].Value, out _); + sb.Append(Stringify(resolved)); + last = m.Index + m.Length; + } + + sb.Append(template, last, template.Length - last); + return JsonValue.Create(sb.ToString()); + } + + /// Convenience helper for tokens that must resolve to a string (ids, partition keys). + public string? BindToString(string? template) + { + var node = BindTemplate(template); + return node is null ? null : Stringify(node); + } + + private JsonNode? BindNode(JsonNode? node) + { + switch (node) + { + case null: + return null; + case JsonValue value when value.TryGetValue(out var s) && s is not null: + return BindTemplate(s); + case JsonObject obj: + var newObj = new JsonObject(); + foreach (var (key, child) in obj) + { + newObj[key] = BindNode(child); + } + + return newObj; + case JsonArray arr: + var newArr = new JsonArray(); + foreach (var child in arr) + { + newArr.Add(BindNode(child)); + } + + return newArr; + default: + return node.DeepClone(); + } + } + + private JsonNode? Resolve(string path, out bool found) + { + found = true; + var segments = path.Split('.'); + var head = segments[0]; + + switch (head) + { + case "system": + if (segments.Length == 2 && segments[1] is "utcNow" or "utcnow") + { + return JsonValue.Create(_utcNow); + } + + found = false; + return null; + + case "generated": + if (segments.Length == 2) + { + return JsonValue.Create(GetGenerated(segments[1])); + } + + found = false; + return null; + + case "input": + return Navigate(ToObject(_input), segments.Skip(1), out found); + + case "steps": + if (segments.Length >= 2 && _steps.TryGetValue(segments[1], out var stepNode)) + { + return Navigate(stepNode, segments.Skip(2), out found); + } + + found = false; + return null; + + default: + // Bare token: look up directly in input. + return Navigate(ToObject(_input), segments, out found); + } + } + + private static JsonObject ToObject(IReadOnlyDictionary input) + { + var obj = new JsonObject(); + foreach (var (key, value) in input) + { + obj[key] = value?.DeepClone(); + } + + return obj; + } + + private static JsonNode? Navigate(JsonNode? node, IEnumerable segments, out bool found) + { + found = true; + var current = node; + foreach (var segment in segments) + { + if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next)) + { + current = next; + } + else + { + found = false; + return null; + } + } + + return current?.DeepClone(); + } + + private static string Stringify(JsonNode? node) + { + if (node is null) + { + return string.Empty; + } + + if (node is JsonValue value) + { + if (value.TryGetValue(out var s) && s is not null) + { + return s; + } + + return value.ToJsonString(); + } + + return node.ToJsonString(); + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredTool.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredTool.cs new file mode 100644 index 0000000..1bc6420 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredTool.cs @@ -0,0 +1,66 @@ +using System.Text.Json; +using AzureCosmosDB.MCP.Toolkit.Configuration; + +namespace AzureCosmosDB.MCP.Toolkit.Runtime; + +/// A validated, fully-resolved tool ready to be registered and executed. +public sealed class ConfiguredTool +{ + public required string Name { get; init; } + public string? Description { get; init; } + public required ToolConfiguration Config { get; init; } + public required string Database { get; init; } + public required GovernanceConfiguration Governance { get; init; } + public required JsonElement InputSchema { get; init; } + + private static readonly HashSet WriteTypes = new(StringComparer.OrdinalIgnoreCase) + { + "create", "replace", "patch", "delete", "transactional-batch", "sequence", + }; + + public bool IsWrite => Config.Operation is not null && WriteTypes.Contains(Config.Operation.Type); + + public bool IsDestructive => string.Equals(Config.Operation?.Type, "delete", StringComparison.OrdinalIgnoreCase); +} + +/// Builds the set of instances from a validated configuration. +public static class ConfiguredToolSet +{ + public static IReadOnlyList Build(ToolkitConfiguration config) + { + var tools = new List(); + + foreach (var (key, toolConfig) in config.Tools) + { + if (toolConfig.Enabled == false) + { + continue; + } + + var sourceName = toolConfig.Source ?? config.Defaults?.Source; + if (sourceName is null || !config.Sources.TryGetValue(sourceName, out var source)) + { + continue; + } + + var database = source.Database + ?? throw new InvalidOperationException($"Source '{sourceName}' used by tool '{key}' has no database configured."); + + var governance = (toolConfig.Governance ?? new GovernanceConfiguration()) + .MergedOver(config.Defaults?.Governance); + governance.ReadOnly ??= true; + + tools.Add(new ConfiguredTool + { + Name = toolConfig.Name ?? key, + Description = toolConfig.Description, + Config = toolConfig, + Database = database, + Governance = governance, + InputSchema = JsonSchemaGenerator.Generate(toolConfig.Input), + }); + } + + return tools; + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredToolExecutor.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredToolExecutor.cs new file mode 100644 index 0000000..0bcf7df --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredToolExecutor.cs @@ -0,0 +1,160 @@ +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Providers; +using Microsoft.Azure.Cosmos; + +namespace AzureCosmosDB.MCP.Toolkit.Runtime; + +/// Structured outcome of executing a configured tool. +public sealed record ConfiguredToolExecutionResult(string Json, bool IsError, string? Category); + +/// +/// The end-to-end execution pipeline for a configured tool: +/// input validation → authorization → identity overlay → binding → governed execution → +/// output shaping → telemetry. Never throws to the caller; all failures are returned as +/// structured, client-safe JSON. +/// +public sealed class ConfiguredToolExecutor +{ + private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false }; + + private readonly ICosmosGateway _gateway; + private readonly ILogger _logger; + + public ConfiguredToolExecutor(ICosmosGateway gateway, ILogger logger) + { + _gateway = gateway; + _logger = logger; + } + + public async Task ExecuteAsync( + ConfiguredTool tool, + IReadOnlyDictionary rawInput, + CallerContext caller, + CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + var category = "ok"; + try + { + var validation = InputValidator.Validate(tool.Config.Input, rawInput); + if (!validation.IsValid) + { + category = "validation"; + return Error(category, "Input validation failed.", validation.Errors); + } + + var auth = AuthorizationEvaluator.Authorize(tool.Config.Authorization, caller, validation.Values); + if (!auth.Allowed) + { + category = "authorization"; + return Error(category, auth.Error ?? "Not authorized."); + } + + var effectiveInput = AuthorizationEvaluator.ApplyIdentityDerivedInputs(tool.Config.Authorization, caller, validation.Values); + var context = new BindingContext(effectiveInput); + var executor = new OperationExecutor(_gateway); + + using var timeoutCts = CreateTimeoutScope(tool, cancellationToken, out var linkedToken); + + JsonNode? result; + try + { + result = await executor.ExecuteAsync(tool.Config.Operation!, tool.Database, tool.Governance, context, linkedToken); + } + catch (OperationCanceledException) when (timeoutCts is not null && timeoutCts.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + category = "timeout"; + return Error(category, $"Operation timed out after {tool.Governance.TimeoutMs}ms."); + } + + var shaped = OutputProjector.Apply(result, tool.Config.Output); + var json = shaped?.ToJsonString(JsonOptions) ?? "null"; + + _logger.LogInformation( + "Configured tool executed. Tool: {Tool} | Version: {Version} | Operation: {Operation} | Database: {Database} | Container: {Container} | LatencyMs: {Latency} | ResultCategory: {Category}", + tool.Name, tool.Config.Version ?? "n/a", tool.Config.Operation!.Type, tool.Database, tool.Config.Operation.Container ?? "n/a", stopwatch.ElapsedMilliseconds, category); + + return new ConfiguredToolExecutionResult(json, false, category); + } + catch (AssertionFailedException ex) + { + category = "assertion"; + return Error(category, ex.Message); + } + catch (BindingFailedException ex) + { + category = "binding"; + return Error(category, ex.Message); + } + catch (CosmosBatchException ex) + { + category = ex.StatusCode == System.Net.HttpStatusCode.PreconditionFailed ? "conflict" : "cosmos"; + return Error(category, ex.Message); + } + catch (CosmosException ex) + { + category = ex.StatusCode switch + { + System.Net.HttpStatusCode.NotFound => "not_found", + System.Net.HttpStatusCode.PreconditionFailed => "conflict", + System.Net.HttpStatusCode.Conflict => "conflict", + _ => "cosmos", + }; + _logger.LogWarning(ex, "Configured tool '{Tool}' Cosmos error {StatusCode}.", tool.Name, ex.StatusCode); + return Error(category, ex.Message, statusCode: (int)ex.StatusCode); + } + catch (Exception ex) + { + category = "internal"; + _logger.LogError(ex, "Configured tool '{Tool}' failed unexpectedly.", tool.Name); + return Error(category, "An internal error occurred while executing the tool."); + } + finally + { + stopwatch.Stop(); + } + } + + private static CancellationTokenSource? CreateTimeoutScope(ConfiguredTool tool, CancellationToken outer, out CancellationToken linked) + { + if (tool.Governance.TimeoutMs is int ms && ms > 0) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(outer); + cts.CancelAfter(ms); + linked = cts.Token; + return cts; + } + + linked = outer; + return null; + } + + private static ConfiguredToolExecutionResult Error(string category, string message, IEnumerable? details = null, int? statusCode = null) + { + var payload = new JsonObject + { + ["error"] = message, + ["category"] = category, + }; + + if (statusCode is int code) + { + payload["statusCode"] = code; + } + + if (details is not null) + { + var arr = new JsonArray(); + foreach (var d in details) + { + arr.Add(d); + } + + payload["details"] = arr; + } + + return new ConfiguredToolExecutionResult(payload.ToJsonString(JsonOptions), true, category); + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/InputValidator.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/InputValidator.cs new file mode 100644 index 0000000..c2c98dc --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/InputValidator.cs @@ -0,0 +1,293 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using AzureCosmosDB.MCP.Toolkit.Configuration; + +namespace AzureCosmosDB.MCP.Toolkit.Runtime; + +/// Result of validating and coercing tool input against its declared schema. +public sealed class InputValidationResult +{ + public bool IsValid => Errors.Count == 0; + public List Errors { get; } = new(); + + /// Validated, coerced, default-populated input values keyed by property name. + public Dictionary Values { get; } = new(StringComparer.Ordinal); +} + +/// +/// Validates caller-supplied arguments against a declarative input schema. +/// Produces structured, client-safe error messages and never throws on invalid input. +/// +public static class InputValidator +{ + public static InputValidationResult Validate(InputSchemaConfiguration? schema, IReadOnlyDictionary input) + { + var result = new InputValidationResult(); + + if (schema is null) + { + foreach (var (key, value) in input) + { + result.Values[key] = value?.DeepClone(); + } + + return result; + } + + var properties = schema.Properties ?? new Dictionary(); + var required = schema.Required ?? new List(); + + foreach (var name in required) + { + if (!input.TryGetValue(name, out var v) || v is null) + { + result.Errors.Add($"Missing required property '{name}'."); + } + } + + foreach (var (name, propSchema) in properties) + { + if (input.TryGetValue(name, out var value) && value is not null) + { + ValidateProperty(name, propSchema, value, result); + } + else if (propSchema.Default is not null) + { + result.Values[name] = JsonSerializer.SerializeToNode(propSchema.Default); + } + } + + // Reject unknown properties (closed schema) to mirror the built-in tools' behavior. + foreach (var (name, _) in input) + { + if (!properties.ContainsKey(name)) + { + result.Errors.Add($"Unknown property '{name}'."); + } + } + + return result; + } + + private static void ValidateProperty(string name, PropertySchema schema, JsonNode value, InputValidationResult result) + { + var beforeErrors = result.Errors.Count; + + switch (schema.Type.ToLowerInvariant()) + { + case "string": + if (TryGetString(value, out var s)) + { + ValidateString(name, schema, s, result); + if (result.Errors.Count == beforeErrors) + { + result.Values[name] = s; + } + } + else + { + result.Errors.Add($"Property '{name}' must be a string."); + } + break; + + case "integer": + if (TryGetInteger(value, out var l)) + { + ValidateNumber(name, schema, l, result); + if (result.Errors.Count == beforeErrors) + { + result.Values[name] = l; + } + } + else + { + result.Errors.Add($"Property '{name}' must be an integer."); + } + break; + + case "number": + if (TryGetNumber(value, out var d)) + { + ValidateNumber(name, schema, d, result); + if (result.Errors.Count == beforeErrors) + { + result.Values[name] = d; + } + } + else + { + result.Errors.Add($"Property '{name}' must be a number."); + } + break; + + case "boolean": + if (value is JsonValue bv && bv.TryGetValue(out var b)) + { + result.Values[name] = b; + } + else + { + result.Errors.Add($"Property '{name}' must be a boolean."); + } + break; + + case "array": + if (value is JsonArray arr) + { + ValidateArray(name, schema, arr, result); + if (result.Errors.Count == beforeErrors) + { + result.Values[name] = arr.DeepClone(); + } + } + else + { + result.Errors.Add($"Property '{name}' must be an array."); + } + break; + + case "object": + if (value is JsonObject obj) + { + if (schema.Properties is not null) + { + var nested = new InputSchemaConfiguration + { + Type = "object", + Required = schema.Required, + Properties = schema.Properties, + }; + var nestedInput = obj.ToDictionary(kvp => kvp.Key, kvp => kvp.Value); + var nestedResult = Validate(nested, nestedInput); + foreach (var err in nestedResult.Errors) + { + result.Errors.Add($"{name}.{err}"); + } + } + + if (result.Errors.Count == beforeErrors) + { + result.Values[name] = obj.DeepClone(); + } + } + else + { + result.Errors.Add($"Property '{name}' must be an object."); + } + break; + + default: + result.Values[name] = value.DeepClone(); + break; + } + + if (schema.Enum is { Count: > 0 } enumValues && result.Errors.Count == beforeErrors) + { + var allowed = enumValues.Select(e => JsonSerializer.Serialize(e)).ToHashSet(StringComparer.Ordinal); + var actual = value.ToJsonString(); + if (!allowed.Contains(actual)) + { + result.Errors.Add($"Property '{name}' must be one of: {string.Join(", ", enumValues)}."); + } + } + } + + private static void ValidateString(string name, PropertySchema schema, string value, InputValidationResult result) + { + if (schema.MinLength is int min && value.Length < min) + { + result.Errors.Add($"Property '{name}' must be at least {min} characters."); + } + + if (schema.MaxLength is int max && value.Length > max) + { + result.Errors.Add($"Property '{name}' must be at most {max} characters."); + } + + if (!string.IsNullOrEmpty(schema.Pattern) && !Regex.IsMatch(value, schema.Pattern)) + { + result.Errors.Add($"Property '{name}' does not match the required pattern."); + } + } + + private static void ValidateNumber(string name, PropertySchema schema, double value, InputValidationResult result) + { + if (schema.Minimum is double min && value < min) + { + result.Errors.Add($"Property '{name}' must be >= {min}."); + } + + if (schema.Maximum is double max && value > max) + { + result.Errors.Add($"Property '{name}' must be <= {max}."); + } + } + + private static void ValidateArray(string name, PropertySchema schema, JsonArray array, InputValidationResult result) + { + if (schema.MinItems is int min && array.Count < min) + { + result.Errors.Add($"Property '{name}' must contain at least {min} items."); + } + + if (schema.MaxItems is int max && array.Count > max) + { + result.Errors.Add($"Property '{name}' must contain at most {max} items."); + } + + if (schema.Items is not null) + { + for (var i = 0; i < array.Count; i++) + { + var item = array[i]; + if (item is not null) + { + ValidateProperty($"{name}[{i}]", schema.Items, item, result); + } + } + } + } + + private static bool TryGetString(JsonNode node, out string value) + { + if (node is JsonValue jv && jv.TryGetValue(out var s) && s is not null) + { + value = s; + return true; + } + + value = string.Empty; + return false; + } + + private static bool TryGetInteger(JsonNode node, out long value) + { + value = 0; + if (node is not JsonValue jv) + { + return false; + } + + if (jv.TryGetValue(out var l)) + { + value = l; + return true; + } + + // Reject fractional numbers and numeric strings so that types stay strict. + return false; + } + + private static bool TryGetNumber(JsonNode node, out double value) + { + value = 0; + if (node is JsonValue jv && jv.TryGetValue(out var d)) + { + value = d; + return true; + } + + return false; + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/JsonSchemaGenerator.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/JsonSchemaGenerator.cs new file mode 100644 index 0000000..3ae0a97 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/JsonSchemaGenerator.cs @@ -0,0 +1,129 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Configuration; + +namespace AzureCosmosDB.MCP.Toolkit.Runtime; + +/// +/// Generates a closed JSON Schema (additionalProperties: false) from a declarative input +/// schema, matching the discovery contract used by the built-in tools. +/// +public static class JsonSchemaGenerator +{ + public static JsonElement Generate(InputSchemaConfiguration? schema) + { + var root = BuildObjectSchema(schema?.Properties, schema?.Required); + return JsonSerializer.SerializeToElement(root); + } + + private static JsonObject BuildObjectSchema(Dictionary? properties, List? required) + { + var node = new JsonObject + { + ["type"] = "object", + ["additionalProperties"] = false, + }; + + var props = new JsonObject(); + if (properties is not null) + { + foreach (var (name, prop) in properties) + { + props[name] = BuildPropertySchema(prop); + } + } + + node["properties"] = props; + + if (required is { Count: > 0 }) + { + var req = new JsonArray(); + foreach (var name in required) + { + req.Add(name); + } + + node["required"] = req; + } + + return node; + } + + private static JsonObject BuildPropertySchema(PropertySchema prop) + { + var node = new JsonObject { ["type"] = prop.Type }; + + if (!string.IsNullOrWhiteSpace(prop.Description)) + { + node["description"] = prop.Description; + } + + if (prop.Enum is { Count: > 0 }) + { + var arr = new JsonArray(); + foreach (var e in prop.Enum) + { + arr.Add(JsonSerializer.SerializeToNode(e)); + } + + node["enum"] = arr; + } + + if (prop.Minimum is double min) + { + node["minimum"] = min; + } + + if (prop.Maximum is double max) + { + node["maximum"] = max; + } + + if (prop.MinLength is int minLen) + { + node["minLength"] = minLen; + } + + if (prop.MaxLength is int maxLen) + { + node["maxLength"] = maxLen; + } + + if (!string.IsNullOrWhiteSpace(prop.Pattern)) + { + node["pattern"] = prop.Pattern; + } + + if (prop.MinItems is int minItems) + { + node["minItems"] = minItems; + } + + if (prop.MaxItems is int maxItems) + { + node["maxItems"] = maxItems; + } + + if (string.Equals(prop.Type, "array", StringComparison.OrdinalIgnoreCase) && prop.Items is not null) + { + node["items"] = BuildPropertySchema(prop.Items); + } + + if (string.Equals(prop.Type, "object", StringComparison.OrdinalIgnoreCase)) + { + node["additionalProperties"] = false; + if (prop.Properties is not null) + { + var nested = new JsonObject(); + foreach (var (name, child) in prop.Properties) + { + nested[name] = BuildPropertySchema(child); + } + + node["properties"] = nested; + } + } + + return node; + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/OperationExecutor.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/OperationExecutor.cs new file mode 100644 index 0000000..41299a8 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/OperationExecutor.cs @@ -0,0 +1,230 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Configuration; +using AzureCosmosDB.MCP.Toolkit.Providers; + +namespace AzureCosmosDB.MCP.Toolkit.Runtime; + +/// +/// Binds a declarative operation against the current invocation context and executes it through the +/// shared . Governance limits (max items, topK, cross-partition) are +/// enforced here; timeouts are enforced by the caller via a linked cancellation token. +/// +public sealed class OperationExecutor +{ + private readonly ICosmosGateway _gateway; + + public OperationExecutor(ICosmosGateway gateway) => _gateway = gateway; + + public async Task ExecuteAsync( + OperationConfiguration op, + string database, + GovernanceConfiguration governance, + BindingContext context, + CancellationToken cancellationToken) + { + switch (op.Type.ToLowerInvariant()) + { + case "point-read": + return await _gateway.PointReadAsync( + database, op.Container!, RequireString(context, op.Id, "id"), RequireString(context, op.PartitionKey, "partitionKey"), cancellationToken); + + case "query": + return await ExecuteQueryAsync(op, database, governance, context, cancellationToken); + + case "text-search": + return await _gateway.TextSearchAsync( + database, op.Container!, op.Property!, RequireString(context, op.SearchText, "searchText"), + ResolveLimit(context, op.Limit, governance.MaxItems ?? 20, 20), cancellationToken); + + case "vector-search": + return await _gateway.VectorSearchAsync(BuildSearch(op, database, governance, context), cancellationToken); + + case "hybrid-search": + return await _gateway.HybridSearchAsync(BuildSearch(op, database, governance, context), cancellationToken); + + case "create": + return await _gateway.CreateAsync( + database, op.Container!, BuildDocument(context, op.Document), + RequireString(context, op.PartitionKey, "partitionKey"), cancellationToken); + + case "replace": + return await _gateway.ReplaceAsync( + database, op.Container!, RequireString(context, op.Id, "id"), BuildDocument(context, op.Document), + RequireString(context, op.PartitionKey, "partitionKey"), context.BindToString(op.Concurrency?.IfMatch), cancellationToken); + + case "patch": + return await _gateway.PatchAsync( + database, op.Container!, RequireString(context, op.Id, "id"), RequireString(context, op.PartitionKey, "partitionKey"), + ResolvePatchOperations(context, op.Operations!), context.BindToString(op.Concurrency?.IfMatch), cancellationToken); + + case "delete": + return await _gateway.DeleteAsync( + database, op.Container!, RequireString(context, op.Id, "id"), RequireString(context, op.PartitionKey, "partitionKey"), + context.BindToString(op.Concurrency?.IfMatch), cancellationToken); + + case "transactional-batch": + return await _gateway.TransactionalBatchAsync( + database, op.Container!, RequireString(context, op.PartitionKey, "partitionKey"), + ResolveBatchSteps(context, op.Steps!), cancellationToken); + + case "sequence": + return await ExecuteSequenceAsync(op, database, governance, context, cancellationToken); + + default: + throw new InvalidOperationException($"Unsupported operation type '{op.Type}'."); + } + } + + private async Task ExecuteQueryAsync(OperationConfiguration op, string database, GovernanceConfiguration governance, BindingContext context, CancellationToken cancellationToken) + { + var parameters = new Dictionary(StringComparer.Ordinal); + if (op.Parameters is not null) + { + foreach (var (name, template) in op.Parameters) + { + parameters[name] = context.BindTemplate(template); + } + } + + var request = new QueryRequest( + database, + op.Container!, + op.Statement!, + parameters, + context.BindToString(op.PartitionKey), + governance.MaxItems ?? 100, + governance.AllowCrossPartition ?? false); + + return await _gateway.QueryAsync(request, cancellationToken); + } + + private async Task ExecuteSequenceAsync(OperationConfiguration op, string database, GovernanceConfiguration governance, BindingContext context, CancellationToken cancellationToken) + { + JsonNode? last = null; + foreach (var step in op.Steps!) + { + var stepType = step.EffectiveType.ToLowerInvariant(); + if (stepType == "assert") + { + if (!SafeExpressionEvaluator.Evaluate(step.Expression ?? "false", context, out var exprError)) + { + throw new AssertionFailedException(step.Message ?? exprError ?? $"Assertion '{step.Id}' failed."); + } + + continue; + } + + var stepOp = new OperationConfiguration + { + Type = stepType, + Container = op.Container, + Id = step.ItemId, + PartitionKey = step.PartitionKey ?? op.PartitionKey, + Document = step.Document, + Operations = step.Operations, + Concurrency = step.Concurrency, + }; + + var result = await ExecuteAsync(stepOp, database, governance, context, cancellationToken); + context.SetStepOutput(step.Id, result); + last = result; + } + + return last; + } + + private static SearchRequest BuildSearch(OperationConfiguration op, string database, GovernanceConfiguration governance, BindingContext context) + => new( + database, + op.Container!, + RequireString(context, op.SearchText, "searchText"), + op.VectorPath!, + op.TextPath, + op.Select ?? new List(), + ResolveLimit(context, op.TopK, governance.MaxTopK ?? 50, governance.MaxTopK ?? 50), + context.BindToString(op.PartitionKey)); + + private static JsonObject BuildDocument(BindingContext context, Dictionary? document) + { + var result = new JsonObject(); + if (document is null) + { + return result; + } + + foreach (var (key, value) in document) + { + result[key] = context.Bind(value); + } + + return result; + } + + private static List ResolvePatchOperations(BindingContext context, List operations) + => operations + .Select(o => new ResolvedPatchOperation(o.Op, o.Path, o.Op.Equals("remove", StringComparison.OrdinalIgnoreCase) ? null : context.Bind(o.Value))) + .ToList(); + + private static List ResolveBatchSteps(BindingContext context, List steps) + => steps.Select(s => new ResolvedBatchStep( + s.Id, + s.EffectiveType, + context.BindToString(s.ItemId), + s.Document is null ? null : BuildDocument(context, s.Document), + s.Operations is null ? null : ResolvePatchOperations(context, s.Operations))).ToList(); + + private static string RequireString(BindingContext context, string? template, string field) + { + var value = context.BindToString(template); + if (string.IsNullOrEmpty(value)) + { + throw new BindingFailedException($"Could not resolve a value for '{field}'."); + } + + return value; + } + + private static int ResolveLimit(BindingContext context, string? template, int fallback, int max) + { + var resolved = fallback; + if (!string.IsNullOrWhiteSpace(template)) + { + var node = context.BindTemplate(template); + if (node is JsonValue jv) + { + if (jv.TryGetValue(out var i)) + { + resolved = i; + } + else if (jv.TryGetValue(out var s) && int.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var parsed)) + { + resolved = parsed; + } + } + } + + if (resolved < 1) + { + resolved = 1; + } + + return Math.Min(resolved, max); + } +} + +/// Raised when a bounded-composition assertion fails. +public sealed class AssertionFailedException : Exception +{ + public AssertionFailedException(string message) : base(message) + { + } +} + +/// Raised when a required binding value cannot be resolved. +public sealed class BindingFailedException : Exception +{ + public BindingFailedException(string message) : base(message) + { + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/OutputProjector.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/OutputProjector.cs new file mode 100644 index 0000000..77f6892 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/OutputProjector.cs @@ -0,0 +1,102 @@ +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Configuration; + +namespace AzureCosmosDB.MCP.Toolkit.Runtime; + +/// +/// Applies declarative output shaping: projection, renaming, nested output, redaction, and limits. +/// Operates on a single object or an array of objects. +/// +public static class OutputProjector +{ + public static JsonNode? Apply(JsonNode? result, OutputConfiguration? output) + { + if (output is null || result is null) + { + return result; + } + + if (result is JsonArray array) + { + var projected = new JsonArray(); + var count = 0; + foreach (var item in array) + { + if (output.MaxItems is int max && count >= max) + { + break; + } + + projected.Add(ApplyToObject(item, output)); + count++; + } + + return projected; + } + + return ApplyToObject(result, output); + } + + private static JsonNode? ApplyToObject(JsonNode? node, OutputConfiguration output) + { + if (node is not JsonObject obj) + { + return node?.DeepClone(); + } + + JsonObject shaped; + if (output.Select is { Count: > 0 } select) + { + shaped = new JsonObject(); + foreach (var (outputName, sourcePath) in select) + { + var value = ReadPath(obj, sourcePath); + shaped[outputName] = value?.DeepClone(); + } + } + else + { + shaped = (JsonObject)obj.DeepClone(); + } + + if (output.Redact is { Count: > 0 } redact) + { + foreach (var field in redact) + { + RemovePath(shaped, field); + } + } + + return shaped; + } + + private static JsonNode? ReadPath(JsonObject root, string path) + { + JsonNode? current = root; + foreach (var segment in path.Split('.')) + { + if (current is JsonObject obj && obj.TryGetPropertyValue(segment, out var next)) + { + current = next; + } + else + { + return null; + } + } + + return current; + } + + private static void RemovePath(JsonObject root, string path) + { + var segments = path.Split('.'); + JsonObject? current = root; + for (var i = 0; i < segments.Length - 1 && current is not null; i++) + { + current = current.TryGetPropertyValue(segments[i], out var next) ? next as JsonObject : null; + } + + current?.Remove(segments[^1]); + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/SafeExpressionEvaluator.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/SafeExpressionEvaluator.cs new file mode 100644 index 0000000..445ad4d --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/SafeExpressionEvaluator.cs @@ -0,0 +1,178 @@ +using System.Globalization; +using System.Text.Json.Nodes; + +namespace AzureCosmosDB.MCP.Toolkit.Runtime; + +/// +/// A deliberately minimal, safe boolean expression evaluator for bounded-composition assertions. +/// +/// +/// Supports only comparisons (== != > >= < <=) combined with && / ||. +/// Operands are numeric literals, single-quoted string literals, boolean literals, or binding paths +/// (for example input.amount or steps.source.balance). There are no function calls, +/// loops, assignments, or arbitrary code — the engine cannot be used as a scripting runtime. +/// +public static class SafeExpressionEvaluator +{ + public static bool Evaluate(string expression, BindingContext context, out string? error) + { + error = null; + var expr = expression.Trim(); + if (expr.StartsWith("${", StringComparison.Ordinal) && expr.EndsWith('}')) + { + expr = expr[2..^1].Trim(); + } + + try + { + return EvaluateOr(expr, context); + } + catch (Exception ex) + { + error = $"Invalid assertion expression '{expression}': {ex.Message}"; + return false; + } + } + + private static bool EvaluateOr(string expr, BindingContext context) + { + var parts = SplitTopLevel(expr, "||"); + if (parts.Count > 1) + { + return parts.Any(p => EvaluateAnd(p, context)); + } + + return EvaluateAnd(expr, context); + } + + private static bool EvaluateAnd(string expr, BindingContext context) + { + var parts = SplitTopLevel(expr, "&&"); + if (parts.Count > 1) + { + return parts.All(p => EvaluateComparison(p, context)); + } + + return EvaluateComparison(expr, context); + } + + private static readonly string[] Operators = { ">=", "<=", "==", "!=", ">", "<" }; + + private static bool EvaluateComparison(string expr, BindingContext context) + { + expr = expr.Trim(); + + foreach (var op in Operators) + { + var idx = expr.IndexOf(op, StringComparison.Ordinal); + if (idx > 0) + { + var left = ResolveOperand(expr[..idx].Trim(), context); + var right = ResolveOperand(expr[(idx + op.Length)..].Trim(), context); + return Compare(left, right, op); + } + } + + // A bare boolean operand. + var single = ResolveOperand(expr, context); + return single is JsonValue jv && jv.TryGetValue(out var b) && b; + } + + private static JsonNode? ResolveOperand(string operand, BindingContext context) + { + if (operand.Length == 0) + { + throw new FormatException("empty operand"); + } + + if (operand.StartsWith('\'') && operand.EndsWith('\'') && operand.Length >= 2) + { + return JsonValue.Create(operand[1..^1]); + } + + if (double.TryParse(operand, NumberStyles.Any, CultureInfo.InvariantCulture, out var number)) + { + return JsonValue.Create(number); + } + + if (bool.TryParse(operand, out var boolean)) + { + return JsonValue.Create(boolean); + } + + return context.BindTemplate("${" + operand + "}"); + } + + private static bool Compare(JsonNode? left, JsonNode? right, string op) + { + if (TryGetDouble(left, out var l) && TryGetDouble(right, out var r)) + { + return op switch + { + ">=" => l >= r, + "<=" => l <= r, + ">" => l > r, + "<" => l < r, + "==" => l == r, + "!=" => l != r, + _ => false, + }; + } + + var ls = left?.ToJsonString(); + var rs = right?.ToJsonString(); + return op switch + { + "==" => ls == rs, + "!=" => ls != rs, + _ => throw new FormatException($"operator '{op}' requires numeric operands"), + }; + } + + private static bool TryGetDouble(JsonNode? node, out double value) + { + value = 0; + if (node is JsonValue jv) + { + if (jv.TryGetValue(out value)) + { + return true; + } + + if (jv.TryGetValue(out var s) && double.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out value)) + { + return true; + } + } + + return false; + } + + private static List SplitTopLevel(string expr, string separator) + { + var parts = new List(); + var depth = 0; + var start = 0; + for (var i = 0; i < expr.Length; i++) + { + var c = expr[i]; + if (c is '(' ) + { + depth++; + } + else if (c is ')') + { + depth--; + } + else if (depth == 0 && i + separator.Length <= expr.Length && expr.Substring(i, separator.Length) == separator) + { + parts.Add(expr[start..i].Trim()); + i += separator.Length - 1; + start = i + 1; + } + } + + parts.Add(expr[start..].Trim()); + return parts; + } +} From 01c22a4674c65dd87265c23439481d77a10e1d28 Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Wed, 5 Aug 2026 16:59:55 +0100 Subject: [PATCH 04/11] feat(mcp): register configured business tools as opt-in MCP tools with custom schemas --- .../Mcp/ConfiguredMcpFunction.cs | 70 ++++++++++++++ .../Mcp/ConfiguredToolsRegistration.cs | 93 +++++++++++++++++++ src/AzureCosmosDB.MCP.Toolkit/Program.cs | 12 ++- 3 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredMcpFunction.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredToolsRegistration.cs diff --git a/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredMcpFunction.cs b/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredMcpFunction.cs new file mode 100644 index 0000000..4db3f8a --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredMcpFunction.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Providers; +using AzureCosmosDB.MCP.Toolkit.Runtime; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.AI; + +namespace AzureCosmosDB.MCP.Toolkit.Mcp; + +/// +/// Adapts a to a Microsoft.Extensions.AI with a +/// fully custom (closed) input schema. Services are resolved per-invocation from +/// , so the same tool instance is safe as a singleton. +/// +public sealed class ConfiguredMcpFunction : AIFunction +{ + private readonly ConfiguredTool _tool; + + public ConfiguredMcpFunction(ConfiguredTool tool) => _tool = tool; + + public override string Name => _tool.Name; + + public override string Description => _tool.Description ?? string.Empty; + + public override JsonElement JsonSchema => _tool.InputSchema; + + protected override async ValueTask InvokeCoreAsync(AIFunctionArguments arguments, CancellationToken cancellationToken) + { + var services = arguments.Services + ?? throw new InvalidOperationException("Configured tools require a request service provider."); + + var gateway = (ICosmosGateway?)services.GetService(typeof(ICosmosGateway)) + ?? throw new InvalidOperationException("ICosmosGateway is not registered."); + var loggerFactory = (ILoggerFactory?)services.GetService(typeof(ILoggerFactory)); + var logger = loggerFactory?.CreateLogger() + ?? Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + + var caller = ResolveCaller(services); + var input = Normalize(arguments); + + var executor = new ConfiguredToolExecutor(gateway, logger); + var result = await executor.ExecuteAsync(_tool, input, caller, cancellationToken); + return result.Json; + } + + private static CallerContext ResolveCaller(IServiceProvider services) + { + var bypass = Environment.GetEnvironmentVariable("DEV_BYPASS_AUTH") == "true"; + var httpContextAccessor = (IHttpContextAccessor?)services.GetService(typeof(IHttpContextAccessor)); + var user = httpContextAccessor?.HttpContext?.User; + return CallerContext.FromPrincipal(user, bypass); + } + + private static Dictionary Normalize(AIFunctionArguments arguments) + { + var result = new Dictionary(StringComparer.Ordinal); + foreach (var (key, value) in arguments) + { + result[key] = value switch + { + null => null, + JsonNode node => node.DeepClone(), + JsonElement element => JsonSerializer.SerializeToNode(element), + _ => JsonSerializer.SerializeToNode(value), + }; + } + + return result; + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredToolsRegistration.cs b/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredToolsRegistration.cs new file mode 100644 index 0000000..46440bb --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredToolsRegistration.cs @@ -0,0 +1,93 @@ +using AzureCosmosDB.MCP.Toolkit.Configuration; +using AzureCosmosDB.MCP.Toolkit.Providers; +using AzureCosmosDB.MCP.Toolkit.Runtime; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Server; + +namespace AzureCosmosDB.MCP.Toolkit.Mcp; + +/// +/// Opt-in registration for the declarative, business-facing tool layer (vNext). +/// If no configuration file is present the toolkit behaves exactly as before — this method is a no-op. +/// +public static class ConfiguredToolsRegistration +{ + /// Environment variable / configuration key that points at the declarative config file. + public const string ConfigPathEnvironmentVariable = "COSMOS_TOOLS_CONFIG"; + + public static IMcpServerBuilder AddConfiguredCosmosTools( + this IMcpServerBuilder builder, + IConfiguration configuration, + ILogger? logger = null) + { + var path = ResolveConfigPath(configuration); + if (string.IsNullOrWhiteSpace(path)) + { + logger?.LogInformation("No declarative tool configuration provided; only built-in GA tools are active."); + return builder; + } + + if (!File.Exists(path)) + { + // A path was explicitly requested but is missing: fail closed rather than silently ignore. + throw new InvalidOperationException($"Declarative tool configuration '{path}' was specified but not found."); + } + + var loader = new ConfigurationLoader(); + var result = loader.LoadFromFile(path); + + foreach (var warning in result.Warnings) + { + logger?.LogWarning("Tool configuration warning: {Warning}", warning); + } + + if (!result.IsValid) + { + // Fail closed: do not start with an invalid declarative configuration. + throw new InvalidOperationException( + $"Declarative tool configuration '{path}' is invalid:{Environment.NewLine}" + + string.Join(Environment.NewLine, result.Errors)); + } + + var tools = ConfiguredToolSet.Build(result.Configuration!); + logger?.LogInformation("Loaded {Count} configured business tool(s) from '{Path}'.", tools.Count, path); + + // Shared provider surface used by the configured tools. + builder.Services.AddSingleton(); + + var serverTools = tools.Select(CreateServerTool).ToList(); + if (serverTools.Count > 0) + { + builder.WithTools(serverTools); + } + + return builder; + } + + private static McpServerTool CreateServerTool(ConfiguredTool tool) + { + var function = new ConfiguredMcpFunction(tool); + var options = new McpServerToolCreateOptions + { + Name = tool.Name, + Description = tool.Description, + ReadOnly = !tool.IsWrite, + Destructive = tool.IsDestructive, + Idempotent = !tool.IsWrite, + }; + + return McpServerTool.Create(function, options); + } + + private static string? ResolveConfigPath(IConfiguration configuration) + { + var fromEnv = Environment.GetEnvironmentVariable(ConfigPathEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(fromEnv)) + { + return fromEnv; + } + + var fromConfig = configuration["CosmosMcp:ToolsConfigPath"]; + return string.IsNullOrWhiteSpace(fromConfig) ? null : fromConfig; + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Program.cs b/src/AzureCosmosDB.MCP.Toolkit/Program.cs index 71da958..2d790ad 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Program.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Program.cs @@ -13,6 +13,7 @@ using System.IdentityModel.Tokens.Jwt; using System.Text; using AzureCosmosDB.MCP.Toolkit.Services; +using AzureCosmosDB.MCP.Toolkit.Mcp; var builder = WebApplication.CreateBuilder(args); @@ -227,10 +228,19 @@ // Register MCP server with SDK transport (SSE + Streamable HTTP) for AI Foundry and other MCP clients. // Tools are defined below using [McpServerTool] attributes on CosmosDbMcpTools class. -builder.Services.AddMcpServer() +var mcpServerBuilder = builder.Services.AddMcpServer() .WithHttpTransport() .WithToolsFromAssembly(); +// Additive, opt-in declarative business-facing tools (vNext). +// This is a no-op unless COSMOS_TOOLS_CONFIG (or CosmosMcp:ToolsConfigPath) points at a config file, +// so existing GA deployments are completely unaffected. +using (var startupLoggerFactory = LoggerFactory.Create(logging => logging.AddConsole())) +{ + var startupLogger = startupLoggerFactory.CreateLogger("ConfiguredTools"); + mcpServerBuilder.AddConfiguredCosmosTools(builder.Configuration, startupLogger); +} + // Configure forwarded headers for proxy scenarios builder.Services.Configure(options => { From 0d9e669541c834b6d3728b829c48fdb0098f758b Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Wed, 5 Aug 2026 17:06:03 +0100 Subject: [PATCH 05/11] test: add configuration runtime unit tests (parse, validation, binding, injection, projection, authorization, batch) --- .../Configured/ConfigurationLoaderTests.cs | 113 +++++++ .../Configured/ConfigurationValidatorTests.cs | 158 ++++++++++ .../Configured/ConfiguredToolExecutorTests.cs | 290 ++++++++++++++++++ .../Configured/FakeCosmosGateway.cs | 114 +++++++ .../Configured/RuntimeTests.cs | 224 ++++++++++++++ 5 files changed, 899 insertions(+) create mode 100644 tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfigurationLoaderTests.cs create mode 100644 tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfigurationValidatorTests.cs create mode 100644 tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfiguredToolExecutorTests.cs create mode 100644 tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/FakeCosmosGateway.cs create mode 100644 tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/RuntimeTests.cs diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfigurationLoaderTests.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfigurationLoaderTests.cs new file mode 100644 index 0000000..9db5b27 --- /dev/null +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfigurationLoaderTests.cs @@ -0,0 +1,113 @@ +using AzureCosmosDB.MCP.Toolkit.Configuration; +using FluentAssertions; +using Xunit; + +namespace AzureCosmosDB.MCP.Toolkit.Tests.Configured; + +public class ConfigurationLoaderTests +{ + private static ConfigurationLoader Loader(Dictionary? env = null) + => new(env ?? new Dictionary(StringComparer.Ordinal)); + + private const string BankingYaml = """ +version: "1.0" +sources: + banking: + type: cosmos + endpoint: "${COSMOS_ENDPOINT}" + database: banking + authentication: + type: managed-identity +defaults: + governance: + timeoutMs: 5000 + maxItems: 100 + readOnly: true +tools: + get_account_balance: + description: Returns the current balance for an account. + source: banking + operation: + type: point-read + container: accounts + id: "${accountId}" + partitionKey: "${customerId}" + input: + type: object + required: [customerId, accountId] + properties: + customerId: + type: string + accountId: + type: string + output: + select: + accountId: accountId + balance: balance +"""; + + [Fact] + public void Loads_valid_yaml_and_substitutes_environment() + { + var loader = Loader(new Dictionary(StringComparer.Ordinal) { ["COSMOS_ENDPOINT"] = "https://acct.documents.azure.com/" }); + + var result = loader.LoadFromText(BankingYaml); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + result.Configuration!.Version.Should().Be("1.0"); + result.Configuration.Sources["banking"].Endpoint.Should().Be("https://acct.documents.azure.com/"); + result.Configuration.Tools.Should().ContainKey("get_account_balance"); + } + + [Fact] + public void Preserves_runtime_binding_tokens_that_are_not_environment_variables() + { + var loader = Loader(new Dictionary(StringComparer.Ordinal) { ["COSMOS_ENDPOINT"] = "https://acct/" }); + + var result = loader.LoadFromText(BankingYaml); + + result.Configuration!.Tools["get_account_balance"].Operation!.Id.Should().Be("${accountId}"); + } + + [Fact] + public void Explicit_env_token_missing_is_an_error() + { + var loader = Loader(); + var yaml = BankingYaml.Replace("${COSMOS_ENDPOINT}", "${env:COSMOS_ENDPOINT}"); + + var result = loader.LoadFromText(yaml); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainMatch("*COSMOS_ENDPOINT*not set*"); + } + + [Fact] + public void Loads_equivalent_json() + { + var loader = Loader(new Dictionary(StringComparer.Ordinal) { ["COSMOS_ENDPOINT"] = "https://acct/" }); + const string json = """ + { + "version": "1.0", + "sources": { "banking": { "type": "cosmos", "endpoint": "https://acct/", "database": "banking" } }, + "tools": { + "get_account_balance": { + "description": "d", + "source": "banking", + "operation": { "type": "point-read", "container": "accounts", "id": "${accountId}", "partitionKey": "${customerId}" } + } + } + } + """; + + var result = loader.LoadFromText(json, isJson: true); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + result.Configuration!.Tools["get_account_balance"].Operation!.Container.Should().Be("accounts"); + } + + [Fact] + public void Empty_document_fails_closed() + { + Loader().LoadFromText(" ").IsValid.Should().BeFalse(); + } +} diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfigurationValidatorTests.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfigurationValidatorTests.cs new file mode 100644 index 0000000..0973f62 --- /dev/null +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfigurationValidatorTests.cs @@ -0,0 +1,158 @@ +using AzureCosmosDB.MCP.Toolkit.Configuration; +using FluentAssertions; +using Xunit; + +namespace AzureCosmosDB.MCP.Toolkit.Tests.Configured; + +public class ConfigurationValidatorTests +{ + private static ConfigurationLoadResult Load(string yaml) + => new ConfigurationLoader(new Dictionary(StringComparer.Ordinal) { ["COSMOS_ENDPOINT"] = "https://acct/" }) + .LoadFromText(yaml); + + private const string Header = """ +version: "1.0" +sources: + banking: + type: cosmos + endpoint: "${COSMOS_ENDPOINT}" + database: banking +tools: + +"""; + + [Fact] + public void Write_operation_without_readOnly_false_fails_closed() + { + var yaml = Header + """ + create_account: + description: create + source: banking + operation: + type: create + container: accounts + partitionKey: "${customerId}" + document: + id: "${generated.id}" +"""; + + var result = Load(yaml); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainMatch("*readOnly is not disabled*"); + } + + [Fact] + public void Write_operation_with_readOnly_false_is_allowed() + { + var yaml = Header + """ + create_account: + description: create + source: banking + governance: + readOnly: false + operation: + type: create + container: accounts + partitionKey: "${customerId}" + document: + id: "${generated.id}" +"""; + + Load(yaml).IsValid.Should().BeTrue(); + } + + [Fact] + public void Delete_requires_allowDelete() + { + var yaml = Header + """ + remove_it: + description: d + source: banking + governance: + readOnly: false + operation: + type: delete + container: accounts + id: "${id}" + partitionKey: "${customerId}" +"""; + + var result = Load(yaml); + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainMatch("*allowDelete*"); + } + + [Fact] + public void Unknown_operation_type_fails() + { + var yaml = Header + """ + weird: + description: d + source: banking + operation: + type: teleport + container: accounts +"""; + + Load(yaml).Errors.Should().ContainMatch("*unknown operation type*"); + } + + [Fact] + public void Vector_search_requires_explicit_select() + { + var yaml = Header + """ + search_offers: + description: d + source: banking + operation: + type: vector-search + container: offers + vectorPath: /embedding + searchText: "${query}" +"""; + + Load(yaml).Errors.Should().ContainMatch("*requires an explicit 'select'*"); + } + + [Fact] + public void Patch_path_outside_allowlist_is_rejected() + { + var yaml = Header + """ + patch_it: + description: d + source: banking + governance: + readOnly: false + allowedPatchPaths: ["/balance"] + operation: + type: patch + container: accounts + id: "${id}" + partitionKey: "${customerId}" + operations: + - op: replace + path: /creditLimit + value: "${x}" +"""; + + Load(yaml).Errors.Should().ContainMatch("*not in governance.allowedPatchPaths*"); + } + + [Fact] + public void Unknown_source_is_rejected() + { + var yaml = Header + """ + t: + description: d + source: nope + operation: + type: point-read + container: accounts + id: "${id}" + partitionKey: "${pk}" +"""; + + Load(yaml).Errors.Should().ContainMatch("*unknown source*"); + } +} diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfiguredToolExecutorTests.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfiguredToolExecutorTests.cs new file mode 100644 index 0000000..0b0a0ba --- /dev/null +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfiguredToolExecutorTests.cs @@ -0,0 +1,290 @@ +using System.Security.Claims; +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Configuration; +using AzureCosmosDB.MCP.Toolkit.Runtime; +using FluentAssertions; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AzureCosmosDB.MCP.Toolkit.Tests.Configured; + +public class ConfiguredToolExecutorTests +{ + private static ConfiguredTool BuildTool(string yaml, string toolKey) + { + var loader = new ConfigurationLoader(new Dictionary(StringComparer.Ordinal) { ["COSMOS_ENDPOINT"] = "https://acct/" }); + var result = loader.LoadFromText(yaml); + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + return ConfiguredToolSet.Build(result.Configuration!).Single(t => t.Name == toolKey); + } + + private static Dictionary Input(params (string, JsonNode?)[] items) + => items.ToDictionary(i => i.Item1, i => i.Item2, StringComparer.Ordinal); + + private static CallerContext Bypass => new() { AuthenticationBypassed = true }; + + private const string PointReadYaml = """ +version: "1.0" +sources: + banking: { type: cosmos, endpoint: "${COSMOS_ENDPOINT}", database: banking } +tools: + get_account_balance: + description: balance + source: banking + operation: + type: point-read + container: accounts + id: "${accountId}" + partitionKey: "${customerId}" + input: + type: object + required: [customerId, accountId] + properties: + customerId: { type: string } + accountId: { type: string } + output: + select: + accountId: accountId + availableBalance: balance + redact: [internalRiskScore] +"""; + + [Fact] + public async Task Point_read_binds_id_and_partition_and_projects_output() + { + var tool = BuildTool(PointReadYaml, "get_account_balance"); + var gateway = new FakeCosmosGateway + { + PointReadResult = new JsonObject { ["accountId"] = "A1", ["balance"] = 250.0, ["internalRiskScore"] = 9 }, + }; + var executor = new ConfiguredToolExecutor(gateway, NullLogger.Instance); + + var result = await executor.ExecuteAsync(tool, Input(("customerId", JsonValue.Create("C1")), ("accountId", JsonValue.Create("A1"))), Bypass, default); + + result.IsError.Should().BeFalse(); + gateway.LastId.Should().Be("A1"); + gateway.LastPartitionKey.Should().Be("C1"); + var node = JsonNode.Parse(result.Json)!; + node["availableBalance"]!.GetValue().Should().Be(250.0); + node.AsObject().ContainsKey("internalRiskScore").Should().BeFalse(); + } + + [Fact] + public async Task Invalid_input_returns_structured_validation_error() + { + var tool = BuildTool(PointReadYaml, "get_account_balance"); + var executor = new ConfiguredToolExecutor(new FakeCosmosGateway(), NullLogger.Instance); + + var result = await executor.ExecuteAsync(tool, Input(("accountId", JsonValue.Create("A1"))), Bypass, default); + + result.IsError.Should().BeTrue(); + result.Category.Should().Be("validation"); + result.Json.Should().Contain("customerId"); + } + + private const string QueryYaml = """ +version: "1.0" +sources: + banking: { type: cosmos, endpoint: "${COSMOS_ENDPOINT}", database: banking } +tools: + get_transaction_history: + description: history + source: banking + operation: + type: query + container: transactions + statement: "SELECT TOP @limit c.id, c.amount FROM c WHERE c.accountId = @accountId ORDER BY c.timestamp DESC" + parameters: + accountId: "${accountId}" + limit: "${limit}" + partitionKey: "${accountId}" + input: + type: object + required: [accountId] + properties: + accountId: { type: string } + limit: { type: integer, default: 10, minimum: 1, maximum: 50 } +"""; + + [Fact] + public async Task Query_binds_parameters_safely_including_injection_attempt() + { + var tool = BuildTool(QueryYaml, "get_transaction_history"); + var gateway = new FakeCosmosGateway { QueryResult = new JsonArray(new JsonObject { ["id"] = "t1" }) }; + var executor = new ConfiguredToolExecutor(gateway, NullLogger.Instance); + var evil = "A1' OR '1'='1"; + + var result = await executor.ExecuteAsync(tool, Input(("accountId", JsonValue.Create(evil))), Bypass, default); + + result.IsError.Should().BeFalse(); + // The statement text is untouched; the malicious value is bound as a parameter value only. + gateway.LastQuery!.Statement.Should().NotContain(evil); + gateway.LastQuery.Parameters["accountId"]!.GetValue().Should().Be(evil); + gateway.LastQuery.Parameters["limit"]!.GetValue().Should().Be(10); + } + + private const string TransferYaml = """ +version: "1.0" +sources: + banking: { type: cosmos, endpoint: "${COSMOS_ENDPOINT}", database: banking } +tools: + bank_transfer: + description: transfer within a customer's accounts + source: banking + governance: + readOnly: false + operation: + type: transactional-batch + container: accounts + partitionKey: "${customerId}" + steps: + - id: debit + type: patch + itemId: "${sourceAccountId}" + operations: + - op: increment + path: /balance + value: "${negativeAmount}" + - id: credit + type: patch + itemId: "${destinationAccountId}" + operations: + - op: increment + path: /balance + value: "${amount}" + - id: record + type: create + document: + id: "${generated.transactionId}" + customerId: "${customerId}" + amount: "${amount}" + createdAt: "${system.utcNow}" + input: + type: object + required: [customerId, sourceAccountId, destinationAccountId, amount, negativeAmount] + properties: + customerId: { type: string } + sourceAccountId: { type: string } + destinationAccountId: { type: string } + amount: { type: number } + negativeAmount: { type: number } +"""; + + [Fact] + public async Task Transactional_batch_binds_all_steps_within_one_partition() + { + var tool = BuildTool(TransferYaml, "bank_transfer"); + var gateway = new FakeCosmosGateway(); + var executor = new ConfiguredToolExecutor(gateway, NullLogger.Instance); + + var result = await executor.ExecuteAsync(tool, Input( + ("customerId", JsonValue.Create("C1")), + ("sourceAccountId", JsonValue.Create("A1")), + ("destinationAccountId", JsonValue.Create("A2")), + ("amount", JsonValue.Create(100.0)), + ("negativeAmount", JsonValue.Create(-100.0))), Bypass, default); + + result.IsError.Should().BeFalse(result.Json); + gateway.LastPartitionKey.Should().Be("C1"); + gateway.LastBatch!.Should().HaveCount(3); + gateway.LastBatch![0].ItemId.Should().Be("A1"); + gateway.LastBatch![1].ItemId.Should().Be("A2"); + gateway.LastBatch![2].Type.Should().Be("create"); + gateway.LastBatch![2].Document!["customerId"]!.GetValue().Should().Be("C1"); + } + + [Fact] + public void Read_only_default_blocks_writes_at_validation_time() + { + // The same transfer without governance.readOnly:false must not load as a valid tool. + const string yaml = """ +version: "1.0" +sources: + banking: { type: cosmos, endpoint: "${COSMOS_ENDPOINT}", database: banking } +tools: + bank_transfer: + description: transfer + source: banking + operation: + type: transactional-batch + container: accounts + partitionKey: "${customerId}" + steps: + - id: record + type: create + document: + id: "${generated.transactionId}" +"""; + var loader = new ConfigurationLoader(new Dictionary(StringComparer.Ordinal) { ["COSMOS_ENDPOINT"] = "https://acct/" }); + var result = loader.LoadFromText(yaml); + + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainMatch("*readOnly is not disabled*"); + } +} + +public class AuthorizationEvaluatorTests +{ + private static Dictionary Input(params (string, JsonNode?)[] items) + => items.ToDictionary(i => i.Item1, i => i.Item2, StringComparer.Ordinal); + + private static CallerContext Caller(bool authenticated, IEnumerable? scopes = null, (string, string)[]? claims = null) + { + var identity = new ClaimsIdentity(authenticated ? "test" : null); + if (scopes is not null) + { + identity.AddClaim(new Claim("scp", string.Join(' ', scopes))); + } + + foreach (var (t, v) in claims ?? Array.Empty<(string, string)>()) + { + identity.AddClaim(new Claim(t, v)); + } + + return CallerContext.FromPrincipal(new ClaimsPrincipal(identity), authenticationBypassed: false); + } + + [Fact] + public void Missing_scope_is_denied() + { + var auth = new AuthorizationConfiguration { RequiredScopes = new() { "banking.accounts.read" } }; + var result = AuthorizationEvaluator.Authorize(auth, Caller(true, scopes: new[] { "other.scope" }), Input()); + result.Allowed.Should().BeFalse(); + result.Error.Should().Contain("scope"); + } + + [Fact] + public void Present_scope_is_allowed() + { + var auth = new AuthorizationConfiguration { RequiredScopes = new() { "banking.accounts.read" } }; + AuthorizationEvaluator.Authorize(auth, Caller(true, scopes: new[] { "banking.accounts.read" }), Input()).Allowed.Should().BeTrue(); + } + + [Fact] + public void Unauthenticated_caller_is_denied_when_policy_present() + { + var auth = new AuthorizationConfiguration { RequiredScopes = new() { "banking.accounts.read" } }; + AuthorizationEvaluator.Authorize(auth, Caller(false), Input()).Allowed.Should().BeFalse(); + } + + [Fact] + public void Model_supplied_tenant_cannot_spoof_a_different_tenant() + { + var auth = new AuthorizationConfiguration { TenantClaim = "tid", TenantField = "tenantId" }; + var caller = Caller(true, claims: new[] { ("tid", "tenant-A") }); + var input = Input(("tenantId", JsonValue.Create("tenant-B"))); + + AuthorizationEvaluator.Authorize(auth, caller, input).Allowed.Should().BeFalse(); + } + + [Fact] + public void Identity_derived_input_overwrites_model_supplied_tenant() + { + var auth = new AuthorizationConfiguration { TenantClaim = "tid", TenantField = "tenantId" }; + var caller = Caller(true, claims: new[] { ("tid", "tenant-A") }); + var input = Input(("tenantId", JsonValue.Create("tenant-A"))); + + var overlaid = AuthorizationEvaluator.ApplyIdentityDerivedInputs(auth, caller, input); + overlaid["tenantId"]!.GetValue().Should().Be("tenant-A"); + } +} diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/FakeCosmosGateway.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/FakeCosmosGateway.cs new file mode 100644 index 0000000..c84b4dd --- /dev/null +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/FakeCosmosGateway.cs @@ -0,0 +1,114 @@ +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Providers; + +namespace AzureCosmosDB.MCP.Toolkit.Tests.Configured; + +/// +/// In-memory used to unit test the configuration runtime without a live account. +/// Records the last call for each operation so tests can assert on bound values (ids, partition keys, +/// query parameters) and injection resistance. +/// +public sealed class FakeCosmosGateway : ICosmosGateway +{ + public JsonNode? PointReadResult { get; set; } + public JsonArray QueryResult { get; set; } = new(); + public JsonArray SearchResult { get; set; } = new(); + public JsonNode? CreateResult { get; set; } + public JsonNode? PatchResult { get; set; } + + public string? LastId { get; private set; } + public string? LastPartitionKey { get; private set; } + public string? LastContainer { get; private set; } + public QueryRequest? LastQuery { get; private set; } + public SearchRequest? LastSearch { get; private set; } + public JsonObject? LastDocument { get; private set; } + public IReadOnlyList? LastPatch { get; private set; } + public string? LastIfMatch { get; private set; } + public IReadOnlyList? LastBatch { get; private set; } + + public Func? PointReadHandler { get; set; } + + public Task PointReadAsync(string database, string container, string id, string partitionKey, CancellationToken cancellationToken) + { + LastContainer = container; + LastId = id; + LastPartitionKey = partitionKey; + return Task.FromResult(PointReadHandler is not null ? PointReadHandler(id) : PointReadResult); + } + + public Task QueryAsync(QueryRequest request, CancellationToken cancellationToken) + { + LastQuery = request; + LastContainer = request.Container; + return Task.FromResult(QueryResult); + } + + public Task TextSearchAsync(string database, string container, string property, string searchText, int maxItems, CancellationToken cancellationToken) + { + LastContainer = container; + return Task.FromResult(SearchResult); + } + + public Task VectorSearchAsync(SearchRequest request, CancellationToken cancellationToken) + { + LastSearch = request; + return Task.FromResult(SearchResult); + } + + public Task HybridSearchAsync(SearchRequest request, CancellationToken cancellationToken) + { + LastSearch = request; + return Task.FromResult(SearchResult); + } + + public Task CreateAsync(string database, string container, JsonObject document, string partitionKey, CancellationToken cancellationToken) + { + LastContainer = container; + LastDocument = document; + LastPartitionKey = partitionKey; + return Task.FromResult(CreateResult ?? (JsonNode?)document.DeepClone()); + } + + public Task ReplaceAsync(string database, string container, string id, JsonObject document, string partitionKey, string? ifMatch, CancellationToken cancellationToken) + { + LastContainer = container; + LastId = id; + LastDocument = document; + LastPartitionKey = partitionKey; + LastIfMatch = ifMatch; + return Task.FromResult((JsonNode?)document.DeepClone()); + } + + public Task PatchAsync(string database, string container, string id, string partitionKey, IReadOnlyList operations, string? ifMatch, CancellationToken cancellationToken) + { + LastContainer = container; + LastId = id; + LastPartitionKey = partitionKey; + LastPatch = operations; + LastIfMatch = ifMatch; + return Task.FromResult(PatchResult ?? new JsonObject { ["id"] = id, ["patched"] = true }); + } + + public Task DeleteAsync(string database, string container, string id, string partitionKey, string? ifMatch, CancellationToken cancellationToken) + { + LastContainer = container; + LastId = id; + LastPartitionKey = partitionKey; + LastIfMatch = ifMatch; + return Task.FromResult(new JsonObject { ["id"] = id, ["deleted"] = true }); + } + + public Task TransactionalBatchAsync(string database, string container, string partitionKey, IReadOnlyList steps, CancellationToken cancellationToken) + { + LastContainer = container; + LastPartitionKey = partitionKey; + LastBatch = steps; + var results = new JsonArray(); + foreach (var step in steps) + { + results.Add(new JsonObject { ["stepId"] = step.Id, ["statusCode"] = 200 }); + } + + return Task.FromResult(results); + } +} diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/RuntimeTests.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/RuntimeTests.cs new file mode 100644 index 0000000..23d3104 --- /dev/null +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/RuntimeTests.cs @@ -0,0 +1,224 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Configuration; +using AzureCosmosDB.MCP.Toolkit.Runtime; +using FluentAssertions; +using Xunit; + +namespace AzureCosmosDB.MCP.Toolkit.Tests.Configured; + +public class InputValidatorTests +{ + private static Dictionary Input(params (string, JsonNode?)[] items) + => items.ToDictionary(i => i.Item1, i => i.Item2, StringComparer.Ordinal); + + private static InputSchemaConfiguration Schema() => new() + { + Type = "object", + Required = new() { "customerId", "amount" }, + Properties = new() + { + ["customerId"] = new PropertySchema { Type = "string", MinLength = 1 }, + ["amount"] = new PropertySchema { Type = "number", Minimum = 0.01 }, + ["limit"] = new PropertySchema { Type = "integer", Minimum = 1, Maximum = 20, Default = 10 }, + ["category"] = new PropertySchema { Type = "string", Enum = new() { "a", "b" } }, + }, + }; + + [Fact] + public void Missing_required_property_is_reported() + { + var result = InputValidator.Validate(Schema(), Input(("amount", JsonValue.Create(5.0)))); + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainMatch("*Missing required property 'customerId'*"); + } + + [Fact] + public void Applies_default_for_absent_optional_property() + { + var result = InputValidator.Validate(Schema(), Input(("customerId", JsonValue.Create("c1")), ("amount", JsonValue.Create(5.0)))); + result.IsValid.Should().BeTrue(string.Join(";", result.Errors)); + result.Values["limit"]!.GetValue().Should().Be(10); + } + + [Fact] + public void Rejects_wrong_type() + { + var result = InputValidator.Validate(Schema(), Input(("customerId", JsonValue.Create("c1")), ("amount", JsonValue.Create("not-a-number")))); + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainMatch("*'amount' must be a number*"); + } + + [Fact] + public void Enforces_numeric_minimum() + { + var result = InputValidator.Validate(Schema(), Input(("customerId", JsonValue.Create("c1")), ("amount", JsonValue.Create(0.0)))); + result.Errors.Should().ContainMatch("*'amount' must be >= 0.01*"); + } + + [Fact] + public void Enforces_enum() + { + var result = InputValidator.Validate(Schema(), Input( + ("customerId", JsonValue.Create("c1")), ("amount", JsonValue.Create(5.0)), ("category", JsonValue.Create("z")))); + result.Errors.Should().ContainMatch("*'category' must be one of*"); + } + + [Fact] + public void Rejects_unknown_property_closed_schema() + { + var result = InputValidator.Validate(Schema(), Input( + ("customerId", JsonValue.Create("c1")), ("amount", JsonValue.Create(5.0)), ("surprise", JsonValue.Create("x")))); + result.Errors.Should().ContainMatch("*Unknown property 'surprise'*"); + } +} + +public class BindingContextTests +{ + private static Dictionary Input(params (string, JsonNode?)[] items) + => items.ToDictionary(i => i.Item1, i => i.Item2, StringComparer.Ordinal); + + [Fact] + public void Binds_bare_and_prefixed_tokens_preserving_type() + { + var ctx = new BindingContext(Input(("accountId", JsonValue.Create("A1")), ("amount", JsonValue.Create(42.5)))); + ctx.BindToString("${accountId}").Should().Be("A1"); + ctx.BindTemplate("${input.amount}")!.GetValue().Should().Be(42.5); + } + + [Fact] + public void Embedded_token_produces_string_interpolation() + { + var ctx = new BindingContext(Input(("accountId", JsonValue.Create("A1")))); + ctx.BindToString("acct-${accountId}-suffix").Should().Be("acct-A1-suffix"); + } + + [Fact] + public void System_and_generated_values_resolve() + { + var ctx = new BindingContext(Input(), new DateTimeOffset(2026, 1, 2, 3, 4, 5, TimeSpan.Zero)); + ctx.BindToString("${system.utcNow}").Should().StartWith("2026-01-02T03:04:05"); + var g1 = ctx.BindToString("${generated.id}"); + var g2 = ctx.BindToString("${generated.id}"); + g1.Should().Be(g2).And.NotBeNullOrEmpty(); + } + + [Fact] + public void Malicious_input_is_not_interpreted_as_sql_it_stays_a_value() + { + // Injection resistance: a SQL-looking string binds as a literal value, never as SQL. + var evil = "'; DROP TABLE accounts; --"; + var ctx = new BindingContext(Input(("accountId", JsonValue.Create(evil)))); + ctx.BindToString("${accountId}").Should().Be(evil); + } + + [Fact] + public void Binds_nested_document_recursively() + { + var ctx = new BindingContext(Input(("customerId", JsonValue.Create("C9")), ("amount", JsonValue.Create(100.0)))); + var doc = new Dictionary + { + ["customerId"] = "${customerId}", + ["amount"] = "${amount}", + ["status"] = "open", + }; + + var bound = (JsonObject)ctx.Bind(doc)!; + bound["customerId"]!.GetValue().Should().Be("C9"); + bound["amount"]!.GetValue().Should().Be(100.0); + bound["status"]!.GetValue().Should().Be("open"); + } +} + +public class SafeExpressionEvaluatorTests +{ + private static BindingContext Ctx() => new(new Dictionary(StringComparer.Ordinal) + { + ["amount"] = JsonValue.Create(50.0), + }); + + [Fact] + public void Evaluates_numeric_comparison_with_step_output() + { + var ctx = Ctx(); + ctx.SetStepOutput("source", new JsonObject { ["balance"] = 100.0 }); + SafeExpressionEvaluator.Evaluate("${steps.source.balance >= input.amount}", ctx, out _).Should().BeTrue(); + } + + [Fact] + public void Fails_when_condition_false() + { + var ctx = Ctx(); + ctx.SetStepOutput("source", new JsonObject { ["balance"] = 10.0 }); + SafeExpressionEvaluator.Evaluate("${steps.source.balance >= input.amount}", ctx, out _).Should().BeFalse(); + } + + [Fact] + public void Supports_logical_and() + { + var ctx = Ctx(); + SafeExpressionEvaluator.Evaluate("input.amount > 0 && input.amount < 100", ctx, out _).Should().BeTrue(); + } +} + +public class OutputProjectorTests +{ + [Fact] + public void Projects_renames_and_redacts() + { + var input = new JsonObject + { + ["accountId"] = "A1", + ["balance"] = 500.0, + ["currency"] = "USD", + ["internalRiskScore"] = 7, + }; + var output = new OutputConfiguration + { + Select = new() { ["accountId"] = "accountId", ["availableBalance"] = "balance" }, + Redact = new() { "internalRiskScore" }, + }; + + var shaped = (JsonObject)OutputProjector.Apply(input, output)!; + + shaped.ContainsKey("availableBalance").Should().BeTrue(); + shaped["availableBalance"]!.GetValue().Should().Be(500.0); + shaped.ContainsKey("currency").Should().BeFalse(); + shaped.ContainsKey("internalRiskScore").Should().BeFalse(); + } + + [Fact] + public void Limits_array_results() + { + var arr = new JsonArray( + new JsonObject { ["id"] = "1" }, + new JsonObject { ["id"] = "2" }, + new JsonObject { ["id"] = "3" }); + var output = new OutputConfiguration { MaxItems = 2 }; + + ((JsonArray)OutputProjector.Apply(arr, output)!).Count.Should().Be(2); + } +} + +public class JsonSchemaGeneratorTests +{ + [Fact] + public void Generates_closed_schema_with_required_and_constraints() + { + var schema = new InputSchemaConfiguration + { + Required = new() { "customerId" }, + Properties = new() + { + ["customerId"] = new PropertySchema { Type = "string", Description = "cust" }, + ["limit"] = new PropertySchema { Type = "integer", Minimum = 1, Maximum = 20 }, + }, + }; + + var element = JsonSchemaGenerator.Generate(schema); + + element.GetProperty("additionalProperties").GetBoolean().Should().BeFalse(); + element.GetProperty("required")[0].GetString().Should().Be("customerId"); + element.GetProperty("properties").GetProperty("limit").GetProperty("maximum").GetDouble().Should().Be(20); + } +} From cb2b6c029e20bf4965a012ec2be6dd00a4594639 Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Wed, 5 Aug 2026 18:21:08 +0100 Subject: [PATCH 06/11] feat(providers): support hierarchical (subpartitioned) partition keys for banking-style containers --- .../Configuration/ConfigurationLoader.cs | 9 +- .../Configuration/ConfigurationValidator.cs | 22 +++- .../Configuration/FlexibleBooleanConverter.cs | 24 ++++ .../Configuration/ToolConfiguration.cs | 7 ++ .../Configuration/YamlToJsonConverter.cs | 112 ++++++++++++++++++ .../Providers/CosmosGateway.cs | 30 +++-- .../Providers/ICosmosGateway.cs | 16 +-- .../Runtime/OperationExecutor.cs | 53 +++++++-- .../Configured/ConfiguredToolExecutorTests.cs | 34 ++++++ .../Configured/FakeCosmosGateway.cs | 28 +++-- 10 files changed, 287 insertions(+), 48 deletions(-) create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Configuration/FlexibleBooleanConverter.cs create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Configuration/YamlToJsonConverter.cs diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs index a76b6c9..4454fa6 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs @@ -1,5 +1,4 @@ using System.Text.Json; -using YamlDotNet.Serialization; namespace AzureCosmosDB.MCP.Toolkit.Configuration; @@ -23,6 +22,8 @@ public sealed class ConfigurationLoader PropertyNameCaseInsensitive = true, ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, + NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, + Converters = { new FlexibleBooleanConverter() }, }; private readonly IReadOnlyDictionary _environment; @@ -80,10 +81,8 @@ public ConfigurationLoadResult LoadFromText(string text, bool? isJson = null) { try { - var deserializer = new DeserializerBuilder().Build(); - var yamlObject = deserializer.Deserialize(substituted); - var serializer = new SerializerBuilder().JsonCompatible().Build(); - json = serializer.Serialize(yamlObject); + var node = YamlToJsonConverter.Parse(substituted); + json = node?.ToJsonString() ?? "null"; } catch (Exception ex) { diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationValidator.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationValidator.cs index e6d2287..4cde96a 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationValidator.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationValidator.cs @@ -145,7 +145,7 @@ private static void ValidateOperationShape(string name, OperationConfiguration o case "point-read": Require(op.Container, $"Tool '{name}': point-read requires 'container'.", result); Require(op.Id, $"Tool '{name}': point-read requires 'id'.", result); - Require(op.PartitionKey, $"Tool '{name}': point-read requires 'partitionKey'.", result); + RequirePartitionKey(name, op, "point-read", result); break; case "query": Require(op.Container, $"Tool '{name}': query requires 'container'.", result); @@ -171,7 +171,7 @@ private static void ValidateOperationShape(string name, OperationConfiguration o break; case "create": Require(op.Container, $"Tool '{name}': create requires 'container'.", result); - Require(op.PartitionKey, $"Tool '{name}': create requires 'partitionKey'.", result); + RequirePartitionKey(name, op, "create", result); if (op.Document is null || op.Document.Count == 0) { result.Errors.Add($"Tool '{name}': create requires a non-empty 'document'."); @@ -180,7 +180,7 @@ private static void ValidateOperationShape(string name, OperationConfiguration o case "replace": Require(op.Container, $"Tool '{name}': replace requires 'container'.", result); Require(op.Id, $"Tool '{name}': replace requires 'id'.", result); - Require(op.PartitionKey, $"Tool '{name}': replace requires 'partitionKey'.", result); + RequirePartitionKey(name, op, "replace", result); if (op.Document is null || op.Document.Count == 0) { result.Errors.Add($"Tool '{name}': replace requires a non-empty 'document'."); @@ -189,17 +189,17 @@ private static void ValidateOperationShape(string name, OperationConfiguration o case "patch": Require(op.Container, $"Tool '{name}': patch requires 'container'.", result); Require(op.Id, $"Tool '{name}': patch requires 'id'.", result); - Require(op.PartitionKey, $"Tool '{name}': patch requires 'partitionKey'.", result); + RequirePartitionKey(name, op, "patch", result); ValidatePatchOperations(name, op.Operations, governance, result); break; case "delete": Require(op.Container, $"Tool '{name}': delete requires 'container'.", result); Require(op.Id, $"Tool '{name}': delete requires 'id'.", result); - Require(op.PartitionKey, $"Tool '{name}': delete requires 'partitionKey'.", result); + RequirePartitionKey(name, op, "delete", result); break; case "transactional-batch": Require(op.Container, $"Tool '{name}': transactional-batch requires 'container'.", result); - Require(op.PartitionKey, $"Tool '{name}': transactional-batch requires 'partitionKey'.", result); + RequirePartitionKey(name, op, "transactional-batch", result); if (op.Steps is null || op.Steps.Count == 0) { result.Errors.Add($"Tool '{name}': transactional-batch requires at least one step."); @@ -272,4 +272,14 @@ private static void Require(string? value, string error, ConfigurationValidation result.Errors.Add(error); } } + + private static void RequirePartitionKey(string name, OperationConfiguration op, string opName, ConfigurationValidationResult result) + { + var hasSingle = !string.IsNullOrWhiteSpace(op.PartitionKey); + var hasHierarchical = op.PartitionKeys is { Count: > 0 }; + if (!hasSingle && !hasHierarchical) + { + result.Errors.Add($"Tool '{name}': {opName} requires 'partitionKey' or 'partitionKeys'."); + } + } } diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/FlexibleBooleanConverter.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/FlexibleBooleanConverter.cs new file mode 100644 index 0000000..f9e77cc --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/FlexibleBooleanConverter.cs @@ -0,0 +1,24 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AzureCosmosDB.MCP.Toolkit.Configuration; + +/// +/// Reads booleans that may arrive as real JSON booleans or as quoted strings. +/// Required because the YAML→JSON bridge emits all scalars as strings. +/// +public sealed class FlexibleBooleanConverter : JsonConverter +{ + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType switch + { + JsonTokenType.True => true, + JsonTokenType.False => false, + JsonTokenType.String => bool.Parse(reader.GetString()!), + JsonTokenType.Number => reader.GetInt32() != 0, + _ => throw new JsonException($"Cannot convert {reader.TokenType} to boolean."), + }; + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + => writer.WriteBooleanValue(value); +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolConfiguration.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolConfiguration.cs index 0deb675..c055a2d 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolConfiguration.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ToolConfiguration.cs @@ -65,6 +65,10 @@ public sealed class OperationConfiguration [JsonPropertyName("partitionKey")] public string? PartitionKey { get; set; } + /// Hierarchical (subpartitioned) partition key components. Takes precedence over . + [JsonPropertyName("partitionKeys")] + public List? PartitionKeys { get; set; } + // query [JsonPropertyName("statement")] public string? Statement { get; set; } @@ -153,6 +157,9 @@ public sealed class StepConfiguration [JsonPropertyName("partitionKey")] public string? PartitionKey { get; set; } + [JsonPropertyName("partitionKeys")] + public List? PartitionKeys { get; set; } + [JsonPropertyName("operations")] public List? Operations { get; set; } diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/YamlToJsonConverter.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/YamlToJsonConverter.cs new file mode 100644 index 0000000..55a3d99 --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/YamlToJsonConverter.cs @@ -0,0 +1,112 @@ +using System.Globalization; +using System.Text.Json.Nodes; +using YamlDotNet.RepresentationModel; + +namespace AzureCosmosDB.MCP.Toolkit.Configuration; + +/// +/// Converts a YAML document into a tree with correct scalar typing. +/// +/// +/// YamlDotNet's built-in JSON-compatible serializer emits every scalar as a quoted string, which +/// loses the integer/number/boolean/null distinction the configuration model relies on. This +/// converter applies the YAML core-schema type inference for plain (unquoted) scalars while +/// preserving explicitly quoted scalars as strings (so version: "1.0" stays a string). +/// +public static class YamlToJsonConverter +{ + public static JsonNode? Parse(string yaml) + { + using var reader = new StringReader(yaml); + var stream = new YamlStream(); + stream.Load(reader); + + if (stream.Documents.Count == 0) + { + return null; + } + + return Convert(stream.Documents[0].RootNode); + } + + private static JsonNode? Convert(YamlNode node) + { + return node switch + { + YamlMappingNode map => ConvertMapping(map), + YamlSequenceNode seq => ConvertSequence(seq), + YamlScalarNode scalar => ConvertScalar(scalar), + _ => null, + }; + } + + private static JsonObject ConvertMapping(YamlMappingNode map) + { + var obj = new JsonObject(); + foreach (var (key, value) in map.Children) + { + var name = ((YamlScalarNode)key).Value ?? string.Empty; + obj[name] = Convert(value); + } + + return obj; + } + + private static JsonArray ConvertSequence(YamlSequenceNode seq) + { + var arr = new JsonArray(); + foreach (var item in seq.Children) + { + arr.Add(Convert(item)); + } + + return arr; + } + + private static JsonNode? ConvertScalar(YamlScalarNode scalar) + { + var value = scalar.Value; + if (value is null) + { + return null; + } + + // Explicitly quoted scalars are always strings. + if (scalar.Style is YamlDotNet.Core.ScalarStyle.SingleQuoted or YamlDotNet.Core.ScalarStyle.DoubleQuoted) + { + return JsonValue.Create(value); + } + + if (value.Length == 0) + { + return JsonValue.Create(string.Empty); + } + + if (value is "null" or "~" or "Null" or "NULL") + { + return null; + } + + if (value is "true" or "True" or "TRUE") + { + return JsonValue.Create(true); + } + + if (value is "false" or "False" or "FALSE") + { + return JsonValue.Create(false); + } + + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var l)) + { + return JsonValue.Create(l); + } + + if (double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out var d)) + { + return JsonValue.Create(d); + } + + return JsonValue.Create(value); + } +} diff --git a/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs b/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs index 6d10acf..1623fb6 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs @@ -28,9 +28,23 @@ public CosmosGateway(CosmosClient client, IConfiguration configuration, ILogger< private Container GetContainer(string database, string container) => _client.GetContainer(database, container); - private static PartitionKey ToPartitionKey(string value) => new(value); + private static PartitionKey ToPartitionKey(IReadOnlyList components) + { + if (components.Count == 1) + { + return new PartitionKey(components[0]); + } + + var builder = new PartitionKeyBuilder(); + foreach (var component in components) + { + builder.Add(component); + } + + return builder.Build(); + } - public async Task PointReadAsync(string database, string container, string id, string partitionKey, CancellationToken cancellationToken) + public async Task PointReadAsync(string database, string container, string id, IReadOnlyList partitionKey, CancellationToken cancellationToken) { var c = GetContainer(database, container); using var response = await c.ReadItemStreamAsync(id, ToPartitionKey(partitionKey), cancellationToken: cancellationToken); @@ -53,7 +67,7 @@ public async Task QueryAsync(QueryRequest request, CancellationToken } var options = new QueryRequestOptions { MaxItemCount = request.MaxItems }; - if (!request.AllowCrossPartition && !string.IsNullOrEmpty(request.PartitionKey)) + if (!request.AllowCrossPartition && request.PartitionKey is { Count: > 0 }) { options.PartitionKey = ToPartitionKey(request.PartitionKey); } @@ -101,7 +115,7 @@ public async Task HybridSearchAsync(SearchRequest request, Cancellati return await DrainAsync(iterator, request.TopK, cancellationToken); } - public async Task CreateAsync(string database, string container, JsonObject document, string partitionKey, CancellationToken cancellationToken) + public async Task CreateAsync(string database, string container, JsonObject document, IReadOnlyList partitionKey, CancellationToken cancellationToken) { var c = GetContainer(database, container); using var stream = ToStream(document); @@ -110,7 +124,7 @@ public async Task HybridSearchAsync(SearchRequest request, Cancellati return await ParseStreamAsync(response.Content, cancellationToken) ?? document.DeepClone(); } - public async Task ReplaceAsync(string database, string container, string id, JsonObject document, string partitionKey, string? ifMatch, CancellationToken cancellationToken) + public async Task ReplaceAsync(string database, string container, string id, JsonObject document, IReadOnlyList partitionKey, string? ifMatch, CancellationToken cancellationToken) { var c = GetContainer(database, container); using var stream = ToStream(document); @@ -120,7 +134,7 @@ public async Task HybridSearchAsync(SearchRequest request, Cancellati return await ParseStreamAsync(response.Content, cancellationToken) ?? document.DeepClone(); } - public async Task PatchAsync(string database, string container, string id, string partitionKey, IReadOnlyList operations, string? ifMatch, CancellationToken cancellationToken) + public async Task PatchAsync(string database, string container, string id, IReadOnlyList partitionKey, IReadOnlyList operations, string? ifMatch, CancellationToken cancellationToken) { var c = GetContainer(database, container); var patchOps = operations.Select(ToCosmosPatch).ToList(); @@ -135,7 +149,7 @@ public async Task HybridSearchAsync(SearchRequest request, Cancellati return await ParseStreamAsync(response.Content, cancellationToken); } - public async Task DeleteAsync(string database, string container, string id, string partitionKey, string? ifMatch, CancellationToken cancellationToken) + public async Task DeleteAsync(string database, string container, string id, IReadOnlyList partitionKey, string? ifMatch, CancellationToken cancellationToken) { var c = GetContainer(database, container); var options = ifMatch is null ? null : new ItemRequestOptions { IfMatchEtag = ifMatch }; @@ -148,7 +162,7 @@ public async Task HybridSearchAsync(SearchRequest request, Cancellati return new JsonObject { ["id"] = id, ["deleted"] = true }; } - public async Task TransactionalBatchAsync(string database, string container, string partitionKey, IReadOnlyList steps, CancellationToken cancellationToken) + public async Task TransactionalBatchAsync(string database, string container, IReadOnlyList partitionKey, IReadOnlyList steps, CancellationToken cancellationToken) { var c = GetContainer(database, container); var batch = c.CreateTransactionalBatch(ToPartitionKey(partitionKey)); diff --git a/src/AzureCosmosDB.MCP.Toolkit/Providers/ICosmosGateway.cs b/src/AzureCosmosDB.MCP.Toolkit/Providers/ICosmosGateway.cs index ae58d97..4912bf3 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Providers/ICosmosGateway.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Providers/ICosmosGateway.cs @@ -19,7 +19,7 @@ public sealed record QueryRequest( string Container, string Statement, IReadOnlyDictionary Parameters, - string? PartitionKey, + IReadOnlyList? PartitionKey, int MaxItems, bool AllowCrossPartition); @@ -32,7 +32,7 @@ public sealed record SearchRequest( string? TextPath, IReadOnlyList Select, int TopK, - string? PartitionKey); + IReadOnlyList? PartitionKey); /// /// Shared abstraction over Cosmos DB data operations. Both the (existing) built-in tools' logic @@ -41,7 +41,7 @@ public sealed record SearchRequest( /// public interface ICosmosGateway { - Task PointReadAsync(string database, string container, string id, string partitionKey, CancellationToken cancellationToken); + Task PointReadAsync(string database, string container, string id, IReadOnlyList partitionKey, CancellationToken cancellationToken); Task QueryAsync(QueryRequest request, CancellationToken cancellationToken); @@ -51,13 +51,13 @@ public interface ICosmosGateway Task HybridSearchAsync(SearchRequest request, CancellationToken cancellationToken); - Task CreateAsync(string database, string container, JsonObject document, string partitionKey, CancellationToken cancellationToken); + Task CreateAsync(string database, string container, JsonObject document, IReadOnlyList partitionKey, CancellationToken cancellationToken); - Task ReplaceAsync(string database, string container, string id, JsonObject document, string partitionKey, string? ifMatch, CancellationToken cancellationToken); + Task ReplaceAsync(string database, string container, string id, JsonObject document, IReadOnlyList partitionKey, string? ifMatch, CancellationToken cancellationToken); - Task PatchAsync(string database, string container, string id, string partitionKey, IReadOnlyList operations, string? ifMatch, CancellationToken cancellationToken); + Task PatchAsync(string database, string container, string id, IReadOnlyList partitionKey, IReadOnlyList operations, string? ifMatch, CancellationToken cancellationToken); - Task DeleteAsync(string database, string container, string id, string partitionKey, string? ifMatch, CancellationToken cancellationToken); + Task DeleteAsync(string database, string container, string id, IReadOnlyList partitionKey, string? ifMatch, CancellationToken cancellationToken); - Task TransactionalBatchAsync(string database, string container, string partitionKey, IReadOnlyList steps, CancellationToken cancellationToken); + Task TransactionalBatchAsync(string database, string container, IReadOnlyList partitionKey, IReadOnlyList steps, CancellationToken cancellationToken); } diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/OperationExecutor.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/OperationExecutor.cs index 41299a8..a509dcf 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Runtime/OperationExecutor.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/OperationExecutor.cs @@ -27,7 +27,7 @@ public sealed class OperationExecutor { case "point-read": return await _gateway.PointReadAsync( - database, op.Container!, RequireString(context, op.Id, "id"), RequireString(context, op.PartitionKey, "partitionKey"), cancellationToken); + database, op.Container!, RequireString(context, op.Id, "id"), ResolvePartitionKey(context, op.PartitionKeys, op.PartitionKey), cancellationToken); case "query": return await ExecuteQueryAsync(op, database, governance, context, cancellationToken); @@ -46,26 +46,26 @@ public sealed class OperationExecutor case "create": return await _gateway.CreateAsync( database, op.Container!, BuildDocument(context, op.Document), - RequireString(context, op.PartitionKey, "partitionKey"), cancellationToken); + ResolvePartitionKey(context, op.PartitionKeys, op.PartitionKey), cancellationToken); case "replace": return await _gateway.ReplaceAsync( database, op.Container!, RequireString(context, op.Id, "id"), BuildDocument(context, op.Document), - RequireString(context, op.PartitionKey, "partitionKey"), context.BindToString(op.Concurrency?.IfMatch), cancellationToken); + ResolvePartitionKey(context, op.PartitionKeys, op.PartitionKey), context.BindToString(op.Concurrency?.IfMatch), cancellationToken); case "patch": return await _gateway.PatchAsync( - database, op.Container!, RequireString(context, op.Id, "id"), RequireString(context, op.PartitionKey, "partitionKey"), + database, op.Container!, RequireString(context, op.Id, "id"), ResolvePartitionKey(context, op.PartitionKeys, op.PartitionKey), ResolvePatchOperations(context, op.Operations!), context.BindToString(op.Concurrency?.IfMatch), cancellationToken); case "delete": return await _gateway.DeleteAsync( - database, op.Container!, RequireString(context, op.Id, "id"), RequireString(context, op.PartitionKey, "partitionKey"), + database, op.Container!, RequireString(context, op.Id, "id"), ResolvePartitionKey(context, op.PartitionKeys, op.PartitionKey), context.BindToString(op.Concurrency?.IfMatch), cancellationToken); case "transactional-batch": return await _gateway.TransactionalBatchAsync( - database, op.Container!, RequireString(context, op.PartitionKey, "partitionKey"), + database, op.Container!, ResolvePartitionKey(context, op.PartitionKeys, op.PartitionKey), ResolveBatchSteps(context, op.Steps!), cancellationToken); case "sequence": @@ -92,7 +92,7 @@ public sealed class OperationExecutor op.Container!, op.Statement!, parameters, - context.BindToString(op.PartitionKey), + ResolvePartitionKeyOrNull(context, op.PartitionKeys, op.PartitionKey), governance.MaxItems ?? 100, governance.AllowCrossPartition ?? false); @@ -121,6 +121,7 @@ public sealed class OperationExecutor Container = op.Container, Id = step.ItemId, PartitionKey = step.PartitionKey ?? op.PartitionKey, + PartitionKeys = step.PartitionKeys ?? op.PartitionKeys, Document = step.Document, Operations = step.Operations, Concurrency = step.Concurrency, @@ -143,7 +144,7 @@ private static SearchRequest BuildSearch(OperationConfiguration op, string datab op.TextPath, op.Select ?? new List(), ResolveLimit(context, op.TopK, governance.MaxTopK ?? 50, governance.MaxTopK ?? 50), - context.BindToString(op.PartitionKey)); + ResolvePartitionKeyOrNull(context, op.PartitionKeys, op.PartitionKey)); private static JsonObject BuildDocument(BindingContext context, Dictionary? document) { @@ -185,6 +186,42 @@ private static string RequireString(BindingContext context, string? template, st return value; } + private static IReadOnlyList ResolvePartitionKey(BindingContext context, List? components, string? single) + { + var resolved = ResolvePartitionKeyOrNull(context, components, single); + if (resolved is null || resolved.Count == 0) + { + throw new BindingFailedException("Could not resolve a value for 'partitionKey'."); + } + + return resolved; + } + + private static IReadOnlyList? ResolvePartitionKeyOrNull(BindingContext context, List? components, string? single) + { + var templates = components is { Count: > 0 } + ? components + : (single is not null ? new List { single } : null); + if (templates is null) + { + return null; + } + + var result = new List(templates.Count); + foreach (var template in templates) + { + var value = context.BindToString(template); + if (string.IsNullOrEmpty(value)) + { + throw new BindingFailedException($"Could not resolve partition key component '{template}'."); + } + + result.Add(value); + } + + return result; + } + private static int ResolveLimit(BindingContext context, string? template, int fallback, int max) { var resolved = fallback; diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfiguredToolExecutorTests.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfiguredToolExecutorTests.cs index 0b0a0ba..19c5cc2 100644 --- a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfiguredToolExecutorTests.cs +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/ConfiguredToolExecutorTests.cs @@ -69,6 +69,40 @@ public async Task Point_read_binds_id_and_partition_and_projects_output() node.AsObject().ContainsKey("internalRiskScore").Should().BeFalse(); } + private const string HierarchicalYaml = """ +version: "1.0" +sources: + banking: { type: cosmos, endpoint: "${COSMOS_ENDPOINT}", database: banking } +tools: + bank_balance: + description: balance + source: banking + operation: + type: point-read + container: accounts + id: "${accountId}" + partitionKeys: ["${tenantId}", "${accountId}"] + input: + type: object + required: [tenantId, accountId] + properties: + tenantId: { type: string } + accountId: { type: string } +"""; + + [Fact] + public async Task Point_read_supports_hierarchical_partition_key() + { + var tool = BuildTool(HierarchicalYaml, "bank_balance"); + var gateway = new FakeCosmosGateway { PointReadResult = new JsonObject { ["accountId"] = "A1", ["balance"] = 10.0 } }; + var executor = new ConfiguredToolExecutor(gateway, NullLogger.Instance); + + var result = await executor.ExecuteAsync(tool, Input(("tenantId", JsonValue.Create("Contoso")), ("accountId", JsonValue.Create("A1"))), Bypass, default); + + result.IsError.Should().BeFalse(result.Json); + gateway.LastPartitionKeyComponents.Should().Equal("Contoso", "A1"); + } + [Fact] public async Task Invalid_input_returns_structured_validation_error() { diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/FakeCosmosGateway.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/FakeCosmosGateway.cs index c84b4dd..c41dc65 100644 --- a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/FakeCosmosGateway.cs +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/FakeCosmosGateway.cs @@ -17,7 +17,8 @@ public sealed class FakeCosmosGateway : ICosmosGateway public JsonNode? PatchResult { get; set; } public string? LastId { get; private set; } - public string? LastPartitionKey { get; private set; } + public IReadOnlyList? LastPartitionKeyComponents { get; private set; } + public string? LastPartitionKey => LastPartitionKeyComponents is null ? null : string.Join("|", LastPartitionKeyComponents); public string? LastContainer { get; private set; } public QueryRequest? LastQuery { get; private set; } public SearchRequest? LastSearch { get; private set; } @@ -28,11 +29,11 @@ public sealed class FakeCosmosGateway : ICosmosGateway public Func? PointReadHandler { get; set; } - public Task PointReadAsync(string database, string container, string id, string partitionKey, CancellationToken cancellationToken) + public Task PointReadAsync(string database, string container, string id, IReadOnlyList partitionKey, CancellationToken cancellationToken) { LastContainer = container; LastId = id; - LastPartitionKey = partitionKey; + LastPartitionKeyComponents = partitionKey; return Task.FromResult(PointReadHandler is not null ? PointReadHandler(id) : PointReadResult); } @@ -40,6 +41,7 @@ public Task QueryAsync(QueryRequest request, CancellationToken cancel { LastQuery = request; LastContainer = request.Container; + LastPartitionKeyComponents = request.PartitionKey; return Task.FromResult(QueryResult); } @@ -61,47 +63,47 @@ public Task HybridSearchAsync(SearchRequest request, CancellationToke return Task.FromResult(SearchResult); } - public Task CreateAsync(string database, string container, JsonObject document, string partitionKey, CancellationToken cancellationToken) + public Task CreateAsync(string database, string container, JsonObject document, IReadOnlyList partitionKey, CancellationToken cancellationToken) { LastContainer = container; LastDocument = document; - LastPartitionKey = partitionKey; + LastPartitionKeyComponents = partitionKey; return Task.FromResult(CreateResult ?? (JsonNode?)document.DeepClone()); } - public Task ReplaceAsync(string database, string container, string id, JsonObject document, string partitionKey, string? ifMatch, CancellationToken cancellationToken) + public Task ReplaceAsync(string database, string container, string id, JsonObject document, IReadOnlyList partitionKey, string? ifMatch, CancellationToken cancellationToken) { LastContainer = container; LastId = id; LastDocument = document; - LastPartitionKey = partitionKey; + LastPartitionKeyComponents = partitionKey; LastIfMatch = ifMatch; return Task.FromResult((JsonNode?)document.DeepClone()); } - public Task PatchAsync(string database, string container, string id, string partitionKey, IReadOnlyList operations, string? ifMatch, CancellationToken cancellationToken) + public Task PatchAsync(string database, string container, string id, IReadOnlyList partitionKey, IReadOnlyList operations, string? ifMatch, CancellationToken cancellationToken) { LastContainer = container; LastId = id; - LastPartitionKey = partitionKey; + LastPartitionKeyComponents = partitionKey; LastPatch = operations; LastIfMatch = ifMatch; return Task.FromResult(PatchResult ?? new JsonObject { ["id"] = id, ["patched"] = true }); } - public Task DeleteAsync(string database, string container, string id, string partitionKey, string? ifMatch, CancellationToken cancellationToken) + public Task DeleteAsync(string database, string container, string id, IReadOnlyList partitionKey, string? ifMatch, CancellationToken cancellationToken) { LastContainer = container; LastId = id; - LastPartitionKey = partitionKey; + LastPartitionKeyComponents = partitionKey; LastIfMatch = ifMatch; return Task.FromResult(new JsonObject { ["id"] = id, ["deleted"] = true }); } - public Task TransactionalBatchAsync(string database, string container, string partitionKey, IReadOnlyList steps, CancellationToken cancellationToken) + public Task TransactionalBatchAsync(string database, string container, IReadOnlyList partitionKey, IReadOnlyList steps, CancellationToken cancellationToken) { LastContainer = container; - LastPartitionKey = partitionKey; + LastPartitionKeyComponents = partitionKey; LastBatch = steps; var results = new JsonArray(); foreach (var step in steps) From ed73b19f31b8e0f5331e0b01fab39001be9880de Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Wed, 5 Aug 2026 18:26:51 +0100 Subject: [PATCH 07/11] feat(samples): add banking declarative tool config; test: add sample validation and emulator integration tests - Add samples/banking/cosmos-tools.yaml covering point-read, query, vector-search, create, and transactional-batch - Document bank_transfer partition analysis (cross-account transfer cannot be a single batch) - Add FlexibleStringConverter so numeric YAML literals bind to template string fields - Add end-to-end emulator integration tests (skipped when emulator absent) --- samples/banking/cosmos-tools.yaml | 320 ++++++++++++++++++ .../Configuration/ConfigurationLoader.cs | 2 +- .../Configuration/FlexibleStringConverter.cs | 29 ++ .../AzureCosmosDB.MCP.Toolkit.Tests.csproj | 6 + .../Configured/BankingSampleConfigTests.cs | 60 ++++ .../Configured/EmulatorIntegrationTests.cs | 199 +++++++++++ 6 files changed, 615 insertions(+), 1 deletion(-) create mode 100644 samples/banking/cosmos-tools.yaml create mode 100644 src/AzureCosmosDB.MCP.Toolkit/Configuration/FlexibleStringConverter.cs create mode 100644 tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/BankingSampleConfigTests.cs create mode 100644 tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/EmulatorIntegrationTests.cs diff --git a/samples/banking/cosmos-tools.yaml b/samples/banking/cosmos-tools.yaml new file mode 100644 index 0000000..45e8711 --- /dev/null +++ b/samples/banking/cosmos-tools.yaml @@ -0,0 +1,320 @@ +# ===================================================================================== +# Azure Cosmos DB MCP Toolkit — Declarative business tools for the Banking sample +# ===================================================================================== +# This file is OPT-IN. Point the toolkit at it with COSMOS_TOOLS_CONFIG (or the +# CosmosMcp:ToolsConfigPath setting). Without it, the toolkit exposes only its GA +# built-in tools and behaves exactly as before. +# +# The banking workshop stores everything in an "accounts" container that uses a +# HIERARCHICAL partition key [tenantId, accountId] and a `type` discriminator field +# (BankAccount, BankTransaction, ServiceRequest). Offers live in an "offers" container +# partitioned by [tenantId]. +# ===================================================================================== + +version: "1.0" + +sources: + banking: + type: cosmos + endpoint: "${COSMOS_ENDPOINT}" # environment substitution at load time + database: "${COSMOS_DATABASE}" + authentication: + type: managed-identity + +defaults: + source: banking + governance: + timeoutMs: 5000 + maxItems: 100 + readOnly: true # writes must be explicitly enabled per tool + +tools: + + # --------------------------------------------------------------------------- + # bank_balance — point read of a single account (read-only) + # Original: BankingDataService.GetAccountDetailsAsync (ReadItemAsync) + # --------------------------------------------------------------------------- + bank_balance: + description: Returns the current balance and details for one of the caller's accounts. + version: "1.0" + tags: [banking, accounts, read] + operation: + type: point-read + container: accounts + id: "${accountId}" + partitionKeys: ["${tenantId}", "${accountId}"] # hierarchical partition key + input: + type: object + required: [tenantId, accountId] + properties: + tenantId: { type: string, description: Tenant identifier (from caller identity). } + accountId: { type: string, description: Account identifier, e.g. Acc001. } + output: + select: + accountId: accountId + name: name + balance: balance + accountType: accountType + authorization: + tenantClaim: tid # trusted tenant comes from the token, never the model + tenantField: tenantId + governance: + maxItems: 1 + timeoutMs: 3000 + + # --------------------------------------------------------------------------- + # get_transaction_history — parameterised, partition-scoped query (read-only) + # Original: BankingDataService.GetTransactionsAsync + # --------------------------------------------------------------------------- + get_transaction_history: + description: Returns transactions for an account between two dates (inclusive). + version: "1.0" + tags: [banking, transactions, read] + operation: + type: query + container: accounts + statement: | + SELECT TOP @limit c.id, c.accountId, c.amount, c.transactionType, c.transactionDateTime + FROM c + WHERE c.accountId = @accountId + AND c.type = 'BankTransaction' + AND c.transactionDateTime >= @startDate + AND c.transactionDateTime <= @endDate + ORDER BY c.transactionDateTime DESC + parameters: + accountId: "${accountId}" + startDate: "${startDate}" + endDate: "${endDate}" + limit: "${limit}" + partitionKeys: ["${tenantId}", "${accountId}"] + input: + type: object + required: [tenantId, accountId, startDate, endDate] + properties: + tenantId: { type: string } + accountId: { type: string } + startDate: { type: string, description: ISO-8601 start date/time. } + endDate: { type: string, description: ISO-8601 end date/time. } + limit: { type: integer, default: 50, minimum: 1, maximum: 100 } + authorization: + tenantClaim: tid + tenantField: tenantId + governance: + maxItems: 100 + + # --------------------------------------------------------------------------- + # get_offer_information — vector search over offer terms (read-only) + # Original: BankingDataService.SearchOfferTermsAsync (embedding + vector search) + # NOTE: scoped to the tenant partition. Additional metadata filters (type/accountType) + # can be layered on with a filtered VectorDistance query if required. + # --------------------------------------------------------------------------- + get_offer_information: + description: Finds banking product offers relevant to a natural-language requirement. + version: "1.0" + tags: [banking, offers, search] + operation: + type: vector-search + container: offers + searchText: "${prompt}" + vectorPath: /vector + partitionKeys: ["${tenantId}"] + topK: 5 + select: + - id + - name + - text + - accountType + input: + type: object + required: [tenantId, prompt] + properties: + tenantId: { type: string } + prompt: { type: string, minLength: 1, maxLength: 1000 } + authorization: + tenantClaim: tid + tenantField: tenantId + governance: + maxTopK: 10 + + # --------------------------------------------------------------------------- + # create_account — write (create). Writes are explicitly enabled here. + # Original: create_account tool / BankAccount creation + # --------------------------------------------------------------------------- + create_account: + description: Opens a new bank account for the caller with an initial balance. + version: "1.0" + tags: [banking, accounts, write] + governance: + readOnly: false # explicit opt-in to allow writes + operation: + type: create + container: accounts + partitionKeys: ["${tenantId}", "${generated.accountId}"] + document: + id: "${generated.accountId}" + type: "BankAccount" + tenantId: "${tenantId}" + userId: "${userId}" + name: "${accountHolder}" + balance: "${balance}" + accountType: "Savings" + createdAt: "${system.utcNow}" + input: + type: object + required: [tenantId, userId, accountHolder, balance] + properties: + tenantId: { type: string } + userId: { type: string } + accountHolder: { type: string, minLength: 1, maxLength: 200 } + balance: { type: number, minimum: 0 } + output: + select: + accountId: id + name: name + balance: balance + authorization: + tenantClaim: tid + tenantField: tenantId + + # --------------------------------------------------------------------------- + # service_request — write (create). Records a customer service request. + # Original: service_request / BankingDataService.AddServiceRequestAsync + # --------------------------------------------------------------------------- + service_request: + description: Files a customer service request against an account. + version: "1.0" + tags: [banking, service, write] + governance: + readOnly: false + operation: + type: create + container: accounts + partitionKeys: ["${tenantId}", "${accountId}"] + document: + id: "${generated.requestId}" + type: "ServiceRequest" + SRType: "Complaint" + tenantId: "${tenantId}" + accountId: "${accountId}" + userId: "${userId}" + requestSummary: "${requestSummary}" + recipientEmail: "${recipientEmail}" + recipientPhone: "${recipientPhone}" + status: "Open" + createdAt: "${system.utcNow}" + input: + type: object + required: [tenantId, accountId, userId, requestSummary] + properties: + tenantId: { type: string } + accountId: { type: string } + userId: { type: string } + requestSummary: { type: string, minLength: 1, maxLength: 2000 } + recipientEmail: { type: string, default: "" } + recipientPhone: { type: string, default: "" } + authorization: + tenantClaim: tid + tenantField: tenantId + + # --------------------------------------------------------------------------- + # bank_transfer — REQUEST-BASED form (faithful to the C# workshop). + # Original: TransactionPlugin.AddFunTransferRequest -> + # BankingDataService.CreateFundTransferRequestAsync (a single create of a + # ServiceRequest of type FundTransfer that is fulfilled asynchronously). + # + # WHY NOT A SINGLE TRANSACTIONAL BATCH? + # The accounts container is partitioned by [tenantId, accountId]. A transfer debits + # the SOURCE account and credits the DESTINATION account — two DIFFERENT accountId + # values and therefore two DIFFERENT logical partitions. A Cosmos transactional batch + # is scoped to a SINGLE logical partition, so a two-account transfer cannot be a single + # atomic batch under this (unchanged, GA) partition design. True atomic cross-partition + # transfer would require a cross-partition/2-phase transaction, which Cosmos DB does not + # provide and which is explicitly out of scope for a Cosmos-only toolkit. The workshop's + # request-based model (record intent, fulfil asynchronously) is the correct pattern and + # is fully declarative, shown here. + # --------------------------------------------------------------------------- + bank_transfer: + description: Requests a funds transfer from one of the caller's accounts to a recipient. + version: "1.0" + tags: [banking, transactions, write] + governance: + readOnly: false + operation: + type: create + container: accounts + partitionKeys: ["${tenantId}", "${fromAccount}"] + document: + id: "${generated.transferId}" + type: "ServiceRequest" + SRType: "FundTransfer" + tenantId: "${tenantId}" + accountId: "${fromAccount}" + userId: "${userId}" + toAccount: "${toAccount}" + amount: "${amount}" + status: "Requested" + createdAt: "${system.utcNow}" + input: + type: object + required: [tenantId, userId, fromAccount, toAccount, amount] + properties: + tenantId: { type: string } + userId: { type: string } + fromAccount: { type: string } + toAccount: { type: string } + amount: { type: number, minimum: 0.01 } + output: + select: + transferId: id + status: status + amount: amount + authorization: + tenantClaim: tid + tenantField: tenantId + + # --------------------------------------------------------------------------- + # same_account_adjustment — TRANSACTIONAL BATCH demonstration. + # When both write legs share a single logical partition (here, one account), the + # toolkit expresses an atomic multi-step write as one transactional batch: adjust the + # balance AND append an immutable transaction record together, all-or-nothing. + # --------------------------------------------------------------------------- + post_account_transaction: + description: Atomically adjusts an account balance and records the matching transaction. + version: "1.0" + tags: [banking, transactions, write] + governance: + readOnly: false + allowedPatchPaths: ["/balance"] + operation: + type: transactional-batch + container: accounts + partitionKeys: ["${tenantId}", "${accountId}"] + steps: + - id: adjustBalance + type: patch + itemId: "${accountId}" + operations: + - op: increment + path: /balance + value: "${amount}" + - id: recordTransaction + type: create + document: + id: "${generated.transactionId}" + type: "BankTransaction" + tenantId: "${tenantId}" + accountId: "${accountId}" + amount: "${amount}" + transactionType: "${transactionType}" + transactionDateTime: "${system.utcNow}" + input: + type: object + required: [tenantId, accountId, amount, transactionType] + properties: + tenantId: { type: string } + accountId: { type: string } + amount: { type: number } + transactionType: { type: string, enum: [Credit, Debit] } + authorization: + tenantClaim: tid + tenantField: tenantId diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs index 4454fa6..ba3c6e2 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs @@ -23,7 +23,7 @@ public sealed class ConfigurationLoader ReadCommentHandling = JsonCommentHandling.Skip, AllowTrailingCommas = true, NumberHandling = System.Text.Json.Serialization.JsonNumberHandling.AllowReadingFromString, - Converters = { new FlexibleBooleanConverter() }, + Converters = { new FlexibleBooleanConverter(), new FlexibleStringConverter() }, }; private readonly IReadOnlyDictionary _environment; diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/FlexibleStringConverter.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/FlexibleStringConverter.cs new file mode 100644 index 0000000..dbf126b --- /dev/null +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/FlexibleStringConverter.cs @@ -0,0 +1,29 @@ +using System.Globalization; +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace AzureCosmosDB.MCP.Toolkit.Configuration; + +/// +/// Reads string-typed configuration fields (which frequently hold binding templates such as +/// ${topK}) even when the underlying YAML scalar was a number or boolean literal +/// (for example topK: 5). This keeps authoring natural while the model stays strongly typed. +/// +public sealed class FlexibleStringConverter : JsonConverter +{ + public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + => reader.TokenType switch + { + JsonTokenType.String => reader.GetString(), + JsonTokenType.Number => reader.TryGetInt64(out var l) + ? l.ToString(CultureInfo.InvariantCulture) + : reader.GetDouble().ToString(CultureInfo.InvariantCulture), + JsonTokenType.True => "true", + JsonTokenType.False => "false", + JsonTokenType.Null => null, + _ => throw new JsonException($"Cannot convert {reader.TokenType} to string."), + }; + + public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOptions options) + => writer.WriteStringValue(value); +} diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/AzureCosmosDB.MCP.Toolkit.Tests.csproj b/tests/AzureCosmosDB.MCP.Toolkit.Tests/AzureCosmosDB.MCP.Toolkit.Tests.csproj index 38dbd77..6c73dbe 100644 --- a/tests/AzureCosmosDB.MCP.Toolkit.Tests/AzureCosmosDB.MCP.Toolkit.Tests.csproj +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/AzureCosmosDB.MCP.Toolkit.Tests.csproj @@ -24,4 +24,10 @@ + + + PreserveNewest + + + \ No newline at end of file diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/BankingSampleConfigTests.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/BankingSampleConfigTests.cs new file mode 100644 index 0000000..27bf978 --- /dev/null +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/BankingSampleConfigTests.cs @@ -0,0 +1,60 @@ +using AzureCosmosDB.MCP.Toolkit.Configuration; +using AzureCosmosDB.MCP.Toolkit.Runtime; +using FluentAssertions; +using Xunit; + +namespace AzureCosmosDB.MCP.Toolkit.Tests.Configured; + +/// +/// Validates the shipped banking sample configuration exactly as an operator would load it, +/// proving the documented example is correct and stays correct. +/// +public class BankingSampleConfigTests +{ + private static string SamplePath => Path.Combine(AppContext.BaseDirectory, "samples", "banking-cosmos-tools.yaml"); + + [Fact] + public void Sample_file_exists() + { + File.Exists(SamplePath).Should().BeTrue($"the banking sample should be copied to '{SamplePath}'."); + } + + [Fact] + public void Sample_loads_and_validates() + { + var env = new Dictionary(StringComparer.Ordinal) + { + ["COSMOS_ENDPOINT"] = "https://banking.documents.azure.com/", + ["COSMOS_DATABASE"] = "banking", + }; + + var result = new ConfigurationLoader(env).LoadFromFile(SamplePath); + + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + result.Configuration!.Sources["banking"].Database.Should().Be("banking"); + } + + [Fact] + public void Sample_registers_expected_banking_tools() + { + var env = new Dictionary(StringComparer.Ordinal) + { + ["COSMOS_ENDPOINT"] = "https://banking.documents.azure.com/", + ["COSMOS_DATABASE"] = "banking", + }; + + var result = new ConfigurationLoader(env).LoadFromFile(SamplePath); + var tools = ConfiguredToolSet.Build(result.Configuration!); + + tools.Select(t => t.Name).Should().Contain(new[] + { + "bank_balance", "get_transaction_history", "get_offer_information", + "create_account", "service_request", "bank_transfer", "post_account_transaction", + }); + + // Read-only tools stay read-only; write tools are explicitly enabled. + tools.Single(t => t.Name == "bank_balance").IsWrite.Should().BeFalse(); + tools.Single(t => t.Name == "create_account").IsWrite.Should().BeTrue(); + tools.Single(t => t.Name == "post_account_transaction").Governance.ReadOnly.Should().BeFalse(); + } +} diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/EmulatorIntegrationTests.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/EmulatorIntegrationTests.cs new file mode 100644 index 0000000..a8a219b --- /dev/null +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/EmulatorIntegrationTests.cs @@ -0,0 +1,199 @@ +using System.Text.Json.Nodes; +using AzureCosmosDB.MCP.Toolkit.Configuration; +using AzureCosmosDB.MCP.Toolkit.Providers; +using AzureCosmosDB.MCP.Toolkit.Runtime; +using FluentAssertions; +using Microsoft.Azure.Cosmos; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace AzureCosmosDB.MCP.Toolkit.Tests.Configured; + +/// +/// End-to-end tests that run the real and configuration runtime against +/// the local Azure Cosmos DB emulator. They are automatically skipped (no-op) when the emulator is +/// not reachable, so they never break CI environments without an emulator. +/// +public sealed class EmulatorIntegrationTests : IAsyncLifetime +{ + private const string EmulatorConnectionString = + "AccountEndpoint=https://localhost:8081/;AccountKey=C2y6yDjf5/R+ob0N8A7Cgv30VRDJIWEHLM+4QDU5DE2nQ9nDuVTqobD4b8mGGyPMbIZnqyMsEcaGQy67XIw/Jw=="; + + private readonly string _databaseName = "mcp_it_" + Guid.NewGuid().ToString("N")[..8]; + private CosmosClient? _client; + private bool _available; + + public async Task InitializeAsync() + { + try + { + _client = new CosmosClient(EmulatorConnectionString, new CosmosClientOptions + { + ConnectionMode = ConnectionMode.Gateway, + HttpClientFactory = () => new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator, + }), + RequestTimeout = TimeSpan.FromSeconds(10), + }); + + var db = await _client.CreateDatabaseIfNotExistsAsync(_databaseName); + await db.Database.CreateContainerIfNotExistsAsync(new ContainerProperties + { + Id = "accounts", + PartitionKeyPaths = new List { "/tenantId", "/accountId" }, + }); + + var accounts = _client.GetContainer(_databaseName, "accounts"); + await accounts.CreateItemAsync(new JsonObject + { + ["id"] = "Acc001", + ["type"] = "BankAccount", + ["tenantId"] = "Contoso", + ["accountId"] = "Acc001", + ["name"] = "Mark", + ["balance"] = 500.0, + ["accountType"] = "Savings", + }, new PartitionKeyBuilder().Add("Contoso").Add("Acc001").Build()); + + _available = true; + } + catch + { + _available = false; + } + } + + public async Task DisposeAsync() + { + if (_client is not null && _available) + { + try + { + await _client.GetDatabase(_databaseName).DeleteAsync(); + } + catch + { + // best-effort cleanup + } + } + + _client?.Dispose(); + } + + private ConfiguredTool BuildTool(string toolName) + { + var yaml = """ +version: "1.0" +sources: + banking: { type: cosmos, endpoint: "https://localhost:8081/", database: __DB__ } +tools: + bank_balance: + description: balance + source: banking + operation: + type: point-read + container: accounts + id: "${accountId}" + partitionKeys: ["${tenantId}", "${accountId}"] + input: + type: object + required: [tenantId, accountId] + properties: + tenantId: { type: string } + accountId: { type: string } + post_account_transaction: + description: adjust + record + source: banking + governance: + readOnly: false + operation: + type: transactional-batch + container: accounts + partitionKeys: ["${tenantId}", "${accountId}"] + steps: + - id: adjust + type: patch + itemId: "${accountId}" + operations: + - op: increment + path: /balance + value: "${amount}" + - id: record + type: create + document: + id: "${generated.txnId}" + type: "BankTransaction" + tenantId: "${tenantId}" + accountId: "${accountId}" + amount: "${amount}" + transactionDateTime: "${system.utcNow}" + input: + type: object + required: [tenantId, accountId, amount] + properties: + tenantId: { type: string } + accountId: { type: string } + amount: { type: number } +""".Replace("__DB__", _databaseName); + var result = new ConfigurationLoader(new Dictionary(StringComparer.Ordinal)).LoadFromText(yaml); + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + return ConfiguredToolSet.Build(result.Configuration!).Single(t => t.Name == toolName); + } + + private ConfiguredToolExecutor CreateExecutor() + { + var configuration = new ConfigurationBuilder().AddInMemoryCollection().Build(); + var gateway = new CosmosGateway(_client!, configuration, NullLogger.Instance); + return new ConfiguredToolExecutor(gateway, NullLogger.Instance); + } + + private static Dictionary Input(params (string, JsonNode?)[] items) + => items.ToDictionary(i => i.Item1, i => i.Item2, StringComparer.Ordinal); + + [Fact] + public async Task Point_read_returns_seeded_account() + { + if (!_available) + { + return; // emulator unavailable — skip + } + + var executor = CreateExecutor(); + var tool = BuildTool("bank_balance"); + + var result = await executor.ExecuteAsync( + tool, + Input(("tenantId", JsonValue.Create("Contoso")), ("accountId", JsonValue.Create("Acc001"))), + new CallerContext { AuthenticationBypassed = true }, + default); + + result.IsError.Should().BeFalse(result.Json); + JsonNode.Parse(result.Json)!["balance"]!.GetValue().Should().Be(500.0); + } + + [Fact] + public async Task Transactional_batch_adjusts_balance_and_records_transaction() + { + if (!_available) + { + return; // emulator unavailable — skip + } + + var executor = CreateExecutor(); + var tool = BuildTool("post_account_transaction"); + + var result = await executor.ExecuteAsync( + tool, + Input(("tenantId", JsonValue.Create("Contoso")), ("accountId", JsonValue.Create("Acc001")), ("amount", JsonValue.Create(150.0))), + new CallerContext { AuthenticationBypassed = true }, + default); + + result.IsError.Should().BeFalse(result.Json); + + var accounts = _client!.GetContainer(_databaseName, "accounts"); + var account = await accounts.ReadItemAsync("Acc001", new PartitionKeyBuilder().Add("Contoso").Add("Acc001").Build()); + account.Resource["balance"]!.GetValue().Should().Be(650.0); + } +} From df2e43ee4ef0326602ede06271633ecf9411316b Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Wed, 5 Aug 2026 18:29:50 +0100 Subject: [PATCH 08/11] docs: add declarative tools reference, security/governance, compatibility matrix, and banking migration walkthrough --- CHANGELOG.md | 21 ++ README.md | 5 + docs/declarative-tools/README.md | 88 ++++++ docs/declarative-tools/banking-migration.md | 83 ++++++ .../declarative-tools/compatibility-matrix.md | 58 ++++ docs/declarative-tools/security-governance.md | 68 +++++ docs/declarative-tools/yaml-reference.md | 277 ++++++++++++++++++ 7 files changed, 600 insertions(+) create mode 100644 docs/declarative-tools/README.md create mode 100644 docs/declarative-tools/banking-migration.md create mode 100644 docs/declarative-tools/compatibility-matrix.md create mode 100644 docs/declarative-tools/security-governance.md create mode 100644 docs/declarative-tools/yaml-reference.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 44bde2a..164d906 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,27 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- **Declarative business tools (opt-in, additive):** define business-facing MCP tools in YAML/JSON + via `COSMOS_TOOLS_CONFIG` (or `CosmosMcp:ToolsConfigPath`). Supports point-read, query, + text/vector/hybrid search, create/replace/patch/delete, optimistic concurrency, transactional + batch, and bounded Cosmos-only `sequence` composition with assertions, generated ids, and system + timestamps. +- Per-tool authorization (scopes/roles/claims), tenant isolation with anti-spoofing, and governance + (read-only default, write/delete/cross-partition opt-in, RU/timeout/maxItems/topK budgets, patch + allow-lists). +- Hierarchical (subpartitioned) partition key support (`partitionKeys: [...]`). +- Shared `ICosmosGateway` provider surface, injection-resistant parameter binding, closed input + schema generation, and output projection/redaction. +- `samples/banking/cosmos-tools.yaml` and `docs/declarative-tools/*` (YAML reference, security & + governance, GA compatibility matrix, banking migration walkthrough). + +### Compatibility +- Fully backward compatible. The declarative runtime is dormant unless a configuration file is + supplied; no existing tool, schema, default, or environment variable was changed. + ## [1.1.2] - 2026-05-29 ### Added diff --git a/README.md b/README.md index 760d7f3..28c9852 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,11 @@ This toolkit provides a **production-ready, fully automated MCP server** that ha **See it in action:** 📚 [Real-world use cases](docs/USE-CASES.md) • 🗺️ [Roadmap](ROADMAP.md) • 🤝 [Contributing](CONTRIBUTING.md) +> 🆕 **Declarative business tools (opt-in):** define secure, governed, business-facing MCP tools in +> YAML — point reads, queries, search, writes, and transactional batches — without writing handler +> code. This is additive and dormant unless you supply a config file, so existing deployments are +> unaffected. See [docs/declarative-tools](docs/declarative-tools/README.md). + ## Prerequisites - Azure subscription ([Free account](https://azure.microsoft.com/free/)) diff --git a/docs/declarative-tools/README.md b/docs/declarative-tools/README.md new file mode 100644 index 0000000..19f3400 --- /dev/null +++ b/docs/declarative-tools/README.md @@ -0,0 +1,88 @@ +# Declarative Business Tools (vNext) + +The Azure Cosmos DB MCP Toolkit can expose **business-facing, governed MCP tools** defined in a +YAML (or JSON) file — no bespoke handler code required. This layer is **additive and opt-in**: +if you do not provide a configuration file, the toolkit exposes only its GA built-in tools and +behaves exactly as before. + +## Contents + +- [YAML configuration reference](./yaml-reference.md) — every field and one example per operation type +- [Security & governance](./security-governance.md) — auth, tenant isolation, RU/timeout budgets +- [GA compatibility matrix](./compatibility-matrix.md) — evidence that existing behavior is unchanged +- [Banking migration walkthrough](./banking-migration.md) — tool-by-tool classification and `bank_transfer` analysis + +## What you can define + +Point reads, parameterised queries, full-text/vector/hybrid search, create/replace/patch/delete, +optimistic concurrency, transactional batches, and short **Cosmos-only** bounded composition +(`sequence`) with assertions, generated ids, and system timestamps. + +It is deliberately **not** a workflow engine, saga orchestrator, or scripting runtime. + +## Quick start + +1. Author a config file, e.g. `cosmos-tools.yaml`: + + ```yaml + version: "1.0" + sources: + app: + type: cosmos + endpoint: "${COSMOS_ENDPOINT}" + database: "${COSMOS_DATABASE}" + authentication: + type: managed-identity + defaults: + source: app + governance: + readOnly: true # writes must be explicitly enabled per tool + timeoutMs: 5000 + maxItems: 100 + tools: + get_account_balance: + description: Returns the current balance for an account. + operation: + type: point-read + container: accounts + id: "${accountId}" + partitionKey: "${customerId}" + input: + type: object + required: [customerId, accountId] + properties: + customerId: { type: string } + accountId: { type: string } + output: + select: + accountId: accountId + balance: balance + ``` + +2. Point the toolkit at it (either form works): + + ```powershell + $env:COSMOS_TOOLS_CONFIG = "C:\path\to\cosmos-tools.yaml" + ``` + + or in `appsettings.json`: + + ```json + { "CosmosMcp": { "ToolsConfigPath": "cosmos-tools.yaml" } } + ``` + +3. Start the server. Configured tools appear alongside the built-in tools in `tools/list`. + +## Fail-closed behavior + +- Writes (`create`, `replace`, `patch`, `delete`, `transactional-batch`, `sequence`) require + `governance.readOnly: false` on the tool. Read-only is the default. +- `delete` additionally requires `governance.allowDelete: true`. +- An invalid configuration prevents startup with a clear diagnostic — the server never starts + with a partially valid tool set. +- If `COSMOS_TOOLS_CONFIG` points at a missing file, startup fails rather than silently ignoring it. + +## A complete example + +See [`samples/banking/cosmos-tools.yaml`](../../samples/banking/cosmos-tools.yaml) for a full, +working configuration covering point reads, queries, vector search, creates, and a transactional batch. diff --git a/docs/declarative-tools/banking-migration.md b/docs/declarative-tools/banking-migration.md new file mode 100644 index 0000000..0788433 --- /dev/null +++ b/docs/declarative-tools/banking-migration.md @@ -0,0 +1,83 @@ +# Banking Multi-Agent Workshop — Migration Walkthrough + +This document classifies the Banking workshop's MCP tools and records how each maps to the +declarative toolkit. Source analyzed: the workshop `mcp` branch +(`csharp/src/BankingAPI/Services/BankingDataService.cs`, the Semantic Kernel plugins, and +`python/src/app/tools/mcp_server.py`). + +## Data model + +- Container **`accounts`** — hierarchical partition key **`[tenantId, accountId]`**, `type` + discriminator (`BankAccount`, `BankTransaction`, `ServiceRequest`). +- Container **`offers`** — partition key **`[tenantId]`**, holds `Offer` and `OfferTerm` (with a + `Vector` embedding property). + +## Tool classification + +| Tool | Purpose | Backend | Uses Cosmos | Complexity | Declarative today | Target form | Recommended location | Reason | +|---|---|---|:--:|---|:--:|---|---|---| +| `bank_balance` | Read one account | Cosmos | ✅ | simple data op | ✅ | `point-read` | Cosmos MCP Toolkit | Direct point read on `[tenantId, accountId]` | +| `get_transaction_history` | List account transactions between dates | Cosmos | ✅ | simple data op | ✅ | `query` (parameterised, partition-scoped) | Cosmos MCP Toolkit | Pure parameterised query | +| `get_offer_information` | Semantic search of product offers | Cosmos (+embeddings) | ✅ | simple data op | ✅ | `vector-search` | Cosmos MCP Toolkit | Embedding + vector search on `offers` | +| `create_account` | Open a new account | Cosmos | ✅ | simple data op | ✅ | `create` (write opt-in) | Cosmos MCP Toolkit | Single document create | +| `service_request` | File a service request | Cosmos | ✅ | simple data op | ✅ | `create` (write opt-in) | Cosmos MCP Toolkit | Single document create | +| `bank_transfer` | Request a funds transfer | Cosmos | ✅ | constrained compound | ✅ (request form) | `create` (request) + `transactional-batch` demo | Cosmos MCP Toolkit | See analysis below | +| `calculate_monthly_payment` | Loan math | pure calculation | ❌ | simple calc | n/a | stays code | Banking MCP Server | No Cosmos involvement | +| `get_branch_location` | Branch lookup by state | static data | ❌ | simple data | n/a | stays code | Banking MCP Server | Static/non-Cosmos data | +| `transfer_to_*_agent` | Agent hand-off | agent framework | ❌ | orchestration | n/a | stays code | Host / agent framework | Not a data operation | +| `health_check` | Liveness | n/a | ❌ | n/a | n/a | stays code | Either server | Infra concern | + +The migrated Cosmos-backed tools are implemented in +[`samples/banking/cosmos-tools.yaml`](../../samples/banking/cosmos-tools.yaml) and verified by +`BankingSampleConfigTests` and the emulator integration tests. + +## `bank_transfer` — detailed analysis + +`bank_transfer` is Cosmos-backed and is treated as a first-class case (not excluded for containing +business logic). + +**Reads/writes involved in a "real" transfer:** read source account, validate funds, debit source, +credit destination, create a transaction record. + +**Partition reality:** the `accounts` container is partitioned by `[tenantId, accountId]`. The +source and destination are **different `accountId` values → different logical partitions**. + +1. **Can it be one transactional batch?** **No.** A Cosmos transactional batch is scoped to a single + logical partition. Debit (source partition) + credit (destination partition) span two partitions, + so they cannot be one atomic batch under this (unchanged, GA) partition design. +2. **Can it be bounded Cosmos-only composition?** Partially. A `sequence` can read-validate-debit-credit + with optimistic concurrency and bounded compensation, but it is **not atomic** across the two + partitions — a crash between debit and credit needs compensation, which is best-effort. +3. **What exact primitive would make it atomic?** A cross-partition (multi-partition) ACID + transaction / two-phase commit. +4. **Is that in scope for a Cosmos toolkit?** **No.** Azure Cosmos DB does not provide cross-partition + ACID transactions; providing one would require a cross-system saga/2PC engine, which the product + boundary explicitly excludes. +5. **Conclusion:** the workshop's **request-based** model is the correct, fully-declarative + representation and is what we migrate: `bank_transfer` records a `FundTransfer` service request + (a single create in the source account's partition) that is fulfilled asynchronously. This is the + `bank_transfer` tool in the sample. + +To still demonstrate atomic multi-write capability, the sample also provides +`post_account_transaction`, a **transactional batch** that adjusts a balance **and** appends the +matching transaction record atomically — valid precisely because both writes share one logical +partition. This is exercised end-to-end against the emulator in +`EmulatorIntegrationTests.Transactional_batch_adjusts_balance_and_records_transaction`. + +## Semantic differences to note + +- `get_offer_information` is scoped to the tenant partition; the workshop additionally filters + `type = 'Term'` and `accountType`. Those metadata filters can be layered on with a filtered + `VectorDistance` query if strict parity is required. +- Identity fields (`tenantId`) are enforced from the caller's token claim (`tid`) rather than trusted + from the model, which is a security improvement over passing `tenantId` as a plain argument. + +## Single vs. multiple MCP servers + +The host aggregates two sibling servers: + +- **Cosmos DB MCP Toolkit** — serves the Cosmos-backed configured tools above. +- **Banking MCP Server** — retains genuinely out-of-scope tools (`calculate_monthly_payment`, + `get_branch_location`, agent hand-offs). + +The Banking server does **not** proxy the Cosmos toolkit; both are exposed to the host directly. diff --git a/docs/declarative-tools/compatibility-matrix.md b/docs/declarative-tools/compatibility-matrix.md new file mode 100644 index 0000000..91954e1 --- /dev/null +++ b/docs/declarative-tools/compatibility-matrix.md @@ -0,0 +1,58 @@ +# GA Compatibility Matrix + +The declarative layer is **additive and opt-in**. This document records the evidence that existing +GA behavior is unchanged. + +## Principle + +- No existing tool is renamed or removed. +- No input or output schema of a built-in tool is changed. +- No default is changed; no new setting is mandatory. +- The declarative runtime is dormant unless `COSMOS_TOOLS_CONFIG` (or `CosmosMcp:ToolsConfigPath`) + is provided. + +## Evidence + +| Existing capability | GA behavior | New behavior | Compatible | Evidence | +|---|---|---|:--:|---| +| Tool discovery (`tools/list`) | 8 built-in tools advertised | Same 8 tools; configured tools only added when a config file is supplied | ✅ | Registration is a no-op without config (`ConfiguredToolsRegistration.AddConfiguredCosmosTools`); existing tests unchanged | +| `list_databases` / `list_collections` | Unchanged static `[McpServerTool]` methods | Not modified | ✅ | `Program.cs` `CosmosDbTools` untouched | +| `get_recent_documents` (1–20) | Range validation unchanged | Not modified | ✅ | `CosmosDbToolsTests.GetRecentDocuments_Should_Validate_Count_Parameter` still passes | +| `text_search` property validation | Regex identifier check | Not modified | ✅ | Existing unit tests pass | +| `find_document_by_id` | Unchanged | Not modified | ✅ | Existing unit tests pass | +| `get_approximate_schema` | Unchanged | Not modified | ✅ | Existing unit tests pass | +| `vector_search` | Unchanged; explicit `selectProperties`, no wildcard | Not modified; configured vector-search also forbids wildcard | ✅ | Existing tests pass; `ConfigurationValidator` rejects `*` in `select` | +| `hybrid_search` | Unchanged | Not modified | ✅ | Existing tests pass | +| Input schemas | Closed (`additionalProperties: false`) | Configured tools also generate closed schemas | ✅ | `JsonSchemaGeneratorTests` | +| Authentication modes (Entra ID / DEV_BYPASS_AUTH) | Unchanged | Reused; configured tools honor the same principal and bypass flag | ✅ | `CallerContext.FromPrincipal`, `AuthorizationEvaluatorTests` | +| Transports (SSE + Streamable HTTP at `/mcp`) | Unchanged | Configured tools registered via the same `AddMcpServer()` builder | ✅ | `Program.cs` wiring after `WithToolsFromAssembly` | +| Environment variables | Reinterpreted? No | New optional `COSMOS_TOOLS_CONFIG` only | ✅ | Only read when present | +| `CosmosClientFactory` / `EmbeddingClientFactory` | Unchanged | Reused by `CosmosGateway` | ✅ | No edits to these files | + +## Test evidence + +- Baseline at the base commit: **18 pass / 5 fail** (the 5 failures are pre-existing and unrelated + to this work — see below). +- After this change: **63 pass / 5 fail**. The same 5 pre-existing failures remain; all 45 new + tests pass, and every originally-passing test still passes. + +### Pre-existing failures (present before this work) + +These fail at the base commit `6ebcd31` and are **not** caused by this change: + +1. `CosmosDbToolsTests.HybridSearch_Should_Reject_Wildcard_SelectProperties` — the test asserts on + `'*'` but `JsonSerializer` escapes `'` to `\u0027`, so the substring match fails. +2–5. `McpProtocolControllerIntegrationTests.*` — these POST to the SDK `/mcp` endpoint without an + `Accept: text/event-stream` header, so the Streamable HTTP transport returns `406 Not Acceptable` + before the asserted JSON-RPC error path is reached. + +They were left untouched to avoid altering baseline test expectations. + +## How to re-verify + +```powershell +cd Q:\repos\MCPToolKit +dotnet test AzureCosmosDB.MCP.Toolkit.sln +``` + +(The `net9.0` runtime is required to execute the tests.) diff --git a/docs/declarative-tools/security-governance.md b/docs/declarative-tools/security-governance.md new file mode 100644 index 0000000..8b5482b --- /dev/null +++ b/docs/declarative-tools/security-governance.md @@ -0,0 +1,68 @@ +# Security & Governance + +The declarative layer is designed to **fail closed** and to keep identity decisions server-side. + +## Authentication + +Configured tools run inside the same ASP.NET pipeline as the built-in tools and honor the same +authentication configuration (Entra ID JWT bearer, or the `DEV_BYPASS_AUTH=true` development +bypass). The caller's `ClaimsPrincipal` is read per invocation from the request `HttpContext`. + +## Authorization + +Per-tool `authorization` supports: + +- `requiredScopes` — every listed scope must be present (`scp` claim). +- `requiredRoles` — every listed role must be present (`roles` claim). +- `claims` — claim type must equal the required value. +- `tenantClaim` + `tenantField` — tenant isolation (below). +- `partitionKeyFromClaim` — partition restriction derived from identity. + +When any authorization rule is present and the caller is unauthenticated (and not in dev bypass), +the tool is denied. + +## Tenant isolation & anti-spoofing + +The tenant identity is **always** taken from a validated token claim, never from model-supplied +input: + +1. If the model supplies a `tenantField` value that differs from the `tenantClaim`, the call is + **denied** (`tenant isolation violation`). +2. Before binding, the trusted claim value is **overlaid** onto the input, so the executed + operation uses the caller's real tenant regardless of what the model sent. + +The same mechanism applies to `partitionKeyFromClaim` for partition-level restriction. + +## Injection resistance + +- Query text comes only from configuration. Caller input is bound as **parameters** + (`@name`), ids, and partition keys — never concatenated into SQL. +- Identifier paths used to build SQL fragments (search property, vector/text paths, projection + fields) come from configuration and are validated; wildcard projection is rejected. +- Unit tests assert that SQL-looking input (`'; DROP TABLE ...`, `A1' OR '1'='1`) is passed through + as a literal parameter value and never appears in the statement text. + +## Governance (fail closed) + +- **Read-only by default.** Writes require `governance.readOnly: false`; `delete` additionally + requires `allowDelete: true`. This is enforced at load time — an offending tool makes the whole + configuration invalid and the server does not start. +- **Cross-partition** queries require `allowCrossPartition: true`; otherwise queries are scoped to + the tool's partition key. +- **Limits:** `maxItems` caps result counts and `MaxItemCount`; `maxTopK` caps vector/hybrid `topK`. +- **Timeouts:** `timeoutMs` wraps each invocation in a linked cancellation token; a timeout returns + a structured `timeout` error rather than hanging. +- **Patch allow-list:** when `allowedPatchPaths` is set, patch operations must target one of the + listed JSON paths. + +## Observability + +Each configured invocation logs tool name, version, operation, database, container, latency, and a +result category (`ok`, `validation`, `authorization`, `not_found`, `conflict`, `timeout`, +`cosmos`, `internal`). Sensitive document content is not logged by default. + +## Error taxonomy + +All failures are returned as structured, client-safe JSON: `{ "error": "...", "category": "...", +"details": [...] }`. Categories include `validation`, `authorization`, `binding`, `assertion`, +`not_found`, `conflict` (ETag/precondition), `timeout`, `cosmos`, and `internal`. diff --git a/docs/declarative-tools/yaml-reference.md b/docs/declarative-tools/yaml-reference.md new file mode 100644 index 0000000..b7f3e2d --- /dev/null +++ b/docs/declarative-tools/yaml-reference.md @@ -0,0 +1,277 @@ +# YAML Configuration Reference + +Configuration is authored in YAML (or JSON) and validated at startup. Values support two token forms: + +- `${ENV}` / `${env:ENV}` — **environment substitution** applied at load time. The `${env:NAME}` + form is required and errors if unset; the bare `${NAME}` form is substituted only when an + environment variable of that name exists, otherwise it is preserved as a runtime binding. +- `${input.x}`, `${x}`, `${system.utcNow}`, `${generated.x}`, `${steps.id.field}` — **runtime + bindings** resolved per invocation. + +## Top-level + +```yaml +version: "1.0" # required; only "1.0" is supported +sources: { ... } # named Cosmos sources (required, at least one) +defaults: { ... } # optional global defaults +tools: { ... } # tool definitions +``` + +## `sources` + +```yaml +sources: + app: + type: cosmos # only "cosmos" supported + endpoint: "${COSMOS_ENDPOINT}" # or connectionString for emulator/local + connectionString: "${COSMOS_CONNECTION_STRING}" + database: "${COSMOS_DATABASE}" + authentication: + type: managed-identity # managed-identity | default-azure-credential | connection-string + connectionMode: gateway # gateway | direct (optional) +``` + +> **Note (current limitation):** all sources resolve to the toolkit's configured Cosmos account +> (from `COSMOS_ENDPOINT`/`COSMOS_CONNECTION_STRING`); `source.database` selects the database. +> Per-source distinct endpoints/credentials are a planned enhancement. + +## `defaults` + +```yaml +defaults: + source: app # default source for tools that omit one + governance: + timeoutMs: 5000 + maxItems: 100 + readOnly: true +``` + +## Tool + +```yaml +tools: + my_tool: + name: my_tool # optional; defaults to the key + description: ... + version: "1.0" + enabled: true + tags: [a, b] + examples: ["..."] + source: app + operation: { ... } # required + input: { ... } # JSON-schema subset + output: { ... } # projection/redaction + authorization: { ... } + governance: { ... } +``` + +### `input` + +```yaml +input: + type: object + required: [customerId] + properties: + customerId: { type: string, minLength: 1, maxLength: 64, pattern: "^[A-Za-z0-9_-]+$" } + limit: { type: integer, default: 10, minimum: 1, maximum: 50 } + amount: { type: number, minimum: 0.01 } + category: { type: string, enum: [a, b, c] } + active: { type: boolean } + tags: { type: array, minItems: 1, maxItems: 10, items: { type: string } } + address: { type: object, required: [city], properties: { city: { type: string } } } +``` + +The generated MCP input schema is **closed** (`additionalProperties: false`); unknown properties +are rejected, matching the built-in tools. + +### `output` + +```yaml +output: + select: # projection + rename: outputName -> sourceField + accountId: accountId + availableBalance: balance + redact: [internalRiskScore] # remove fields entirely + maxItems: 50 # cap array results +``` + +### `authorization` + +```yaml +authorization: + requiredScopes: [banking.accounts.read] + requiredRoles: [Mcp.Tool.Executor] + claims: { department: retail } # claim type => required value + tenantClaim: tid # trusted tenant source (token claim) + tenantField: tenantId # input/document field forced to equal the claim + partitionKeyFromClaim: { customerId: sub } # input value forced from identity +``` + +Tenant/partition values are always taken from validated claims and enforced against input, so a +model cannot spoof another tenant. + +### `governance` + +```yaml +governance: + readOnly: true # default; set false to enable writes + allowDelete: false # required true to permit delete + allowCrossPartition: false # required true for cross-partition queries + timeoutMs: 3000 + maxItems: 100 + maxRequestUnits: 10 + maxTopK: 10 + allowedPatchPaths: [/balance] # when set, patch ops must target one of these +``` + +## Operation types (one example each) + +### point-read + +```yaml +operation: + type: point-read + container: accounts + id: "${accountId}" + partitionKey: "${customerId}" # or partitionKeys: ["${tenantId}", "${accountId}"] +``` + +### query + +```yaml +operation: + type: query + container: transactions + statement: | + SELECT TOP @limit c.id, c.amount, c.timestamp + FROM c WHERE c.accountId = @accountId + ORDER BY c.timestamp DESC + parameters: + accountId: "${accountId}" + limit: "${limit}" + partitionKey: "${accountId}" +``` + +Only `@parameters` are bound from input; the statement text is never built from user input. + +### text-search + +```yaml +operation: + type: text-search + container: docs + property: content # validated identifier + searchText: "${query}" + limit: "${limit}" +``` + +### vector-search + +```yaml +operation: + type: vector-search + container: offers + searchText: "${query}" + vectorPath: /embedding + topK: 5 + select: [id, title, description] +``` + +### hybrid-search + +```yaml +operation: + type: hybrid-search + container: offers + searchText: "${query}" + vectorPath: /embedding + textPath: /description + topK: 5 + select: [id, title, description] +``` + +### create (write) + +```yaml +governance: { readOnly: false } +operation: + type: create + container: serviceRequests + partitionKey: "${customerId}" + document: + id: "${generated.id}" + customerId: "${customerId}" + createdAt: "${system.utcNow}" + status: open +``` + +### replace / patch (write) + +```yaml +governance: { readOnly: false, allowedPatchPaths: [/balance] } +operation: + type: patch + container: accounts + id: "${accountId}" + partitionKey: "${customerId}" + operations: + - op: replace # set | replace | add | remove | increment + path: /balance + value: "${newBalance}" + concurrency: + ifMatch: "${etag}" # optimistic concurrency +``` + +### delete (write, extra opt-in) + +```yaml +governance: { readOnly: false, allowDelete: true } +operation: + type: delete + container: accounts + id: "${accountId}" + partitionKey: "${customerId}" +``` + +### transactional-batch (single logical partition) + +```yaml +governance: { readOnly: false } +operation: + type: transactional-batch + container: accounts + partitionKeys: ["${tenantId}", "${accountId}"] + steps: + - id: adjust + type: patch + itemId: "${accountId}" + operations: [{ op: increment, path: /balance, value: "${amount}" }] + - id: record + type: create + document: { id: "${generated.txnId}", amount: "${amount}", at: "${system.utcNow}" } +``` + +### sequence (bounded Cosmos-only composition) + +```yaml +governance: { readOnly: false } +operation: + type: sequence + container: accounts + partitionKey: "${customerId}" + steps: + - id: source + type: point-read + itemId: "${sourceAccountId}" + - id: validateFunds + type: assert + expression: "${steps.source.balance >= input.amount}" + message: Insufficient funds. + - id: debit + type: patch + itemId: "${sourceAccountId}" + operations: [{ op: increment, path: /balance, value: "${negativeAmount}" }] +``` + +`assert` steps evaluate a **bounded** boolean expression (comparisons + `&&`/`||` only). +Sequences are short and acyclic — no loops, scripts, or cross-service calls. From daffadae8ce8cb458285feb755b21c63eae5cbad Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Thu, 6 Aug 2026 12:56:52 +0100 Subject: [PATCH 09/11] docs(samples): add samples overview and a non-banking (e-commerce) example to demonstrate the runtime is domain-agnostic - Add samples/README.md and samples/ecommerce/cosmos-tools.yaml (point-read, query, hybrid-search, patch allow-list, bounded sequence) - Prove genericness with EcommerceSampleConfigTests loading on the identical engine - Cross-link from declarative-tools docs and CHANGELOG --- CHANGELOG.md | 3 +- docs/declarative-tools/README.md | 13 +- samples/README.md | 26 +++ samples/ecommerce/cosmos-tools.yaml | 157 ++++++++++++++++++ .../AzureCosmosDB.MCP.Toolkit.Tests.csproj | 3 + .../Configured/BankingSampleConfigTests.cs | 41 +++++ 6 files changed, 240 insertions(+), 3 deletions(-) create mode 100644 samples/README.md create mode 100644 samples/ecommerce/cosmos-tools.yaml diff --git a/CHANGELOG.md b/CHANGELOG.md index 164d906..b33a7e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Hierarchical (subpartitioned) partition key support (`partitionKeys: [...]`). - Shared `ICosmosGateway` provider surface, injection-resistant parameter binding, closed input schema generation, and output projection/redaction. -- `samples/banking/cosmos-tools.yaml` and `docs/declarative-tools/*` (YAML reference, security & +- `samples/banking/cosmos-tools.yaml`, `samples/ecommerce/cosmos-tools.yaml` (a non-banking example + proving the engine is domain-agnostic), and `docs/declarative-tools/*` (YAML reference, security & governance, GA compatibility matrix, banking migration walkthrough). ### Compatibility diff --git a/docs/declarative-tools/README.md b/docs/declarative-tools/README.md index 19f3400..4478c6e 100644 --- a/docs/declarative-tools/README.md +++ b/docs/declarative-tools/README.md @@ -84,5 +84,14 @@ It is deliberately **not** a workflow engine, saga orchestrator, or scripting ru ## A complete example -See [`samples/banking/cosmos-tools.yaml`](../../samples/banking/cosmos-tools.yaml) for a full, -working configuration covering point reads, queries, vector search, creates, and a transactional batch. +The toolkit engine is **domain-agnostic** — it executes whatever a config file describes. See the +[samples overview](../../samples/README.md), which includes two configs built on the identical engine +with no code differences: + +- [`samples/banking/cosmos-tools.yaml`](../../samples/banking/cosmos-tools.yaml) — retail banking + (hierarchical partition keys, tenant isolation, point-read, query, vector-search, create, transactional-batch). +- [`samples/ecommerce/cosmos-tools.yaml`](../../samples/ecommerce/cosmos-tools.yaml) — a different + domain (catalog/orders) using point-read, query, hybrid-search, patch with an allow-list, and a + bounded `sequence` with an assertion. + +Anything expressible with the supported operation types works for any Cosmos-backed application. diff --git a/samples/README.md b/samples/README.md new file mode 100644 index 0000000..6f011d0 --- /dev/null +++ b/samples/README.md @@ -0,0 +1,26 @@ +# Declarative Tool Configuration Samples + +These samples demonstrate the **generic**, opt-in declarative runtime of the Azure Cosmos DB MCP +Toolkit. The toolkit engine contains **no domain-specific logic** — it reads a YAML/JSON file and +executes the described operations against any Cosmos DB container. Each sample below is just a +different configuration file; none of them required any code change to the toolkit. + +To use any sample, point the toolkit at it: + +```powershell +$env:COSMOS_TOOLS_CONFIG = "C:\path\to\.yaml" +``` + +## Samples + +| Sample | Domain | Shows | +|---|---|---| +| [`banking/cosmos-tools.yaml`](./banking/cosmos-tools.yaml) | Retail banking | Hierarchical partition keys, tenant isolation, `point-read`, `query`, `vector-search`, `create`, `transactional-batch` | +| [`ecommerce/cosmos-tools.yaml`](./ecommerce/cosmos-tools.yaml) | E‑commerce catalog & orders | A completely different domain using the same engine: `point-read`, `query`, `hybrid-search`, `patch` with an allow‑list, `sequence` with an assertion | + +## The point + +The two samples share **no toolkit code** — only different YAML. Anything you can express with the +supported operation types (point read, query, text/vector/hybrid search, create/replace/patch/delete, +transactional batch, bounded `sequence`) works for any Cosmos-backed application. See the +[YAML reference](../docs/declarative-tools/yaml-reference.md) for the full schema. diff --git a/samples/ecommerce/cosmos-tools.yaml b/samples/ecommerce/cosmos-tools.yaml new file mode 100644 index 0000000..7cefe54 --- /dev/null +++ b/samples/ecommerce/cosmos-tools.yaml @@ -0,0 +1,157 @@ +# ===================================================================================== +# Azure Cosmos DB MCP Toolkit — E-COMMERCE example (non-banking) +# ===================================================================================== +# This sample intentionally uses a completely different domain than the banking sample to +# demonstrate that the toolkit runtime is GENERIC. It shares NO toolkit code with the banking +# sample — only a different YAML file. Enable it with COSMOS_TOOLS_CONFIG. +# +# Assumed data model: +# products container — partition key /category, docs have /price, /description, /embedding +# orders container — partition key /customerId +# inventory container — partition key /sku, docs have /available +# ===================================================================================== + +version: "1.0" + +sources: + catalog: + type: cosmos + endpoint: "${COSMOS_ENDPOINT}" + database: "${COSMOS_DATABASE}" + authentication: + type: managed-identity + +defaults: + source: catalog + governance: + timeoutMs: 5000 + maxItems: 100 + readOnly: true + +tools: + + # Point read ------------------------------------------------------------------------- + get_product: + description: Returns a single product by id. + tags: [catalog, read] + operation: + type: point-read + container: products + id: "${productId}" + partitionKey: "${category}" + input: + type: object + required: [category, productId] + properties: + category: { type: string } + productId: { type: string } + output: + select: + id: id + name: name + price: price + inStock: inStock + + # Hybrid search ---------------------------------------------------------------------- + search_catalog: + description: Finds products matching a natural-language query (semantic + keyword). + tags: [catalog, search] + operation: + type: hybrid-search + container: products + searchText: "${query}" + vectorPath: /embedding + textPath: /description + topK: 10 + select: [id, name, price, description] + input: + type: object + required: [query] + properties: + query: { type: string, minLength: 1, maxLength: 500 } + governance: + maxTopK: 20 + + # Parameterised, partition-scoped query ---------------------------------------------- + list_customer_orders: + description: Lists a customer's most recent orders. + tags: [orders, read] + operation: + type: query + container: orders + statement: | + SELECT TOP @limit c.id, c.total, c.status, c.placedAt + FROM c + WHERE c.customerId = @customerId + ORDER BY c.placedAt DESC + parameters: + customerId: "${customerId}" + limit: "${limit}" + partitionKey: "${customerId}" + input: + type: object + required: [customerId] + properties: + customerId: { type: string } + limit: { type: integer, default: 20, minimum: 1, maximum: 100 } + + # Patch with an allow-list (write, explicitly enabled) -------------------------------- + update_product_price: + description: Updates a product's list price. + tags: [catalog, write] + governance: + readOnly: false + allowedPatchPaths: ["/price"] + operation: + type: patch + container: products + id: "${productId}" + partitionKey: "${category}" + operations: + - op: replace + path: /price + value: "${newPrice}" + concurrency: + ifMatch: "${etag}" + input: + type: object + required: [category, productId, newPrice] + properties: + category: { type: string } + productId: { type: string } + newPrice: { type: number, minimum: 0 } + etag: { type: string, default: "" } + + # Bounded Cosmos-only composition: read → assert → write, within one partition --------- + reserve_inventory: + description: Reserves stock for a SKU if enough is available. + tags: [inventory, write] + governance: + readOnly: false + allowedPatchPaths: ["/available"] + operation: + type: sequence + container: inventory + partitionKey: "${sku}" + steps: + - id: current + type: point-read + itemId: "${sku}" + - id: checkStock + type: assert + expression: "${steps.current.available >= input.quantity}" + message: Insufficient stock for the requested quantity. + - id: reserve + type: patch + itemId: "${sku}" + operations: + - op: increment + path: /available + value: "${negativeQuantity}" + input: + type: object + required: [sku, quantity, negativeQuantity] + properties: + sku: { type: string } + quantity: { type: integer, minimum: 1 } + negativeQuantity: { type: integer, maximum: -1 } diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/AzureCosmosDB.MCP.Toolkit.Tests.csproj b/tests/AzureCosmosDB.MCP.Toolkit.Tests/AzureCosmosDB.MCP.Toolkit.Tests.csproj index 6c73dbe..2dcbaf1 100644 --- a/tests/AzureCosmosDB.MCP.Toolkit.Tests/AzureCosmosDB.MCP.Toolkit.Tests.csproj +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/AzureCosmosDB.MCP.Toolkit.Tests.csproj @@ -28,6 +28,9 @@ PreserveNewest + + PreserveNewest + \ No newline at end of file diff --git a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/BankingSampleConfigTests.cs b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/BankingSampleConfigTests.cs index 27bf978..a2b1261 100644 --- a/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/BankingSampleConfigTests.cs +++ b/tests/AzureCosmosDB.MCP.Toolkit.Tests/Configured/BankingSampleConfigTests.cs @@ -58,3 +58,44 @@ public void Sample_registers_expected_banking_tools() tools.Single(t => t.Name == "post_account_transaction").Governance.ReadOnly.Should().BeFalse(); } } + +/// +/// Validates the non-banking (e-commerce) sample. Its sole purpose is to prove the runtime is +/// generic: a completely different domain loads and registers on the identical engine with no code +/// changes — only a different YAML file. +/// +public class EcommerceSampleConfigTests +{ + private static string SamplePath => Path.Combine(AppContext.BaseDirectory, "samples", "ecommerce-cosmos-tools.yaml"); + + private static Dictionary Env => new(StringComparer.Ordinal) + { + ["COSMOS_ENDPOINT"] = "https://shop.documents.azure.com/", + ["COSMOS_DATABASE"] = "catalog", + }; + + [Fact] + public void Sample_loads_and_validates() + { + var result = new ConfigurationLoader(Env).LoadFromFile(SamplePath); + result.IsValid.Should().BeTrue(string.Join("; ", result.Errors)); + result.Configuration!.Sources.Should().ContainKey("catalog"); + } + + [Fact] + public void Sample_registers_expected_ecommerce_tools_on_the_same_engine() + { + var result = new ConfigurationLoader(Env).LoadFromFile(SamplePath); + var tools = ConfiguredToolSet.Build(result.Configuration!); + + tools.Select(t => t.Name).Should().Contain(new[] + { + "get_product", "search_catalog", "list_customer_orders", + "update_product_price", "reserve_inventory", + }); + + tools.Single(t => t.Name == "get_product").IsWrite.Should().BeFalse(); + tools.Single(t => t.Name == "update_product_price").IsWrite.Should().BeTrue(); + tools.Single(t => t.Name == "reserve_inventory").Governance.ReadOnly.Should().BeFalse(); + } +} From 56a67e6f5608ab48fc285b8358fd40a7f3596c56 Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Thu, 6 Aug 2026 19:53:05 +0100 Subject: [PATCH 10/11] fix(providers): guard against null stream content when draining query/search results --- src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs b/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs index 1623fb6..5a6d257 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs @@ -309,6 +309,11 @@ private static async Task DrainAsync(FeedIterator iterator, int maxIt while (iterator.HasMoreResults && results.Count < maxItems) { using var response = await iterator.ReadNextAsync(cancellationToken); + if (response.Content is null) + { + continue; + } + using var doc = await JsonDocument.ParseAsync(response.Content, cancellationToken: cancellationToken); if (doc.RootElement.TryGetProperty("Documents", out var documents)) { From 243a8510ed439e92fa918f10bab0e06dba135690 Mon Sep 17 00:00:00 2001 From: Theo van Kraay Date: Mon, 10 Aug 2026 14:56:14 +0100 Subject: [PATCH 11/11] docs: mark the declarative business-tools layer as experimental Add a runtime LogWarning (opt-in path only), EXPERIMENTAL code comments/XML docs on the entry points, and experimental notices in README, docs/declarative-tools, and CHANGELOG. Additive only; no behavior change. --- CHANGELOG.md | 4 +++- README.md | 5 +++-- docs/declarative-tools/README.md | 4 ++++ .../Configuration/ConfigurationLoader.cs | 2 ++ .../Mcp/ConfiguredToolsRegistration.cs | 10 +++++++++- .../Providers/CosmosGateway.cs | 1 + .../Runtime/ConfiguredToolExecutor.cs | 1 + 7 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b33a7e3..16c9d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- **Declarative business tools (opt-in, additive):** define business-facing MCP tools in YAML/JSON +- **Declarative business tools (opt-in, additive, experimental):** define business-facing MCP tools in YAML/JSON via `COSMOS_TOOLS_CONFIG` (or `CosmosMcp:ToolsConfigPath`). Supports point-read, query, text/vector/hybrid search, create/replace/patch/delete, optimistic concurrency, transactional batch, and bounded Cosmos-only `sequence` composition with assertions, generated ids, and system @@ -26,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Compatibility - Fully backward compatible. The declarative runtime is dormant unless a configuration file is supplied; no existing tool, schema, default, or environment variable was changed. +- The declarative layer is **experimental** and may change in a future release; it is opt-in and + dormant by default. ## [1.1.2] - 2026-05-29 diff --git a/README.md b/README.md index 28c9852..e9d5832 100644 --- a/README.md +++ b/README.md @@ -26,10 +26,11 @@ This toolkit provides a **production-ready, fully automated MCP server** that ha **See it in action:** 📚 [Real-world use cases](docs/USE-CASES.md) • 🗺️ [Roadmap](ROADMAP.md) • 🤝 [Contributing](CONTRIBUTING.md) -> 🆕 **Declarative business tools (opt-in):** define secure, governed, business-facing MCP tools in +> 🧪 **Declarative business tools (opt-in, experimental):** define secure, governed, business-facing MCP tools in > YAML — point reads, queries, search, writes, and transactional batches — without writing handler > code. This is additive and dormant unless you supply a config file, so existing deployments are -> unaffected. See [docs/declarative-tools](docs/declarative-tools/README.md). +> unaffected. **This feature is experimental and may change in a future release.** +> See [docs/declarative-tools](docs/declarative-tools/README.md). ## Prerequisites diff --git a/docs/declarative-tools/README.md b/docs/declarative-tools/README.md index 4478c6e..fc9be32 100644 --- a/docs/declarative-tools/README.md +++ b/docs/declarative-tools/README.md @@ -1,5 +1,9 @@ # Declarative Business Tools (vNext) +> ⚠️ **Experimental.** This declarative layer is experimental and may change in a future release. +> It is additive and opt-in (dormant unless you supply a configuration file). Review the security, +> authorization, tenant-isolation, and governance settings before using it in production. + The Azure Cosmos DB MCP Toolkit can expose **business-facing, governed MCP tools** defined in a YAML (or JSON) file — no bespoke handler code required. This layer is **additive and opt-in**: if you do not provide a configuration file, the toolkit exposes only its GA built-in tools and diff --git a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs index ba3c6e2..37d89a9 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Configuration/ConfigurationLoader.cs @@ -1,3 +1,5 @@ +// EXPERIMENTAL: part of the opt-in declarative business-tools layer (vNext). This feature is +// experimental and may change in a future release. It is dormant unless a configuration file is supplied. using System.Text.Json; namespace AzureCosmosDB.MCP.Toolkit.Configuration; diff --git a/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredToolsRegistration.cs b/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredToolsRegistration.cs index 46440bb..f034eb3 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredToolsRegistration.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Mcp/ConfiguredToolsRegistration.cs @@ -8,8 +8,13 @@ namespace AzureCosmosDB.MCP.Toolkit.Mcp; /// /// Opt-in registration for the declarative, business-facing tool layer (vNext). -/// If no configuration file is present the toolkit behaves exactly as before — this method is a no-op. +/// If no configuration file is present the toolkit behaves exactly as before, so this method is a no-op. /// +/// +/// EXPERIMENTAL: this declarative layer is experimental and may change in a future release. It is +/// additive and opt-in (dormant unless a configuration file is supplied). Review the security, +/// authorization, tenant-isolation, and governance settings before using it in production. +/// public static class ConfiguredToolsRegistration { /// Environment variable / configuration key that points at the declarative config file. @@ -51,6 +56,9 @@ public static IMcpServerBuilder AddConfiguredCosmosTools( var tools = ConfiguredToolSet.Build(result.Configuration!); logger?.LogInformation("Loaded {Count} configured business tool(s) from '{Path}'.", tools.Count, path); + logger?.LogWarning( + "[EXPERIMENTAL] The declarative business-tools layer is experimental and may change in a future " + + "release. Review its security, authorization, tenant-isolation, and governance settings before production use."); // Shared provider surface used by the configured tools. builder.Services.AddSingleton(); diff --git a/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs b/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs index 5a6d257..4733411 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Providers/CosmosGateway.cs @@ -11,6 +11,7 @@ namespace AzureCosmosDB.MCP.Toolkit.Providers; /// avoid POCO coupling, binds all caller-derived values as parameters, and never concatenates input /// into SQL. Vector/hybrid SQL is built only from configuration-controlled (validated) paths. /// +/// EXPERIMENTAL: provider surface for the opt-in declarative business-tools layer (vNext); may change in a future release. public sealed class CosmosGateway : ICosmosGateway { private static readonly JsonSerializerOptions JsonOptions = new(); diff --git a/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredToolExecutor.cs b/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredToolExecutor.cs index 0bcf7df..32ac4b1 100644 --- a/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredToolExecutor.cs +++ b/src/AzureCosmosDB.MCP.Toolkit/Runtime/ConfiguredToolExecutor.cs @@ -15,6 +15,7 @@ public sealed record ConfiguredToolExecutionResult(string Json, bool IsError, st /// output shaping → telemetry. Never throws to the caller; all failures are returned as /// structured, client-safe JSON. /// +/// EXPERIMENTAL: part of the opt-in declarative business-tools layer (vNext); may change in a future release. public sealed class ConfiguredToolExecutor { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = false };