-
Notifications
You must be signed in to change notification settings - Fork 280
[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
base: main
Are you sure you want to change the base?
Conversation
/azp run |
Azure Pipelines successfully started running 6 pipeline(s). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull Request Overview
This PR implements the read_records
tool for the MCP (Model Context Protocol), enabling reading of entity records with parameters like select, filter, first, orderby, and after for pagination. The implementation includes proper authorization checks and handles both REST and MCP endpoint responses differently.
- Added a new
ReadRecordsTool
class that implements the MCP read_records functionality - Modified response formatting to support both REST (nextLink) and MCP (after) pagination approaches
- Changed
GenerateOrderByLists
method visibility from private to public for reuse
Reviewed Changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.
File | Description |
---|---|
src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs |
Complete implementation of the read_records tool with argument parsing, authorization, and query execution |
src/Core/Resolvers/SqlResponseHelpers.cs |
Enhanced response formatting to support both REST nextLink and MCP after pagination patterns |
src/Core/Parsers/RequestParser.cs |
Changed GenerateOrderByLists method from private to public for reuse |
src/Core/Parsers/FilterParser.cs |
Removed blank line formatting change |
Tip: Customize your code reviews with copilot-instructions.md. Create the file or learn how to get started.
/azp run |
Azure Pipelines successfully started running 6 pipeline(s). |
}, | ||
""filter"": { | ||
""type"": ""string"", | ||
""description"": ""A filter expression string to restrict results. Optional."" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's update this to be more expressive:
A case-insensitive OData-like expression that defines a query predicate. Supports logical grouping with parentheses and the operators eq, ne, gt, ge, lt, le, and, or, not. Examples:
year ge 1990
,date lt 2025-01-01T00:00:00Z
,(title eq 'Foundation') and (available ne false)
.
""properties"": { | ||
""entity"": { | ||
""type"": ""string"", | ||
""description"": ""The entity name to read from. Required."" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's update this to be more expressive:
The name of the entity to read, as provided by the
describe_entities
tool. Required.
}, | ||
""select"": { | ||
""type"": ""string"", | ||
""description"": ""A CSV of field names to include in the response. If not provided, all fields are returned. Optional."" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's update this to be more expressive:
A comma-separated list of field names to include in the response. If omitted, all fields are returned. Optional.
return new Tool | ||
{ | ||
Name = "read_records", | ||
Description = "Reads the records from the specified entity.", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's update this to be more expressive:
Retrieves records from a given entity.
}, | ||
""first"": { | ||
""type"": ""integer"", | ||
""description"": ""The maximum number of records to return in this page. Optional."" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's update this to be more expressive:
The maximum number of records to return in the current page. Optional.
} | ||
} | ||
|
||
error = "You do not have permission to read records for this entity."; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You know the entity name so include it in the error message. Remember, error messages are intended to inform the caller with as much information as possible without leaking security so that the caller can correct the problem.
entityName
|
||
string output = JsonSerializer.Serialize(normalized, new JsonSerializerOptions { WriteIndented = true }); | ||
|
||
logger?.LogInformation("ReadRecordsTool success for entity {Entity}.", entityName); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Logs are nice, but where is the OTEL wrapper? This is important.
mcp_read_total_count, mcp_read_active_count, etc.
We want to ensure the developer experience across all 3 endpoints is the same.
{ | ||
Dictionary<string, object?> errorObj = new() | ||
{ | ||
["status"] = "error", |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Curious. Why are we not setting Success = true
and Success = false
?
// Create a 'nextLink' object if the request comes from REST endpoint. | ||
else | ||
{ | ||
string basePaginationUri = SqlPaginationUtil.ConstructBaseUriForPagination(httpContext, runtimeConfig.Runtime?.BaseRoute); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I cannot tell by looking, you might just dismiss this comment, but does this line obey this?
{
"runtime": {
"rest": {
"next-link-relative": true // default is false
}
}
}
{ | ||
public class ReadRecordsTool : IMcpTool | ||
{ | ||
// private readonly IMetadataProviderFactory _metadataProviderFactory; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove these lines since they are no longer used.
{ | ||
ILogger<ReadRecordsTool>? logger = serviceProvider.GetService<ILogger<ReadRecordsTool>>(); | ||
|
||
if (arguments == null) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Maybe we can move this inside try as well.
fieldsReturnedForFind = authResolver.GetAllowedExposedColumns(context.EntityName, effectiveRole!, context.OperationType); | ||
} | ||
|
||
context.UpdateReturnFields(fieldsReturnedForFind); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: we can add a comment on what this call is doing.
|
||
// Normalize response | ||
string rawPayloadJson = ExtractResultJson(actionResult); | ||
using JsonDocument result = JsonDocument.Parse(rawPayloadJson); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Can we declare this using in the imports section, start of the document?
} | ||
} | ||
|
||
private static bool TryResolveAuthorizedRole( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add the function summary.
error = "You do not have permission to read records for this entity."; | ||
return false; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add the function summary.
} | ||
}; | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add the function summary
public FilterClause GetFilterClause(string filterQueryString, string resourcePath, ODataUriResolver? customResolver = null) | ||
{ | ||
if (_model is null) | ||
{ |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: Is this new line introduced on purpose, if not we can revert it.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I am not following, I deleted the line that shouldn't be there. I did not introduce a new line.
nit: Fix the description, |
Why make this change?
read_records
#2833One of the main DML tools that will be used is
read_records
, which will allow the MCP to read the records of an entity based on the needs of the user.What is this change?
read_records
tool:SqlQueryEngine
in order to create and run the query and receive the resultsGenerateOrderByLists
function inside theRequestParser.cs
file was changed from private to public in order to allow theread_records
tool to also use it to generate the proper context for the query.SqlResponseHelper.cs
file were changed to check if the query request comes from theread_records
tool. This is done in order to output the correct information, right now the REST requests can also return anextLink
object which gives the API link necessary to get values in the case that not all of them were shown. We want to do something similar with theread_records
tool, however we only want to return theafter
object which is the parameter that allows the query to know the exact place where it left from the previous query. This gets rid of unecessary information that can be found in thenextLink
object.Exceptions thrown when:
orderby
parameter are empty or null. (Note:orderby
is an optional value as a whole, but the individual values it contains need to exist)Errors:
How was this tested?
Sample Request(s)
{ Entity: Book }
{ Select: title,publisher_id }
{ First: 3 }
{ Orderby: ["publisher_id asc", "title desc"] }