-
Notifications
You must be signed in to change notification settings - Fork 301
[MCP] Added read_records tool implementation #2893
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 1 commit
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
5b83a8e
Create Read Records Tool
RubenCerna2079 20bf59f
Resolve copilot comments
RubenCerna2079 f3016d0
Merge branch 'main' into dev/rubencerna/mcp-read-records-tool
RubenCerna2079 5c00888
Resolve comments
RubenCerna2079 540c44c
Address comments
RubenCerna2079 d2d2a73
Fix variable formatting
RubenCerna2079 209a3b5
Fix bug issue with policy property
RubenCerna2079 466bf53
Changes based on comments
RubenCerna2079 6925fa2
Merge branch 'main' into dev/rubencerna/mcp-read-records-tool
RubenCerna2079 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Some comments aren't visible on the classic Files Changed page.
There are no files selected for viewing
379 changes: 379 additions & 0 deletions
379
src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,379 @@ | ||
| // Copyright (c) Microsoft Corporation. | ||
| // Licensed under the MIT License. | ||
|
|
||
| using System.Text.Json; | ||
| using Azure.DataApiBuilder.Auth; | ||
| using Azure.DataApiBuilder.Config.DatabasePrimitives; | ||
| using Azure.DataApiBuilder.Config.ObjectModel; | ||
| using Azure.DataApiBuilder.Core.Authorization; | ||
| using Azure.DataApiBuilder.Core.Configurations; | ||
| using Azure.DataApiBuilder.Core.Models; | ||
| using Azure.DataApiBuilder.Core.Parsers; | ||
| using Azure.DataApiBuilder.Core.Resolvers; | ||
| using Azure.DataApiBuilder.Core.Resolvers.Factories; | ||
| using Azure.DataApiBuilder.Core.Services; | ||
| using Azure.DataApiBuilder.Core.Services.MetadataProviders; | ||
| using Azure.DataApiBuilder.Mcp.Model; | ||
| using Microsoft.AspNetCore.Http; | ||
| using Microsoft.AspNetCore.Mvc; | ||
| using Microsoft.Extensions.DependencyInjection; | ||
| using Microsoft.Extensions.Logging; | ||
| using ModelContextProtocol.Protocol; | ||
| using static Azure.DataApiBuilder.Mcp.Model.McpEnums; | ||
|
|
||
| namespace Azure.DataApiBuilder.Mcp.BuiltInTools | ||
| { | ||
| public class ReadRecordsTool : IMcpTool | ||
| { | ||
| // private readonly IMetadataProviderFactory _metadataProviderFactory; | ||
| // private readonly IQueryEngineFactory _queryEngineFactory; | ||
|
|
||
| public ToolType ToolType { get; } = ToolType.BuiltIn; | ||
|
|
||
| public Tool GetToolMetadata() | ||
| { | ||
| return new Tool | ||
| { | ||
| Name = "read_records", | ||
| Description = "Reads the records from the specified entity.", | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| InputSchema = JsonSerializer.Deserialize<JsonElement>( | ||
| @"{ | ||
| ""type"": ""object"", | ||
| ""properties"": { | ||
| ""entity"": { | ||
| ""type"": ""string"", | ||
| ""description"": ""The entity name to read from. Required."" | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }, | ||
| ""select"": { | ||
| ""type"": ""string"", | ||
| ""description"": ""A CSV of field names to include in the response. If not provided, all fields are returned. Optional."" | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }, | ||
| ""filter"": { | ||
| ""type"": ""string"", | ||
| ""description"": ""A filter expression string to restrict results. Optional."" | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }, | ||
| ""first"": { | ||
| ""type"": ""integer"", | ||
| ""description"": ""The maximum number of records to return in this page. Optional."" | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }, | ||
| ""orderby"": { | ||
| ""type"": ""array"", | ||
| ""items"": { ""type"": ""string"" }, | ||
| ""description"": ""A list of field names and directions for sorting (e.g., \""name asc\""). Optional."" | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| }, | ||
| ""after"": { | ||
| ""type"": ""string"", | ||
| ""description"": ""A cursor token for retrieving the next page of results. Optional."" | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
| }" | ||
| ) | ||
| }; | ||
| } | ||
|
|
||
| public async Task<CallToolResult> ExecuteAsync( | ||
| JsonDocument? arguments, | ||
| IServiceProvider serviceProvider, | ||
| CancellationToken cancellationToken = default) | ||
| { | ||
| ILogger<ReadRecordsTool>? logger = serviceProvider.GetService<ILogger<ReadRecordsTool>>(); | ||
|
|
||
RubenCerna2079 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| if (arguments == null) | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| return BuildErrorResult("InvalidArguments", "No arguments provided.", logger); | ||
| } | ||
|
|
||
| try | ||
| { | ||
| string entityName; | ||
| string? select = null; | ||
| string? filter = null; | ||
| int? first = null; | ||
| IEnumerable<string>? orderby = null; | ||
| string? after = null; | ||
|
|
||
| // Extract arguments | ||
| JsonElement root = arguments.RootElement; | ||
|
|
||
| if (!root.TryGetProperty("entity", out JsonElement entityElement) || string.IsNullOrWhiteSpace(entityElement.GetString())) | ||
| { | ||
| return BuildErrorResult("InvalidArguments", "Missing requirement argument 'entity'.", logger); | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| entityName = entityElement.GetString()!; | ||
|
|
||
| if (root.TryGetProperty("select", out JsonElement selectElement)) | ||
| { | ||
| select = selectElement.GetString(); | ||
| } | ||
|
|
||
| if (root.TryGetProperty("filter", out JsonElement filterElement)) | ||
| { | ||
| filter = filterElement.GetString(); | ||
| } | ||
|
|
||
| if (root.TryGetProperty("first", out JsonElement firstElement)) | ||
| { | ||
| first = firstElement.GetInt32(); | ||
| } | ||
|
|
||
| if (root.TryGetProperty("orderby", out JsonElement orderbyElement)) | ||
| { | ||
| orderby = (IEnumerable<string>?)orderbyElement.EnumerateArray().Select(e => e.GetString()); | ||
RubenCerna2079 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
|
|
||
| if (root.TryGetProperty("after", out JsonElement afterElement)) | ||
| { | ||
| after = afterElement.GetString(); | ||
| } | ||
|
|
||
| // Get required services & configuration | ||
| IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService<IQueryEngineFactory>(); | ||
| IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService<IMetadataProviderFactory>(); | ||
| RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService<RuntimeConfigProvider>(); | ||
| RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig(); | ||
|
|
||
| // Check metadata for entity exists | ||
| string dataSourceName; | ||
| ISqlMetadataProvider sqlMetadataProvider; | ||
|
|
||
| try | ||
| { | ||
| dataSourceName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); | ||
| sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); | ||
| } | ||
| catch (Exception) | ||
| { | ||
| return BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); | ||
| } | ||
|
|
||
| if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) | ||
| { | ||
| return BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); | ||
| } | ||
|
|
||
| // Authorization check in the existing entity | ||
| IAuthorizationResolver authResolver = serviceProvider.GetRequiredService<IAuthorizationResolver>(); | ||
| IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService<IHttpContextAccessor>(); | ||
| HttpContext? httpContext = httpContextAccessor.HttpContext; | ||
|
|
||
| if (httpContext is null || !authResolver.IsValidRoleContext(httpContext)) | ||
| { | ||
| return BuildErrorResult("PermissionDenied", "You do not have permission to read records for this entity.", logger); | ||
| } | ||
|
|
||
| if (!TryResolveAuthorizedRole(httpContext, authResolver, entityName, out string? effectiveRole, out string authError)) | ||
| { | ||
| return BuildErrorResult("PermissionDenied", authError, logger); | ||
| } | ||
|
|
||
| // Build and validate Find context | ||
| RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); | ||
| FindRequestContext context = new(entityName, dbObject, true); | ||
|
|
||
| requestValidator.ValidateEntity(entityName); | ||
|
|
||
| IEnumerable<string> fieldsReturnedForFind; | ||
| if (!string.IsNullOrWhiteSpace(select)) | ||
RubenCerna2079 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| fieldsReturnedForFind = select.Split(",").ToList(); | ||
| } | ||
| else | ||
| { | ||
| fieldsReturnedForFind = authResolver.GetAllowedExposedColumns(context.EntityName, effectiveRole!, context.OperationType); | ||
| } | ||
|
|
||
| context.UpdateReturnFields(fieldsReturnedForFind); | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| if (!string.IsNullOrWhiteSpace(filter)) | ||
| { | ||
| string filterQueryString = $"?{RequestParser.FILTER_URL}={filter}"; | ||
| context.FilterClauseInUrl = sqlMetadataProvider.GetODataParser().GetFilterClause(filterQueryString, $"{context.EntityName}.{context.DatabaseObject.FullName}"); | ||
| } | ||
|
|
||
| if (orderby is not null) | ||
| { | ||
| string sortQueryString = $"?{RequestParser.SORT_URL}="; | ||
| foreach (string param in orderby) | ||
| { | ||
| if (string.IsNullOrWhiteSpace(param)) | ||
| { | ||
| return BuildErrorResult("InvalidArguments", "Parameters inside 'orderby' argument cannot be empty or null.", logger); | ||
| } | ||
|
|
||
| sortQueryString += $"{param}, "; | ||
| } | ||
|
|
||
| sortQueryString = sortQueryString.Substring(0, sortQueryString.Length - 2); | ||
| (context.OrderByClauseInUrl, context.OrderByClauseOfBackingColumns) = RequestParser.GenerateOrderByLists(context, sqlMetadataProvider, sortQueryString); | ||
| } | ||
|
|
||
| context.First = first; | ||
| context.After = after; | ||
|
|
||
| // Execute | ||
| IQueryEngine queryEngine = queryEngineFactory.GetQueryEngine(sqlMetadataProvider.GetDatabaseType()); | ||
| JsonDocument? queryResult = await queryEngine.ExecuteAsync(context); | ||
| IActionResult actionResult = queryResult is null ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, metadataProviderFactory.GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true) | ||
| : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, metadataProviderFactory.GetMetadataProvider(dataSourceName), runtimeConfigProvider.GetConfig(), httpContext, true); | ||
|
|
||
| cancellationToken.ThrowIfCancellationRequested(); | ||
|
|
||
| // Normalize response | ||
| string rawPayloadJson = ExtractResultJson(actionResult); | ||
| using JsonDocument result = JsonDocument.Parse(rawPayloadJson); | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| JsonElement queryRoot = result.RootElement; | ||
|
|
||
| return BuildSuccessResult( | ||
| entityName, | ||
| queryRoot.Clone(), | ||
| logger); | ||
| } | ||
| catch (OperationCanceledException) | ||
| { | ||
| return BuildErrorResult("OperationCanceled", "The read operation was canceled.", logger: null); | ||
| } | ||
| catch (ArgumentException argEx) | ||
| { | ||
| return BuildErrorResult("InvalidArguments", argEx.Message, logger); | ||
| } | ||
| catch (Exception ex) | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| ILogger<ReadRecordsTool>? innerLogger = serviceProvider.GetService<ILogger<ReadRecordsTool>>(); | ||
| innerLogger?.LogError(ex, "Unexpected error in ReadRecordTool."); | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| return BuildErrorResult( | ||
| "UnexpectedError", | ||
| "An unexpected error occurred while reading the record.", | ||
| logger: null); | ||
| } | ||
| } | ||
|
|
||
| private static bool TryResolveAuthorizedRole( | ||
RubenCerna2079 marked this conversation as resolved.
Show resolved
Hide resolved
RubenCerna2079 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| HttpContext httpContext, | ||
| IAuthorizationResolver authorizationResolver, | ||
| string entityName, | ||
| out string? effectiveRole, | ||
| out string error) | ||
| { | ||
| effectiveRole = null; | ||
| error = string.Empty; | ||
|
|
||
| string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); | ||
|
|
||
| if (string.IsNullOrWhiteSpace(roleHeader)) | ||
| { | ||
| error = "Client role header is missing or empty."; | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return false; | ||
| } | ||
|
|
||
| string[] roles = roleHeader | ||
| .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) | ||
| .Distinct(StringComparer.OrdinalIgnoreCase) | ||
| .ToArray(); | ||
|
|
||
| if (roles.Length == 0) | ||
| { | ||
| error = "Client role header is missing or empty."; | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return false; | ||
| } | ||
|
|
||
| foreach (string role in roles) | ||
| { | ||
| bool allowed = authorizationResolver.AreRoleAndOperationDefinedForEntity( | ||
| entityName, role, EntityActionOperation.Read); | ||
|
|
||
| if (allowed) | ||
| { | ||
| effectiveRole = role; | ||
| return true; | ||
| } | ||
| } | ||
|
|
||
| error = "You do not have permission to read records for this entity."; | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| return false; | ||
| } | ||
|
|
||
RubenCerna2079 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| private static CallToolResult BuildSuccessResult( | ||
| string entityName, | ||
| JsonElement engineRootElement, | ||
| ILogger? logger) | ||
| { | ||
| // Build normalized response | ||
| Dictionary<string, object?> normalized = new() | ||
| { | ||
| ["status"] = "success", | ||
| ["result"] = engineRootElement // only requested values | ||
| }; | ||
|
|
||
| string output = JsonSerializer.Serialize(normalized, new JsonSerializerOptions { WriteIndented = true }); | ||
|
|
||
| logger?.LogInformation("UpdateRecordTool success for entity {Entity}.", entityName); | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| return new CallToolResult | ||
| { | ||
| Content = new List<ContentBlock> | ||
| { | ||
| new TextContentBlock { Type = "text", Text = output } | ||
| } | ||
| }; | ||
| } | ||
|
|
||
RubenCerna2079 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| private static CallToolResult BuildErrorResult( | ||
| string errorType, | ||
| string message, | ||
| ILogger? logger) | ||
| { | ||
| Dictionary<string, object?> errorObj = new() | ||
| { | ||
| ["status"] = "error", | ||
RubenCerna2079 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| ["error"] = new Dictionary<string, object?> | ||
| { | ||
| ["type"] = errorType, | ||
| ["message"] = message | ||
| } | ||
| }; | ||
|
|
||
| string output = JsonSerializer.Serialize(errorObj); | ||
|
|
||
| logger?.LogWarning("UpdateRecordTool error {ErrorType}: {Message}", errorType, message); | ||
RubenCerna2079 marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| return new CallToolResult | ||
| { | ||
| Content = | ||
| [ | ||
| new TextContentBlock { Type = "text", Text = output } | ||
| ] | ||
| }; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Extracts a JSON string from a typical IActionResult. | ||
| /// Falls back to "{}" for unsupported/empty cases to avoid leaking internals. | ||
| /// </summary> | ||
| private static string ExtractResultJson(IActionResult? result) | ||
RubenCerna2079 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| { | ||
| switch (result) | ||
| { | ||
| case ObjectResult obj: | ||
| if (obj.Value is JsonElement je) | ||
| { | ||
| return je.GetRawText(); | ||
| } | ||
|
|
||
| if (obj.Value is JsonDocument jd) | ||
| { | ||
| return jd.RootElement.GetRawText(); | ||
| } | ||
|
|
||
| return JsonSerializer.Serialize(obj.Value ?? new object()); | ||
|
|
||
| case ContentResult content: | ||
| return string.IsNullOrWhiteSpace(content.Content) ? "{}" : content.Content; | ||
|
|
||
| default: | ||
| return "{}"; | ||
| } | ||
| } | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.