diff --git a/.aspire/settings.json b/.aspire/settings.json new file mode 100644 index 0000000000..61dd69a516 --- /dev/null +++ b/.aspire/settings.json @@ -0,0 +1,3 @@ +{ + "appHostPath": "../src/Aspire.AppHost/Aspire.AppHost.csproj" +} \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index a224f6bcbb..fbe6bd70b1 100644 --- a/.gitattributes +++ b/.gitattributes @@ -15,3 +15,6 @@ *.verified.txt text eol=lf working-tree-encoding=UTF-8 *.verified.xml text eol=lf working-tree-encoding=UTF-8 *.verified.json text eol=lf working-tree-encoding=UTF-8 + +# This can't be enabled as there are tests that rely on the CRLF endings in files' +#*.cs text eol=lf diff --git a/.pipelines/azure-pipelines.yml b/.pipelines/azure-pipelines.yml index a29cd29de2..88ce6bb186 100644 --- a/.pipelines/azure-pipelines.yml +++ b/.pipelines/azure-pipelines.yml @@ -9,6 +9,18 @@ trigger: - gh-readonly-queue/main - release/* +pr: + branches: + include: + - main + - release/* + paths: + exclude: + - samples/** + - docs/** + - '*.md' + - templates/** + variables: # DebugArguments: ' --filter "TestCategory!=ABC" --verbosity normal ' # ReleaseArguments: ' --filter "TestCategory!=ABC" --verbosity normal ' diff --git a/.pipelines/cosmos-pipelines.yml b/.pipelines/cosmos-pipelines.yml index 2f95f45211..61d066aac7 100644 --- a/.pipelines/cosmos-pipelines.yml +++ b/.pipelines/cosmos-pipelines.yml @@ -13,6 +13,18 @@ trigger: - gh-readonly-queue/main - release/* +pr: + branches: + include: + - main + - release/* + paths: + exclude: + - samples/** + - docs/** + - '*.md' + - templates/** + strategy: matrix: windows: diff --git a/.pipelines/dwsql-pipelines.yml b/.pipelines/dwsql-pipelines.yml index 2ae2730747..767c6c7df9 100644 --- a/.pipelines/dwsql-pipelines.yml +++ b/.pipelines/dwsql-pipelines.yml @@ -16,6 +16,18 @@ trigger: exclude: - docs +pr: + branches: + include: + - main + - release/* + paths: + exclude: + - samples/** + - docs/** + - '*.md' + - templates/** + jobs: - job: linux pool: diff --git a/.pipelines/mssql-pipelines.yml b/.pipelines/mssql-pipelines.yml index 81955bd889..af823db200 100644 --- a/.pipelines/mssql-pipelines.yml +++ b/.pipelines/mssql-pipelines.yml @@ -16,6 +16,18 @@ trigger: exclude: - docs +pr: + branches: + include: + - main + - release/* + paths: + exclude: + - samples/** + - docs/** + - '*.md' + - templates/** + jobs: - job: linux condition: false # Disable until we resolve flakey pipeline issue. https://github.com/Azure/data-api-builder/issues/2010 @@ -110,7 +122,7 @@ jobs: displayName: 'Run MsSql Integration Tests' inputs: command: test - arguments: '--filter "TestCategory=MsSql" --no-build --configuration $(buildConfiguration) --collect "XPlat Code coverage"' + arguments: '--filter "TestCategory=MsSql&FullyQualifiedName!~ConfigurationHotReloadTests" --no-build --configuration $(buildConfiguration) --collect "XPlat Code coverage"' projects: '**/*Tests/*.csproj' - task: CmdLine@2 @@ -234,11 +246,20 @@ jobs: targetFiles: 'src/out/tests/*/dab-config.MsSql.json' - task: DotNetCoreCLI@2 - displayName: 'Run MsSql Integration Tests' + displayName: 'MsSql Integration Tests' + inputs: + command: test + arguments: '--filter "TestCategory=MsSql&FullyQualifiedName!~ConfigurationHotReloadTests" --no-build --configuration $(buildConfiguration) --collect "XPlat Code coverage"' + projects: '**/*Tests/*.csproj' + + + - task: DotNetCoreCLI@2 + displayName: 'Hot-Reload Tests' inputs: command: test - arguments: '--filter "TestCategory=MsSql" --no-build --configuration $(buildConfiguration) --collect "XPlat Code coverage"' + arguments: '--filter "TestCategory=MsSql&FullyQualifiedName~ConfigurationHotReloadTests" --no-build --configuration $(buildConfiguration) --collect "XPlat Code coverage" --logger "console;verbosity=detailed"' projects: '**/*Tests/*.csproj' + timeoutInMinutes: 45 - task: PublishCodeCoverageResults@1 displayName: 'Publish code coverage' diff --git a/.pipelines/mysql-pipelines.yml b/.pipelines/mysql-pipelines.yml index 2623415a70..7377d5b69c 100644 --- a/.pipelines/mysql-pipelines.yml +++ b/.pipelines/mysql-pipelines.yml @@ -14,6 +14,18 @@ trigger: - gh-readonly-queue/main - release/* +pr: + branches: + include: + - main + - release/* + paths: + exclude: + - samples/** + - docs/** + - '*.md' + - templates/** + jobs: - job: linux pool: diff --git a/.pipelines/pg-pipelines.yml b/.pipelines/pg-pipelines.yml index 6618dab734..d6b7600e1f 100644 --- a/.pipelines/pg-pipelines.yml +++ b/.pipelines/pg-pipelines.yml @@ -9,6 +9,18 @@ trigger: - gh-readonly-queue/main - release/* +pr: + branches: + include: + - main + - release/* + paths: + exclude: + - samples/** + - docs/** + - '*.md' + - templates/** + jobs: - job: linux pool: diff --git a/.pipelines/unittest-pipelines.yml b/.pipelines/unittest-pipelines.yml index b82562f780..56357a181c 100644 --- a/.pipelines/unittest-pipelines.yml +++ b/.pipelines/unittest-pipelines.yml @@ -1,6 +1,26 @@ # Copyright (c) Microsoft Corporation. # Licensed under the MIT License. +trigger: + batch: true + branches: + include: + - main + - gh-readonly-queue/main + - release/* + +pr: + branches: + include: + - main + - release/* + paths: + exclude: + - samples/** + - docs/** + - '*.md' + - templates/** + pool: vmImage: 'ubuntu-latest' # examples of other options: 'macOS-10.15', 'windows-2019' diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ab98fbe8cf..a9fd633f27 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,7 +95,7 @@ We use `dotnet format` to enforce code conventions. It is run automatically in C #### Enforcing code style with git hooks -You can copy paste the following commands to install a git pre-commit hook. This will cause a commit to fail if you forgot to run `dotnet format`. If you have run on save enabled in your editor this is not necessary. +You can copy paste the following commands to install a git pre-commit hook (creates a pre-commit file in your .git folder, which isn't shown in vs code). This will cause a commit to fail if you forgot to run `dotnet format`. If you have run on save enabled in your editor this is not necessary. ```bash cat > .git/hooks/pre-commit << __EOF__ @@ -112,17 +112,42 @@ if [ "\$(get_files)" = '' ]; then fi get_files | - xargs dotnet format src/Azure.DataApiBuilder.Service.sln \\ - --check \\ - --fix-whitespace --fix-style warn --fix-analyzers warn \\ + xargs dotnet format src/Azure.DataApiBuilder.sln \\ + --verify-no-changes --include \\ || { get_files | - xargs dotnet format src/Azure.DataApiBuilder.Service.sln \\ - --fix-whitespace --fix-style warn --fix-analyzers warn \\ + xargs dotnet format src/Azure.DataApiBuilder.sln \\ --include exit 1 } __EOF__ chmod +x .git/hooks/pre-commit ``` + +The file should look like this + +``` bash +#!/bin/bash +set -euo pipefail + +get_files() { + git diff --cached --name-only --diff-filter=ACMR | \ + grep '\.cs$' +} + +if [ "$(get_files)" = '' ]; then + exit 0 +fi + +get_files | + xargs dotnet format src/Azure.DataApiBuilder.sln \ + --verify-no-changes \ + --include \ + || { + get_files | + xargs dotnet format src/Azure.DataApiBuilder.sln \ + --include + exit 1 +} +``` diff --git a/README.md b/README.md index 1746a62482..ec9d83ca73 100644 --- a/README.md +++ b/README.md @@ -7,9 +7,9 @@ [What's new?](https://learn.microsoft.com/azure/data-api-builder/whats-new) -## Community +## Join the community -Join the Data API builder community! This sign up will help us maintain a list of interested developers to be part of our roadmap and to help us better understand the different ways DAB is being used. Sign up [here](https://forms.office.com/pages/responsepage.aspx?id=v4j5cvGGr0GRqy180BHbR1S1JdzGAxhDrefV-tBYtwZUNE1RWVo0SUVMTkRESUZLMVVOS0wwUFNVRy4u). +Want to be part of our priorities and roadmap? Sign up [here](https://forms.office.com/pages/responsepage.aspx?id=v4j5cvGGr0GRqy180BHbR1S1JdzGAxhDrefV-tBYtwZUNE1RWVo0SUVMTkRESUZLMVVOS0wwUFNVRy4u). ![](docs/media/dab-logo.png) @@ -17,199 +17,299 @@ Join the Data API builder community! This sign up will help us maintain a list o Data API builder (DAB) is an open-source, no-code tool that creates secure, full-featured REST and GraphQL endpoints for your database. It’s a CRUD data API engine that runs in a container—on Azure, any other cloud, or on-premises. DAB is built for developers with integrated tooling, telemetry, and other productivity features. -```mermaid -erDiagram - DATA_API_BUILDER ||--|{ DATA_API : "Provides" - DATA_API_BUILDER { - container true "Microsoft Container Repository" - open-source true "MIT license / any cloud or on-prem." - objects true "Supports: Table / View / Stored Procedure" - developer true "Swagger / Nitro (fka Banana Cake Pop)" - otel true "Open Telemetry / Structured Logs / Health Endpoints" - security true "EntraId / EasyAuth / OAuth / JWT / Anonymous" - cache true "Level1 (in-memory) / Level2 (redis)" - policy true "Item policy / Database policy / Claims policy" - hot_reload true "Dynamically controllable log levels" - } - DATA_API ||--o{ DATASOURCE : "Queries" - DATA_API { - REST true "$select / $filter / $orderby" - GraphQL true "relationships / multiple mutations" - } - DATASOURCE { - MS_SQL Supported - PostgreSQL Supported - Cosmos_DB Supported - MySQL Supported - SQL_DW Supported - } - CLIENT ||--o{ DATA_API : "Consumes" - CLIENT { - Transport HTTP "HTTP / HTTPS" - Syntax JSON "Standard payloads" - Mobile Supported "No requirement" - Web Supported "No requirement" - Desktop Supported "No requirement" - Language Any "No requirement" - Framework None "Not required" - Library None "Not required" - ORM None "Not required" - Driver None "Not required" - } -``` +> [!IMPORTANT] +> Data API builder (DAB) is open source and always free. + +### Which databases does Data API builder support? + +| | Azure SQL | SQL Server | SQLDW | Cosmos DB | PostgreSQL | MySQL | +| :-----------: | :-------: | :--------: | :---: | :-------: | :--------: | :---: | +| **Supported** | Yes | Yes | Yes | Yes | Yes | Yes | + +### Which environments does Data API builder support? + +| | On-Prem | Azure | AWS | GCP | Other | +| :-----------: | :-----: | :---: | :--: | :--: | :---: | +| **Supported** | Yes | Yes | Yes | Yes | Yes | + +### Which endpoints does Data API builder support? + +| | REST | GraphQL | MCP | +| :-----------: | :--: | :-----: | :---------: | +| **Supported** | Yes | Yes | Coming soon | + +## Getting started + +Use the [Getting Started](https://learn.microsoft.com/azure/data-api-builder/get-started/get-started-with-data-api-builder) tutorial to quickly explore the core tools and concepts. -## Getting Started +### 1. Install the `dotnet` [command line](https://get.dot.net) -Use the [Getting Started](https://learn.microsoft.com/azure/data-api-builder/get-started/get-started-with-data-api-builder) tutorial to quickly explore the core tools and concepts. It gives you hands-on experience with how DAB makes you more efficient by removing boilerplate code. +https://get.dot.net -**1. Install the DAB CLI** +> [!NOTE] +> You may already have .NET installed! -The [DAB CLI](https://aka.ms/dab/docs) is a cross-platform .NET tool. Install the [.NET SDK](https://get.dot.net) before running: +The Data API builder (DAB) command line requires the .NET runtime version 8 or later. +#### Validate your installation + +```sh +dotnet --version ``` + +### 2. Install the `dab` command line + +The Data API builder (DAB) command line is cross-platform and intended for local developer use. + +```sh dotnet tool install microsoft.dataapibuilder -g ``` -**2. Create your initial configuration file** +#### Validate your installation + +```sh +dab --version +``` + +### 3. Create your database (example: Azure SQL database / T-SQL) + +This example uses a single table for simplicity. + +```sql +CREATE TABLE dbo.Todo +( + Id INT PRIMARY KEY IDENTITY, + Title NVARCHAR(500) NOT NULL, + IsCompleted BIT NOT NULL DEFAULT 0 +); +INSERT dbo.Todo (Title, IsCompleted) +VALUES + ('Walk the dog', 0), + ('Feed the fish', 0), + ('Clean the cat', 1); +``` + +### 4. Prepare your connection string -DAB requires a JSON configuration file. Edit manually or with the CLI. Use `dab --help` for syntax options. +Data API builder (DAB) supports `.env` files for testing process-level environment variables. +#### PowerShell (Windows) + +```ps +echo "my-connection-string=$env:database_connection_string" > .env ``` + +#### cmd.exe (Windows) + +```cmd +echo my-connection-string=%database_connection_string% > .env +``` + +#### bash (macOS/Linux) + +```bash +echo "my-connection-string=$database_connection_string" > .env +``` + +#### Resulting .env file + +The file `.env` is automatically created through this process. These are the resulting contents: + +``` +"my-connection-string=$env:database_connection_string" +``` +> [!NOTE] +> Be sure and replace `database_connection_string` with your actual database connection string. + +> [!IMPORTANT] +> Adding `.env` to your `.gitignore` file will help ensure your secrets are not added to source control. + +### 5. Create your initial configuration file + +Data API builder (DAB) requires a JSON configuration file. Use `dab --help` for syntax options. + +```sh dab init --database-type mssql --connection-string "@env('my-connection-string')" --host-mode development ``` -**3. Add your first table** +> [!NOTE] +> Including `--host-mode development` enables Swagger for REST and Nitro for GraphQL. -DAB supports tables, views, and stored procedures. It works with SQL Server, Azure Cosmos DB, PostgreSQL, MySQL, and SQL Data Warehouse. Security is engine-level, but permissions are per entity. +#### Resulting configuration +The file `dab-config.json` is automatically created through this process. These are the resulting contents: + +```json +{ + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.5.56/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "@env('my-connection-string')", + "options": { + "set-session-context": false + } + }, + "runtime": { + "rest": { + "enabled": true, + "path": "/api", + "request-body-strict": true + }, + "graphql": { + "enabled": true, + "path": "/graphql", + "allow-introspection": true + }, + "host": { + "cors": { + "origins": [], + "allow-credentials": false + }, + "authentication": { + "provider": "AppService" + }, + "mode": "development" + } + }, + "entities": { } +} ``` -dab add Actor - --source "dbo.Actor" +### 6. Add your table to the configuration + +```sh +dab add Todo + --source "dbo.Todo" --permissions "anonymous:*" ``` -**4. Run Data API builder** +> [!NOTE] +> DAB supports tables, views, and stored procedures. When the type is not specified, the default is `table`. + +#### Resulting configuration + +The `entities` section of the configuration is no longer empty: + +```json +{ + "entities": { + "Todo": { + "source": { + "object": "dbo.Todo", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "Todo", + "plural": "Todos" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ] + } + } +} +``` -In `production`, DAB runs in a container. In `development`, it’s self-hosted locally with hot reload, Swagger, and Nitro (fka Banana Cake Pop) support. +### 7. Run Data API builder -``` +In `production`, DAB runs in a container. In `development`, it’s locally self-hosted. + +```sh dab start ``` -> **Note**: Before you run `dab start`, make sure your connection string is stored in an environment variable called `my-connection-string`. This is required for `@env('my-connection-string')` in your config file to work. The easiest way is to create a `.env` file with `name=value` pairs—DAB will load these automatically at runtime. +> [!IMPORTANT] +> The DAB CLI assumes your configuration file is called `dab-config.json` and is in the local folder. -**5. Access your data source** +### 8. Access your data! -By default, DAB enables both REST and GraphQL. REST supports `$select`, `$filter`, and `$orderBy`. GraphQL uses config-defined relationships. +By default, DAB enables both REST and GraphQL. +```sh +GET http://localhost:5000/api/Todo ``` -GET http://localhost:5000/api/Actor -``` - -### Walk-through video - - Play Video - +> [!NOTE] +> Change the URL to match your port if it is different. -Demo source code: [startrek](https://aka.ms/dab/startrek) +#### Other things you should try -## Overview - -| Category | Features | -|----------------|----------| -| **Database Objects** | • NoSQL collections
• RDBMS tables, views, stored procedures | -| **Data Sources** | • SQL Server & Azure SQL
• Azure Cosmos DB
• PostgreSQL
• MySQL | -| **REST** | • `$select` for projection
• `$filter` for filtering
• `$orderBy` for sorting | -| **GraphQL** | • Relationship navigation
• Data aggregation
• Multiple mutations | -| **Telemetry** | • Structured logs
• OpenTelemetry
• Application Insights
• Health endpoints | -| **Advanced** | • Pagination
• Level 1 (in-memory) cache | -| **Authentication** | • OAuth2/JWT
• EasyAuth
• Entra ID | -| **Authorization** | • Role-based support
• Entity permissions
• Database policies | -| **Developer** | • Cross-platform CLI
• Swagger (REST)
• Nitro [previously Banana Cake Pop] (GraphQL)
• Open Source
• Configuration Hot Reload | +* DAB’s Health endpoint: `http://localhost:5000/health` +* DAB’s Swagger UI: `http://localhost:5000/swagger` +* DAB’s Nitro UI: `http://localhost:5000/graphql` ## How does it work? -This diagram shows how DAB works. DAB dynamically creates endpoints from your config file. It translates HTTP requests to SQL, returns JSON, and auto-pages results. +DAB dynamically creates endpoints and translates requests to SQL, returning JSON. ```mermaid sequenceDiagram - actor Client - - box Data API builder (DAB) - participant Endpoint - participant QueryBuilder - end - - participant Configuration as Configuration File - - box Data Source - participant DB - end - - Endpoint->>Endpoint: Start - activate Endpoint - Endpoint->>Configuration: Request - Configuration-->>Endpoint: Configuration - Endpoint->>DB: Request - DB-->>Endpoint: Metadata - Note over Endpoint, DB: Some configuration is validated against the metadata - Endpoint-->>Endpoint: Configure - deactivate Endpoint - Client-->>Endpoint: HTTP Request - activate Endpoint - critical - Endpoint-->>Endpoint: Authenticate - Endpoint-->>Endpoint: Authorize - end - Endpoint->>QueryBuilder: Request - QueryBuilder-->>Endpoint: SQL - alt Cache - Endpoint-->>Endpoint: Use Cache - else Query - Endpoint-->>DB: Request - Note over Endpoint, DB: Query is automatically throttled and results paginated - DB->>Endpoint: Results - Note over Endpoint, DB: Results are automatically cached for use in next request - end - Endpoint->>Client: HTTP 200 - deactivate Endpoint -``` - -Because DAB is stateless, it can scale up or out using any container size. It builds a feature-rich API like you would from scratch—but now you don’t have to. - -## Additional Resources - -- [Online Documentation](https://aka.ms/dab/docs) -- [Official Samples](https://aka.ms/dab/samples) -- [Known Issues](https://learn.microsoft.com/azure/data-api-builder/known-issues) -- [Feature Roadmap](https://github.com/Azure/data-api-builder/discussions/1377) + actor Client as Client + participant Endpoint as Endpoint + participant QueryBuilder as QueryBuilder + participant DB as Database + + %% Initialization / Warming up section (light grey) + rect rgba(120,120,120,0.10) + Endpoint -->>+ Endpoint: Read Config + Endpoint ->> DB: Query Metadata + DB -->> Endpoint: Metadata Response + Endpoint ->>- Endpoint: Start Engine + end + + %% Request/Response section (very light purple) + rect rgba(180,150,255,0.11) + Client ->>+ Endpoint: HTTP Request + Endpoint ->> Endpoint: Authorize + Endpoint ->> QueryBuilder: Invoke + QueryBuilder -->> Endpoint: SQL Query + Endpoint ->> DB: Submit Query + DB -->> Endpoint: Data Response + Endpoint -->>- Client: HTTP Response + end +``` -#### References +## Additional resources -- [Microsoft REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/Guidelines.md) -- [Microsoft Azure REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md) -- [GraphQL Specification](https://graphql.org/) +* [Online Documentation](https://aka.ms/dab/docs) +* [Official Samples](https://aka.ms/dab/samples) +* [Known Issues](https://learn.microsoft.com/azure/data-api-builder/known-issues) +* [Feature Roadmap](https://github.com/Azure/data-api-builder/discussions/1377) -### How to Contribute +#### References -To contribute, see these documents: +* [Microsoft REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/Guidelines.md) +* [Microsoft Azure REST API Guidelines](https://github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md) +* [GraphQL Specification](https://graphql.org/) -- [Code of Conduct](./CODE_OF_CONDUCT.md) -- [Security](./SECURITY.md) -- [Contributing](./CONTRIBUTING.md) +### How to contribute -### License +To contribute, see these documents: -**Data API builder for Azure Databases** is licensed under the MIT License. See [LICENSE](./LICENSE.txt) for details. +* [Code of Conduct](./CODE_OF_CONDUCT.md) +* [Security](./SECURITY.md) +* [Contributing](./CONTRIBUTING.md) +* [MIT License](./LICENSE.txt) -### Third-Party Component Notice +### Third-party component notice -Nitro (fka Banana Cake Pop by ChilliCream, Inc.) may optionally store work in its cloud service via your ChilliCream account. Microsoft is not affiliated with or endorsing this service. Use at your discretion. +Nitro (formerly Banana Cake Pop by ChilliCream, Inc.) may optionally store work in its cloud service via your ChilliCream account. Microsoft is not affiliated with or endorsing this service. Use at your discretion. ### Trademarks -This project may use trademarks or logos. Use of Microsoft trademarks must follow Microsoft’s [Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks). Use of third-party marks is subject to their policies. +This project may use trademarks or logos. Use of Microsoft trademarks must follow Microsoft’s [Trademark & Brand Guidelines](https://www.microsoft.com/en-us/legal/intellectualproperty/trademarks). Use of third-party marks is subject to their policies. \ No newline at end of file diff --git a/docs/media/dab-aifoundry-architecture.png b/docs/media/dab-aifoundry-architecture.png new file mode 100644 index 0000000000..6823ef6418 Binary files /dev/null and b/docs/media/dab-aifoundry-architecture.png differ diff --git a/docs/testing-guide/ai-foundry-integration.md b/docs/testing-guide/ai-foundry-integration.md new file mode 100644 index 0000000000..0165b725f6 --- /dev/null +++ b/docs/testing-guide/ai-foundry-integration.md @@ -0,0 +1,275 @@ +# Deploying SQL MCP Server implemented in Data API builder and Integrating with Azure AI Foundry + +This document provides an end‑to‑end guide to stand up a **SQL MCP Server** with **Model Context Protocol (MCP)** tools implemented in **Data API builder (DAB)** container that also exposes **REST** and **GraphQL** endpoints, and to integrate those MCP tools with an **Azure AI Foundry Agent**. + +Architecture diagram showing SQL MCP Server integration with Azure AI Foundry + +## 1. Architecture Overview + +**Components** +- **Azure SQL Database** hosting domain tables and stored procedures. +- **DAB container** (Azure Container Instances in this guide) that: + - reads `dab-config.json` from an **Azure Files** share at startup, + - exposes **REST**, **GraphQL**, and **MCP** endpoints. +- **Azure Storage (Files)** to store and version `dab-config.json`. +- **Azure AI Foundry Agent** configured with an **MCP tool** pointing to the SQL MCP Server endpoint. + +**Flow** +1. DAB starts in ACI → reads `dab-config.json` from the mounted Azure Files share. +2. DAB exposes `/api` (REST), `/graphql` (GraphQL), and `/mcp` (MCP). +3. Azure AI Foundry Agent invokes MCP tools to read/update data via DAB’s surface (tables, views and stored procedures). + + +## 2. Prerequisites +- Azure Subscription with permissions for Resource Groups, Storage, ACI, and Azure SQL. +- Azure SQL Database provisioned and reachable from ACI. +- Azure CLI (`az`) and .NET SDK installed locally. +- DAB CLI version **1.7.81 or later**. +- Outbound network access from ACI to your Azure SQL server. + + +## 3. Prepare the Database + +You need to create the necessary tables and stored procedures in your Azure SQL Database. Below is an example of how to create a simple `Products` table and a stored procedure to retrieve products by category. + +**Example:** + +1. Connect to your Azure SQL Database using Azure Data Studio, SQL Server Management Studio, or the Azure Portal's Query Editor. + +2. Run the following SQL script to create a sample table and stored procedure: + +```sql +-- Create Products table +CREATE TABLE Products ( + ProductID INT IDENTITY(1,1) PRIMARY KEY, + Name NVARCHAR(100) NOT NULL, + Category NVARCHAR(50) NOT NULL, + Price DECIMAL(10,2) NOT NULL +); + +-- Create stored procedure to get products by category +CREATE PROCEDURE GetProductsByCategory + @Category NVARCHAR(50) +AS +BEGIN + SET NOCOUNT ON; + SELECT ProductID, Name, Category, Price + FROM Products + WHERE Category = @Category; +END; +``` + +## 4. Install DAB CLI and Bootstrap Configuration + +``` +dotnet tool install --global Microsoft.DataApiBuilder --version 1.7.81 +export DATABASE_CONNECTION_STRING="Server=.database.windows.net;Database=;User ID=;Password=;Encrypt=True;" + +dab init \ + --database-type "mssql" \ + --connection-string "@env('DATABASE_CONNECTION_STRING')" \ + --host-mode "Development" \ + --rest.enabled true \ + --graphql.enabled true \ + --mcp.enabled true \ + --mcp.path "/mcp" + +``` + +## 5. Add all required entities (tables and stored procedures) to `dab-config.json` and enable MCP tools in the config + +Here is how to add a table entity and a stored procedure to your `dab-config.json`, and ensure MCP tools are enabled: + +1. **Open your `dab-config.json` file.** + +2. **Add an entity (table) definition** under the `"entities"` section. For example, to expose a `Customers` table: + ``` + "entities": { + "Customers": { + "source": "Customers", + "rest": true, + "graphql": true, + "mcp": true, + "permissions": [ + { + "role": "anonymous", + "actions": [ "read", "create", "update", "delete" ] + } + ] + } + } + ``` + +3. **Add a stored procedure** under the "entities" section. For example, to expose a stored procedure called GetCustomerOrders: + + ``` + "GetCustomerOrders": { + "source": { + "object": "GetCustomerOrders", + "type": "stored-procedure" + }, + "rest": true, + "graphql": true, + "mcp": true, + "permissions": [ + { + "role": "anonymous", + "actions": [ "execute" ] + } + ] + } + ``` + +Note: Make sure the "entities" section is a valid JSON object. If you have multiple entities, separate them with commas. + +4. **Ensure MCP is enabled in the "runtime" section:** + +``` +"runtime": { + "rest": { "enabled": true }, + "graphql": { "enabled": true }, + "mcp": { + "enabled": true, + "path": "/mcp" + } +} +``` + +5. **Example dab-config.json structure:** + +``` +{ + "data-source": { + "database-type": "mssql", + "connection-string": "@env('DATABASE_CONNECTION_STRING')" + }, + "runtime": { + "rest": { "enabled": true }, + "graphql": { "enabled": true }, + "mcp": { + "enabled": true, + "path": "/mcp" + } + }, + "entities": { + "Customers": { + "source": "Customers", + "rest": true, + "graphql": true, + "mcp": true, + "permissions": [ + { + "role": "anonymous", + "actions": [ "read", "create", "update", "delete" ] + } + ] + }, + "GetCustomerOrders": { + "source": { + "object": "GetCustomerOrders", + "type": "stored-procedure" + }, + "rest": true, + "graphql": true, + "mcp": true, + "permissions": [ + { + "role": "anonymous", + "actions": [ "execute" ] + } + ] + } + } +} +``` + +6. **Save the file.** + +## 6. Store dab-config.json in Azure Files + +1. **Create a Storage Account** (if you don't have one): +az storage account create +--name +--resource-group +--location +--sku Standard_LRS + + +2. **Create a File Share**: +az storage share create +--name +--account-name + + +3. **Upload `dab-config.json` to the File Share**: +az storage file upload +--account-name +--share-name +--source ./dab-config.json +--path dab-config.json + + +4. **Retrieve the Storage Account key** (needed for mounting in ACI): +az storage account keys list +--account-name +--resource-group + +Use the value of `key1` or `key2` as `` in the next step. + + +## 7. Deploy DAB to Azure Container Instances + +``` +az container create \ + --resource-group \ + --name dab-mcp-demo \ + --image mcr.microsoft.com/azure-databases/data-api-builder:1.7.81-rc \ + --dns-name-label \ + --ports 5000 \ + --location \ + --environment-variables DAB_CONFIG_PATH="/aci/dab-config.json" \ + --azure-file-volume-share-name \ + --azure-file-volume-account-name \ + --azure-file-volume-account-key \ + --azure-file-volume-mount-path "/aci" \ + --os-type Linux \ + --cpu 1 \ + --memory 1.5 \ + --command-line "dotnet Azure.DataApiBuilder.Service.dll --ConfigFileName $DAB_CONFIG_PATH --LogLevel Debug" +``` + +## 8. Integrate with Azure AI Foundry + +Follow these steps to connect your SQL MCP endpoint deployed in DAB to Azure AI Foundry and test the integration: + +1. **Create or Open a Project** + - Navigate to the [Azure AI Foundry portal](https://ai.azure.com/foundry) and sign in. + - On the dashboard, click **Projects** in the left navigation pane. + - To create a new project, click **New Project**, enter a name (e.g., `DAB-MCP-Demo`), and click **Create**. + - To use an existing project, select it from the list. + +2. **Add an Agent** + - Within your project, go to the **Agents** tab. + - Click **Add Agent**. + - Enter an agent name (e.g., `DAB-MCP-Agent`). + - (Optional) Add a description. + - Click **Create**. + +3. **Configure the MCP Tool** + - In the agent's configuration page, go to the **Tools** section. + - Click **Add Tool** and select **MCP** from the tool type dropdown. + - In the **MCP Endpoint URL** field, enter your SQL MCP endpoint in DAB, e.g., `http:///mcp`. + - (Optional) Configure authentication if your endpoint requires it. + - Click **Save** to add the tool. + +4. **Test in Playground** + - Go to the **Playground** tab in your project. + - Select the agent you created from the agent dropdown. + - In the input box, enter a prompt that will trigger the MCP tool, such as: + ``` + Get all records from the Customers entity. + ``` + - Click **Run**. + - The agent should invoke the MCP tool, which will call your DAB MCP endpoint and return the results. + - **Expected Result:** You should see the data returned from your DAB instance displayed in the Playground output panel. + - If there are errors, check the DAB container logs and ensure the MCP endpoint is reachable from Azure AI Foundry. diff --git a/docs/Testing/mcp-inspector-testing.md b/docs/testing-guide/mcp-inspector-testing.md similarity index 100% rename from docs/Testing/mcp-inspector-testing.md rename to docs/testing-guide/mcp-inspector-testing.md diff --git a/global.json b/global.json index 7e9f2f6bb4..1bdb496ef0 100644 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.414", + "version": "8.0.417", "rollForward": "latestFeature" } } diff --git a/samples/aspire/.aspire/settings.json b/samples/aspire/.aspire/settings.json new file mode 100644 index 0000000000..bbfbabae61 --- /dev/null +++ b/samples/aspire/.aspire/settings.json @@ -0,0 +1,5 @@ +{ + "appHostPath": "../apphost.cs", + "features.minimumSdkCheckEnabled": true, + "features.singleFileAppHostEnabled": true +} \ No newline at end of file diff --git a/samples/aspire/.gitignore b/samples/aspire/.gitignore new file mode 100644 index 0000000000..f7ed5b1225 --- /dev/null +++ b/samples/aspire/.gitignore @@ -0,0 +1,5 @@ +# Do not ignore anything in this folder +!* + +# Explicitly include dab-config.json +!dab-config.json diff --git a/samples/aspire/apphost.cs b/samples/aspire/apphost.cs new file mode 100644 index 0000000000..0e44c700a8 --- /dev/null +++ b/samples/aspire/apphost.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#:sdk Aspire.AppHost.Sdk@13.0.0 +#:package Aspire.Hosting.SqlServer@13.0.0 +#:package CommunityToolkit.Aspire.Hosting.SqlDatabaseProjects@9.8.1-beta.420 +#:package CommunityToolkit.Aspire.Hosting.Azure.DataApiBuilder@9.8.1-beta.420 +#:package CommunityToolkit.Aspire.Hosting.McpInspector@9.8.0 + +var builder = DistributedApplication.CreateBuilder(args); + +var options = new +{ + SqlScript = "database.sql", + SqlDatabase = "StarTrek", + DabConfig = "dab-config.json", + DabImage = "1.7.81-rc", + SqlCmdrImage = "latest" +}; + +var sqlServer = builder + .AddSqlServer("sql-server") + .WithDataVolume("sql-data-volume") + .WithLifetime(ContainerLifetime.Persistent); + +var sqlDatabase = sqlServer + .AddDatabase("sql-database", options.SqlDatabase) + .WithCreationScript(File.ReadAllText(new FileInfo(options.SqlScript).FullName)); + +var dabServer = builder + .AddContainer("data-api", image: "azure-databases/data-api-builder", tag: options.DabImage) + .WithImageRegistry("mcr.microsoft.com") + .WithBindMount(source: new FileInfo(options.DabConfig).FullName, target: "/App/dab-config.json", isReadOnly: true) + .WithHttpEndpoint(targetPort: 5000, name: "http") + .WithEnvironment("MSSQL_CONNECTION_STRING", sqlDatabase) + .WithUrls(context => + { + context.Urls.Clear(); + context.Urls.Add(new() { Url = "/graphql", DisplayText = "Nitro", Endpoint = context.GetEndpoint("http") }); + context.Urls.Add(new() { Url = "/swagger", DisplayText = "Swagger", Endpoint = context.GetEndpoint("http") }); + context.Urls.Add(new() { Url = "/health", DisplayText = "Health", Endpoint = context.GetEndpoint("http") }); + }) + .WithOtlpExporter() + .WithParentRelationship(sqlDatabase) + .WithHttpHealthCheck("/health") + .WaitFor(sqlDatabase); + +var sqlCommander = builder + .AddContainer("sql-cmdr", "jerrynixon/sql-commander", options.SqlCmdrImage) + .WithImageRegistry("docker.io") + .WithHttpEndpoint(targetPort: 8080, name: "http") + .WithEnvironment("ConnectionStrings__db", sqlDatabase) + .WithUrls(context => + { + context.Urls.Clear(); + context.Urls.Add(new() { Url = "/", DisplayText = "Commander", Endpoint = context.GetEndpoint("http") }); + }) + .WithParentRelationship(sqlDatabase) + .WithHttpHealthCheck("/health") + .WaitFor(sqlDatabase); + +var mcpInspector = builder + .AddMcpInspector("mcp-inspector") + .WithMcpServer(dabServer) + .WithParentRelationship(dabServer) + .WithEnvironment("NODE_TLS_REJECT_UNAUTHORIZED", "0") + .WaitFor(dabServer) + .WithUrls(context => + { + context.Urls.First().DisplayText = "Inspector"; + }); + +await builder.Build().RunAsync(); \ No newline at end of file diff --git a/samples/aspire/dab-config.cmd b/samples/aspire/dab-config.cmd new file mode 100644 index 0000000000..6cc89703cc --- /dev/null +++ b/samples/aspire/dab-config.cmd @@ -0,0 +1,66 @@ +@echo off + +del /q dab-config.json 2>nul + +set MSSQL_CONNECTION_STRING=%MSSQL_CONNECTION_STRING% + +dab init --database-type mssql --connection-string "@env('MSSQL_CONNECTION_STRING')" --config dab-config.json --host-mode development + +dab add Series --source dbo.Series --source.type table --permissions "anonymous:*" --description "Star Trek series" +dab update Series --fields.name Id --fields.description "Primary key" --fields.primary-key true +dab update Series --fields.name Name --fields.description "Series name" --fields.primary-key false + +dab add Actor --source dbo.Actor --source.type table --permissions "anonymous:*" --description "An actor in the franchise" +dab update Actor --fields.name Id --fields.description "Primary key" --fields.primary-key true +dab update Actor --fields.name FirstName --fields.description "Given name" --fields.primary-key false +dab update Actor --fields.name LastName --fields.description "Family name" --fields.primary-key false +dab update Actor --fields.name BirthYear --fields.description "Year of birth" --fields.primary-key false +dab update Actor --fields.name FullName --fields.description "Computed full name" --fields.primary-key false + +dab add Species --source dbo.Species --source.type table --permissions "anonymous:*" --description "Alien species in Star Trek" +dab update Species --fields.name Id --fields.description "Primary key" --fields.primary-key true +dab update Species --fields.name Name --fields.description "Species name" --fields.primary-key false + +dab add Character --source dbo.Character --source.type table --permissions "anonymous:*" --description "A fictional character portrayed by an actor" +dab update Character --fields.name Id --fields.description "Primary key" --fields.primary-key true +dab update Character --fields.name Name --fields.description "Character name" --fields.primary-key false +dab update Character --fields.name ActorId --fields.description "Foreign key to Actor" --fields.primary-key false +dab update Character --fields.name Stardate --fields.description "Birth stardate" --fields.primary-key false + +dab add Series_Character --source dbo.Series_Character --source.type table --permissions "anonymous:*" --description "Characters appearing in series" +dab update Series_Character --fields.name SeriesId --fields.description "Foreign key to Series" --fields.primary-key true +dab update Series_Character --fields.name CharacterId --fields.description "Foreign key to Character" --fields.primary-key true +dab update Series_Character --fields.name Role --fields.description "Character role in series" --fields.primary-key false + +dab add Character_Species --source dbo.Character_Species --source.type table --permissions "anonymous:*" --description "Species composition of characters" +dab update Character_Species --fields.name CharacterId --fields.description "Foreign key to Character" --fields.primary-key true +dab update Character_Species --fields.name SpeciesId --fields.description "Foreign key to Species" --fields.primary-key true + +dab update Character --relationship Character_Actor --target.entity Actor --cardinality one --relationship.fields "ActorId:Id" +dab update Character --relationship Character_Series --cardinality many --target.entity Series_Character --relationship.fields "Id:CharacterId" +dab update Character --relationship Character_Species --cardinality many --target.entity Character_Species --relationship.fields "Id:CharacterId" + +dab update Actor --relationship Actor_Characters --cardinality many --target.entity Character --relationship.fields "Id:ActorId" + +dab update Species --relationship Species_Characters --cardinality many --target.entity Character_Species --relationship.fields "Id:SpeciesId" + +dab update Series --relationship Series_Characters --cardinality many --target.entity Series_Character --relationship.fields "Id:SeriesId" + +dab update Series_Character --relationship SeriesCharacter_Series --cardinality one --target.entity Series --relationship.fields "SeriesId:Id" +dab update Series_Character --relationship SeriesCharacter_Character --cardinality one --target.entity Character --relationship.fields "CharacterId:Id" + +dab update Character_Species --relationship CharacterSpecies_Character --cardinality one --target.entity Character --relationship.fields "CharacterId:Id" +dab update Character_Species --relationship CharacterSpecies_Species --cardinality one --target.entity Species --relationship.fields "SpeciesId:Id" + +dab add SeriesActors --source dbo.SeriesActors --source.type view --source.key-fields "Id,SeriesId" --permissions "anonymous:*" --description "Actors appearing in each series" +dab update SeriesActors --fields.name Id --fields.description "Actor id" --fields.primary-key true +dab update SeriesActors --fields.name SeriesId --fields.description "Series id" --fields.primary-key true +dab update SeriesActors --fields.name Actor --fields.description "Actor name" --fields.primary-key false +dab update SeriesActors --fields.name BirthYear --fields.description "Year of birth" --fields.primary-key false +dab update SeriesActors --fields.name Series --fields.description "Series name" --fields.primary-key false + +dab add GetSeriesActors --source dbo.GetSeriesActors --source.type stored-procedure --permissions "anonymous:*" --description "Return actors in a series" +dab update GetSeriesActors --parameters.name seriesId --parameters.description "Series identifier" --parameters.required false --parameters.default 1 +dab update GetSeriesActors --parameters.name top --parameters.description "Limit rows" --parameters.required false --parameters.default 5 + +echo DAB configuration complete! diff --git a/samples/aspire/dab-config.json b/samples/aspire/dab-config.json new file mode 100644 index 0000000000..0d3ea6f48b --- /dev/null +++ b/samples/aspire/dab-config.json @@ -0,0 +1,534 @@ +{ + "$schema": "https://github.com/Azure/data-api-builder/releases/download/v1.7.81/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "@env('MSSQL_CONNECTION_STRING')", + "options": { + "set-session-context": false + } + }, + "runtime": { + "rest": { + "enabled": true, + "path": "/api", + "request-body-strict": true + }, + "graphql": { + "enabled": true, + "path": "/graphql", + "allow-introspection": true + }, + "mcp": { + "enabled": true, + "path": "/mcp" + }, + "host": { + "cors": { + "origins": [], + "allow-credentials": false + }, + "authentication": { + "provider": "StaticWebApps" + }, + "mode": "development" + } + }, + "entities": { + "Series": { + "description": "Star Trek series", + "source": { + "object": "dbo.Series", + "type": "table" + }, + "fields": [ + { + "name": "Id", + "description": "Primary key", + "primary-key": true + }, + { + "name": "Name", + "description": "Series name", + "primary-key": false + } + ], + "graphql": { + "enabled": true, + "type": { + "singular": "Series", + "plural": "Series" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "Series_Characters": { + "cardinality": "many", + "target.entity": "Series_Character", + "source.fields": [ + "Id" + ], + "target.fields": [ + "SeriesId" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, + "Actor": { + "description": "An actor in the franchise", + "source": { + "object": "dbo.Actor", + "type": "table" + }, + "fields": [ + { + "name": "Id", + "description": "Primary key", + "primary-key": true + }, + { + "name": "FirstName", + "description": "Given name", + "primary-key": false + }, + { + "name": "LastName", + "description": "Family name", + "primary-key": false + }, + { + "name": "BirthYear", + "description": "Year of birth", + "primary-key": false + }, + { + "name": "FullName", + "description": "Computed full name", + "primary-key": false + } + ], + "graphql": { + "enabled": true, + "type": { + "singular": "Actor", + "plural": "Actors" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "Actor_Characters": { + "cardinality": "many", + "target.entity": "Character", + "source.fields": [ + "Id" + ], + "target.fields": [ + "ActorId" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, + "Species": { + "description": "Alien species in Star Trek", + "source": { + "object": "dbo.Species", + "type": "table" + }, + "fields": [ + { + "name": "Id", + "description": "Primary key", + "primary-key": true + }, + { + "name": "Name", + "description": "Species name", + "primary-key": false + } + ], + "graphql": { + "enabled": true, + "type": { + "singular": "Species", + "plural": "Species" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "Species_Characters": { + "cardinality": "many", + "target.entity": "Character_Species", + "source.fields": [ + "Id" + ], + "target.fields": [ + "SpeciesId" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, + "Character": { + "description": "A fictional character portrayed by an actor", + "source": { + "object": "dbo.Character", + "type": "table" + }, + "fields": [ + { + "name": "Id", + "description": "Primary key", + "primary-key": true + }, + { + "name": "Name", + "description": "Character name", + "primary-key": false + }, + { + "name": "ActorId", + "description": "Foreign key to Actor", + "primary-key": false + }, + { + "name": "Stardate", + "description": "Birth stardate", + "primary-key": false + } + ], + "graphql": { + "enabled": true, + "type": { + "singular": "Character", + "plural": "Characters" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "Character_Actor": { + "cardinality": "one", + "target.entity": "Actor", + "source.fields": [ + "ActorId" + ], + "target.fields": [ + "Id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + }, + "Character_Series": { + "cardinality": "many", + "target.entity": "Series_Character", + "source.fields": [ + "Id" + ], + "target.fields": [ + "CharacterId" + ], + "linking.source.fields": [], + "linking.target.fields": [] + }, + "Character_Species": { + "cardinality": "many", + "target.entity": "Character_Species", + "source.fields": [ + "Id" + ], + "target.fields": [ + "CharacterId" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, + "Series_Character": { + "description": "Characters appearing in series", + "source": { + "object": "dbo.Series_Character", + "type": "table" + }, + "fields": [ + { + "name": "SeriesId", + "description": "Foreign key to Series", + "primary-key": true + }, + { + "name": "CharacterId", + "description": "Foreign key to Character", + "primary-key": true + }, + { + "name": "Role", + "description": "Character role in series", + "primary-key": false + } + ], + "graphql": { + "enabled": true, + "type": { + "singular": "Series_Character", + "plural": "Series_Characters" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "SeriesCharacter_Series": { + "cardinality": "one", + "target.entity": "Series", + "source.fields": [ + "SeriesId" + ], + "target.fields": [ + "Id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + }, + "SeriesCharacter_Character": { + "cardinality": "one", + "target.entity": "Character", + "source.fields": [ + "CharacterId" + ], + "target.fields": [ + "Id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, + "Character_Species": { + "description": "Species composition of characters", + "source": { + "object": "dbo.Character_Species", + "type": "table" + }, + "fields": [ + { + "name": "CharacterId", + "description": "Foreign key to Character", + "primary-key": true + }, + { + "name": "SpeciesId", + "description": "Foreign key to Species", + "primary-key": true + } + ], + "graphql": { + "enabled": true, + "type": { + "singular": "Character_Species", + "plural": "Character_Species" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "CharacterSpecies_Character": { + "cardinality": "one", + "target.entity": "Character", + "source.fields": [ + "CharacterId" + ], + "target.fields": [ + "Id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + }, + "CharacterSpecies_Species": { + "cardinality": "one", + "target.entity": "Species", + "source.fields": [ + "SpeciesId" + ], + "target.fields": [ + "Id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, + "SeriesActors": { + "description": "Actors appearing in each series", + "source": { + "object": "dbo.SeriesActors", + "type": "view" + }, + "fields": [ + { + "name": "Id", + "description": "Actor id", + "primary-key": true + }, + { + "name": "SeriesId", + "description": "Series id", + "primary-key": true + }, + { + "name": "Actor", + "description": "Actor name", + "primary-key": false + }, + { + "name": "BirthYear", + "description": "Year of birth", + "primary-key": false + }, + { + "name": "Series", + "description": "Series name", + "primary-key": false + } + ], + "graphql": { + "enabled": true, + "type": { + "singular": "SeriesActors", + "plural": "SeriesActors" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ] + }, + "GetSeriesActors": { + "description": "Return actors in a series", + "source": { + "object": "dbo.GetSeriesActors", + "type": "stored-procedure", + "parameters": [ + { + "name": "top", + "description": "Limit rows", + "required": false, + "default": "5" + }, + { + "name": "seriesId", + "description": "Series identifier", + "required": false, + "default": "1" + } + ] + }, + "fields": [], + "graphql": { + "enabled": true, + "operation": "mutation", + "type": { + "singular": "GetSeriesActors", + "plural": "GetSeriesActors" + } + }, + "rest": { + "enabled": true, + "methods": [ + "post" + ] + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ] + } + } +} \ No newline at end of file diff --git a/samples/aspire/database.sql b/samples/aspire/database.sql new file mode 100644 index 0000000000..ca17dadb99 --- /dev/null +++ b/samples/aspire/database.sql @@ -0,0 +1,465 @@ +USE [Master] + +GO +IF NOT EXISTS (SELECT * FROM sys.databases WHERE name = 'StarTrek') +BEGIN + CREATE DATABASE StarTrek; +END + +GO +USE [StarTrek] + +GO +IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'Actor') +BEGIN + RETURN; -- assume all tables exist +END + +GO +-- Drop tables in reverse order of creation due to foreign key dependencies +DROP TABLE IF EXISTS Character_Species; +DROP TABLE IF EXISTS Series_Character; +DROP TABLE IF EXISTS Character; +DROP TABLE IF EXISTS Species; +DROP TABLE IF EXISTS Actor; +DROP TABLE IF EXISTS Series; +DROP VIEW IF EXISTS SeriesActors; +DROP PROC IF EXISTS GetSeriesActors; + +GO +-- create tables +CREATE TABLE Series ( + Id INT PRIMARY KEY, + Name NVARCHAR(255) NOT NULL +); + +GO +CREATE TABLE Actor ( + Id INT PRIMARY KEY, + FirstName NVARCHAR(100) NOT NULL, + LastName NVARCHAR(100) NOT NULL, + [BirthYear] INT NOT NULL, + FullName AS (RTRIM(LTRIM(FirstName + ' ' + LastName))) PERSISTED +); + +GO +CREATE TABLE Species ( + Id INT PRIMARY KEY, + Name NVARCHAR(255) NOT NULL +); + +GO +CREATE TABLE Character ( + Id INT PRIMARY KEY, + Name NVARCHAR(255) NOT NULL, + ActorId INT NOT NULL, + Stardate DECIMAL(10, 2), + FOREIGN KEY (ActorId) REFERENCES Actor(Id) +); + +GO +CREATE TABLE Series_Character ( + SeriesId INT, + CharacterId INT, + Role VARCHAR(500), + FOREIGN KEY (SeriesId) REFERENCES Series(Id), + FOREIGN KEY (CharacterId) REFERENCES Character(Id), + PRIMARY KEY (SeriesId, CharacterId) +); + +GO +CREATE TABLE Character_Species ( + CharacterId INT, + SpeciesId INT, + FOREIGN KEY (CharacterId) REFERENCES Character(Id), + FOREIGN KEY (SpeciesId) REFERENCES Species(Id), + PRIMARY KEY (CharacterId, SpeciesId) +); + +GO +-- create data +INSERT INTO Series (Id, Name) VALUES + (1, 'Star Trek'), + (2, 'Star Trek: The Next Generation'), + (3, 'Star Trek: Voyager'), + (4, 'Star Trek: Deep Space Nine'), + (5, 'Star Trek: Enterprise'), + (6, 'Star Trek: Discovery'), + (7, 'Star Trek: Strange New Worlds'), + (8, 'Star Trek: Lower Decks'), + (9, 'Star Trek: Prodigy'), + (10, 'Star Trek: Starfleet Academy'), + (11, 'Star Trek: Section 31'); + +GO +INSERT INTO Species (Id, Name) VALUES + (1, 'Human'), + (2, 'Vulcan'), + (3, 'Android'), + (4, 'Klingon'), + (5, 'Betazoid'), + (6, 'Hologram'), + (7, 'Bajoran'), + (8, 'Changeling'), + (9, 'Trill'), + (10, 'Ferengi'), + (11, 'Denobulan'), + (12, 'Borg'), + (13, 'Kelpien'), + (14, 'Illyrian'), + (15, 'Brikar'), + (16, 'Medusan'), + (17, 'Vau N''Akat'), + (18, 'Deltan'), + (19, 'Chameloid'), + (20, 'Nanokin'); + +GO +INSERT INTO Actor (Id, FirstName, LastName, [BirthYear]) VALUES + (1, 'William', 'Shatner', 1931), + (2, 'Leonard', 'Nimoy', 1931), + (3, 'DeForest', 'Kelley', 1920), + (4, 'James', 'Doohan', 1920), + (5, 'Nichelle', 'Nichols', 1932), + (6, 'George', 'Takei', 1937), + (7, 'Walter', 'Koenig', 1936), + (8, 'Patrick', 'Stewart', 1940), + (9, 'Jonathan', 'Frakes', 1952), + (10, 'Brent', 'Spiner', 1949), + (11, 'Michael', 'Dorn', 1952), + (12, 'Gates', 'McFadden', 1949), + (13, 'Marina', 'Sirtis', 1955), + (14, 'LeVar', 'Burton', 1957), + (15, 'Kate', 'Mulgrew', 1955), + (16, 'Robert', 'Beltran', 1953), + (17, 'Tim', 'Russ', 1956), + (18, 'Roxann', 'Dawson', 1958), + (19, 'Robert', 'Duncan McNeill', 1964), + (20, 'Garrett', 'Wang', 1968), + (21, 'Robert', 'Picardo', 1953), + (22, 'Jeri', 'Ryan', 1968), + (23, 'Avery', 'Brooks', 1948), + (24, 'Nana', 'Visitor', 1957), + (25, 'Rene', 'Auberjonois', 1940), + (26, 'Terry', 'Farrell', 1963), + (27, 'Alexander', 'Siddig', 1965), + (28, 'Armin', 'Shimerman', 1949), + (29, 'Cirroc', 'Lofton', 1978), + (30, 'Scott', 'Bakula', 1954), + (31, 'Jolene', 'Blalock', 1975), + (32, 'John', 'Billingsley', 1960), + (33, 'Connor', 'Trinneer', 1969), + (34, 'Dominic', 'Keating', 1962), + (35, 'Linda', 'Park', 1978), + (36, 'Anthony', 'Montgomery', 1971), + (37, 'Sonequa', 'Martin-Green', 1985), + (38, 'Doug', 'Jones', 1960), + (39, 'Anthony', 'Rapp', 1971), + (40, 'Mary', 'Wiseman', 1985), + (41, 'Wilson', 'Cruz', 1974), + -- Strange New Worlds cast + (42, 'Anson', 'Mount', 1973), + (43, 'Ethan', 'Peck', 1986), + (44, 'Rebecca', 'Romijn', 1972), + (45, 'Celia', 'Rose Gooding', 1999), + (46, 'Jess', 'Bush', 1991), + -- Lower Decks cast + (47, 'Tawny', 'Newsome', 1983), + (48, 'Jack', 'Quaid', 1992), + (49, 'Noel', 'Wells', 1986), + (50, 'Eugene', 'Cordero', 1986), + (51, 'Dawnn', 'Lewis', 1961), + -- Prodigy cast + (52, 'Brett', 'Gray', 1996), + (53, 'Ella', 'Purnell', 1996), + (54, 'Jason', 'Mantzoukas', 1972), + (55, 'Angus', 'Imrie', 1994), + (56, 'Dee Bradley', 'Baker', 1962), + -- Starfleet Academy cast + (57, 'Holly', 'Hunter', 1958), + (58, 'Paul', 'Giamatti', 1967), + (59, 'Kerrice', 'Brooks', 1990), + (60, 'Bella', 'Shepard', 2005), + (61, 'George', 'Hawkins', 1999), + -- Section 31 cast + (62, 'Michelle', 'Yeoh', 1962), + (63, 'Omari', 'Hardwick', 1974), + (64, 'Sam', 'Richardson', 1984), + (65, 'Robert', 'Kazinsky', 1983), + (66, 'Kacey', 'Rohl', 1991); + +INSERT INTO Character (Id, Name, ActorId, Stardate) VALUES + (1, 'James T. Kirk', 1, 2233.04), + (2, 'Spock', 2, 2230.06), + (3, 'Leonard McCoy', 3, 2227.00), + (4, 'Montgomery Scott', 4, 2222.00), + (5, 'Uhura', 5, 2233.00), + (6, 'Hikaru Sulu', 6, 2237.00), + (7, 'Pavel Chekov', 7, 2245.00), + (8, 'Jean-Luc Picard', 8, 2305.07), + (9, 'William Riker', 9, 2335.08), + (10, 'Data', 10, 2336.00), + (11, 'Worf', 11, 2340.00), + (12, 'Beverly Crusher', 12, 2324.00), + (13, 'Deanna Troi', 13, 2336.00), + (14, 'Geordi La Forge', 14, 2335.02), + (15, 'Kathryn Janeway', 15, 2336.05), + (16, 'Chakotay', 16, 2329.00), + (17, 'Tuvok', 17, 2264.00), + (18, 'B''Elanna Torres', 18, 2349.00), + (19, 'Tom Paris', 19, 2346.00), + (20, 'Harry Kim', 20, 2349.00), + (21, 'The Doctor', 21, 2371.00), + (22, 'Seven of Nine', 22, 2348.00), + (23, 'Benjamin Sisko', 23, 2332.00), + (24, 'Kira Nerys', 24, 2343.00), + (25, 'Odo', 25, 2337.00), + (26, 'Ezri Dax', 26, 2368.00), + (27, 'Jadzia Dax', 26, 2341.00), + (28, 'Julian Bashir', 27, 2341.00), + (29, 'Quark', 28, 2333.00), + (30, 'Jake Sisko', 29, 2355.00), + (31, 'Jonathan Archer', 30, 2112.00), + (32, 'T''Pol', 31, 2088.00), + (33, 'Phlox', 32, 2102.00), + (34, 'Charles "Trip" Tucker III', 33, 2121.00), + (35, 'Malcolm Reed', 34, 2117.00), + (36, 'Hoshi Sato', 35, 2129.00), + (37, 'Travis Mayweather', 36, 2126.00), + (38, 'Michael Burnham', 37, 2226.00), + (39, 'Saru', 38, 2237.00), + (40, 'Paul Stamets', 39, 2246.00), + (41, 'Sylvia Tilly', 40, 2249.00), + (42, 'Hugh Culber', 41, 2246.00), + -- Strange New Worlds characters + (43, 'Christopher Pike', 42, 2219.00), + (44, 'Spock (SNW)', 43, 2230.06), + (45, 'Una Chin-Riley', 44, 2220.00), + (46, 'Nyota Uhura (SNW)', 45, 2233.00), + (47, 'Christine Chapel', 46, 2230.00), + -- Lower Decks characters + (48, 'Beckett Mariner', 47, 2357.00), + (49, 'Brad Boimler', 48, 2361.00), + (50, 'D''Vana Tendi', 49, 2368.00), + (51, 'Sam Rutherford', 50, 2359.00), + (52, 'Carol Freeman', 51, 2337.00), + -- Prodigy characters + (53, 'Dal R''El', 52, 2369.00), + (54, 'Gwyndala', 53, 2369.00), + (55, 'Jankom Pog', 54, 2368.00), + (56, 'Zero', 55, 2370.00), + (57, 'Murf', 56, 2371.00), + -- Starfleet Academy characters + (58, 'Admiral Grace', 57, 2328.00), + (59, 'Chancellor Vix', 58, 2335.00), + (60, 'Nyota Uhura (Academy)', 59, 2233.00), + (61, 'Cadet Sato', 60, 2377.00), + (62, 'Cadet Thira Sidhu', 61, 2378.00), + -- Section 31 characters + (63, 'Philippa Georgiou (Section 31)', 62, 2202.00), + (64, 'Alok Sahar', 63, 1970.00), + (65, 'Quasi', 64, 2335.00), + (66, 'Zeph', 65, 2330.00), + (67, 'Rachel Garrett (Young)', 66, 2305.00); + +INSERT INTO Series_Character (SeriesId, CharacterId, Role) VALUES + -- Star Trek (Original Series) + (1, 1, 'Captain'), + (1, 2, 'First Officer/Science Officer'), + (1, 3, 'Chief Medical Officer'), + (1, 4, 'Chief Engineer'), + (1, 5, 'Communications Officer'), + (1, 6, 'Helmsman'), + (1, 7, 'Navigator'), + -- Star Trek: The Next Generation + (2, 8, 'Captain'), + (2, 9, 'First Officer'), + (2, 10, 'Operations Officer'), + (2, 11, 'Chief of Security/Tactical Officer'), + (2, 12, 'Chief Medical Officer'), + (2, 13, 'Ship''s Counselor'), + (2, 14, 'Chief Engineer'), + -- Star Trek: Voyager + (3, 15, 'Captain'), + (3, 16, 'First Officer'), + (3, 17, 'Chief of Security/Tactical Officer'), + (3, 18, 'Chief Engineer'), + (3, 19, 'Helmsman'), + (3, 20, 'Operations Officer'), + (3, 21, 'Chief Medical Officer'), + (3, 22, 'Astrometrics Officer'), + -- Star Trek: Deep Space Nine + (4, 23, 'Commanding Officer'), + (4, 24, 'First Officer'), + (4, 25, 'Chief of Security'), + (4, 26, 'Science Officer'), + (4, 27, 'Science Officer'), + (4, 28, 'Chief Medical Officer'), + (4, 29, 'Bar Owner'), + (4, 30, 'Civilian'), + (4, 11, 'Strategic Operations Officer'), -- Worf also served on DS9 + -- Star Trek: Enterprise + (5, 31, 'Captain'), + (5, 32, 'Science Officer/First Officer'), + (5, 33, 'Chief Medical Officer'), + (5, 34, 'Chief Engineer'), + (5, 35, 'Armory Officer'), + (5, 36, 'Communications Officer'), + (5, 37, 'Helmsman'), + -- Star Trek: Discovery + (6, 38, 'Science Specialist/First Officer'), + (6, 39, 'First Officer/Captain'), + (6, 40, 'Chief Engineer'), + (6, 41, 'Ensign/Cadet'), + (6, 42, 'Chief Medical Officer'), + -- Star Trek: Strange New Worlds + (7, 43, 'Captain'), + (7, 44, 'Science Officer'), + (7, 45, 'First Officer'), + (7, 46, 'Communications Officer'), + (7, 47, 'Nurse'), + -- Star Trek: Lower Decks + (8, 48, 'Ensign'), + (8, 49, 'Ensign'), + (8, 50, 'Ensign'), + (8, 51, 'Ensign'), + (8, 52, 'Captain'), + -- Star Trek: Prodigy + (9, 53, 'Crew Member'), + (9, 54, 'Crew Member'), + (9, 55, 'Crew Member'), + (9, 56, 'Crew Member'), + (9, 57, 'Crew Member'), + -- Star Trek: Starfleet Academy + (10, 58, 'Academy Commandant'), + (10, 59, 'Academy Chancellor'), + (10, 60, 'Cadet/Communications Specialist'), + (10, 61, 'First Year Cadet'), + (10, 62, 'First Year Cadet'), + -- Star Trek: Section 31 + (11, 63, 'Emperor/Section 31 Operative'), + (11, 64, 'Section 31 Agent'), + (11, 65, 'Section 31 Agent/Shapeshifter'), + (11, 66, 'Section 31 Agent'), + (11, 67, 'Starfleet Officer/Future Captain'); + +INSERT INTO Character_Species (CharacterId, SpeciesId) VALUES + -- Original Series + (1, 1), -- Kirk: Human + (2, 2), -- Spock: Vulcan + (2, 1), -- Spock: Human (half-human) + (3, 1), -- McCoy: Human + (4, 1), -- Scott: Human + (5, 1), -- Uhura: Human + (6, 1), -- Sulu: Human + (7, 1), -- Chekov: Human + -- Next Generation + (8, 1), -- Picard: Human + (9, 1), -- Riker: Human + (10, 3), -- Data: Android + (11, 4), -- Worf: Klingon + (12, 1), -- Crusher: Human + (13, 5), -- Troi: Betazoid + (13, 1), -- Troi: Human (half-human) + (14, 1), -- La Forge: Human + -- Voyager + (15, 1), -- Janeway: Human + (16, 1), -- Chakotay: Human + (17, 2), -- Tuvok: Vulcan + (18, 4), -- Torres: Klingon + (18, 1), -- Torres: Human (half-human) + (19, 1), -- Paris: Human + (20, 1), -- Kim: Human + (21, 6), -- The Doctor: Hologram + (22, 1), -- Seven of Nine: Human + (22, 12), -- Seven of Nine: Borg (ex-Borg) + -- Deep Space Nine + (23, 1), -- Sisko: Human + (24, 7), -- Kira: Bajoran + (25, 8), -- Odo: Changeling + (26, 1), -- Ezri Dax: Human + (26, 9), -- Ezri Dax: Trill (joined) + (27, 9), -- Jadzia Dax: Trill + (28, 1), -- Bashir: Human + (29, 10), -- Quark: Ferengi + (30, 1), -- Jake Sisko: Human + -- Enterprise + (31, 1), -- Archer: Human + (32, 2), -- T'Pol: Vulcan + (33, 11), -- Phlox: Denobulan + (34, 1), -- Tucker: Human + (35, 1), -- Reed: Human + (36, 1), -- Sato: Human + (37, 1), -- Mayweather: Human + -- Discovery + (38, 1), -- Burnham: Human + (39, 13), -- Saru: Kelpien + (40, 1), -- Stamets: Human + (41, 1), -- Tilly: Human + (42, 1), -- Culber: Human + -- Strange New Worlds + (43, 1), -- Pike: Human + (44, 2), -- Spock: Vulcan + (44, 1), -- Spock: Human (half-human) + (45, 1), -- Una: Human + (45, 14), -- Una: Illyrian (augmented) + (46, 1), -- Uhura: Human + (47, 1), -- Chapel: Human + -- Lower Decks + (48, 1), -- Mariner: Human + (49, 1), -- Boimler: Human + (50, 1), -- Tendi: Human (Orion) + (51, 1), -- Rutherford: Human + (52, 1), -- Freeman: Human + -- Prodigy + (53, 1), -- Dal: Human (augment) + (54, 17), -- Gwyndala: Vau N'Akat + (55, 1), -- Jankom Pog: Human (Tellarite) + (56, 16), -- Zero: Medusan + (57, 1), -- Murf: Unknown (listed as Human for database purposes) + -- Starfleet Academy + (58, 1), -- Admiral Grace: Human + (59, 1), -- Chancellor Vix: Human + (60, 1), -- Uhura (Academy): Human + (61, 1), -- Sato: Human + (62, 1), -- Thira Sidhu: Human + -- Section 31 + (63, 1), -- Philippa Georgiou: Human + (64, 1), -- Alok Sahar: Human + (65, 19), -- Quasi: Chameloid + (66, 1), -- Zeph: Human + (67, 1); -- Rachel Garrett: Human + +GO +CREATE VIEW [dbo].[SeriesActors] +AS +SELECT + a.Id AS Id, + a.FullName AS Actor, + a.BirthYear AS BirthYear, + s.Id AS SeriesId, + s.Name AS Series +FROM Series s +JOIN Series_Character AS sc ON s.Id = sc.SeriesId +JOIN Character AS c ON sc.CharacterId = c.Id +JOIN Actor AS a ON c.ActorId = a.Id; + +GO +CREATE PROCEDURE [dbo].[GetSeriesActors] + @seriesId INT = 1, + @top INT = 5 +AS +SET NOCOUNT ON; +SELECT TOP (@top) * +FROM SeriesActors +WHERE SeriesId = @seriesId; + +GO +-- demo indexes for performance +CREATE INDEX IX_Character_ActorId ON Character(ActorId); + +GO +CREATE INDEX IX_SeriesCharacter_SeriesId ON Series_Character(SeriesId); + +GO +CREATE INDEX IX_CharacterSpecies_CharacterId ON Character_Species(CharacterId); diff --git a/samples/aspire/global.json b/samples/aspire/global.json new file mode 100644 index 0000000000..376af49c07 --- /dev/null +++ b/samples/aspire/global.json @@ -0,0 +1,5 @@ +{ + "sdk": { + "version": "10.0.100" + } +} diff --git a/samples/aspire/readme.md b/samples/aspire/readme.md new file mode 100644 index 0000000000..ff3157624f --- /dev/null +++ b/samples/aspire/readme.md @@ -0,0 +1,66 @@ +# Sample: Data API builder in Aspire + +To run this sample, install the prerequisites, then execute the following command in this directory: + +```sh +> aspire run +``` + +### Prerequisites + + - Dotnet CLI v10+ [Install](https://dotnet.microsoft.com/en-us/download/dotnet) Verify with `dotnet --version` + - Aspire CLI v13+ [Install](https://aspire.dev/get-started/install-cli/) Verify with `aspire --version` + - Docker Desktop (running) [Install](https://www.docker.com/products/docker-desktop/) + +## Resources + +```mermaid +flowchart TD + SQL["SQL Server"] + DAB["Data API Builder"] + CMD["SQL Commander"] + MCP["MCP Inspector"] + + SQL --> DAB + SQL --> CMD + DAB --> MCP +``` + +## Files + +- [`apphost.cs`](apphost.cs) - Single-page [Aspire orchestration](https://learn.microsoft.com/en-us/dotnet/aspire/app-host/configuration) +- [`database.sql`](database.sql) - Basic Star Trek schema & seed data +- [`dab-config.json`](dab-config.json) - Standard DAB configuration file +- [`dab-config.cmd`](dab-config.cmd) - [CLI commands](https://learn.microsoft.com/en-us/azure/data-api-builder/command-line/) to create `dab-config.json` (reference only) +- [`global.json`](global.json) - .NET SDK version settings + +## Data structures + +This schema demonstrates all of the relationship cardinalities. + +```mermaid +stateDiagram-v2 +direction LR + + classDef empty fill:none,stroke:none + classDef table stroke:black; + classDef phantom stroke:gray,stroke-dasharray:5 5; + + + class Actor table + class Character table + class Series table + class Species table + class Series_Character table + class Character_Species table + class Series_Character phantom + class Character_Species phantom + state Tables { + Actor --> Character + Character --> Actor + Series_Character --> Series + Character_Species --> Species + Series_Character --> Character + Character_Species --> Character + } +``` diff --git a/schemas/dab.draft.schema.json b/schemas/dab.draft.schema.json index 80cfd953ad..920c0a4da6 100644 --- a/schemas/dab.draft.schema.json +++ b/schemas/dab.draft.schema.json @@ -41,8 +41,8 @@ "additionalProperties": false, "properties": { "enabled": { - "type": "boolean", - "description": "Enable health check endpoint", + "$ref": "#/$defs/boolean-or-string", + "description": "Enable health check endpoint for something", "default": true, "additionalProperties": false }, @@ -186,7 +186,7 @@ "type": "string" }, "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Allow enabling/disabling REST requests for all entities." }, "request-body-strict": { @@ -210,7 +210,7 @@ "type": "string" }, "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Allow enabling/disabling GraphQL requests for all entities." }, "depth-limit": { @@ -370,7 +370,7 @@ "description": "Custom authentication provider defined by the user. Use the JWT property to configure the custom provider." } ], - "default": "StaticWebApps" + "default": "AppService" }, "jwt": { "type": "object", @@ -438,7 +438,7 @@ "description": "Application Insights connection string" }, "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Allow enabling/disabling Application Insights telemetry.", "default": true } @@ -481,7 +481,7 @@ "additionalProperties": false, "properties": { "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Allow enabling/disabling Azure Log Analytics.", "default": false }, @@ -618,7 +618,7 @@ "additionalProperties": false, "properties": { "enabled": { - "type": "boolean", + "$ref": "#/$defs/boolean-or-string", "description": "Enable health check endpoint globally", "default": true, "additionalProperties": false @@ -693,9 +693,169 @@ } } }, + "autoentities": { + "type": "object", + "description": "Defines automatic entity generation rules for MSSQL tables based on include/exclude patterns and defaults.", + "patternProperties": { + "^.*$": { + "type": "object", + "additionalProperties": false, + "properties": { + "patterns": { + "type": "object", + "description": "Pattern matching rules for including/excluding database objects", + "additionalProperties": false, + "properties": { + "include": { + "type": "array", + "description": "MSSQL LIKE pattern for objects to include (e.g., '%.%'). Null includes all.", + "items": { + "type": "string" + }, + "default": [ "%.%" ] + }, + "exclude": { + "type": "array", + "description": "MSSQL LIKE pattern for objects to exclude (e.g., 'sales.%'). Null excludes none.", + "items": { + "type": "string" + }, + "default": null + }, + "name": { + "type": "string", + "description": "Entity name interpolation pattern using {schema} and {object}. Null defaults to {object}. Must be unique for every entity inside the pattern", + "default": "{object}" + } + } + }, + "template": { + "type": "object", + "description": "Template configuration for generated entities", + "additionalProperties": false, + "properties": { + "mcp": { + "type": "object", + "description": "MCP endpoint configuration", + "additionalProperties": false, + "properties": { + "dml-tools": { + "type": "boolean", + "description": "Enable/disable all DML tools with default settings." + } + } + }, + "rest": { + "type": "object", + "description": "REST endpoint configuration", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable REST endpoint", + "default": true + } + } + }, + "graphql": { + "type": "object", + "description": "GraphQL endpoint configuration", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable GraphQL endpoint", + "default": true + } + } + }, + "health": { + "type": "object", + "description": "Health check configuration", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable health check endpoint", + "default": true + } + } + }, + "cache": { + "type": "object", + "description": "Cache configuration", + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable caching", + "default": false + }, + "ttl-seconds": { + "type": [ "integer", "null" ], + "description": "Time-to-live for cached responses in seconds", + "default": null, + "minimum": 1 + }, + "level": { + "type": "string", + "description": "Cache level (L1 or L1L2)", + "enum": [ "L1", "L1L2", null ], + "default": "L1L2" + } + } + } + } + }, + "permissions": { + "type": "array", + "description": "Permissions assigned to this object", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "role": { + "type": "string" + }, + "actions": { + "oneOf": [ + { + "type": "string", + "pattern": "[*]" + }, + { + "type": "array", + "items": { + "oneOf": [ + { + "$ref": "#/$defs/action" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/$defs/action" + } + } + } + ] + }, + "uniqueItems": true + } + ] + } + } + }, + "required": [ "role", "actions" ] + } + } + } + } + }, "entities": { "type": "object", - "description": "Entities that will be exposed via REST and/or GraphQL", + "description": "Entities that will be exposed via REST, GraphQL and/or MCP", "patternProperties": { "^.*$": { "type": "object", @@ -961,6 +1121,31 @@ "default": 5 } } + }, + "mcp": { + "oneOf": [ + { + "type": "boolean", + "description": "Boolean shorthand: true enables dml-tools only (custom-tool remains false), false disables all MCP functionality." + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "dml-tools": { + "type": "boolean", + "description": "Enable MCP DML (Data Manipulation Language) tools for this entity. Allows CRUD operations via MCP.", + "default": true + }, + "custom-tool": { + "type": "boolean", + "description": "Enable MCP custom tool for this entity. Only valid for stored procedures.", + "default": false + } + } + } + ], + "description": "Model Context Protocol (MCP) configuration for this entity. Controls whether the entity is exposed via MCP tools." } }, "if": { @@ -1145,6 +1330,33 @@ ] } } + }, + { + "if": { + "properties": { + "mcp": { + "properties": { + "custom-tool": { + "const": true + } + } + } + }, + "required": ["mcp"] + }, + "then": { + "properties": { + "source": { + "properties": { + "type": { + "const": "stored-procedure" + } + }, + "required": ["type"] + } + }, + "errorMessage": "custom-tool can only be enabled for entities with source type 'stored-procedure'." + } } ] } @@ -1179,7 +1391,15 @@ "type": "string" } }, - "required": ["singular"] + "required": [ "singular" ] + } + ] + }, + "boolean-or-string": { + "oneOf":[ + { + "type": [ "boolean", "string" ], + "pattern": "^(?:true|false|1|0|@env\\('.*'\\)|@akv\\('.*'\\))$" } ] }, diff --git a/src/.editorconfig b/src/.editorconfig index 5639a063ae..6367bdcb3c 100644 --- a/src/.editorconfig +++ b/src/.editorconfig @@ -75,9 +75,9 @@ dotnet_style_allow_statement_immediately_after_block_experimental = false:error #### C# Coding Conventions #### # var preferences -csharp_style_var_elsewhere = false:error -csharp_style_var_for_built_in_types = false:error -csharp_style_var_when_type_is_apparent = false:error +# csharp_style_var_elsewhere = false:error +# csharp_style_var_for_built_in_types = false:error +# csharp_style_var_when_type_is_apparent = false:error # Modifier preferences csharp_prefer_static_local_function = true:suggestion diff --git a/src/Aspire.AppHost/AppHost.cs b/src/Aspire.AppHost/AppHost.cs new file mode 100644 index 0000000000..5477c5c81b --- /dev/null +++ b/src/Aspire.AppHost/AppHost.cs @@ -0,0 +1,95 @@ +using Microsoft.Extensions.DependencyInjection; + +var builder = DistributedApplication.CreateBuilder(args); + +var aspireDB = Environment.GetEnvironmentVariable("ASPIRE_DATABASE_TYPE"); + +var databaseConnectionString = Environment.GetEnvironmentVariable("ASPIRE_DATABASE_CONNECTION_STRING") ?? ""; + +switch (aspireDB) +{ + case "mssql": + var sqlScript = File.ReadAllText("./init-scripts/sql/create-database.sql"); + + IResourceBuilder? sqlDbContainer = null; + + if (string.IsNullOrEmpty(databaseConnectionString)) + { + Console.WriteLine("No connection string provided, starting a local SQL Server container."); + + sqlDbContainer = builder.AddSqlServer("sqlserver") + .WithDataVolume() + .WithLifetime(ContainerLifetime.Persistent) + .AddDatabase("msSqlDb", "Trek") + .WithCreationScript(sqlScript); + } + + var mssqlService = builder.AddProject("mssql-service", "Development") + .WithArgs("-f", "net8.0") + .WithEndpoint(endpointName: "https", (e) => e.Port = 1234) + .WithEndpoint(endpointName: "http", (e) => e.Port = 2345) + .WithEnvironment("db-type", "mssql") + .WithUrls((e) => + { + e.Urls.Clear(); + e.Urls.Add(new() { Url = "/swagger", DisplayText = "🔒Swagger", Endpoint = e.GetEndpoint("https") }); + e.Urls.Add(new() { Url = "/graphql", DisplayText = "🔒GraphQL", Endpoint = e.GetEndpoint("https") }); + }) + .WithHttpHealthCheck("/health"); + + if (sqlDbContainer is null) + { + mssqlService.WithEnvironment("ConnectionStrings__Database", databaseConnectionString); + } + else + { + mssqlService.WithEnvironment("ConnectionStrings__Database", sqlDbContainer) + .WaitFor(sqlDbContainer); + } + + break; + case "postgresql": + var pgScript = File.ReadAllText("./init-scripts/pg/create-database-pg.sql"); + + IResourceBuilder? postgresDB = null; + + if (!string.IsNullOrEmpty(databaseConnectionString)) + { + Console.WriteLine("No connection string provided, starting a local PostgreSQL container."); + + postgresDB = builder.AddPostgres("postgres") + .WithPgAdmin() + .WithLifetime(ContainerLifetime.Persistent) + .AddDatabase("pgDb", "postgres") + .WithCreationScript(pgScript); + } + + var pgService = builder.AddProject("pg-service", "Development") + .WithArgs("-f", "net8.0") + .WithEndpoint(endpointName: "https", (e) => e.Port = 1234) + .WithEndpoint(endpointName: "http", (e) => e.Port = 2345) + .WithEnvironment("db-type", "postgresql") + .WithUrls((e) => + { + e.Urls.Clear(); + e.Urls.Add(new() { Url = "/swagger", DisplayText = "🔒Swagger", Endpoint = e.GetEndpoint("https") }); + e.Urls.Add(new() { Url = "/graphql", DisplayText = "🔒GraphQL", Endpoint = e.GetEndpoint("https") }); + }) + .WithHttpHealthCheck("/health"); + + if (postgresDB is null) + { + pgService.WithEnvironment("ConnectionStrings__Database", databaseConnectionString); + } + else + { + pgService.WithEnvironment("ConnectionStrings__Database", postgresDB) + .WaitFor(postgresDB); + } + + break; + default: + throw new Exception("Please set the ASPIRE_DATABASE environment variable to either 'mssql' or 'postgresql'."); +} + +builder.Build().Run(); diff --git a/src/Aspire.AppHost/Aspire.AppHost.csproj b/src/Aspire.AppHost/Aspire.AppHost.csproj new file mode 100644 index 0000000000..79d44e51a1 --- /dev/null +++ b/src/Aspire.AppHost/Aspire.AppHost.csproj @@ -0,0 +1,24 @@ + + + + + + Exe + net8.0 + enable + NU1603;NU1605 + enable + f08719fd-267f-459e-9980-77b1c52c8755 + + + + + + + + + + + + + diff --git a/src/Aspire.AppHost/DockerStatus.cs b/src/Aspire.AppHost/DockerStatus.cs new file mode 100644 index 0000000000..ad4237357e --- /dev/null +++ b/src/Aspire.AppHost/DockerStatus.cs @@ -0,0 +1,26 @@ +using System.Diagnostics; + +public static class DockerStatus +{ + public static async Task IsDockerRunningAsync() + { + var psi = new ProcessStartInfo + { + FileName = "docker", + Arguments = "info", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + try + { + using var process = Process.Start(psi)!; + await process.WaitForExitAsync(); + return process.ExitCode == 0; + } + catch + { + return false; + } + } +} diff --git a/src/Aspire.AppHost/Properties/launchSettings.json b/src/Aspire.AppHost/Properties/launchSettings.json new file mode 100644 index 0000000000..43a2673a34 --- /dev/null +++ b/src/Aspire.AppHost/Properties/launchSettings.json @@ -0,0 +1,57 @@ +{ + "profiles": { + "https": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21213", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22145" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:17047;http://localhost:15161" + }, + "http": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19015", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20166" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:15161" + }, + "aspire-mssql": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21213", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22145", + "ASPIRE_DATABASE_TYPE": "mssql", + "ASPIRE_DATABASE_CONNECTION_STRING": "" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:17047;http://localhost:15161" + }, + "aspire-postgresql": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "DOTNET_ENVIRONMENT": "Development", + "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21213", + "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22145", + "ASPIRE_DATABASE_TYPE": "postgresql", + "ASPIRE_DATABASE_CONNECTION_STRING": "" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:17047;http://localhost:15161" + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} diff --git a/src/Aspire.AppHost/README.md b/src/Aspire.AppHost/README.md new file mode 100644 index 0000000000..3437c367bb --- /dev/null +++ b/src/Aspire.AppHost/README.md @@ -0,0 +1,17 @@ +# Aspire Instructions + +This project allows you to run DAB in debug mode using [Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview). + +## Prerequisites +- [.NET SDK](https://dotnet.microsoft.com/download) (8.0 or later) +- [Docker](https://www.docker.com/products/docker-desktop) (optional, for containerized development) + +## Database Configuration + +In the `launchProfile.json` file, you can configure the database connection string. If you don't, Aspire will start for you a local instance in a Docker container. + +Simply provide a value for the `ASPIRE_DATABASE_CONNECTION_STRING` environment variable. + +You can select to run Aspire with different databases selecting the appropriate launch profile: +- `aspire-sql` +- `aspire-postgres` diff --git a/src/Aspire.AppHost/appsettings.json b/src/Aspire.AppHost/appsettings.json new file mode 100644 index 0000000000..31c092aa45 --- /dev/null +++ b/src/Aspire.AppHost/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} diff --git a/src/Aspire.AppHost/init-scripts/pg/create-database-pg.sql b/src/Aspire.AppHost/init-scripts/pg/create-database-pg.sql new file mode 100644 index 0000000000..73adccc008 --- /dev/null +++ b/src/Aspire.AppHost/init-scripts/pg/create-database-pg.sql @@ -0,0 +1,238 @@ +-- create-database-pg.sql +-- PostgreSQL version of create-database.sql + +-- Create database (run this as a superuser, outside the target database) +-- Uncomment and edit the database name as needed +--CREATE DATABASE "Trek"; + +-- Connect to the target database before running the rest of the script +--\connect Trek; + +-- Drop tables in reverse order of creation due to foreign key dependencies +DROP TABLE IF EXISTS "Character_Species"; +DROP TABLE IF EXISTS "Series_Character"; +DROP TABLE IF EXISTS "Character"; +DROP TABLE IF EXISTS "Species"; +DROP TABLE IF EXISTS "Actor"; +DROP TABLE IF EXISTS "Series"; + +-- create tables +CREATE TABLE "Series" ( + "Id" INTEGER PRIMARY KEY, + "Name" VARCHAR(255) NOT NULL +); + +CREATE TABLE "Actor" ( + "Id" INTEGER PRIMARY KEY, + "Name" VARCHAR(255) NOT NULL, + "BirthYear" INTEGER NOT NULL +); + +CREATE TABLE "Species" ( + "Id" INTEGER PRIMARY KEY, + "Name" VARCHAR(255) NOT NULL +); + +CREATE TABLE "Character" ( + "Id" INTEGER PRIMARY KEY, + "Name" VARCHAR(255) NOT NULL, + "ActorId" INTEGER NOT NULL, + "Stardate" DECIMAL(10, 2), + FOREIGN KEY ("ActorId") REFERENCES "Actor"("Id") +); + +CREATE TABLE "Series_Character" ( + "SeriesId" INTEGER, + "CharacterId" INTEGER, + "Role" VARCHAR(500), + FOREIGN KEY ("SeriesId") REFERENCES "Series"("Id"), + FOREIGN KEY ("CharacterId") REFERENCES "Character"("Id"), + PRIMARY KEY ("SeriesId", "CharacterId") +); + +CREATE TABLE "Character_Species" ( + "CharacterId" INTEGER, + "SpeciesId" INTEGER, + FOREIGN KEY ("CharacterId") REFERENCES "Character"("Id"), + FOREIGN KEY ("SpeciesId") REFERENCES "Species"("Id"), + PRIMARY KEY ("CharacterId", "SpeciesId") +); + +-- create data +INSERT INTO "Series" ("Id", "Name") VALUES + (1, 'Star Trek'), + (2, 'Star Trek: The Next Generation'), + (3, 'Star Trek: Voyager'), + (4, 'Star Trek: Deep Space Nine'), + (5, 'Star Trek: Enterprise'); + +INSERT INTO "Species" ("Id", "Name") VALUES + (1, 'Human'), + (2, 'Vulcan'), + (3, 'Android'), + (4, 'Klingon'), + (5, 'Betazoid'), + (6, 'Hologram'), + (7, 'Bajoran'), + (8, 'Changeling'), + (9, 'Trill'), + (10, 'Ferengi'), + (11, 'Denobulan'), + (12, 'Borg'); + +INSERT INTO "Actor" ("Id", "Name", "BirthYear") VALUES + (1, 'William Shatner', 1931), + (2, 'Leonard Nimoy', 1931), + (3, 'DeForest Kelley', 1920), + (4, 'James Doohan', 1920), + (5, 'Nichelle Nichols', 1932), + (6, 'George Takei', 1937), + (7, 'Walter Koenig', 1936), + (8, 'Patrick Stewart', 1940), + (9, 'Jonathan Frakes', 1952), + (10, 'Brent Spiner', 1949), + (11, 'Michael Dorn', 1952), + (12, 'Gates McFadden', 1949), + (13, 'Marina Sirtis', 1955), + (14, 'LeVar Burton', 1957), + (15, 'Kate Mulgrew', 1955), + (16, 'Robert Beltran', 1953), + (17, 'Tim Russ', 1956), + (18, 'Roxann Dawson', 1958), + (19, 'Robert Duncan McNeill', 1964), + (20, 'Garrett Wang', 1968), + (21, 'Robert Picardo', 1953), + (22, 'Jeri Ryan', 1968), + (23, 'Avery Brooks', 1948), + (24, 'Nana Visitor', 1957), + (25, 'Rene Auberjonois', 1940), + (26, 'Terry Farrell', 1963), + (27, 'Alexander Siddig', 1965), + (28, 'Armin Shimerman', 1949), + (29, 'Cirroc Lofton', 1978), + (30, 'Scott Bakula', 1954), + (31, 'Jolene Blalock', 1975), + (32, 'John Billingsley', 1960), + (33, 'Connor Trinneer', 1969), + (34, 'Dominic Keating', 1962), + (35, 'Linda Park', 1978), + (36, 'Anthony Montgomery', 1971); + +INSERT INTO "Character" ("Id", "Name", "ActorId", "Stardate") VALUES + (1, 'James T. Kirk', 1, 2233.04), + (2, 'Spock', 2, 2230.06), + (3, 'Leonard McCoy', 3, 2227.00), + (4, 'Montgomery Scott', 4, 2222.00), + (5, 'Uhura', 5, 2233.00), + (6, 'Hikaru Sulu', 6, 2237.00), + (7, 'Pavel Chekov', 7, 2245.00), + (8, 'Jean-Luc Picard', 8, 2305.07), + (9, 'William Riker', 9, 2335.08), + (10, 'Data', 10, 2336.00), + (11, 'Worf', 11, 2340.00), + (12, 'Beverly Crusher', 12, 2324.00), + (13, 'Deanna Troi', 13, 2336.00), + (14, 'Geordi La Forge', 14, 2335.02), + (15, 'Kathryn Janeway', 15, 2336.05), + (16, 'Chakotay', 16, 2329.00), + (17, 'Tuvok', 17, 2264.00), + (18, 'B''Elanna Torres', 18, 2349.00), + (19, 'Tom Paris', 19, 2346.00), + (20, 'Harry Kim', 20, 2349.00), + (21, 'The Doctor', 21, 2371.00), -- Stardate of activation + (22, 'Seven of Nine', 22, 2348.00), + (23, 'Benjamin Sisko', 23, 2332.00), + (24, 'Kira Nerys', 24, 2343.00), + (25, 'Odo', 25, 2337.00), -- Approximate stardate of discovery + (27, 'Jadzia Dax', 26, 2341.00), + (28, 'Julian Bashir', 27, 2341.00), + (29, 'Quark', 28, 2333.00), + (30, 'Jake Sisko', 29, 2355.00), + (31, 'Jonathan Archer', 30, 2112.00), + (32, 'T''Pol', 31, 2088.00), + (33, 'Phlox', 32, 2102.00), + (34, 'Charles "Trip" Tucker III', 33, 2121.00), + (35, 'Malcolm Reed', 34, 2117.00), + (36, 'Hoshi Sato', 35, 2129.00), + (37, 'Travis Mayweather', 36, 2126.00); + +INSERT INTO "Series_Character" ("SeriesId", "CharacterId", "Role") VALUES + (1, 1, 'Captain'), -- James T. Kirk in Star Trek + (1, 2, 'Science Officer'), -- Spock in Star Trek + (1, 3, 'Doctor'), -- Leonard McCoy in Star Trek + (1, 4, 'Engineer'), -- Montgomery Scott in Star Trek + (1, 5, 'Communications Officer'), -- Uhura in Star Trek + (1, 6, 'Helmsman'), -- Hikaru Sulu in Star Trek + (1, 7, 'Navigator'), -- Pavel Chekov in Star Trek + (2, 8, 'Captain'), -- Jean-Luc Picard in Star Trek: The Next Generation + (2, 9, 'First Officer'), -- William Riker in Star Trek: The Next Generation + (2, 10, 'Operations Officer'),-- Data in Star Trek: The Next Generation + (2, 11, 'Security Officer'),-- Worf in Star Trek: The Next Generation + (2, 12, 'Doctor'),-- Beverly Crusher in Star Trek: The Next Generation + (2, 13, 'Counselor'),-- Deanna Troi in Star Trek: The Next Generation + (2, 14, 'Engineer'),-- Geordi La Forge in Star Trek: The Next Generation + (3, 15, 'Captain'),-- Kathryn Janeway in Star Trek: Voyager + (3, 16, 'First Officer'),-- Chakotay in Star Trek: Voyager + (3, 17, 'Tactical Officer'),-- Tuvok in Star Trek: Voyager + (3, 18, 'Engineer'),-- B'Elanna Torres in Star Trek: Voyager + (3, 19, 'Helmsman'),-- Tom Paris in Star Trek: Voyager + (3, 20, 'Operations Officer'),-- Harry Kim in Star Trek: Voyager + (3, 21, 'Doctor'),-- The Doctor in Star Trek: Voyager + (3, 22, 'Astrometrics Officer'),-- Seven of Nine in Star Trek: Voyager + (4, 23, 'Commanding Officer'),-- Benjamin Sisko in Star Trek: Deep Space Nine + (4, 24, 'First Officer'),-- Kira Nerys in Star Trek: Deep Space Nine + (4, 25, 'Security Officer'),-- Odo in Star Trek: Deep Space Nine + (4, 11, 'Strategic Operations Officer'),-- Worf in Star Trek: Deep Space Nine + (4, 27, 'Science Officer'),-- Jadzia Dax in Star Trek: Deep Space Nine + (4, 28, 'Doctor'),-- Julian Bashir in Star Trek: Deep Space Nine + (4, 29, 'Bar Owner'),-- Quark in Star Trek: Deep Space Nine + (4, 30, 'Civilian'),-- Jake Sisko in Star Trek: Deep Space Nine + (5, 31, 'Captain'),-- Jonathan Archer in Star Trek: Enterprise + (5, 32, 'Science Officer'),-- T'Pol in Star Trek: Enterprise + (5, 33, 'Doctor'),-- Phlox in Star Trek: Enterprise + (5, 34, 'Chief Engineer'),-- Charles "Trip" Tucker III in Star Trek: Enterprise + (5, 35, 'Armory Officer'),-- Malcolm Reed in Star Trek: Enterprise + (5, 36, 'Communications Officer'),-- Hoshi Sato in Star Trek: Enterprise + (5, 37, 'Helmsman');-- Travis Mayweather in Star Trek: Enterprise + +INSERT INTO "Character_Species" ("CharacterId", "SpeciesId") VALUES + (1, 1), -- James T. Kirk is Human + (2, 2), -- Spock is Vulcan + (2, 1), -- Spock is also Human + (3, 1), -- Leonard McCoy is Human + (4, 1), -- Montgomery Scott is Human + (5, 1), -- Uhura is Human + (6, 1), -- Hikaru Sulu is Human + (7, 1), -- Pavel Chekov is Human + (8, 1), -- Jean-Luc Picard is Human + (9, 1), -- William Riker is Human + (10, 3), -- Data is Android + (11, 4), -- Worf is Klingon + (12, 1), -- Beverly Crusher is Human + (13, 1), -- Deanna Troi is Human + (13, 5), -- Deanna Troi is also Betazoid + (14, 1), -- Geordi La Forge is Human + (15, 1), -- Kathryn Janeway is Human + (16, 1), -- Chakotay is Human + (17, 2), -- Tuvok is Vulcan + (18, 1), -- B'Elanna Torres is Human + (18, 4), -- B'Elanna Torres is also Klingon + (19, 1), -- Tom Paris is Human + (20, 1), -- Harry Kim is Human + (21, 6), -- The Doctor is a Hologram + (22, 1), -- Seven of Nine is Human + (22, 12),-- Seven of Nine is also Borg + (23, 1), -- Benjamin Sisko is Human + (24, 7), -- Kira Nerys is Bajoran + (25, 8), -- Odo is Changeling + (27, 9), -- Jadzia Dax is Trill + (28, 1), -- Julian Bashir is Human + (29, 10),-- Quark is Ferengi + (30, 1), -- Jake Sisko is Human + (31, 1), -- Jonathan Archer is Human + (32, 2), -- T'Pol is Vulcan + (33, 11),-- Phlox is Denobulan + (34, 1), -- Charles "Trip" Tucker III is Human + (35, 1), -- Malcolm Reed is Human + (36, 1), -- Hoshi Sato is Human + (37, 1); -- Travis Mayweather is Human diff --git a/src/Aspire.AppHost/init-scripts/sql/create-database.sql b/src/Aspire.AppHost/init-scripts/sql/create-database.sql new file mode 100644 index 0000000000..a12c71138e --- /dev/null +++ b/src/Aspire.AppHost/init-scripts/sql/create-database.sql @@ -0,0 +1,239 @@ + +USE [master] +GO + +CREATE DATABASE [Trek] +GO + +USE [Trek] +GO + +-- Drop tables in reverse order of creation due to foreign key dependencies +DROP TABLE IF EXISTS Character_Species; +DROP TABLE IF EXISTS Series_Character; +DROP TABLE IF EXISTS Character; +DROP TABLE IF EXISTS Species; +DROP TABLE IF EXISTS Actor; +DROP TABLE IF EXISTS Series; + +-- create tables +CREATE TABLE Series ( + Id INT PRIMARY KEY, + Name NVARCHAR(255) NOT NULL +); + +CREATE TABLE Actor ( + Id INT PRIMARY KEY, + Name NVARCHAR(255) NOT NULL, + [BirthYear] INT NOT NULL +); + +CREATE TABLE Species ( + Id INT PRIMARY KEY, + Name NVARCHAR(255) NOT NULL +); + +CREATE TABLE Character ( + Id INT PRIMARY KEY, + Name NVARCHAR(255) NOT NULL, + ActorId INT NOT NULL, + Stardate DECIMAL(10, 2), + FOREIGN KEY (ActorId) REFERENCES Actor(Id) +); + +CREATE TABLE Series_Character ( + SeriesId INT, + CharacterId INT, + Role VARCHAR(500), + FOREIGN KEY (SeriesId) REFERENCES Series(Id), + FOREIGN KEY (CharacterId) REFERENCES Character(Id), + PRIMARY KEY (SeriesId, CharacterId) +); + +CREATE TABLE Character_Species ( + CharacterId INT, + SpeciesId INT, + FOREIGN KEY (CharacterId) REFERENCES Character(Id), + FOREIGN KEY (SpeciesId) REFERENCES Species(Id), + PRIMARY KEY (CharacterId, SpeciesId) +); + +-- create data +INSERT INTO Series (Id, Name) VALUES + (1, 'Star Trek'), + (2, 'Star Trek: The Next Generation'), + (3, 'Star Trek: Voyager'), + (4, 'Star Trek: Deep Space Nine'), + (5, 'Star Trek: Enterprise'); + +INSERT INTO Species (Id, Name) VALUES + (1, 'Human'), + (2, 'Vulcan'), + (3, 'Android'), + (4, 'Klingon'), + (5, 'Betazoid'), + (6, 'Hologram'), + (7, 'Bajoran'), + (8, 'Changeling'), + (9, 'Trill'), + (10, 'Ferengi'), + (11, 'Denobulan'), + (12, 'Borg'); + +INSERT INTO Actor (Id, Name, [BirthYear]) VALUES + (1, 'William Shatner', 1931), + (2, 'Leonard Nimoy', 1931), + (3, 'DeForest Kelley', 1920), + (4, 'James Doohan', 1920), + (5, 'Nichelle Nichols', 1932), + (6, 'George Takei', 1937), + (7, 'Walter Koenig', 1936), + (8, 'Patrick Stewart', 1940), + (9, 'Jonathan Frakes', 1952), + (10, 'Brent Spiner', 1949), + (11, 'Michael Dorn', 1952), + (12, 'Gates McFadden', 1949), + (13, 'Marina Sirtis', 1955), + (14, 'LeVar Burton', 1957), + (15, 'Kate Mulgrew', 1955), + (16, 'Robert Beltran', 1953), + (17, 'Tim Russ', 1956), + (18, 'Roxann Dawson', 1958), + (19, 'Robert Duncan McNeill', 1964), + (20, 'Garrett Wang', 1968), + (21, 'Robert Picardo', 1953), + (22, 'Jeri Ryan', 1968), + (23, 'Avery Brooks', 1948), + (24, 'Nana Visitor', 1957), + (25, 'Rene Auberjonois', 1940), + (26, 'Terry Farrell', 1963), + (27, 'Alexander Siddig', 1965), + (28, 'Armin Shimerman', 1949), + (29, 'Cirroc Lofton', 1978), + (30, 'Scott Bakula', 1954), + (31, 'Jolene Blalock', 1975), + (32, 'John Billingsley', 1960), + (33, 'Connor Trinneer', 1969), + (34, 'Dominic Keating', 1962), + (35, 'Linda Park', 1978), + (36, 'Anthony Montgomery', 1971); + +INSERT INTO Character (Id, Name, ActorId, Stardate) VALUES + (1, 'James T. Kirk', 1, 2233.04), + (2, 'Spock', 2, 2230.06), + (3, 'Leonard McCoy', 3, 2227.00), + (4, 'Montgomery Scott', 4, 2222.00), + (5, 'Uhura', 5, 2233.00), + (6, 'Hikaru Sulu', 6, 2237.00), + (7, 'Pavel Chekov', 7, 2245.00), + (8, 'Jean-Luc Picard', 8, 2305.07), + (9, 'William Riker', 9, 2335.08), + (10, 'Data', 10, 2336.00), + (11, 'Worf', 11, 2340.00), + (12, 'Beverly Crusher', 12, 2324.00), + (13, 'Deanna Troi', 13, 2336.00), + (14, 'Geordi La Forge', 14, 2335.02), + (15, 'Kathryn Janeway', 15, 2336.05), + (16, 'Chakotay', 16, 2329.00), + (17, 'Tuvok', 17, 2264.00), + (18, 'B''Elanna Torres', 18, 2349.00), + (19, 'Tom Paris', 19, 2346.00), + (20, 'Harry Kim', 20, 2349.00), + (21, 'The Doctor', 21, 2371.00), -- Stardate of activation + (22, 'Seven of Nine', 22, 2348.00), + (23, 'Benjamin Sisko', 23, 2332.00), + (24, 'Kira Nerys', 24, 2343.00), + (25, 'Odo', 25, 2337.00), -- Approximate stardate of discovery + (27, 'Jadzia Dax', 26, 2341.00), + (28, 'Julian Bashir', 27, 2341.00), + (29, 'Quark', 28, 2333.00), + (30, 'Jake Sisko', 29, 2355.00), + (31, 'Jonathan Archer', 30, 2112.00), + (32, 'T''Pol', 31, 2088.00), + (33, 'Phlox', 32, 2102.00), + (34, 'Charles "Trip" Tucker III', 33, 2121.00), + (35, 'Malcolm Reed', 34, 2117.00), + (36, 'Hoshi Sato', 35, 2129.00), + (37, 'Travis Mayweather', 36, 2126.00); + +INSERT INTO Series_Character (SeriesId, CharacterId, Role) VALUES + (1, 1, 'Captain'), -- James T. Kirk in Star Trek + (1, 2, 'Science Officer'), -- Spock in Star Trek + (1, 3, 'Doctor'), -- Leonard McCoy in Star Trek + (1, 4, 'Engineer'), -- Montgomery Scott in Star Trek + (1, 5, 'Communications Officer'), -- Uhura in Star Trek + (1, 6, 'Helmsman'), -- Hikaru Sulu in Star Trek + (1, 7, 'Navigator'), -- Pavel Chekov in Star Trek + (2, 8, 'Captain'), -- Jean-Luc Picard in Star Trek: The Next Generation + (2, 9, 'First Officer'), -- William Riker in Star Trek: The Next Generation + (2, 10, 'Operations Officer'),-- Data in Star Trek: The Next Generation + (2, 11, 'Security Officer'),-- Worf in Star Trek: The Next Generation + (2, 12, 'Doctor'),-- Beverly Crusher in Star Trek: The Next Generation + (2, 13, 'Counselor'),-- Deanna Troi in Star Trek: The Next Generation + (2, 14, 'Engineer'),-- Geordi La Forge in Star Trek: The Next Generation + (3, 15, 'Captain'),-- Kathryn Janeway in Star Trek: Voyager + (3, 16, 'First Officer'),-- Chakotay in Star Trek: Voyager + (3, 17, 'Tactical Officer'),-- Tuvok in Star Trek: Voyager + (3, 18, 'Engineer'),-- B'Elanna Torres in Star Trek: Voyager + (3, 19, 'Helmsman'),-- Tom Paris in Star Trek: Voyager + (3, 20, 'Operations Officer'),-- Harry Kim in Star Trek: Voyager + (3, 21, 'Doctor'),-- The Doctor in Star Trek: Voyager + (3, 22, 'Astrometrics Officer'),-- Seven of Nine in Star Trek: Voyager + (4, 23, 'Commanding Officer'),-- Benjamin Sisko in Star Trek: Deep Space Nine + (4, 24, 'First Officer'),-- Kira Nerys in Star Trek: Deep Space Nine + (4, 25, 'Security Officer'),-- Odo in Star Trek: Deep Space Nine + (4, 11, 'Strategic Operations Officer'),-- Worf in Star Trek: Deep Space Nine + (4, 27, 'Science Officer'),-- Jadzia Dax in Star Trek: Deep Space Nine + (4, 28, 'Doctor'),-- Julian Bashir in Star Trek: Deep Space Nine + (4, 29, 'Bar Owner'),-- Quark in Star Trek: Deep Space Nine + (4, 30, 'Civilian'),-- Jake Sisko in Star Trek: Deep Space Nine + (5, 31, 'Captain'),-- Jonathan Archer in Star Trek: Enterprise + (5, 32, 'Science Officer'),-- T'Pol in Star Trek: Enterprise + (5, 33, 'Doctor'),-- Phlox in Star Trek: Enterprise + (5, 34, 'Chief Engineer'),-- Charles "Trip" Tucker III in Star Trek: Enterprise + (5, 35, 'Armory Officer'),-- Malcolm Reed in Star Trek: Enterprise + (5, 36, 'Communications Officer'),-- Hoshi Sato in Star Trek: Enterprise + (5, 37, 'Helmsman');-- Travis Mayweather in Star Trek: Enterprise + +INSERT INTO Character_Species (CharacterId, SpeciesId) VALUES + (1, 1), -- James T. Kirk is Human + (2, 2), -- Spock is Vulcan + (2, 1), -- Spock is also Human + (3, 1), -- Leonard McCoy is Human + (4, 1), -- Montgomery Scott is Human + (5, 1), -- Uhura is Human + (6, 1), -- Hikaru Sulu is Human + (7, 1), -- Pavel Chekov is Human + (8, 1), -- Jean-Luc Picard is Human + (9, 1), -- William Riker is Human + (10, 3), -- Data is Android + (11, 4), -- Worf is Klingon + (12, 1), -- Beverly Crusher is Human + (13, 1), -- Deanna Troi is Human + (13, 5), -- Deanna Troi is also Betazoid + (14, 1), -- Geordi La Forge is Human + (15, 1), -- Kathryn Janeway is Human + (16, 1), -- Chakotay is Human + (17, 2), -- Tuvok is Vulcan + (18, 1), -- B'Elanna Torres is Human + (18, 4), -- B'Elanna Torres is also Klingon + (19, 1), -- Tom Paris is Human + (20, 1), -- Harry Kim is Human + (21, 6), -- The Doctor is a Hologram + (22, 1), -- Seven of Nine is Human + (22, 12),-- Seven of Nine is also Borg + (23, 1), -- Benjamin Sisko is Human + (24, 7), -- Kira Nerys is Bajoran + (25, 8), -- Odo is Changeling + (27, 9), -- Jadzia Dax is Trill + (28, 1), -- Julian Bashir is Human + (29, 10),-- Quark is Ferengi + (30, 1), -- Jake Sisko is Human + (31, 1), -- Jonathan Archer is Human + (32, 2), -- T'Pol is Vulcan + (33, 11),-- Phlox is Denobulan + (34, 1), -- Charles "Trip" Tucker III is Human + (35, 1), -- Malcolm Reed is Human + (36, 1), -- Hoshi Sato is Human + (37, 1); -- Travis Mayweather is Human + \ No newline at end of file diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs index 6fbe08879b..1a944d115b 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/CreateRecordTool.cs @@ -5,14 +5,13 @@ 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.Resolvers; using Azure.DataApiBuilder.Core.Resolvers.Factories; using Azure.DataApiBuilder.Core.Services; -using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Mcp.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -31,18 +30,18 @@ public Tool GetToolMetadata() return new Tool { Name = "create_record", - Description = "Creates a new record in the specified entity.", + Description = "STEP 1: describe_entities -> find entities with CREATE permission and their fields. STEP 2: call this tool with matching field names and values.", InputSchema = JsonSerializer.Deserialize( @"{ ""type"": ""object"", ""properties"": { ""entity"": { ""type"": ""string"", - ""description"": ""The name of the entity"" + ""description"": ""Entity name with CREATE permission."" }, ""data"": { ""type"": ""object"", - ""description"": ""The data for the new record"" + ""description"": ""Required fields and values for the new record."" } }, ""required"": [""entity"", ""data""] @@ -57,23 +56,21 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; if (arguments == null) { - return Utils.McpResponseBuilder.BuildErrorResult("Invalid Arguments", "No arguments provided", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); if (!runtimeConfigProvider.TryGetConfig(out RuntimeConfig? runtimeConfig)) { - return Utils.McpResponseBuilder.BuildErrorResult("Invalid Configuration", "Runtime configuration not available", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidConfiguration", "Runtime configuration not available.", logger); } if (runtimeConfig.McpDmlTools?.CreateRecord != true) { - return Utils.McpResponseBuilder.BuildErrorResult( - "ToolDisabled", - "The create_record tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(toolName, logger); } try @@ -81,39 +78,21 @@ public async Task ExecuteAsync( cancellationToken.ThrowIfCancellationRequested(); JsonElement root = arguments.RootElement; - if (!root.TryGetProperty("entity", out JsonElement entityElement) || - !root.TryGetProperty("data", out JsonElement dataElement)) + if (!McpArgumentParser.TryParseEntityAndData(root, out string entityName, out JsonElement dataElement, out string parseError)) { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidArguments", "Missing required arguments 'entity' or 'data'", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } - string entityName = entityElement.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(entityName)) + if (!McpMetadataHelper.TryResolveMetadata( + entityName, + runtimeConfig, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidArguments", "Entity name cannot be empty", logger); - } - - string dataSourceName; - try - { - dataSourceName = runtimeConfig.GetDataSourceNameFromEntityName(entityName); - } - catch (Exception) - { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidConfiguration", $"Entity '{entityName}' not found in configuration", logger); - } - - IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); - ISqlMetadataProvider sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); - - DatabaseObject dbObject; - try - { - dbObject = sqlMetadataProvider.GetDatabaseObjectByKey(entityName); - } - catch (Exception) - { - return Utils.McpResponseBuilder.BuildErrorResult("InvalidConfiguration", $"Database object for entity '{entityName}' not found", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // Create an HTTP context for authorization @@ -121,15 +100,20 @@ public async Task ExecuteAsync( HttpContext httpContext = httpContextAccessor.HttpContext ?? new DefaultHttpContext(); IAuthorizationResolver authorizationResolver = serviceProvider.GetRequiredService(); - if (httpContext is null || !authorizationResolver.IsValidRoleContext(httpContext)) + if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authorizationResolver, out string roleCtxError)) { - return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", "Permission denied: Unable to resolve a valid role context for update operation.", logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", roleCtxError, logger); } - // Validate that we have at least one role authorized for create - if (!TryResolveAuthorizedRole(httpContext, authorizationResolver, entityName, out string authError)) + if (!McpAuthorizationHelper.TryResolveAuthorizedRole( + httpContext, + authorizationResolver, + entityName, + EntityActionOperation.Create, + out string? effectiveRole, + out string authError)) { - return Utils.McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "create", authError, logger); } JsonElement insertPayloadRoot = dataElement.Clone(); @@ -150,12 +134,13 @@ public async Task ExecuteAsync( } catch (Exception ex) { - return Utils.McpResponseBuilder.BuildErrorResult("ValidationFailed", $"Request validation failed: {ex.Message}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "ValidationFailed", $"Request validation failed: {ex.Message}", logger); } } else { - return Utils.McpResponseBuilder.BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( + toolName, "InvalidCreateTarget", "The create_record tool is only available for tables.", logger); @@ -169,7 +154,7 @@ public async Task ExecuteAsync( if (result is CreatedResult createdResult) { - return Utils.McpResponseBuilder.BuildSuccessResult( + return McpResponseBuilder.BuildSuccessResult( new Dictionary { ["entity"] = entityName, @@ -184,14 +169,15 @@ public async Task ExecuteAsync( bool isError = objectResult.StatusCode.HasValue && objectResult.StatusCode.Value >= 400 && objectResult.StatusCode.Value != 403; if (isError) { - return Utils.McpResponseBuilder.BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( + toolName, "CreateFailed", $"Failed to create record in entity '{entityName}'. Error: {JsonSerializer.Serialize(objectResult.Value)}", logger); } else { - return Utils.McpResponseBuilder.BuildSuccessResult( + return McpResponseBuilder.BuildSuccessResult( new Dictionary { ["entity"] = entityName, @@ -206,14 +192,15 @@ public async Task ExecuteAsync( { if (result is null) { - return Utils.McpResponseBuilder.BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", $"Mutation engine returned null result for entity '{entityName}'", logger); } else { - return Utils.McpResponseBuilder.BuildSuccessResult( + return McpResponseBuilder.BuildSuccessResult( new Dictionary { ["entity"] = entityName, @@ -226,50 +213,8 @@ public async Task ExecuteAsync( } catch (Exception ex) { - return Utils.McpResponseBuilder.BuildErrorResult("Error", $"Error: {ex.Message}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "Error", $"Error: {ex.Message}", logger); } } - - private static bool TryResolveAuthorizedRole( - HttpContext httpContext, - IAuthorizationResolver authorizationResolver, - string entityName, - out string error) - { - 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."; - 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."; - return false; - } - - foreach (string role in roles) - { - bool allowed = authorizationResolver.AreRoleAndOperationDefinedForEntity( - entityName, role, EntityActionOperation.Create); - - if (allowed) - { - return true; - } - } - - error = "You do not have permission to create records for this entity."; - return false; - } } } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs index 86a5ce15ec..d7837c0103 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DeleteRecordTool.cs @@ -44,18 +44,18 @@ public Tool GetToolMetadata() return new Tool { Name = "delete_record", - Description = "Deletes a record from a table based on primary key or composite key", + Description = "STEP 1: describe_entities -> find entities with DELETE permission and their key fields. STEP 2: call this tool with full key values.", InputSchema = JsonSerializer.Deserialize( @"{ ""type"": ""object"", ""properties"": { ""entity"": { ""type"": ""string"", - ""description"": ""The name of the entity (table) as configured in dab-config. Required."" + ""description"": ""Entity name with DELETE permission."" }, ""keys"": { ""type"": ""object"", - ""description"": ""Primary key values to identify the record to delete. For composite keys, provide all key columns as properties. Required."" + ""description"": ""All key fields identifying the record."" } }, ""required"": [""entity"", ""keys""] @@ -73,6 +73,7 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; try { @@ -86,49 +87,37 @@ public async Task ExecuteAsync( // 2) Check if the tool is enabled in configuration before proceeding if (config.McpDmlTools?.DeleteRecord != true) { - return McpResponseBuilder.BuildErrorResult( - "ToolDisabled", - $"The {this.GetToolMetadata().Name} tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger); } // 3) Parsing & basic argument validation if (arguments is null) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } if (!McpArgumentParser.TryParseEntityAndKeys(arguments.RootElement, out string entityName, out Dictionary keys, out string parseError)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); - } - - IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); - IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); - - // 4) Resolve metadata for entity existence check - string dataSourceName; - ISqlMetadataProvider sqlMetadataProvider; - - try - { - dataSourceName = config.GetDataSourceNameFromEntityName(entityName); - sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); - } - catch (Exception) - { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } - if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? dbObject) || dbObject is null) + // 4) Resolve metadata for entity existence + if (!McpMetadataHelper.TryResolveMetadata( + entityName, + config, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // Validate it's a table or view if (dbObject.SourceType != EntitySourceType.Table && dbObject.SourceType != EntitySourceType.View) { - return McpResponseBuilder.BuildErrorResult("InvalidEntity", $"Entity '{entityName}' is not a table or view. Use 'execute-entity' for stored procedures.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity '{entityName}' is not a table or view. Use 'execute-entity' for stored procedures.", logger); } // 5) Authorization @@ -138,7 +127,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {roleError}", logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "delete", roleError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -149,10 +138,11 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "delete", authError, logger); } - // 6) Build and validate Delete context + // Need MetadataProviderFactory for RequestValidator; resolve here. + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); DeleteRequestContext context = new( @@ -164,7 +154,7 @@ public async Task ExecuteAsync( { if (kvp.Value is null) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); } context.PrimaryKeyValuePairs[kvp.Key] = kvp.Value; @@ -172,7 +162,7 @@ public async Task ExecuteAsync( requestValidator.ValidatePrimaryKey(context); - // 7) Execute + IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); DatabaseType dbType = config.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType; IMutationEngine mutationEngine = mutationEngineFactory.GetMutationEngine(dbType); @@ -195,6 +185,7 @@ public async Task ExecuteAsync( { string keyDetails = McpJsonHelper.FormatKeyDetails(keys); return McpResponseBuilder.BuildErrorResult( + toolName, "RecordNotFound", $"No record found with the specified primary key: {keyDetails}", logger); @@ -203,6 +194,7 @@ public async Task ExecuteAsync( message.Contains("REFERENCE constraint", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "ConstraintViolation", "Cannot delete record due to foreign key constraint. Other records depend on this record.", logger); @@ -211,6 +203,7 @@ public async Task ExecuteAsync( message.Contains("authorization", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "PermissionDenied", "You do not have permission to delete this record.", logger); @@ -219,6 +212,7 @@ public async Task ExecuteAsync( message.Contains("type", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "InvalidArguments", "Invalid data type for one or more key values.", logger); @@ -226,6 +220,7 @@ public async Task ExecuteAsync( // For any other DAB exceptions, return the message as-is return McpResponseBuilder.BuildErrorResult( + toolName, "DataApiBuilderError", dabEx.Message, logger); @@ -242,7 +237,7 @@ public async Task ExecuteAsync( 208 => $"Table '{dbObject.FullName}' not found in the database.", _ => $"Database error: {sqlEx.Message}" }; - return McpResponseBuilder.BuildErrorResult("DatabaseError", errorMessage, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", errorMessage, logger); } catch (DbException dbEx) { @@ -254,6 +249,7 @@ public async Task ExecuteAsync( if (errorMsg.Contains("foreign key") || errorMsg.Contains("constraint")) { return McpResponseBuilder.BuildErrorResult( + toolName, "ConstraintViolation", "Cannot delete record due to foreign key constraint. Other records depend on this record.", logger); @@ -261,24 +257,25 @@ public async Task ExecuteAsync( else if (errorMsg.Contains("not found") || errorMsg.Contains("does not exist")) { return McpResponseBuilder.BuildErrorResult( + toolName, "RecordNotFound", "No record found with the specified primary key.", logger); } - return McpResponseBuilder.BuildErrorResult("DatabaseError", $"Database error: {dbEx.Message}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", $"Database error: {dbEx.Message}", logger); } catch (InvalidOperationException ioEx) when (ioEx.Message.Contains("connection", StringComparison.OrdinalIgnoreCase)) { // Handle connection-related issues logger?.LogError(ioEx, "Database connection error"); - return McpResponseBuilder.BuildErrorResult("ConnectionError", "Failed to connect to the database.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "ConnectionError", "Failed to connect to the database.", logger); } catch (TimeoutException timeoutEx) { // Handle query timeout logger?.LogError(timeoutEx, "Delete operation timeout for {Entity}", entityName); - return McpResponseBuilder.BuildErrorResult("TimeoutError", "The delete operation timed out.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "TimeoutError", "The delete operation timed out.", logger); } catch (Exception ex) { @@ -289,6 +286,7 @@ public async Task ExecuteAsync( { string keyDetails = McpJsonHelper.FormatKeyDetails(keys); return McpResponseBuilder.BuildErrorResult( + toolName, "RecordNotFound", $"No entity found with the given key {keyDetails}.", logger); @@ -325,18 +323,18 @@ public async Task ExecuteAsync( } catch (OperationCanceledException) { - return McpResponseBuilder.BuildErrorResult("OperationCanceled", "The delete operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The delete operation was canceled.", logger); } catch (ArgumentException argEx) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argEx.Message, logger); } catch (Exception ex) { - ILogger? innerLogger = serviceProvider.GetService>(); - innerLogger?.LogError(ex, "Unexpected error in DeleteRecordTool."); + logger?.LogError(ex, "Unexpected error in DeleteRecordTool."); return McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", "An unexpected error occurred during the delete operation.", logger); diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs index 95c53d1d28..c5b283214f 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/DescribeEntitiesTool.cs @@ -2,11 +2,14 @@ // Licensed under the MIT License. using System.Text.Json; +using Azure.DataApiBuilder.Auth; using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Authorization; using Azure.DataApiBuilder.Core.Configurations; using Azure.DataApiBuilder.Mcp.Model; using Azure.DataApiBuilder.Mcp.Utils; using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -25,7 +28,7 @@ public class DescribeEntitiesTool : IMcpTool public ToolType ToolType { get; } = ToolType.BuiltIn; /// - /// Gets the metadata for the delete-record tool, including its name, description, and input schema. + /// Gets the metadata for the describe-entities tool, including its name, description, and input schema. /// /// public Tool GetToolMetadata() @@ -33,21 +36,21 @@ public Tool GetToolMetadata() return new Tool { Name = "describe_entities", - Description = "Lists and describes all entities in the database, including their types and available operations.", + Description = "Lists all entities and metadata. ALWAYS CALL FIRST. Each entity includes: name, type, fields, parameters, and permissions. The permissions array defines which tools are allowed. 'ALL' expands by type: data->CREATE, READ, UPDATE, DELETE.", InputSchema = JsonSerializer.Deserialize( @"{ ""type"": ""object"", ""properties"": { ""nameOnly"": { ""type"": ""boolean"", - ""description"": ""If true, only entity names and descriptions will be returned. If false, full metadata including fields, parameters etc. will be included. Default is false."" + ""description"": ""If true, the response includes only entity names and short summaries, omitting detailed metadata such as fields, parameters, and permissions. Use this when the database contains many entities and the full payload would be too large. The usual strategy is: first call describe_entities with nameOnly=true to get a lightweight list, then call describe_entities again with nameOnly=false for specific entities that require full metadata. This flag is meant for discovery, not execution planning. The model must not assume that nameOnly=true provides enough detail for CRUD or EXECUTE operations."" }, ""entities"": { ""type"": ""array"", ""items"": { ""type"": ""string"" }, - ""description"": ""Optional list of specific entity names to filter by. If empty, all entities will be described."" + ""description"": ""Optional list of entity names to describe in full detail. Use this to reduce payload size when only certain entities are relevant. Do NOT pass both entities[] and nameOnly=true together, as that combination is nonsensical: nameOnly=true ignores detailed metadata, while entities[] explicitly requests it. Choose one approach—broad discovery with nameOnly=true OR targeted metadata with entities[]."" } } }" @@ -64,6 +67,7 @@ public Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; try { @@ -74,14 +78,72 @@ public Task ExecuteAsync( if (!IsToolEnabled(runtimeConfig)) { - return Task.FromResult(McpResponseBuilder.BuildErrorResult( - "ToolDisabled", - $"The {GetToolMetadata().Name} tool is disabled in the configuration.", - logger)); + return Task.FromResult(McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger)); + } + + // Get authorization services to determine current user's role + IAuthorizationResolver authResolver = serviceProvider.GetRequiredService(); + IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); + HttpContext? httpContext = httpContextAccessor.HttpContext; + + // Get current user's role for permission filtering + // For discovery tools like describe_entities, we use the first valid role from the header + // This differs from operation-specific tools that check permissions per entity per operation + string? currentUserRole = null; + if (httpContext != null && authResolver.IsValidRoleContext(httpContext)) + { + string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); + if (!string.IsNullOrWhiteSpace(roleHeader)) + { + string[] roles = roleHeader + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (roles.Length > 1) + { + logger?.LogWarning("Multiple roles detected in request header: [{Roles}]. Using first role '{FirstRole}' for entity discovery. " + + "Consider using a single role for consistent permission reporting.", + string.Join(", ", roles), roles[0]); + } + + // For discovery operations, take the first role from comma-separated list + // This provides a consistent view of available entities for the primary role + currentUserRole = roles.FirstOrDefault(); + } + } + + // Get current user's role for permission filtering + // For discovery tools like describe_entities, we use the first valid role from the header + // This differs from operation-specific tools that check permissions per entity per operation + if (httpContext != null && authResolver.IsValidRoleContext(httpContext)) + { + string roleHeader = httpContext.Request.Headers[AuthorizationResolver.CLIENT_ROLE_HEADER].ToString(); + if (!string.IsNullOrWhiteSpace(roleHeader)) + { + string[] roles = roleHeader + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (roles.Length > 1) + { + logger?.LogWarning("Multiple roles detected in request header: [{Roles}]. Using first role '{FirstRole}' for entity discovery. " + + "Consider using a single role for consistent permission reporting.", + string.Join(", ", roles), roles[0]); + } + + // For discovery operations, take the first role from comma-separated list + // This provides a consistent view of available entities for the primary role + currentUserRole = roles.FirstOrDefault(); + } } (bool nameOnly, HashSet? entityFilter) = ParseArguments(arguments, logger); + if (currentUserRole == null) + { + logger?.LogWarning("Current user role could not be determined from HTTP context or role header. " + + "Entity permissions will be empty (no permissions shown) rather than using anonymous permissions. " + + "Ensure the '{RoleHeader}' header is properly set.", AuthorizationResolver.CLIENT_ROLE_HEADER); + } + List> entityList = new(); if (runtimeConfig.Entities != null) @@ -102,7 +164,7 @@ public Task ExecuteAsync( { Dictionary entityInfo = nameOnly ? BuildBasicEntityInfo(entityName, entity) - : BuildFullEntityInfo(entityName, entity); + : BuildFullEntityInfo(entityName, entity, currentUserRole); entityList.Add(entityInfo); } @@ -118,6 +180,7 @@ public Task ExecuteAsync( if (entityFilter != null && entityFilter.Count > 0) { return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "EntitiesNotFound", $"No entities found matching the filter: {string.Join(", ", entityFilter)}", logger)); @@ -125,6 +188,7 @@ public Task ExecuteAsync( else { return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "NoEntitiesConfigured", "No entities are configured in the runtime configuration.", logger)); @@ -140,19 +204,14 @@ public Task ExecuteAsync( Dictionary responseData = new() { ["entities"] = finalEntityList, - ["count"] = finalEntityList.Count, - ["mode"] = nameOnly ? "basic" : "full" + ["count"] = finalEntityList.Count }; - if (entityFilter != null && entityFilter.Count > 0) - { - responseData["filter"] = entityFilter.ToArray(); - } - logger?.LogInformation( - "DescribeEntitiesTool returned {EntityCount} entities in {Mode} mode.", + "DescribeEntitiesTool returned {EntityCount} entities. Response type: {ResponseType} (nameOnly={NameOnly}).", finalEntityList.Count, - nameOnly ? "basic" : "full"); + nameOnly ? "lightweight summary (names and descriptions only)" : "full metadata with fields, parameters, and permissions", + nameOnly); return Task.FromResult(McpResponseBuilder.BuildSuccessResult( responseData, @@ -162,6 +221,7 @@ public Task ExecuteAsync( catch (OperationCanceledException) { return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "OperationCanceled", "The describe operation was canceled.", logger)); @@ -170,6 +230,7 @@ public Task ExecuteAsync( { logger?.LogError(dabEx, "Data API Builder error in DescribeEntitiesTool"); return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "DataApiBuilderError", dabEx.Message, logger)); @@ -177,6 +238,7 @@ public Task ExecuteAsync( catch (ArgumentException argEx) { return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "InvalidArguments", argEx.Message, logger)); @@ -185,6 +247,7 @@ public Task ExecuteAsync( { logger?.LogError(ioEx, "Invalid operation in DescribeEntitiesTool"); return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "InvalidOperation", "Failed to retrieve entity metadata: " + ioEx.Message, logger)); @@ -193,6 +256,7 @@ public Task ExecuteAsync( { logger?.LogError(ex, "Unexpected error in DescribeEntitiesTool"); return Task.FromResult(McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", "An unexpected error occurred while describing entities.", logger)); @@ -276,13 +340,18 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti /// /// The name of the entity to include in the dictionary. /// The entity object from which to extract additional information. - /// A dictionary with two keys: "name", containing the entity name, and "description", containing the entity's + /// A dictionary with two keys: "name", containing the entity alias (or name if no alias), and "description", containing the entity's /// description or an empty string if the description is null. private static Dictionary BuildBasicEntityInfo(string entityName, Entity entity) { + // Use GraphQL singular name as alias if available, otherwise use entity name + string displayName = !string.IsNullOrWhiteSpace(entity.GraphQL?.Singular) + ? entity.GraphQL.Singular + : entityName; + return new Dictionary { - ["name"] = entityName, + ["name"] = displayName, ["description"] = entity.Description ?? string.Empty }; } @@ -290,11 +359,22 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti /// /// Builds full entity info: name, description, fields, parameters (for stored procs), permissions. /// - private static Dictionary BuildFullEntityInfo(string entityName, Entity entity) + /// The name of the entity to include in the dictionary. + /// The entity object from which to extract additional information. + /// The role of the current user, used to determine permissions. + /// + /// A dictionary containing the entity's name, description, fields, parameters (if applicable), and permissions. + /// + private static Dictionary BuildFullEntityInfo(string entityName, Entity entity, string? currentUserRole) { + // Use GraphQL singular name as alias if available, otherwise use entity name + string displayName = !string.IsNullOrWhiteSpace(entity.GraphQL?.Singular) + ? entity.GraphQL.Singular + : entityName; + Dictionary info = new() { - ["name"] = entityName, + ["name"] = displayName, ["description"] = entity.Description ?? string.Empty, ["fields"] = BuildFieldMetadataInfo(entity.Fields), }; @@ -304,7 +384,7 @@ private static bool ShouldIncludeEntity(string entityName, HashSet? enti info["parameters"] = BuildParameterMetadataInfo(entity.Source.Parameters); } - info["permissions"] = BuildPermissionsInfo(entity); + info["permissions"] = BuildPermissionsInfo(entity, currentUserRole); return info; } @@ -325,7 +405,7 @@ private static List BuildFieldMetadataInfo(List? fields) { result.Add(new { - name = field.Name, + name = field.Alias ?? field.Name, description = field.Description ?? string.Empty }); } @@ -338,7 +418,7 @@ private static List BuildFieldMetadataInfo(List? fields) /// Builds a list of parameter metadata objects containing information about each parameter. /// /// A list of objects representing the parameters to process. Can be null. - /// A list of anonymous objects, each containing the parameter's name, whether it is required, its default + /// A list of dictionaries, each containing the parameter's name, whether it is required, its default /// value, and its description. Returns an empty list if is null. private static List BuildParameterMetadataInfo(List? parameters) { @@ -348,13 +428,14 @@ private static List BuildParameterMetadataInfo(List? { foreach (ParameterMetadata param in parameters) { - result.Add(new + Dictionary paramInfo = new() { - name = param.Name, - required = param.Default == null, // required if no default - @default = param.Default, - description = param.Description ?? string.Empty - }); + ["name"] = param.Name, + ["required"] = param.Required, + ["default"] = param.Default, + ["description"] = param.Description ?? string.Empty + }; + result.Add(paramInfo); } } @@ -362,19 +443,44 @@ private static List BuildParameterMetadataInfo(List? } /// - /// Build a list of permission metadata info + /// Build a list of permission metadata info for the current user's role /// /// The entity object - /// A list of permissions available to the entity - private static string[] BuildPermissionsInfo(Entity entity) + /// The current user's role - if null, returns empty permissions + /// A list of permissions available to the current user's role for this entity + private static string[] BuildPermissionsInfo(Entity entity, string? currentUserRole) { - HashSet permissions = new(); + if (entity.Permissions == null || string.IsNullOrWhiteSpace(currentUserRole)) + { + return Array.Empty(); + } - if (entity.Permissions != null) + bool isStoredProcedure = entity.Source.Type == EntitySourceType.StoredProcedure; + HashSet validOperations = isStoredProcedure + ? EntityAction.ValidStoredProcedurePermissionOperations + : EntityAction.ValidPermissionOperations; + + HashSet permissions = new(StringComparer.OrdinalIgnoreCase); + + // Only include permissions for the current user's role + foreach (EntityPermission permission in entity.Permissions) { - foreach (EntityPermission permission in entity.Permissions) + // Check if this permission applies to the current user's role + if (!string.Equals(permission.Role, currentUserRole, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + foreach (EntityAction action in permission.Actions) { - foreach (EntityAction action in permission.Actions) + if (action.Action == EntityActionOperation.All) + { + foreach (EntityActionOperation op in validOperations) + { + permissions.Add(op.ToString().ToUpperInvariant()); + } + } + else { permissions.Add(action.Action.ToString().ToUpperInvariant()); } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs index c7734eea22..e780c8ddeb 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ExecuteEntityTool.cs @@ -44,18 +44,18 @@ public Tool GetToolMetadata() return new Tool { Name = "execute_entity", - Description = "Executes a stored procedure or function, returns the results (if any)", + Description = "STEP 1: describe_entities -> find entities with EXECUTE permission and their parameters. STEP 2: call this tool with matching parameter values. Used for entities that perform actions or return computed results.", InputSchema = JsonSerializer.Deserialize( @"{ ""type"": ""object"", ""properties"": { ""entity"": { ""type"": ""string"", - ""description"": ""The entity name of the procedure or function to execute. Must match a stored-procedure entity as configured in dab-config. Required."" + ""description"": ""Entity name with EXECUTE permission."" }, ""parameters"": { ""type"": ""object"", - ""description"": ""A dictionary of parameter names and values to pass to the procedure. Parameters must match those defined in dab-config. Optional if no parameters."" + ""description"": ""Optional parameter names and values."" } }, ""required"": [""entity""] @@ -73,6 +73,7 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; try { @@ -86,27 +87,24 @@ public async Task ExecuteAsync( // 2) Check if the tool is enabled in configuration before proceeding if (config.McpDmlTools?.ExecuteEntity != true) { - return McpResponseBuilder.BuildErrorResult( - "ToolDisabled", - $"The {this.GetToolMetadata().Name} tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(this.GetToolMetadata().Name, logger); } // 3) Parsing & basic argument validation if (arguments is null) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } - if (!TryParseExecuteArguments(arguments.RootElement, out string entity, out Dictionary parameters, out string parseError)) + if (!McpArgumentParser.TryParseExecuteArguments(arguments.RootElement, out string entity, out Dictionary parameters, out string parseError)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", parseError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } // Entity is required if (string.IsNullOrWhiteSpace(entity)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", "Entity is required", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Entity is required", logger); } IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); @@ -115,31 +113,25 @@ public async Task ExecuteAsync( // 4) Validate entity exists and is a stored procedure if (!config.Entities.TryGetValue(entity, out Entity? entityConfig)) { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Entity '{entity}' not found in configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{entity}' not found in configuration.", logger); } if (entityConfig.Source.Type != EntitySourceType.StoredProcedure) { - return McpResponseBuilder.BuildErrorResult("InvalidEntity", $"Entity {entity} cannot be executed.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity {entity} cannot be executed.", logger); } - // 5) Resolve metadata - string dataSourceName; - ISqlMetadataProvider sqlMetadataProvider; - - try + // Use shared metadata helper. + if (!McpMetadataHelper.TryResolveMetadata( + entity, + config, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) { - dataSourceName = config.GetDataSourceNameFromEntityName(entity); - sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); - } - catch (Exception) - { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Failed to resolve entity metadata for '{entity}'.", logger); - } - - if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entity, out DatabaseObject? dbObject) || dbObject is null) - { - return McpResponseBuilder.BuildErrorResult("EntityNotFound", $"Failed to resolve database object for entity '{entity}'.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // 6) Authorization - Never bypass permissions @@ -149,7 +141,7 @@ public async Task ExecuteAsync( if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", roleError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entity, "execute", roleError, logger); } if (!McpAuthorizationHelper.TryResolveAuthorizedRole( @@ -160,7 +152,7 @@ public async Task ExecuteAsync( out string? effectiveRole, out string authError)) { - return McpResponseBuilder.BuildErrorResult("PermissionDenied", authError, logger); + return McpErrorHelpers.PermissionDenied(toolName, entity, "execute", authError, logger); } // 7) Validate parameters against metadata @@ -171,7 +163,7 @@ public async Task ExecuteAsync( { if (!entityConfig.Source.Parameters.Any(p => p.Name == param.Key)) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", $"Invalid parameter: {param.Key}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", $"Invalid parameter: {param.Key}", logger); } } } @@ -241,6 +233,7 @@ public async Task ExecuteAsync( message.Contains("authorization", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "PermissionDenied", "You do not have permission to execute this stored procedure.", logger); @@ -249,6 +242,7 @@ public async Task ExecuteAsync( message.Contains("type", StringComparison.OrdinalIgnoreCase)) { return McpResponseBuilder.BuildErrorResult( + toolName, "InvalidArguments", "Invalid data type for one or more parameters.", logger); @@ -256,6 +250,7 @@ public async Task ExecuteAsync( // For any other DAB exceptions, return the message as-is return McpResponseBuilder.BuildErrorResult( + toolName, "DataApiBuilderError", dabEx.Message, logger); @@ -273,96 +268,55 @@ public async Task ExecuteAsync( 229 or 262 => $"Permission denied to execute stored procedure '{entityConfig.Source.Object}'.", _ => $"Database error: {sqlEx.Message}" }; - return McpResponseBuilder.BuildErrorResult("DatabaseError", errorMessage, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", errorMessage, logger); } catch (DbException dbEx) { // Handle generic database exceptions (works for PostgreSQL, MySQL, etc.) logger?.LogError(dbEx, "Database error executing stored procedure {StoredProcedure}", entity); - return McpResponseBuilder.BuildErrorResult("DatabaseError", $"Database error: {dbEx.Message}", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", $"Database error: {dbEx.Message}", logger); } catch (InvalidOperationException ioEx) when (ioEx.Message.Contains("connection", StringComparison.OrdinalIgnoreCase)) { // Handle connection-related issues logger?.LogError(ioEx, "Database connection error"); - return McpResponseBuilder.BuildErrorResult("ConnectionError", "Failed to connect to the database.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "ConnectionError", "Failed to connect to the database.", logger); } catch (TimeoutException timeoutEx) { // Handle query timeout logger?.LogError(timeoutEx, "Stored procedure execution timeout for {StoredProcedure}", entity); - return McpResponseBuilder.BuildErrorResult("TimeoutError", "The stored procedure execution timed out.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "TimeoutError", "The stored procedure execution timed out.", logger); } catch (Exception ex) { // Generic database/execution errors logger?.LogError(ex, "Unexpected error executing stored procedure {StoredProcedure}", entity); - return McpResponseBuilder.BuildErrorResult("DatabaseError", "An error occurred while executing the stored procedure.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", "An error occurred while executing the stored procedure.", logger); } // 11) Build response with execution result - return BuildExecuteSuccessResponse(entity, parameters, queryResult, logger); + return BuildExecuteSuccessResponse(toolName, entity, parameters, queryResult, logger); } catch (OperationCanceledException) { - return McpResponseBuilder.BuildErrorResult("OperationCanceled", "The execute operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The execute operation was canceled.", logger); } catch (ArgumentException argEx) { - return McpResponseBuilder.BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argEx.Message, logger); } catch (Exception ex) { logger?.LogError(ex, "Unexpected error in ExecuteEntityTool."); return McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", "An unexpected error occurred during the execute operation.", logger); } } - /// - /// Parses the execute arguments from the JSON input. - /// - private static bool TryParseExecuteArguments( - JsonElement rootElement, - out string entity, - out Dictionary parameters, - out string parseError) - { - entity = string.Empty; - parameters = new Dictionary(); - parseError = string.Empty; - - if (rootElement.ValueKind != JsonValueKind.Object) - { - parseError = "Arguments must be an object"; - return false; - } - - // Extract entity name (required) - if (!rootElement.TryGetProperty("entity", out JsonElement entityElement) || - entityElement.ValueKind != JsonValueKind.String) - { - parseError = "Missing or invalid 'entity' parameter"; - return false; - } - - entity = entityElement.GetString() ?? string.Empty; - - // Extract parameters if provided (optional) - if (rootElement.TryGetProperty("parameters", out JsonElement parametersElement) && - parametersElement.ValueKind == JsonValueKind.Object) - { - foreach (JsonProperty property in parametersElement.EnumerateObject()) - { - parameters[property.Name] = GetParameterValue(property.Value); - } - } - - return true; - } - /// /// Converts a JSON element to its appropriate CLR type matching GraphQL data types. /// @@ -386,6 +340,7 @@ private static bool TryParseExecuteArguments( /// Builds a successful response for the execute operation. /// private static CallToolResult BuildExecuteSuccessResponse( + string toolName, string entityName, Dictionary? parameters, IActionResult? queryResult, @@ -426,16 +381,14 @@ private static CallToolResult BuildExecuteSuccessResponse( else if (queryResult is BadRequestObjectResult badRequest) { return McpResponseBuilder.BuildErrorResult( + toolName, "BadRequest", badRequest.Value?.ToString() ?? "Bad request", logger); } else if (queryResult is UnauthorizedObjectResult) { - return McpResponseBuilder.BuildErrorResult( - "PermissionDenied", - "You do not have permission to execute this entity", - logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "execute", "You do not have permission to execute this entity", logger); } else { diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs index db1c761d2f..1ed91c30a8 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/ReadRecordsTool.cs @@ -15,6 +15,7 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Mcp.Utils; using Azure.DataApiBuilder.Service.Exceptions; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -35,37 +36,38 @@ public Tool GetToolMetadata() return new Tool { Name = "read_records", - Description = "Retrieves records from a given entity.", + Description = "STEP 1: describe_entities -> find entities with READ permission and their fields. STEP 2: call this tool with select, filter, sort, or pagination options.", InputSchema = JsonSerializer.Deserialize( @"{ ""type"": ""object"", ""properties"": { ""entity"": { ""type"": ""string"", - ""description"": ""The name of the entity to read, as provided by the describe_entities tool. Required."" + ""description"": ""Entity name with READ permission."" }, ""select"": { ""type"": ""string"", - ""description"": ""A comma-separated list of field names to include in the response. If omitted, all fields are returned. Optional."" + ""description"": ""Comma-separated field names."" }, ""filter"": { ""type"": ""string"", - ""description"": ""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). Optional."" - }, - ""first"": { - ""type"": ""integer"", - ""description"": ""The maximum number of records to return in the current page. Optional."" + ""description"": ""OData expression: eq, ne, gt, ge, lt, le, and, or, not."" }, ""orderby"": { ""type"": ""array"", ""items"": { ""type"": ""string"" }, - ""description"": ""A list of field names and directions for sorting, for example 'name asc' or 'year desc'. Optional."" + ""description"": ""Sort fields and directions, e.g., ['name asc', 'year desc']."" + }, + ""first"": { + ""type"": ""integer"", + ""description"": ""Max number of records (page size)."" }, ""after"": { ""type"": ""string"", - ""description"": ""A cursor token for retrieving the next page of results. Returned as 'after' in the previous response. Optional."" + ""description"": ""Cursor token for next page."" } - } + }, + ""required"": [""entity""] }" ) }; @@ -77,6 +79,7 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; // Get runtime config RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); @@ -84,10 +87,7 @@ public async Task ExecuteAsync( if (runtimeConfig.McpDmlTools?.ReadRecords is not true) { - return BuildErrorResult( - "ToolDisabled", - "The read_records tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(toolName, logger); } try @@ -104,18 +104,16 @@ public async Task ExecuteAsync( // Extract arguments if (arguments == null) { - return BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } JsonElement root = arguments.RootElement; - if (!root.TryGetProperty("entity", out JsonElement entityElement) || string.IsNullOrWhiteSpace(entityElement.GetString())) + if (!McpArgumentParser.TryParseEntity(root, out entityName, out string parseError)) { - return BuildErrorResult("InvalidArguments", "Missing required argument 'entity'.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } - entityName = entityElement.GetString()!; - if (root.TryGetProperty("select", out JsonElement selectElement)) { select = selectElement.GetString(); @@ -141,27 +139,16 @@ public async Task ExecuteAsync( after = afterElement.GetString(); } - // Get required services & configuration - IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); - IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); - - // Check metadata for entity exists - string dataSourceName; - ISqlMetadataProvider sqlMetadataProvider; - - try + if (!McpMetadataHelper.TryResolveMetadata( + entityName, + runtimeConfig, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) { - 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); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // Authorization check in the existing entity @@ -170,20 +157,29 @@ public async Task ExecuteAsync( IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); HttpContext? httpContext = httpContextAccessor.HttpContext; - if (httpContext is null || !authResolver.IsValidRoleContext(httpContext)) + if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleCtxError)) { - return BuildErrorResult("PermissionDenied", $"You do not have permission to read records for entity '{entityName}'.", logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "read", roleCtxError, logger); } - if (!TryResolveAuthorizedRole(httpContext, authResolver, entityName, out string? effectiveRole, out string authError)) + if (!McpAuthorizationHelper.TryResolveAuthorizedRole( + httpContext!, + authResolver, + entityName, + EntityActionOperation.Read, + out string? effectiveRole, + out string readAuthError)) { - return BuildErrorResult("PermissionDenied", authError, logger); + string finalError = readAuthError.StartsWith("You do not have permission", StringComparison.OrdinalIgnoreCase) + ? $"You do not have permission to read records for entity '{entityName}'." + : readAuthError; + return McpErrorHelpers.PermissionDenied(toolName, entityName, "read", finalError, logger); } // Build and validate Find context - RequestValidator requestValidator = new(metadataProviderFactory, runtimeConfigProvider); + RequestValidator requestValidator = new(serviceProvider.GetRequiredService(), runtimeConfigProvider); FindRequestContext context = new(entityName, dbObject, true); - httpContext.Request.Method = "GET"; + httpContext!.Request.Method = "GET"; requestValidator.ValidateEntity(entityName); @@ -207,7 +203,7 @@ public async Task ExecuteAsync( { if (string.IsNullOrWhiteSpace(param)) { - return BuildErrorResult("InvalidArguments", "Parameters inside 'orderby' argument cannot be empty or null.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "Parameters inside 'orderby' argument cannot be empty or null.", logger); } sortQueryString += $"{param}, "; @@ -229,193 +225,53 @@ public async Task ExecuteAsync( requirements: new[] { new ColumnsPermissionsRequirement() }); if (!authorizationResult.Succeeded) { - return BuildErrorResult("PermissionDenied", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "read", DataApiBuilderException.AUTHORIZATION_FAILURE, logger); } // Execute + IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); 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); + IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); + IActionResult actionResult = queryResult is null + ? SqlResponseHelpers.FormatFindResult(JsonDocument.Parse("[]").RootElement.Clone(), context, sqlMetadataProvider, runtimeConfig, httpContext, true) + : SqlResponseHelpers.FormatFindResult(queryResult.RootElement.Clone(), context, sqlMetadataProvider, runtimeConfig, httpContext, true); // Normalize response - string rawPayloadJson = ExtractResultJson(actionResult); - JsonDocument result = JsonDocument.Parse(rawPayloadJson); + string rawPayloadJson = McpResponseBuilder.ExtractResultJson(actionResult); + using JsonDocument result = JsonDocument.Parse(rawPayloadJson); JsonElement queryRoot = result.RootElement; - return BuildSuccessResult( - entityName, - queryRoot.Clone(), - logger); + return McpResponseBuilder.BuildSuccessResult( + new Dictionary + { + ["entity"] = entityName, + ["result"] = queryRoot.Clone(), + ["message"] = $"Successfully read records for entity '{entityName}'" + }, + logger, + $"ReadRecordsTool success for entity {entityName}."); } catch (OperationCanceledException) { - return BuildErrorResult("OperationCanceled", "The read operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The read operation was canceled.", logger); } catch (DbException argEx) { - return BuildErrorResult("DatabaseOperationFailed", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseOperationFailed", argEx.Message, logger); } catch (ArgumentException argEx) { - return BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argEx.Message, logger); } catch (DataApiBuilderException argEx) { - return BuildErrorResult(argEx.StatusCode.ToString(), argEx.Message, logger); - } - catch (Exception) - { - return BuildErrorResult("UnexpectedError", "Unexpected error occurred in ReadRecordsTool.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, argEx.StatusCode.ToString(), argEx.Message, logger); } - } - - /// - /// Ensures that the role used on the request has the necessary authorizations. - /// - /// Contains request headers and metadata of the user. - /// Resolver used to check if role has necessary authorizations. - /// Name of the entity used in the request. - /// Role defined in client role header. - /// Error message given to the user. - /// True if the user role is authorized, along with the role. - private static bool TryResolveAuthorizedRole( - 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)) + catch (Exception ex) { - error = $"Client role header '{AuthorizationResolver.CLIENT_ROLE_HEADER}' is missing or empty."; - return false; - } - - string[] roles = roleHeader - .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) - .Distinct(StringComparer.OrdinalIgnoreCase) - .ToArray(); - - if (roles.Length == 0) - { - error = $"Client role header '{AuthorizationResolver.CLIENT_ROLE_HEADER}' is missing or empty."; - 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 entity '{entityName}'."; - return false; - } - - /// - /// Returns a result from the query in the case that it was successfully ran. - /// - /// Name of the entity used in the request. - /// Query result from engine. - /// MCP logger that returns all logged events. - private static CallToolResult BuildSuccessResult( - string entityName, - JsonElement engineRootElement, - ILogger? logger) - { - // Build normalized response - Dictionary normalized = new() - { - ["status"] = "success", - ["result"] = engineRootElement // only requested values - }; - - string output = JsonSerializer.Serialize(normalized, new JsonSerializerOptions { WriteIndented = true }); - - logger?.LogInformation("ReadRecordsTool success for entity {Entity}.", entityName); - - return new CallToolResult - { - Content = new List - { - new TextContentBlock { Type = "text", Text = output } - } - }; - } - - /// - /// Returns an error if the query failed to run at any point. - /// - /// Type of error that is encountered. - /// Error message given to the user. - /// MCP logger that returns all logged events. - private static CallToolResult BuildErrorResult( - string errorType, - string message, - ILogger? logger) - { - Dictionary errorObj = new() - { - ["status"] = "error", - ["error"] = new Dictionary - { - ["type"] = errorType, - ["message"] = message - } - }; - - string output = JsonSerializer.Serialize(errorObj); - - logger?.LogError("ReadRecordsTool error {ErrorType}: {Message}", errorType, message); - - return new CallToolResult - { - Content = - [ - new TextContentBlock { Type = "text", Text = output } - ], - IsError = true - }; - } - - /// - /// Extracts a JSON string from a typical IActionResult. - /// Falls back to "{}" for unsupported/empty cases to avoid leaking internals. - /// - private static string ExtractResultJson(IActionResult? result) - { - 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 "{}"; + logger?.LogError(ex, "Unexpected error in ReadRecordsTool."); + return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "Unexpected error occurred in ReadRecordsTool.", logger); } } } diff --git a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs index 195a758454..195e27a0cd 100644 --- a/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs +++ b/src/Azure.DataApiBuilder.Mcp/BuiltInTools/UpdateRecordTool.cs @@ -5,7 +5,6 @@ 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.Resolvers; @@ -13,6 +12,7 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Mcp.Utils; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.DependencyInjection; @@ -46,22 +46,22 @@ public Tool GetToolMetadata() return new Tool { Name = "update_record", - Description = "Updates an existing record in the specified entity. Requires 'keys' to locate the record and 'fields' to specify new values.", + Description = "STEP 1: describe_entities -> find entities with UPDATE permission and their key fields. STEP 2: call this tool with keys and new field values.", InputSchema = JsonSerializer.Deserialize( @"{ ""type"": ""object"", ""properties"": { ""entity"": { ""type"": ""string"", - ""description"": ""The name of the entity"" + ""description"": ""Entity name with UPDATE permission."" }, ""keys"": { ""type"": ""object"", - ""description"": ""Key fields and their values to identify the record"" + ""description"": ""Primary or composite keys identifying the record."" }, ""fields"": { ""type"": ""object"", - ""description"": ""Fields and their new values to update"" + ""description"": ""Fields and their new values."" } }, ""required"": [""entity"", ""keys"", ""fields""] @@ -83,8 +83,7 @@ public async Task ExecuteAsync( CancellationToken cancellationToken = default) { ILogger? logger = serviceProvider.GetService>(); - - // 1) Resolve required services & configuration + string toolName = GetToolMetadata().Name; RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); RuntimeConfig config = runtimeConfigProvider.GetConfig(); @@ -92,10 +91,7 @@ public async Task ExecuteAsync( // 2)Check if the tool is enabled in configuration before proceeding. if (config.McpDmlTools?.UpdateRecord != true) { - return BuildErrorResult( - "ToolDisabled", - "The update_record tool is disabled in the configuration.", - logger); + return McpErrorHelpers.ToolDisabled(GetToolMetadata().Name, logger); } try @@ -106,34 +102,32 @@ public async Task ExecuteAsync( // 3) Parsing & basic argument validation (entity, keys, fields) if (arguments is null) { - return BuildErrorResult("InvalidArguments", "No arguments provided.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No arguments provided.", logger); } - if (!TryParseArguments(arguments.RootElement, out string entityName, out Dictionary keys, out Dictionary fields, out string parseError)) + if (!McpArgumentParser.TryParseEntityKeysAndFields( + arguments.RootElement, + out string entityName, + out Dictionary keys, + out Dictionary fields, + out string parseError)) { - return BuildErrorResult("InvalidArguments", parseError, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", parseError, logger); } IMetadataProviderFactory metadataProviderFactory = serviceProvider.GetRequiredService(); IMutationEngineFactory mutationEngineFactory = serviceProvider.GetRequiredService(); - // 4) Resolve metadata for entity existence check - string dataSourceName; - ISqlMetadataProvider sqlMetadataProvider; - - try - { - dataSourceName = config.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) + if (!McpMetadataHelper.TryResolveMetadata( + entityName, + config, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) { - return BuildErrorResult("EntityNotFound", $"Entity '{entityName}' is not defined in the configuration.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); } // 5) Authorization after we have a known entity @@ -143,12 +137,18 @@ public async Task ExecuteAsync( if (httpContext is null || !authResolver.IsValidRoleContext(httpContext)) { - return BuildErrorResult("PermissionDenied", "Permission denied: unable to resolve a valid role context for update operation.", logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", "unable to resolve a valid role context for update operation.", logger); } - if (!TryResolveAuthorizedRoleHasPermission(httpContext, authResolver, entityName, out string? effectiveRole, out string authError)) + if (!McpAuthorizationHelper.TryResolveAuthorizedRole( + httpContext!, + authResolver, + entityName, + EntityActionOperation.Update, + out string? effectiveRole, + out string authError)) { - return BuildErrorResult("PermissionDenied", $"Permission denied: {authError}", logger); + return McpErrorHelpers.PermissionDenied(toolName, entityName, "update", authError, logger); } // 6) Build and validate Upsert (UpdateIncremental) context @@ -165,7 +165,7 @@ public async Task ExecuteAsync( { if (kvp.Value is null) { - return BuildErrorResult("InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", $"Primary key value for '{kvp.Key}' cannot be null.", logger); } context.PrimaryKeyValuePairs[kvp.Key] = kvp.Value; @@ -193,10 +193,7 @@ public async Task ExecuteAsync( if (errorMsg.Contains("No Update could be performed, record not found", StringComparison.OrdinalIgnoreCase)) { - return BuildErrorResult( - "InvalidArguments", - "No record found with the given key.", - logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", "No record found with the given key.", logger); } else { @@ -208,265 +205,51 @@ public async Task ExecuteAsync( cancellationToken.ThrowIfCancellationRequested(); // 8) Normalize response (success or engine error payload) - string rawPayloadJson = ExtractResultJson(mutationResult); + string rawPayloadJson = McpResponseBuilder.ExtractResultJson(mutationResult); using JsonDocument resultDoc = JsonDocument.Parse(rawPayloadJson); JsonElement root = resultDoc.RootElement; - return BuildSuccessResult( - entityName: entityName, - engineRootElement: root.Clone(), - logger: logger); + // Extract first item of value[] array (updated record) + Dictionary filteredResult = new(); + if (root.TryGetProperty("value", out JsonElement valueArray) && + valueArray.ValueKind == JsonValueKind.Array && + valueArray.GetArrayLength() > 0) + { + JsonElement firstItem = valueArray[0]; + foreach (JsonProperty prop in firstItem.EnumerateObject()) + { + filteredResult[prop.Name] = McpResponseBuilder.GetJsonValue(prop.Value); + } + } + + return McpResponseBuilder.BuildSuccessResult( + new Dictionary + { + ["entity"] = entityName, + ["result"] = filteredResult, + ["message"] = $"Successfully updated record in entity '{entityName}'" + }, + logger, + $"UpdateRecordTool success for entity {entityName}."); } catch (OperationCanceledException) { - return BuildErrorResult("OperationCanceled", "The update operation was canceled.", logger); + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The update operation was canceled.", logger); } catch (ArgumentException argEx) { - return BuildErrorResult("InvalidArguments", argEx.Message, logger); + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidArguments", argEx.Message, logger); } catch (Exception ex) { - ILogger? innerLogger = serviceProvider.GetService>(); - innerLogger?.LogError(ex, "Unexpected error in UpdateRecordTool."); + logger?.LogError(ex, "Unexpected error in UpdateRecordTool."); - return BuildErrorResult( + return McpResponseBuilder.BuildErrorResult( + toolName, "UnexpectedError", ex.Message ?? "An unexpected error occurred during the update operation.", logger); } } - - #region Parsing & Authorization - - private static bool TryParseArguments( - JsonElement root, - out string entityName, - out Dictionary keys, - out Dictionary fields, - out string error) - { - entityName = string.Empty; - keys = new Dictionary(); - fields = new Dictionary(); - error = string.Empty; - - if (!root.TryGetProperty("entity", out JsonElement entityEl) || - !root.TryGetProperty("keys", out JsonElement keysEl) || - !root.TryGetProperty("fields", out JsonElement fieldsEl)) - { - error = "Missing required arguments 'entity', 'keys', or 'fields'."; - return false; - } - - // Parse and validate required arguments: entity, keys, fields - entityName = entityEl.GetString() ?? string.Empty; - if (string.IsNullOrWhiteSpace(entityName)) - { - throw new ArgumentException("Entity is required", nameof(entityName)); - } - - if (keysEl.ValueKind != JsonValueKind.Object || fieldsEl.ValueKind != JsonValueKind.Object) - { - throw new ArgumentException("'keys' and 'fields' must be JSON objects."); - } - - try - { - keys = JsonSerializer.Deserialize>(keysEl.GetRawText()) ?? new Dictionary(); - fields = JsonSerializer.Deserialize>(fieldsEl.GetRawText()) ?? new Dictionary(); - } - catch (Exception ex) - { - throw new ArgumentException("Failed to parse 'keys' or 'fields'", ex); - } - - if (keys.Count == 0) - { - throw new ArgumentException("Keys are required to update an entity"); - } - - if (fields.Count == 0) - { - throw new ArgumentException("At least one field must be provided to update an entity", nameof(fields)); - } - - foreach (KeyValuePair kv in keys) - { - if (kv.Value is null || (kv.Value is string str && string.IsNullOrWhiteSpace(str))) - { - throw new ArgumentException($"Key value for '{kv.Key}' cannot be null or empty."); - } - } - - return true; - } - - private static bool TryResolveAuthorizedRoleHasPermission( - 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."; - 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."; - return false; - } - - foreach (string role in roles) - { - bool allowed = authorizationResolver.AreRoleAndOperationDefinedForEntity( - entityName, role, EntityActionOperation.Update); - - if (allowed) - { - effectiveRole = role; - return true; - } - } - - error = "You do not have permission to update records for this entity."; - return false; - } - - #endregion - - #region Response Builders & Utilities - - private static CallToolResult BuildSuccessResult( - string entityName, - JsonElement engineRootElement, - ILogger? logger) - { - // Extract only requested keys and updated fields from engineRootElement - Dictionary filteredResult = new(); - - // Navigate to "value" array in the engine result - if (engineRootElement.TryGetProperty("value", out JsonElement valueArray) && - valueArray.ValueKind == JsonValueKind.Array && - valueArray.GetArrayLength() > 0) - { - JsonElement firstItem = valueArray[0]; - - // Include all properties from the result - foreach (JsonProperty prop in firstItem.EnumerateObject()) - { - filteredResult[prop.Name] = GetJsonValue(prop.Value); - } - } - - // Build normalized response - Dictionary normalized = new() - { - ["status"] = "success", - ["result"] = filteredResult - }; - - string output = JsonSerializer.Serialize(normalized, new JsonSerializerOptions { WriteIndented = true }); - - logger?.LogInformation("UpdateRecordTool success for entity {Entity}.", entityName); - - return new CallToolResult - { - Content = new List - { - new TextContentBlock { Type = "text", Text = output } - } - }; - } - - /// - /// Converts JsonElement to .NET object dynamically. - /// - private static object? GetJsonValue(JsonElement element) - { - return element.ValueKind switch - { - JsonValueKind.String => element.GetString(), - JsonValueKind.Number => element.TryGetInt64(out long l) ? l : element.GetDouble(), - JsonValueKind.True => true, - JsonValueKind.False => false, - JsonValueKind.Null => null, - _ => element.GetRawText() // fallback for arrays/objects - }; - } - - private static CallToolResult BuildErrorResult( - string errorType, - string message, - ILogger? logger) - { - Dictionary errorObj = new() - { - ["status"] = "error", - ["error"] = new Dictionary - { - ["type"] = errorType, - ["message"] = message - } - }; - - string output = JsonSerializer.Serialize(errorObj); - - logger?.LogWarning("UpdateRecordTool error {ErrorType}: {Message}", errorType, message); - - return new CallToolResult - { - Content = - [ - new TextContentBlock { Type = "text", Text = output } - ], - IsError = true - }; - } - - /// - /// Extracts a JSON string from a typical IActionResult. - /// Falls back to "{}" for unsupported/empty cases to avoid leaking internals. - /// - private static string ExtractResultJson(IActionResult? result) - { - 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 "{}"; - } - } - - #endregion } } diff --git a/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs new file mode 100644 index 0000000000..f688eeb80a --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/CustomMcpToolFactory.cs @@ -0,0 +1,63 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// Factory for creating custom MCP tools from stored procedure entity configurations. + /// Scans runtime configuration and generates dynamic tools for entities marked with custom-tool enabled. + /// + public class CustomMcpToolFactory + { + /// + /// Creates custom MCP tools from entities configured with "mcp": { "custom-tool": true }. + /// + /// The runtime configuration containing entity definitions. + /// Optional logger for diagnostic information. + /// Enumerable of custom tools generated from configuration. + public static IEnumerable CreateCustomTools(RuntimeConfig config, ILogger? logger = null) + { + if (config.Entities == null) + { + logger?.LogWarning("No entities found in runtime configuration for custom tool generation."); + return Enumerable.Empty(); + } + + List customTools = new(); + + foreach ((string entityName, Entity entity) in config.Entities) + { + // Filter: Only stored procedures with custom-tool enabled + if (entity.Source.Type == EntitySourceType.StoredProcedure && + entity.Mcp?.CustomToolEnabled == true) + { + try + { + DynamicCustomTool tool = new(entityName, entity); + + logger?.LogInformation( + "Created custom MCP tool '{ToolName}' for stored procedure entity '{EntityName}'", + tool.GetToolMetadata().Name, + entityName); + + customTools.Add(tool); + } + catch (Exception ex) + { + logger?.LogError( + ex, + "Failed to create custom tool for entity '{EntityName}'. Skipping.", + entityName); + } + } + } + + logger?.LogInformation("Custom MCP tool generation complete. Created {Count} custom tools.", customTools.Count); + return customTools; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs new file mode 100644 index 0000000000..3c79839877 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/DynamicCustomTool.cs @@ -0,0 +1,375 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Data.Common; +using System.Text.Json; +using System.Text.RegularExpressions; +using Azure.DataApiBuilder.Auth; +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Core.Models; +using Azure.DataApiBuilder.Core.Resolvers; +using Azure.DataApiBuilder.Core.Resolvers.Factories; +using Azure.DataApiBuilder.Core.Services; +using Azure.DataApiBuilder.Mcp.Model; +using Azure.DataApiBuilder.Mcp.Utils; +using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; +using static Azure.DataApiBuilder.Mcp.Model.McpEnums; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// Dynamic custom MCP tool generated from stored procedure entity configuration. + /// Each custom tool represents a single stored procedure exposed as a dedicated MCP tool. + /// + /// Note: The entity configuration is captured at tool construction time. If the RuntimeConfig + /// is hot-reloaded, GetToolMetadata() will return cached metadata (name, description, parameters) + /// from the original configuration. This is acceptable because: + /// 1. MCP clients typically call tools/list once at startup + /// 2. ExecuteAsync always validates against the current runtime configuration + /// 3. Cached metadata improves performance for repeated metadata requests + /// + public class DynamicCustomTool : IMcpTool + { + private readonly string _entityName; + private readonly Entity _entity; + + /// + /// Initializes a new instance of DynamicCustomTool. + /// + /// The entity name from configuration. + /// The entity configuration object. + public DynamicCustomTool(string entityName, Entity entity) + { + _entityName = entityName ?? throw new ArgumentNullException(nameof(entityName)); + _entity = entity ?? throw new ArgumentNullException(nameof(entity)); + + // Validate that this is a stored procedure + if (_entity.Source.Type != EntitySourceType.StoredProcedure) + { + throw new ArgumentException( + $"Custom tools can only be created for stored procedures. Entity '{entityName}' is of type '{_entity.Source.Type}'.", + nameof(entity)); + } + } + + /// + /// Gets the type of the tool, which is Custom for dynamically generated tools. + /// + public ToolType ToolType { get; } = ToolType.Custom; + + /// + /// Gets the metadata for this custom tool, including name, description, and input schema. + /// + public Tool GetToolMetadata() + { + string toolName = ConvertToToolName(_entityName); + string description = _entity.Description ?? $"Executes the {toolName} stored procedure"; + + // Build input schema based on parameters + JsonElement inputSchema = BuildInputSchema(); + + return new Tool + { + Name = toolName, + Description = description, + InputSchema = inputSchema + }; + } + + /// + /// Executes the stored procedure represented by this custom tool. + /// + public async Task ExecuteAsync( + JsonDocument? arguments, + IServiceProvider serviceProvider, + CancellationToken cancellationToken = default) + { + ILogger? logger = serviceProvider.GetService>(); + string toolName = GetToolMetadata().Name; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + + // 1) Resolve required services & configuration + RuntimeConfigProvider runtimeConfigProvider = serviceProvider.GetRequiredService(); + RuntimeConfig config = runtimeConfigProvider.GetConfig(); + + // 2) Parse arguments from the request + Dictionary parameters = new(); + if (arguments != null) + { + foreach (JsonProperty property in arguments.RootElement.EnumerateObject()) + { + parameters[property.Name] = GetParameterValue(property.Value); + } + } + + // 3) Validate entity still exists in configuration + if (!config.Entities.TryGetValue(_entityName, out Entity? entityConfig)) + { + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", $"Entity '{_entityName}' not found in configuration.", logger); + } + + if (entityConfig.Source.Type != EntitySourceType.StoredProcedure) + { + return McpResponseBuilder.BuildErrorResult(toolName, "InvalidEntity", $"Entity {_entityName} is not a stored procedure.", logger); + } + + // 4) Resolve metadata + if (!McpMetadataHelper.TryResolveMetadata( + _entityName, + config, + serviceProvider, + out ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string metadataError)) + { + return McpResponseBuilder.BuildErrorResult(toolName, "EntityNotFound", metadataError, logger); + } + + // 5) Authorization check + IAuthorizationResolver authResolver = serviceProvider.GetRequiredService(); + IHttpContextAccessor httpContextAccessor = serviceProvider.GetRequiredService(); + HttpContext? httpContext = httpContextAccessor.HttpContext; + + if (!McpAuthorizationHelper.ValidateRoleContext(httpContext, authResolver, out string roleError)) + { + return McpErrorHelpers.PermissionDenied(toolName, _entityName, "execute", roleError, logger); + } + + if (!McpAuthorizationHelper.TryResolveAuthorizedRole( + httpContext!, + authResolver, + _entityName, + EntityActionOperation.Execute, + out string? effectiveRole, + out string authError)) + { + return McpErrorHelpers.PermissionDenied(toolName, _entityName, "execute", authError, logger); + } + + // 6) Build request payload + JsonElement? requestPayloadRoot = null; + if (parameters.Count > 0) + { + string jsonPayload = JsonSerializer.Serialize(parameters); + using JsonDocument doc = JsonDocument.Parse(jsonPayload); + requestPayloadRoot = doc.RootElement.Clone(); + } + + // 7) Build stored procedure execution context + StoredProcedureRequestContext context = new( + entityName: _entityName, + dbo: dbObject, + requestPayloadRoot: requestPayloadRoot, + operationType: EntityActionOperation.Execute); + + // Add user-provided parameters + if (requestPayloadRoot != null) + { + foreach (JsonProperty property in requestPayloadRoot.Value.EnumerateObject()) + { + context.FieldValuePairsInBody[property.Name] = GetParameterValue(property.Value); + } + } + + // Add default parameters from configuration if not provided + if (entityConfig.Source.Parameters != null) + { + foreach (ParameterMetadata param in entityConfig.Source.Parameters) + { + if (!context.FieldValuePairsInBody.ContainsKey(param.Name)) + { + context.FieldValuePairsInBody[param.Name] = param.Default; + } + } + } + + // Populate resolved parameters + context.PopulateResolvedParameters(); + + // 8) Execute stored procedure + DatabaseType dbType = config.GetDataSourceFromDataSourceName(dataSourceName).DatabaseType; + IQueryEngineFactory queryEngineFactory = serviceProvider.GetRequiredService(); + IQueryEngine queryEngine = queryEngineFactory.GetQueryEngine(dbType); + + IActionResult? queryResult = null; + + try + { + cancellationToken.ThrowIfCancellationRequested(); + queryResult = await queryEngine.ExecuteAsync(context, dataSourceName).ConfigureAwait(false); + } + catch (DataApiBuilderException dabEx) + { + logger?.LogError(dabEx, "Error executing custom tool {ToolName} for entity {Entity}", toolName, _entityName); + return McpResponseBuilder.BuildErrorResult(toolName, "ExecutionError", dabEx.Message, logger); + } + catch (SqlException sqlEx) + { + logger?.LogError(sqlEx, "SQL error executing custom tool {ToolName}", toolName); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", $"Database error: {sqlEx.Message}", logger); + } + catch (DbException dbEx) + { + logger?.LogError(dbEx, "Database error executing custom tool {ToolName}", toolName); + return McpResponseBuilder.BuildErrorResult(toolName, "DatabaseError", $"Database error: {dbEx.Message}", logger); + } + catch (Exception ex) + { + logger?.LogError(ex, "Unexpected error executing custom tool {ToolName}", toolName); + return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "An error occurred during execution.", logger); + } + + // 9) Build success response + return BuildExecuteSuccessResponse(toolName, _entityName, parameters, queryResult, logger); + } + catch (OperationCanceledException) + { + return McpResponseBuilder.BuildErrorResult(toolName, "OperationCanceled", "The operation was canceled.", logger); + } + catch (Exception ex) + { + logger?.LogError(ex, "Unexpected error in DynamicCustomTool for {EntityName}", _entityName); + return McpResponseBuilder.BuildErrorResult(toolName, "UnexpectedError", "An unexpected error occurred.", logger); + } + } + + /// + /// Converts entity name to tool name format (lowercase with underscores). + /// + private static string ConvertToToolName(string entityName) + { + // Convert PascalCase to snake_case + string result = Regex.Replace(entityName, "([a-z0-9])([A-Z])", "$1_$2"); + return result.ToLowerInvariant(); + } + + /// + /// Builds the input schema for the tool based on entity parameters. + /// + private JsonElement BuildInputSchema() + { + Dictionary schema = new() + { + ["type"] = "object", + ["properties"] = new Dictionary() + }; + + if (_entity.Source.Parameters != null && _entity.Source.Parameters.Any()) + { + Dictionary properties = (Dictionary)schema["properties"]; + + foreach (ParameterMetadata param in _entity.Source.Parameters) + { + // Note: Parameter type information is not available in ParameterMetadata, + // so we allow multiple JSON types to match the behavior of GetParameterValue + // that handles string, number, boolean, and null values. + properties[param.Name] = new Dictionary + { + ["type"] = new[] { "string", "number", "boolean", "null" }, + ["description"] = param.Description ?? $"Parameter {param.Name}" + }; + } + } + + return JsonSerializer.SerializeToElement(schema); + } + + /// + /// Converts a JSON element to its appropriate CLR type. + /// + private static object? GetParameterValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => + element.TryGetInt64(out long longValue) ? longValue : + element.TryGetDecimal(out decimal decimalValue) ? decimalValue : + element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.ToString() + }; + } + + /// + /// Builds a successful response for the execute operation. + /// + private static CallToolResult BuildExecuteSuccessResponse( + string toolName, + string entityName, + Dictionary? parameters, + IActionResult? queryResult, + ILogger? logger) + { + Dictionary responseData = new() + { + ["entity"] = entityName, + ["message"] = "Stored procedure executed successfully" + }; + + // Include parameters if any were provided + if (parameters?.Count > 0) + { + responseData["parameters"] = parameters; + } + + // Handle different result types + if (queryResult is OkObjectResult okResult && okResult.Value != null) + { + // Extract the actual data from the action result + if (okResult.Value is JsonDocument jsonDoc) + { + JsonElement root = jsonDoc.RootElement; + responseData["value"] = root.ValueKind == JsonValueKind.Array ? root : JsonSerializer.SerializeToElement(new[] { root }); + } + else if (okResult.Value is JsonElement jsonElement) + { + responseData["value"] = jsonElement.ValueKind == JsonValueKind.Array ? jsonElement : JsonSerializer.SerializeToElement(new[] { jsonElement }); + } + else + { + // Serialize the value directly + JsonElement serialized = JsonSerializer.SerializeToElement(okResult.Value); + responseData["value"] = serialized; + } + } + else if (queryResult is BadRequestObjectResult badRequest) + { + return McpResponseBuilder.BuildErrorResult( + toolName, + "BadRequest", + badRequest.Value?.ToString() ?? "Bad request", + logger); + } + else if (queryResult is UnauthorizedObjectResult) + { + return McpErrorHelpers.PermissionDenied(toolName, entityName, "execute", "You do not have permission to execute this entity", logger); + } + else + { + // Empty or unknown result + responseData["value"] = JsonSerializer.SerializeToElement(Array.Empty()); + } + + return McpResponseBuilder.BuildSuccessResult( + responseData, + logger, + $"Custom tool {toolName} executed successfully for entity {entityName}." + ); + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpProtocolDefaults.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpProtocolDefaults.cs new file mode 100644 index 0000000000..48b235f480 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpProtocolDefaults.cs @@ -0,0 +1,39 @@ +using Azure.DataApiBuilder.Product; +using Microsoft.Extensions.Configuration; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// Centralized defaults and configuration keys for MCP protocol settings. + /// + public static class McpProtocolDefaults + { + /// + /// Default MCP server name advertised during initialization. + /// + public const string MCP_SERVER_NAME = "SQL MCP Server"; + /// + /// Default MCP server version advertised during initialization. + /// + public static readonly string MCP_SERVER_VERSION = ProductInfo.GetProductVersion(); + /// + /// Default MCP protocol version advertised when no configuration override is provided. + /// + public const string DEFAULT_PROTOCOL_VERSION = "2025-06-18"; + + /// + /// Configuration key used to override the MCP protocol version. + /// + public const string PROTOCOL_VERSION_CONFIG_KEY = "MCP:ProtocolVersion"; + + /// + /// Helper to resolve the effective protocol version from configuration. + /// Falls back to when the key is not set. + /// + public static string ResolveProtocolVersion(IConfiguration? configuration) + { + return configuration?.GetValue(PROTOCOL_VERSION_CONFIG_KEY) ?? DEFAULT_PROTOCOL_VERSION; + } + } +} + diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs index 86cccd2aaf..d76af816bd 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServerConfiguration.cs @@ -21,7 +21,7 @@ internal static IServiceCollection ConfigureMcpServer(this IServiceCollection se { services.AddMcpServer(options => { - options.ServerInfo = new() { Name = "Data API builder MCP Server", Version = "1.0.0" }; + options.ServerInfo = new() { Name = McpProtocolDefaults.MCP_SERVER_NAME, Version = McpProtocolDefaults.MCP_SERVER_VERSION }; options.Capabilities = new() { Tools = new() diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs index 01f6015786..bc87602da9 100644 --- a/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpServiceCollectionExtensions.cs @@ -38,6 +38,9 @@ public static IServiceCollection AddDabMcpServer(this IServiceCollection service // Auto-discover and register all MCP tools RegisterAllMcpTools(services); + // Register custom tools from configuration + RegisterCustomTools(services, runtimeConfig); + // Configure MCP server services.ConfigureMcpServer(); @@ -54,12 +57,25 @@ private static void RegisterAllMcpTools(IServiceCollection services) IEnumerable toolTypes = mcpAssembly.GetTypes() .Where(t => t.IsClass && !t.IsAbstract && - typeof(IMcpTool).IsAssignableFrom(t)); + typeof(IMcpTool).IsAssignableFrom(t) && + t != typeof(DynamicCustomTool)); // Exclude DynamicCustomTool from auto-registration foreach (Type toolType in toolTypes) { services.AddSingleton(typeof(IMcpTool), toolType); } } + + /// + /// Registers custom MCP tools generated from stored procedure entity configurations. + /// + private static void RegisterCustomTools(IServiceCollection services, RuntimeConfig config) + { + // Create custom tools and register each as a singleton + foreach (IMcpTool customTool in CustomMcpToolFactory.CreateCustomTools(config)) + { + services.AddSingleton(customTool); + } + } } } diff --git a/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs new file mode 100644 index 0000000000..51d8295068 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Core/McpStdioServer.cs @@ -0,0 +1,498 @@ +using System.Collections; +using System.Reflection; +using System.Security.Claims; +using System.Text; +using System.Text.Json; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Core.AuthenticationHelpers.AuthenticationSimulator; +using Azure.DataApiBuilder.Core.Configurations; +using Azure.DataApiBuilder.Mcp.Model; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp.Core +{ + /// + /// MCP stdio server: + /// - Reads JSON-RPC requests (initialize, listTools, callTool) from STDIN + /// - Writes ONLY MCP JSON responses to STDOUT + /// - Writes diagnostics to STDERR (so STDOUT remains “pure MCP”) + /// + public class McpStdioServer : IMcpStdioServer + { + private readonly McpToolRegistry _toolRegistry; + private readonly IServiceProvider _serviceProvider; + private readonly string _protocolVersion; + + private const int MAX_LINE_LENGTH = 1024 * 1024; // 1 MB limit for incoming JSON-RPC requests + + public McpStdioServer(McpToolRegistry toolRegistry, IServiceProvider serviceProvider) + { + _toolRegistry = toolRegistry ?? throw new ArgumentNullException(nameof(toolRegistry)); + _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + + // Allow protocol version to be configured via IConfiguration, using centralized defaults. + IConfiguration? configuration = _serviceProvider.GetService(); + _protocolVersion = McpProtocolDefaults.ResolveProtocolVersion(configuration); + } + + /// + /// Runs the MCP stdio server loop, reading JSON-RPC requests from STDIN and writing MCP JSON responses to STDOUT. + /// + /// Token to signal cancellation of the server loop. + /// A task representing the asynchronous operation. + public async Task RunAsync(CancellationToken cancellationToken) + { + Console.Error.WriteLine("[MCP DEBUG] MCP stdio server started."); + + // Use UTF-8 WITHOUT BOM + UTF8Encoding utf8NoBom = new(encoderShouldEmitUTF8Identifier: false); + + using Stream stdin = Console.OpenStandardInput(); + using Stream stdout = Console.OpenStandardOutput(); + using StreamReader reader = new(stdin, utf8NoBom); + using StreamWriter writer = new(stdout, utf8NoBom) { AutoFlush = true }; + + // Redirect Console.Out to use our writer + Console.SetOut(writer); + while (!cancellationToken.IsCancellationRequested) + { + string? line = await reader.ReadLineAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + if (line.Length > MAX_LINE_LENGTH) + { + WriteError(id: null, code: McpStdioJsonRpcErrorCodes.INVALID_REQUEST, message: "Request too large"); + continue; + } + + JsonDocument doc; + try + { + doc = JsonDocument.Parse(line); + } + catch (JsonException jsonEx) + { + Console.Error.WriteLine($"[MCP DEBUG] JSON parse error: {jsonEx.Message}"); + WriteError(id: null, code: McpStdioJsonRpcErrorCodes.PARSE_ERROR, message: "Parse error"); + continue; + } + catch (Exception ex) + { + Console.Error.WriteLine($"[MCP DEBUG] Unexpected error parsing request: {ex.Message}"); + WriteError(id: null, code: McpStdioJsonRpcErrorCodes.INTERNAL_ERROR, message: "Internal error"); + continue; + } + + using (doc) + { + JsonElement root = doc.RootElement; + + JsonElement? id = null; + if (root.TryGetProperty("id", out JsonElement idEl)) + { + id = idEl; // preserve original type (string or number) + } + + if (!root.TryGetProperty("method", out JsonElement methodEl)) + { + WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_REQUEST, "Invalid Request"); + continue; + } + + string method = methodEl.GetString() ?? string.Empty; + + try + { + switch (method) + { + case "initialize": + HandleInitialize(id); + break; + + case "notifications/initialized": + break; + + case "tools/list": + HandleListTools(id); + break; + + case "tools/call": + await HandleCallToolAsync(id, root, cancellationToken); + break; + + case "ping": + WriteResult(id, new { ok = true }); + break; + + case "shutdown": + WriteResult(id, new { ok = true }); + return; + + default: + WriteError(id, McpStdioJsonRpcErrorCodes.METHOD_NOT_FOUND, $"Method not found: {method}"); + break; + } + } + catch (Exception) + { + WriteError(id, McpStdioJsonRpcErrorCodes.INTERNAL_ERROR, "Internal error"); + } + } + } + } + + /// + /// Handles the "initialize" JSON-RPC method by sending the MCP protocol version, server capabilities, and server info to the client. + /// + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// + /// + /// This method constructs and writes the MCP "initialize" response to STDOUT. It uses the protocol version defined by PROTOCOL_VERSION + /// and includes supported capabilities and server information. No notifications are sent here; the server waits for the client to send + /// "notifications/initialized" before sending any notifications. + /// + private void HandleInitialize(JsonElement? id) + { + // Get the description from runtime config if available + string? instructions = null; + RuntimeConfigProvider? runtimeConfigProvider = _serviceProvider.GetService(); + if (runtimeConfigProvider != null) + { + try + { + RuntimeConfig runtimeConfig = runtimeConfigProvider.GetConfig(); + instructions = runtimeConfig.Runtime?.Mcp?.Description; + } + catch (Exception ex) + { + // Log to stderr for diagnostics and rethrow to avoid masking configuration errors + Console.Error.WriteLine($"[MCP WARNING] Failed to retrieve MCP description from config: {ex.Message}"); + throw; + } + } + + // Create the initialize response + object result = new + { + protocolVersion = _protocolVersion, + capabilities = new + { + tools = new { listChanged = true }, + logging = new { } + }, + serverInfo = new + { + name = McpProtocolDefaults.MCP_SERVER_NAME, + version = McpProtocolDefaults.MCP_SERVER_VERSION + }, + instructions = !string.IsNullOrWhiteSpace(instructions) ? instructions : null + }; + + WriteResult(id, result); + } + + /// + /// Handles the "tools/list" JSON-RPC method by sending the list of available tools to the client. + /// + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// + private void HandleListTools(JsonElement? id) + { + List toolsWire = new(); + int count = 0; + + // Tools are expected to be registered during application startup only. + // If this ever changes and tools can be added/removed at runtime while + // requests are being handled, we may need to introduce locking here or + // have the registry return a thread-safe snapshot. + foreach (Tool tool in _toolRegistry.GetAllTools()) + { + count++; + toolsWire.Add(new + { + name = tool.Name, + description = tool.Description, + inputSchema = tool.InputSchema + }); + } + + WriteResult(id, new { tools = toolsWire }); + } + + /// + /// Handles the "tools/call" JSON-RPC method by executing the specified tool with the provided arguments. + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// The root JSON element of the incoming JSON-RPC request. + /// Cancellation token to signal operation cancellation. + private async Task HandleCallToolAsync(JsonElement? id, JsonElement root, CancellationToken ct) + { + if (!root.TryGetProperty("params", out JsonElement @params) || @params.ValueKind != JsonValueKind.Object) + { + WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_PARAMS, "Missing params"); + return; + } + + // If neither params.name (the MCP-standard field for the tool identifier) + // nor the legacy params.tool field is present or non-empty, we cannot tell + // which tool to execute. In that case we log a debug message to STDERR for + // diagnostics and return a JSON-RPC error (-32602 "Missing tool name") to + // the MCP client so it can fix the request payload. + string? toolName = null; + if (@params.TryGetProperty("name", out JsonElement nameEl) && nameEl.ValueKind == JsonValueKind.String) + { + toolName = nameEl.GetString(); + } + else if (@params.TryGetProperty("tool", out JsonElement toolEl) && toolEl.ValueKind == JsonValueKind.String) + { + toolName = toolEl.GetString(); + } + + if (string.IsNullOrWhiteSpace(toolName)) + { + Console.Error.WriteLine("[MCP DEBUG] callTool → missing tool name."); + WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_PARAMS, "Missing tool name"); + return; + } + + if (!_toolRegistry.TryGetTool(toolName!, out IMcpTool? tool) || tool is null) + { + Console.Error.WriteLine($"[MCP DEBUG] callTool → tool not found: {toolName}"); + WriteError(id, McpStdioJsonRpcErrorCodes.INVALID_PARAMS, $"Tool not found: {toolName}"); + return; + } + + JsonDocument? argsDoc = null; + try + { + if (@params.TryGetProperty("arguments", out JsonElement argsEl) && argsEl.ValueKind == JsonValueKind.Object) + { + string rawArgs = argsEl.GetRawText(); + Console.Error.WriteLine($"[MCP DEBUG] callTool → tool: {toolName}, args: {rawArgs}"); + argsDoc = JsonDocument.Parse(rawArgs); + } + else + { + Console.Error.WriteLine($"[MCP DEBUG] callTool → tool: {toolName}, args: "); + } + + // Execute the tool. + // If a MCP stdio role override is set in the environment, create + // a request HttpContext with the X-MS-API-ROLE header so tools and authorization + // helpers that read IHttpContextAccessor will see the role. We also ensure the + // Simulator authentication handler can authenticate the user by flowing the + // Authorization header commonly used in tests/simulator scenarios. + CallToolResult callResult; + IConfiguration? configuration = _serviceProvider.GetService(); + string? stdioRole = configuration?.GetValue("MCP:Role"); + if (!string.IsNullOrWhiteSpace(stdioRole)) + { + IServiceScopeFactory scopeFactory = _serviceProvider.GetRequiredService(); + using IServiceScope scope = scopeFactory.CreateScope(); + IServiceProvider scopedProvider = scope.ServiceProvider; + + // Create a default HttpContext and set the client role header + DefaultHttpContext httpContext = new(); + httpContext.Request.Headers["X-MS-API-ROLE"] = stdioRole; + + // Build a simulator-style identity with the given role + ClaimsIdentity identity = new( + authenticationType: SimulatorAuthenticationDefaults.AUTHENTICATIONSCHEME); + identity.AddClaim(new Claim(ClaimTypes.Role, stdioRole)); + httpContext.User = new ClaimsPrincipal(identity); + + // If IHttpContextAccessor is registered, populate it for downstream code. + IHttpContextAccessor? httpContextAccessor = scopedProvider.GetService(); + if (httpContextAccessor is not null) + { + httpContextAccessor.HttpContext = httpContext; + } + + try + { + // Execute the tool with the scoped service provider so any scoped services resolve correctly. + callResult = await tool.ExecuteAsync(argsDoc, scopedProvider, ct); + } + finally + { + // Clear the accessor's HttpContext to avoid leaking across calls + if (httpContextAccessor is not null) + { + httpContextAccessor.HttpContext = null; + } + } + } + else + { + callResult = await tool.ExecuteAsync(argsDoc, _serviceProvider, ct); + } + + // Normalize to MCP content blocks (array). We try to pass through if a 'Content' property exists, + // otherwise we wrap into a single text block. + object[] content = CoerceToMcpContentBlocks(callResult); + + WriteResult(id, new { content }); + } + finally + { + argsDoc?.Dispose(); + } + } + + /// + /// Coerces the call result into an array of MCP content blocks. + /// Tools can either return a custom object with a public "Content" property + /// or a raw value; this helper normalizes both patterns into the MCP wire format. + /// + /// The result object returned from a tool execution. + /// An array of content blocks suitable for MCP output. + private static object[] CoerceToMcpContentBlocks(object? callResult) + { + if (callResult is null) + { + return Array.Empty(); + } + + // Prefer a public instance "Content" property if present. + PropertyInfo? prop = callResult.GetType().GetProperty("Content", BindingFlags.Instance | BindingFlags.Public); + + if (prop is not null) + { + object? value = prop.GetValue(callResult); + + if (value is IEnumerable enumerable && value is not string) + { + List list = new(); + foreach (object item in enumerable) + { + if (item is string s) + { + list.Add(new { type = "text", text = s }); + } + else if (item is JsonElement jsonEl) + { + list.Add(new { type = "application/json", data = jsonEl }); + } + else + { + list.Add(item); + } + } + + return list.ToArray(); + } + + if (value is string sContent) + { + return new object[] { new { type = "text", text = sContent } }; + } + + if (value is JsonElement jsonContent) + { + return new object[] { new { type = "application/json", data = jsonContent } }; + } + } + + // If callResult itself is a JsonElement, treat it as application/json. + if (callResult is JsonElement jsonResult) + { + return new object[] { new { type = "application/json", data = jsonResult } }; + } + + // Fallback: serialize to text. + string text = SafeToString(callResult); + return new object[] { new { type = "text", text } }; + } + + /// + /// Safely converts an object to its string representation, preferring JSON serialization for readability. + /// + /// The object to convert to a string. + /// A string representation of the object. + private static string SafeToString(object obj) + { + try + { + // Try JSON first for readability + string json = JsonSerializer.Serialize(obj); + + // If JSON is extremely large, truncate to avoid flooding MCP output. + // 32 KB is large enough to show useful JSON detail for diagnostics + // without flooding MCP output or impacting performance. + const int MAX_JSON_PREVIEW_CHARS = 32 * 1024; // 32 KB + + if (json.Length > MAX_JSON_PREVIEW_CHARS) + { + return string.Concat(json.AsSpan(0, MAX_JSON_PREVIEW_CHARS), $"... [truncated, total length={json.Length} chars]"); + } + + return json; + } + catch + { + return obj.ToString() ?? string.Empty; + } + } + + /// + /// Writes a JSON-RPC result response to the standard output. + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// The result object to include in the response. + private static void WriteResult(JsonElement? id, object resultObject) + { + var response = new + { + jsonrpc = "2.0", + id = id.HasValue ? GetIdValue(id.Value) : null, + result = resultObject + }; + + string json = JsonSerializer.Serialize(response); + Console.Out.WriteLine(json); + } + + /// + /// Writes a JSON-RPC error response to the standard output. + /// + /// The request identifier extracted from the incoming JSON-RPC request. Used to correlate the response with the request. + /// The error code. + /// The error message. + private static void WriteError(JsonElement? id, int code, string message) + { + var errorObj = new + { + jsonrpc = "2.0", + id = id.HasValue ? GetIdValue(id.Value) : null, + error = new { code, message } + }; + + string json = JsonSerializer.Serialize(errorObj); + Console.Out.WriteLine(json); + } + + /// + /// Extracts the value of a JSON-RPC request identifier. + /// + /// The JSON element representing the request identifier. + /// The extracted identifier value as an object, or null if the identifier is not a primitive type. + private static object? GetIdValue(JsonElement id) + { + return id.ValueKind switch + { + JsonValueKind.String => id.GetString(), + JsonValueKind.Number => id.TryGetInt64(out long l) ? l : + id.TryGetDouble(out double d) ? d : null, + _ => null + }; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/IMcpStdioServer.cs b/src/Azure.DataApiBuilder.Mcp/IMcpStdioServer.cs new file mode 100644 index 0000000000..033e0e3eaa --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/IMcpStdioServer.cs @@ -0,0 +1,7 @@ +namespace Azure.DataApiBuilder.Mcp.Core +{ + public interface IMcpStdioServer + { + Task RunAsync(CancellationToken cancellationToken); + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Model/McpErrorCode.cs b/src/Azure.DataApiBuilder.Mcp/Model/McpErrorCode.cs new file mode 100644 index 0000000000..ed13f62783 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Model/McpErrorCode.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.DataApiBuilder.Mcp.Model +{ + /// + /// MCP error codes standardized for built-in tools. + /// + public enum McpErrorCode + { + ToolDisabled, + PermissionDenied + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Model/McpStdioJsonRpcErrorCodes.cs b/src/Azure.DataApiBuilder.Mcp/Model/McpStdioJsonRpcErrorCodes.cs new file mode 100644 index 0000000000..3bac194068 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Model/McpStdioJsonRpcErrorCodes.cs @@ -0,0 +1,36 @@ +namespace Azure.DataApiBuilder.Mcp.Model +{ + /// + /// JSON-RPC 2.0 standard error codes used by the MCP stdio server. + /// These values come from the JSON-RPC 2.0 specification and are shared + /// so they are not hard-coded throughout the codebase. + /// + internal static class McpStdioJsonRpcErrorCodes + { + /// + /// Invalid JSON was received by the server. + /// An error occurred on the server while parsing the JSON text. + /// + public const int PARSE_ERROR = -32700; + + /// + /// The JSON sent is not a valid Request object. + /// + public const int INVALID_REQUEST = -32600; + + /// + /// The method does not exist / is not available. + /// + public const int METHOD_NOT_FOUND = -32601; + + /// + /// Invalid method parameter(s). + /// + public const int INVALID_PARAMS = -32602; + + /// + /// Internal JSON-RPC error. + /// + public const int INTERNAL_ERROR = -32603; + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs index 04d14eb5d6..02344c2956 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpArgumentParser.cs @@ -11,26 +11,24 @@ namespace Azure.DataApiBuilder.Mcp.Utils public static class McpArgumentParser { /// - /// Parses entity and keys arguments for delete/update operations. + /// Parses only the entity name from arguments. /// - public static bool TryParseEntityAndKeys( + public static bool TryParseEntity( JsonElement root, out string entityName, - out Dictionary keys, - out string error) + out string error, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); entityName = string.Empty; - keys = new Dictionary(); error = string.Empty; - if (!root.TryGetProperty("entity", out JsonElement entityEl) || - !root.TryGetProperty("keys", out JsonElement keysEl)) + if (!root.TryGetProperty("entity", out JsonElement entityEl)) { - error = "Missing required arguments 'entity' or 'keys'."; + error = "Missing required argument 'entity'."; return false; } - // Parse and validate entity name entityName = entityEl.GetString() ?? string.Empty; if (string.IsNullOrWhiteSpace(entityName)) { @@ -38,7 +36,65 @@ public static bool TryParseEntityAndKeys( return false; } - // Parse and validate keys + return true; + } + + /// + /// Parses entity and data arguments for create operations. + /// + public static bool TryParseEntityAndData( + JsonElement root, + out string entityName, + out JsonElement dataElement, + out string error, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + dataElement = default; + + if (!TryParseEntity(root, out entityName, out error, cancellationToken)) + { + return false; + } + + if (!root.TryGetProperty("data", out dataElement)) + { + error = "Missing required argument 'data'."; + return false; + } + + if (dataElement.ValueKind != JsonValueKind.Object) + { + error = "'data' must be a JSON object."; + return false; + } + + return true; + } + + /// + /// Parses entity and keys arguments for delete/update operations. + /// + public static bool TryParseEntityAndKeys( + JsonElement root, + out string entityName, + out Dictionary keys, + out string error, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + keys = new Dictionary(); + if (!TryParseEntity(root, out entityName, out error, cancellationToken)) + { + return false; + } + + if (!root.TryGetProperty("keys", out JsonElement keysEl)) + { + error = "Missing required argument 'keys'."; + return false; + } + if (keysEl.ValueKind != JsonValueKind.Object) { error = "'keys' must be a JSON object."; @@ -64,6 +120,8 @@ public static bool TryParseEntityAndKeys( // Validate key values foreach (KeyValuePair kv in keys) { + cancellationToken.ThrowIfCancellationRequested(); + if (kv.Value is null || (kv.Value is string str && string.IsNullOrWhiteSpace(str))) { error = $"Primary key value for '{kv.Key}' cannot be null or empty"; @@ -82,12 +140,14 @@ public static bool TryParseEntityKeysAndFields( out string entityName, out Dictionary keys, out Dictionary fields, - out string error) + out string error, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); fields = new Dictionary(); // First parse entity and keys - if (!TryParseEntityAndKeys(root, out entityName, out keys, out error)) + if (!TryParseEntityAndKeys(root, out entityName, out keys, out error, cancellationToken)) { return false; } @@ -123,5 +183,61 @@ public static bool TryParseEntityKeysAndFields( return true; } + + /// + /// Parses the execute arguments from the JSON input. + /// + public static bool TryParseExecuteArguments( + JsonElement rootElement, + out string entity, + out Dictionary parameters, + out string parseError, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + entity = string.Empty; + parameters = new Dictionary(); + + if (rootElement.ValueKind != JsonValueKind.Object) + { + parseError = "Arguments must be an object"; + return false; + } + + if (!TryParseEntity(rootElement, out entity, out parseError, cancellationToken)) + { + return false; + } + + // Extract parameters if provided (optional) + if (rootElement.TryGetProperty("parameters", out JsonElement parametersElement) && + parametersElement.ValueKind == JsonValueKind.Object) + { + foreach (JsonProperty property in parametersElement.EnumerateObject()) + { + cancellationToken.ThrowIfCancellationRequested(); + parameters[property.Name] = GetExecuteParameterValue(property.Value); + } + } + + return true; + } + + // Local helper replicating ExecuteEntityTool.GetParameterValue without refactoring other tools. + private static object? GetExecuteParameterValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => + element.TryGetInt64(out long longValue) ? longValue : + element.TryGetDecimal(out decimal decimalValue) ? decimalValue : + element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.ToString() + }; + } } } diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs new file mode 100644 index 0000000000..75335b2db1 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpErrorHelpers.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Extensions.Logging; +using ModelContextProtocol.Protocol; + +namespace Azure.DataApiBuilder.Mcp.Utils +{ + /// + /// Helper utilities for creating standardized MCP error responses. + /// Only includes helpers currently being centralized. + /// + public static class McpErrorHelpers + { + public static CallToolResult PermissionDenied(string toolName, string entityName, string operation, string detail, ILogger? logger) + { + string message = $"Permission denied for {operation} on entity '{entityName}'. {detail}"; + return McpResponseBuilder.BuildErrorResult(toolName, Model.McpErrorCode.PermissionDenied.ToString(), message, logger); + } + + // Centralized language for 'tool disabled' errors. Pass the tool name, e.g. "read_records". + public static CallToolResult ToolDisabled(string toolName, ILogger? logger) + { + string message = $"The {toolName} tool is disabled in the configuration."; + return McpResponseBuilder.BuildErrorResult(toolName, Model.McpErrorCode.ToolDisabled.ToString(), message, logger); + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs new file mode 100644 index 0000000000..1e79e86b15 --- /dev/null +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpMetadataHelper.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Config.DatabasePrimitives; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Service.Exceptions; // Added for DataApiBuilderException +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.DataApiBuilder.Mcp.Utils +{ + /// + /// Utility class for resolving metadata and datasource information for MCP tools. + /// + public static class McpMetadataHelper + { + public static bool TryResolveMetadata( + string entityName, + RuntimeConfig config, + IServiceProvider serviceProvider, + out Azure.DataApiBuilder.Core.Services.ISqlMetadataProvider sqlMetadataProvider, + out DatabaseObject dbObject, + out string dataSourceName, + out string error, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + sqlMetadataProvider = default!; + dbObject = default!; + dataSourceName = string.Empty; + error = string.Empty; + + if (string.IsNullOrWhiteSpace(entityName)) + { + error = "Entity name cannot be null or empty."; + return false; + } + + var metadataProviderFactory = serviceProvider.GetRequiredService(); + + // Resolve datasource name for the entity. + try + { + cancellationToken.ThrowIfCancellationRequested(); + dataSourceName = config.GetDataSourceNameFromEntityName(entityName); + } + catch (DataApiBuilderException dabEx) when (dabEx.SubStatusCode == DataApiBuilderException.SubStatusCodes.EntityNotFound) + { + error = $"Entity '{entityName}' is not defined in the configuration."; + return false; + } + catch (DataApiBuilderException dabEx) + { + // Other DAB exceptions during entity->datasource resolution. + error = dabEx.Message; + return false; + } + + // Resolve metadata provider for the datasource. + try + { + cancellationToken.ThrowIfCancellationRequested(); + sqlMetadataProvider = metadataProviderFactory.GetMetadataProvider(dataSourceName); + } + catch (DataApiBuilderException dabEx) when (dabEx.SubStatusCode == DataApiBuilderException.SubStatusCodes.DataSourceNotFound) + { + error = $"Data source '{dataSourceName}' for entity '{entityName}' is not defined in the configuration."; + return false; + } + catch (DataApiBuilderException dabEx) + { + // Other DAB exceptions during metadata provider resolution. + error = dabEx.Message; + return false; + } + + cancellationToken.ThrowIfCancellationRequested(); + + // Validate entity exists in metadata mapping. + if (!sqlMetadataProvider.EntityToDatabaseObject.TryGetValue(entityName, out DatabaseObject? temp) || temp is null) + { + error = $"Entity '{entityName}' is not defined in the configuration."; + return false; + } + + dbObject = temp; + return true; + } + } +} diff --git a/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs b/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs index afbccbda38..49cacef2c3 100644 --- a/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs +++ b/src/Azure.DataApiBuilder.Mcp/Utils/McpResponseBuilder.cs @@ -43,12 +43,14 @@ public static CallToolResult BuildSuccessResult( /// Builds an error response for MCP tools. /// public static CallToolResult BuildErrorResult( + string toolName, string errorType, string message, ILogger? logger = null) { Dictionary errorObj = new() { + ["toolName"] = toolName, ["status"] = "error", ["error"] = new Dictionary { @@ -99,5 +101,21 @@ public static string ExtractResultJson(IActionResult? result) return "{}"; } } + + /// + /// Extracts value from a JsonElement. + /// + public static object? GetJsonValue(JsonElement element) + { + return element.ValueKind switch + { + JsonValueKind.String => element.GetString(), + JsonValueKind.Number => element.TryGetInt64(out long l) ? l : element.GetDouble(), + JsonValueKind.True => true, + JsonValueKind.False => false, + JsonValueKind.Null => null, + _ => element.GetRawText() + }; + } } } diff --git a/src/Azure.DataApiBuilder.sln b/src/Azure.DataApiBuilder.sln index aa3c8e2bad..58fe70b07c 100644 --- a/src/Azure.DataApiBuilder.sln +++ b/src/Azure.DataApiBuilder.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 17 VisualStudioVersion = 17.3.32405.409 @@ -31,6 +32,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.DataApiBuilder.Core", EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Azure.DataApiBuilder.Product", "Product\Azure.DataApiBuilder.Product.csproj", "{E3D2076C-EE49-43A0-8F92-5FC41EC99DA7}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aspire.AppHost", "Aspire.AppHost\Aspire.AppHost.csproj", "{87B53030-EB52-4EB1-870D-72540DA4724E}" +EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Azure.DataApiBuilder.Mcp", "Azure.DataApiBuilder.Mcp\Azure.DataApiBuilder.Mcp.csproj", "{A287E849-A043-4F37-BC40-A87C4705F583}" EndProject Global @@ -75,6 +78,10 @@ Global {E3D2076C-EE49-43A0-8F92-5FC41EC99DA7}.Debug|Any CPU.Build.0 = Debug|Any CPU {E3D2076C-EE49-43A0-8F92-5FC41EC99DA7}.Release|Any CPU.ActiveCfg = Release|Any CPU {E3D2076C-EE49-43A0-8F92-5FC41EC99DA7}.Release|Any CPU.Build.0 = Release|Any CPU + {87B53030-EB52-4EB1-870D-72540DA4724E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {87B53030-EB52-4EB1-870D-72540DA4724E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {87B53030-EB52-4EB1-870D-72540DA4724E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {87B53030-EB52-4EB1-870D-72540DA4724E}.Release|Any CPU.Build.0 = Release|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Debug|Any CPU.Build.0 = Debug|Any CPU {A287E849-A043-4F37-BC40-A87C4705F583}.Release|Any CPU.ActiveCfg = Release|Any CPU diff --git a/src/Cli.Tests/AddEntityTests.cs b/src/Cli.Tests/AddEntityTests.cs index 9386916f7f..e96d131880 100644 --- a/src/Cli.Tests/AddEntityTests.cs +++ b/src/Cli.Tests/AddEntityTests.cs @@ -633,5 +633,259 @@ private Task ExecuteVerifyTest(AddOptions options, string config = INITIAL_CONFI return Verify(updatedRuntimeConfig, settings); } + + #region MCP Entity Configuration Tests + + /// + /// Test adding table entity with MCP dml-tools enabled or disabled + /// + [DataTestMethod] + [DataRow("true", "books", "Book", DisplayName = "AddTableEntityWithMcpDmlToolsEnabled")] + [DataRow("false", "authors", "Author", DisplayName = "AddTableEntityWithMcpDmlToolsDisabled")] + public Task AddTableEntityWithMcpDmlTools(string mcpDmlTools, string source, string entity) + { + AddOptions options = new( + source: source, + permissions: new string[] { "anonymous", "*" }, + entity: entity, + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: mcpDmlTools, + mcpCustomTool: null + ); + + VerifySettings settings = new(); + settings.UseParameters(mcpDmlTools, source); + return ExecuteVerifyTest(options, settings: settings); + } + + /// + /// Test adding stored procedure with MCP custom-tool enabled (should serialize as object) + /// + [TestMethod] + public Task AddStoredProcedureWithMcpCustomToolEnabled() + { + AddOptions options = new( + source: "dbo.GetBookById", + permissions: new string[] { "anonymous", "execute" }, + entity: "GetBookById", + description: null, + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: null, + mcpCustomTool: "true" + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test adding stored procedure with both MCP properties set to different values (should serialize as object with both) + /// + [TestMethod] + public Task AddStoredProcedureWithBothMcpProperties() + { + AddOptions options = new( + source: "dbo.UpdateBook", + permissions: new string[] { "anonymous", "execute" }, + entity: "UpdateBook", + description: null, + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: "false", + mcpCustomTool: "true" + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test adding stored procedure with both MCP properties enabled (common use case) + /// + [TestMethod] + public Task AddStoredProcedureWithBothMcpPropertiesEnabled() + { + AddOptions options = new( + source: "dbo.GetAllBooks", + permissions: new string[] { "anonymous", "execute" }, + entity: "GetAllBooks", + description: null, + sourceType: "stored-procedure", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: "true", + mcpCustomTool: "true" + ); + return ExecuteVerifyTest(options); + } + + /// + /// Test that adding table entity with custom-tool fails validation + /// + [TestMethod] + public void AddTableEntityWithInvalidMcpCustomTool() + { + AddOptions options = new( + source: "reviews", + permissions: new string[] { "anonymous", "*" }, + entity: "Review", + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: null, + mcpCustomTool: "true" + ); + + RuntimeConfigLoader.TryParseConfig(INITIAL_CONFIG, out RuntimeConfig? runtimeConfig); + + Assert.IsFalse(TryAddNewEntity(options, runtimeConfig!, out RuntimeConfig _), + "Should fail to add table entity with custom-tool enabled"); + } + + /// + /// Test that invalid MCP option value fails + /// + [DataTestMethod] + [DataRow("invalid", null, DisplayName = "Invalid dml-tools value")] + [DataRow(null, "invalid", DisplayName = "Invalid custom-tool value")] + [DataRow("yes", "no", DisplayName = "Invalid boolean-like values")] + public void AddEntityWithInvalidMcpOptions(string? mcpDmlTools, string? mcpCustomTool) + { + AddOptions options = new( + source: "MyTable", + permissions: new string[] { "anonymous", "*" }, + entity: "MyEntity", + description: null, + sourceType: "table", + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: Array.Empty(), + fieldsToExclude: Array.Empty(), + policyRequest: null, + policyDatabase: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: [], + fieldsAliasCollection: [], + fieldsDescriptionCollection: [], + fieldsPrimaryKeyCollection: [], + mcpDmlTools: mcpDmlTools, + mcpCustomTool: mcpCustomTool + ); + + RuntimeConfigLoader.TryParseConfig(INITIAL_CONFIG, out RuntimeConfig? runtimeConfig); + + Assert.IsFalse(TryAddNewEntity(options, runtimeConfig!, out RuntimeConfig _), + "Should fail with invalid MCP option values"); + } + + #endregion MCP Entity Configuration Tests } } diff --git a/src/Cli.Tests/AddOpenTelemetryTests.cs b/src/Cli.Tests/AddOpenTelemetryTests.cs index d84473980c..b1a702d158 100644 --- a/src/Cli.Tests/AddOpenTelemetryTests.cs +++ b/src/Cli.Tests/AddOpenTelemetryTests.cs @@ -138,7 +138,7 @@ private static string GenerateRuntimeSection(string telemetrySection) ""allow-credentials"": false }}, ""authentication"": {{ - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" }} }}, {telemetrySection} diff --git a/src/Cli.Tests/AddTelemetryTests.cs b/src/Cli.Tests/AddTelemetryTests.cs index ac4fe99a8f..37cd77f17d 100644 --- a/src/Cli.Tests/AddTelemetryTests.cs +++ b/src/Cli.Tests/AddTelemetryTests.cs @@ -140,7 +140,7 @@ private static string GenerateRuntimeSection(string telemetrySection) ""allow-credentials"": false }}, ""authentication"": {{ - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" }} }}, {telemetrySection} diff --git a/src/Cli.Tests/ConfigGeneratorTests.cs b/src/Cli.Tests/ConfigGeneratorTests.cs index 58e006b75d..59a7f7b8dd 100644 --- a/src/Cli.Tests/ConfigGeneratorTests.cs +++ b/src/Cli.Tests/ConfigGeneratorTests.cs @@ -139,7 +139,7 @@ public void TestSpecialCharactersInConnectionString() setSessionContext: false, hostMode: HostMode.Production, corsOrigin: null, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), config: TEST_RUNTIME_CONFIG_FILE); StringBuilder expectedRuntimeConfigJson = new( @@ -166,14 +166,14 @@ public void TestSpecialCharactersInConnectionString() ""mcp"": { ""enabled"": true, ""path"": ""/mcp"" - }, + }, ""host"": { ""cors"": { ""origins"": [], ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" }, ""mode"": ""production"" } diff --git a/src/Cli.Tests/ConfigureOptionsTests.cs b/src/Cli.Tests/ConfigureOptionsTests.cs index 073f349a67..4dad501fda 100644 --- a/src/Cli.Tests/ConfigureOptionsTests.cs +++ b/src/Cli.Tests/ConfigureOptionsTests.cs @@ -926,6 +926,34 @@ public void TestFailureWhenAddingSetSessionContextToMySQLDatabase() Assert.IsFalse(isSuccess); } + /// + /// Tests that running "dab configure --runtime.mcp.description {value}" on a config with various values results + /// in runtime config update. Takes in updated value for mcp.description and + /// validates whether the runtime config reflects those updated values + /// + [DataTestMethod] + [DataRow("This MCP provides access to the Products database and should be used to answer product-related or inventory-related questions from the user.", DisplayName = "Set MCP description.")] + [DataRow("Use this server for customer data queries.", DisplayName = "Set MCP description with short text.")] + public void TestConfigureDescriptionForMcpSettings(string descriptionValue) + { + // Arrange -> all the setup which includes creating options. + SetupFileSystemWithInitialConfig(INITIAL_CONFIG); + + // Act: Attempts to update mcp.description value + ConfigureOptions options = new( + runtimeMcpDescription: descriptionValue, + config: TEST_RUNTIME_CONFIG_FILE + ); + bool isSuccess = TryConfigureSettings(options, _runtimeConfigLoader!, _fileSystem!); + + // Assert: Validate the Description is updated + Assert.IsTrue(isSuccess); + string updatedConfig = _fileSystem!.File.ReadAllText(TEST_RUNTIME_CONFIG_FILE); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(updatedConfig, out RuntimeConfig? runtimeConfig)); + Assert.IsNotNull(runtimeConfig.Runtime?.Mcp?.Description); + Assert.AreEqual(descriptionValue, runtimeConfig.Runtime.Mcp.Description); + } + /// /// Sets up the mock file system with an initial configuration file. /// This method adds a config file to the mock file system and verifies its existence. diff --git a/src/Cli.Tests/EndToEndTests.cs b/src/Cli.Tests/EndToEndTests.cs index 7fe017501f..99a9b77b6e 100644 --- a/src/Cli.Tests/EndToEndTests.cs +++ b/src/Cli.Tests/EndToEndTests.cs @@ -116,10 +116,11 @@ public void TestInitializingRestAndGraphQLGlobalSettings() string[] args = { "init", "-c", TEST_RUNTIME_CONFIG_FILE, "--connection-string", SAMPLE_TEST_CONN_STRING, "--database-type", "mssql", "--rest.path", "/rest-api", "--rest.enabled", "false", "--graphql.path", "/graphql-api" }; Program.Execute(args, _cliLogger!, _fileSystem!, _runtimeConfigLoader!); + DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true); Assert.IsTrue(_runtimeConfigLoader!.TryLoadConfig( TEST_RUNTIME_CONFIG_FILE, out RuntimeConfig? runtimeConfig, - replaceEnvVar: true)); + replacementSettings: replacementSettings)); SqlConnectionStringBuilder builder = new(runtimeConfig.DataSource.ConnectionString); Assert.AreEqual(ProductInfo.GetDataApiBuilderUserAgent(), builder.ApplicationName); @@ -195,10 +196,11 @@ public void TestEnablingMultipleCreateOperation(CliBool isMultipleCreateEnabled, Program.Execute(args.ToArray(), _cliLogger!, _fileSystem!, _runtimeConfigLoader!); + DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true); Assert.IsTrue(_runtimeConfigLoader!.TryLoadConfig( TEST_RUNTIME_CONFIG_FILE, out RuntimeConfig? runtimeConfig, - replaceEnvVar: true)); + replacementSettings: replacementSettings)); Assert.IsNotNull(runtimeConfig); Assert.AreEqual(expectedDbType, runtimeConfig.DataSource.DatabaseType); @@ -224,7 +226,7 @@ public void TestEnablingMultipleCreateOperation(CliBool isMultipleCreateEnabled, public void TestAddEntity() { string[] initArgs = { "init", "-c", TEST_RUNTIME_CONFIG_FILE, "--host-mode", "development", "--database-type", - "mssql", "--connection-string", TEST_ENV_CONN_STRING, "--auth.provider", "StaticWebApps" }; + "mssql", "--connection-string", TEST_ENV_CONN_STRING, "--auth.provider", "AppService" }; Program.Execute(initArgs, _cliLogger!, _fileSystem!, _runtimeConfigLoader!); Assert.IsTrue(_runtimeConfigLoader!.TryLoadConfig(TEST_RUNTIME_CONFIG_FILE, out RuntimeConfig? runtimeConfig)); diff --git a/src/Cli.Tests/EnvironmentTests.cs b/src/Cli.Tests/EnvironmentTests.cs index 151d5babb2..2d6378cf74 100644 --- a/src/Cli.Tests/EnvironmentTests.cs +++ b/src/Cli.Tests/EnvironmentTests.cs @@ -19,7 +19,13 @@ public class EnvironmentTests [TestInitialize] public void TestInitialize() { - StringJsonConverterFactory converterFactory = new(EnvironmentVariableReplacementFailureMode.Throw); + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: null, + doReplaceEnvVar: true, + doReplaceAkvVar: false, + envFailureMode: EnvironmentVariableReplacementFailureMode.Throw); + + StringJsonConverterFactory converterFactory = new(replacementSettings); _options = new() { PropertyNameCaseInsensitive = true diff --git a/src/Cli.Tests/InitTests.cs b/src/Cli.Tests/InitTests.cs index 80eda44788..051bfdf7a7 100644 --- a/src/Cli.Tests/InitTests.cs +++ b/src/Cli.Tests/InitTests.cs @@ -49,7 +49,7 @@ public Task MsSQLDatabase() setSessionContext: true, hostMode: HostMode.Development, corsOrigin: new List() { "http://localhost:3000", "http://nolocalhost:80" }, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restPath: "rest-api", config: TEST_RUNTIME_CONFIG_FILE); @@ -71,7 +71,7 @@ public Task CosmosDbPostgreSqlDatabase() setSessionContext: false, hostMode: HostMode.Development, corsOrigin: new List() { "http://localhost:3000", "http://nolocalhost:80" }, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restPath: "/rest-endpoint", config: TEST_RUNTIME_CONFIG_FILE); @@ -94,7 +94,7 @@ public Task TestInitializingConfigWithoutConnectionString() setSessionContext: false, hostMode: HostMode.Development, corsOrigin: new List() { "http://localhost:3000", "http://nolocalhost:80" }, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), config: TEST_RUNTIME_CONFIG_FILE); return ExecuteVerifyTest(options); @@ -118,7 +118,7 @@ public Task CosmosDbNoSqlDatabase() setSessionContext: false, hostMode: HostMode.Production, corsOrigin: null, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), config: TEST_RUNTIME_CONFIG_FILE); return ExecuteVerifyTest(options); @@ -151,7 +151,7 @@ bool expectSuccess setSessionContext: false, hostMode: HostMode.Production, corsOrigin: null, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restEnabled: CliBool.True, graphqlEnabled: CliBool.True, config: TEST_RUNTIME_CONFIG_FILE); @@ -189,7 +189,7 @@ public void VerifyRequiredOptionsForCosmosDbNoSqlDatabase( setSessionContext: false, hostMode: HostMode.Production, corsOrigin: null, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), config: TEST_RUNTIME_CONFIG_FILE); Assert.AreEqual(expectedResult, TryCreateRuntimeConfig(options, _runtimeConfigLoader!, _fileSystem!, out RuntimeConfig? _)); @@ -219,7 +219,7 @@ public void EnsureFailureWhenBothRestAndGraphQLAreDisabled( setSessionContext: false, hostMode: HostMode.Production, corsOrigin: null, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restEnabled: restEnabled, graphqlEnabled: graphQLEnabled, restDisabled: restDisabled, @@ -245,7 +245,7 @@ public Task TestSpecialCharactersInConnectionString() setSessionContext: false, hostMode: HostMode.Production, corsOrigin: null, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), config: TEST_RUNTIME_CONFIG_FILE); return ExecuteVerifyTest(options); @@ -267,7 +267,7 @@ public void EnsureFailureOnReInitializingExistingConfig() setSessionContext: false, hostMode: HostMode.Development, corsOrigin: new List() { }, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), config: TEST_RUNTIME_CONFIG_FILE); // Config generated successfully for the first time. @@ -346,7 +346,7 @@ public void EnsureFailureReInitializingExistingConfigWithDifferentCase() setSessionContext: true, hostMode: HostMode.Development, corsOrigin: new List() { "http://localhost:3000", "http://nolocalhost:80" }, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restPath: "rest-api", config: TEST_RUNTIME_CONFIG_FILE); Assert.AreEqual(true, TryGenerateConfig(initOptionsWithAllLowerCaseFileName, _runtimeConfigLoader!, _fileSystem!)); @@ -361,7 +361,7 @@ public void EnsureFailureReInitializingExistingConfigWithDifferentCase() setSessionContext: true, hostMode: HostMode.Development, corsOrigin: new List() { "http://localhost:3000", "http://nolocalhost:80" }, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restPath: "rest-api", config: TEST_RUNTIME_CONFIG_FILE.ToUpper()); // Platform Dependent @@ -384,7 +384,7 @@ public Task RestPathWithoutStartingSlashWillHaveItAdded() setSessionContext: false, hostMode: HostMode.Production, corsOrigin: null, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restPath: "abc", config: TEST_RUNTIME_CONFIG_FILE); @@ -403,7 +403,7 @@ public Task GraphQLPathWithoutStartingSlashWillHaveItAdded() setSessionContext: false, hostMode: HostMode.Production, corsOrigin: null, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), graphQLPath: "abc", config: TEST_RUNTIME_CONFIG_FILE); @@ -466,7 +466,7 @@ public Task VerifyCorrectConfigGenerationWithMultipleMutationOptions(DatabaseTyp setSessionContext: true, hostMode: HostMode.Development, corsOrigin: new List() { "http://localhost:3000", "http://nolocalhost:80" }, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restPath: "rest-api", config: TEST_RUNTIME_CONFIG_FILE, multipleCreateOperationEnabled: isMultipleCreateEnabled); @@ -482,7 +482,7 @@ public Task VerifyCorrectConfigGenerationWithMultipleMutationOptions(DatabaseTyp setSessionContext: true, hostMode: HostMode.Development, corsOrigin: new List() { "http://localhost:3000", "http://nolocalhost:80" }, - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restPath: "rest-api", config: TEST_RUNTIME_CONFIG_FILE, multipleCreateOperationEnabled: isMultipleCreateEnabled); diff --git a/src/Cli.Tests/ModuleInitializer.cs b/src/Cli.Tests/ModuleInitializer.cs index e00dc00a89..a03dcddd10 100644 --- a/src/Cli.Tests/ModuleInitializer.cs +++ b/src/Cli.Tests/ModuleInitializer.cs @@ -65,6 +65,10 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); + // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedDmlToolsEnabled); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsRequestBodyStrict); // Ignore the IsGraphQLEnabled as that's unimportant from a test standpoint. @@ -77,6 +81,8 @@ public static void Init() VerifierSettings.IgnoreMember(config => config.McpDmlTools); // Ignore the IsStaticWebAppsIdentityProvider as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsStaticWebAppsIdentityProvider); + // Ignore the IsAppServiceIdentityProvider as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.IsAppServiceIdentityProvider); // Ignore the RestPath as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.RestPath); // Ignore the GraphQLPath as that's unimportant from a test standpoint. diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithAnExistingNameButWithDifferentCase.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithAnExistingNameButWithDifferentCase.verified.txt index 44fbbaa18b..12d7802079 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithAnExistingNameButWithDifferentCase.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithAnExistingNameButWithDifferentCase.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt index 6b3b5cc018..b4b428038b 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithCachingEnabled.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_70de36ebf1478d0d.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_70de36ebf1478d0d.verified.txt index 7c701037fd..10324a7f50 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_70de36ebf1478d0d.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_70de36ebf1478d0d.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_9f612e68879149a3.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_9f612e68879149a3.verified.txt index cec0a3d8ab..72d4c28329 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_9f612e68879149a3.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_9f612e68879149a3.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_bea2d26f3e5462d8.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_bea2d26f3e5462d8.verified.txt index c318861497..d21d3b195d 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_bea2d26f3e5462d8.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddEntityWithPolicyAndFieldProperties_bea2d26f3e5462d8.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesEmpty.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesEmpty.verified.txt index 8d8f226805..3d6deefaba 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesEmpty.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesEmpty.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesNotEmpty.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesNotEmpty.verified.txt index 4e3184736d..d2a1b26553 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesNotEmpty.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesNotEmpty.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesWithSourceAsStoredProcedure.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesWithSourceAsStoredProcedure.verified.txt index 21759deeed..2418bbaf9e 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesWithSourceAsStoredProcedure.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddNewEntityWhenEntitiesWithSourceAsStoredProcedure.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt new file mode 100644 index 0000000000..c5772388d6 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpProperties.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService + } + } + }, + Entities: [ + { + UpdateBook: { + Source: { + Object: dbo.UpdateBook, + Type: stored-procedure + }, + GraphQL: { + Singular: UpdateBook, + Plural: UpdateBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt new file mode 100644 index 0000000000..f0c74a20b7 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithBothMcpPropertiesEnabled.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService + } + } + }, + Entities: [ + { + GetAllBooks: { + Source: { + Object: dbo.GetAllBooks, + Type: stored-procedure + }, + GraphQL: { + Singular: GetAllBooks, + Plural: GetAllBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt new file mode 100644 index 0000000000..50b1b5c571 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService + } + } + }, + Entities: [ + { + GetBookById: { + Source: { + Object: dbo.GetBookById, + Type: stored-procedure + }, + GraphQL: { + Singular: GetBookById, + Plural: GetBookByIds, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt new file mode 100644 index 0000000000..e1ba348224 --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=false_source=authors.verified.txt @@ -0,0 +1,57 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService + } + } + }, + Entities: [ + { + Author: { + Source: { + Object: authors, + Type: Table + }, + GraphQL: { + Singular: Author, + Plural: Authors, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt new file mode 100644 index 0000000000..82287b53aa --- /dev/null +++ b/src/Cli.Tests/Snapshots/AddEntityTests.AddTableEntityWithMcpDmlTools_mcpDmlTools=true_source=books.verified.txt @@ -0,0 +1,57 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /api, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService + } + } + }, + Entities: [ + { + Book: { + Source: { + Object: books, + Type: Table + }, + GraphQL: { + Singular: Book, + Plural: Books, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_0c9cbb8942b4a4e5.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_0c9cbb8942b4a4e5.verified.txt index c7cb996d03..9729cf4aae 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_0c9cbb8942b4a4e5.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_0c9cbb8942b4a4e5.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_286d268a654ece27.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_286d268a654ece27.verified.txt index 13beb6bc7c..99d78eaa0e 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_286d268a654ece27.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_286d268a654ece27.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_3048323e01b42681.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_3048323e01b42681.verified.txt index bdd7c8f7a0..ec4549f38a 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_3048323e01b42681.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_3048323e01b42681.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_3440d150a2282b9c.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_3440d150a2282b9c.verified.txt index 20d7bcd624..6ba80ba348 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_3440d150a2282b9c.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_3440d150a2282b9c.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_381c28d25063be0c.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_381c28d25063be0c.verified.txt index 13beb6bc7c..99d78eaa0e 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_381c28d25063be0c.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_381c28d25063be0c.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_458373311f6ed4ed.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_458373311f6ed4ed.verified.txt index b87e804456..11d8adb815 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_458373311f6ed4ed.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_458373311f6ed4ed.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_66799c963a6306ae.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_66799c963a6306ae.verified.txt index 18c5f966c5..5c6a0b7d6b 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_66799c963a6306ae.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_66799c963a6306ae.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_66f598295b8682fd.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_66f598295b8682fd.verified.txt index 1cd138d10f..e3a6363fb1 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_66f598295b8682fd.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_66f598295b8682fd.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_73f95f7e2cd3ed71.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_73f95f7e2cd3ed71.verified.txt index c7cb996d03..9729cf4aae 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_73f95f7e2cd3ed71.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_73f95f7e2cd3ed71.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_79d59edde7f6a272.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_79d59edde7f6a272.verified.txt index b87e804456..11d8adb815 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_79d59edde7f6a272.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_79d59edde7f6a272.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_7ec82512a1df5293.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_7ec82512a1df5293.verified.txt index c7cb996d03..9729cf4aae 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_7ec82512a1df5293.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_7ec82512a1df5293.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_cbb6e5548e4d3535.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_cbb6e5548e4d3535.verified.txt index c7cb996d03..9729cf4aae 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_cbb6e5548e4d3535.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_cbb6e5548e4d3535.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_dc629052f38cea32.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_dc629052f38cea32.verified.txt index 612a65c14a..aa074116d0 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_dc629052f38cea32.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_dc629052f38cea32.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_e4a97c7e3507d2c6.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_e4a97c7e3507d2c6.verified.txt index 87b7a7697c..9b7d9f96e9 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_e4a97c7e3507d2c6.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_e4a97c7e3507d2c6.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_f8d0d0c2a38bd3b8.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_f8d0d0c2a38bd3b8.verified.txt index 1cd138d10f..e3a6363fb1 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_f8d0d0c2a38bd3b8.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddNewSpWithDifferentRestAndGraphQLOptions_f8d0d0c2a38bd3b8.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt index 83d3882a96..11829a214c 100644 --- a/src/Cli.Tests/Snapshots/AddEntityTests.TestAddStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt +++ b/src/Cli.Tests/Snapshots/AddEntityTests.TestAddStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt index 4411b47348..3fa1fbc14e 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestAddingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt @@ -18,14 +18,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt index 636d44805e..76ea01dfca 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceAsStoredProcedure.verified.txt @@ -18,14 +18,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt index a77ecc134b..3a8c738a70 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithSourceWithDefaultType.verified.txt @@ -18,14 +18,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt index a19694b688..df2cd4b009 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestConfigGeneratedAfterAddingEntityWithoutIEnumerables.verified.txt @@ -18,14 +18,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService }, Mode: Production } diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt index 081c5f8e55..1b14a3a7f0 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestInitForCosmosDBNoSql.verified.txt @@ -19,7 +19,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -30,7 +46,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService }, Mode: Production } diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt index fef1d83bf2..62d9e237b5 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethods.verified.txt @@ -18,14 +18,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt index 09007e27f8..fa8b16e739 100644 --- a/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt +++ b/src/Cli.Tests/Snapshots/EndToEndTests.TestUpdatingStoredProcedureWithRestMethodsAndGraphQLOperations.verified.txt @@ -18,14 +18,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt b/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt index b3f63dd336..9d5458c0ee 100644 --- a/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.CosmosDbNoSqlDatabase.verified.txt @@ -19,14 +19,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService }, Mode: Production } diff --git a/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt b/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt index 42e0ff5e2f..51f6ad8d95 100644 --- a/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.CosmosDbPostgreSqlDatabase.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt index 0af93023dc..978d1a253b 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_171ea8114ff71814.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt index 9e77b24d74..402bf4d2bc 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_2df7a1794712f154.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt index 32f72a7a54..ab71a40f03 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_59fe1a10aa78899d.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt index 24416a0d02..25e3976685 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_b95b637ea87f16a7.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { diff --git a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt index 6c674a4772..140f017b78 100644 --- a/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.EnsureCorrectConfigGenerationWithDifferentAuthenticationProviders_daacbd948b7ef72f.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { diff --git a/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt b/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt index b6aac13236..a3a056ac0a 100644 --- a/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.GraphQLPathWithoutStartingSlashWillHaveItAdded.verified.txt @@ -18,14 +18,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService }, Mode: Production } diff --git a/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt b/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt index 8841c0f326..f40350c4da 100644 --- a/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.MsSQLDatabase.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -29,7 +45,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt b/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt index 68e4d231fd..b792d41c9f 100644 --- a/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.RestPathWithoutStartingSlashWillHaveItAdded.verified.txt @@ -18,14 +18,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService }, Mode: Production } diff --git a/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt b/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt index 3c281ad6aa..173960d7b1 100644 --- a/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.TestInitializingConfigWithoutConnectionString.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -29,7 +45,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt b/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt index 32f72a7a54..25e3976685 100644 --- a/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.TestSpecialCharactersInConnectionString.verified.txt @@ -18,14 +18,30 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService }, Mode: Production } diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt index 888466ab4a..63f0da701c 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0546bef37027a950.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -29,7 +45,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt index 8841c0f326..f40350c4da 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0ac567dd32a2e8f5.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -29,7 +45,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt index d56e05c483..e59070d692 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_0c06949221514e77.verified.txt @@ -23,7 +23,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -34,7 +50,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt index bc31484242..f7de35b7ae 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_18667ab7db033e9d.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt index 888466ab4a..63f0da701c 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_2f42f44c328eb020.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -29,7 +45,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt index bc31484242..f7de35b7ae 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_3243d3f3441fdcc1.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt index 48f5e7a7c9..75613db959 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_53350b8b47df2112.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt index 8fa9677f1d..d93aac7dc6 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_6584e0ec46b8a11d.verified.txt @@ -19,7 +19,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -30,7 +46,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt index e3108801f5..640815babb 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_81cc88db3d4eecfb.verified.txt @@ -23,7 +23,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -34,7 +50,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt index 59f6636fb2..5900015d5a 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_8ea187616dbb5577.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt index 888466ab4a..63f0da701c 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_905845c29560a3ef.verified.txt @@ -18,7 +18,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -29,7 +45,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt index 8fa9677f1d..d93aac7dc6 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_b2fd24fab5b80917.verified.txt @@ -19,7 +19,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -30,7 +46,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt index 8fa9677f1d..d93aac7dc6 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_bd7cd088755287c9.verified.txt @@ -19,7 +19,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -30,7 +46,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt index 48f5e7a7c9..75613db959 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d2eccba2f836b380.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt index 59f6636fb2..5900015d5a 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d463eed7fe5e4bbe.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt index 48f5e7a7c9..75613db959 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_d5520dd5c33f7b8d.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt index bc31484242..f7de35b7ae 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_eab4a6010e602b59.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt index 59f6636fb2..5900015d5a 100644 --- a/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt +++ b/src/Cli.Tests/Snapshots/InitTests.VerifyCorrectConfigGenerationWithMultipleMutationOptions_ecaa688829b4030e.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -26,7 +42,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_036a859f50ce167c.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_036a859f50ce167c.verified.txt index 260eecd0c9..003bc17a3e 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_036a859f50ce167c.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_036a859f50ce167c.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_103655d39b48d89f.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_103655d39b48d89f.verified.txt index 80f61e17ac..29d477944f 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_103655d39b48d89f.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_103655d39b48d89f.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_442649c7ef2176bd.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_442649c7ef2176bd.verified.txt index 260eecd0c9..003bc17a3e 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_442649c7ef2176bd.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_442649c7ef2176bd.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_7f2338fdc84aafc3.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_7f2338fdc84aafc3.verified.txt index b6b9269e87..edfefa5bac 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_7f2338fdc84aafc3.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_7f2338fdc84aafc3.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_a70c086a74142c82.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_a70c086a74142c82.verified.txt index 21759deeed..2418bbaf9e 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_a70c086a74142c82.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_a70c086a74142c82.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_c26902b0e44f97cd.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_c26902b0e44f97cd.verified.txt index 2d00804545..5339d06351 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_c26902b0e44f97cd.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestConversionOfSourceObject_c26902b0e44f97cd.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityByAddingNewRelationship.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityByAddingNewRelationship.verified.txt index 2789572051..ef0e2b9151 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityByAddingNewRelationship.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityByAddingNewRelationship.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityByModifyingRelationship.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityByModifyingRelationship.verified.txt index 092dec7745..44828bd61a 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityByModifyingRelationship.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityByModifyingRelationship.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt index 9ae0e33948..a3df8fd6c4 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityCaching.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermission.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermission.verified.txt index 1a77d65bcd..477f15db4a 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermission.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermission.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionByAddingNewRole.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionByAddingNewRole.verified.txt index 6775db8dde..63c4e24898 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionByAddingNewRole.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionByAddingNewRole.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionHavingWildcardAction.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionHavingWildcardAction.verified.txt index 64517082ad..f3db383d0f 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionHavingWildcardAction.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionHavingWildcardAction.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionWithExistingAction.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionWithExistingAction.verified.txt index 52dc99399e..c0818f3c84 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionWithExistingAction.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionWithExistingAction.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionWithWildcardAction.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionWithWildcardAction.verified.txt index 92b5917b92..19444d8105 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionWithWildcardAction.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityPermissionWithWildcardAction.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithMappings.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithMappings.verified.txt index 54d9077f1c..e6f2bec0c9 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithMappings.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithMappings.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_088d6237033e0a7c.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_088d6237033e0a7c.verified.txt index 7c701037fd..10324a7f50 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_088d6237033e0a7c.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_088d6237033e0a7c.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_3ea32fdef7aed1b4.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_3ea32fdef7aed1b4.verified.txt index c318861497..d21d3b195d 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_3ea32fdef7aed1b4.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_3ea32fdef7aed1b4.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_4d25c2c012107597.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_4d25c2c012107597.verified.txt index cdf30a00c9..f44cb7a9b4 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_4d25c2c012107597.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithPolicyAndFieldProperties_4d25c2c012107597.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithSpecialCharacterInMappings.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithSpecialCharacterInMappings.verified.txt index 1906f87425..396d09edf3 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithSpecialCharacterInMappings.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateEntityWithSpecialCharacterInMappings.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateExistingMappings.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateExistingMappings.verified.txt index 56ce5b55c3..2884cf540b 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateExistingMappings.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateExistingMappings.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps, + Provider: AppService, Jwt: { Audience: , Issuer: diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdatePolicy.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdatePolicy.verified.txt index c8002a2fbc..57c5f18284 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdatePolicy.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdatePolicy.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_10ea92e3b25ab0c9.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_10ea92e3b25ab0c9.verified.txt index b87e804456..11d8adb815 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_10ea92e3b25ab0c9.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_10ea92e3b25ab0c9.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_127bb81593f835fe.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_127bb81593f835fe.verified.txt index 1cd138d10f..e3a6363fb1 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_127bb81593f835fe.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_127bb81593f835fe.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_386efa1a113fac6b.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_386efa1a113fac6b.verified.txt index c7cb996d03..9729cf4aae 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_386efa1a113fac6b.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_386efa1a113fac6b.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_53db4712d83be8e6.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_53db4712d83be8e6.verified.txt index 20d7bcd624..6ba80ba348 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_53db4712d83be8e6.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_53db4712d83be8e6.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_5e9ddd8c7c740efd.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_5e9ddd8c7c740efd.verified.txt index 13beb6bc7c..99d78eaa0e 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_5e9ddd8c7c740efd.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_5e9ddd8c7c740efd.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_6c5b3bfc72e5878a.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_6c5b3bfc72e5878a.verified.txt index c7cb996d03..9729cf4aae 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_6c5b3bfc72e5878a.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_6c5b3bfc72e5878a.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_8398059a743d7027.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_8398059a743d7027.verified.txt index c7cb996d03..9729cf4aae 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_8398059a743d7027.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_8398059a743d7027.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_a49380ce6d1fd8ba.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_a49380ce6d1fd8ba.verified.txt index bdd7c8f7a0..ec4549f38a 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_a49380ce6d1fd8ba.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_a49380ce6d1fd8ba.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_c9b12fe27be53878.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_c9b12fe27be53878.verified.txt index e8fc427339..92cb5caac0 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_c9b12fe27be53878.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_c9b12fe27be53878.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_d19603117eb8b51b.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_d19603117eb8b51b.verified.txt index 13beb6bc7c..99d78eaa0e 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_d19603117eb8b51b.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_d19603117eb8b51b.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_d770d682c5802737.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_d770d682c5802737.verified.txt index b87e804456..11d8adb815 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_d770d682c5802737.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_d770d682c5802737.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_ef8cc721c9dfc7e4.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_ef8cc721c9dfc7e4.verified.txt index 87b7a7697c..9b7d9f96e9 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_ef8cc721c9dfc7e4.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_ef8cc721c9dfc7e4.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f3897e2254996db0.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f3897e2254996db0.verified.txt index 18c5f966c5..5c6a0b7d6b 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f3897e2254996db0.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f3897e2254996db0.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f4cadb897fc5b0fe.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f4cadb897fc5b0fe.verified.txt index 612a65c14a..aa074116d0 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f4cadb897fc5b0fe.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f4cadb897fc5b0fe.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f59b2a65fc1e18a3.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f59b2a65fc1e18a3.verified.txt index 1cd138d10f..e3a6363fb1 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f59b2a65fc1e18a3.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateRestAndGraphQLSettingsForStoredProcedures_f59b2a65fc1e18a3.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_574e1995f787740f.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_574e1995f787740f.verified.txt index 260eecd0c9..003bc17a3e 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_574e1995f787740f.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_574e1995f787740f.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_a13a9ca73b21f261.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_a13a9ca73b21f261.verified.txt index 80f61e17ac..29d477944f 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_a13a9ca73b21f261.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_a13a9ca73b21f261.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_a5ce76c8bea25cc8.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_a5ce76c8bea25cc8.verified.txt index 80f61e17ac..29d477944f 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_a5ce76c8bea25cc8.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_a5ce76c8bea25cc8.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_bba111332a1f973f.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_bba111332a1f973f.verified.txt index ba28cd7509..f5dd22534c 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_bba111332a1f973f.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateSourceStringToDatabaseSourceObject_bba111332a1f973f.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt new file mode 100644 index 0000000000..a2aa5c0f40 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpProperties.verified.txt @@ -0,0 +1,64 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + UpdateBook: { + Source: { + Type: stored-procedure + }, + GraphQL: { + Singular: UpdateBook, + Plural: UpdateBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt new file mode 100644 index 0000000000..6526bd5e38 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithBothMcpPropertiesEnabled.verified.txt @@ -0,0 +1,64 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + GetAllBooks: { + Source: { + Type: stored-procedure + }, + GraphQL: { + Singular: GetAllBooks, + Plural: GetAllBooks, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt new file mode 100644 index 0000000000..b74749dcf6 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateStoredProcedureWithMcpCustomToolEnabled.verified.txt @@ -0,0 +1,64 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + GetBookById: { + Source: { + Type: stored-procedure + }, + GraphQL: { + Singular: GetBookById, + Plural: GetBookByIds, + Enabled: true, + Operation: Mutation + }, + Rest: { + Methods: [ + Post + ], + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: Execute + } + ] + } + ], + Mcp: { + CustomToolEnabled: true, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt new file mode 100644 index 0000000000..9efb911f99 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=false.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + MyEntity: { + Source: { + Object: MyTable, + Type: Table + }, + GraphQL: { + Singular: MyEntity, + Plural: MyEntities, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: false + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt new file mode 100644 index 0000000000..8ffa3b4893 --- /dev/null +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.TestUpdateTableEntityWithMcpDmlTools_newMcpDmlTools=true.verified.txt @@ -0,0 +1,61 @@ +{ + DataSource: { + DatabaseType: MSSQL + }, + Runtime: { + Rest: { + Enabled: true, + Path: /, + RequestBodyStrict: true + }, + GraphQL: { + Enabled: true, + Path: /graphql, + AllowIntrospection: true + }, + Host: { + Cors: { + AllowCredentials: false + }, + Authentication: { + Provider: AppService, + Jwt: { + Audience: , + Issuer: + } + } + } + }, + Entities: [ + { + MyEntity: { + Source: { + Object: MyTable, + Type: Table + }, + GraphQL: { + Singular: MyEntity, + Plural: MyEntities, + Enabled: true + }, + Rest: { + Enabled: true + }, + Permissions: [ + { + Role: anonymous, + Actions: [ + { + Action: * + } + ] + } + ], + Mcp: { + CustomToolEnabled: false, + DmlToolEnabled: true + } + } + } + ] +} \ No newline at end of file diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceKeyFields.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceKeyFields.verified.txt index 544a3484f9..73703220d8 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceKeyFields.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceKeyFields.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceName.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceName.verified.txt index 1719e1ade2..50c0a1786c 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceName.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceName.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceParameters.verified.txt b/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceParameters.verified.txt index 0cbdc4347f..a34882cefe 100644 --- a/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceParameters.verified.txt +++ b/src/Cli.Tests/Snapshots/UpdateEntityTests.UpdateDatabaseSourceParameters.verified.txt @@ -18,7 +18,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Cli.Tests/TestHelper.cs b/src/Cli.Tests/TestHelper.cs index a5a0079797..8224a079d4 100644 --- a/src/Cli.Tests/TestHelper.cs +++ b/src/Cli.Tests/TestHelper.cs @@ -143,7 +143,7 @@ public static Process ExecuteDabCommand(string command, string flags) ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }, @@ -170,7 +170,7 @@ public static Process ExecuteDabCommand(string command, string flags) ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }"; @@ -228,7 +228,7 @@ public static Process ExecuteDabCommand(string command, string flags) ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }, @@ -1003,7 +1003,7 @@ public static Process ExecuteDabCommand(string command, string flags) ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }, @@ -1048,7 +1048,7 @@ public static Process ExecuteDabCommand(string command, string flags) ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }, @@ -1118,7 +1118,7 @@ public static Process ExecuteDabCommand(string command, string flags) ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }, @@ -1199,7 +1199,7 @@ public static Process ExecuteDabCommand(string command, string flags) ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }, @@ -1298,7 +1298,7 @@ public static string GenerateConfigWithGivenDepthLimit(string? depthLimitJson = ""allow-credentials"": false }}, ""authentication"": {{ - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" }} }} }}, @@ -1323,7 +1323,7 @@ public static InitOptions CreateBasicInitOptionsForMsSqlWithConfig(string? confi setSessionContext: true, hostMode: HostMode.Development, corsOrigin: new List(), - authenticationProvider: EasyAuthType.StaticWebApps.ToString(), + authenticationProvider: EasyAuthType.AppService.ToString(), restRequestBodyStrict: CliBool.True, config: config); } diff --git a/src/Cli.Tests/UpdateEntityTests.cs b/src/Cli.Tests/UpdateEntityTests.cs index 3a106c0adc..effea1fa68 100644 --- a/src/Cli.Tests/UpdateEntityTests.cs +++ b/src/Cli.Tests/UpdateEntityTests.cs @@ -1100,6 +1100,92 @@ public void TestUpdateEntityDescription() Assert.AreEqual("Updated description", updatedRuntimeConfig.Entities["MyEntity"].Description); } + /// + /// Updating a field's description should either preserve or clear + /// the existing primary-key flag depending on whether an explicit + /// --fields.primary-key value is provided. + /// + [DataTestMethod] + [DataRow(null, true, DisplayName = "No primary-key flag: preserve existing true")] + [DataRow(false, false, DisplayName = "Explicit primary-key false: clear existing true")] + public void TestUpdateFieldDescriptionPrimaryKeyBehavior(bool? primaryKeyFlag, bool expectedPrimaryKey) + { + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""fields"": [ + { + ""name"": ""Id"", + ""description"": ""Primary key"", + ""primary-key"": true + } + ], + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""read""] + } + ] + } + } + }"; + + IEnumerable? primaryKeyFlags = primaryKeyFlag.HasValue + ? new[] { primaryKeyFlag.Value } + : null; + + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: new[] { "Id" }, + fieldsAliasCollection: null, + fieldsDescriptionCollection: new[] { "Unique Key" }, + fieldsPrimaryKeyCollection: primaryKeyFlags, + mcpDmlTools: null, + mcpCustomTool: null + ); + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(initialConfig, out RuntimeConfig? runtimeConfig), "Parsed config file."); + Assert.IsTrue(TryUpdateExistingEntity(options, runtimeConfig!, out RuntimeConfig updatedRuntimeConfig), "Successfully updated entity in the config."); + + Entity updatedEntity = updatedRuntimeConfig.Entities["MyEntity"]; + Assert.IsNotNull(updatedEntity.Fields); + Assert.AreEqual(1, updatedEntity.Fields!.Count); + FieldMetadata field = updatedEntity.Fields[0]; + Assert.AreEqual("Id", field.Name); + Assert.AreEqual("Unique Key", field.Description); + Assert.AreEqual(expectedPrimaryKey, field.PrimaryKey); + } + private static string GetInitialConfigString() { return @"{" + @@ -1125,7 +1211,7 @@ private static string GetInitialConfigString() ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"", + ""provider"": ""AppService"", ""jwt"": { ""audience"": """", ""issuer"": """" @@ -1160,7 +1246,9 @@ private static UpdateOptions GenerateBaseUpdateOptions( string? graphQLOperationForStoredProcedure = null, string? cacheEnabled = null, string? cacheTtl = null, - string? description = null + string? description = null, + string? mcpDmlTools = null, + string? mcpCustomTool = null ) { return new( @@ -1197,7 +1285,9 @@ private static UpdateOptions GenerateBaseUpdateOptions( fieldsNameCollection: null, fieldsAliasCollection: null, fieldsDescriptionCollection: null, - fieldsPrimaryKeyCollection: null + fieldsPrimaryKeyCollection: null, + mcpDmlTools: mcpDmlTools, + mcpCustomTool: mcpCustomTool ); } @@ -1211,5 +1301,419 @@ private Task ExecuteVerifyTest(string initialConfig, UpdateOptions options, Veri return Verify(updatedRuntimeConfig, settings); } + + #region MCP Entity Configuration Tests + + /// + /// Test updating table entity with MCP dml-tools from false to true, or true to false + /// Tests actual update scenario where existing MCP config is modified + /// + [DataTestMethod] + [DataRow("true", "false", DisplayName = "TestUpdateTableEntityWithMcpDmlToolsEnabled")] + [DataRow("false", "true", DisplayName = "TestUpdateTableEntityWithMcpDmlToolsDisabled")] + public Task TestUpdateTableEntityWithMcpDmlTools(string newMcpDmlTools, string initialMcpDmlTools) + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: newMcpDmlTools, + mcpCustomTool: null + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ], + ""mcp"": " + initialMcpDmlTools + @" + } + } + }"; + + VerifySettings settings = new(); + settings.UseParameters(newMcpDmlTools); + return ExecuteVerifyTest(initialConfig, options, settings: settings); + } + + /// + /// Test updating stored procedure with MCP custom-tool from false to true + /// Tests actual update scenario where existing MCP config is modified + /// + [TestMethod] + public Task TestUpdateStoredProcedureWithMcpCustomToolEnabled() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "GetBookById", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: null, + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""GetBookById"": { + ""source"": ""dbo.GetBookById"", + ""source"": { + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""execute""] + } + ], + ""mcp"": { + ""custom-tool"": false + } + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating stored procedure with both MCP properties + /// Updates from both true to custom-tool=true, dml-tools=false + /// + [TestMethod] + public Task TestUpdateStoredProcedureWithBothMcpProperties() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "UpdateBook", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: "false", + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""UpdateBook"": { + ""source"": ""dbo.UpdateBook"", + ""source"": { + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""execute""] + } + ], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": true + } + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test updating stored procedure with both MCP properties enabled + /// Updates from both false to both true + /// + [TestMethod] + public Task TestUpdateStoredProcedureWithBothMcpPropertiesEnabled() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "GetAllBooks", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: "true", + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""GetAllBooks"": { + ""source"": ""dbo.GetAllBooks"", + ""source"": { + ""type"": ""stored-procedure"" + }, + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""execute""] + } + ], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": false + } + } + } + }"; + + return ExecuteVerifyTest(initialConfig, options); + } + + /// + /// Test that updating table entity with custom-tool fails validation + /// + [TestMethod] + public void TestUpdateTableEntityWithInvalidMcpCustomTool() + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: null, + mcpCustomTool: "true" + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ] + } + } + }"; + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(initialConfig, out RuntimeConfig? runtimeConfig)); + + Assert.IsFalse(TryUpdateExistingEntity(options, runtimeConfig!, out RuntimeConfig _), + "Should fail to update table entity with custom-tool enabled"); + } + + /// + /// Test that invalid MCP option value fails + /// + [DataTestMethod] + [DataRow("invalid", null, DisplayName = "Invalid dml-tools value")] + [DataRow(null, "invalid", DisplayName = "Invalid custom-tool value")] + [DataRow("yes", "no", DisplayName = "Invalid boolean-like values")] + public void TestUpdateEntityWithInvalidMcpOptions(string? mcpDmlTools, string? mcpCustomTool) + { + UpdateOptions options = new( + source: null, + permissions: null, + entity: "MyEntity", + sourceType: null, + sourceParameters: null, + sourceKeyFields: null, + restRoute: null, + graphQLType: null, + fieldsToInclude: null, + fieldsToExclude: null, + policyRequest: null, + policyDatabase: null, + relationship: null, + cardinality: null, + targetEntity: null, + linkingObject: null, + linkingSourceFields: null, + linkingTargetFields: null, + relationshipFields: null, + map: null, + cacheEnabled: null, + cacheTtl: null, + config: TEST_RUNTIME_CONFIG_FILE, + restMethodsForStoredProcedure: null, + graphQLOperationForStoredProcedure: null, + description: null, + parametersNameCollection: null, + parametersDescriptionCollection: null, + parametersRequiredCollection: null, + parametersDefaultCollection: null, + fieldsNameCollection: null, + fieldsAliasCollection: null, + fieldsDescriptionCollection: null, + fieldsPrimaryKeyCollection: null, + mcpDmlTools: mcpDmlTools, + mcpCustomTool: mcpCustomTool + ); + + string initialConfig = GetInitialConfigString() + "," + @" + ""entities"": { + ""MyEntity"": { + ""source"": ""MyTable"", + ""permissions"": [ + { + ""role"": ""anonymous"", + ""actions"": [""*""] + } + ] + } + } + }"; + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(initialConfig, out RuntimeConfig? runtimeConfig)); + + Assert.IsFalse(TryUpdateExistingEntity(options, runtimeConfig!, out RuntimeConfig _), + $"Should fail to update entity with invalid MCP options: dml-tools={mcpDmlTools}, custom-tool={mcpCustomTool}"); + } + + #endregion MCP Entity Configuration Tests } } diff --git a/src/Cli/Commands/AddOptions.cs b/src/Cli/Commands/AddOptions.cs index b7d9fbeb08..e7e378d94b 100644 --- a/src/Cli/Commands/AddOptions.cs +++ b/src/Cli/Commands/AddOptions.cs @@ -43,7 +43,9 @@ public AddOptions( IEnumerable? fieldsAliasCollection, IEnumerable? fieldsDescriptionCollection, IEnumerable? fieldsPrimaryKeyCollection, - string? config + string? mcpDmlTools = null, + string? mcpCustomTool = null, + string? config = null ) : base( entity, @@ -69,6 +71,8 @@ public AddOptions( fieldsAliasCollection, fieldsDescriptionCollection, fieldsPrimaryKeyCollection, + mcpDmlTools, + mcpCustomTool, config ) { diff --git a/src/Cli/Commands/ConfigureOptions.cs b/src/Cli/Commands/ConfigureOptions.cs index 60cb12c3f8..c3e0352249 100644 --- a/src/Cli/Commands/ConfigureOptions.cs +++ b/src/Cli/Commands/ConfigureOptions.cs @@ -38,6 +38,7 @@ public ConfigureOptions( bool? runtimeRestRequestBodyStrict = null, bool? runtimeMcpEnabled = null, string? runtimeMcpPath = null, + string? runtimeMcpDescription = null, bool? runtimeMcpDmlToolsEnabled = null, bool? runtimeMcpDmlToolsDescribeEntitiesEnabled = null, bool? runtimeMcpDmlToolsCreateRecordEnabled = null, @@ -93,6 +94,7 @@ public ConfigureOptions( // Mcp RuntimeMcpEnabled = runtimeMcpEnabled; RuntimeMcpPath = runtimeMcpPath; + RuntimeMcpDescription = runtimeMcpDescription; RuntimeMcpDmlToolsEnabled = runtimeMcpDmlToolsEnabled; RuntimeMcpDmlToolsDescribeEntitiesEnabled = runtimeMcpDmlToolsDescribeEntitiesEnabled; RuntimeMcpDmlToolsCreateRecordEnabled = runtimeMcpDmlToolsCreateRecordEnabled; @@ -180,6 +182,9 @@ public ConfigureOptions( [Option("runtime.mcp.path", Required = false, HelpText = "Customize DAB's MCP endpoint path. Default: '/mcp' Conditions: Prefix path with '/'.")] public string? RuntimeMcpPath { get; } + [Option("runtime.mcp.description", Required = false, HelpText = "Set the MCP server description to be exposed in the initialize response.")] + public string? RuntimeMcpDescription { get; } + [Option("runtime.mcp.dml-tools.enabled", Required = false, HelpText = "Enable DAB's MCP DML tools endpoint. Default: true (boolean).")] public bool? RuntimeMcpDmlToolsEnabled { get; } @@ -216,7 +221,7 @@ public ConfigureOptions( [Option("runtime.host.cors.allow-credentials", Required = false, HelpText = "Set value for Access-Control-Allow-Credentials header in Host.Cors. Default: false (boolean).")] public bool? RuntimeHostCorsAllowCredentials { get; } - [Option("runtime.host.authentication.provider", Required = false, HelpText = "Configure the name of authentication provider. Default: StaticWebApps.")] + [Option("runtime.host.authentication.provider", Required = false, HelpText = "Configure the name of authentication provider. Default: AppService.")] public string? RuntimeHostAuthenticationProvider { get; } [Option("runtime.host.authentication.jwt.audience", Required = false, HelpText = "Configure the intended recipient(s) of the Jwt Token.")] diff --git a/src/Cli/Commands/EntityOptions.cs b/src/Cli/Commands/EntityOptions.cs index 7f26816800..3b2b77d9b2 100644 --- a/src/Cli/Commands/EntityOptions.cs +++ b/src/Cli/Commands/EntityOptions.cs @@ -34,7 +34,9 @@ public EntityOptions( IEnumerable? fieldsAliasCollection, IEnumerable? fieldsDescriptionCollection, IEnumerable? fieldsPrimaryKeyCollection, - string? config + string? mcpDmlTools = null, + string? mcpCustomTool = null, + string? config = null ) : base(config) { @@ -61,6 +63,8 @@ public EntityOptions( FieldsAliasCollection = fieldsAliasCollection; FieldsDescriptionCollection = fieldsDescriptionCollection; FieldsPrimaryKeyCollection = fieldsPrimaryKeyCollection; + McpDmlTools = mcpDmlTools; + McpCustomTool = mcpCustomTool; } // Entity is required but we have made required as false to have custom error message (more user friendly), if not provided. @@ -132,5 +136,11 @@ public EntityOptions( [Option("fields.primary-key", Required = false, Separator = ',', HelpText = "Set this field as a primary key.")] public IEnumerable? FieldsPrimaryKeyCollection { get; } + + [Option("mcp.dml-tools", Required = false, HelpText = "Enable MCP DML (Data Manipulation Language) tools for this entity. Allows CRUD operations via MCP. Default value is true.")] + public string? McpDmlTools { get; } + + [Option("mcp.custom-tool", Required = false, HelpText = "Enable MCP custom tool for this entity. Only valid for stored procedures. Default value is false.")] + public string? McpCustomTool { get; } } } diff --git a/src/Cli/Commands/InitOptions.cs b/src/Cli/Commands/InitOptions.cs index 91786d99ff..e01ad1b774 100644 --- a/src/Cli/Commands/InitOptions.cs +++ b/src/Cli/Commands/InitOptions.cs @@ -94,7 +94,7 @@ public InitOptions( [Option("cors-origin", Separator = ',', Required = false, HelpText = "Specify the list of allowed origins.")] public IEnumerable? CorsOrigin { get; } - [Option("auth.provider", Default = "StaticWebApps", Required = false, HelpText = "Specify the Identity Provider.")] + [Option("auth.provider", Default = "AppService", Required = false, HelpText = "Specify the Identity Provider.")] public string AuthenticationProvider { get; } [Option("auth.audience", Required = false, HelpText = "Identifies the recipients that the JWT is intended for.")] diff --git a/src/Cli/Commands/StartOptions.cs b/src/Cli/Commands/StartOptions.cs index c335c6bcc5..050f410801 100644 --- a/src/Cli/Commands/StartOptions.cs +++ b/src/Cli/Commands/StartOptions.cs @@ -19,12 +19,14 @@ public class StartOptions : Options { private const string LOGLEVEL_HELPTEXT = "Specifies logging level as provided value. For possible values, see: https://go.microsoft.com/fwlink/?linkid=2263106"; - public StartOptions(bool verbose, LogLevel? logLevel, bool isHttpsRedirectionDisabled, string config) + public StartOptions(bool verbose, LogLevel? logLevel, bool isHttpsRedirectionDisabled, bool mcpStdio, string? mcpRole, string config) : base(config) { // When verbose is true we set LogLevel to information. LogLevel = verbose is true ? Microsoft.Extensions.Logging.LogLevel.Information : logLevel; IsHttpsRedirectionDisabled = isHttpsRedirectionDisabled; + McpStdio = mcpStdio; + McpRole = mcpRole; } // SetName defines mutually exclusive sets, ie: can not have @@ -38,6 +40,12 @@ public StartOptions(bool verbose, LogLevel? logLevel, bool isHttpsRedirectionDis [Option("no-https-redirect", Required = false, HelpText = "Disables automatic https redirects.")] public bool IsHttpsRedirectionDisabled { get; } + [Option("mcp-stdio", Required = false, HelpText = "Run Data API Builder in MCP stdio mode while starting the engine.")] + public bool McpStdio { get; } + + [Value(0, MetaName = "role", Required = false, HelpText = "Optional MCP permissions role, e.g. role:anonymous. If omitted, defaults to anonymous.")] + public string? McpRole { get; } + public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSystem fileSystem) { logger.LogInformation("{productName} {version}", PRODUCT_NAME, ProductInfo.GetProductVersion()); @@ -45,7 +53,8 @@ public int Handler(ILogger logger, FileSystemRuntimeConfigLoader loader, IFileSy if (!isSuccess) { - logger.LogError("Failed to start the engine."); + logger.LogError("Failed to start the engine{mode}.", + McpStdio ? " in MCP stdio mode" : string.Empty); } return isSuccess ? CliReturnCode.SUCCESS : CliReturnCode.GENERAL_ERROR; diff --git a/src/Cli/Commands/UpdateOptions.cs b/src/Cli/Commands/UpdateOptions.cs index fe1664c5bb..050afa2ddb 100644 --- a/src/Cli/Commands/UpdateOptions.cs +++ b/src/Cli/Commands/UpdateOptions.cs @@ -51,7 +51,9 @@ public UpdateOptions( IEnumerable? fieldsAliasCollection, IEnumerable? fieldsDescriptionCollection, IEnumerable? fieldsPrimaryKeyCollection, - string? config) + string? mcpDmlTools = null, + string? mcpCustomTool = null, + string? config = null) : base(entity, sourceType, sourceParameters, @@ -75,6 +77,8 @@ public UpdateOptions( fieldsAliasCollection, fieldsDescriptionCollection, fieldsPrimaryKeyCollection, + mcpDmlTools, + mcpCustomTool, config) { Source = source; diff --git a/src/Cli/ConfigGenerator.cs b/src/Cli/ConfigGenerator.cs index 9a56f83c4a..78a5e63a7d 100644 --- a/src/Cli/ConfigGenerator.cs +++ b/src/Cli/ConfigGenerator.cs @@ -449,6 +449,18 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt EntityRestOptions restOptions = ConstructRestOptions(options.RestRoute, SupportedRestMethods, initialRuntimeConfig.DataSource.DatabaseType == DatabaseType.CosmosDB_NoSQL); EntityGraphQLOptions graphqlOptions = ConstructGraphQLTypeDetails(options.GraphQLType, graphQLOperationsForStoredProcedures); EntityCacheOptions? cacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + EntityMcpOptions? mcpOptions = null; + + if (options.McpDmlTools is not null || options.McpCustomTool is not null) + { + mcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedure); + + if (mcpOptions is null) + { + _logger.LogError("Failed to construct MCP options."); + return false; + } + } // Create new entity. Entity entity = new( @@ -460,7 +472,8 @@ public static bool TryAddNewEntity(AddOptions options, RuntimeConfig initialRunt Relationships: null, Mappings: null, Cache: cacheOptions, - Description: string.IsNullOrWhiteSpace(options.Description) ? null : options.Description); + Description: string.IsNullOrWhiteSpace(options.Description) ? null : options.Description, + Mcp: mcpOptions); // Add entity to existing runtime config. IDictionary entities = new Dictionary(initialRuntimeConfig.Entities.Entities) @@ -797,7 +810,15 @@ private static bool TryUpdateConfiguredRuntimeOptions( // MCP: Enabled and Path if (options.RuntimeMcpEnabled != null || - options.RuntimeMcpPath != null) + options.RuntimeMcpPath != null || + options.RuntimeMcpDescription != null || + options.RuntimeMcpDmlToolsEnabled != null || + options.RuntimeMcpDmlToolsDescribeEntitiesEnabled != null || + options.RuntimeMcpDmlToolsCreateRecordEnabled != null || + options.RuntimeMcpDmlToolsReadRecordsEnabled != null || + options.RuntimeMcpDmlToolsUpdateRecordEnabled != null || + options.RuntimeMcpDmlToolsDeleteRecordEnabled != null || + options.RuntimeMcpDmlToolsExecuteEntityEnabled != null) { McpRuntimeOptions updatedMcpOptions = runtimeConfig?.Runtime?.Mcp ?? new(); bool status = TryUpdateConfiguredMcpValues(options, ref updatedMcpOptions); @@ -1053,6 +1074,14 @@ private static bool TryUpdateConfiguredMcpValues( } } + // Runtime.Mcp.Description + updatedValue = options?.RuntimeMcpDescription; + if (updatedValue != null) + { + updatedMcpOptions = updatedMcpOptions! with { Description = (string)updatedValue }; + _logger.LogInformation("Updated RuntimeConfig with Runtime.Mcp.Description as '{updatedValue}'", updatedValue); + } + // Handle DML tools configuration bool hasToolUpdates = false; DmlToolsConfig? currentDmlTools = updatedMcpOptions?.DmlTools; @@ -1260,7 +1289,8 @@ private static bool TryUpdateConfiguredHostValues( string? updatedProviderValue = options?.RuntimeHostAuthenticationProvider; if (updatedProviderValue != null) { - updatedValue = updatedProviderValue?.ToString() ?? nameof(EasyAuthType.StaticWebApps); + // Default to AppService when provider string is not provided + updatedValue = updatedProviderValue?.ToString() ?? nameof(EasyAuthType.AppService); AuthenticationOptions AuthOptions; if (updatedHostOptions?.Authentication == null) { @@ -1621,6 +1651,26 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig EntityActionFields? updatedFields = GetFieldsForOperation(options.FieldsToInclude, options.FieldsToExclude); EntityCacheOptions? updatedCacheOptions = ConstructCacheOptions(options.CacheEnabled, options.CacheTtl); + // Determine if the entity is or will be a stored procedure + bool isStoredProcedureAfterUpdate = doOptionsRepresentStoredProcedure || (isCurrentEntityStoredProcedure && options.SourceType is null); + + // Construct and validate MCP options if provided + EntityMcpOptions? updatedMcpOptions = null; + if (options.McpDmlTools is not null || options.McpCustomTool is not null) + { + updatedMcpOptions = ConstructMcpOptions(options.McpDmlTools, options.McpCustomTool, isStoredProcedureAfterUpdate); + if (updatedMcpOptions is null) + { + _logger.LogError("Failed to construct MCP options."); + return false; + } + } + else + { + // Keep existing MCP options if no updates provided + updatedMcpOptions = entity.Mcp; + } + if (!updatedGraphQLDetails.Enabled) { _logger.LogWarning("Disabling GraphQL for this entity will restrict its usage in relationships"); @@ -1713,6 +1763,7 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig List updatedFieldsList = ComposeFieldsFromOptions(options); Dictionary updatedFieldsDict = updatedFieldsList.ToDictionary(f => f.Name, f => f); List mergedFields = []; + bool primaryKeyOptionProvided = options.FieldsPrimaryKeyCollection?.Any() == true; foreach (FieldMetadata field in existingFields) { @@ -1723,7 +1774,10 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig Name = updatedField.Name, Alias = updatedField.Alias ?? field.Alias, Description = updatedField.Description ?? field.Description, - PrimaryKey = updatedField.PrimaryKey + // If --fields.primary-key was not provided at all, + // keep the existing primary-key flag. Otherwise, + // use the value coming from updatedField. + PrimaryKey = primaryKeyOptionProvided ? updatedField.PrimaryKey : field.PrimaryKey }); updatedFieldsDict.Remove(field.Name); // Remove so only new fields remain } @@ -1857,7 +1911,8 @@ public static bool TryUpdateExistingEntity(UpdateOptions options, RuntimeConfig Relationships: updatedRelationships, Mappings: updatedMappings, Cache: updatedCacheOptions, - Description: string.IsNullOrWhiteSpace(options.Description) ? entity.Description : options.Description + Description: string.IsNullOrWhiteSpace(options.Description) ? entity.Description : options.Description, + Mcp: updatedMcpOptions ); IDictionary entities = new Dictionary(initialConfig.Entities.Entities) { @@ -2359,6 +2414,17 @@ public static bool TryStartEngineWithOptions(StartOptions options, FileSystemRun args.Add(Startup.NO_HTTPS_REDIRECT_FLAG); } + // If MCP stdio was requested, append the stdio-specific switches. + if (options.McpStdio) + { + string effectiveRole = string.IsNullOrWhiteSpace(options.McpRole) + ? "anonymous" + : options.McpRole; + + args.Add("--mcp-stdio"); + args.Add(effectiveRole); + } + return Azure.DataApiBuilder.Service.Program.StartEngine(args.ToArray()); } @@ -2700,9 +2766,10 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( // Azure Key Vault Endpoint if (options.AzureKeyVaultEndpoint is not null) { + // Ensure endpoint flag is marked user provided so converter writes it. updatedAzureKeyVaultOptions = updatedAzureKeyVaultOptions is not null - ? updatedAzureKeyVaultOptions with { Endpoint = options.AzureKeyVaultEndpoint } - : new AzureKeyVaultOptions { Endpoint = options.AzureKeyVaultEndpoint }; + ? updatedAzureKeyVaultOptions with { Endpoint = options.AzureKeyVaultEndpoint, UserProvidedEndpoint = true } + : new AzureKeyVaultOptions(endpoint: options.AzureKeyVaultEndpoint); _logger.LogInformation("Updated RuntimeConfig with azure-key-vault.endpoint as '{endpoint}'", options.AzureKeyVaultEndpoint); } @@ -2711,7 +2778,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( { updatedRetryPolicyOptions = updatedRetryPolicyOptions is not null ? updatedRetryPolicyOptions with { Mode = options.AzureKeyVaultRetryPolicyMode.Value, UserProvidedMode = true } - : new AKVRetryPolicyOptions { Mode = options.AzureKeyVaultRetryPolicyMode.Value, UserProvidedMode = true }; + : new AKVRetryPolicyOptions(mode: options.AzureKeyVaultRetryPolicyMode.Value); _logger.LogInformation("Updated RuntimeConfig with azure-key-vault.retry-policy.mode as '{mode}'", options.AzureKeyVaultRetryPolicyMode.Value); } @@ -2726,7 +2793,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( updatedRetryPolicyOptions = updatedRetryPolicyOptions is not null ? updatedRetryPolicyOptions with { MaxCount = options.AzureKeyVaultRetryPolicyMaxCount.Value, UserProvidedMaxCount = true } - : new AKVRetryPolicyOptions { MaxCount = options.AzureKeyVaultRetryPolicyMaxCount.Value, UserProvidedMaxCount = true }; + : new AKVRetryPolicyOptions(maxCount: options.AzureKeyVaultRetryPolicyMaxCount.Value); _logger.LogInformation("Updated RuntimeConfig with azure-key-vault.retry-policy.max-count as '{maxCount}'", options.AzureKeyVaultRetryPolicyMaxCount.Value); } @@ -2741,7 +2808,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( updatedRetryPolicyOptions = updatedRetryPolicyOptions is not null ? updatedRetryPolicyOptions with { DelaySeconds = options.AzureKeyVaultRetryPolicyDelaySeconds.Value, UserProvidedDelaySeconds = true } - : new AKVRetryPolicyOptions { DelaySeconds = options.AzureKeyVaultRetryPolicyDelaySeconds.Value, UserProvidedDelaySeconds = true }; + : new AKVRetryPolicyOptions(delaySeconds: options.AzureKeyVaultRetryPolicyDelaySeconds.Value); _logger.LogInformation("Updated RuntimeConfig with azure-key-vault.retry-policy.delay-seconds as '{delaySeconds}'", options.AzureKeyVaultRetryPolicyDelaySeconds.Value); } @@ -2756,7 +2823,7 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( updatedRetryPolicyOptions = updatedRetryPolicyOptions is not null ? updatedRetryPolicyOptions with { MaxDelaySeconds = options.AzureKeyVaultRetryPolicyMaxDelaySeconds.Value, UserProvidedMaxDelaySeconds = true } - : new AKVRetryPolicyOptions { MaxDelaySeconds = options.AzureKeyVaultRetryPolicyMaxDelaySeconds.Value, UserProvidedMaxDelaySeconds = true }; + : new AKVRetryPolicyOptions(maxDelaySeconds: options.AzureKeyVaultRetryPolicyMaxDelaySeconds.Value); _logger.LogInformation("Updated RuntimeConfig with azure-key-vault.retry-policy.max-delay-seconds as '{maxDelaySeconds}'", options.AzureKeyVaultRetryPolicyMaxDelaySeconds.Value); } @@ -2771,16 +2838,17 @@ private static bool TryUpdateConfiguredAzureKeyVaultOptions( updatedRetryPolicyOptions = updatedRetryPolicyOptions is not null ? updatedRetryPolicyOptions with { NetworkTimeoutSeconds = options.AzureKeyVaultRetryPolicyNetworkTimeoutSeconds.Value, UserProvidedNetworkTimeoutSeconds = true } - : new AKVRetryPolicyOptions { NetworkTimeoutSeconds = options.AzureKeyVaultRetryPolicyNetworkTimeoutSeconds.Value, UserProvidedNetworkTimeoutSeconds = true }; + : new AKVRetryPolicyOptions(networkTimeoutSeconds: options.AzureKeyVaultRetryPolicyNetworkTimeoutSeconds.Value); _logger.LogInformation("Updated RuntimeConfig with azure-key-vault.retry-policy.network-timeout-seconds as '{networkTimeoutSeconds}'", options.AzureKeyVaultRetryPolicyNetworkTimeoutSeconds.Value); } - // Update Azure Key Vault options with retry policy if retry policy was modified + // Update Azure Key Vault options with retry policy if modified if (updatedRetryPolicyOptions is not null) { + // Ensure outer AKV object marks retry policy as user provided so it serializes. updatedAzureKeyVaultOptions = updatedAzureKeyVaultOptions is not null - ? updatedAzureKeyVaultOptions with { RetryPolicy = updatedRetryPolicyOptions } - : new AzureKeyVaultOptions { RetryPolicy = updatedRetryPolicyOptions }; + ? updatedAzureKeyVaultOptions with { RetryPolicy = updatedRetryPolicyOptions, UserProvidedRetryPolicy = true } + : new AzureKeyVaultOptions(retryPolicy: updatedRetryPolicyOptions); } // Update runtime config if Azure Key Vault options were modified diff --git a/src/Cli/Exporter.cs b/src/Cli/Exporter.cs index d4f103e868..e694317cd4 100644 --- a/src/Cli/Exporter.cs +++ b/src/Cli/Exporter.cs @@ -44,7 +44,8 @@ public static bool Export(ExportOptions options, ILogger logger, FileSystemRunti } // Load the runtime configuration from the file - if (!loader.TryLoadConfig(runtimeConfigFile, out RuntimeConfig? runtimeConfig, replaceEnvVar: true)) + DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true); + if (!loader.TryLoadConfig(runtimeConfigFile, out RuntimeConfig? runtimeConfig, replacementSettings: replacementSettings)) { logger.LogError("Failed to read the config file: {0}.", runtimeConfigFile); return false; @@ -109,7 +110,13 @@ private static async Task ExportGraphQL( } else { - StartOptions startOptions = new(false, LogLevel.None, false, options.Config!); + StartOptions startOptions = new( + verbose: false, + logLevel: LogLevel.None, + isHttpsRedirectionDisabled: false, + config: options.Config!, + mcpStdio: false, + mcpRole: null); Task dabService = Task.Run(() => { diff --git a/src/Cli/Utils.cs b/src/Cli/Utils.cs index 451c330503..48edd4411c 100644 --- a/src/Cli/Utils.cs +++ b/src/Cli/Utils.cs @@ -892,6 +892,57 @@ public static EntityGraphQLOptions ConstructGraphQLTypeDetails(string? graphQL, return cacheOptions with { Enabled = isEnabled, TtlSeconds = ttl, UserProvidedTtlOptions = isCacheTtlUserProvided }; } + /// + /// Constructs the EntityMcpOptions for Add/Update. + /// + /// String value that defines if DML tools are enabled for MCP. + /// String value that defines if custom tool is enabled for MCP. + /// Whether the entity is a stored procedure. + /// EntityMcpOptions if values are provided, null otherwise. + public static EntityMcpOptions? ConstructMcpOptions(string? mcpDmlTools, string? mcpCustomTool, bool isStoredProcedure) + { + if (mcpDmlTools is null && mcpCustomTool is null) + { + return null; + } + + bool? dmlToolsEnabled = null; + bool? customToolEnabled = null; + + // Parse dml-tools option + if (mcpDmlTools is not null) + { + if (!bool.TryParse(mcpDmlTools, out bool dmlValue)) + { + _logger.LogError("Invalid format for --mcp.dml-tools. Accepted values are true/false."); + return null; + } + + dmlToolsEnabled = dmlValue; + } + + // Parse custom-tool option + if (mcpCustomTool is not null) + { + if (!bool.TryParse(mcpCustomTool, out bool customValue)) + { + _logger.LogError("Invalid format for --mcp.custom-tool. Accepted values are true/false."); + return null; + } + + // Validate that custom-tool can only be used with stored procedures + if (customValue && !isStoredProcedure) + { + _logger.LogError("--mcp.custom-tool can only be enabled for stored procedures."); + return null; + } + + customToolEnabled = customValue; + } + + return new EntityMcpOptions(customToolEnabled, dmlToolsEnabled); + } + /// /// Check if add/update command has Entity provided. Return false otherwise. /// diff --git a/src/Config/Azure.DataApiBuilder.Config.csproj b/src/Config/Azure.DataApiBuilder.Config.csproj index a494bc38ae..6b5bdf0955 100644 --- a/src/Config/Azure.DataApiBuilder.Config.csproj +++ b/src/Config/Azure.DataApiBuilder.Config.csproj @@ -15,6 +15,7 @@ + diff --git a/src/Config/Converters/AKVRetryPolicyOptionsConverterFactory.cs b/src/Config/Converters/AKVRetryPolicyOptionsConverterFactory.cs index 06d00b64d3..553e43db53 100644 --- a/src/Config/Converters/AKVRetryPolicyOptionsConverterFactory.cs +++ b/src/Config/Converters/AKVRetryPolicyOptionsConverterFactory.cs @@ -12,9 +12,9 @@ namespace Azure.DataApiBuilder.Config.Converters; /// internal class AKVRetryPolicyOptionsConverterFactory : JsonConverterFactory { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + // Currently allows for Azure Key Vault (via @akv('secret-name')) and Environment Variable replacement. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -25,34 +25,34 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new AKVRetryPolicyOptionsConverter(_replaceEnvVar); + return new AKVRetryPolicyOptionsConverter(_replacementSettings); } - /// Whether to replace environment variable with its - /// value or not while deserializing. - internal AKVRetryPolicyOptionsConverterFactory(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + internal AKVRetryPolicyOptionsConverterFactory(DeserializationVariableReplacementSettings? replacementSettings = null) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class AKVRetryPolicyOptionsConverter : JsonConverter { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + // Currently allows for Azure Key Vault (via @akv('')) and Environment Variable replacement. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; - /// Whether to replace environment variable with its - /// value or not while deserializing. - public AKVRetryPolicyOptionsConverter(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public AKVRetryPolicyOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// /// Defines how DAB reads AKV Retry Policy options and defines which values are /// used to instantiate those options. /// - /// Thrown when improperly formatted cache options are provided. + /// Thrown when improperly formatted retry policy options are provided. public override AKVRetryPolicyOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType is JsonTokenType.StartObject) @@ -82,7 +82,7 @@ public AKVRetryPolicyOptionsConverter(bool replaceEnvVar) } else { - mode = EnumExtensions.Deserialize(reader.DeserializeString(_replaceEnvVar)!); + mode = EnumExtensions.Deserialize(reader.DeserializeString(_replacementSettings)!); } break; diff --git a/src/Config/Converters/AutoentityConverter.cs b/src/Config/Converters/AutoentityConverter.cs new file mode 100644 index 0000000000..5c09ed8e7b --- /dev/null +++ b/src/Config/Converters/AutoentityConverter.cs @@ -0,0 +1,110 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +internal class AutoentityConverter : JsonConverter +{ + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; + + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public AutoentityConverter(DeserializationVariableReplacementSettings? replacementSettings = null) + { + _replacementSettings = replacementSettings; + } + + /// + public override Autoentity? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.StartObject) + { + // Initialize all sub-properties to null. + AutoentityPatterns? patterns = null; + AutoentityTemplate? template = null; + EntityPermission[]? permissions = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new Autoentity(patterns, template, permissions); + } + + string? propertyName = reader.GetString(); + + reader.Read(); + switch (propertyName) + { + case "patterns": + AutoentityPatternsConverter patternsConverter = new(_replacementSettings); + patterns = patternsConverter.Read(ref reader, typeof(AutoentityPatterns), options); + break; + + case "template": + AutoentityTemplateConverter templateConverter = new(_replacementSettings); + template = templateConverter.Read(ref reader, typeof(AutoentityTemplate), options); + break; + + case "permissions": + permissions = JsonSerializer.Deserialize(ref reader, options) + ?? throw new JsonException("The 'permissions' property must contain at least one permission."); + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + throw new JsonException("Failed to read the Autoentities"); + } + + /// + /// When writing the autoentities back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + /// + public override void Write(Utf8JsonWriter writer, Autoentity value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + AutoentityPatterns? patterns = value?.Patterns; + if (patterns?.UserProvidedIncludeOptions is true + || patterns?.UserProvidedExcludeOptions is true + || patterns?.UserProvidedNameOptions is true) + { + AutoentityPatternsConverter autoentityPatternsConverter = options.GetConverter(typeof(AutoentityPatterns)) as AutoentityPatternsConverter ?? + throw new JsonException("Failed to get autoentities.patterns options converter"); + writer.WritePropertyName("patterns"); + autoentityPatternsConverter.Write(writer, patterns, options); + } + + AutoentityTemplate? template = value?.Template; + if (template?.UserProvidedRestOptions is true + || template?.UserProvidedGraphQLOptions is true + || template?.UserProvidedHealthOptions is true + || template?.UserProvidedCacheOptions is true) + { + AutoentityTemplateConverter autoentityTemplateConverter = options.GetConverter(typeof(AutoentityTemplate)) as AutoentityTemplateConverter ?? + throw new JsonException("Failed to get autoentities.template options converter"); + writer.WritePropertyName("template"); + autoentityTemplateConverter.Write(writer, template, options); + } + + if (value?.Permissions is not null) + { + writer.WritePropertyName("permissions"); + JsonSerializer.Serialize(writer, value.Permissions, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Config/Converters/AutoentityPatternsConverter.cs b/src/Config/Converters/AutoentityPatternsConverter.cs new file mode 100644 index 0000000000..d8029ff033 --- /dev/null +++ b/src/Config/Converters/AutoentityPatternsConverter.cs @@ -0,0 +1,151 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +internal class AutoentityPatternsConverter : JsonConverter +{ + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; + + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public AutoentityPatternsConverter(DeserializationVariableReplacementSettings? replacementSettings = null) + { + _replacementSettings = replacementSettings; + } + + /// + public override AutoentityPatterns? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.StartObject) + { + string[]? include = null; + string[]? exclude = null; + string? name = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new AutoentityPatterns(include, exclude, name); + } + + string? propertyName = reader.GetString(); + + reader.Read(); + switch (propertyName) + { + case "include": + if (reader.TokenType is not JsonTokenType.Null) + { + List includeList = new(); + + if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + string? value = reader.DeserializeString(_replacementSettings); + if (value is not null) + { + includeList.Add(value); + } + } + + include = includeList.ToArray(); + } + else + { + throw new JsonException("Expected array for 'include' property."); + } + } + + break; + + case "exclude": + if (reader.TokenType is not JsonTokenType.Null) + { + List excludeList = new(); + + if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read() && reader.TokenType != JsonTokenType.EndArray) + { + string? value = reader.DeserializeString(_replacementSettings); + if (value is not null) + { + excludeList.Add(value); + } + } + + exclude = excludeList.ToArray(); + } + else + { + throw new JsonException("Expected array for 'exclude' property."); + } + } + + break; + + case "name": + name = reader.DeserializeString(_replacementSettings); + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + throw new JsonException("Failed to read the Autoentities Pattern Options"); + } + + /// + /// When writing the autoentities.patterns back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + /// + public override void Write(Utf8JsonWriter writer, AutoentityPatterns value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value?.UserProvidedIncludeOptions is true) + { + writer.WritePropertyName("include"); + writer.WriteStartArray(); + foreach (string? include in value.Include) + { + JsonSerializer.Serialize(writer, include, options); + } + + writer.WriteEndArray(); + } + + if (value?.UserProvidedExcludeOptions is true) + { + writer.WritePropertyName("exclude"); + writer.WriteStartArray(); + foreach (string? exclude in value.Exclude) + { + JsonSerializer.Serialize(writer, exclude, options); + } + + writer.WriteEndArray(); + } + + if (value?.UserProvidedNameOptions is true) + { + writer.WritePropertyName("name"); + JsonSerializer.Serialize(writer, value.Name, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Config/Converters/AutoentityTemplateConverter.cs b/src/Config/Converters/AutoentityTemplateConverter.cs new file mode 100644 index 0000000000..275cfc4314 --- /dev/null +++ b/src/Config/Converters/AutoentityTemplateConverter.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +internal class AutoentityTemplateConverter : JsonConverter +{ + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; + + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public AutoentityTemplateConverter(DeserializationVariableReplacementSettings? replacementSettings = null) + { + _replacementSettings = replacementSettings; + } + + /// + public override AutoentityTemplate? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.StartObject) + { + // Create converters for each of the sub-properties. + EntityRestOptionsConverterFactory restOptionsConverterFactory = new(_replacementSettings); + JsonConverter restOptionsConverter = (JsonConverter)(restOptionsConverterFactory.CreateConverter(typeof(EntityRestOptions), options) + ?? throw new JsonException("Unable to create converter for EntityRestOptions")); + + EntityGraphQLOptionsConverterFactory graphQLOptionsConverterFactory = new(_replacementSettings); + JsonConverter graphQLOptionsConverter = (JsonConverter)(graphQLOptionsConverterFactory.CreateConverter(typeof(EntityGraphQLOptions), options) + ?? throw new JsonException("Unable to create converter for EntityGraphQLOptions")); + + EntityMcpOptionsConverterFactory mcpOptionsConverterFactory = new(); + JsonConverter mcpOptionsConverter = (JsonConverter)(mcpOptionsConverterFactory.CreateConverter(typeof(EntityMcpOptions), options) + ?? throw new JsonException("Unable to create converter for EntityMcpOptions")); + + EntityHealthOptionsConvertorFactory healthOptionsConverterFactory = new(); + JsonConverter healthOptionsConverter = (JsonConverter)(healthOptionsConverterFactory.CreateConverter(typeof(EntityHealthCheckConfig), options) + ?? throw new JsonException("Unable to create converter for EntityHealthCheckConfig")); + + EntityCacheOptionsConverterFactory cacheOptionsConverterFactory = new(_replacementSettings); + JsonConverter cacheOptionsConverter = (JsonConverter)(cacheOptionsConverterFactory.CreateConverter(typeof(EntityCacheOptions), options) + ?? throw new JsonException("Unable to create converter for EntityCacheOptions")); + + // Initialize all sub-properties to null. + EntityRestOptions? rest = null; + EntityGraphQLOptions? graphQL = null; + EntityMcpOptions? mcp = null; + EntityHealthCheckConfig? health = null; + EntityCacheOptions? cache = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new AutoentityTemplate(rest, graphQL, mcp, health, cache); + } + + string? propertyName = reader.GetString(); + + reader.Read(); + switch (propertyName) + { + case "rest": + rest = restOptionsConverter.Read(ref reader, typeof(EntityRestOptions), options); + break; + + case "graphql": + graphQL = graphQLOptionsConverter.Read(ref reader, typeof(EntityGraphQLOptions), options); + break; + + case "mcp": + mcp = mcpOptionsConverter.Read(ref reader, typeof(EntityMcpOptions), options); + break; + + case "health": + health = healthOptionsConverter.Read(ref reader, typeof(EntityHealthCheckConfig), options); + break; + + case "cache": + cache = cacheOptionsConverter.Read(ref reader, typeof(EntityCacheOptions), options); + break; + + default: + throw new JsonException($"Unexpected property {propertyName}"); + } + } + } + + throw new JsonException("Failed to read the Autoentities Template Options"); + } + + /// + /// When writing the autoentities.template back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + /// + public override void Write(Utf8JsonWriter writer, AutoentityTemplate value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value?.UserProvidedRestOptions is true) + { + writer.WritePropertyName("rest"); + JsonSerializer.Serialize(writer, value.Rest, options); + } + + if (value?.UserProvidedGraphQLOptions is true) + { + writer.WritePropertyName("graphql"); + JsonSerializer.Serialize(writer, value.GraphQL, options); + } + + if (value?.UserProvidedHealthOptions is true) + { + writer.WritePropertyName("health"); + JsonSerializer.Serialize(writer, value.Health, options); + } + + if (value?.UserProvidedCacheOptions is true) + { + writer.WritePropertyName("cache"); + JsonSerializer.Serialize(writer, value.Cache, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Config/Converters/AzureKeyVaultOptionsConverterFactory.cs b/src/Config/Converters/AzureKeyVaultOptionsConverterFactory.cs new file mode 100644 index 0000000000..92ed0c1a85 --- /dev/null +++ b/src/Config/Converters/AzureKeyVaultOptionsConverterFactory.cs @@ -0,0 +1,128 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// Converter factory for AzureKeyVaultOptions that can optionally perform variable replacement. +/// +internal class AzureKeyVaultOptionsConverterFactory : JsonConverterFactory +{ + // Determines whether to replace environment variable with its + // value or not while deserializing. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; + + /// How to handle variable replacement during deserialization. + internal AzureKeyVaultOptionsConverterFactory(DeserializationVariableReplacementSettings? replacementSettings = null) + { + _replacementSettings = replacementSettings; + } + + /// + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert.IsAssignableTo(typeof(AzureKeyVaultOptions)); + } + + /// + public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new AzureKeyVaultOptionsConverter(_replacementSettings); + } + + private class AzureKeyVaultOptionsConverter : JsonConverter + { + // Determines whether to replace environment variable with its + // value or not while deserializing. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; + + /// Whether to replace environment variable with its + /// value or not while deserializing. + public AzureKeyVaultOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) + { + _replacementSettings = replacementSettings; + } + + /// + /// Reads AzureKeyVaultOptions with optional variable replacement. + /// + public override AzureKeyVaultOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + return null; + } + + if (reader.TokenType is JsonTokenType.StartObject) + { + string? endpoint = null; + AKVRetryPolicyOptions? retryPolicy = null; + + while (reader.Read()) + { + if (reader.TokenType is JsonTokenType.EndObject) + { + return new AzureKeyVaultOptions(endpoint, retryPolicy); + } + + string? property = reader.GetString(); + reader.Read(); + + switch (property) + { + case "endpoint": + if (reader.TokenType is JsonTokenType.String) + { + endpoint = reader.DeserializeString(_replacementSettings); + } + + break; + + case "retry-policy": + if (reader.TokenType is JsonTokenType.StartObject) + { + // Uses the AKVRetryPolicyOptionsConverter to read the retry-policy object. + retryPolicy = JsonSerializer.Deserialize(ref reader, options); + } + + break; + + default: + throw new JsonException($"Unexpected property {property}"); + } + } + } + + throw new JsonException("Invalid AzureKeyVaultOptions format"); + } + + /// + /// When writing the AzureKeyVaultOptions back to a JSON file, only write the properties + /// if they are user provided. This avoids polluting the written JSON file with properties + /// the user most likely omitted when writing the original DAB runtime config file. + /// This Write operation is only used when a RuntimeConfig object is serialized to JSON. + /// + public override void Write(Utf8JsonWriter writer, AzureKeyVaultOptions value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + + if (value?.UserProvidedEndpoint is true) + { + writer.WritePropertyName("endpoint"); + JsonSerializer.Serialize(writer, value.Endpoint, options); + } + + if (value?.UserProvidedRetryPolicy is true) + { + writer.WritePropertyName("retry-policy"); + JsonSerializer.Serialize(writer, value.RetryPolicy, options); + } + + writer.WriteEndObject(); + } + } +} diff --git a/src/Config/Converters/AzureLogAnalyticsAuthOptionsConverter.cs b/src/Config/Converters/AzureLogAnalyticsAuthOptionsConverter.cs index 1428c0d75f..d4b7623aa2 100644 --- a/src/Config/Converters/AzureLogAnalyticsAuthOptionsConverter.cs +++ b/src/Config/Converters/AzureLogAnalyticsAuthOptionsConverter.cs @@ -9,15 +9,14 @@ namespace Azure.DataApiBuilder.Config.Converters; internal class AzureLogAnalyticsAuthOptionsConverter : JsonConverter { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; - /// Whether to replace environment variable with its - /// value or not while deserializing. - public AzureLogAnalyticsAuthOptionsConverter(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public AzureLogAnalyticsAuthOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings = null) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// @@ -48,7 +47,7 @@ public AzureLogAnalyticsAuthOptionsConverter(bool replaceEnvVar) case "custom-table-name": if (reader.TokenType is not JsonTokenType.Null) { - customTableName = reader.DeserializeString(_replaceEnvVar); + customTableName = reader.DeserializeString(_replacementSettings); } break; @@ -56,7 +55,7 @@ public AzureLogAnalyticsAuthOptionsConverter(bool replaceEnvVar) case "dcr-immutable-id": if (reader.TokenType is not JsonTokenType.Null) { - dcrImmutableId = reader.DeserializeString(_replaceEnvVar); + dcrImmutableId = reader.DeserializeString(_replacementSettings); } break; @@ -64,7 +63,7 @@ public AzureLogAnalyticsAuthOptionsConverter(bool replaceEnvVar) case "dce-endpoint": if (reader.TokenType is not JsonTokenType.Null) { - dceEndpoint = reader.DeserializeString(_replaceEnvVar); + dceEndpoint = reader.DeserializeString(_replacementSettings); } break; diff --git a/src/Config/Converters/AzureLogAnalyticsOptionsConverterFactory.cs b/src/Config/Converters/AzureLogAnalyticsOptionsConverterFactory.cs index 3fcbe8c7bd..608c8e79e3 100644 --- a/src/Config/Converters/AzureLogAnalyticsOptionsConverterFactory.cs +++ b/src/Config/Converters/AzureLogAnalyticsOptionsConverterFactory.cs @@ -12,9 +12,8 @@ namespace Azure.DataApiBuilder.Config.Converters; /// internal class AzureLogAnalyticsOptionsConverterFactory : JsonConverterFactory { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -25,27 +24,26 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new AzureLogAnalyticsOptionsConverter(_replaceEnvVar); + return new AzureLogAnalyticsOptionsConverter(_replacementSettings); } - /// Whether to replace environment variable with its - /// value or not while deserializing. - internal AzureLogAnalyticsOptionsConverterFactory(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + internal AzureLogAnalyticsOptionsConverterFactory(DeserializationVariableReplacementSettings? replacementSettings = null) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class AzureLogAnalyticsOptionsConverter : JsonConverter { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; - /// Whether to replace environment variable with its - /// value or not while deserializing. - internal AzureLogAnalyticsOptionsConverter(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + internal AzureLogAnalyticsOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// @@ -57,7 +55,7 @@ internal AzureLogAnalyticsOptionsConverter(bool replaceEnvVar) { if (reader.TokenType is JsonTokenType.StartObject) { - AzureLogAnalyticsAuthOptionsConverter authOptionsConverter = new(_replaceEnvVar); + AzureLogAnalyticsAuthOptionsConverter authOptionsConverter = new(_replacementSettings); bool? enabled = null; AzureLogAnalyticsAuthOptions? auth = null; @@ -91,7 +89,7 @@ internal AzureLogAnalyticsOptionsConverter(bool replaceEnvVar) case "dab-identifier": if (reader.TokenType is not JsonTokenType.Null) { - logType = reader.DeserializeString(_replaceEnvVar); + logType = reader.DeserializeString(_replacementSettings); } break; @@ -144,7 +142,7 @@ public override void Write(Utf8JsonWriter writer, AzureLogAnalyticsOptions value if (value?.Auth is not null && (value.Auth.UserProvidedCustomTableName || value.Auth.UserProvidedDcrImmutableId || value.Auth.UserProvidedDceEndpoint)) { AzureLogAnalyticsAuthOptionsConverter authOptionsConverter = options.GetConverter(typeof(AzureLogAnalyticsAuthOptions)) as AzureLogAnalyticsAuthOptionsConverter ?? - throw new JsonException("Failed to get azure-log-analytics.auth options converter"); + throw new JsonException("Failed to get azure-log-analytics.auth options converter"); writer.WritePropertyName("auth"); authOptionsConverter.Write(writer, value.Auth, options); diff --git a/src/Config/Converters/BooleanJsonConverter.cs b/src/Config/Converters/BooleanJsonConverter.cs new file mode 100644 index 0000000000..5932a70092 --- /dev/null +++ b/src/Config/Converters/BooleanJsonConverter.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// JSON converter for boolean values that also supports string representations such as +/// "true", "false", "1", and "0". Any environment variable replacement is handled by +/// other converters (for example, the string converter) before the value is parsed here. +/// +public class BoolJsonConverter : JsonConverter +{ + + public override bool Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType is JsonTokenType.Null) + { + + throw new JsonException("Unexpected null JSON token. Expected a boolean literal or a valid @expression."); + } + + if (reader.TokenType == JsonTokenType.String) + { + + string? tempBoolean = JsonSerializer.Deserialize(ref reader, options); + + bool result = tempBoolean?.ToLower() switch + { + //numeric values have to be checked here as they may come from string replacement + "true" or "1" => true, + "false" or "0" => false, + _ => throw new JsonException($"Invalid boolean value: {tempBoolean}. Specify either true or 1 for true, false or 0 for false"), + }; + + return result; + } + else if (reader.TokenType == JsonTokenType.Number) + { + bool result = reader.GetInt32() switch + { + 1 => true, + 0 => false, + _ => throw new JsonException($"Invalid value for boolean attribute. Specify either 1 or 0."), + }; + return result; + } + else + { + return reader.GetBoolean(); + } + } + + public override void Write(Utf8JsonWriter writer, bool value, JsonSerializerOptions options) + { + writer.WriteBooleanValue(value); + } +} diff --git a/src/Config/Converters/DataSourceConverterFactory.cs b/src/Config/Converters/DataSourceConverterFactory.cs index dabbee405e..1788ebf2b4 100644 --- a/src/Config/Converters/DataSourceConverterFactory.cs +++ b/src/Config/Converters/DataSourceConverterFactory.cs @@ -9,9 +9,8 @@ namespace Azure.DataApiBuilder.Config.Converters; internal class DataSourceConverterFactory : JsonConverterFactory { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -22,27 +21,26 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new DataSourceConverter(_replaceEnvVar); + return new DataSourceConverter(_replacementSettings); } - /// Whether to replace environment variable with its - /// value or not while deserializing. - internal DataSourceConverterFactory(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + internal DataSourceConverterFactory(DeserializationVariableReplacementSettings? replacementSettings = null) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class DataSourceConverter : JsonConverter { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; - /// Whether to replace environment variable with its - /// value or not while deserializing. - public DataSourceConverter(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public DataSourceConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } public override DataSource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -69,11 +67,11 @@ public DataSourceConverter(bool replaceEnvVar) switch (propertyName) { case "database-type": - databaseType = EnumExtensions.Deserialize(reader.DeserializeString(_replaceEnvVar)!); + databaseType = EnumExtensions.Deserialize(reader.DeserializeString(_replacementSettings)!); break; case "connection-string": - connectionString = reader.DeserializeString(replaceEnvVar: _replaceEnvVar)!; + connectionString = reader.DeserializeString(_replacementSettings)!; break; case "health": @@ -106,7 +104,7 @@ public DataSourceConverter(bool replaceEnvVar) if (reader.TokenType is JsonTokenType.String) { // Determine whether to resolve the environment variable or keep as-is. - string stringValue = reader.DeserializeString(replaceEnvVar: _replaceEnvVar)!; + string stringValue = reader.DeserializeString(_replacementSettings)!; if (bool.TryParse(stringValue, out bool boolValue)) { diff --git a/src/Config/Converters/DatasourceHealthOptionsConvertorFactory.cs b/src/Config/Converters/DatasourceHealthOptionsConvertorFactory.cs index 52272c57a7..d8286ff7a0 100644 --- a/src/Config/Converters/DatasourceHealthOptionsConvertorFactory.cs +++ b/src/Config/Converters/DatasourceHealthOptionsConvertorFactory.cs @@ -11,7 +11,7 @@ internal class DataSourceHealthOptionsConvertorFactory : JsonConverterFactory { // Determines whether to replace environment variable with its // value or not while deserializing. - private bool _replaceEnvVar; + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -22,27 +22,27 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new HealthCheckOptionsConverter(_replaceEnvVar); + return new HealthCheckOptionsConverter(_replacementSettings); } /// Whether to replace environment variable with its /// value or not while deserializing. - internal DataSourceHealthOptionsConvertorFactory(bool replaceEnvVar) + internal DataSourceHealthOptionsConvertorFactory(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class HealthCheckOptionsConverter : JsonConverter { // Determines whether to replace environment variable with its // value or not while deserializing. - private bool _replaceEnvVar; + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// Whether to replace environment variable with its /// value or not while deserializing. - public HealthCheckOptionsConverter(bool replaceEnvVar) + public HealthCheckOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// @@ -85,7 +85,7 @@ public HealthCheckOptionsConverter(bool replaceEnvVar) case "name": if (reader.TokenType is not JsonTokenType.Null) { - name = reader.DeserializeString(_replaceEnvVar); + name = reader.DeserializeString(_replacementSettings); } break; diff --git a/src/Config/Converters/DmlToolsConfigConverter.cs b/src/Config/Converters/DmlToolsConfigConverter.cs index 9acef0f9b2..82ac3f6069 100644 --- a/src/Config/Converters/DmlToolsConfigConverter.cs +++ b/src/Config/Converters/DmlToolsConfigConverter.cs @@ -37,7 +37,7 @@ internal class DmlToolsConfigConverter : JsonConverter // Handle object format if (reader.TokenType is JsonTokenType.StartObject) { - // When using object format, unspecified tools default to true + // Start with null values - only set when explicitly provided in JSON bool? describeEntities = null; bool? createRecord = null; bool? readRecords = null; @@ -102,8 +102,7 @@ internal class DmlToolsConfigConverter : JsonConverter } } - // Create the config with specified values - // Unspecified values (null) will default to true in the DmlToolsConfig constructor + // Pass null for unspecified values - the constructor will handle defaults return new DmlToolsConfig( allToolsEnabled: null, describeEntities: describeEntities, @@ -140,12 +139,12 @@ public override void Write(Utf8JsonWriter writer, DmlToolsConfig? value, JsonSer // Only write the boolean value if it's provided by user // This prevents writing "dml-tools": true when it's the default - if (!hasIndividualSettings && value.UserProvidedAllToolsEnabled) + if (!hasIndividualSettings && value.UserProvidedAllTools) { writer.WritePropertyName("dml-tools"); writer.WriteBooleanValue(value.AllToolsEnabled); } - else + else if (hasIndividualSettings) { writer.WritePropertyName("dml-tools"); diff --git a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs index 32a616ab81..641efd062f 100644 --- a/src/Config/Converters/EntityCacheOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityCacheOptionsConverterFactory.cs @@ -14,7 +14,7 @@ internal class EntityCacheOptionsConverterFactory : JsonConverterFactory { // Determines whether to replace environment variable with its // value or not while deserializing. - private bool _replaceEnvVar; + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -25,27 +25,25 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new EntityCacheOptionsConverter(_replaceEnvVar); + return new EntityCacheOptionsConverter(_replacementSettings); } - /// Whether to replace environment variable with its - /// value or not while deserializing. - internal EntityCacheOptionsConverterFactory(bool replaceEnvVar) + /// The replacement settings to use while deserializing. + internal EntityCacheOptionsConverterFactory(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class EntityCacheOptionsConverter : JsonConverter { // Determines whether to replace environment variable with its // value or not while deserializing. - private bool _replaceEnvVar; + private readonly DeserializationVariableReplacementSettings? _replacementSettings; - /// Whether to replace environment variable with its - /// value or not while deserializing. - public EntityCacheOptionsConverter(bool replaceEnvVar) + /// The replacement settings to use while deserializing. + public EntityCacheOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// @@ -110,7 +108,7 @@ public EntityCacheOptionsConverter(bool replaceEnvVar) throw new JsonException("level property cannot be null."); } - level = EnumExtensions.Deserialize(reader.DeserializeString(_replaceEnvVar)!); + level = EnumExtensions.Deserialize(reader.DeserializeString(_replacementSettings)!); break; } diff --git a/src/Config/Converters/EntityGraphQLOptionsConverterFactory.cs b/src/Config/Converters/EntityGraphQLOptionsConverterFactory.cs index 576850b1cb..abe094e970 100644 --- a/src/Config/Converters/EntityGraphQLOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityGraphQLOptionsConverterFactory.cs @@ -9,9 +9,8 @@ namespace Azure.DataApiBuilder.Config.Converters; internal class EntityGraphQLOptionsConverterFactory : JsonConverterFactory { - /// Determines whether to replace environment variable with its - /// value or not while deserializing. - private bool _replaceEnvVar; + /// Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -22,27 +21,26 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new EntityGraphQLOptionsConverter(_replaceEnvVar); + return new EntityGraphQLOptionsConverter(_replacementSettings); } - /// Whether to replace environment variable with its - /// value or not while deserializing. - internal EntityGraphQLOptionsConverterFactory(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + internal EntityGraphQLOptionsConverterFactory(DeserializationVariableReplacementSettings? replacementSettings = null) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class EntityGraphQLOptionsConverter : JsonConverter { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; - /// Whether to replace environment variable with its - /// value or not while deserializing. - public EntityGraphQLOptionsConverter(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public EntityGraphQLOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// @@ -73,7 +71,7 @@ public EntityGraphQLOptionsConverter(bool replaceEnvVar) case "type": if (reader.TokenType is JsonTokenType.String) { - singular = reader.DeserializeString(_replaceEnvVar) ?? string.Empty; + singular = reader.DeserializeString(_replacementSettings) ?? string.Empty; } else if (reader.TokenType is JsonTokenType.StartObject) { @@ -95,10 +93,10 @@ public EntityGraphQLOptionsConverter(bool replaceEnvVar) switch (property2) { case "singular": - singular = reader.DeserializeString(_replaceEnvVar) ?? string.Empty; + singular = reader.DeserializeString(_replacementSettings) ?? string.Empty; break; case "plural": - plural = reader.DeserializeString(_replaceEnvVar) ?? string.Empty; + plural = reader.DeserializeString(_replacementSettings) ?? string.Empty; break; } } @@ -112,7 +110,7 @@ public EntityGraphQLOptionsConverter(bool replaceEnvVar) break; case "operation": - string? op = reader.DeserializeString(_replaceEnvVar); + string? op = reader.DeserializeString(_replacementSettings); if (op is not null) { @@ -136,7 +134,7 @@ public EntityGraphQLOptionsConverter(bool replaceEnvVar) if (reader.TokenType is JsonTokenType.String) { - string? singular = reader.DeserializeString(_replaceEnvVar); + string? singular = reader.DeserializeString(_replacementSettings); return new EntityGraphQLOptions(singular ?? string.Empty, string.Empty); } diff --git a/src/Config/Converters/EntityMcpOptionsConverterFactory.cs b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs new file mode 100644 index 0000000000..b4ad0e9170 --- /dev/null +++ b/src/Config/Converters/EntityMcpOptionsConverterFactory.cs @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// Factory for creating EntityMcpOptions converters. +/// +internal class EntityMcpOptionsConverterFactory : JsonConverterFactory +{ + public override bool CanConvert(Type typeToConvert) + { + return typeToConvert == typeof(EntityMcpOptions); + } + + public override JsonConverter CreateConverter(Type typeToConvert, JsonSerializerOptions options) + { + return new EntityMcpOptionsConverter(); + } + + /// + /// Converter for EntityMcpOptions that handles both boolean and object representations. + /// When boolean: true enables dml-tools and custom-tool remains false (default), false disables dml-tools and custom-tool remains false. + /// When object: can specify individual properties (custom-tool and dml-tools). + /// + private class EntityMcpOptionsConverter : JsonConverter + { + public override EntityMcpOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + + // Handle boolean shorthand: true/false + if (reader.TokenType == JsonTokenType.True || reader.TokenType == JsonTokenType.False) + { + bool value = reader.GetBoolean(); + // Boolean true means: dml-tools=true, custom-tool=false (default) + // Boolean false means: dml-tools=false, custom-tool=false + // Pass null for customToolEnabled to keep it as default (not user-provided) + return new EntityMcpOptions( + customToolEnabled: null, + dmlToolsEnabled: value + ); + } + + // Handle object representation + if (reader.TokenType == JsonTokenType.StartObject) + { + bool? customToolEnabled = null; + bool? dmlToolsEnabled = null; + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + return new EntityMcpOptions(customToolEnabled, dmlToolsEnabled); + } + + if (reader.TokenType == JsonTokenType.PropertyName) + { + string? propertyName = reader.GetString(); + reader.Read(); // Move to the value + + switch (propertyName) + { + case "custom-tool": + customToolEnabled = reader.GetBoolean(); + break; + case "dml-tools": + dmlToolsEnabled = reader.GetBoolean(); + break; + default: + throw new JsonException($"Unknown property '{propertyName}' in EntityMcpOptions"); + } + } + } + } + + throw new JsonException($"Unexpected token type {reader.TokenType} for EntityMcpOptions"); + } + + public override void Write(Utf8JsonWriter writer, EntityMcpOptions value, JsonSerializerOptions options) + { + if (value == null) + { + return; + } + + // Check if we should write as boolean shorthand + // Write as boolean if: only dml-tools is set (or custom-tool is default false) + bool writeAsBoolean = !value.UserProvidedCustomToolEnabled && value.UserProvidedDmlToolsEnabled; + + if (writeAsBoolean) + { + // Write as boolean shorthand + writer.WriteBooleanValue(value.DmlToolEnabled); + } + else if (value.UserProvidedCustomToolEnabled || value.UserProvidedDmlToolsEnabled) + { + // Write as object + writer.WriteStartObject(); + + if (value.UserProvidedCustomToolEnabled) + { + writer.WriteBoolean("custom-tool", value.CustomToolEnabled); + } + + if (value.UserProvidedDmlToolsEnabled) + { + writer.WriteBoolean("dml-tools", value.DmlToolEnabled); + } + + writer.WriteEndObject(); + } + } + } +} diff --git a/src/Config/Converters/EntityRestOptionsConverterFactory.cs b/src/Config/Converters/EntityRestOptionsConverterFactory.cs index cc33943caa..7f8fc87a05 100644 --- a/src/Config/Converters/EntityRestOptionsConverterFactory.cs +++ b/src/Config/Converters/EntityRestOptionsConverterFactory.cs @@ -9,9 +9,8 @@ namespace Azure.DataApiBuilder.Config.Converters; internal class EntityRestOptionsConverterFactory : JsonConverterFactory { - /// Determines whether to replace environment variable with its - /// value or not while deserializing. - private bool _replaceEnvVar; + /// Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -22,27 +21,26 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new EntityRestOptionsConverter(_replaceEnvVar); + return new EntityRestOptionsConverter(_replacementSettings); } - /// Whether to replace environment variable with its - /// value or not while deserializing. - internal EntityRestOptionsConverterFactory(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + internal EntityRestOptionsConverterFactory(DeserializationVariableReplacementSettings? replacementSettings = null) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } internal class EntityRestOptionsConverter : JsonConverter { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; - /// Whether to replace environment variable with its - /// value or not while deserializing. - public EntityRestOptionsConverter(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public EntityRestOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// @@ -67,7 +65,7 @@ public EntityRestOptionsConverter(bool replaceEnvVar) if (reader.TokenType is JsonTokenType.String || reader.TokenType is JsonTokenType.Null) { - restOptions = restOptions with { Path = reader.DeserializeString(_replaceEnvVar) }; + restOptions = restOptions with { Path = reader.DeserializeString(_replacementSettings) }; break; } @@ -87,11 +85,12 @@ public EntityRestOptionsConverter(bool replaceEnvVar) break; } - methods.Add(EnumExtensions.Deserialize(reader.DeserializeString(replaceEnvVar: true)!)); + methods.Add(EnumExtensions.Deserialize(reader.DeserializeString(new DeserializationVariableReplacementSettings())!)); } restOptions = restOptions with { Methods = methods.ToArray() }; break; + case "enabled": reader.Read(); restOptions = restOptions with { Enabled = reader.GetBoolean() }; @@ -107,7 +106,7 @@ public EntityRestOptionsConverter(bool replaceEnvVar) if (reader.TokenType is JsonTokenType.String) { - return new EntityRestOptions(Array.Empty(), reader.DeserializeString(_replaceEnvVar), true); + return new EntityRestOptions(Array.Empty(), reader.DeserializeString(_replacementSettings), true); } if (reader.TokenType is JsonTokenType.True || reader.TokenType is JsonTokenType.False) diff --git a/src/Config/Converters/EntitySourceConverterFactory.cs b/src/Config/Converters/EntitySourceConverterFactory.cs index a748382e01..2edafe31e1 100644 --- a/src/Config/Converters/EntitySourceConverterFactory.cs +++ b/src/Config/Converters/EntitySourceConverterFactory.cs @@ -9,9 +9,8 @@ namespace Azure.DataApiBuilder.Config.Converters; internal class EntitySourceConverterFactory : JsonConverterFactory { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -22,34 +21,33 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new EntitySourceConverter(_replaceEnvVar); + return new EntitySourceConverter(_replacementSettings); } - /// Whether to replace environment variable with its - /// value or not while deserializing. - internal EntitySourceConverterFactory(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + internal EntitySourceConverterFactory(DeserializationVariableReplacementSettings? replacementSettings = null) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class EntitySourceConverter : JsonConverter { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; - /// Whether to replace environment variable with its - /// value or not while deserializing. - public EntitySourceConverter(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public EntitySourceConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } public override EntitySource? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { if (reader.TokenType == JsonTokenType.String) { - string? obj = reader.DeserializeString(_replaceEnvVar); + string? obj = reader.DeserializeString(_replacementSettings); return new EntitySource(obj ?? string.Empty, EntitySourceType.Table, new(), Array.Empty()); } diff --git a/src/Config/Converters/EnumMemberJsonEnumConverterFactory.cs b/src/Config/Converters/EnumMemberJsonEnumConverterFactory.cs index 1d6dd9f7c4..4455a474e1 100644 --- a/src/Config/Converters/EnumMemberJsonEnumConverterFactory.cs +++ b/src/Config/Converters/EnumMemberJsonEnumConverterFactory.cs @@ -114,7 +114,7 @@ public JsonStringEnumConverterEx() public override TEnum Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { // Always replace env variable in case of Enum otherwise string to enum conversion will fail. - string? stringValue = reader.DeserializeString(replaceEnvVar: true); + string? stringValue = reader.DeserializeString(new(doReplaceEnvVar: true)); if (stringValue == null) { diff --git a/src/Config/Converters/FileSinkConverter.cs b/src/Config/Converters/FileSinkConverter.cs index cc7d138a1b..4299fb913b 100644 --- a/src/Config/Converters/FileSinkConverter.cs +++ b/src/Config/Converters/FileSinkConverter.cs @@ -7,18 +7,17 @@ using Serilog; namespace Azure.DataApiBuilder.Config.Converters; + class FileSinkConverter : JsonConverter { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; - - /// - /// Whether to replace environment variable with its value or not while deserializing. - /// - public FileSinkConverter(bool replaceEnvVar) + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; + + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + public FileSinkConverter(DeserializationVariableReplacementSettings? replacementSettings = null) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// @@ -59,7 +58,7 @@ public FileSinkConverter(bool replaceEnvVar) case "path": if (reader.TokenType is not JsonTokenType.Null) { - path = reader.DeserializeString(_replaceEnvVar); + path = reader.DeserializeString(_replacementSettings); } break; @@ -67,7 +66,7 @@ public FileSinkConverter(bool replaceEnvVar) case "rolling-interval": if (reader.TokenType is not JsonTokenType.Null) { - rollingInterval = EnumExtensions.Deserialize(reader.DeserializeString(_replaceEnvVar)!); + rollingInterval = EnumExtensions.Deserialize(reader.DeserializeString(_replacementSettings)!); } break; diff --git a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs index 082c982e7e..109caef0d5 100644 --- a/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/GraphQLRuntimeOptionsConverterFactory.cs @@ -9,9 +9,8 @@ namespace Azure.DataApiBuilder.Config.Converters; internal class GraphQLRuntimeOptionsConverterFactory : JsonConverterFactory { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -22,25 +21,26 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new GraphQLRuntimeOptionsConverter(_replaceEnvVar); + return new GraphQLRuntimeOptionsConverter(_replacementSettings); } - internal GraphQLRuntimeOptionsConverterFactory(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + internal GraphQLRuntimeOptionsConverterFactory(DeserializationVariableReplacementSettings? replacementSettings = null) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class GraphQLRuntimeOptionsConverter : JsonConverter { - // Determines whether to replace environment variable with its - // value or not while deserializing. - private bool _replaceEnvVar; + // Settings for variable replacement during deserialization. + private readonly DeserializationVariableReplacementSettings? _replacementSettings; - /// Whether to replace environment variable with its - /// value or not while deserializing. - internal GraphQLRuntimeOptionsConverter(bool replaceEnvVar) + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. + internal GraphQLRuntimeOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } public override GraphQLRuntimeOptions? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -117,7 +117,7 @@ internal GraphQLRuntimeOptionsConverter(bool replaceEnvVar) case "path": if (reader.TokenType is JsonTokenType.String) { - string? path = reader.DeserializeString(_replaceEnvVar); + string? path = reader.DeserializeString(_replacementSettings); if (path is null) { path = "/graphql"; diff --git a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs index db9acfa603..8b3c640725 100644 --- a/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs +++ b/src/Config/Converters/McpRuntimeOptionsConverterFactory.cs @@ -14,7 +14,7 @@ internal class McpRuntimeOptionsConverterFactory : JsonConverterFactory { // Determines whether to replace environment variable with its // value or not while deserializing. - private bool _replaceEnvVar; + private DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -25,25 +25,25 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new McpRuntimeOptionsConverter(_replaceEnvVar); + return new McpRuntimeOptionsConverter(_replacementSettings); } - internal McpRuntimeOptionsConverterFactory(bool replaceEnvVar) + internal McpRuntimeOptionsConverterFactory(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class McpRuntimeOptionsConverter : JsonConverter { // Determines whether to replace environment variable with its // value or not while deserializing. - private bool _replaceEnvVar; + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// Whether to replace environment variable with its /// value or not while deserializing. - internal McpRuntimeOptionsConverter(bool replaceEnvVar) + internal McpRuntimeOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// @@ -65,12 +65,13 @@ internal McpRuntimeOptionsConverter(bool replaceEnvVar) bool enabled = true; string? path = null; DmlToolsConfig? dmlTools = null; + string? description = null; while (reader.Read()) { if (reader.TokenType == JsonTokenType.EndObject) { - return new McpRuntimeOptions(enabled, path, dmlTools); + return new McpRuntimeOptions(enabled, path, dmlTools, description); } string? propertyName = reader.GetString(); @@ -89,7 +90,7 @@ internal McpRuntimeOptionsConverter(bool replaceEnvVar) case "path": if (reader.TokenType is not JsonTokenType.Null) { - path = reader.DeserializeString(_replaceEnvVar); + path = reader.DeserializeString(_replacementSettings); } break; @@ -98,6 +99,14 @@ internal McpRuntimeOptionsConverter(bool replaceEnvVar) dmlTools = dmlToolsConfigConverter.Read(ref reader, typeToConvert, options); break; + case "description": + if (reader.TokenType is not JsonTokenType.Null) + { + description = reader.DeserializeString(_replacementSettings); + } + + break; + default: throw new JsonException($"Unexpected property {propertyName}"); } @@ -134,6 +143,13 @@ public override void Write(Utf8JsonWriter writer, McpRuntimeOptions value, JsonS dmlToolsOptionsConverter.Write(writer, value.DmlTools, options); } + // Write description if it's provided + if (value is not null && !string.IsNullOrWhiteSpace(value.Description)) + { + writer.WritePropertyName("description"); + JsonSerializer.Serialize(writer, value.Description, options); + } + writer.WriteEndObject(); } } diff --git a/src/Config/Converters/RuntimeAutoentitiesConverter.cs b/src/Config/Converters/RuntimeAutoentitiesConverter.cs new file mode 100644 index 0000000000..b65bcb9989 --- /dev/null +++ b/src/Config/Converters/RuntimeAutoentitiesConverter.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.ObjectModel; +using System.Text.Json; +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.ObjectModel; + +namespace Azure.DataApiBuilder.Config.Converters; + +/// +/// This converter is used to convert all the autoentities defined in the configuration file +/// each into a object. The resulting collection is then wrapped in the +/// object. +/// +class RuntimeAutoentitiesConverter : JsonConverter +{ + /// + public override RuntimeAutoentities? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + Dictionary autoEntities = + JsonSerializer.Deserialize>(ref reader, options) ?? + throw new JsonException("Failed to read autoentities"); + + return new RuntimeAutoentities(new ReadOnlyDictionary(autoEntities)); + } + + /// + public override void Write(Utf8JsonWriter writer, RuntimeAutoentities value, JsonSerializerOptions options) + { + writer.WriteStartObject(); + foreach ((string key, Autoentity autoEntity) in value.AutoEntities) + { + writer.WritePropertyName(key); + JsonSerializer.Serialize(writer, autoEntity, options); + } + + writer.WriteEndObject(); + } +} diff --git a/src/Config/Converters/RuntimeHealthOptionsConvertorFactory.cs b/src/Config/Converters/RuntimeHealthOptionsConvertorFactory.cs index d49cc264e7..9c5f46dce2 100644 --- a/src/Config/Converters/RuntimeHealthOptionsConvertorFactory.cs +++ b/src/Config/Converters/RuntimeHealthOptionsConvertorFactory.cs @@ -11,7 +11,7 @@ internal class RuntimeHealthOptionsConvertorFactory : JsonConverterFactory { // Determines whether to replace environment variable with its // value or not while deserializing. - private bool _replaceEnvVar; + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// public override bool CanConvert(Type typeToConvert) @@ -22,25 +22,25 @@ public override bool CanConvert(Type typeToConvert) /// public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new HealthCheckOptionsConverter(_replaceEnvVar); + return new HealthCheckOptionsConverter(_replacementSettings); } - internal RuntimeHealthOptionsConvertorFactory(bool replaceEnvVar) + internal RuntimeHealthOptionsConvertorFactory(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } private class HealthCheckOptionsConverter : JsonConverter { // Determines whether to replace environment variable with its // value or not while deserializing. - private bool _replaceEnvVar; + private readonly DeserializationVariableReplacementSettings? _replacementSettings; /// Whether to replace environment variable with its /// value or not while deserializing. - internal HealthCheckOptionsConverter(bool replaceEnvVar) + internal HealthCheckOptionsConverter(DeserializationVariableReplacementSettings? replacementSettings) { - _replaceEnvVar = replaceEnvVar; + _replacementSettings = replacementSettings; } /// @@ -102,7 +102,7 @@ internal HealthCheckOptionsConverter(bool replaceEnvVar) { if (reader.TokenType == JsonTokenType.String) { - string? currentRole = reader.DeserializeString(_replaceEnvVar); + string? currentRole = reader.DeserializeString(_replacementSettings); if (!string.IsNullOrEmpty(currentRole)) { stringList.Add(currentRole); diff --git a/src/Config/Converters/StringJsonConverterFactory.cs b/src/Config/Converters/StringJsonConverterFactory.cs index 078b611789..c3f5333237 100644 --- a/src/Config/Converters/StringJsonConverterFactory.cs +++ b/src/Config/Converters/StringJsonConverterFactory.cs @@ -4,21 +4,20 @@ using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; -using Azure.DataApiBuilder.Service.Exceptions; namespace Azure.DataApiBuilder.Config.Converters; /// -/// Custom string json converter factory to replace environment variables of the pattern -/// @env('ENV_NAME') with their value during deserialization. +/// Custom string json converter factory to replace environment variables and other variable patterns +/// during deserialization using the DeserializationVariableReplacementSettings. /// public class StringJsonConverterFactory : JsonConverterFactory { - private EnvironmentVariableReplacementFailureMode _replacementFailureMode; + private readonly DeserializationVariableReplacementSettings _replacementSettings; - public StringJsonConverterFactory(EnvironmentVariableReplacementFailureMode replacementFailureMode) + public StringJsonConverterFactory(DeserializationVariableReplacementSettings replacementSettings) { - _replacementFailureMode = replacementFailureMode; + _replacementSettings = replacementSettings; } public override bool CanConvert(Type typeToConvert) @@ -28,32 +27,16 @@ public override bool CanConvert(Type typeToConvert) public override JsonConverter? CreateConverter(Type typeToConvert, JsonSerializerOptions options) { - return new StringJsonConverter(_replacementFailureMode); + return new StringJsonConverter(_replacementSettings); } class StringJsonConverter : JsonConverter { - // @env\(' : match @env(' - // .*? : lazy match any character except newline 0 or more times - // (?='\)) : look ahead for ') which will combine with our lazy match - // ie: in @env('hello')goodbye') we match @env('hello') - // '\) : consume the ') into the match (look ahead doesn't capture) - // This pattern lazy matches any string that starts with @env(' and ends with ') - // ie: fooBAR@env('hello-world')bash)FOO') match: @env('hello-world') - // This matching pattern allows for the @env('') to be safely nested - // within strings that contain ') after our match. - // ie: if the environment variable "Baz" has the value of "Bar" - // fooBarBaz: "('foo@env('Baz')Baz')" would parse into - // fooBarBaz: "('fooBarBaz')" - // Note that there is no escape character currently for ') to exist - // within the name of the environment variable, but that ') is not - // a valid environment variable name in certain shells. - const string ENV_PATTERN = @"@env\('.*?(?='\))'\)"; - private EnvironmentVariableReplacementFailureMode _replacementFailureMode; + private DeserializationVariableReplacementSettings _replacementSettings; - public StringJsonConverter(EnvironmentVariableReplacementFailureMode replacementFailureMode) + public StringJsonConverter(DeserializationVariableReplacementSettings replacementSettings) { - _replacementFailureMode = replacementFailureMode; + _replacementSettings = replacementSettings; } public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) @@ -61,7 +44,18 @@ public StringJsonConverter(EnvironmentVariableReplacementFailureMode replacement if (reader.TokenType == JsonTokenType.String) { string? value = reader.GetString(); - return Regex.Replace(value!, ENV_PATTERN, new MatchEvaluator(ReplaceMatchWithEnvVariable)); + if (string.IsNullOrWhiteSpace(value)) + { + return value; + } + + // Apply all replacement strategies configured in the settings + foreach (KeyValuePair> strategy in _replacementSettings.ReplacementStrategies) + { + value = strategy.Key.Replace(value, new MatchEvaluator(strategy.Value)); + } + + return value; } if (reader.TokenType == JsonTokenType.Null) @@ -76,30 +70,5 @@ public override void Write(Utf8JsonWriter writer, string value, JsonSerializerOp { writer.WriteStringValue(value); } - - private string ReplaceMatchWithEnvVariable(Match match) - { - // [^@env\(] : any substring that is not @env( - // .* : any char except newline any number of times - // (?=\)) : look ahead for end char of ) - // This pattern greedy matches all characters that are not a part of @env() - // ie: @env('hello@env('goodbye')world') match: 'hello@env('goodbye')world' - string innerPattern = @"[^@env\(].*(?=\))"; - - // strips first and last characters, ie: '''hello'' --> ''hello' - string envName = Regex.Match(match.Value, innerPattern).Value[1..^1]; - string? envValue = Environment.GetEnvironmentVariable(envName); - if (_replacementFailureMode == EnvironmentVariableReplacementFailureMode.Throw) - { - return envValue is not null ? envValue : - throw new DataApiBuilderException(message: $"Environmental Variable, {envName}, not found.", - statusCode: System.Net.HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); - } - else - { - return envValue ?? match.Value; - } - } } } diff --git a/src/Config/Converters/Utf8JsonReaderExtensions.cs b/src/Config/Converters/Utf8JsonReaderExtensions.cs index 20c6821d02..5e16357227 100644 --- a/src/Config/Converters/Utf8JsonReaderExtensions.cs +++ b/src/Config/Converters/Utf8JsonReaderExtensions.cs @@ -13,14 +13,12 @@ static internal class Utf8JsonReaderExtensions /// substitution is applied. /// /// The reader that we want to pull the string from. - /// Whether to replace environment variable with its - /// value or not while deserializing. + /// The replacement settings to use while deserializing. /// The failure mode to use when replacing environment variables. /// The result of deserialization. /// Thrown if the is not String. public static string? DeserializeString(this Utf8JsonReader reader, - bool replaceEnvVar, - EnvironmentVariableReplacementFailureMode replacementFailureMode = EnvironmentVariableReplacementFailureMode.Throw) + DeserializationVariableReplacementSettings? replacementSettings) { if (reader.TokenType is JsonTokenType.Null) { @@ -34,9 +32,9 @@ static internal class Utf8JsonReaderExtensions // Add the StringConverterFactory so that we can do environment variable substitution. JsonSerializerOptions options = new(); - if (replaceEnvVar) + if (replacementSettings is not null) { - options.Converters.Add(new StringJsonConverterFactory(replacementFailureMode)); + options.Converters.Add(new StringJsonConverterFactory(replacementSettings)); } return JsonSerializer.Deserialize(ref reader, options); diff --git a/src/Config/DeserializationVariableReplacementSettings.cs b/src/Config/DeserializationVariableReplacementSettings.cs new file mode 100644 index 0000000000..350824409b --- /dev/null +++ b/src/Config/DeserializationVariableReplacementSettings.cs @@ -0,0 +1,288 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.RegularExpressions; +using Azure.Core; +using Azure.DataApiBuilder.Config.Converters; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Service.Exceptions; +using Azure.Identity; +using Azure.Security.KeyVault.Secrets; +using Microsoft.Extensions.Logging; + +namespace Azure.DataApiBuilder.Config +{ + public class DeserializationVariableReplacementSettings + { + public bool DoReplaceEnvVar { get; set; } + public bool DoReplaceAkvVar { get; set; } + public EnvironmentVariableReplacementFailureMode EnvFailureMode { get; set; } = EnvironmentVariableReplacementFailureMode.Throw; + + // @env\(' : match @env(' + // @akv\(' : match @akv(' + // .*? : lazy match any character except newline 0 or more times + // (?='\)) : look ahead for ')' which will combine with our lazy match + // ie: in @env('hello')goodbye') we match @env('hello') + // '\) : consume the ') into the match (look ahead doesn't capture) + // This pattern lazy matches any string that starts with @env(' and ends with ') OR @akv(' and ends with ') + // Example: fooBAR@env('hello-world')bash)FOO') match: @env('hello-world') + // Example: fooBAR@akv('secret-name')bash)FOO') match: @akv('secret-name') + // This matching pattern allows for the @env('') / @akv('') to be safely nested + // within strings that contain ')' after our match. + // Note that there is no escape character currently for ')' to exist within the name of the variable. + public const string OUTER_ENV_PATTERN = @"@env\('.*?(?='\))'\)"; + public const string OUTER_AKV_PATTERN = @"@akv\('.*?(?='\))'\)"; + + // [^@env\(] : any substring that is not @env( + // [^@akv\(] : any substring that is not @akv( + // .* : any char except newline any number of times + // (?=\)) : look ahead for end char of ) + // This pattern greedy matches all characters that are not a part of @env() / @akv() + // ie: @env('hello@env('goodbye')world') match: 'hello@env('goodbye')world' + public const string INNER_ENV_PATTERN = @"[^@env\(].*(?=\))"; + public const string INNER_AKV_PATTERN = @"[^@akv\(].*(?=\))"; + + private readonly AzureKeyVaultOptions? _azureKeyVaultOptions; + private readonly SecretClient? _akvClient; + private readonly Dictionary? _akvFileSecrets; + private readonly ILogger? _logger; + + public Dictionary> ReplacementStrategies { get; private set; } = new(); + + public DeserializationVariableReplacementSettings( + AzureKeyVaultOptions? azureKeyVaultOptions = null, + bool doReplaceEnvVar = false, + bool doReplaceAkvVar = false, + EnvironmentVariableReplacementFailureMode envFailureMode = EnvironmentVariableReplacementFailureMode.Throw, + ILogger? logger = null) + { + _azureKeyVaultOptions = azureKeyVaultOptions; + DoReplaceEnvVar = doReplaceEnvVar; + DoReplaceAkvVar = doReplaceAkvVar; + EnvFailureMode = envFailureMode; + _logger = logger; + + if (DoReplaceEnvVar) + { + ReplacementStrategies.Add( + new Regex(OUTER_ENV_PATTERN, RegexOptions.Compiled), + ReplaceEnvVariable); + } + + if (DoReplaceAkvVar && _azureKeyVaultOptions is not null) + { + // Determine if endpoint points to a local .akv file. If so, load secrets from file; otherwise, use remote AKV. + if (IsLocalAkvFileEndpoint(_azureKeyVaultOptions.Endpoint)) + { + _akvFileSecrets = LoadAkvFileSecrets(_azureKeyVaultOptions.Endpoint!, _logger); + } + else + { + _akvClient = CreateSecretClient(_azureKeyVaultOptions); + } + + ReplacementStrategies.Add( + new Regex(OUTER_AKV_PATTERN, RegexOptions.Compiled), + ReplaceAkvVariable); + } + } + + // Checks if the endpoint is a path to a local .akv file. + private static bool IsLocalAkvFileEndpoint(string? endpoint) + => !string.IsNullOrWhiteSpace(endpoint) + && endpoint.EndsWith(".akv", StringComparison.OrdinalIgnoreCase) + && File.Exists(endpoint); + + // Loads key=value pairs from a .akv file, similar to .env style. Lines starting with '#' are comments. + private static Dictionary LoadAkvFileSecrets(string filePath, ILogger? logger = null) + { + Dictionary secrets = new(StringComparer.OrdinalIgnoreCase); + foreach (string rawLine in File.ReadAllLines(filePath)) + { + string line = rawLine.Trim(); + if (string.IsNullOrEmpty(line) || line.StartsWith('#')) + { + continue; + } + + int eqIndex = line.IndexOf('='); + if (eqIndex <= 0) + { + logger?.LogDebug("Ignoring malformed line in AKV secrets file {FilePath}: {Line}", filePath, rawLine); + continue; + } + + string key = line.Substring(0, eqIndex).Trim(); + string value = line[(eqIndex + 1)..].Trim(); + + // Remove optional surrounding quotes + if (value.Length >= 2 && ((value.StartsWith('"') && value.EndsWith('"')) || (value.StartsWith('\'') && value.EndsWith('\'')))) + { + value = value[1..^1]; + } + + if (!string.IsNullOrEmpty(key)) + { + if (!secrets.TryAdd(key, value)) + { + logger?.LogDebug("Duplicate key '{Key}' encountered in AKV secrets file {FilePath}. Skipping later value.", key, filePath); + } + } + } + + return secrets; + } + + private string ReplaceEnvVariable(Match match) + { + // strips first and last characters, ie: '''hello'' --> ''hello' + string name = Regex.Match(match.Value, INNER_ENV_PATTERN).Value[1..^1]; + string? value = Environment.GetEnvironmentVariable(name); + if (EnvFailureMode is EnvironmentVariableReplacementFailureMode.Throw) + { + return value is not null ? value : + throw new DataApiBuilderException( + message: $"Environmental Variable, {name}, not found.", + statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + else + { + return value ?? match.Value; + } + } + + private string ReplaceAkvVariable(Match match) + { + // strips first and last characters, ie: '''hello'' --> ''hello' + string name = Regex.Match(match.Value, INNER_AKV_PATTERN).Value[1..^1]; + + // Validate AKV secret name per rules: + // Allowed: alphanumeric and hyphen (-) + // Disallowed: spaces or any other symbols + // Must start and end with alphanumeric + // Length: 1 to 127 chars + if (!IsValidAkvSecretName(name, out string validationError)) + { + throw new DataApiBuilderException( + message: $"Azure Key Vault secret name '{name}' is invalid. {validationError} Requirements: allowed characters are alphanumeric and hyphen (-); must start and end with an alphanumeric character; length 1-127 characters; case-insensitive.", + statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + + string? value = GetAkvVariable(name); + if (EnvFailureMode == EnvironmentVariableReplacementFailureMode.Throw) + { + return value is not null ? value : + throw new DataApiBuilderException(message: $"Azure Key Vault Variable, '{name}', not found.", + statusCode: System.Net.HttpStatusCode.ServiceUnavailable, + subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + else + { + return value ?? match.Value; + } + } + + private static bool IsValidAkvSecretName(string name, out string error) + { + error = string.Empty; + if (string.IsNullOrEmpty(name)) + { + error = "Name cannot be null or empty."; + return false; + } + + if (name.Length < 1 || name.Length > 127) + { + error = $"Length {name.Length} is outside allowed range (1-127)."; + return false; + } + + // Must start and end with alphanumeric + if (!char.IsLetterOrDigit(name[0]) || !char.IsLetterOrDigit(name[^1])) + { + error = "Must start and end with an alphanumeric character."; + return false; + } + + // Allowed characters: letters, digits, hyphen. + for (int i = 0; i < name.Length; i++) + { + char c = name[i]; + if (!(char.IsLetterOrDigit(c) || c == '-')) + { + error = $"Invalid character '{c}' at position {i}."; + return false; + } + } + + return true; + } + + private static SecretClient CreateSecretClient(AzureKeyVaultOptions options) + { + if (string.IsNullOrWhiteSpace(options.Endpoint)) + { + throw new DataApiBuilderException( + "Missing 'endpoint' property is required to connect to Azure Key Vault.", + System.Net.HttpStatusCode.InternalServerError, + DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + + // If endpoint is a local .akv file, we should not create a SecretClient. + if (IsLocalAkvFileEndpoint(options.Endpoint)) + { + throw new DataApiBuilderException( + "Attempted to create Azure Key Vault client for local .akv file endpoint.", + System.Net.HttpStatusCode.InternalServerError, + DataApiBuilderException.SubStatusCodes.ErrorInInitialization); + } + + SecretClientOptions clientOptions = new(); + + if (options.RetryPolicy is not null) + { + // Convert AKVRetryPolicyMode to RetryMode + RetryMode retryMode = options.RetryPolicy.Mode switch + { + AKVRetryPolicyMode.Fixed => RetryMode.Fixed, + AKVRetryPolicyMode.Exponential => RetryMode.Exponential, + null => RetryMode.Exponential, + _ => RetryMode.Exponential + }; + + clientOptions.Retry.Mode = retryMode; + clientOptions.Retry.MaxRetries = options.RetryPolicy.MaxCount ?? AKVRetryPolicyOptions.DEFAULT_MAX_COUNT; + clientOptions.Retry.Delay = TimeSpan.FromSeconds(options.RetryPolicy.DelaySeconds ?? AKVRetryPolicyOptions.DEFAULT_DELAY_SECONDS); + clientOptions.Retry.MaxDelay = TimeSpan.FromSeconds(options.RetryPolicy.MaxDelaySeconds ?? AKVRetryPolicyOptions.DEFAULT_MAX_DELAY_SECONDS); + clientOptions.Retry.NetworkTimeout = TimeSpan.FromSeconds(options.RetryPolicy.NetworkTimeoutSeconds ?? AKVRetryPolicyOptions.DEFAULT_NETWORK_TIMEOUT_SECONDS); + } + + return new SecretClient(new Uri(options.Endpoint), new DefaultAzureCredential(), clientOptions); // CodeQL [SM05137] DefaultAzureCredential will use Managed Identity if available or fallback to default. + } + + private string? GetAkvVariable(string name) + { + // If using local .akv file secrets, return from dictionary. + if (_akvFileSecrets is not null) + { + return _akvFileSecrets.TryGetValue(name, out string? value) ? value : null; + } + + if (_akvClient is null) + { + throw new InvalidOperationException("Azure Key Vault client is not initialized."); + } + + try + { + return _akvClient.GetSecret(name).Value.Value; + } + catch (Azure.RequestFailedException ex) when (ex.Status == 404) + { + return null; + } + } + } +} diff --git a/src/Config/FileSystemRuntimeConfigLoader.cs b/src/Config/FileSystemRuntimeConfigLoader.cs index 9c2a8e50b5..614cfbd11c 100644 --- a/src/Config/FileSystemRuntimeConfigLoader.cs +++ b/src/Config/FileSystemRuntimeConfigLoader.cs @@ -182,17 +182,16 @@ private void OnNewFileContentsDetected(object? sender, EventArgs e) /// /// The path to the dab-config.json file. /// The loaded RuntimeConfig, or null if none was loaded. - /// Whether to replace environment variable with its - /// value or not while deserializing. /// ILogger for logging errors. /// When not null indicates we need to overwrite mode and how to do so. + /// Settings for variable replacement during deserialization. If null, uses default settings with environment variable replacement disabled. /// True if the config was loaded, otherwise false. public bool TryLoadConfig( string path, [NotNullWhen(true)] out RuntimeConfig? config, - bool replaceEnvVar = false, ILogger? logger = null, - bool? isDevMode = null) + bool? isDevMode = null, + DeserializationVariableReplacementSettings? replacementSettings = null) { if (_fileSystem.File.Exists(path)) { @@ -226,7 +225,15 @@ public bool TryLoadConfig( } } - if (!string.IsNullOrEmpty(json) && TryParseConfig(json, out RuntimeConfig, connectionString: _connectionString, replaceEnvVar: replaceEnvVar)) + // Use default replacement settings if none provided + replacementSettings ??= new DeserializationVariableReplacementSettings(); + + if (!string.IsNullOrEmpty(json) && TryParseConfig( + json, + out RuntimeConfig, + replacementSettings, + logger: null, + connectionString: _connectionString)) { if (TrySetupConfigFileWatcher()) { @@ -292,12 +299,13 @@ public bool TryLoadConfig( /// Tries to load the config file using the filename known to the RuntimeConfigLoader and for the default environment. /// /// The loaded RuntimeConfig, or null if none was loaded. - /// Whether to replace environment variable with its - /// value or not while deserializing. + /// Settings for variable replacement during deserialization. If null, uses default settings with environment variable replacement disabled. /// True if the config was loaded, otherwise false. public override bool TryLoadKnownConfig([NotNullWhen(true)] out RuntimeConfig? config, bool replaceEnvVar = false) { - return TryLoadConfig(ConfigFilePath, out config, replaceEnvVar); + // Convert legacy replaceEnvVar parameter to replacement settings for backward compatibility + DeserializationVariableReplacementSettings? replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: replaceEnvVar, doReplaceAkvVar: replaceEnvVar); + return TryLoadConfig(ConfigFilePath, out config, replacementSettings: replacementSettings); } /// @@ -307,7 +315,11 @@ public override bool TryLoadKnownConfig([NotNullWhen(true)] out RuntimeConfig? c private void HotReloadConfig(bool isDevMode, ILogger? logger = null) { logger?.LogInformation(message: "Starting hot-reload process for config: {ConfigFilePath}", ConfigFilePath); - if (!TryLoadConfig(ConfigFilePath, out _, replaceEnvVar: true, isDevMode: isDevMode)) + + // Use default replacement settings for hot reload + DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true); + + if (!TryLoadConfig(ConfigFilePath, out _, logger: logger, isDevMode: isDevMode, replacementSettings: replacementSettings)) { throw new DataApiBuilderException( message: "Deserialization of the configuration file failed.", @@ -467,7 +479,7 @@ public override string GetPublishedDraftSchemaLink() string? schemaPath = _fileSystem.Path.Combine(assemblyDirectory, "dab.draft.schema.json"); string schemaFileContent = _fileSystem.File.ReadAllText(schemaPath); - Dictionary? jsonDictionary = JsonSerializer.Deserialize>(schemaFileContent, GetSerializationOptions()); + Dictionary? jsonDictionary = JsonSerializer.Deserialize>(schemaFileContent, GetSerializationOptions(replacementSettings: null)); if (jsonDictionary is null) { diff --git a/src/Config/ObjectModel/AuthenticationOptions.cs b/src/Config/ObjectModel/AuthenticationOptions.cs index 6750d6e807..a937168493 100644 --- a/src/Config/ObjectModel/AuthenticationOptions.cs +++ b/src/Config/ObjectModel/AuthenticationOptions.cs @@ -6,12 +6,12 @@ namespace Azure.DataApiBuilder.Config.ObjectModel; /// /// Authentication configuration. /// -/// Identity Provider. Default is StaticWebApps. +/// Identity Provider. Default is AppService. /// With EasyAuth and Simulator, no Audience or Issuer are expected. /// /// Settings enabling validation of the received JWT token. /// Required only when Provider is other than EasyAuth. -public record AuthenticationOptions(string Provider = nameof(EasyAuthType.StaticWebApps), JwtOptions? Jwt = null) +public record AuthenticationOptions(string Provider = nameof(EasyAuthType.AppService), JwtOptions? Jwt = null) { public const string SIMULATOR_AUTHENTICATION = "Simulator"; public const string CLIENT_PRINCIPAL_HEADER = "X-MS-CLIENT-PRINCIPAL"; diff --git a/src/Config/ObjectModel/Autoentity.cs b/src/Config/ObjectModel/Autoentity.cs new file mode 100644 index 0000000000..45fca68642 --- /dev/null +++ b/src/Config/ObjectModel/Autoentity.cs @@ -0,0 +1,32 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Defines an individual auto-entity definition with patterns, template, and permissions. +/// +/// Pattern matching rules for including/excluding database objects +/// Template configuration for generated entities +/// Permissions configuration for generated entities (at least one required) +public record Autoentity +{ + public AutoentityPatterns Patterns { get; init; } + public AutoentityTemplate Template { get; init; } + public EntityPermission[] Permissions { get; init; } + + [JsonConstructor] + public Autoentity( + AutoentityPatterns? Patterns, + AutoentityTemplate? Template, + EntityPermission[]? Permissions) + { + this.Patterns = Patterns ?? new AutoentityPatterns(); + + this.Template = Template ?? new AutoentityTemplate(); + + this.Permissions = Permissions ?? Array.Empty(); + } +} diff --git a/src/Config/ObjectModel/AutoentityPatterns.cs b/src/Config/ObjectModel/AutoentityPatterns.cs new file mode 100644 index 0000000000..d287c56109 --- /dev/null +++ b/src/Config/ObjectModel/AutoentityPatterns.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Defines the pattern matching rules for auto-entities. +/// +/// T-SQL LIKE pattern to include database objects +/// T-SQL LIKE pattern to exclude database objects +/// Interpolation syntax for entity naming (must be unique for each generated entity) +public record AutoentityPatterns +{ + public string[] Include { get; init; } + public string[] Exclude { get; init; } + public string Name { get; init; } + + [JsonConstructor] + public AutoentityPatterns( + string[]? Include = null, + string[]? Exclude = null, + string? Name = null) + { + if (Include is not null) + { + this.Include = Include; + UserProvidedIncludeOptions = true; + } + else + { + this.Include = ["%.%"]; + } + + if (Exclude is not null) + { + this.Exclude = Exclude; + UserProvidedExcludeOptions = true; + } + else + { + this.Exclude = []; + } + + if (!string.IsNullOrWhiteSpace(Name)) + { + this.Name = Name; + UserProvidedNameOptions = true; + } + else + { + this.Name = "{object}"; + } + } + + /// + /// Flag which informs CLI and JSON serializer whether to write include + /// property and value to the runtime config file. + /// When user doesn't provide the include property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a include + /// property/value specified would be interpreted by DAB as "user explicitly set include." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Include))] + public bool UserProvidedIncludeOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write exclude + /// property and value to the runtime config file. + /// When user doesn't provide the exclude property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a exclude + /// property/value specified would be interpreted by DAB as "user explicitly set exclude." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Exclude))] + public bool UserProvidedExcludeOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write name + /// property and value to the runtime config file. + /// When user doesn't provide the name property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a name + /// property/value specified would be interpreted by DAB as "user explicitly set name." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Name))] + public bool UserProvidedNameOptions { get; init; } = false; +} diff --git a/src/Config/ObjectModel/AutoentityTemplate.cs b/src/Config/ObjectModel/AutoentityTemplate.cs new file mode 100644 index 0000000000..78a804d0a4 --- /dev/null +++ b/src/Config/ObjectModel/AutoentityTemplate.cs @@ -0,0 +1,153 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Template used by auto-entities to configure all entities it generates. +/// +/// MCP endpoint configuration +/// REST endpoint configuration +/// GraphQL endpoint configuration +/// Health check configuration +/// Cache configuration +public record AutoentityTemplate +{ + public EntityMcpOptions? Mcp { get; init; } + public EntityRestOptions Rest { get; init; } + public EntityGraphQLOptions GraphQL { get; init; } + public EntityHealthCheckConfig Health { get; init; } + public EntityCacheOptions Cache { get; init; } + + [JsonConstructor] + public AutoentityTemplate( + EntityRestOptions? Rest = null, + EntityGraphQLOptions? GraphQL = null, + EntityMcpOptions? Mcp = null, + EntityHealthCheckConfig? Health = null, + EntityCacheOptions? Cache = null) + { + if (Rest is not null) + { + this.Rest = Rest; + UserProvidedRestOptions = true; + } + else + { + this.Rest = new EntityRestOptions(); + } + + if (GraphQL is not null) + { + this.GraphQL = GraphQL; + UserProvidedGraphQLOptions = true; + } + else + { + this.GraphQL = new EntityGraphQLOptions(string.Empty, string.Empty); + } + + if (Mcp is not null) + { + this.Mcp = Mcp; + UserProvidedMcpOptions = true; + } + else + { + this.Mcp = new EntityMcpOptions(null, null); + } + + if (Health is not null) + { + this.Health = Health; + UserProvidedHealthOptions = true; + } + else + { + this.Health = new EntityHealthCheckConfig(); + } + + if (Cache is not null) + { + this.Cache = Cache; + UserProvidedCacheOptions = true; + } + else + { + this.Cache = new EntityCacheOptions(); + } + } + + /// + /// Flag which informs CLI and JSON serializer whether to write rest + /// property and value to the runtime config file. + /// When user doesn't provide the rest property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a rest + /// property/value specified would be interpreted by DAB as "user explicitly set rest." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Rest))] + public bool UserProvidedRestOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write graphql + /// property and value to the runtime config file. + /// When user doesn't provide the graphql property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a graphql + /// property/value specified would be interpreted by DAB as "user explicitly set graphql." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(GraphQL))] + public bool UserProvidedGraphQLOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write mcp + /// property and value to the runtime config file. + /// When user doesn't provide the mcp property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a mcp + /// property/value specified would be interpreted by DAB as "user explicitly set mcp." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Mcp))] + public bool UserProvidedMcpOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write health + /// property and value to the runtime config file. + /// When user doesn't provide the health property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a health + /// property/value specified would be interpreted by DAB as "user explicitly set health." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Health))] + public bool UserProvidedHealthOptions { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write cache + /// property and value to the runtime config file. + /// When user doesn't provide the cache property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// This is because the user's intent is to use DAB's default value which could change + /// and DAB CLI writing the property and value would lose the user's intent. + /// This is because if the user were to use the CLI created config, a cache + /// property/value specified would be interpreted by DAB as "user explicitly set cache." + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Cache))] + public bool UserProvidedCacheOptions { get; init; } = false; +} diff --git a/src/Config/ObjectModel/AzureKeyVaultOptions.cs b/src/Config/ObjectModel/AzureKeyVaultOptions.cs index 27094cd16f..ebd1e909c1 100644 --- a/src/Config/ObjectModel/AzureKeyVaultOptions.cs +++ b/src/Config/ObjectModel/AzureKeyVaultOptions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; namespace Azure.DataApiBuilder.Config.ObjectModel; @@ -12,4 +13,40 @@ public record AzureKeyVaultOptions [JsonPropertyName("retry-policy")] public AKVRetryPolicyOptions? RetryPolicy { get; init; } + + /// + /// Flag which informs CLI and JSON serializer whether to write endpoint + /// property and value to the runtime config file. + /// When user doesn't provide the endpoint property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(Endpoint))] + public bool UserProvidedEndpoint { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write retry-policy + /// property and value to the runtime config file. + /// When user doesn't provide the retry-policy property/value, which signals DAB to use the default, + /// the DAB CLI should not write the default value to a serialized config. + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + [MemberNotNullWhen(true, nameof(RetryPolicy))] + public bool UserProvidedRetryPolicy { get; init; } = false; + + [JsonConstructor] + public AzureKeyVaultOptions(string? endpoint = null, AKVRetryPolicyOptions? retryPolicy = null) + { + if (endpoint is not null) + { + Endpoint = endpoint; + UserProvidedEndpoint = true; + } + + if (retryPolicy is not null) + { + RetryPolicy = retryPolicy; + UserProvidedRetryPolicy = true; + } + } } diff --git a/src/Config/ObjectModel/DmlToolsConfig.cs b/src/Config/ObjectModel/DmlToolsConfig.cs index c14f8e49ed..2a09e9d53c 100644 --- a/src/Config/ObjectModel/DmlToolsConfig.cs +++ b/src/Config/ObjectModel/DmlToolsConfig.cs @@ -64,71 +64,71 @@ public DmlToolsConfig( if (allToolsEnabled is not null) { AllToolsEnabled = allToolsEnabled.Value; - UserProvidedAllToolsEnabled = true; - } - else - { - AllToolsEnabled = DEFAULT_ENABLED; - } + UserProvidedAllTools = true; - if (describeEntities is not null) - { - DescribeEntities = describeEntities; - UserProvidedDescribeEntities = true; - } + // When allToolsEnabled is set, use it as the default for all tools + bool toolDefault = allToolsEnabled.Value; - if (createRecord is not null) - { - CreateRecord = createRecord; - UserProvidedCreateRecord = true; + DescribeEntities = describeEntities ?? toolDefault; + CreateRecord = createRecord ?? toolDefault; + ReadRecords = readRecords ?? toolDefault; + UpdateRecord = updateRecord ?? toolDefault; + DeleteRecord = deleteRecord ?? toolDefault; + ExecuteEntity = executeEntity ?? toolDefault; } - - if (readRecords is not null) - { - ReadRecords = readRecords; - UserProvidedReadRecords = true; - } - - if (updateRecord is not null) + else { - UpdateRecord = updateRecord; - UserProvidedUpdateRecord = true; - } + AllToolsEnabled = DEFAULT_ENABLED; - if (deleteRecord is not null) - { - DeleteRecord = deleteRecord; - UserProvidedDeleteRecord = true; + // Set values with defaults + DescribeEntities = describeEntities ?? DEFAULT_ENABLED; + CreateRecord = createRecord ?? DEFAULT_ENABLED; + ReadRecords = readRecords ?? DEFAULT_ENABLED; + UpdateRecord = updateRecord ?? DEFAULT_ENABLED; + DeleteRecord = deleteRecord ?? DEFAULT_ENABLED; + ExecuteEntity = executeEntity ?? DEFAULT_ENABLED; } - if (executeEntity is not null) - { - ExecuteEntity = executeEntity; - UserProvidedExecuteEntity = true; - } + // Track user-provided status - only true if the parameter was not null + UserProvidedDescribeEntities = describeEntities is not null; + UserProvidedCreateRecord = createRecord is not null; + UserProvidedReadRecords = readRecords is not null; + UserProvidedUpdateRecord = updateRecord is not null; + UserProvidedDeleteRecord = deleteRecord is not null; + UserProvidedExecuteEntity = executeEntity is not null; } /// /// Creates a DmlToolsConfig with all tools set to the same state + /// Used when user explicitly sets "dml-tools": true/false /// public static DmlToolsConfig FromBoolean(bool enabled) { - return new DmlToolsConfig - { - AllToolsEnabled = enabled, - DescribeEntities = null, - CreateRecord = null, - ReadRecords = null, - UpdateRecord = null, - DeleteRecord = null, - ExecuteEntity = null - }; + // Only pass allToolsEnabled, leave individual tools as null + return new DmlToolsConfig( + allToolsEnabled: enabled, + describeEntities: null, + createRecord: null, + readRecords: null, + updateRecord: null, + deleteRecord: null, + executeEntity: null + ); } /// /// Creates a default DmlToolsConfig with all tools enabled + /// Used when dml-tools is not specified in config at all /// - public static DmlToolsConfig Default => FromBoolean(DEFAULT_ENABLED); + public static DmlToolsConfig Default => new( + allToolsEnabled: null, + describeEntities: null, + createRecord: null, + readRecords: null, + updateRecord: null, + deleteRecord: null, + executeEntity: null + ); /// /// Flag which informs CLI and JSON serializer whether to write all-tools-enabled @@ -136,7 +136,7 @@ public static DmlToolsConfig FromBoolean(bool enabled) /// [JsonIgnore(Condition = JsonIgnoreCondition.Always)] [MemberNotNullWhen(true, nameof(AllToolsEnabled))] - public bool UserProvidedAllToolsEnabled { get; init; } = false; + public bool UserProvidedAllTools { get; init; } = false; /// /// Flag which informs CLI and JSON serializer whether to write describe-entities diff --git a/src/Config/ObjectModel/Entity.cs b/src/Config/ObjectModel/Entity.cs index c9f247e0f6..1e8c5a6dba 100644 --- a/src/Config/ObjectModel/Entity.cs +++ b/src/Config/ObjectModel/Entity.cs @@ -3,6 +3,7 @@ using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Config.HealthCheck; namespace Azure.DataApiBuilder.Config.ObjectModel; @@ -39,6 +40,9 @@ public record Entity public EntityCacheOptions? Cache { get; init; } public EntityHealthCheckConfig? Health { get; init; } + [JsonConverter(typeof(EntityMcpOptionsConverterFactory))] + public EntityMcpOptions? Mcp { get; init; } + [JsonIgnore] public bool IsLinkingEntity { get; init; } @@ -54,7 +58,8 @@ public Entity( EntityCacheOptions? Cache = null, bool IsLinkingEntity = false, EntityHealthCheckConfig? Health = null, - string? Description = null) + string? Description = null, + EntityMcpOptions? Mcp = null) { this.Health = Health; this.Source = Source; @@ -67,6 +72,7 @@ public Entity( this.Cache = Cache; this.IsLinkingEntity = IsLinkingEntity; this.Description = Description; + this.Mcp = Mcp; } /// diff --git a/src/Config/ObjectModel/EntityMcpOptions.cs b/src/Config/ObjectModel/EntityMcpOptions.cs new file mode 100644 index 0000000000..ad928a21ab --- /dev/null +++ b/src/Config/ObjectModel/EntityMcpOptions.cs @@ -0,0 +1,59 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Azure.DataApiBuilder.Config.ObjectModel +{ + /// + /// Options for Model Context Protocol (MCP) tools at the entity level. + /// + public record EntityMcpOptions + { + /// + /// Indicates whether custom tools are enabled for this entity. + /// Only applicable for stored procedures. + /// + [JsonPropertyName("custom-tool")] + public bool CustomToolEnabled { get; init; } = false; + + /// + /// Indicates whether DML tools are enabled for this entity. + /// Defaults to true when not explicitly provided. + /// + [JsonPropertyName("dml-tools")] + public bool DmlToolEnabled { get; init; } = true; + + /// + /// Flag which informs CLI and JSON serializer whether to write the CustomToolEnabled + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UserProvidedCustomToolEnabled { get; init; } = false; + + /// + /// Flag which informs CLI and JSON serializer whether to write the DmlToolEnabled + /// + [JsonIgnore(Condition = JsonIgnoreCondition.Always)] + public bool UserProvidedDmlToolsEnabled { get; init; } = false; + + /// + /// Constructor for EntityMcpOptions + /// + /// The custom tool enabled flag. + /// The DML tools enabled flag. + public EntityMcpOptions(bool? customToolEnabled, bool? dmlToolsEnabled) + { + if (customToolEnabled.HasValue) + { + this.CustomToolEnabled = customToolEnabled.Value; + this.UserProvidedCustomToolEnabled = true; + } + + if (dmlToolsEnabled.HasValue) + { + this.DmlToolEnabled = dmlToolsEnabled.Value; + this.UserProvidedDmlToolsEnabled = true; + } + } + } +} diff --git a/src/Config/ObjectModel/McpRuntimeOptions.cs b/src/Config/ObjectModel/McpRuntimeOptions.cs index 73d695ee4a..e17d53fc8f 100644 --- a/src/Config/ObjectModel/McpRuntimeOptions.cs +++ b/src/Config/ObjectModel/McpRuntimeOptions.cs @@ -30,13 +30,20 @@ public record McpRuntimeOptions [JsonConverter(typeof(DmlToolsConfigConverter))] public DmlToolsConfig? DmlTools { get; init; } + /// + /// Description of the MCP server to be exposed in the initialize response + /// + [JsonPropertyName("description")] + public string? Description { get; init; } + [JsonConstructor] public McpRuntimeOptions( - bool Enabled = true, + bool? Enabled = null, string? Path = null, - DmlToolsConfig? DmlTools = null) + DmlToolsConfig? DmlTools = null, + string? Description = null) { - this.Enabled = Enabled; + this.Enabled = Enabled ?? true; if (Path is not null) { @@ -48,7 +55,18 @@ public McpRuntimeOptions( this.Path = DEFAULT_PATH; } - this.DmlTools = DmlTools; + // if DmlTools is null, set All tools enabled by default + if (DmlTools is null) + { + // Use Default instead of FromBoolean to avoid setting UserProvided flags + this.DmlTools = DmlToolsConfig.Default; + } + else + { + this.DmlTools = DmlTools; + } + + this.Description = Description; } /// diff --git a/src/Config/ObjectModel/RuntimeAutoentities.cs b/src/Config/ObjectModel/RuntimeAutoentities.cs new file mode 100644 index 0000000000..0fec45f5a1 --- /dev/null +++ b/src/Config/ObjectModel/RuntimeAutoentities.cs @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config.Converters; + +namespace Azure.DataApiBuilder.Config.ObjectModel; + +/// +/// Represents a collection of available from the RuntimeConfig. +/// +[JsonConverter(typeof(RuntimeAutoentitiesConverter))] +public record RuntimeAutoentities +{ + /// + /// The collection of available from the RuntimeConfig. + /// + public IReadOnlyDictionary AutoEntities { get; init; } + + /// + /// Creates a new instance of the class using a collection of entities. + /// + /// The collection of auto-entities to map to RuntimeAutoentities. + public RuntimeAutoentities(IReadOnlyDictionary autoEntities) + { + AutoEntities = autoEntities; + } +} diff --git a/src/Config/ObjectModel/RuntimeConfig.cs b/src/Config/ObjectModel/RuntimeConfig.cs index a450e1265c..1e567da1cd 100644 --- a/src/Config/ObjectModel/RuntimeConfig.cs +++ b/src/Config/ObjectModel/RuntimeConfig.cs @@ -25,6 +25,8 @@ public record RuntimeConfig [JsonPropertyName("azure-key-vault")] public AzureKeyVaultOptions? AzureKeyVault { get; init; } + public RuntimeAutoentities? Autoentities { get; init; } + public virtual RuntimeEntities Entities { get; init; } public DataSourceFiles? DataSourceFiles { get; init; } @@ -98,6 +100,17 @@ Runtime.Host is null || Runtime.Host.Authentication is null || EasyAuthType.StaticWebApps.ToString().Equals(Runtime.Host.Authentication.Provider, StringComparison.OrdinalIgnoreCase); + /// + /// A shorthand method to determine whether App Service is configured for the current authentication provider. + /// + /// True if the authentication provider is enabled for App Service, otherwise false. + [JsonIgnore] + public bool IsAppServiceIdentityProvider => + Runtime is null || + Runtime.Host is null || + Runtime.Host.Authentication is null || + EasyAuthType.AppService.ToString().Equals(Runtime.Host.Authentication.Provider, StringComparison.OrdinalIgnoreCase); + /// /// The path at which Rest APIs are available /// @@ -246,6 +259,7 @@ public RuntimeConfig( string? Schema, DataSource DataSource, RuntimeEntities Entities, + RuntimeAutoentities? Autoentities = null, RuntimeOptions? Runtime = null, DataSourceFiles? DataSourceFiles = null, AzureKeyVaultOptions? AzureKeyVault = null) @@ -255,6 +269,7 @@ public RuntimeConfig( this.Runtime = Runtime; this.AzureKeyVault = AzureKeyVault; this.Entities = Entities; + this.Autoentities = Autoentities; this.DefaultDataSourceName = Guid.NewGuid().ToString(); if (this.DataSource is null) @@ -298,7 +313,10 @@ public RuntimeConfig( foreach (string dataSourceFile in DataSourceFiles.SourceFiles) { - if (loader.TryLoadConfig(dataSourceFile, out RuntimeConfig? config, replaceEnvVar: true)) + // Use default replacement settings for environment variable replacement + DeserializationVariableReplacementSettings replacementSettings = new(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true); + + if (loader.TryLoadConfig(dataSourceFile, out RuntimeConfig? config, replacementSettings: replacementSettings)) { try { @@ -448,7 +466,7 @@ public bool CheckDataSourceExists(string dataSourceName) public string ToJson(JsonSerializerOptions? jsonSerializerOptions = null) { // get default serializer options if none provided. - jsonSerializerOptions = jsonSerializerOptions ?? RuntimeConfigLoader.GetSerializationOptions(); + jsonSerializerOptions = jsonSerializerOptions ?? RuntimeConfigLoader.GetSerializationOptions(replacementSettings: null); return JsonSerializer.Serialize(this, jsonSerializerOptions); } diff --git a/src/Config/RuntimeConfigLoader.cs b/src/Config/RuntimeConfigLoader.cs index f78c32ebc1..9a54d09d8e 100644 --- a/src/Config/RuntimeConfigLoader.cs +++ b/src/Config/RuntimeConfigLoader.cs @@ -129,25 +129,86 @@ protected void SignalConfigChanged(string message = "") /// public abstract string GetPublishedDraftSchemaLink(); + /// + /// Extracts AzureKeyVaultOptions from JSON string with configurable variable replacement. + /// + /// JSON that represents the config file. + /// Whether to enable environment variable replacement during extraction. + /// Failure mode for environment variable replacement if enabled. + /// AzureKeyVaultOptions if present, null otherwise. + private static AzureKeyVaultOptions? ExtractAzureKeyVaultOptions( + string json, + bool enableEnvReplacement, + EnvironmentVariableReplacementFailureMode replacementFailureMode = EnvironmentVariableReplacementFailureMode.Throw) + { + JsonSerializerOptions options = new() + { + PropertyNameCaseInsensitive = false, + PropertyNamingPolicy = new HyphenatedNamingPolicy(), + ReadCommentHandling = JsonCommentHandling.Skip + }; + DeserializationVariableReplacementSettings envOnlySettings = new( + azureKeyVaultOptions: null, + doReplaceEnvVar: enableEnvReplacement, + doReplaceAkvVar: false, + envFailureMode: replacementFailureMode); + options.Converters.Add(new StringJsonConverterFactory(envOnlySettings)); + options.Converters.Add(new EnumMemberJsonEnumConverterFactory()); + options.Converters.Add(new AzureKeyVaultOptionsConverterFactory(replacementSettings: envOnlySettings)); + options.Converters.Add(new AKVRetryPolicyOptionsConverterFactory(replacementSettings: envOnlySettings)); + + try + { + using JsonDocument doc = JsonDocument.Parse(json); + if (doc.RootElement.TryGetProperty("azure-key-vault", out JsonElement akvElement)) + { + return JsonSerializer.Deserialize(akvElement.GetRawText(), options); + } + } + catch + { + // If we can't extract AKV options, return null and proceed without AKV variable replacement + return null; + } + + return null; + } + /// /// Parses a JSON string into a RuntimeConfig object for single database scenario. /// /// JSON that represents the config file. /// The parsed config, or null if it parsed unsuccessfully. - /// True if the config was parsed, otherwise false. + /// Settings for variable replacement during deserialization. If null, no variable replacement will be performed. /// logger to log messages /// connectionString to add to config if specified - /// Whether to replace environment variable with its - /// value or not while deserializing. By default, no replacement happens. - /// Determines failure mode for env variable replacement. + /// True if the config was parsed, otherwise false. public static bool TryParseConfig(string json, [NotNullWhen(true)] out RuntimeConfig? config, + DeserializationVariableReplacementSettings? replacementSettings = null, ILogger? logger = null, - string? connectionString = null, - bool replaceEnvVar = false, - EnvironmentVariableReplacementFailureMode replacementFailureMode = EnvironmentVariableReplacementFailureMode.Throw) + string? connectionString = null) { - JsonSerializerOptions options = GetSerializationOptions(replaceEnvVar, replacementFailureMode); + // First pass: extract AzureKeyVault options if AKV replacement is requested + if (replacementSettings?.DoReplaceAkvVar is true) + { + AzureKeyVaultOptions? azureKeyVaultOptions = ExtractAzureKeyVaultOptions( + json: json, + enableEnvReplacement: replacementSettings.DoReplaceEnvVar, + replacementFailureMode: replacementSettings.EnvFailureMode); + + // Update replacement settings with the extracted AKV options + if (azureKeyVaultOptions is not null) + { + replacementSettings = new DeserializationVariableReplacementSettings( + azureKeyVaultOptions: azureKeyVaultOptions, + doReplaceEnvVar: replacementSettings.DoReplaceEnvVar, + doReplaceAkvVar: replacementSettings.DoReplaceAkvVar, + envFailureMode: replacementSettings.EnvFailureMode); + } + } + + JsonSerializerOptions options = GetSerializationOptions(replacementSettings); try { @@ -180,11 +241,11 @@ public static bool TryParseConfig(string json, DataSource ds = config.GetDataSourceFromDataSourceName(dataSourceKey); // Add Application Name for telemetry for MsSQL or PgSql - if (ds.DatabaseType is DatabaseType.MSSQL && replaceEnvVar) + if (ds.DatabaseType is DatabaseType.MSSQL && replacementSettings?.DoReplaceEnvVar == true) { updatedConnection = GetConnectionStringWithApplicationName(connectionValue); } - else if (ds.DatabaseType is DatabaseType.PostgreSQL && replaceEnvVar) + else if (ds.DatabaseType is DatabaseType.PostgreSQL && replacementSettings?.DoReplaceEnvVar == true) { updatedConnection = GetPgSqlConnectionStringWithApplicationName(connectionValue); } @@ -225,11 +286,10 @@ ex is JsonException || /// /// Get Serializer options for the config file. /// - /// Whether to replace environment variable with value or not while deserializing. - /// By default, no replacement happens. + /// Settings for variable replacement during deserialization. + /// If null, no variable replacement will be performed. public static JsonSerializerOptions GetSerializationOptions( - bool replaceEnvVar = false, - EnvironmentVariableReplacementFailureMode replacementFailureMode = EnvironmentVariableReplacementFailureMode.Throw) + DeserializationVariableReplacementSettings? replacementSettings = null) { JsonSerializerOptions options = new() { @@ -241,33 +301,42 @@ public static JsonSerializerOptions GetSerializationOptions( Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }; options.Converters.Add(new EnumMemberJsonEnumConverterFactory()); - options.Converters.Add(new RuntimeHealthOptionsConvertorFactory(replaceEnvVar)); - options.Converters.Add(new DataSourceHealthOptionsConvertorFactory(replaceEnvVar)); + options.Converters.Add(new RuntimeHealthOptionsConvertorFactory(replacementSettings)); + options.Converters.Add(new DataSourceHealthOptionsConvertorFactory(replacementSettings)); options.Converters.Add(new EntityHealthOptionsConvertorFactory()); options.Converters.Add(new RestRuntimeOptionsConverterFactory()); - options.Converters.Add(new GraphQLRuntimeOptionsConverterFactory(replaceEnvVar)); - options.Converters.Add(new McpRuntimeOptionsConverterFactory(replaceEnvVar)); + options.Converters.Add(new GraphQLRuntimeOptionsConverterFactory(replacementSettings)); + options.Converters.Add(new McpRuntimeOptionsConverterFactory(replacementSettings)); options.Converters.Add(new DmlToolsConfigConverter()); - options.Converters.Add(new EntitySourceConverterFactory(replaceEnvVar)); - options.Converters.Add(new EntityGraphQLOptionsConverterFactory(replaceEnvVar)); - options.Converters.Add(new EntityRestOptionsConverterFactory(replaceEnvVar)); + options.Converters.Add(new EntitySourceConverterFactory(replacementSettings)); + options.Converters.Add(new EntityGraphQLOptionsConverterFactory(replacementSettings)); + options.Converters.Add(new EntityRestOptionsConverterFactory(replacementSettings)); options.Converters.Add(new EntityActionConverterFactory()); options.Converters.Add(new DataSourceFilesConverter()); - options.Converters.Add(new EntityCacheOptionsConverterFactory(replaceEnvVar)); + options.Converters.Add(new EntityCacheOptionsConverterFactory(replacementSettings)); + options.Converters.Add(new AutoentityConverter(replacementSettings)); + options.Converters.Add(new AutoentityPatternsConverter(replacementSettings)); + options.Converters.Add(new AutoentityTemplateConverter(replacementSettings)); + options.Converters.Add(new EntityMcpOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheOptionsConverterFactory()); options.Converters.Add(new RuntimeCacheLevel2OptionsConverterFactory()); options.Converters.Add(new MultipleCreateOptionsConverter()); options.Converters.Add(new MultipleMutationOptionsConverter(options)); - options.Converters.Add(new DataSourceConverterFactory(replaceEnvVar)); + options.Converters.Add(new DataSourceConverterFactory(replacementSettings)); options.Converters.Add(new HostOptionsConvertorFactory()); - options.Converters.Add(new AKVRetryPolicyOptionsConverterFactory(replaceEnvVar)); - options.Converters.Add(new AzureLogAnalyticsOptionsConverterFactory(replaceEnvVar)); - options.Converters.Add(new AzureLogAnalyticsAuthOptionsConverter(replaceEnvVar)); - options.Converters.Add(new FileSinkConverter(replaceEnvVar)); + options.Converters.Add(new AKVRetryPolicyOptionsConverterFactory(replacementSettings)); + options.Converters.Add(new AzureLogAnalyticsOptionsConverterFactory(replacementSettings)); + options.Converters.Add(new AzureLogAnalyticsAuthOptionsConverter(replacementSettings)); + options.Converters.Add(new BoolJsonConverter()); + options.Converters.Add(new FileSinkConverter(replacementSettings)); + + // Add AzureKeyVaultOptionsConverterFactory to ensure AKV config is deserialized properly + options.Converters.Add(new AzureKeyVaultOptionsConverterFactory(replacementSettings)); - if (replaceEnvVar) + // Only add the extensible string converter if we have replacement settings + if (replacementSettings is not null) { - options.Converters.Add(new StringJsonConverterFactory(replacementFailureMode)); + options.Converters.Add(new StringJsonConverterFactory(replacementSettings)); } return options; diff --git a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs index a233355a7d..c83de9ed3a 100644 --- a/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs +++ b/src/Core/AuthenticationHelpers/ClientRoleHeaderAuthenticationMiddleware.cs @@ -62,7 +62,7 @@ public async Task InvokeAsync(HttpContext httpContext) // Determine the authentication scheme to use based on dab-config.json. // Compatible with both ConfigureAuthentication and ConfigureAuthenticationV2 in startup.cs. // This means that this code is resilient to whether or not the default authentication scheme is set in startup. - string scheme = EasyAuthAuthenticationDefaults.SWAAUTHSCHEME; + string scheme = EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME; if (!_runtimeConfigProvider.IsLateConfigured) { AuthenticationOptions? dabAuthNOptions = _runtimeConfigProvider.GetConfig().Runtime?.Host?.Authentication; diff --git a/src/Core/AuthenticationHelpers/EasyAuthAuthenticationBuilderExtensions.cs b/src/Core/AuthenticationHelpers/EasyAuthAuthenticationBuilderExtensions.cs index cb1d11a149..e4edf72a7c 100644 --- a/src/Core/AuthenticationHelpers/EasyAuthAuthenticationBuilderExtensions.cs +++ b/src/Core/AuthenticationHelpers/EasyAuthAuthenticationBuilderExtensions.cs @@ -54,7 +54,8 @@ public static AuthenticationBuilder AddEasyAuthAuthentication( /// /// Registers the StaticWebApps and AppService EasyAuth authentication schemes. /// Used for ConfigureAuthenticationV2() where all EasyAuth schemes are registered. - /// This function doesn't register EasyAuthType.AppService if the AppService environment is not detected. + /// AppService authentication is always registered, while StaticWebApps authentication + /// is also available and configured here. /// /// public static AuthenticationBuilder AddEnvDetectedEasyAuth(this AuthenticationBuilder builder) @@ -64,6 +65,7 @@ public static AuthenticationBuilder AddEnvDetectedEasyAuth(this AuthenticationBu throw new ArgumentNullException(nameof(builder)); } + // Always register Static Web Apps authentication scheme. builder.AddScheme( authenticationScheme: EasyAuthAuthenticationDefaults.SWAAUTHSCHEME, displayName: EasyAuthAuthenticationDefaults.SWAAUTHSCHEME, @@ -72,20 +74,16 @@ public static AuthenticationBuilder AddEnvDetectedEasyAuth(this AuthenticationBu options.EasyAuthProvider = EasyAuthType.StaticWebApps; }); - bool appServiceEnvironmentDetected = AppServiceAuthenticationInfo.AreExpectedAppServiceEnvVarsPresent(); - - if (appServiceEnvironmentDetected) - { - // Loggers not available at this point in startup. - Console.WriteLine("AppService environment detected, configuring EasyAuth.AppService authentication scheme."); - builder.AddScheme( - authenticationScheme: EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME, - displayName: EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME, - options => - { - options.EasyAuthProvider = EasyAuthType.AppService; - }); - } + // Always register App Service authentication scheme as well so that + // AppService can be treated as the default EasyAuth provider without + // relying on environment variable detection. + builder.AddScheme( + authenticationScheme: EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME, + displayName: EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME, + options => + { + options.EasyAuthProvider = EasyAuthType.AppService; + }); return builder; } diff --git a/src/Core/Configurations/RuntimeConfigProvider.cs b/src/Core/Configurations/RuntimeConfigProvider.cs index faeb2b94d0..b46a716f48 100644 --- a/src/Core/Configurations/RuntimeConfigProvider.cs +++ b/src/Core/Configurations/RuntimeConfigProvider.cs @@ -6,7 +6,6 @@ using System.IO.Abstractions; using System.Net; using Azure.DataApiBuilder.Config; -using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Config.NamingPolicies; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Service.Exceptions; @@ -189,8 +188,7 @@ public async Task Initialize( if (RuntimeConfigLoader.TryParseConfig( configuration, out RuntimeConfig? runtimeConfig, - replaceEnvVar: false, - replacementFailureMode: EnvironmentVariableReplacementFailureMode.Ignore)) + replacementSettings: null)) { _configLoader.RuntimeConfig = runtimeConfig; @@ -257,8 +255,7 @@ public async Task Initialize( string? graphQLSchema, string connectionString, string? accessToken, - bool replaceEnvVar = true, - EnvironmentVariableReplacementFailureMode replacementFailureMode = EnvironmentVariableReplacementFailureMode.Throw) + DeserializationVariableReplacementSettings? replacementSettings) { if (string.IsNullOrEmpty(connectionString)) { @@ -272,7 +269,7 @@ public async Task Initialize( IsLateConfigured = true; - if (RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? runtimeConfig, replaceEnvVar: replaceEnvVar, replacementFailureMode: replacementFailureMode)) + if (RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig? runtimeConfig, replacementSettings)) { _configLoader.RuntimeConfig = runtimeConfig.DataSource.DatabaseType switch { diff --git a/src/Core/Configurations/RuntimeConfigValidator.cs b/src/Core/Configurations/RuntimeConfigValidator.cs index fd8f811c9e..ec97a48e4c 100644 --- a/src/Core/Configurations/RuntimeConfigValidator.cs +++ b/src/Core/Configurations/RuntimeConfigValidator.cs @@ -848,6 +848,14 @@ private void ValidateAuthenticationOptions(RuntimeConfig runtimeConfig) return; } + // Warn if the configured EasyAuth provider is StaticWebApps (deprecated) + if (!string.IsNullOrWhiteSpace(runtimeConfig.Runtime.Host.Authentication.Provider) && + Enum.TryParse(runtimeConfig.Runtime.Host.Authentication.Provider, ignoreCase: true, out EasyAuthType provider) && + provider == EasyAuthType.StaticWebApps) + { + _logger.LogWarning("The 'StaticWebApps' authentication provider is deprecated."); + } + bool isAudienceSet = !string.IsNullOrEmpty(runtimeConfig.Runtime.Host.Authentication.Jwt?.Audience); bool isIssuerSet = !string.IsNullOrEmpty(runtimeConfig.Runtime.Host.Authentication.Jwt?.Issuer); diff --git a/src/Core/Resolvers/QueryExecutor.cs b/src/Core/Resolvers/QueryExecutor.cs index 908c1bb1e8..97e2f7e8d4 100644 --- a/src/Core/Resolvers/QueryExecutor.cs +++ b/src/Core/Resolvers/QueryExecutor.cs @@ -740,6 +740,12 @@ internal int StreamCharData(DbDataReader dbDataReader, long availableSize, Strin // else we throw exception. ValidateSize(availableSize, resultFieldSize); + // If the cell is empty, don't append anything to the resultJsonString and return 0. + if (resultFieldSize == 0) + { + return 0; + } + char[] buffer = new char[resultFieldSize]; // read entire field into buffer and reduce available size. @@ -766,6 +772,13 @@ internal int StreamByteData(DbDataReader dbDataReader, long availableSize, int o // else we throw exception. ValidateSize(availableSize, resultFieldSize); + // If the cell is empty, set resultBytes to an empty array and return 0. + if (resultFieldSize == 0) + { + resultBytes = Array.Empty(); + return 0; + } + resultBytes = new byte[resultFieldSize]; dbDataReader.GetBytes(ordinal: ordinal, dataOffset: 0, buffer: resultBytes, bufferOffset: 0, length: resultBytes.Length); diff --git a/src/Core/Resolvers/SqlQueryEngine.cs b/src/Core/Resolvers/SqlQueryEngine.cs index 7b261ecb2b..6523589532 100644 --- a/src/Core/Resolvers/SqlQueryEngine.cs +++ b/src/Core/Resolvers/SqlQueryEngine.cs @@ -224,12 +224,18 @@ public JsonElement ResolveObject(JsonElement element, ObjectField fieldSchema, r parentMetadata = paginationObjectMetadata; } - PaginationMetadata currentMetadata = parentMetadata.Subqueries[fieldSchema.Name]; - metadata = currentMetadata; - - if (currentMetadata.IsPaginated) + // In some scenarios (for example when RBAC removes a relationship + // or when multiple sibling nested entities are present), we may not + // have pagination metadata for the current field. In those cases we + // should simply return the element as-is instead of throwing. + if (parentMetadata.Subqueries.TryGetValue(fieldSchema.Name, out PaginationMetadata? currentMetadata)) { - return SqlPaginationUtil.CreatePaginationConnectionFromJsonElement(element, currentMetadata); + metadata = currentMetadata; + + if (currentMetadata.IsPaginated) + { + return SqlPaginationUtil.CreatePaginationConnectionFromJsonElement(element, currentMetadata); + } } } diff --git a/src/Core/Services/ExecutionHelper.cs b/src/Core/Services/ExecutionHelper.cs index a0a81f02cc..79bdc6af9c 100644 --- a/src/Core/Services/ExecutionHelper.cs +++ b/src/Core/Services/ExecutionHelper.cs @@ -4,6 +4,7 @@ using System.Diagnostics; using System.Globalization; using System.Net; +using System.Text; using System.Text.Json; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Configurations; @@ -534,7 +535,24 @@ public static InputObjectType InputObjectTypeFromIInputField(IInputValueDefiniti // /books/items/items[idx]/authors -> Depth: 3 (0-indexed) which maps to the // pagination metadata for the "authors/items" subquery. string paginationObjectParentName = GetMetadataKey(context.Path) + "::" + context.Path.Parent.Depth(); - return (IMetadata?)context.ContextData[paginationObjectParentName]; + + // For nested list fields under relationships (e.g. reviews.items, authors.items), + // include the relationship path suffix so we look up the same key that + // SetNewMetadataChildren stored ("::depth::relationshipPath"). + string relationshipPath = GetRelationshipPathSuffix(context.Path.Parent); + if (!string.IsNullOrEmpty(relationshipPath)) + { + paginationObjectParentName = paginationObjectParentName + "::" + relationshipPath; + } + + if (context.ContextData.TryGetValue(key: paginationObjectParentName, out object? itemsPaginationMetadata) && itemsPaginationMetadata is not null) + { + return (IMetadata)itemsPaginationMetadata; + } + + // If metadata is missing (e.g. Cosmos DB or pruned relationship), return an empty + // pagination metadata object to avoid KeyNotFoundException. + return PaginationMetadata.MakeEmptyPaginationMetadata(); } // This section would be reached when processing a Cosmos query of the form: @@ -582,7 +600,24 @@ private static IMetadata GetMetadataObjectField(IResolverContext context) // pagination metadata from context.ContextData // The PaginationMetadata fetched has subquery metadata for "authors" from path "/books/items/authors" string objectParentName = GetMetadataKey(context.Path) + "::" + context.Path.Parent.Parent.Depth(); - return (IMetadata)context.ContextData[objectParentName]!; + + // Include relationship path suffix (for example, "addresses" or "phoneNumbers") so + // we look up the same key that SetNewMetadataChildren stored + // ("::depth::relationshipPath"). + string relationshipPath = GetRelationshipPathSuffix(context.Path.Parent.Parent); + if (!string.IsNullOrEmpty(relationshipPath)) + { + objectParentName = objectParentName + "::" + relationshipPath; + } + + if (context.ContextData.TryGetValue(objectParentName, out object? indexerMetadata) && indexerMetadata is not null) + { + return (IMetadata)indexerMetadata; + } + + // If no metadata is present (for example, for non-paginated relationships or when + // RBAC prunes a branch), return an empty pagination metadata object. + return PaginationMetadata.MakeEmptyPaginationMetadata(); } if (!context.Path.IsRootField() && ((NamePathSegment)context.Path.Parent).Name != PURE_RESOLVER_CONTEXT_SUFFIX) @@ -592,12 +627,35 @@ private static IMetadata GetMetadataObjectField(IResolverContext context) // e.g. metadata for index 4 will not exist. only 3. // Depth: / 0 / 1 / 2 / 3 / 4 // Path: /books/items/items[0]/publishers/books + // + // To handle arbitrary nesting depths with sibling relationships, we need to include + // the relationship field path in the key. For example: + // - /entity/items[0]/rel1/nested uses key ::3::rel1 + // - /entity/items[0]/rel2/nested uses key ::3::rel2 + // - /entity/items[0]/rel1/nested/deeper uses key ::4::rel1::nested + // - /entity/items[0]/rel1/nested2/deeper uses key ::4::rel1::nested2 string objectParentName = GetMetadataKey(context.Path.Parent) + "::" + context.Path.Parent.Depth(); - return (IMetadata)context.ContextData[objectParentName]!; + string relationshipPath = GetRelationshipPathSuffix(context.Path.Parent); + if (!string.IsNullOrEmpty(relationshipPath)) + { + objectParentName = objectParentName + "::" + relationshipPath; + } + + if (context.ContextData.TryGetValue(objectParentName, out object? nestedMetadata) && nestedMetadata is not null) + { + return (IMetadata)nestedMetadata; + } + + return PaginationMetadata.MakeEmptyPaginationMetadata(); } string metadataKey = GetMetadataKey(context.Path) + "::" + context.Path.Depth(); - return (IMetadata)context.ContextData[metadataKey]!; + if (context.ContextData.TryGetValue(metadataKey, out object? rootMetadata) && rootMetadata is not null) + { + return (IMetadata)rootMetadata; + } + + return PaginationMetadata.MakeEmptyPaginationMetadata(); } private static string GetMetadataKey(HotChocolate.Path path) @@ -614,6 +672,50 @@ private static string GetMetadataKey(HotChocolate.Path path) return GetMetadataKey(path: path.Parent); } + /// + /// Builds a suffix representing the relationship path from the IndexerPathSegment (items[n]) + /// up to (but not including) the current path segment. This is used to create unique metadata + /// keys for sibling relationships at any nesting depth. + /// + /// The path to build the suffix for + /// + /// A string like "rel1" for /entity/items[0]/rel1, + /// or "rel1::nested" for /entity/items[0]/rel1/nested, + /// or empty string if no IndexerPathSegment is found in the path ancestry. + /// + private static string GetRelationshipPathSuffix(HotChocolate.Path path) + { + List pathParts = new(); + HotChocolate.Path? current = path; + + // Walk up the path collecting relationship field names until we hit an IndexerPathSegment + while (current is not null && !current.IsRoot) + { + if (current is IndexerPathSegment) + { + // We've reached items[n], stop here + break; + } + + if (current is NamePathSegment nameSegment) + { + pathParts.Add(nameSegment.Name); + } + + current = current.Parent; + } + + // If we didn't find an IndexerPathSegment, return empty (this handles root-level queries) + if (current is not IndexerPathSegment) + { + return string.Empty; + } + + // Reverse because we walked up the tree, but we want the path from root to leaf + pathParts.Reverse(); + return string.Join("::", pathParts); + } + /// /// Resolves the name of the root object of a selection set to /// use as the beginning of a key used to index pagination metadata in the @@ -655,7 +757,25 @@ private static void SetNewMetadataChildren(IResolverContext context, IMetadata? // When context.Path takes the form: "/entity/items[index]/nestedEntity" HC counts the depth as // if the path took the form: "/entity/items/items[index]/nestedEntity" -> Depth of "nestedEntity" // is 3 because depth is 0-indexed. - string contextKey = GetMetadataKey(context.Path) + "::" + context.Path.Depth(); + StringBuilder contextKeyBuilder = new(); + contextKeyBuilder + .Append(GetMetadataKey(context.Path)) + .Append("::") + .Append(context.Path.Depth()); + + // For relationship fields at any depth, include the relationship path suffix to distinguish + // between sibling relationships. This handles arbitrary nesting depths. + // e.g., "/entity/items[0]/rel1" gets key ::3::rel1 + // e.g., "/entity/items[0]/rel1/nested" gets key ::4::rel1::nested + string relationshipPath = GetRelationshipPathSuffix(context.Path); + if (!string.IsNullOrEmpty(relationshipPath)) + { + contextKeyBuilder + .Append("::") + .Append(relationshipPath); + } + + string contextKey = contextKeyBuilder.ToString(); // It's okay to overwrite the context when we are visiting a different item in items e.g. books/items/items[1]/publishers since // context for books/items/items[0]/publishers processing is done and that context isn't needed anymore. diff --git a/src/Core/Services/MetadataProviders/Converters/DatabaseObjectConverter.cs b/src/Core/Services/MetadataProviders/Converters/DatabaseObjectConverter.cs index c6e694a394..6d625c7f9d 100644 --- a/src/Core/Services/MetadataProviders/Converters/DatabaseObjectConverter.cs +++ b/src/Core/Services/MetadataProviders/Converters/DatabaseObjectConverter.cs @@ -17,6 +17,11 @@ namespace Azure.DataApiBuilder.Core.Services.MetadataProviders.Converters public class DatabaseObjectConverter : JsonConverter { private const string TYPE_NAME = "TypeName"; + private const string DOLLAR_CHAR = "$"; + + // ``DAB_ESCAPE$`` is used to escape column names that start with `$` during serialization. + // It is chosen to be unique enough to avoid collisions with actual column names. + private const string ESCAPED_DOLLARCHAR = "DAB_ESCAPE$"; public override DatabaseObject Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { @@ -29,6 +34,15 @@ public override DatabaseObject Read(ref Utf8JsonReader reader, Type typeToConver DatabaseObject objA = (DatabaseObject)JsonSerializer.Deserialize(document, concreteType, options)!; + foreach (PropertyInfo prop in objA.GetType().GetProperties().Where(IsSourceDefinitionOrDerivedClassProperty)) + { + SourceDefinition? sourceDef = (SourceDefinition?)prop.GetValue(objA); + if (sourceDef is not null) + { + UnescapeDollaredColumns(sourceDef); + } + } + return objA; } } @@ -58,12 +72,72 @@ public override void Write(Utf8JsonWriter writer, DatabaseObject value, JsonSeri } writer.WritePropertyName(prop.Name); - JsonSerializer.Serialize(writer, prop.GetValue(value), options); + object? propVal = prop.GetValue(value); + + // Only escape columns for properties whose type(derived type) is SourceDefinition. + if (IsSourceDefinitionOrDerivedClassProperty(prop) && propVal is SourceDefinition sourceDef) + { + EscapeDollaredColumns(sourceDef); + } + + JsonSerializer.Serialize(writer, propVal, options); } writer.WriteEndObject(); } + private static bool IsSourceDefinitionOrDerivedClassProperty(PropertyInfo prop) + { + // Return true for properties whose type is SourceDefinition or any class derived from SourceDefinition + return typeof(SourceDefinition).IsAssignableFrom(prop.PropertyType); + } + + /// + /// Escapes column keys that start with '$' to '_$' for serialization. + /// + private static void EscapeDollaredColumns(SourceDefinition sourceDef) + { + if (sourceDef.Columns is null || sourceDef.Columns.Count == 0) + { + return; + } + + List keysToEscape = sourceDef.Columns.Keys + .Where(k => k.StartsWith(DOLLAR_CHAR, StringComparison.Ordinal)) + .ToList(); + + foreach (string key in keysToEscape) + { + ColumnDefinition col = sourceDef.Columns[key]; + sourceDef.Columns.Remove(key); + string newKey = ESCAPED_DOLLARCHAR + key[1..]; + sourceDef.Columns[newKey] = col; + } + } + + /// + /// Unescapes column keys that start with '_$' to '$' for deserialization. + /// + private static void UnescapeDollaredColumns(SourceDefinition sourceDef) + { + if (sourceDef.Columns is null || sourceDef.Columns.Count == 0) + { + return; + } + + List keysToUnescape = sourceDef.Columns.Keys + .Where(k => k.StartsWith(ESCAPED_DOLLARCHAR, StringComparison.Ordinal)) + .ToList(); + + foreach (string key in keysToUnescape) + { + ColumnDefinition col = sourceDef.Columns[key]; + sourceDef.Columns.Remove(key); + string newKey = DOLLAR_CHAR + key[11..]; + sourceDef.Columns[newKey] = col; + } + } + private static Type GetTypeFromName(string typeName) { Type? type = Type.GetType(typeName); diff --git a/src/Directory.Packages.props b/src/Directory.Packages.props index 14f097915c..ccd69b9600 100644 --- a/src/Directory.Packages.props +++ b/src/Directory.Packages.props @@ -3,8 +3,13 @@ true - + + + + + + @@ -29,6 +34,9 @@ + + + diff --git a/src/Service.Tests/Authentication/Helpers/WebHostBuilderHelper.cs b/src/Service.Tests/Authentication/Helpers/WebHostBuilderHelper.cs index 4ccf7945d1..644bd338e3 100644 --- a/src/Service.Tests/Authentication/Helpers/WebHostBuilderHelper.cs +++ b/src/Service.Tests/Authentication/Helpers/WebHostBuilderHelper.cs @@ -75,6 +75,7 @@ public static async Task CreateWebHost( else { EasyAuthType easyAuthProvider = (EasyAuthType)Enum.Parse(typeof(EasyAuthType), provider, ignoreCase: true); + services.AddAuthentication() .AddEasyAuthAuthentication(easyAuthProvider); } diff --git a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs index 2780af63c5..1294c009da 100644 --- a/src/Service.Tests/Caching/CachingConfigProcessingTests.cs +++ b/src/Service.Tests/Caching/CachingConfigProcessingTests.cs @@ -5,7 +5,6 @@ using System.Text; using System.Text.Json; using Azure.DataApiBuilder.Config; -using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Config.ObjectModel; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -56,10 +55,7 @@ public void EntityCacheOptionsDeserialization_ValidJson( RuntimeConfigLoader.TryParseConfig( json: fullConfig, out RuntimeConfig? config, - logger: null, - connectionString: null, - replaceEnvVar: false, - replacementFailureMode: EnvironmentVariableReplacementFailureMode.Throw); + replacementSettings: null); // Assert Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed."); @@ -103,10 +99,7 @@ public void EntityCacheOptionsDeserialization_InvalidValues(string entityCacheCo bool isParsingSuccessful = RuntimeConfigLoader.TryParseConfig( json: fullConfig, out _, - logger: null, - connectionString: null, - replaceEnvVar: false, - replacementFailureMode: EnvironmentVariableReplacementFailureMode.Throw); + replacementSettings: null); // Assert Assert.IsFalse(isParsingSuccessful, message: "Expected JSON parsing to fail."); @@ -141,10 +134,7 @@ public void GlobalCacheOptionsDeserialization_ValidValues( RuntimeConfigLoader.TryParseConfig( json: fullConfig, out RuntimeConfig? config, - logger: null, - connectionString: null, - replaceEnvVar: false, - replacementFailureMode: EnvironmentVariableReplacementFailureMode.Throw); + replacementSettings: null); // Assert Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed."); @@ -171,8 +161,6 @@ public void GlobalCacheOptionsDeserialization_ValidValues( [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 0 }", DisplayName = "EntityCacheOptions.TtlSeconds set to zero is invalid configuration.")] [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": -1 }", DisplayName = "EntityCacheOptions.TtlSeconds set to negative number is invalid configuration.")] [DataRow(@",""cache"": { ""enabled"": true, ""ttl-seconds"": 1.1 }", DisplayName = "EntityCacheOptions.TtlSeconds set to decimal is invalid configuration.")] - [DataRow(@",""cache"": { ""enabled"": 1 }", DisplayName = "EntityCacheOptions.Enabled property set to 1 should fail because not a boolean.")] - [DataRow(@",""cache"": { ""enabled"": 0 }", DisplayName = "EntityCacheOptions.Enabled property set to 0 should fail because not a boolean.")] [DataRow(@",""cache"": 1", DisplayName = "EntityCacheOptions property set to 1 should fail because it's not a JSON object.")] [DataRow(@",""cache"": 0", DisplayName = "EntityCacheOptions property set to 0 should fail because it's not a JSON object.")] [DataRow(@",""cache"": true", DisplayName = "EntityCacheOptions property set to true should fail because it's not a JSON object.")] @@ -187,10 +175,7 @@ public void GlobalCacheOptionsDeserialization_InvalidValues(string globalCacheCo bool parsingSuccessful = RuntimeConfigLoader.TryParseConfig( json: fullConfig, out _, - logger: null, - connectionString: null, - replaceEnvVar: false, - replacementFailureMode: EnvironmentVariableReplacementFailureMode.Throw); + replacementSettings: null); // Assert Assert.IsFalse(parsingSuccessful, message: "Expected JSON parsing to fail."); @@ -216,10 +201,7 @@ public void GlobalCacheOptionsOverridesEntityCacheOptions(string globalCacheConf RuntimeConfigLoader.TryParseConfig( json: fullConfig, out RuntimeConfig? config, - logger: null, - connectionString: null, - replaceEnvVar: false, - replacementFailureMode: EnvironmentVariableReplacementFailureMode.Throw); + replacementSettings: null); // Assert Assert.IsNotNull(config, message: "Config must not be null, runtime config JSON deserialization failed."); @@ -252,10 +234,7 @@ public void UserDefinedTtlWrittenToSerializedJsonConfigFile(bool expectIsUserDef RuntimeConfigLoader.TryParseConfig( json: fullConfig, out RuntimeConfig? config, - logger: null, - connectionString: null, - replaceEnvVar: false, - replacementFailureMode: EnvironmentVariableReplacementFailureMode.Throw); + replacementSettings: null); Assert.IsNotNull(config, message: "Test setup failure. Config must not be null, runtime config JSON deserialization failed."); // Act @@ -300,10 +279,7 @@ public void CachePropertyNotWrittenToSerializedJsonConfigFile(string cacheConfig RuntimeConfigLoader.TryParseConfig( json: fullConfig, out RuntimeConfig? config, - logger: null, - connectionString: null, - replaceEnvVar: false, - replacementFailureMode: EnvironmentVariableReplacementFailureMode.Throw); + replacementSettings: null); Assert.IsNotNull(config, message: "Test setup failure. Config must not be null, runtime config JSON deserialization failed."); // Act @@ -342,10 +318,7 @@ public void DefaultTtlNotWrittenToSerializedJsonConfigFile(string cacheConfig) RuntimeConfigLoader.TryParseConfig( json: fullConfig, out RuntimeConfig? config, - logger: null, - connectionString: null, - replaceEnvVar: false, - replacementFailureMode: EnvironmentVariableReplacementFailureMode.Throw); + replacementSettings: null); Assert.IsNotNull(config, message: "Test setup failure. Config must not be null, runtime config JSON deserialization failed."); // Act @@ -408,7 +381,7 @@ private static string GetRawConfigJson(string globalCacheConfig, string entityCa ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" }, ""mode"": ""production"" }" + globalCacheConfig + diff --git a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs index 02b7ca6492..68c9225b96 100644 --- a/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs +++ b/src/Service.Tests/Caching/DabCacheServiceIntegrationTests.cs @@ -760,12 +760,13 @@ private static Mock CreateMockRuntimeConfigProvider(strin }); Mock mockRuntimeConfig = new( - string.Empty, - dataSource, - entities, - null, - null, - null + string.Empty, // Schema + dataSource, // DataSource + entities, // Entities + null, // Autoentities + null, // Runtime + null, // DataSourceFiles + null // AzureKeyVault ); mockRuntimeConfig .Setup(c => c.GetDataSourceFromDataSourceName(It.IsAny())) diff --git a/src/Service.Tests/Caching/HealthEndpointCachingTests.cs b/src/Service.Tests/Caching/HealthEndpointCachingTests.cs index fcc3e097e5..664e6070e6 100644 --- a/src/Service.Tests/Caching/HealthEndpointCachingTests.cs +++ b/src/Service.Tests/Caching/HealthEndpointCachingTests.cs @@ -148,7 +148,7 @@ private static void CreateCustomConfigFile(Dictionary entityMap, ConfigurationTests.GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null, Health: new(true)); - HostOptions hostOptions = new(Mode: HostMode.Development, Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.StaticWebApps) }); + HostOptions hostOptions = new(Mode: HostMode.Development, Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.AppService) }); RuntimeConfig runtimeConfig = new( Schema: string.Empty, diff --git a/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs b/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs index 963211ae40..846a34ebee 100644 --- a/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs +++ b/src/Service.Tests/Configuration/AuthenticationConfigValidatorUnitTests.cs @@ -38,7 +38,7 @@ public void TestInitialize() public void ValidateEasyAuthConfig() { RuntimeConfig config = - CreateRuntimeConfigWithOptionalAuthN(new AuthenticationOptions(EasyAuthType.StaticWebApps.ToString(), null)); + CreateRuntimeConfigWithOptionalAuthN(new AuthenticationOptions(EasyAuthType.AppService.ToString(), null)); _mockFileSystem.AddFile( FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, diff --git a/src/Service.Tests/Configuration/ConfigurationTests.cs b/src/Service.Tests/Configuration/ConfigurationTests.cs index 65f6e6643b..9df54be519 100644 --- a/src/Service.Tests/Configuration/ConfigurationTests.cs +++ b/src/Service.Tests/Configuration/ConfigurationTests.cs @@ -50,6 +50,7 @@ using Serilog; using VerifyMSTest; using static Azure.DataApiBuilder.Config.FileSystemRuntimeConfigLoader; +using static Azure.DataApiBuilder.Core.AuthenticationHelpers.AppServiceAuthentication; using static Azure.DataApiBuilder.Service.Tests.Configuration.ConfigurationEndpoints; using static Azure.DataApiBuilder.Service.Tests.Configuration.TestConfigFileReader; @@ -74,7 +75,7 @@ public class ConfigurationTests private const string BROWSER_ACCEPT_HEADER = "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9"; private const int RETRY_COUNT = 5; - private const int RETRY_WAIT_SECONDS = 1; + private const int RETRY_WAIT_SECONDS = 2; /// /// @@ -393,7 +394,7 @@ public class ConfigurationTests ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" }, ""mode"": ""development"" } @@ -656,13 +657,53 @@ type Moon { }, ""host"": { ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }, ""entities"":{ } }"; + public const string CONFIG_FILE_WITH_BOOLEAN_AS_ENV = @"{ + // Link for latest draft schema. + ""$schema"":""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""sample-conn-string"", + ""health"": { + ""enabled"": + } + }, + ""runtime"": { + ""health"": { + ""enabled"": + }, + ""rest"": { + ""enabled"": , + ""path"": ""/api"" + }, + ""graphql"": { + ""enabled"": , + ""path"": ""/graphql"", + ""allow-introspection"": true + }, + ""host"": { + ""authentication"": { + ""provider"": ""AppService"" + } + }, + ""telemetry"": { + ""application-insights"":{ + ""enabled"": , + ""connection-string"":""sample-ai-connection-string"" + } + + } + + }, + ""entities"":{ } + }"; + [TestCleanup] public void CleanupAfterEachTest() { @@ -838,9 +879,9 @@ public void MsSqlConnStringSupplementedWithAppNameProperty( // Act bool configParsed = RuntimeConfigLoader.TryParseConfig( - runtimeConfig.ToJson(), - out RuntimeConfig updatedRuntimeConfig, - replaceEnvVar: true); + json: runtimeConfig.ToJson(), + config: out RuntimeConfig updatedRuntimeConfig, + replacementSettings: new(doReplaceEnvVar: true)); // Assert Assert.AreEqual( @@ -891,9 +932,9 @@ public void PgSqlConnStringSupplementedWithAppNameProperty( // Act bool configParsed = RuntimeConfigLoader.TryParseConfig( - runtimeConfig.ToJson(), - out RuntimeConfig updatedRuntimeConfig, - replaceEnvVar: true); + json: runtimeConfig.ToJson(), + config: out RuntimeConfig updatedRuntimeConfig, + replacementSettings: new(doReplaceEnvVar: true)); // Assert Assert.AreEqual( @@ -956,9 +997,9 @@ public void TestConnectionStringIsCorrectlyUpdatedWithApplicationName( // Act bool configParsed = RuntimeConfigLoader.TryParseConfig( - runtimeConfig.ToJson(), - out RuntimeConfig updatedRuntimeConfig, - replaceEnvVar: true); + json: runtimeConfig.ToJson(), + config: out RuntimeConfig updatedRuntimeConfig, + replacementSettings: new(doReplaceEnvVar: true)); // Assert Assert.AreEqual( @@ -1128,7 +1169,7 @@ public async Task TestSqlSettingPostStartupConfigurations(string configurationEn await httpClient.GetAsync($"/{OPENAPI_SWAGGER_ENDPOINT}"); Assert.AreEqual(HttpStatusCode.ServiceUnavailable, preConfigOpenApiSwaggerEndpointAvailability.StatusCode); - HttpStatusCode responseCode = await HydratePostStartupConfiguration(httpClient, content, configurationEndpoint); + HttpStatusCode responseCode = await HydratePostStartupConfiguration(httpClient, content, configurationEndpoint, configuration.Runtime.Rest); // When the authorization resolver is properly configured, authorization will have failed // because no auth headers are present. @@ -1140,10 +1181,21 @@ public async Task TestSqlSettingPostStartupConfigurations(string configurationEn // Sends a GET request to a protected entity which requires a specific role to access. // Authorization will pass because proper auth headers are present. HttpRequestMessage message = new(method: HttpMethod.Get, requestUri: $"api/{POST_STARTUP_CONFIG_ENTITY}"); - string swaTokenPayload = AuthTestHelper.CreateStaticWebAppsEasyAuthToken( - addAuthenticated: true, - specificRole: POST_STARTUP_CONFIG_ROLE); - message.Headers.Add(Config.ObjectModel.AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, swaTokenPayload); + + // Use an AppService EasyAuth principal carrying the required role when + // authentication is configured to use AppService. + string appServiceTokenPayload = AuthTestHelper.CreateAppServiceEasyAuthToken( + roleClaimType: Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, + additionalClaims: + [ + new AppServiceClaim + { + Typ = Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, + Val = POST_STARTUP_CONFIG_ROLE + } + ]); + + message.Headers.Add(Config.ObjectModel.AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, appServiceTokenPayload); message.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, POST_STARTUP_CONFIG_ROLE); HttpResponseMessage authorizedResponse = await httpClient.SendAsync(message); Assert.AreEqual(expected: HttpStatusCode.OK, actual: authorizedResponse.StatusCode); @@ -1808,8 +1860,44 @@ public void TestBasicConfigSchemaWithNoOptionalFieldsIsValid(string jsonData) JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); - Assert.IsTrue(result.IsValid); + Assert.AreEqual("", String.Join('\n', result.ValidationErrors?.Select(s => $"{s.Message} at {s.Path} {s.LineNumber} {s.LinePosition}") ?? []), "Expected no validation errors."); Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors)); + + Assert.IsTrue(result.IsValid); + schemaValidatorLogger.Verify( + x => x.Log( + LogLevel.Information, + It.IsAny(), + It.Is((o, t) => o.ToString()!.Contains($"The config satisfies the schema requirements.")), + It.IsAny(), + (Func)It.IsAny()), + Times.Once); + } + + [DataTestMethod] + [DataRow("true", DisplayName = "Validates variable boolean schema for true value")] + [DataRow("false", DisplayName = "Validates variable boolean schema for false value.")] + [DataRow("\"true\"", DisplayName = "Validates variable boolean schema for true as string.")] + [DataRow("\"false\"", DisplayName = "Validates variable boolean schema for false as string.")] + [DataRow("\"1\"", DisplayName = "Validates variable boolean schema for 1 as string.")] + [DataRow("\"0\"", DisplayName = "Validates variable boolean schema for 0as string.")] + [DataRow("\"@env('SAMPLE')\"", DisplayName = "Validates variable boolean schema for environment variables.")] + [DataRow("\"@akv('SAMPLE')\"", DisplayName = "Validates variable boolean schema for keyvaul variables.")] + public void TestBasicConfigSchemaWithFlexibleBoolean(string Value) + { + Mock> schemaValidatorLogger = new(); + + string jsonSchema = File.ReadAllText("dab.draft.schema.json"); + + JsonConfigSchemaValidator jsonSchemaValidator = new(schemaValidatorLogger.Object, new MockFileSystem()); + + string jsonData = CONFIG_FILE_WITH_BOOLEAN_AS_ENV.Replace("", Value); + JsonSchemaValidationResult result = jsonSchemaValidator.ValidateJsonConfigWithSchema(jsonSchema, jsonData); + Assert.AreEqual("", String.Join('\n', result.ValidationErrors?.Select(s => $"{s.Message} at {s.Path} {s.LineNumber} {s.LinePosition}") ?? []), "Expected no validation errors."); + + Assert.IsTrue(EnumerableUtilities.IsNullOrEmpty(result.ValidationErrors), "Validation Erros null of empty"); + + Assert.IsTrue(result.IsValid, "Result should be valid"); schemaValidatorLogger.Verify( x => x.Log( LogLevel.Information, @@ -2346,7 +2434,12 @@ public async Task TestSPRestDefaultsForManuallyConstructedConfigs( HttpStatusCode expectedResponseStatusCode) { string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, entityJson); - RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig, logger: null, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); + RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig deserializedConfig, + replacementSettings: new(), + logger: null, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); string configFileName = "custom-config.json"; File.WriteAllText(configFileName, deserializedConfig.ToJson()); string[] args = new[] @@ -2429,7 +2522,12 @@ public async Task SanityTestForRestAndGQLRequestsWithoutMultipleMutationFeatureF // The configuration file is constructed by merging hard-coded JSON strings to simulate the scenario where users manually edit the // configuration file (instead of using CLI). string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, BOOK_ENTITY_JSON); - Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig, logger: null, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig deserializedConfig, + replacementSettings: new(), + logger: null, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL))); string configFileName = "custom-config.json"; File.WriteAllText(configFileName, deserializedConfig.ToJson()); string[] args = new[] @@ -2488,7 +2586,7 @@ public async Task TestRuntimeBaseRouteInNextLinkForPaginatedRestResponse() { const string CUSTOM_CONFIG = "custom-config.json"; string runtimeBaseRoute = "/base-route"; - TestHelper.ConstructNewConfigWithSpecifiedHostMode(CUSTOM_CONFIG, HostMode.Production, TestCategory.MSSQL, runtimeBaseRoute: runtimeBaseRoute); + TestHelper.ConstructNewConfigWithSpecifiedHostMode(CUSTOM_CONFIG, HostMode.Production, TestCategory.MSSQL, runtimeBaseRoute: runtimeBaseRoute, "StaticWebApps"); string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" @@ -2528,26 +2626,37 @@ public async Task TestRuntimeBaseRouteInNextLinkForPaginatedRestResponse() /// Expected HTTP status code code for the GraphQL request [DataTestMethod] [TestCategory(TestCategory.MSSQL)] - [DataRow(true, true, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Both Rest and GraphQL endpoints enabled globally")] - [DataRow(true, false, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled and GraphQL endpoints disabled globally")] - [DataRow(false, true, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled and GraphQL endpoints enabled globally")] - [DataRow(true, true, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Both Rest and GraphQL endpoints enabled globally")] - [DataRow(true, false, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled and GraphQL endpoints disabled globally")] - [DataRow(false, true, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled and GraphQL endpoints enabled globally")] - public async Task TestGlobalFlagToEnableRestAndGraphQLForHostedAndNonHostedEnvironment( + [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest, GraphQL, and MCP enabled globally")] + [DataRow(true, true, false, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest and GraphQL enabled, MCP disabled globally")] + [DataRow(true, false, true, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL disabled, and MCP enabled globally")] + [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest enabled, GraphQL and MCP disabled globally")] + [DataRow(false, true, true, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled, GraphQL and MCP enabled globally")] + [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest disabled, GraphQL enabled, and MCP disabled globally")] + [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT, DisplayName = "V1 - Rest and GraphQL disabled, MCP enabled globally")] + [DataRow(true, true, true, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest, GraphQL, and MCP enabled globally")] + [DataRow(true, true, false, HttpStatusCode.OK, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest and GraphQL enabled, MCP disabled globally")] + [DataRow(true, false, true, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL disabled, and MCP enabled globally")] + [DataRow(true, false, false, HttpStatusCode.OK, HttpStatusCode.NotFound, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest enabled, GraphQL and MCP disabled globally")] + [DataRow(false, true, true, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled, GraphQL and MCP enabled globally")] + [DataRow(false, true, false, HttpStatusCode.NotFound, HttpStatusCode.OK, HttpStatusCode.NotFound, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest disabled, GraphQL enabled, and MCP disabled globally")] + [DataRow(false, false, true, HttpStatusCode.NotFound, HttpStatusCode.NotFound, HttpStatusCode.OK, CONFIGURATION_ENDPOINT_V2, DisplayName = "V2 - Rest and GraphQL disabled, MCP enabled globally")] + public async Task TestGlobalFlagToEnableRestGraphQLAndMcpForHostedAndNonHostedEnvironment( bool isRestEnabled, bool isGraphQLEnabled, + bool isMcpEnabled, HttpStatusCode expectedStatusCodeForREST, HttpStatusCode expectedStatusCodeForGraphQL, + HttpStatusCode expectedStatusCodeForMcp, string configurationEndpoint) { GraphQLRuntimeOptions graphqlOptions = new(Enabled: isGraphQLEnabled); RestRuntimeOptions restRuntimeOptions = new(Enabled: isRestEnabled); + McpRuntimeOptions mcpRuntimeOptions = new(Enabled: isMcpEnabled); DataSource dataSource = new(DatabaseType.MSSQL, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null); - RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, null); + RuntimeConfig configuration = InitMinimalRuntimeConfig(dataSource, graphqlOptions, restRuntimeOptions, mcpRuntimeOptions); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, configuration.ToJson()); @@ -2570,17 +2679,23 @@ public async Task TestGlobalFlagToEnableRestAndGraphQLForHostedAndNonHostedEnvir object payload = new { query }; - HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + // GraphQL request + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, configuration.Runtime.GraphQL.Path) { Content = JsonContent.Create(payload) }; HttpResponseMessage graphQLResponse = await client.SendAsync(graphQLRequest); - Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode); + Assert.AreEqual(expectedStatusCodeForGraphQL, graphQLResponse.StatusCode, "The GraphQL response is different from the expected result."); - HttpRequestMessage restRequest = new(HttpMethod.Get, "/api/Book"); + // REST request + HttpRequestMessage restRequest = new(HttpMethod.Get, $"{configuration.Runtime.Rest.Path}/Book"); HttpResponseMessage restResponse = await client.SendAsync(restRequest); - Assert.AreEqual(expectedStatusCodeForREST, restResponse.StatusCode); + Assert.AreEqual(expectedStatusCodeForREST, restResponse.StatusCode, "The REST response is different from the expected result."); + + // MCP request + HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp); + Assert.AreEqual(expectedStatusCodeForMcp, mcpResponseCode, "The MCP response is different from the expected result."); } // Hosted Scenario @@ -2590,18 +2705,19 @@ public async Task TestGlobalFlagToEnableRestAndGraphQLForHostedAndNonHostedEnvir { JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); - HttpResponseMessage postResult = - await client.PostAsync(configurationEndpoint, content); - Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); - - HttpStatusCode restResponseCode = await GetRestResponsePostConfigHydration(client); + HttpResponseMessage postResult = await client.PostAsync(configurationEndpoint, content); + Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode, "The hydration post-response is different from the expected result."); - Assert.AreEqual(expected: expectedStatusCodeForREST, actual: restResponseCode); + HttpStatusCode restResponseCode = await GetRestResponsePostConfigHydration(client, configuration.Runtime.Rest); + Assert.AreEqual(expected: expectedStatusCodeForREST, actual: restResponseCode, "The REST hydration post-response is different from the expected result."); - HttpStatusCode graphqlResponseCode = await GetGraphQLResponsePostConfigHydration(client); - - Assert.AreEqual(expected: expectedStatusCodeForGraphQL, actual: graphqlResponseCode); + HttpStatusCode graphqlResponseCode = await GetGraphQLResponsePostConfigHydration(client, configuration.Runtime.GraphQL); + Assert.AreEqual(expected: expectedStatusCodeForGraphQL, actual: graphqlResponseCode, "The GraphQL hydration post-response is different from the expected result."); + // TODO: Issue #3012 - Currently DAB is unable to start MCP with the hydration post-response. + // This needs to be fixed before uncommenting the MCP check + // HttpStatusCode mcpResponseCode = await GetMcpResponse(client, configuration.Runtime.Mcp); + // Assert.AreEqual(expected: expectedStatusCodeForMcp, actual: mcpResponseCode, "The MCP hydration post-response is different from the expected result."); } } @@ -2663,12 +2779,30 @@ public async Task ValidateErrorMessageForMutationWithoutReadPermission() $"--ConfigFileName={CUSTOM_CONFIG}" }; - string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(); + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(); using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) { try { + // Pre-clean to avoid PK violation if a previous run left the row behind. + string preCleanupDeleteMutation = @" + mutation { + deleteStock(categoryid: 5001, pieceid: 5001) { + categoryid + pieceid + } + }"; + + _ = await GraphQLRequestExecutor.PostGraphQLRequestAsync( + client, + server.Services.GetRequiredService(), + query: preCleanupDeleteMutation, + queryName: "deleteStock", + variables: null, + authToken: authToken, + clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED); + // A create mutation operation is executed in the context of Anonymous role. The Anonymous role has create action configured but lacks // read action. As a result, a new record should be created in the database but the mutation operation should return an error message. string graphQLMutation = @" @@ -2693,7 +2827,8 @@ public async Task ValidateErrorMessageForMutationWithoutReadPermission() query: graphQLMutation, queryName: "createStock", variables: null, - clientRoleHeader: null + authToken: null, + clientRoleHeader: AuthorizationResolver.ROLE_ANONYMOUS ); Assert.IsNotNull(mutationResponse); @@ -2995,7 +3130,7 @@ public async Task ValidateInheritanceOfReadPermissionFromAnonymous() query: graphQLMutation, queryName: "createStock", variables: null, - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(), + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED ); @@ -3290,7 +3425,12 @@ public async Task ValidateStrictModeAsDefaultForRestRequestBody(bool includeExtr // The BASE_CONFIG omits the rest.request-body-strict option in the runtime section. string configJson = TestHelper.AddPropertiesToJson(TestHelper.BASE_CONFIG, entityJson); - RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig, logger: null, GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); + RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig deserializedConfig, + replacementSettings: new(), + logger: null, + GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL)); const string CUSTOM_CONFIG = "custom-config.json"; File.WriteAllText(CUSTOM_CONFIG, deserializedConfig.ToJson()); string[] args = new[] @@ -3304,7 +3444,7 @@ public async Task ValidateStrictModeAsDefaultForRestRequestBody(bool includeExtr HttpMethod httpMethod = SqlTestHelper.ConvertRestMethodToHttpMethod(SupportedHttpVerb.Post); string requestBody = @"{ ""title"": ""Harry Potter and the Order of Phoenix"", - ""publisher_id"": 1234"; + ""publisher_id"": 1234 "; if (includeExtraneousFieldInRequestBody) { @@ -3557,7 +3697,7 @@ type Planet @model(name:""PlanetAlias"") { [TestCategory(TestCategory.MSSQL)] [DataRow(HostMode.Development, EasyAuthType.AppService, false, false, DisplayName = "AppService Dev - No EnvVars - No Error")] [DataRow(HostMode.Development, EasyAuthType.AppService, true, false, DisplayName = "AppService Dev - EnvVars - No Error")] - [DataRow(HostMode.Production, EasyAuthType.AppService, false, true, DisplayName = "AppService Prod - No EnvVars - Error")] + [DataRow(HostMode.Production, EasyAuthType.AppService, false, false, DisplayName = "AppService Prod - No EnvVars - Error")] [DataRow(HostMode.Production, EasyAuthType.AppService, true, false, DisplayName = "AppService Prod - EnvVars - Error")] [DataRow(HostMode.Development, EasyAuthType.StaticWebApps, false, false, DisplayName = "SWA Dev - No EnvVars - No Error")] [DataRow(HostMode.Development, EasyAuthType.StaticWebApps, true, false, DisplayName = "SWA Dev - EnvVars - No Error")] @@ -3591,8 +3731,10 @@ public void TestProductionModeAppServiceEnvironmentCheck(HostMode hostMode, Easy string[] args = new[] { $"--ConfigFileName={CUSTOM_CONFIG}" - }; + }; + // When host is in Production mode with AppService as Identity Provider and the environment variables are not set + // we do not throw an exception any longer(PR: 2943), instead log a warning to the user. In this case expectError is false. // This test only checks for startup errors, so no requests are sent to the test server. try { @@ -3646,7 +3788,7 @@ public async Task TestSchemaIntrospectionQuery(bool enableIntrospection, bool ex using (HttpClient client = server.CreateClient()) { JsonContent content = GetPostStartupConfigParams(MSSQL_ENVIRONMENT, configuration, configurationEndpoint); - HttpStatusCode responseCode = await HydratePostStartupConfiguration(client, content, configurationEndpoint); + HttpStatusCode responseCode = await HydratePostStartupConfiguration(client, content, configurationEndpoint, configuration.Runtime.Rest); Assert.AreEqual(expected: HttpStatusCode.OK, actual: responseCode, message: "Configuration hydration failed."); @@ -4271,6 +4413,176 @@ public void FileSinkSerialization( } } + /// + /// Test validates that autoentities section can be deserialized and serialized correctly. + /// + [DataTestMethod] + [TestCategory(TestCategory.MSSQL)] + [DataRow(null, null, null, null, null, null, null, null, null, "anonymous", EntityActionOperation.Read)] + [DataRow(new[] { "%.%" }, new[] { "%.%" }, "{object}", true, true, true, false, 5, EntityCacheLevel.L1L2, "anonymous", EntityActionOperation.Read)] + [DataRow(new[] { "books.%" }, new[] { "books.pages.%" }, "books_{object}", false, false, false, true, 2147483647, EntityCacheLevel.L1, "test-user", EntityActionOperation.Delete)] + [DataRow(new[] { "books.%" }, null, "books_{object}", false, null, false, null, 2147483647, null, "test-user", EntityActionOperation.Delete)] + [DataRow(null, new[] { "books.pages.%" }, null, null, false, null, true, null, EntityCacheLevel.L1, "test-user", EntityActionOperation.Delete)] + [DataRow(new[] { "title.%", "books.%", "names.%" }, new[] { "names.%", "%.%" }, "{schema}.{object}", true, false, false, true, 1, null, "second-test-user", EntityActionOperation.Create)] + public void TestAutoEntitiesSerializationDeserialization( + string[]? include, + string[]? exclude, + string? name, + bool? restEnabled, + bool? graphqlEnabled, + bool? healthCheckEnabled, + bool? cacheEnabled, + int? cacheTTL, + EntityCacheLevel? cacheLevel, + string role, + EntityActionOperation entityActionOp) + { + TestHelper.SetupDatabaseEnvironment(MSSQL_ENVIRONMENT); + + Dictionary createdAutoentity = new(); + createdAutoentity.Add("test-entity", + new Autoentity( + Patterns: new AutoentityPatterns(include, exclude, name), + Template: new AutoentityTemplate( + Rest: restEnabled == null ? null : new EntityRestOptions(Enabled: (bool)restEnabled), + GraphQL: graphqlEnabled == null ? null : new EntityGraphQLOptions(Singular: string.Empty, Plural: string.Empty, Enabled: (bool)graphqlEnabled), + Health: healthCheckEnabled == null ? null : new EntityHealthCheckConfig(healthCheckEnabled), + Cache: (cacheEnabled == null && cacheTTL == null && cacheLevel == null) ? null : new EntityCacheOptions(Enabled: cacheEnabled, TtlSeconds: cacheTTL, Level: cacheLevel) + ), + Permissions: new EntityPermission[1])); + + EntityAction[] entityActions = new EntityAction[] { new(entityActionOp, null, null) }; + createdAutoentity["test-entity"].Permissions[0] = new EntityPermission(role, entityActions); + RuntimeAutoentities autoentities = new(createdAutoentity); + + FileSystemRuntimeConfigLoader baseLoader = TestHelper.GetRuntimeConfigLoader(); + baseLoader.TryLoadKnownConfig(out RuntimeConfig? baseConfig); + + RuntimeConfig config = new( + Schema: baseConfig!.Schema, + DataSource: baseConfig.DataSource, + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(null, null), + Telemetry: new() + ), + Entities: baseConfig.Entities, + Autoentities: autoentities + ); + + string configWithCustomJson = config.ToJson(); + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(configWithCustomJson, out RuntimeConfig? deserializedRuntimeConfig)); + + string serializedConfig = deserializedRuntimeConfig.ToJson(); + + using (JsonDocument parsedDocument = JsonDocument.Parse(serializedConfig)) + { + JsonElement root = parsedDocument.RootElement; + JsonElement autoentitiesElement = root.GetProperty("autoentities"); + + bool entityExists = autoentitiesElement.TryGetProperty("test-entity", out JsonElement entityElement); + Assert.AreEqual(expected: true, actual: entityExists); + + // Validate patterns properties and their values exists in autoentities + bool expectedPatternsExist = include != null || exclude != null || name != null; + bool patternsExists = entityElement.TryGetProperty("patterns", out JsonElement patternsElement); + Assert.AreEqual(expected: expectedPatternsExist, actual: patternsExists); + + if (patternsExists) + { + bool includeExists = patternsElement.TryGetProperty("include", out JsonElement includeElement); + Assert.AreEqual(expected: (include != null), actual: includeExists); + if (includeExists) + { + CollectionAssert.AreEqual(expected: include, actual: includeElement.EnumerateArray().Select(e => e.GetString()).ToArray()); + } + + bool excludeExists = patternsElement.TryGetProperty("exclude", out JsonElement excludeElement); + Assert.AreEqual(expected: (exclude != null), actual: excludeExists); + if (excludeExists) + { + CollectionAssert.AreEqual(expected: exclude, actual: excludeElement.EnumerateArray().Select(e => e.GetString()).ToArray()); + } + + bool nameExists = patternsElement.TryGetProperty("name", out JsonElement nameElement); + Assert.AreEqual(expected: (name != null), actual: nameExists); + if (nameExists) + { + Assert.AreEqual(expected: name, actual: nameElement.GetString()); + } + } + + // Validate template properties and their values exists in autoentities + bool expectedTemplateExist = restEnabled != null || graphqlEnabled != null || healthCheckEnabled != null + || cacheEnabled != null || cacheLevel != null || cacheTTL != null; + bool templateExists = entityElement.TryGetProperty("template", out JsonElement templateElement); + Assert.AreEqual(expected: expectedTemplateExist, actual: templateExists); + + if (templateExists) + { + bool restPropertyExists = templateElement.TryGetProperty("rest", out JsonElement restElement); + Assert.AreEqual(expected: (restEnabled != null), actual: restPropertyExists); + if (restPropertyExists) + { + Assert.IsTrue(restElement.TryGetProperty("enabled", out JsonElement restEnabledElement)); + Assert.AreEqual(expected: restEnabled, actual: restEnabledElement.GetBoolean()); + } + + bool graphqlPropertyExists = templateElement.TryGetProperty("graphql", out JsonElement graphqlElement); + Assert.AreEqual(expected: (graphqlEnabled != null), actual: graphqlPropertyExists); + if (graphqlPropertyExists) + { + Assert.IsTrue(graphqlElement.TryGetProperty("enabled", out JsonElement graphqlEnabledElement)); + Assert.AreEqual(expected: graphqlEnabled, actual: graphqlEnabledElement.GetBoolean()); + } + + bool healthPropertyExists = templateElement.TryGetProperty("health", out JsonElement healthElement); + Assert.AreEqual(expected: (healthCheckEnabled != null), actual: healthPropertyExists); + if (healthPropertyExists) + { + Assert.IsTrue(healthElement.TryGetProperty("enabled", out JsonElement healthEnabledElement)); + Assert.AreEqual(expected: healthCheckEnabled, actual: healthEnabledElement.GetBoolean()); + } + + bool expectedCacheExist = cacheEnabled != null || cacheTTL != null || cacheLevel != null; + bool cachePropertyExists = templateElement.TryGetProperty("cache", out JsonElement cacheElement); + Assert.AreEqual(expected: expectedCacheExist, actual: cachePropertyExists); + if (cacheEnabled != null) + { + Assert.IsTrue(cacheElement.TryGetProperty("enabled", out JsonElement cacheEnabledElement)); + Assert.AreEqual(expected: cacheEnabled, actual: cacheEnabledElement.GetBoolean()); + } + + if (cacheTTL != null) + { + Assert.IsTrue(cacheElement.TryGetProperty("ttl-seconds", out JsonElement cacheTtlElement)); + Assert.AreEqual(expected: cacheTTL, actual: cacheTtlElement.GetInt32()); + } + + if (cacheLevel != null) + { + Assert.IsTrue(cacheElement.TryGetProperty("level", out JsonElement cacheLevelElement)); + Assert.IsTrue(string.Equals(cacheLevel.ToString(), cacheLevelElement.GetString(), StringComparison.OrdinalIgnoreCase)); + } + } + + // Validate permissions properties and their values exists in autoentities + JsonElement permissionsElement = entityElement.GetProperty("permissions"); + + bool roleExists = permissionsElement[0].TryGetProperty("role", out JsonElement roleElement); + Assert.AreEqual(expected: true, actual: roleExists); + Assert.AreEqual(expected: role, actual: roleElement.GetString()); + + bool entityActionsExists = permissionsElement[0].TryGetProperty("actions", out JsonElement entityActionsElement); + Assert.AreEqual(expected: true, actual: entityActionsExists); + bool entityActionOpExists = entityActionsElement[0].TryGetProperty("action", out JsonElement entityActionOpElement); + Assert.AreEqual(expected: true, actual: entityActionOpExists); + Assert.IsTrue(string.Equals(entityActionOp.ToString(), entityActionOpElement.GetString(), StringComparison.OrdinalIgnoreCase)); + } + } + #nullable disable /// @@ -5241,41 +5553,67 @@ private static JsonContent GetPostStartupConfigParams(string environment, Runtim /// by executing HTTP requests against the engine until a non-503 error is received. /// /// Client used for request execution. - /// Post-startup configuration + /// New config file content that will be added to DAB. + /// Endpoint through which content will be sent to DAB." + /// Global settings used at runtime for REST APIs. /// ServiceUnavailable if service is not successfully hydrated with config - private static async Task HydratePostStartupConfiguration(HttpClient httpClient, JsonContent content, string configurationEndpoint) + private static async Task HydratePostStartupConfiguration(HttpClient httpClient, JsonContent content, string configurationEndpoint, RestRuntimeOptions rest) { - // Hydrate configuration post-startup - HttpResponseMessage postResult = - await httpClient.PostAsync(configurationEndpoint, content); - Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode); + string appServiceTokenPayload = AuthTestHelper.CreateAppServiceEasyAuthToken( + roleClaimType: Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, + additionalClaims: + [ + new AppServiceClaim + { + Typ = Config.ObjectModel.AuthenticationOptions.ROLE_CLAIM_TYPE, + Val = POST_STARTUP_CONFIG_ROLE + } + ]); - return await GetRestResponsePostConfigHydration(httpClient); + using HttpRequestMessage postRequest = new(HttpMethod.Post, configurationEndpoint) + { + Content = content + }; + + postRequest.Headers.Add( + Config.ObjectModel.AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, + appServiceTokenPayload); + + HttpResponseMessage postResult = await httpClient.SendAsync(postRequest); + string body = await postResult.Content.ReadAsStringAsync(); + Assert.AreEqual(HttpStatusCode.OK, postResult.StatusCode, body); + + return await GetRestResponsePostConfigHydration(httpClient, rest); } /// /// Executing REST requests against the engine until a non-503 error is received. /// /// Client used for request execution. + /// Global settings used at runtime for REST APIs. /// ServiceUnavailable if service is not successfully hydrated with config, /// else the response code from the REST request - private static async Task GetRestResponsePostConfigHydration(HttpClient httpClient) + private static async Task GetRestResponsePostConfigHydration(HttpClient httpClient, RestRuntimeOptions rest) { - // Retry request RETRY_COUNT times in 1 second increments to allow required services - // time to instantiate and hydrate permissions. - int retryCount = RETRY_COUNT; + // Retry request RETRY_COUNT times in exponential increments to allow + // required services time to instantiate and hydrate permissions because + // the DAB services may take an unpredictable amount of time to become ready. + // + // The service might still fail due to the service not being available yet, + // but it is highly unlikely to be the case. + int retryCount = 0; HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; - while (retryCount > 0) + while (retryCount < RETRY_COUNT) { // Spot test authorization resolver utilization to ensure configuration is used. HttpResponseMessage postConfigHydrationResult = - await httpClient.GetAsync($"api/{POST_STARTUP_CONFIG_ENTITY}"); + await httpClient.GetAsync($"{rest.Path}/{POST_STARTUP_CONFIG_ENTITY}"); responseCode = postConfigHydrationResult.StatusCode; if (postConfigHydrationResult.StatusCode == HttpStatusCode.ServiceUnavailable) { - retryCount--; - Thread.Sleep(TimeSpan.FromSeconds(RETRY_WAIT_SECONDS)); + retryCount++; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); continue; } @@ -5291,13 +5629,17 @@ private static async Task GetRestResponsePostConfigHydration(Htt /// Client used for request execution. /// ServiceUnavailable if service is not successfully hydrated with config, /// else the response code from the GRAPHQL request - private static async Task GetGraphQLResponsePostConfigHydration(HttpClient httpClient) + private static async Task GetGraphQLResponsePostConfigHydration(HttpClient httpClient, GraphQLRuntimeOptions graphQL) { - // Retry request RETRY_COUNT times in 1 second increments to allow required services - // time to instantiate and hydrate permissions. - int retryCount = RETRY_COUNT; + // Retry request RETRY_COUNT times in exponential increments to allow + // required services time to instantiate and hydrate permissions because + // the DAB services may take an unpredictable amount of time to become ready. + // + // The service might still fail due to the service not being available yet, + // but it is highly unlikely to be the case. + int retryCount = 0; HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; - while (retryCount > 0) + while (retryCount < RETRY_COUNT) { string query = @"{ book_by_pk(id: 1) { @@ -5309,7 +5651,7 @@ private static async Task GetGraphQLResponsePostConfigHydration( object payload = new { query }; - HttpRequestMessage graphQLRequest = new(HttpMethod.Post, "/graphql") + HttpRequestMessage graphQLRequest = new(HttpMethod.Post, graphQL.Path) { Content = JsonContent.Create(payload) }; @@ -5319,8 +5661,55 @@ private static async Task GetGraphQLResponsePostConfigHydration( if (responseCode == HttpStatusCode.ServiceUnavailable) { - retryCount--; - Thread.Sleep(TimeSpan.FromSeconds(RETRY_WAIT_SECONDS)); + retryCount++; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); + continue; + } + + break; + } + + return responseCode; + } + + /// + /// Executing MCP POST requests against the engine until a non-503 error is received. + /// + /// Client used for request execution. + /// ServiceUnavailable if service is not successfully hydrated with config, + /// else the response code from the MCP request + public static async Task GetMcpResponse(HttpClient httpClient, McpRuntimeOptions mcp) + { + // Retry request RETRY_COUNT times in exponential increments to allow + // required services time to instantiate and hydrate permissions because + // the DAB services may take an unpredictable amount of time to become ready. + // + // The service might still fail due to the service not being available yet, + // but it is highly unlikely to be the case. + int retryCount = 0; + HttpStatusCode responseCode = HttpStatusCode.ServiceUnavailable; + while (retryCount < RETRY_COUNT) + { + // Minimal MCP request (list tools) – valid JSON-RPC request + object payload = new + { + jsonrpc = "2.0", + id = 1, + method = "tools/list" + }; + HttpRequestMessage mcpRequest = new(HttpMethod.Post, mcp.Path) + { + Content = JsonContent.Create(payload) + }; + mcpRequest.Headers.Add("Accept", "*/*"); + + HttpResponseMessage mcpResponse = await httpClient.SendAsync(mcpRequest); + responseCode = mcpResponse.StatusCode; + + if (responseCode == HttpStatusCode.ServiceUnavailable || responseCode == HttpStatusCode.NotFound) + { + retryCount++; + await Task.Delay(TimeSpan.FromSeconds(Math.Pow(RETRY_WAIT_SECONDS, retryCount))); continue; } @@ -5450,7 +5839,7 @@ public static RuntimeConfig InitMinimalRuntimeConfig( ); entityMap.Add("Publisher", anotherEntity); - Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: nameof(EasyAuthType.StaticWebApps), null); + Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: nameof(EasyAuthType.AppService), null); return new( Schema: "IntegrationTestMinimalSchema", @@ -5494,7 +5883,7 @@ public static string GetConnectionStringFromEnvironmentConfig(string environment string sqlFile = new FileSystemRuntimeConfigLoader(fileSystem).GetFileNameForEnvironment(environment, considerOverrides: true); string configPayload = File.ReadAllText(sqlFile); - RuntimeConfigLoader.TryParseConfig(configPayload, out RuntimeConfig runtimeConfig, replaceEnvVar: true); + RuntimeConfigLoader.TryParseConfig(configPayload, out RuntimeConfig runtimeConfig, replacementSettings: new()); return runtimeConfig.DataSource.ConnectionString; } diff --git a/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs new file mode 100644 index 0000000000..5ce34c9355 --- /dev/null +++ b/src/Service.Tests/Configuration/EntityMcpConfigurationTests.cs @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Configuration +{ + /// + /// Tests for entity-level MCP configuration deserialization and validation. + /// Validates that EntityMcpOptions are correctly deserialized from runtime config JSON. + /// + [TestClass] + public class EntityMcpConfigurationTests + { + private const string BASE_CONFIG_TEMPLATE = @"{{ + ""$schema"": ""test-schema"", + ""data-source"": {{ + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }}, + ""runtime"": {{ + ""rest"": {{ ""enabled"": true, ""path"": ""/api"" }}, + ""graphql"": {{ ""enabled"": true, ""path"": ""/graphql"" }}, + ""host"": {{ ""mode"": ""development"" }} + }}, + ""entities"": {{ + {0} + }} + }}"; + + /// + /// Helper method to create a config with specified entities JSON + /// + private static string CreateConfig(string entitiesJson) + { + return string.Format(BASE_CONFIG_TEMPLATE, entitiesJson); + } + + /// + /// Helper method to assert entity MCP configuration + /// + private static void AssertEntityMcp(Entity entity, bool? expectedDmlTools, bool? expectedCustomTool, string message = null) + { + if (expectedDmlTools == null && expectedCustomTool == null) + { + Assert.IsNull(entity.Mcp, "MCP options should be null when not specified"); + return; + } + + Assert.IsNotNull(entity.Mcp, message ?? "MCP options should be present"); + + bool actualDmlTools = entity.Mcp?.DmlToolEnabled ?? true; // Default is true + bool actualCustomTool = entity.Mcp?.CustomToolEnabled ?? false; // Default is false + + Assert.AreEqual(expectedDmlTools ?? true, actualDmlTools, + $"DmlToolEnabled should be {expectedDmlTools ?? true}"); + Assert.AreEqual(expectedCustomTool ?? false, actualCustomTool, + $"CustomToolEnabled should be {expectedCustomTool ?? false}"); + } + /// + /// Test that deserializing boolean 'true' shorthand correctly sets dml-tools enabled. + /// + [TestMethod] + public void DeserializeConfig_McpBooleanTrue_EnablesDmlToolsOnly() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": true + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); + } + + /// + /// Test that deserializing boolean 'false' shorthand correctly sets dml-tools disabled. + /// + [TestMethod] + public void DeserializeConfig_McpBooleanFalse_DisablesDmlTools() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": false + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: false, expectedCustomTool: false); + } + + /// + /// Test that deserializing object format with both properties works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObject_SetsBothProperties() + { + // Arrange + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": false + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: true); + } + + /// + /// Test that deserializing object format with only dml-tools works. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithDmlToolsOnly_WorksCorrectly() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": { + ""dml-tools"": true + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); + } + + /// + /// Test that entity without MCP configuration has null Mcp property. + /// + [TestMethod] + public void DeserializeConfig_NoMcp_HasNullMcpOptions() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }] + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + Assert.IsNull(runtimeConfig.Entities["Book"].Mcp, "MCP options should be null when not specified"); + } + + /// + /// Test that deserializing object format with both properties set to true works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithBothTrue_SetsCorrectly() + { + // Arrange + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": true + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: true, expectedCustomTool: true); + } + + /// + /// Test that deserializing object format with both properties set to false works correctly. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithBothFalse_SetsCorrectly() + { + // Arrange + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": false, + ""dml-tools"": false + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: false); + } + + /// + /// Test that deserializing object format with only custom-tool works. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithCustomToolOnly_WorksCorrectly() + { + // Arrange + string config = CreateConfig(@" + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: true, expectedCustomTool: true); + } + + /// + /// Test that deserializing config with multiple entities having different MCP settings works. + /// + [TestMethod] + public void DeserializeConfig_MultipleEntitiesWithDifferentMcpSettings_WorksCorrectly() + { + // Arrange + string config = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""test"" + }, + ""runtime"": { + ""rest"": { ""enabled"": true, ""path"": ""/api"" }, + ""graphql"": { ""enabled"": true, ""path"": ""/graphql"" }, + ""host"": { ""mode"": ""development"" } + }, + ""entities"": { + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": true + }, + ""Author"": { + ""source"": ""authors"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": false + }, + ""Publisher"": { + ""source"": ""publishers"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }] + }, + ""GetBook"": { + ""source"": { ""type"": ""stored-procedure"", ""object"": ""dbo.GetBook"" }, + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""execute""] }], + ""mcp"": { + ""custom-tool"": true, + ""dml-tools"": false + } + } + } + }"; + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out RuntimeConfig runtimeConfig); + + // Assert + Assert.IsTrue(success, "Config should parse successfully"); + Assert.IsNotNull(runtimeConfig); + + // Book: mcp = true + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Book")); + AssertEntityMcp(runtimeConfig.Entities["Book"], expectedDmlTools: true, expectedCustomTool: false); + + // Author: mcp = false + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Author")); + AssertEntityMcp(runtimeConfig.Entities["Author"], expectedDmlTools: false, expectedCustomTool: false); + + // Publisher: no mcp (null) + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("Publisher")); + Assert.IsNull(runtimeConfig.Entities["Publisher"].Mcp, "Mcp should be null when not specified"); + + // GetBook: mcp object + Assert.IsTrue(runtimeConfig.Entities.ContainsKey("GetBook")); + AssertEntityMcp(runtimeConfig.Entities["GetBook"], expectedDmlTools: false, expectedCustomTool: true); + } + + /// + /// Test that deserializing invalid MCP value (non-boolean, non-object) fails gracefully. + /// + [TestMethod] + public void DeserializeConfig_InvalidMcpValue_FailsGracefully() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": ""invalid"" + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out _); + + // Assert + Assert.IsFalse(success, "Config parsing should fail with invalid MCP value"); + } + + /// + /// Test that deserializing MCP object with unknown property fails gracefully. + /// + [TestMethod] + public void DeserializeConfig_McpObjectWithUnknownProperty_FailsGracefully() + { + // Arrange + string config = CreateConfig(@" + ""Book"": { + ""source"": ""books"", + ""permissions"": [{ ""role"": ""anonymous"", ""actions"": [""*""] }], + ""mcp"": { + ""dml-tools"": true, + ""unknown-property"": true + } + } + "); + + // Act + bool success = RuntimeConfigLoader.TryParseConfig(config, out _); + + // Assert + Assert.IsFalse(success, "Config parsing should fail with unknown MCP property"); + } + } +} diff --git a/src/Service.Tests/Configuration/HealthEndpointRolesTests.cs b/src/Service.Tests/Configuration/HealthEndpointRolesTests.cs index 9ad36bfa15..99f3284a8a 100644 --- a/src/Service.Tests/Configuration/HealthEndpointRolesTests.cs +++ b/src/Service.Tests/Configuration/HealthEndpointRolesTests.cs @@ -11,6 +11,7 @@ using Azure.DataApiBuilder.Core.Authorization; using Microsoft.AspNetCore.TestHost; using Microsoft.VisualStudio.TestTools.UnitTesting; +using static Azure.DataApiBuilder.Core.AuthenticationHelpers.AppServiceAuthentication; namespace Azure.DataApiBuilder.Service.Tests.Configuration { @@ -75,10 +76,20 @@ public async Task ComprehensiveHealthEndpoint_RolesTests(string role, HostMode h // Sends a GET request to a protected entity which requires a specific role to access. // Authorization checks HttpRequestMessage message = new(method: HttpMethod.Get, requestUri: $"/health"); - string swaTokenPayload = AuthTestHelper.CreateStaticWebAppsEasyAuthToken( - addAuthenticated: true, - specificRole: STARTUP_CONFIG_ROLE); - message.Headers.Add(AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, swaTokenPayload); + string appServiceTokenPayload = AuthTestHelper.CreateAppServiceEasyAuthToken( + roleClaimType: AuthenticationOptions.ROLE_CLAIM_TYPE, + additionalClaims: !string.IsNullOrEmpty(STARTUP_CONFIG_ROLE) + ? + [ + new AppServiceClaim + { + Typ = AuthenticationOptions.ROLE_CLAIM_TYPE, + Val = STARTUP_CONFIG_ROLE + } + ] + : null); + + message.Headers.Add(AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, appServiceTokenPayload); message.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, STARTUP_CONFIG_ROLE); HttpResponseMessage authorizedResponse = await client.SendAsync(message); @@ -119,7 +130,7 @@ private static void CreateCustomConfigFile(Dictionary entityMap, ConfigurationTests.GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null, Health: new(true)); - HostOptions hostOptions = new(Mode: hostMode, Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.StaticWebApps) }); + HostOptions hostOptions = new(Mode: hostMode, Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.AppService) }); RuntimeConfig runtimeConfig = new( Schema: string.Empty, diff --git a/src/Service.Tests/Configuration/HealthEndpointTests.cs b/src/Service.Tests/Configuration/HealthEndpointTests.cs index 1eac7416e3..a1a8bf4223 100644 --- a/src/Service.Tests/Configuration/HealthEndpointTests.cs +++ b/src/Service.Tests/Configuration/HealthEndpointTests.cs @@ -547,7 +547,7 @@ private static RuntimeConfig CreateRuntimeConfig(Dictionary enti ConfigurationTests.GetConnectionStringFromEnvironmentConfig(environment: TestCategory.MSSQL), Options: null, Health: new(enableDatasourceHealth)); - HostOptions hostOptions = new(Mode: hostMode, Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.StaticWebApps) }); + HostOptions hostOptions = new(Mode: hostMode, Cors: null, Authentication: new() { Provider = nameof(EasyAuthType.AppService) }); RuntimeConfig runtimeConfig = new( Schema: string.Empty, @@ -564,6 +564,94 @@ private static RuntimeConfig CreateRuntimeConfig(Dictionary enti return runtimeConfig; } + /// + /// Verifies that stored procedures are excluded from health check results. + /// Creates a config with both a table entity and a stored procedure entity, + /// then validates that only the table entity appears in the health endpoint response. + /// + [TestMethod] + [TestCategory(TestCategory.MSSQL)] + public async Task HealthEndpoint_ExcludesStoredProcedures() + { + // Create a table entity + Entity tableEntity = new( + Health: new(enabled: true), + Source: new("books", EntitySourceType.Table, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new("book", "bookLists", true), + Permissions: new[] { ConfigurationTests.GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + // Create a stored procedure entity - using an actual stored procedure from test schema + Entity storedProcEntity = new( + Health: new(enabled: true), + Source: new("get_books", EntitySourceType.StoredProcedure, null, null), + Fields: null, + Rest: new(Enabled: true), + GraphQL: new("executeGetBooks", "executeGetBooksList", true), + Permissions: new[] { ConfigurationTests.GetMinimalPermissionConfig(AuthorizationResolver.ROLE_ANONYMOUS) }, + Relationships: null, + Mappings: null); + + Dictionary entityMap = new() + { + { "Book", tableEntity }, + { "GetBooks", storedProcEntity } + }; + + RuntimeConfig runtimeConfig = CreateRuntimeConfig( + entityMap, + enableGlobalRest: true, + enableGlobalGraphql: true, + enabledGlobalMcp: true, + enableGlobalHealth: true, + enableDatasourceHealth: true, + hostMode: HostMode.Development); + + WriteToCustomConfigFile(runtimeConfig); + + string[] args = new[] + { + $"--ConfigFileName={CUSTOM_CONFIG_FILENAME}" + }; + + using (TestServer server = new(Program.CreateWebHostBuilder(args))) + using (HttpClient client = server.CreateClient()) + { + HttpRequestMessage healthRequest = new(HttpMethod.Get, $"{BASE_DAB_URL}/health"); + HttpResponseMessage response = await client.SendAsync(healthRequest); + + Assert.AreEqual(HttpStatusCode.OK, response.StatusCode, "Health endpoint should return OK"); + + string responseBody = await response.Content.ReadAsStringAsync(); + Dictionary responseProperties = JsonSerializer.Deserialize>(responseBody); + + // Get the checks array + Assert.IsTrue(responseProperties.TryGetValue("checks", out JsonElement checksElement), "Response should contain 'checks' property"); + Assert.AreEqual(JsonValueKind.Array, checksElement.ValueKind, "Checks should be an array"); + + // Get all entity names from the health check results + List entityNamesInHealthCheck = new(); + foreach (JsonElement check in checksElement.EnumerateArray()) + { + if (check.TryGetProperty("name", out JsonElement nameElement)) + { + entityNamesInHealthCheck.Add(nameElement.GetString()); + } + } + + // Verify that the table entity (Book) appears in health checks + Assert.IsTrue(entityNamesInHealthCheck.Contains("Book"), + "Table entity 'Book' should be included in health check results"); + + // Verify that the stored procedure entity (GetBooks) does NOT appear in health checks + Assert.IsFalse(entityNamesInHealthCheck.Contains("GetBooks"), + "Stored procedure entity 'GetBooks' should be excluded from health check results"); + } + } + private static void WriteToCustomConfigFile(RuntimeConfig runtimeConfig) { File.WriteAllText( diff --git a/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs b/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs index d2ea7708ce..0f9ff6c1b8 100644 --- a/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs +++ b/src/Service.Tests/Configuration/HotReload/ConfigurationHotReloadTests.cs @@ -26,8 +26,12 @@ public class ConfigurationHotReloadTests private static HttpClient _testClient; private static RuntimeConfigProvider _configProvider; private static StringWriter _writer; + private static readonly object _writerLock = new(); private const string CONFIG_FILE_NAME = "hot-reload.dab-config.json"; private const string GQL_QUERY_NAME = "books"; + private const string HOT_RELOAD_SUCCESS_MESSAGE = "Validated hot-reloaded configuration file"; + private const string HOT_RELOAD_FAILURE_MESSAGE = "Unable to hot reload configuration file due to"; + private const int HOT_RELOAD_TIMEOUT_SECONDS = 120; private const string GQL_QUERY = @"{ books(first: 100) { @@ -59,6 +63,7 @@ private static void GenerateConfigFile( string restEntityEnabled = "true", string entityBackingColumn = "title", string entityExposedName = "title", + string mcpEnabled = "true", string configFileName = CONFIG_FILE_NAME) { File.WriteAllText(configFileName, @" @@ -82,6 +87,9 @@ private static void GenerateConfigFile( ""path"": """ + gQLPath + @""", ""allow-introspection"": true }, + ""mcp"": { + ""enabled"": " + mcpEnabled + @" + }, ""host"": { ""cors"": { ""origins"": [ @@ -90,7 +98,7 @@ private static void GenerateConfigFile( ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" }, ""mode"": ""development"" }, @@ -186,54 +194,116 @@ public static async Task ClassInitializeAsync(TestContext context) { // Arrange GenerateConfigFile(connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}"); - _testServer = new(Program.CreateWebHostBuilder(new string[] { "--ConfigFileName", CONFIG_FILE_NAME })); - _testClient = _testServer.CreateClient(); - _configProvider = _testServer.Services.GetService(); - string query = GQL_QUERY; - object payload = - new { query }; + int maxRetries = 3; + int retryDelayMs = 2000; + Exception lastException = null; - HttpRequestMessage request = new(HttpMethod.Post, "/graphQL") + for (int attempt = 1; attempt <= maxRetries; attempt++) { - Content = JsonContent.Create(payload) - }; + try + { + Console.WriteLine($"Initializing test server (attempt {attempt}/{maxRetries})..."); + _testServer = new(Program.CreateWebHostBuilder(new string[] { "--ConfigFileName", CONFIG_FILE_NAME })); + _testClient = _testServer.CreateClient(); + _configProvider = _testServer.Services.GetService(); - HttpResponseMessage restResult = await _testClient.GetAsync("/rest/Book"); - HttpResponseMessage gQLResult = await _testClient.SendAsync(request); + // Give the server a moment to fully initialize + await Task.Delay(1000); - // Assert rest and graphQL requests return status OK. - Assert.AreEqual(HttpStatusCode.OK, restResult.StatusCode); - Assert.AreEqual(HttpStatusCode.OK, gQLResult.StatusCode); + string query = GQL_QUERY; + object payload = new { query }; + + HttpRequestMessage request = new(HttpMethod.Post, "/graphQL") + { + Content = JsonContent.Create(payload) + }; + + HttpResponseMessage restResult = await _testClient.GetAsync("/rest/Book"); + HttpResponseMessage gQLResult = await _testClient.SendAsync(request); + + // Assert rest and graphQL requests return status OK. + Assert.AreEqual(HttpStatusCode.OK, restResult.StatusCode, + $"REST request failed on attempt {attempt}. Response: {await restResult.Content.ReadAsStringAsync()}"); + Assert.AreEqual(HttpStatusCode.OK, gQLResult.StatusCode, + $"GraphQL request failed on attempt {attempt}. Response: {await gQLResult.Content.ReadAsStringAsync()}"); - // Save the contents from request to validate results after hot-reloads. - string restContent = await restResult.Content.ReadAsStringAsync(); - using JsonDocument doc = JsonDocument.Parse(restContent); - _bookDBOContents = doc.RootElement.GetProperty("value").ToString(); + // Save the contents from request to validate results after hot-reloads. + string restContent = await restResult.Content.ReadAsStringAsync(); + using JsonDocument doc = JsonDocument.Parse(restContent); + _bookDBOContents = doc.RootElement.GetProperty("value").ToString(); + + Console.WriteLine($"Test server initialized successfully on attempt {attempt}"); + return; + } + catch (Exception ex) + { + lastException = ex; + Console.WriteLine($"Test server initialization attempt {attempt} failed: {ex.Message}"); + + // Clean up failed attempt + try + { + _testClient?.Dispose(); + _testServer?.Dispose(); + } + catch { /* Ignore cleanup errors */ } + + if (attempt < maxRetries) + { + Console.WriteLine($"Waiting {retryDelayMs}ms before retry..."); + await Task.Delay(retryDelayMs); + } + } + } + + // If we got here, all retries failed + throw new Exception($"Failed to initialize test server after {maxRetries} attempts. Last error: {lastException?.Message}", lastException); } [ClassCleanup] public static void ClassCleanup() { - if (File.Exists(CONFIG_FILE_NAME)) + try { - File.Delete(CONFIG_FILE_NAME); + if (File.Exists(CONFIG_FILE_NAME)) + { + File.Delete(CONFIG_FILE_NAME); + } + + _testClient?.Dispose(); + _testServer?.Dispose(); + Console.WriteLine("Test cleanup completed successfully"); } + catch (Exception ex) + { + Console.WriteLine($"Error during test cleanup: {ex.Message}"); + } + } - _testServer.Dispose(); - _testClient.Dispose(); + /// + /// Thread-safe helper to check if the writer contains a specific message + /// + private static bool WriterContains(string message) + { + lock (_writerLock) + { + return _writer.ToString().Contains(message); + } } /// /// Hot reload the configuration by saving a new file with different rest and graphQL paths. /// Validate that the response is correct when making a request with the newly hot-reloaded paths. /// - [Ignore] [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod("Hot-reload runtime paths.")] public async Task HotReloadConfigRuntimePathsEndToEndTest() { // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + string restBookContents = $"{{\"value\":{_bookDBOContents}}}"; string restPath = "restApi"; string gQLPath = "/gQLApi"; @@ -250,7 +320,12 @@ public async Task HotReloadConfigRuntimePathsEndToEndTest() connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}", restPath: restPath, gQLPath: gQLPath); - System.Threading.Thread.Sleep(2000); + + // Wait for hot-reload to complete successfully + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); // Act HttpResponseMessage badPathRestResult = await _testClient.GetAsync($"rest/Book"); @@ -278,18 +353,25 @@ public async Task HotReloadConfigRuntimePathsEndToEndTest() /// set to false. Validate that the response from the server is NOT FOUND when making a request after /// the hot reload. /// - [Ignore] [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod("Hot-reload rest enabled.")] public async Task HotReloadConfigRuntimeRestEnabledEndToEndTest() { // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + string restEnabled = "false"; GenerateConfigFile( connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}", restEnabled: restEnabled); - System.Threading.Thread.Sleep(2000); + + // Wait for hot-reload to complete successfully + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); // Act HttpResponseMessage restResult = await _testClient.GetAsync($"rest/Book"); @@ -303,12 +385,14 @@ public async Task HotReloadConfigRuntimeRestEnabledEndToEndTest() /// set to false. Validate that the response from the server is NOT FOUND when making a request after /// the hot reload. /// - [Ignore] [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod("Hot-reload gql enabled.")] public async Task HotReloadConfigRuntimeGQLEnabledEndToEndTest() { // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + string gQLEnabled = "false"; string query = GQL_QUERY; object payload = @@ -318,10 +402,16 @@ public async Task HotReloadConfigRuntimeGQLEnabledEndToEndTest() { Content = JsonContent.Create(payload) }; + GenerateConfigFile( connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}", gQLEnabled: gQLEnabled); - System.Threading.Thread.Sleep(2000); + + // Wait for hot-reload to complete successfully + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); // Act HttpResponseMessage gQLResult = await _testClient.SendAsync(request); @@ -337,10 +427,13 @@ public async Task HotReloadConfigRuntimeGQLEnabledEndToEndTest() /// [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod("Hot-reload gql disabled at entity level.")] - [Ignore] + [Ignore] // This test requires GraphQL schema reload. See: issue #3019 public async Task HotReloadEntityGQLEnabledFlag() { // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + string gQLEntityEnabled = "false"; string query = @"{ book_by_pk(id: 1) { @@ -359,7 +452,12 @@ public async Task HotReloadEntityGQLEnabledFlag() GenerateConfigFile( connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}", gQLEntityEnabled: gQLEntityEnabled); - System.Threading.Thread.Sleep(2000); + + // Wait for hot-reload to complete successfully + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); // Act HttpResponseMessage gQLResult = await _testClient.SendAsync(request); @@ -376,10 +474,13 @@ public async Task HotReloadEntityGQLEnabledFlag() /// [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod] - [Ignore] + [Ignore] // This test requires GraphQL schema reload. See: issue #3019 public async Task HotReloadConfigAddEntity() { // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + string newEntityName = "Author"; string newEntitySource = "authors"; string newEntityGQLSingular = "author"; @@ -391,7 +492,12 @@ public async Task HotReloadConfigAddEntity() sourceObject: newEntitySource, gQLEntitySingular: newEntityGQLSingular, gQLEntityPlural: newEntityGQLPlural); - System.Threading.Thread.Sleep(2000); + + // Wait for hot-reload to complete successfully + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); // Act string queryWithOldEntity = @"{ @@ -451,19 +557,28 @@ public async Task HotReloadConfigAddEntity() /// Here, we updated the old mappings of the entity book field "title" to "bookTitle". /// Validate that the response from the server is correct, by ensuring that the old mappings when used in the query /// results in bad request, while the new mappings results in a correct response as "title" field is no longer valid. + /// [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod] - [Ignore] + [Ignore] // This test requires GraphQL schema reload. See: issue #3019 public async Task HotReloadConfigUpdateMappings() { // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + string newMappingFieldName = "bookTitle"; // Update the configuration with new mappings GenerateConfigFile( connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}", entityBackingColumn: "title", entityExposedName: newMappingFieldName); - System.Threading.Thread.Sleep(2000); + + // Wait for hot-reload to complete successfully + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); // Act string queryWithOldMapping = @"{ @@ -524,12 +639,14 @@ public async Task HotReloadConfigUpdateMappings() /// By asserting that hot reload worked properly for the session-context it also implies that /// the new connection string with additional parameters is also valid. /// - [Ignore] [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod] public async Task HotReloadConfigDataSource() { // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + RuntimeConfig previousRuntimeConfig = _configProvider.GetConfig(); MsSqlOptions previousSessionContext = previousRuntimeConfig.DataSource.GetTypedOptions(); @@ -540,7 +657,12 @@ public async Task HotReloadConfigDataSource() GenerateConfigFile( sessionContext: "false", connectionString: expectedConnectionString); - System.Threading.Thread.Sleep(3000); + + // Wait for hot-reload to complete successfully + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); RuntimeConfig updatedRuntimeConfig = _configProvider.GetConfig(); MsSqlOptions actualSessionContext = updatedRuntimeConfig.DataSource.GetTypedOptions(); @@ -561,27 +683,34 @@ public async Task HotReloadConfigDataSource() /// Then we assert that the log-level property is properly updated by ensuring it is /// not the same as the previous log-level and asserting it is the expected log-level. /// - [Ignore] [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod] - public void HotReloadLogLevel() + public async Task HotReloadLogLevel() { - // Arange + // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + LogLevel expectedLogLevel = LogLevel.Trace; string expectedFilter = "trace"; RuntimeConfig previousRuntimeConfig = _configProvider.GetConfig(); LogLevel previouslogLevel = previousRuntimeConfig.GetConfiguredLogLevel(); - //Act + // Act GenerateConfigFile( connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}", logFilter: expectedFilter); - System.Threading.Thread.Sleep(3000); + + // Wait for hot-reload to complete successfully + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); RuntimeConfig updatedRuntimeConfig = _configProvider.GetConfig(); LogLevel actualLogLevel = updatedRuntimeConfig.GetConfiguredLogLevel(); - //Assert + // Assert Assert.AreNotEqual(previouslogLevel, actualLogLevel); Assert.AreEqual(expectedLogLevel, actualLogLevel); } @@ -591,7 +720,6 @@ public void HotReloadLogLevel() /// to an invalid connection string, then it hot reloads once more to the original /// connection string. Lastly, we assert that the first reload fails while the second one succeeds. /// - [Ignore] [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod] public async Task HotReloadConfigConnectionString() @@ -600,38 +728,43 @@ public async Task HotReloadConfigConnectionString() _writer = new StringWriter(); Console.SetOut(_writer); - string failedKeyWord = "Unable to hot reload configuration file due to"; - string succeedKeyWord = "Validated hot-reloaded configuration file"; - // Act // Hot Reload should fail here GenerateConfigFile( connectionString: $"WrongConnectionString"); - await ConfigurationHotReloadTests.WaitForConditionAsync( - () => _writer.ToString().Contains(failedKeyWord), - TimeSpan.FromSeconds(12), + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_FAILURE_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), TimeSpan.FromMilliseconds(500)); // Log that shows that hot-reload was not able to validate properly - string failedConfigLog = $"{_writer.ToString()}"; - _writer.GetStringBuilder().Clear(); + string failedConfigLog; + lock (_writerLock) + { + failedConfigLog = _writer.ToString(); + _writer.GetStringBuilder().Clear(); + } // Hot Reload should succeed here GenerateConfigFile( connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}"); - await ConfigurationHotReloadTests.WaitForConditionAsync( - () => _writer.ToString().Contains(succeedKeyWord), - TimeSpan.FromSeconds(12), + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), TimeSpan.FromMilliseconds(500)); // Log that shows that hot-reload validated properly - string succeedConfigLog = $"{_writer.ToString()}"; + string succeedConfigLog; + lock (_writerLock) + { + succeedConfigLog = _writer.ToString(); + } HttpResponseMessage restResult = await _testClient.GetAsync("/rest/Book"); // Assert - Assert.IsTrue(failedConfigLog.Contains(failedKeyWord)); - Assert.IsTrue(succeedConfigLog.Contains(succeedKeyWord)); + Assert.IsTrue(failedConfigLog.Contains(HOT_RELOAD_FAILURE_MESSAGE)); + Assert.IsTrue(succeedConfigLog.Contains(HOT_RELOAD_SUCCESS_MESSAGE)); Assert.AreEqual(HttpStatusCode.OK, restResult.StatusCode); } @@ -643,7 +776,6 @@ await ConfigurationHotReloadTests.WaitForConditionAsync( /// Then it hot reloads once more to the original database type. We assert that the /// first reload fails while the second one succeeds. /// - [Ignore] [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod] public async Task HotReloadConfigDatabaseType() @@ -652,88 +784,96 @@ public async Task HotReloadConfigDatabaseType() _writer = new StringWriter(); Console.SetOut(_writer); - string failedKeyWord = "Unable to hot reload configuration file due to"; - string succeedKeyWord = "Validated hot-reloaded configuration file"; - // Act // Hot Reload should fail here GenerateConfigFile( databaseType: DatabaseType.PostgreSQL, connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.POSTGRESQL).Replace("\\", "\\\\")}"); - await ConfigurationHotReloadTests.WaitForConditionAsync( - () => _writer.ToString().Contains(failedKeyWord), - TimeSpan.FromSeconds(12), + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_FAILURE_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), TimeSpan.FromMilliseconds(500)); // Log that shows that hot-reload was not able to validate properly - string failedConfigLog = $"{_writer.ToString()}"; - _writer.GetStringBuilder().Clear(); + string failedConfigLog; + lock (_writerLock) + { + failedConfigLog = _writer.ToString(); + _writer.GetStringBuilder().Clear(); + } // Hot Reload should succeed here GenerateConfigFile( databaseType: DatabaseType.MSSQL, connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}"); - await ConfigurationHotReloadTests.WaitForConditionAsync( - () => _writer.ToString().Contains(succeedKeyWord), - TimeSpan.FromSeconds(12), + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_SUCCESS_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), TimeSpan.FromMilliseconds(500)); // Log that shows that hot-reload validated properly - string succeedConfigLog = $"{_writer.ToString()}"; + string succeedConfigLog; + lock (_writerLock) + { + succeedConfigLog = _writer.ToString(); + } HttpResponseMessage restResult = await _testClient.GetAsync("/rest/Book"); // Assert - Assert.IsTrue(failedConfigLog.Contains(failedKeyWord)); - Assert.IsTrue(succeedConfigLog.Contains(succeedKeyWord)); + Assert.IsTrue(failedConfigLog.Contains(HOT_RELOAD_FAILURE_MESSAGE)); + Assert.IsTrue(succeedConfigLog.Contains(HOT_RELOAD_SUCCESS_MESSAGE)); Assert.AreEqual(HttpStatusCode.OK, restResult.StatusCode); } /// - /// Creates a hot reload scenario in which the schema file is invalid which causes - /// hot reload to fail, then we check that the program is still able to work + /// Creates a hot reload scenario in which the configuration file has validation errors + /// which causes hot reload to fail, then we check that the program is still able to work /// properly by validating that the DAB engine is still using the same configuration file /// from before the hot reload. /// - /// Invalid change that was added is a schema file that is not complete, which should be - /// catched by the validator. + /// Invalid change: Setting both REST, GraphQL, and MCP to disabled, which is not allowed. /// - [Ignore] [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod] - public void HotReloadValidationFail() + public async Task HotReloadValidationFail() { // Arrange - string schemaName = "hot-reload.draft.schema.json"; - string schemaConfig = TestHelper.GenerateInvalidSchema(); - - if (File.Exists(schemaName)) - { - File.Delete(schemaName); - } + _writer = new StringWriter(); + Console.SetOut(_writer); - File.WriteAllText(schemaName, schemaConfig); RuntimeConfig lkgRuntimeConfig = _configProvider.GetConfig(); Assert.IsNotNull(lkgRuntimeConfig); + // Capture properties to verify config hasn't changed + bool originalRestEnabled = lkgRuntimeConfig.Runtime.Rest.Enabled; + bool originalGraphQLEnabled = lkgRuntimeConfig.Runtime.GraphQL.Enabled; + bool originalMcpEnabled = lkgRuntimeConfig.Runtime.Mcp.Enabled; + // Act - // Simulate an invalid change to the schema file while the config is updated to a valid state + // Generate a config that will fail validation by disabling REST, GraphQL, and MCP (which is not allowed) GenerateConfigFile( - schema: schemaName, connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}", restEnabled: "false", - gQLEnabled: "false"); - System.Threading.Thread.Sleep(10000); + gQLEnabled: "false", + mcpEnabled: "false"); - RuntimeConfig newRuntimeConfig = _configProvider.GetConfig(); + // Wait for hot-reload to fail + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_FAILURE_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); - // Assert - Assert.AreEqual(expected: lkgRuntimeConfig, actual: newRuntimeConfig); + RuntimeConfig newRuntimeConfig = _configProvider.GetConfig(); - if (File.Exists(schemaName)) - { - File.Delete(schemaName); - } + // Assert - Verify the configuration hasn't changed by comparing properties + Assert.IsNotNull(newRuntimeConfig, "RuntimeConfig should not be null after failed hot-reload."); + Assert.AreEqual(originalRestEnabled, newRuntimeConfig.Runtime.Rest.Enabled, + "REST enabled setting should remain unchanged after hot-reload failure."); + Assert.AreEqual(originalGraphQLEnabled, newRuntimeConfig.Runtime.GraphQL.Enabled, + "GraphQL enabled setting should remain unchanged after hot-reload failure."); + Assert.AreEqual(originalMcpEnabled, newRuntimeConfig.Runtime.Mcp.Enabled, + "MCP enabled setting should remain unchanged after hot-reload failure."); } /// @@ -746,23 +886,39 @@ public void HotReloadValidationFail() /// [TestCategory(MSSQL_ENVIRONMENT)] [TestMethod] - public void HotReloadParsingFail() + public async Task HotReloadParsingFail() { // Arrange + _writer = new StringWriter(); + Console.SetOut(_writer); + RuntimeConfig lkgRuntimeConfig = _configProvider.GetConfig(); Assert.IsNotNull(lkgRuntimeConfig); + // Capture properties to verify config hasn't changed + bool originalRestEnabled = lkgRuntimeConfig.Runtime.Rest.Enabled; + bool originalGraphQLEnabled = lkgRuntimeConfig.Runtime.GraphQL.Enabled; + // Act GenerateConfigFile( connectionString: $"{ConfigurationTests.GetConnectionStringFromEnvironmentConfig(TestCategory.MSSQL).Replace("\\", "\\\\")}", restEnabled: "invalid", gQLEnabled: "invalid"); - System.Threading.Thread.Sleep(5000); + + // Wait for hot-reload to fail (parsing error should trigger failure message) + await WaitForConditionAsync( + () => WriterContains(HOT_RELOAD_FAILURE_MESSAGE), + TimeSpan.FromSeconds(HOT_RELOAD_TIMEOUT_SECONDS), + TimeSpan.FromMilliseconds(500)); RuntimeConfig newRuntimeConfig = _configProvider.GetConfig(); - // Assert - Assert.AreEqual(expected: lkgRuntimeConfig, actual: newRuntimeConfig); + // Assert - Verify the configuration hasn't changed by comparing properties + Assert.IsNotNull(newRuntimeConfig, "RuntimeConfig should not be null after failed hot-reload."); + Assert.AreEqual(originalRestEnabled, newRuntimeConfig.Runtime.Rest.Enabled, + "REST enabled setting should remain unchanged after hot-reload failure."); + Assert.AreEqual(originalGraphQLEnabled, newRuntimeConfig.Runtime.GraphQL.Enabled, + "GraphQL enabled setting should remain unchanged after hot-reload failure."); } /// @@ -772,16 +928,30 @@ public void HotReloadParsingFail() private static async Task WaitForConditionAsync(Func condition, TimeSpan timeout, TimeSpan pollingInterval) { System.Diagnostics.Stopwatch stopwatch = System.Diagnostics.Stopwatch.StartNew(); + int attemptCount = 0; while (stopwatch.Elapsed < timeout) { + attemptCount++; if (condition()) { + Console.WriteLine($"Hot-reload condition met after {stopwatch.Elapsed.TotalSeconds:F2} seconds ({attemptCount} attempts)"); return; } + if (attemptCount % 10 == 0) // Log every 10 attempts (every 5 seconds) + { + Console.WriteLine($"Still waiting for hot-reload condition... Elapsed: {stopwatch.Elapsed.TotalSeconds:F2}s, Attempts: {attemptCount}"); + } + await Task.Delay(pollingInterval); } + Console.WriteLine($"Hot-reload timeout after {stopwatch.Elapsed.TotalSeconds:F2} seconds ({attemptCount} attempts)"); + lock (_writerLock) + { + Console.WriteLine($"Console output captured:\n{_writer.ToString()}"); + } + throw new TimeoutException("The condition was not met within the timeout period."); } } diff --git a/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs new file mode 100644 index 0000000000..eefa1f08f4 --- /dev/null +++ b/src/Service.Tests/Configuration/McpRuntimeOptionsSerializationTests.cs @@ -0,0 +1,220 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Collections.Generic; +using Azure.DataApiBuilder.Config; +using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Configuration +{ + /// + /// Tests for McpRuntimeOptions serialization and deserialization, + /// including edge cases for the description field. + /// + [TestClass] + public class McpRuntimeOptionsSerializationTests + { + /// + /// Validates that McpRuntimeOptions with a description can be serialized to JSON + /// and deserialized back to the same object. + /// + [TestMethod] + public void TestMcpRuntimeOptionsSerializationWithDescription() + { + // Arrange + string description = "This MCP provides access to the Products database and should be used to answer product-related or inventory-related questions from the user."; + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: description + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config with MCP description"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.IsTrue(json.Contains("\"description\""), "JSON should contain description field"); + Assert.IsTrue(json.Contains(description), "JSON should contain description value"); + Assert.AreEqual(description, deserializedConfig.Runtime.Mcp.Description, "Description should match"); + } + + /// + /// Validates that McpRuntimeOptions without a description is serialized correctly + /// and the description field is omitted from JSON. + /// + [TestMethod] + public void TestMcpRuntimeOptionsSerializationWithoutDescription() + { + // Arrange + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: null + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config without MCP description"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.IsNull(deserializedConfig.Runtime.Mcp.Description, "Description should be null"); + Assert.IsFalse(json.Contains("\"description\""), "JSON should not contain description field when null"); + } + + /// + /// Validates that McpRuntimeOptions with an empty string description is serialized correctly + /// and the description field is omitted from JSON. + /// + [TestMethod] + public void TestMcpRuntimeOptionsSerializationWithEmptyDescription() + { + // Arrange + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: "" + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config with empty MCP description"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.IsTrue(string.IsNullOrEmpty(deserializedConfig.Runtime.Mcp.Description), "Description should be empty"); + Assert.IsFalse(json.Contains("\"description\""), "JSON should not contain description field when empty"); + } + + /// + /// Validates that McpRuntimeOptions with a very long description is serialized and deserialized correctly. + /// + [TestMethod] + public void TestMcpRuntimeOptionsSerializationWithLongDescription() + { + // Arrange + string longDescription = new('A', 5000); // 5000 character description + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: longDescription + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config with long MCP description"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.AreEqual(longDescription, deserializedConfig.Runtime.Mcp.Description, "Long description should match"); + Assert.AreEqual(5000, deserializedConfig.Runtime.Mcp.Description?.Length, "Description length should be 5000"); + } + + /// + /// Validates that McpRuntimeOptions with special characters in description is serialized and deserialized correctly. + /// + [DataTestMethod] + [DataRow("Description with \"quotes\" and 'apostrophes'", DisplayName = "Description with quotes")] + [DataRow("Description with\nnewlines\nand\ttabs", DisplayName = "Description with newlines and tabs")] + [DataRow("Description with special chars: <>&@#$%^*()[]{}|\\", DisplayName = "Description with special characters")] + [DataRow("Description with unicode: 你好世界 🚀 café", DisplayName = "Description with unicode")] + public void TestMcpRuntimeOptionsSerializationWithSpecialCharacters(string description) + { + // Arrange + McpRuntimeOptions mcpOptions = new( + Enabled: true, + Path: "/mcp", + DmlTools: null, + Description: description + ); + + RuntimeConfig config = CreateMinimalConfigWithMcp(mcpOptions); + + // Act + string json = config.ToJson(); + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(json, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, $"Failed to deserialize config with special character description: {description}"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.AreEqual(description, deserializedConfig.Runtime.Mcp.Description, "Description with special characters should match exactly"); + } + + /// + /// Validates that existing MCP configuration without description field can be deserialized successfully. + /// This ensures backward compatibility. + /// + [TestMethod] + public void TestBackwardCompatibilityDeserializationWithoutDescriptionField() + { + // Arrange - JSON config without description field + string configJson = @"{ + ""$schema"": ""test-schema"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=test;Database=test;"" + }, + ""runtime"": { + ""mcp"": { + ""enabled"": true, + ""path"": ""/mcp"" + } + }, + ""entities"": {} + }"; + + // Act + bool parseSuccess = RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig); + + // Assert + Assert.IsTrue(parseSuccess, "Failed to deserialize config without description field"); + Assert.IsNotNull(deserializedConfig.Runtime?.Mcp, "MCP options should not be null"); + Assert.IsNull(deserializedConfig.Runtime.Mcp.Description, "Description should be null when not present in JSON"); + } + + /// + /// Creates a minimal RuntimeConfig with the specified MCP options for testing. + /// + private static RuntimeConfig CreateMinimalConfigWithMcp(McpRuntimeOptions mcpOptions) + { + DataSource dataSource = new( + DatabaseType: DatabaseType.MSSQL, + ConnectionString: "Server=test;Database=test;", + Options: null + ); + + RuntimeOptions runtimeOptions = new( + Rest: null, + GraphQL: null, + Host: null, + Mcp: mcpOptions + ); + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: dataSource, + Runtime: runtimeOptions, + Entities: new RuntimeEntities(new Dictionary()) + ); + } + } +} diff --git a/src/Service.Tests/CosmosTests/MutationTests.cs b/src/Service.Tests/CosmosTests/MutationTests.cs index de931dcf22..ec611da59e 100644 --- a/src/Service.Tests/CosmosTests/MutationTests.cs +++ b/src/Service.Tests/CosmosTests/MutationTests.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.IO; using System.Net.Http; +using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; using Azure.DataApiBuilder.Config.NamingPolicies; @@ -19,6 +20,7 @@ using Microsoft.Azure.Cosmos; using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; +using static Azure.DataApiBuilder.Core.AuthenticationHelpers.AppServiceAuthentication; namespace Azure.DataApiBuilder.Service.Tests.CosmosTests { @@ -278,7 +280,18 @@ public async Task CreateItemWithAuthPermissions(string roleName, string expected name }} }}"; - string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); + // For App Service, the effective role must be present as a role + // claim on the principal (in addition to X-MS-API-ROLE) so that + // DAB authZ can evaluate permissions consistently with SWA tests. + AppServiceClaim roleClaim = new() + { + Val = roleName, + Typ = ClaimTypes.Role + }; + + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken( + additionalClaims: [roleClaim]); + JsonElement response = await ExecuteGraphQLRequestAsync("createPlanetAgain", mutation, variables: new(), authToken: authToken, clientRoleHeader: roleName); // Validate the result contains the GraphQL authorization error code. @@ -317,10 +330,21 @@ public async Task UpdateItemWithAuthPermissions(string roleName, string expected name }} }}"; + // For App Service, the effective role must be present as a role + // claim on the principal (in addition to X-MS-API-ROLE) so that + // DAB authZ can evaluate permissions consistently with SWA tests. + AppServiceClaim roleClaim = new() + { + Val = roleName, + Typ = ClaimTypes.Role + }; + + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken( + additionalClaims: [roleClaim]); JsonElement createResponse = await ExecuteGraphQLRequestAsync("createPlanetAgain", createMutation, variables: new(), - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: AuthorizationType.Authenticated.ToString()), + authToken: authToken, clientRoleHeader: AuthorizationType.Authenticated.ToString()); // Making sure item is created successfully @@ -340,7 +364,6 @@ public async Task UpdateItemWithAuthPermissions(string roleName, string expected name = "new_name" }; - string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); JsonElement response = await ExecuteGraphQLRequestAsync( queryName: "updatePlanetAgain", query: mutation, @@ -384,9 +407,21 @@ public async Task DeleteItemWithAuthPermissions(string roleName, string expected }} }}"; + // For App Service, the effective role must be present as a role + // claim on the principal (in addition to X-MS-API-ROLE) so that + // DAB authZ can evaluate permissions consistently with SWA tests. + AppServiceClaim roleClaim = new() + { + Val = roleName, + Typ = ClaimTypes.Role + }; + + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken( + additionalClaims: [roleClaim]); + JsonElement createResponse = await ExecuteGraphQLRequestAsync("createPlanetAgain", createMutation, variables: new(), - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: AuthorizationType.Authenticated.ToString()), + authToken: authToken, clientRoleHeader: AuthorizationType.Authenticated.ToString()); // Making sure item is created successfully @@ -400,7 +435,6 @@ public async Task DeleteItemWithAuthPermissions(string roleName, string expected name } }"; - string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); JsonElement response = await ExecuteGraphQLRequestAsync( queryName: "deletePlanetAgain", query: mutation, @@ -563,7 +597,7 @@ type Planet @model(name:""Planet"") { }; string id = Guid.NewGuid().ToString(); - string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(); + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(); using (TestServer server = new(Program.CreateWebHostBuilder(args))) using (HttpClient client = server.CreateClient()) { @@ -713,7 +747,7 @@ type Planet @model(name:""Planet"") { query: _createPlanetMutation, queryName: "createPlanet", variables: new() { { "item", input } }, - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(), + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), clientRoleHeader: AuthorizationResolver.ROLE_AUTHENTICATED ); @@ -762,6 +796,7 @@ public async Task CanPatchItemWithoutVariables() }} }}"; JsonElement response = await ExecuteGraphQLRequestAsync("patchPlanet", mutation, variables: new()); + // Validate results Assert.AreEqual(id, response.GetProperty("id").GetString()); Assert.AreEqual(newName, response.GetProperty("name").GetString()); @@ -906,7 +941,7 @@ public async Task CanPatchNestedItemWithVariables() public async Task CanPatchMoreThan10AttributesInAnItemWithVariables() { string roleName = "anonymous"; - string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: roleName); + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(); // Run mutation Add planet; string id = Guid.NewGuid().ToString(); diff --git a/src/Service.Tests/CosmosTests/QueryFilterTests.cs b/src/Service.Tests/CosmosTests/QueryFilterTests.cs index 0dac6a1c1c..187b447973 100644 --- a/src/Service.Tests/CosmosTests/QueryFilterTests.cs +++ b/src/Service.Tests/CosmosTests/QueryFilterTests.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Collections.Generic; +using System.Security.Claims; using System.Text.Json; using System.Threading.Tasks; using Azure.DataApiBuilder.Config.ObjectModel; @@ -11,6 +12,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.VisualStudio.TestTools.UnitTesting; using Newtonsoft.Json.Linq; +using static Azure.DataApiBuilder.Core.AuthenticationHelpers.AppServiceAuthentication; using QueryBuilder = Azure.DataApiBuilder.Service.GraphQLBuilder.Queries.QueryBuilder; namespace Azure.DataApiBuilder.Service.Tests.CosmosTests @@ -280,7 +282,7 @@ public async Task TestStringMultiFiltersOnArrayTypeWithOrCondition() private async Task ExecuteAndValidateResult(string graphQLQueryName, string gqlQuery, string dbQuery, bool ignoreBlankResults = false, Dictionary variables = null) { - string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: AuthorizationType.Authenticated.ToString()); + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(); JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQueryName, query: gqlQuery, authToken: authToken, variables: variables); JsonDocument expected = await ExecuteCosmosRequestAsync(dbQuery, _pageSize, null, _containerName); ValidateResults(actual.GetProperty("items"), expected.RootElement, ignoreBlankResults); @@ -918,11 +920,19 @@ public async Task TestQueryFilterFieldAuth_Only_AuthorizedArrayItem() // Now get the item with item level permission string clientRoleHeader = "item-level-permission-role"; - // string clientRoleHeader = "authenticated"; + + AppServiceClaim roleClaim = new() + { + Val = clientRoleHeader, + Typ = ClaimTypes.Role + }; + + // For App Service, roles must exist as claims on the principal + // (in addition to X-MS-API-ROLE) for DAB authZ to allow the request. JsonElement actual = await ExecuteGraphQLRequestAsync( queryName: _graphQLQueryName, query: gqlQuery, - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader), + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(additionalClaims: [roleClaim]), clientRoleHeader: clientRoleHeader); string dbQuery = $"SELECT c.id " + @@ -980,11 +990,22 @@ public async Task TestQueryFilterFieldAuth_UnauthorizedField() } }"; string clientRoleHeader = "limited-read-role"; + + AppServiceClaim roleClaim = new() + { + Val = clientRoleHeader, + Typ = ClaimTypes.Role + }; + + // For App Service, roles must exist as claims on the principal + // (in addition to X-MS-API-ROLE) for DAB authZ to allow the request. + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(additionalClaims: [roleClaim]); + JsonElement response = await ExecuteGraphQLRequestAsync( queryName: _graphQLQueryName, query: gqlQuery, variables: new() { { "name", "test name" } }, - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader), + authToken: authToken, clientRoleHeader: clientRoleHeader); // Validate the result contains the GraphQL authorization error code. @@ -1012,7 +1033,7 @@ public async Task TestQueryFilterFieldAuth_AuthorizedWildCard() queryName: "planets", query: gqlQuery, variables: new() { }, - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader), + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), clientRoleHeader: clientRoleHeader); Assert.AreEqual(response.GetProperty("items")[0].GetProperty("name").ToString(), "Earth"); @@ -1037,7 +1058,7 @@ public async Task TestQueryFilterNestedFieldAuth_AuthorizedNestedField() } }"; - string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: AuthorizationType.Authenticated.ToString()); + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(); JsonElement actual = await ExecuteGraphQLRequestAsync(_graphQLQueryName, query: gqlQuery, authToken: authToken); Assert.AreEqual(actual.GetProperty("items")[0].GetProperty("earth").GetProperty("id").ToString(), _idList[0]); } @@ -1065,11 +1086,22 @@ public async Task TestQueryFilterNestedFieldAuth_UnauthorizedNestedField() }"; string clientRoleHeader = "limited-read-role"; + + AppServiceClaim roleClaim = new() + { + Val = clientRoleHeader, + Typ = ClaimTypes.Role + }; + + // For App Service, roles must exist as claims on the principal + // (in addition to X-MS-API-ROLE) for DAB authZ to allow the request. + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(additionalClaims: [roleClaim]); + JsonElement response = await ExecuteGraphQLRequestAsync( queryName: _graphQLQueryName, query: gqlQuery, variables: new() { { "name", "test name" } }, - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader), + authToken: authToken, clientRoleHeader: clientRoleHeader); // Validate the result contains the GraphQL authorization error code. @@ -1100,7 +1132,7 @@ public async Task TestQueryFilterNestedArrayFieldAuth_UnauthorizedNestedField() JsonElement response = await ExecuteGraphQLRequestAsync( queryName: _graphQLQueryName, query: gqlQuery, - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader), + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), clientRoleHeader: clientRoleHeader); // Validate the result contains the GraphQL authorization error code. @@ -1131,7 +1163,17 @@ public async Task TestQueryFieldAuthConflictingWithFilterFieldAuth_Unauthorized( }"; string clientRoleHeader = "limited-read-role"; - string authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader); + + AppServiceClaim roleClaim = new() + { + Val = clientRoleHeader, + Typ = ClaimTypes.Role + }; + + // For App Service, roles must exist as claims on the principal + // (in addition to X-MS-API-ROLE) for DAB authZ to allow the request. + // SWA tests passed without this because SWA uses a looser, header-driven role model. + string authToken = AuthTestHelper.CreateAppServiceEasyAuthToken(additionalClaims: [roleClaim]); JsonElement response = await ExecuteGraphQLRequestAsync(_graphQLQueryName, query: gqlQuery, authToken: authToken, @@ -1190,7 +1232,7 @@ public async Task TestQueryFilterFieldAuth_ExcludeTakesPredecence() queryName: _graphQLQueryName, query: gqlQuery, variables: new() { { "name", "test name" } }, - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader), + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), clientRoleHeader: clientRoleHeader); // Validate the result contains the GraphQL authorization error code. diff --git a/src/Service.Tests/CosmosTests/QueryTests.cs b/src/Service.Tests/CosmosTests/QueryTests.cs index 52afa8e788..bfcdb2cf91 100644 --- a/src/Service.Tests/CosmosTests/QueryTests.cs +++ b/src/Service.Tests/CosmosTests/QueryTests.cs @@ -105,7 +105,7 @@ public async Task GetWithInvalidAuthorizationPolicyInSchema() queryName: "invalidAuthModel_by_pk", query: MoonWithInvalidAuthorizationPolicy, variables: new() { { "id", id }, { "partitionKeyValue", id } }, - authToken: AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader), + authToken: AuthTestHelper.CreateAppServiceEasyAuthToken(), clientRoleHeader: clientRoleHeader); // Validate the result contains the GraphQL authorization error code. diff --git a/src/Service.Tests/DatabaseSchema-DwSql.sql b/src/Service.Tests/DatabaseSchema-DwSql.sql index 300ef7ff32..daed665949 100644 --- a/src/Service.Tests/DatabaseSchema-DwSql.sql +++ b/src/Service.Tests/DatabaseSchema-DwSql.sql @@ -336,7 +336,8 @@ VALUES (1, 'Awesome book', 1234), (17, 'CONN%_CONN', 1234), (18, '[Special Book]', 1234), (19, 'ME\YOU', 1234), -(20, 'C:\\LIFE', 1234); +(20, 'C:\\LIFE', 1234), +(21, '', 1234); INSERT INTO book_website_placements(id, book_id, price) VALUES (1, 1, 100), (2, 2, 50), (3, 3, 23), (4, 5, 33); diff --git a/src/Service.Tests/DatabaseSchema-MsSql.sql b/src/Service.Tests/DatabaseSchema-MsSql.sql index 3605b2628a..4e87394aee 100644 --- a/src/Service.Tests/DatabaseSchema-MsSql.sql +++ b/src/Service.Tests/DatabaseSchema-MsSql.sql @@ -531,7 +531,8 @@ VALUES (1, 'Awesome book', 1234), (17, 'CONN%_CONN', 1234), (18, '[Special Book]', 1234), (19, 'ME\YOU', 1234), -(20, 'C:\\LIFE', 1234); +(20, 'C:\\LIFE', 1234), +(21, '', 1234); SET IDENTITY_INSERT books OFF SET IDENTITY_INSERT books_mm ON diff --git a/src/Service.Tests/DatabaseSchema-MySql.sql b/src/Service.Tests/DatabaseSchema-MySql.sql index f746bc063a..dda93d86d1 100644 --- a/src/Service.Tests/DatabaseSchema-MySql.sql +++ b/src/Service.Tests/DatabaseSchema-MySql.sql @@ -388,7 +388,8 @@ INSERT INTO books(id, title, publisher_id) (17, 'CONN%_CONN', 1234), (18, '[Special Book]', 1234), (19, 'ME\\YOU', 1234), - (20, 'C:\\\\LIFE', 1234); + (20, 'C:\\\\LIFE', 1234), + (21, '', 1234); INSERT INTO book_website_placements(book_id, price) VALUES (1, 100), (2, 50), (3, 23), (5, 33); INSERT INTO website_users(id, username) VALUES (1, 'George'), (2, NULL), (3, ''), (4, 'book_lover_95'), (5, 'null'); INSERT INTO book_author_link(book_id, author_id) VALUES (1, 123), (2, 124), (3, 123), (3, 124), (4, 123), (4, 124), (5, 126); diff --git a/src/Service.Tests/DatabaseSchema-PostgreSql.sql b/src/Service.Tests/DatabaseSchema-PostgreSql.sql index 14615707b1..523e96c22f 100644 --- a/src/Service.Tests/DatabaseSchema-PostgreSql.sql +++ b/src/Service.Tests/DatabaseSchema-PostgreSql.sql @@ -391,7 +391,8 @@ INSERT INTO books(id, title, publisher_id) (17, 'CONN%_CONN', 1234), (18, '[Special Book]', 1234), (19, 'ME\YOU', 1234), - (20, 'C:\\LIFE', 1234); + (20, 'C:\\LIFE', 1234), + (21, '', 1234); INSERT INTO book_website_placements(book_id, price) VALUES (1, 100), (2, 50), (3, 23), (5, 33); INSERT INTO website_users(id, username) VALUES (1, 'George'), (2, NULL), (3, ''), (4, 'book_lover_95'), (5, 'null'); INSERT INTO book_author_link(book_id, author_id) VALUES (1, 123), (2, 124), (3, 123), (3, 124), (4, 123), (4, 124), (5, 126);; diff --git a/src/Service.Tests/Mcp/CustomMcpToolFactoryTests.cs b/src/Service.Tests/Mcp/CustomMcpToolFactoryTests.cs new file mode 100644 index 0000000000..efd65fd06e --- /dev/null +++ b/src/Service.Tests/Mcp/CustomMcpToolFactoryTests.cs @@ -0,0 +1,176 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Linq; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Mcp.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Minimal unit tests for CustomMcpToolFactory covering filtering and creation logic. + /// Comprehensive tests will be added in subsequent PRs. + /// + [TestClass] + public class CustomMcpToolFactoryTests + { + /// + /// Test that CreateCustomTools returns empty collection when no entities exist. + /// + [TestMethod] + public void CreateCustomTools_ReturnsEmptyCollection_WhenNoEntities() + { + // Arrange + RuntimeConfig config = CreateEmptyConfig(); + + // Act + System.Collections.Generic.IEnumerable tools = CustomMcpToolFactory.CreateCustomTools(config, null); + + // Assert + Assert.IsNotNull(tools); + Assert.AreEqual(0, tools.Count()); + } + + /// + /// Test that CreateCustomTools filters entities correctly for custom tools. + /// Should only include stored procedures with custom-tool enabled. + /// + [TestMethod] + public void CreateCustomTools_FiltersEntitiesCorrectly() + { + // Arrange + RuntimeConfig config = CreateConfigWithMixedEntities(); + + // Act + System.Collections.Generic.IEnumerable tools = CustomMcpToolFactory.CreateCustomTools(config, null); + + // Assert + Assert.IsNotNull(tools); + // Should only include GetBook (SP with custom-tool enabled) + Assert.AreEqual(1, tools.Count()); + Assert.AreEqual("get_book", tools.First().GetToolMetadata().Name); + } + + /// + /// Test that CreateCustomTools generates correct metadata for tools. + /// + [TestMethod] + public void CreateCustomTools_GeneratesCorrectMetadata() + { + // Arrange + RuntimeConfig config = CreateConfigWithDescribedEntity(); + + // Act + System.Collections.Generic.IEnumerable tools = CustomMcpToolFactory.CreateCustomTools(config, null); + + // Assert + Assert.AreEqual(1, tools.Count()); + ModelContextProtocol.Protocol.Tool metadata = tools.First().GetToolMetadata(); + Assert.AreEqual("get_user", metadata.Name); + Assert.AreEqual("Gets user by ID", metadata.Description); + } + + #region Helper Methods + + private static RuntimeConfig CreateEmptyConfig() + { + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: null, + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(new Dictionary()) + ); + } + + private static RuntimeConfig CreateConfigWithMixedEntities() + { + Dictionary entities = new() + { + // Table entity - should be filtered out + ["Book"] = new Entity( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }) }, + Mappings: null, + Relationships: null + ), + // SP without custom-tool enabled - should be filtered out + ["CountBooks"] = new Entity( + Source: new("count_books", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("CountBooks", "CountBooks"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: false, dmlToolsEnabled: null) + ), + // SP with custom-tool enabled - should be included + ["GetBook"] = new Entity( + Source: new("get_book", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetBook", "GetBook"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) }) }, + Mappings: null, + Relationships: null, + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: null) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: null, + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + private static RuntimeConfig CreateConfigWithDescribedEntity() + { + Dictionary entities = new() + { + ["GetUser"] = new Entity( + Source: new("get_user", EntitySourceType.StoredProcedure, null, null), + GraphQL: new("GetUser", "GetUser"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) }) }, + Mappings: null, + Relationships: null, + Description: "Gets user by ID", + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: null) + ) + }; + + return new RuntimeConfig( + Schema: "test-schema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, ConnectionString: "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: null, + Host: new(Cors: null, Authentication: null, Mode: HostMode.Development) + ), + Entities: new(entities) + ); + } + + #endregion + } +} diff --git a/src/Service.Tests/Mcp/DynamicCustomToolTests.cs b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs new file mode 100644 index 0000000000..8f6bf6fd84 --- /dev/null +++ b/src/Service.Tests/Mcp/DynamicCustomToolTests.cs @@ -0,0 +1,237 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#nullable enable + +using System; +using System.Linq; +using System.Text.Json; +using Azure.DataApiBuilder.Config.ObjectModel; +using Azure.DataApiBuilder.Mcp.Core; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace Azure.DataApiBuilder.Service.Tests.Mcp +{ + /// + /// Minimal unit tests for DynamicCustomTool covering critical functionality. + /// Comprehensive tests will be added in subsequent PRs. + /// + [TestClass] + public class DynamicCustomToolTests + { + /// + /// Test that DynamicCustomTool correctly converts PascalCase entity names to snake_case tool names. + /// + [TestMethod] + [DataRow("GetUserProfile", "get_user_profile")] + [DataRow("GetBook", "get_book")] + [DataRow("InsertBookRecord", "insert_book_record")] + [DataRow("CountBooks", "count_books")] + [DataRow("lowercase", "lowercase")] + [DataRow("UPPERCASE", "uppercase")] + public void GetToolMetadata_ConvertsEntityNameToSnakeCase(string entityName, string expectedToolName) + { + // Arrange + Entity entity = CreateTestStoredProcedureEntity(); + DynamicCustomTool tool = new(entityName, entity); + + // Act + ModelContextProtocol.Protocol.Tool metadata = tool.GetToolMetadata(); + + // Assert + Assert.AreEqual(expectedToolName, metadata.Name); + } + + /// + /// Test that tool metadata includes entity description when provided. + /// + [TestMethod] + public void GetToolMetadata_UsesEntityDescription_WhenProvided() + { + // Arrange + string description = "Retrieves a book by ID"; + Entity entity = CreateTestStoredProcedureEntity(description: description); + DynamicCustomTool tool = new("GetBook", entity); + + // Act + ModelContextProtocol.Protocol.Tool metadata = tool.GetToolMetadata(); + + // Assert + Assert.AreEqual(description, metadata.Description); + } + + /// + /// Test that tool metadata generates default description when not provided. + /// + [TestMethod] + public void GetToolMetadata_GeneratesDefaultDescription_WhenNotProvided() + { + // Arrange + Entity entity = CreateTestStoredProcedureEntity(); + DynamicCustomTool tool = new("GetBook", entity); + + // Act + ModelContextProtocol.Protocol.Tool metadata = tool.GetToolMetadata(); + + // Assert + Assert.IsTrue(metadata.Description?.Contains("get_book") ?? false); + Assert.IsTrue(metadata.Description?.Contains("stored procedure") ?? false); + } + + /// + /// Test that constructor throws ArgumentNullException when entity name is null. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void Constructor_ThrowsArgumentNullException_WhenEntityNameIsNull() + { + // Arrange + Entity entity = CreateTestStoredProcedureEntity(); + + // Act + _ = new DynamicCustomTool(null!, entity); + } + + /// + /// Test that constructor throws ArgumentNullException when entity is null. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentNullException))] + public void Constructor_ThrowsArgumentNullException_WhenEntityIsNull() + { + // Act + _ = new DynamicCustomTool("TestEntity", null!); + } + + /// + /// Test that constructor throws ArgumentException when entity is not a stored procedure. + /// + [TestMethod] + [ExpectedException(typeof(ArgumentException))] + public void Constructor_ThrowsArgumentException_WhenEntityIsNotStoredProcedure() + { + // Arrange - Create table entity + Entity tableEntity = new( + Source: new("books", EntitySourceType.Table, null, null), + GraphQL: new("Book", "Books"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] { new EntityPermission(Role: "anonymous", Actions: new[] { new EntityAction(Action: EntityActionOperation.Read, Fields: null, Policy: null) }) }, + Mappings: null, + Relationships: null + ); + + // Act + _ = new DynamicCustomTool("Book", tableEntity); + } + + /// + /// Test that input schema is generated with empty properties when no parameters. + /// + [TestMethod] + public void GetToolMetadata_GeneratesEmptySchema_WhenNoParameters() + { + // Arrange + Entity entity = CreateTestStoredProcedureEntity(); + DynamicCustomTool tool = new("GetBooks", entity); + + // Act + ModelContextProtocol.Protocol.Tool metadata = tool.GetToolMetadata(); + + // Assert + Assert.IsNotNull(metadata.InputSchema); + JsonDocument schemaObj = JsonDocument.Parse(metadata.InputSchema.GetRawText()); + Assert.IsTrue(schemaObj.RootElement.TryGetProperty("properties", out JsonElement props)); + Assert.AreEqual(JsonValueKind.Object, props.ValueKind); + Assert.AreEqual(0, props.EnumerateObject().Count()); + } + + /// + /// Test that input schema includes parameter definitions with descriptions. + /// + [TestMethod] + public void GetToolMetadata_GeneratesSchemaWithParameters_WhenParametersProvided() + { + // Arrange + ParameterMetadata[] parameters = new[] + { + new ParameterMetadata { Name = "id", Description = "The book ID" }, + new ParameterMetadata { Name = "title", Description = "The book title" } + }; + Entity entity = CreateTestStoredProcedureEntity(parameters: parameters); + DynamicCustomTool tool = new("GetBook", entity); + + // Act + ModelContextProtocol.Protocol.Tool metadata = tool.GetToolMetadata(); + + // Assert + JsonDocument schemaObj = JsonDocument.Parse(metadata.InputSchema.GetRawText()); + Assert.IsTrue(schemaObj.RootElement.TryGetProperty("properties", out JsonElement props)); + Assert.IsTrue(props.TryGetProperty("id", out JsonElement idParam)); + Assert.IsTrue(idParam.TryGetProperty("description", out JsonElement idDesc)); + Assert.AreEqual("The book ID", idDesc.GetString()); + Assert.IsTrue(props.TryGetProperty("title", out JsonElement titleParam)); + Assert.IsTrue(titleParam.TryGetProperty("description", out JsonElement titleDesc)); + Assert.AreEqual("The book title", titleDesc.GetString()); + } + + /// + /// Test that parameter schema uses default description when not provided. + /// + [TestMethod] + public void GetToolMetadata_UsesDefaultParameterDescription_WhenNotProvided() + { + // Arrange + ParameterMetadata[] parameters = new[] + { + new ParameterMetadata { Name = "userId" } + }; + Entity entity = CreateTestStoredProcedureEntity(parameters: parameters); + DynamicCustomTool tool = new("GetUser", entity); + + // Act + ModelContextProtocol.Protocol.Tool metadata = tool.GetToolMetadata(); + + // Assert + JsonDocument schemaObj = JsonDocument.Parse(metadata.InputSchema.GetRawText()); + Assert.IsTrue(schemaObj.RootElement.TryGetProperty("properties", out JsonElement props)); + Assert.IsTrue(props.TryGetProperty("userId", out JsonElement userIdParam)); + Assert.IsTrue(userIdParam.TryGetProperty("description", out JsonElement desc)); + Assert.IsTrue(desc.GetString()!.Contains("userId")); + } + + /// + /// Helper method to create a test stored procedure entity. + /// + private static Entity CreateTestStoredProcedureEntity( + string? description = null, + ParameterMetadata[]? parameters = null) + { + return new Entity( + Source: new( + Object: "test_procedure", + Type: EntitySourceType.StoredProcedure, + Parameters: parameters?.ToList(), + KeyFields: null + ), + GraphQL: new(Singular: "TestProcedure", Plural: "TestProcedures"), + Fields: null, + Rest: new(Enabled: true), + Permissions: new[] + { + new EntityPermission( + Role: "anonymous", + Actions: new[] { new EntityAction(Action: EntityActionOperation.Execute, Fields: null, Policy: null) } + ) + }, + Mappings: null, + Relationships: null, + Cache: null, + IsLinkingEntity: false, + Health: null, + Description: description, + Mcp: new EntityMcpOptions(customToolEnabled: true, dmlToolsEnabled: null) + ); + } + } +} diff --git a/src/Service.Tests/ModuleInitializer.cs b/src/Service.Tests/ModuleInitializer.cs index ba0407ecd5..89b7dbc3c4 100644 --- a/src/Service.Tests/ModuleInitializer.cs +++ b/src/Service.Tests/ModuleInitializer.cs @@ -69,6 +69,10 @@ public static void Init() VerifierSettings.IgnoreMember(entity => entity.IsLinkingEntity); // Ignore the UserProvidedTtlOptions. They aren't serialized to our config file, enforced by EntityCacheOptionsConverter. VerifierSettings.IgnoreMember(cacheOptions => cacheOptions.UserProvidedTtlOptions); + // Ignore the UserProvidedCustomToolEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedCustomToolEnabled); + // Ignore the UserProvidedDmlToolsEnabled. They aren't serialized to our config file, enforced by EntityMcpOptionsConverterFactory. + VerifierSettings.IgnoreMember(mcpOptions => mcpOptions.UserProvidedDmlToolsEnabled); // Ignore the CosmosDataSourceUsed as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.CosmosDataSourceUsed); // Ignore the IsRequestBodyStrict as that's unimportant from a test standpoint. @@ -77,6 +81,8 @@ public static void Init() VerifierSettings.IgnoreMember(config => config.McpDmlTools); // Ignore the IsStaticWebAppsIdentityProvider as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.IsStaticWebAppsIdentityProvider); + // Ignore the IsAppServiceIdentityProvider as that's unimportant from a test standpoint. + VerifierSettings.IgnoreMember(config => config.IsAppServiceIdentityProvider); // Ignore the RestPath as that's unimportant from a test standpoint. VerifierSettings.IgnoreMember(config => config.RestPath); // Ignore the GraphQLPath as that's unimportant from a test standpoint. diff --git a/src/Service.Tests/Multidab-config.MsSql.json b/src/Service.Tests/Multidab-config.MsSql.json index a428c0a27b..b54b629023 100644 --- a/src/Service.Tests/Multidab-config.MsSql.json +++ b/src/Service.Tests/Multidab-config.MsSql.json @@ -30,7 +30,7 @@ "allow-credentials": false }, "authentication": { - "provider": "StaticWebApps" + "provider": "AppService" }, "mode": "development" } diff --git a/src/Service.Tests/Multidab-config.MySql.json b/src/Service.Tests/Multidab-config.MySql.json index efd7e7c973..5a9bc4cee0 100644 --- a/src/Service.Tests/Multidab-config.MySql.json +++ b/src/Service.Tests/Multidab-config.MySql.json @@ -22,7 +22,7 @@ "allow-credentials": false }, "authentication": { - "provider": "StaticWebApps" + "provider": "AppService" }, "mode": "development" } diff --git a/src/Service.Tests/Multidab-config.PostgreSql.json b/src/Service.Tests/Multidab-config.PostgreSql.json index 106e6d5ac2..0279076273 100644 --- a/src/Service.Tests/Multidab-config.PostgreSql.json +++ b/src/Service.Tests/Multidab-config.PostgreSql.json @@ -22,7 +22,7 @@ "allow-credentials": false }, "authentication": { - "provider": "StaticWebApps" + "provider": "AppService" }, "mode": "development" } diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt index 420977ed26..9279da9d59 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForCosmos.verified.txt @@ -19,7 +19,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -29,7 +45,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt index b622552ef5..35fd562c87 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMsSql.verified.txt @@ -23,7 +23,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -33,7 +49,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt index 6c81c138ce..1490309ece 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForMySql.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -25,7 +41,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt index 5e8631d46f..ceba40ae63 100644 --- a/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt +++ b/src/Service.Tests/Snapshots/ConfigurationTests.TestReadingRuntimeConfigForPostgreSql.verified.txt @@ -15,7 +15,23 @@ }, Mcp: { Enabled: true, - Path: /mcp + Path: /mcp, + DmlTools: { + AllToolsEnabled: true, + DescribeEntities: true, + CreateRecord: true, + ReadRecords: true, + UpdateRecord: true, + DeleteRecord: true, + ExecuteEntity: true, + UserProvidedAllTools: false, + UserProvidedDescribeEntities: false, + UserProvidedCreateRecord: false, + UserProvidedReadRecords: false, + UserProvidedUpdateRecord: false, + UserProvidedDeleteRecord: false, + UserProvidedExecuteEntity: false + } }, Host: { Cors: { @@ -25,7 +41,7 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } }, diff --git a/src/Service.Tests/Snapshots/CorsUnitTests.TestCorsConfigReadCorrectly.verified.txt b/src/Service.Tests/Snapshots/CorsUnitTests.TestCorsConfigReadCorrectly.verified.txt index d31e71399b..ce6395909b 100644 --- a/src/Service.Tests/Snapshots/CorsUnitTests.TestCorsConfigReadCorrectly.verified.txt +++ b/src/Service.Tests/Snapshots/CorsUnitTests.TestCorsConfigReadCorrectly.verified.txt @@ -3,6 +3,6 @@ AllowCredentials: false }, Authentication: { - Provider: StaticWebApps + Provider: AppService } } \ No newline at end of file diff --git a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs index c9207a6672..745d5eade3 100644 --- a/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLMutationTests/GraphQLMutationTestBase.cs @@ -257,7 +257,7 @@ public async Task TestStoredProcedureMutationForDeletion(string dbQueryToVerifyD string currentDbResponse = await GetDatabaseResultAsync(dbQueryToVerifyDeletion); JsonDocument currentResult = JsonDocument.Parse(currentDbResponse); - Assert.AreEqual(currentResult.RootElement.GetProperty("maxId").GetInt64(), 20); + Assert.AreEqual(currentResult.RootElement.GetProperty("maxId").GetInt64(), 21); JsonElement graphQLResponse = await ExecuteGraphQLRequestAsync(graphQLMutation, graphQLMutationName, isAuthenticated: true); // Stored Procedure didn't return anything @@ -266,7 +266,7 @@ public async Task TestStoredProcedureMutationForDeletion(string dbQueryToVerifyD // check to verify new element is inserted string updatedDbResponse = await GetDatabaseResultAsync(dbQueryToVerifyDeletion); JsonDocument updatedResult = JsonDocument.Parse(updatedDbResponse); - Assert.AreEqual(updatedResult.RootElement.GetProperty("maxId").GetInt64(), 19); + Assert.AreEqual(updatedResult.RootElement.GetProperty("maxId").GetInt64(), 20); } public async Task InsertMutationOnTableWithTriggerWithNonAutoGenPK(string dbQuery) diff --git a/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs b/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs index e7e18d6090..33db8f8b49 100644 --- a/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs +++ b/src/Service.Tests/SqlTests/GraphQLPaginationTests/GraphQLPaginationTestBase.cs @@ -84,95 +84,101 @@ public async Task RequestMaxUsingNegativeOne() } }"; - // this resultset represents all books in the db. - JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); string expected = @"{ - ""items"": [ + ""items"": [ { - ""id"": 1, - ""title"": ""Awesome book"" + ""id"": 1, + ""title"": ""Awesome book"" }, { - ""id"": 2, - ""title"": ""Also Awesome book"" + ""id"": 2, + ""title"": ""Also Awesome book"" }, { - ""id"": 3, - ""title"": ""Great wall of china explained"" + ""id"": 3, + ""title"": ""Great wall of china explained"" }, { - ""id"": 4, - ""title"": ""US history in a nutshell"" + ""id"": 4, + ""title"": ""US history in a nutshell"" }, { - ""id"": 5, - ""title"": ""Chernobyl Diaries"" + ""id"": 5, + ""title"": ""Chernobyl Diaries"" }, { - ""id"": 6, - ""title"": ""The Palace Door"" + ""id"": 6, + ""title"": ""The Palace Door"" }, { - ""id"": 7, - ""title"": ""The Groovy Bar"" + ""id"": 7, + ""title"": ""The Groovy Bar"" }, { - ""id"": 8, - ""title"": ""Time to Eat"" + ""id"": 8, + ""title"": ""Time to Eat"" }, { - ""id"": 9, - ""title"": ""Policy-Test-01"" + ""id"": 9, + ""title"": ""Policy-Test-01"" }, { - ""id"": 10, - ""title"": ""Policy-Test-02"" + ""id"": 10, + ""title"": ""Policy-Test-02"" }, { - ""id"": 11, - ""title"": ""Policy-Test-04"" + ""id"": 11, + ""title"": ""Policy-Test-04"" }, { - ""id"": 12, - ""title"": ""Time to Eat 2"" + ""id"": 12, + ""title"": ""Time to Eat 2"" + }, + { + ""id"": 13, + ""title"": ""Before Sunrise"" }, { - ""id"": 13, - ""title"": ""Before Sunrise"" + ""id"": 14, + ""title"": ""Before Sunset"" }, { - ""id"": 14, - ""title"": ""Before Sunset"" + ""id"": 15, + ""title"": ""SQL_CONN"" }, { - ""id"": 15, - ""title"": ""SQL_CONN"" + ""id"": 16, + ""title"": ""SOME%CONN"" }, { - ""id"": 16, - ""title"": ""SOME%CONN"" + ""id"": 17, + ""title"": ""CONN%_CONN"" }, { - ""id"": 17, - ""title"": ""CONN%_CONN"" + ""id"": 18, + ""title"": ""[Special Book]"" }, { - ""id"": 18, - ""title"": ""[Special Book]"" + ""id"": 19, + ""title"": ""ME\\YOU"" }, { - ""id"": 19, - ""title"": ""ME\\YOU"" + ""id"": 20, + ""title"": ""C:\\\\LIFE"" }, { - ""id"": 20, - ""title"": ""C:\\\\LIFE"" + ""id"": 21, + ""title"": """" } - ], - ""endCursor"": null, - ""hasNextPage"": false + ], + ""endCursor"": null, + ""hasNextPage"": false }"; + // Note: The max page size is 21 for MsSql and 20 for all other data sources, so when using -1 + // this resultset represents all books in the db. + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); } @@ -196,91 +202,96 @@ public async Task RequestNoParamFullConnection() }"; JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + string expected = @"{ - ""items"": [ + ""items"": [ { - ""id"": 1, - ""title"": ""Awesome book"" + ""id"": 1, + ""title"": ""Awesome book"" }, { - ""id"": 2, - ""title"": ""Also Awesome book"" + ""id"": 2, + ""title"": ""Also Awesome book"" }, { - ""id"": 3, - ""title"": ""Great wall of china explained"" + ""id"": 3, + ""title"": ""Great wall of china explained"" }, { - ""id"": 4, - ""title"": ""US history in a nutshell"" + ""id"": 4, + ""title"": ""US history in a nutshell"" }, { - ""id"": 5, - ""title"": ""Chernobyl Diaries"" + ""id"": 5, + ""title"": ""Chernobyl Diaries"" }, { - ""id"": 6, - ""title"": ""The Palace Door"" + ""id"": 6, + ""title"": ""The Palace Door"" }, { - ""id"": 7, - ""title"": ""The Groovy Bar"" + ""id"": 7, + ""title"": ""The Groovy Bar"" }, { - ""id"": 8, - ""title"": ""Time to Eat"" + ""id"": 8, + ""title"": ""Time to Eat"" }, { - ""id"": 9, - ""title"": ""Policy-Test-01"" + ""id"": 9, + ""title"": ""Policy-Test-01"" }, { - ""id"": 10, - ""title"": ""Policy-Test-02"" + ""id"": 10, + ""title"": ""Policy-Test-02"" }, { - ""id"": 11, - ""title"": ""Policy-Test-04"" + ""id"": 11, + ""title"": ""Policy-Test-04"" }, { - ""id"": 12, - ""title"": ""Time to Eat 2"" + ""id"": 12, + ""title"": ""Time to Eat 2"" }, { - ""id"": 13, - ""title"": ""Before Sunrise"" + ""id"": 13, + ""title"": ""Before Sunrise"" }, { - ""id"": 14, - ""title"": ""Before Sunset"" + ""id"": 14, + ""title"": ""Before Sunset"" }, { - ""id"": 15, - ""title"": ""SQL_CONN"" + ""id"": 15, + ""title"": ""SQL_CONN"" }, { - ""id"": 16, - ""title"": ""SOME%CONN"" + ""id"": 16, + ""title"": ""SOME%CONN"" }, { - ""id"": 17, - ""title"": ""CONN%_CONN"" + ""id"": 17, + ""title"": ""CONN%_CONN"" }, { - ""id"": 18, - ""title"": ""[Special Book]"" + ""id"": 18, + ""title"": ""[Special Book]"" }, { - ""id"": 19, - ""title"": ""ME\\YOU"" + ""id"": 19, + ""title"": ""ME\\YOU"" }, { - ""id"": 20, - ""title"": ""C:\\\\LIFE"" + ""id"": 20, + ""title"": ""C:\\\\LIFE"" + }, + { + ""id"": 21, + ""title"": """" } - ], - ""endCursor"": null, - ""hasNextPage"": false + ], + ""endCursor"": null, + ""hasNextPage"": false }"; SqlTestHelper.PerformTestEqualJsonStrings(expected, actual.ToString()); diff --git a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs index 00930103c7..876424f0dd 100644 --- a/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs +++ b/src/Service.Tests/SqlTests/GraphQLQueryTests/MsSqlGraphQLQueryTests.cs @@ -4,7 +4,9 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Threading.Tasks; using Azure.DataApiBuilder.Config.ObjectModel; using Microsoft.VisualStudio.TestTools.UnitTesting; @@ -229,6 +231,199 @@ FOR JSON PATH await InFilterOneToOneJoinQuery(msSqlQuery); } + /// + /// Verifies that the nested reviews connection under books correctly paginates + /// when there are more than 100 reviews for a single book, while also + /// exercising sibling navigation properties (websiteplacement and authors) + /// under RBAC in the same request. The first page should contain 100 reviews + /// and the endCursor should encode the id of the last review on that page. + /// + [TestMethod] + public async Task NestedReviewsConnection_WithSiblings_PaginatesMoreThanHundredItems() + { + // Seed > 100 reviews for book id 1. Use a distinct id range so we can + // clean up without impacting existing rows used by other tests. + StringBuilder sb = new(); + sb.AppendLine("SET IDENTITY_INSERT reviews ON;"); + sb.AppendLine("INSERT INTO reviews(id, book_id, content) VALUES"); + + for (int id = 2000; id <= 2100; id++) + { + string line = $" ({id}, 1, 'Bulk review {id}')"; + if (id < 2100) + { + line += ","; + } + else + { + line += ";"; + } + + sb.AppendLine(line); + } + + sb.AppendLine("SET IDENTITY_INSERT reviews OFF;"); + string seedReviewsSql = sb.ToString(); + + string cleanupReviewsSql = "DELETE FROM reviews WHERE id BETWEEN 2000 AND 2100;"; + + try + { + // Seed additional data for this test only. + await _queryExecutor.ExecuteQueryAsync( + seedReviewsSql, + dataSourceName: string.Empty, + parameters: null, + dataReaderHandler: null); + + string graphQLQueryName = "books"; + string graphQLQuery = @"query { + books(filter: { id: { eq: 1 } }) { + items { + id + title + websiteplacement { + price + } + reviews(first: 100) { + items { + id + } + endCursor + hasNextPage + } + authors { + items { + name + } + } + } + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync( + graphQLQuery, + graphQLQueryName, + isAuthenticated: true, + clientRoleHeader: "authenticated"); + + JsonElement book = actual.GetProperty("items")[0]; + JsonElement websiteplacement = book.GetProperty("websiteplacement"); + JsonElement authorsConnection = book.GetProperty("authors"); + JsonElement reviewsConnection = book.GetProperty("reviews"); + JsonElement reviewItems = reviewsConnection.GetProperty("items"); + + // First page should contain exactly 100 reviews when more than 100 exist. + Assert.AreEqual(100, reviewItems.GetArrayLength(), "Expected first page of reviews to contain 100 items."); + + bool hasNextPage = reviewsConnection.GetProperty("hasNextPage").GetBoolean(); + Assert.IsTrue(hasNextPage, "Expected hasNextPage to be true when more than 100 reviews exist."); + + string endCursor = reviewsConnection.GetProperty("endCursor").GetString(); + Assert.IsFalse(string.IsNullOrEmpty(endCursor), "Expected endCursor to be populated when hasNextPage is true."); + + // Decode the opaque cursor and verify it encodes the id of the + // last review on the first page. + int lastReviewIdOnPage = reviewItems[reviewItems.GetArrayLength() - 1].GetProperty("id").GetInt32(); + + string decodedCursorJson = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(endCursor)); + using JsonDocument cursorDoc = JsonDocument.Parse(decodedCursorJson); + JsonElement cursorArray = cursorDoc.RootElement; + + bool foundIdFieldInCursor = false; + foreach (JsonElement field in cursorArray.EnumerateArray()) + { + if (field.GetProperty("FieldName").GetString() == "id") + { + int cursorId = field.GetProperty("FieldValue").GetInt32(); + Assert.AreEqual(lastReviewIdOnPage, cursorId, "endCursor should encode the id of the last review on the first page."); + foundIdFieldInCursor = true; + break; + } + } + + Assert.IsTrue(foundIdFieldInCursor, "endCursor payload should include a pagination field for id."); + + // Also validate that sibling navigation properties under RBAC are + // materialized as part of the same query and that their results + // match the outcome of equivalent SQL JSON queries. + Assert.AreEqual(JsonValueKind.Object, websiteplacement.ValueKind, "Expected websiteplacement object to be materialized."); + + JsonElement authorsItems = authorsConnection.GetProperty("items"); + Assert.IsTrue(authorsItems.GetArrayLength() > 0, "Expected authors collection to be materialized with at least one author."); + + // Compare the full nested tree (id, title, websiteplacement, reviews.items, authors.items) + // against an equivalent SQL JSON query. Shape the SQL JSON to match the GraphQL + // "book" object so we can use the actual result directly for comparison. + string fullTreeSql = @" + SELECT + [b].[id] AS [id], + [b].[title] AS [title], + JSON_QUERY(( + SELECT TOP 1 [wp].[price] AS [price] + FROM [dbo].[book_website_placements] AS [wp] + WHERE [wp].[book_id] = [b].[id] + ORDER BY [wp].[id] ASC + FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER + )) AS [websiteplacement], + JSON_QUERY(( + SELECT + JSON_QUERY(( + SELECT TOP 100 [r].[id] AS [id] + FROM [dbo].[reviews] AS [r] + WHERE [r].[book_id] = [b].[id] + ORDER BY [r].[id] ASC + FOR JSON PATH, INCLUDE_NULL_VALUES + )) AS [items] + FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER + )) AS [reviews], + JSON_QUERY(( + SELECT + JSON_QUERY(( + SELECT [a].[name] AS [name] + FROM [dbo].[authors] AS [a] + INNER JOIN [dbo].[book_author_link] AS [bal] + ON [bal].[author_id] = [a].[id] + WHERE [bal].[book_id] = [b].[id] + ORDER BY [a].[id] ASC + FOR JSON PATH, INCLUDE_NULL_VALUES + )) AS [items] + FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER + )) AS [authors] + FROM [dbo].[books] AS [b] + WHERE [b].[id] = 1 + FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER"; + + string expectedFullTreeJson = await GetDatabaseResultAsync(fullTreeSql); + + // Use the actual GraphQL "book" object for comparison, trimming pagination metadata + // (endCursor, hasNextPage) that cannot be reproduced via SQL. + JsonNode actualBookNode = JsonNode.Parse(book.ToString()); + if (actualBookNode is JsonObject bookObject && + bookObject["reviews"] is JsonObject reviewsObject) + { + reviewsObject.Remove("endCursor"); + reviewsObject.Remove("hasNextPage"); + } + + string actualComparableJson = actualBookNode.ToJsonString(); + + SqlTestHelper.PerformTestEqualJsonStrings( + expectedFullTreeJson, + actualComparableJson); + } + finally + { + // Clean up the seeded reviews so other tests relying on the base + // fixture data remain unaffected. + await _queryExecutor.ExecuteQueryAsync( + cleanupReviewsSql, + dataSourceName: string.Empty, + parameters: null, + dataReaderHandler: null); + } + } + /// /// Test query on One-To-One relationship when the fields defining /// the relationship in the entity include fields that are mapped in @@ -268,6 +463,22 @@ SELECT title FROM books await QueryWithSingleColumnPrimaryKey(msSqlQuery); } + [TestMethod] + public virtual async Task QueryWithEmptyStringResult() + { + string graphQLQueryName = "book_by_pk"; + string graphQLQuery = @"{ + book_by_pk(id: 21) { + title + } + }"; + + JsonElement actual = await ExecuteGraphQLRequestAsync(graphQLQuery, graphQLQueryName, isAuthenticated: false); + + string title = actual.GetProperty("title").GetString(); + Assert.AreEqual("", title); + } + [TestMethod] public async Task QueryWithSingleColumnPrimaryKeyAndMappings() { diff --git a/src/Service.Tests/SqlTests/RestApiTests/Delete/DwSqlDeleteApiTest.cs b/src/Service.Tests/SqlTests/RestApiTests/Delete/DwSqlDeleteApiTest.cs index c574db540f..984b252727 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Delete/DwSqlDeleteApiTest.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Delete/DwSqlDeleteApiTest.cs @@ -20,7 +20,7 @@ public class DwSqlDeleteApiTests : DeleteApiTestBase { "DeleteOneWithStoredProcedureTest", $"SELECT [id] FROM { _integrationTableName } " + - $"WHERE id = 20" + $"WHERE id = 21" } }; #region Test Fixture Setup diff --git a/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs b/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs index 13f4d31cf2..cf8a1f6fc5 100644 --- a/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs +++ b/src/Service.Tests/SqlTests/RestApiTests/Delete/MsSqlDeleteApiTest.cs @@ -29,7 +29,7 @@ public class MsSqlDeleteApiTests : DeleteApiTestBase // This query is used to confirm that the item no longer exists, not the // actual delete query. $"SELECT [id] FROM { _integrationTableName } " + - $"WHERE id = 20 FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" + $"WHERE id = 21 FOR JSON PATH, INCLUDE_NULL_VALUES, WITHOUT_ARRAY_WRAPPER" } }; #region Test Fixture Setup diff --git a/src/Service.Tests/SqlTests/SqlTestBase.cs b/src/Service.Tests/SqlTests/SqlTestBase.cs index bb8ed0628d..16e804f117 100644 --- a/src/Service.Tests/SqlTests/SqlTestBase.cs +++ b/src/Service.Tests/SqlTests/SqlTestBase.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; using System.IO; using System.Net; @@ -36,6 +37,7 @@ using MySqlConnector; using Npgsql; using ZiggyCreatures.Caching.Fusion; +using static Azure.DataApiBuilder.Core.AuthenticationHelpers.AppServiceAuthentication; namespace Azure.DataApiBuilder.Service.Tests.SqlTests { @@ -84,6 +86,7 @@ protected async static Task InitializeTestFixture( bool isRestBodyStrict = true) { TestHelper.SetupDatabaseEnvironment(DatabaseEngine); + // Get the base config file from disk RuntimeConfig runtimeConfig = SqlTestHelper.SetupRuntimeConfig(); @@ -508,9 +511,37 @@ protected static async Task SetupAndRunRestApiTest( if (clientRoleHeader is not null) { - request.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, clientRoleHeader.ToString()); - request.Headers.Add(AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, - AuthTestHelper.CreateStaticWebAppsEasyAuthToken(addAuthenticated: true, specificRole: clientRoleHeader)); + request.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, clientRoleHeader); + + // Detect runtime auth provider once per call + RuntimeConfigProvider configProvider = _application.Services.GetRequiredService(); + string provider = configProvider.GetConfig().Runtime.Host.Authentication.Provider; + + if (string.Equals(provider, nameof(EasyAuthType.AppService), StringComparison.OrdinalIgnoreCase)) + { + // AppService EasyAuth principal with this role + request.Headers.Add( + AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, + AuthTestHelper.CreateAppServiceEasyAuthToken( + roleClaimType: AuthenticationOptions.ROLE_CLAIM_TYPE, + additionalClaims: + [ + new AppServiceClaim + { + Typ = AuthenticationOptions.ROLE_CLAIM_TYPE, + Val = clientRoleHeader + } + ])); + } + else + { + // Static Web Apps principal as before + request.Headers.Add( + AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, + AuthTestHelper.CreateStaticWebAppsEasyAuthToken( + addAuthenticated: true, + specificRole: clientRoleHeader)); + } } // Send request to the engine. @@ -614,13 +645,43 @@ protected virtual async Task ExecuteGraphQLRequestAsync( bool expectsError = false) { RuntimeConfigProvider configProvider = _application.Services.GetService(); + + string authToken = null; + + if (isAuthenticated) + { + string provider = configProvider.GetConfig().Runtime.Host.Authentication.Provider; + + if (string.Equals(provider, nameof(EasyAuthType.AppService), StringComparison.OrdinalIgnoreCase)) + { + authToken = AuthTestHelper.CreateAppServiceEasyAuthToken( + roleClaimType: AuthenticationOptions.ROLE_CLAIM_TYPE, + additionalClaims: !string.IsNullOrEmpty(clientRoleHeader) + ? + [ + new AppServiceClaim + { + Typ = AuthenticationOptions.ROLE_CLAIM_TYPE, + Val = clientRoleHeader + } + ] + : null); + } + else + { + authToken = AuthTestHelper.CreateStaticWebAppsEasyAuthToken( + addAuthenticated: true, + specificRole: clientRoleHeader); + } + } + return await GraphQLRequestExecutor.PostGraphQLRequestAsync( HttpClient, configProvider, queryName, query, variables, - isAuthenticated ? AuthTestHelper.CreateStaticWebAppsEasyAuthToken(specificRole: clientRoleHeader) : null, + authToken, clientRoleHeader: clientRoleHeader); } diff --git a/src/Service.Tests/SqlTests/SqlTestHelper.cs b/src/Service.Tests/SqlTests/SqlTestHelper.cs index e739f6cc8c..cf65c9a9f5 100644 --- a/src/Service.Tests/SqlTests/SqlTestHelper.cs +++ b/src/Service.Tests/SqlTests/SqlTestHelper.cs @@ -381,7 +381,7 @@ public static RuntimeConfig InitBasicRuntimeConfigWithNoEntity( string testCategory = TestCategory.MSSQL) { DataSource dataSource = new(dbType, GetConnectionStringFromEnvironmentConfig(environment: testCategory), new()); - Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: nameof(EasyAuthType.StaticWebApps), null); + Config.ObjectModel.AuthenticationOptions authenticationOptions = new(Provider: nameof(EasyAuthType.AppService), null); RuntimeConfig runtimeConfig = new( Schema: "IntegrationTestMinimalSchema", diff --git a/src/Service.Tests/TestHelper.cs b/src/Service.Tests/TestHelper.cs index b94470b96b..035ee3e6ee 100644 --- a/src/Service.Tests/TestHelper.cs +++ b/src/Service.Tests/TestHelper.cs @@ -185,7 +185,7 @@ public static string GenerateInvalidSchema() ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }, @@ -217,7 +217,7 @@ public static string GenerateInvalidSchema() ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }, @@ -259,7 +259,7 @@ public static string GenerateInvalidSchema() ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" } } }" + "," + @@ -330,27 +330,39 @@ public static RuntimeConfigProvider GenerateInMemoryRuntimeConfigProvider(Runtim /// HostMode for the engine /// Database type /// Base route for API requests. - public static void ConstructNewConfigWithSpecifiedHostMode(string configFileName, HostMode hostModeType, string databaseType, string runtimeBaseRoute = "/") + /// Provider for authentication + public static void ConstructNewConfigWithSpecifiedHostMode( + string configFileName, + HostMode hostModeType, + string databaseType, + string runtimeBaseRoute = "/", + string provider = "AppService") { SetupDatabaseEnvironment(databaseType); RuntimeConfigProvider configProvider = GetRuntimeConfigProvider(GetRuntimeConfigLoader()); RuntimeConfig config = configProvider.GetConfig(); + AuthenticationOptions auth = config.Runtime?.Host?.Authentication with { Provider = provider }; + + bool isStaticWebApps = string.Equals(provider, "StaticWebApps", StringComparison.OrdinalIgnoreCase); + RuntimeConfig configWithCustomHostMode = - config - with + config with { - Runtime = config.Runtime - with + Runtime = config.Runtime with { - Host = config.Runtime?.Host - with - { Mode = hostModeType }, - BaseRoute = runtimeBaseRoute + Host = config.Runtime?.Host with + { + Mode = hostModeType, + // For tests that explicitly set SWA, we’ll keep BaseRoute. + // For others (AppService), BaseRoute will be null. + Authentication = auth + }, + BaseRoute = isStaticWebApps ? runtimeBaseRoute : null } }; - File.WriteAllText(configFileName, configWithCustomHostMode.ToJson()); + File.WriteAllText(configFileName, configWithCustomHostMode.ToJson()); } /// diff --git a/src/Service.Tests/UnitTests/ConfigFileWatcherUnitTests.cs b/src/Service.Tests/UnitTests/ConfigFileWatcherUnitTests.cs index 4807fcfe6f..b0ae580828 100644 --- a/src/Service.Tests/UnitTests/ConfigFileWatcherUnitTests.cs +++ b/src/Service.Tests/UnitTests/ConfigFileWatcherUnitTests.cs @@ -54,7 +54,7 @@ HostMode mode ""allow-credentials"": false }, ""authentication"": { - ""provider"": ""StaticWebApps"" + ""provider"": ""AppService"" }, ""mode"": """ + mode + @""" } diff --git a/src/Service.Tests/UnitTests/MySqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/MySqlQueryExecutorUnitTests.cs index cbfef36664..63deed78d3 100644 --- a/src/Service.Tests/UnitTests/MySqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/MySqlQueryExecutorUnitTests.cs @@ -81,7 +81,8 @@ await provider.Initialize( provider.GetConfig().ToJson(), graphQLSchema: null, connectionString: connectionString, - accessToken: CONFIG_TOKEN); + accessToken: CONFIG_TOKEN, + replacementSettings: new()); mySqlQueryExecutor = new(provider, dbExceptionParser.Object, queryExecutorLogger.Object, httpContextAccessor.Object); } } diff --git a/src/Service.Tests/UnitTests/PostgreSqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/PostgreSqlQueryExecutorUnitTests.cs index ccaa90b353..6039c46a72 100644 --- a/src/Service.Tests/UnitTests/PostgreSqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/PostgreSqlQueryExecutorUnitTests.cs @@ -89,7 +89,8 @@ await provider.Initialize( provider.GetConfig().ToJson(), graphQLSchema: null, connectionString: connectionString, - accessToken: CONFIG_TOKEN); + accessToken: CONFIG_TOKEN, + replacementSettings: new()); postgreSqlQueryExecutor = new(provider, dbExceptionParser.Object, queryExecutorLogger.Object, httpContextAccessor.Object); } } diff --git a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs index b98de993e2..3ec7c47f10 100644 --- a/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs +++ b/src/Service.Tests/UnitTests/RuntimeConfigLoaderJsonDeserializerTests.cs @@ -13,7 +13,10 @@ using Azure.DataApiBuilder.Config.Converters; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Service.Exceptions; +using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Logging; using Microsoft.VisualStudio.TestTools.UnitTesting; +using Moq; namespace Azure.DataApiBuilder.Service.Tests.UnitTests { @@ -79,18 +82,18 @@ public void CheckConfigEnvParsingTest( if (replaceEnvVar) { Assert.IsTrue(RuntimeConfigLoader.TryParseConfig( - GetModifiedJsonString(repValues, @"""postgresql"""), out expectedConfig, replaceEnvVar: replaceEnvVar), + GetModifiedJsonString(repValues, @"""postgresql"""), out expectedConfig, replacementSettings: new DeserializationVariableReplacementSettings(azureKeyVaultOptions: null, doReplaceEnvVar: replaceEnvVar, doReplaceAkvVar: false)), "Should read the expected config"); } else { Assert.IsTrue(RuntimeConfigLoader.TryParseConfig( - GetModifiedJsonString(repKeys, @"""postgresql"""), out expectedConfig, replaceEnvVar: replaceEnvVar), + GetModifiedJsonString(repKeys, @"""postgresql"""), out expectedConfig, replacementSettings: new DeserializationVariableReplacementSettings(azureKeyVaultOptions: null, doReplaceEnvVar: replaceEnvVar, doReplaceAkvVar: false)), "Should read the expected config"); } Assert.IsTrue(RuntimeConfigLoader.TryParseConfig( - GetModifiedJsonString(repKeys, @"""@env('enumVarName')"""), out RuntimeConfig actualConfig, replaceEnvVar: replaceEnvVar), + GetModifiedJsonString(repKeys, @"""@env('enumVarName')"""), out RuntimeConfig actualConfig, replacementSettings: new DeserializationVariableReplacementSettings(azureKeyVaultOptions: null, doReplaceEnvVar: replaceEnvVar, doReplaceAkvVar: false)), "Should read actual config"); Assert.AreEqual(expectedConfig.ToJson(), actualConfig.ToJson()); } @@ -130,7 +133,7 @@ public void TestConfigParsingWithEnvVarReplacement(bool replaceEnvVar, string da string configWithEnvVar = _configWithVariableDataSource.Replace("{0}", GetDataSourceConfigForGivenDatabase(databaseType)); bool isParsingSuccessful = RuntimeConfigLoader.TryParseConfig( - configWithEnvVar, out RuntimeConfig runtimeConfig, replaceEnvVar: replaceEnvVar); + configWithEnvVar, out RuntimeConfig runtimeConfig, replacementSettings: new DeserializationVariableReplacementSettings(azureKeyVaultOptions: null, doReplaceEnvVar: replaceEnvVar, doReplaceAkvVar: true)); // Assert Assert.IsTrue(isParsingSuccessful); @@ -178,7 +181,7 @@ public void TestConfigParsingWhenDataSourceOptionsForCosmosDBContainsInvalidValu string configWithEnvVar = _configWithVariableDataSource.Replace("{0}", GetDataSourceOptionsForCosmosDBWithInvalidValues()); bool isParsingSuccessful = RuntimeConfigLoader.TryParseConfig( - configWithEnvVar, out RuntimeConfig runtimeConfig, replaceEnvVar: true); + configWithEnvVar, out RuntimeConfig runtimeConfig, replacementSettings: new DeserializationVariableReplacementSettings(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true)); // Assert Assert.IsTrue(isParsingSuccessful); @@ -212,6 +215,140 @@ private static string GetExpectedPropertyValue(string envVarName, bool replaceEn } } + /// + /// Test method to validate that environment variable replacement works correctly + /// for the telemetry.application-insights.enabled property when set through config + /// or through environment variables + /// + [TestMethod] + [DataRow(true, DisplayName = "ApplicationInsights.Enabled set to true (literal bool)")] + [DataRow(false, DisplayName = "ApplicationInsights.Enabled set to false (literal bool)")] + public void TestTelemetryApplicationInsightsEnabled(bool expected) + { + TestTelemetryApplicationInsightsEnabledInternal(expected.ToString().ToLower(), expected); + } + + [TestMethod] + [DataRow("true", true, DisplayName = "ApplicationInsights.Enabled from string 'true'")] + [DataRow("false", false, DisplayName = "ApplicationInsights.Enabled from string 'false'")] + [DataRow("1", true, DisplayName = "ApplicationInsights.Enabled from string '1'")] + [DataRow("0", false, DisplayName = "ApplicationInsights.Enabled from string '0'")] + public void TestTelemetryApplicationInsightsEnabledFromString(string configSetting, bool expected) + { + + TestTelemetryApplicationInsightsEnabledInternal($"\"{configSetting}\"", expected); + } + + [TestMethod] + [DataRow("true", true, DisplayName = "ApplicationInsights.Enabled from environment 'true'")] + [DataRow("false", false, DisplayName = "ApplicationInsights.Enabled from environment 'false'")] + [DataRow("1", true, DisplayName = "ApplicationInsights.Enabled from environment '1'")] + [DataRow("0", false, DisplayName = "ApplicationInsights.Enabled from environment '0'")] + public void TestTelemetryApplicationInsightsEnabledFromEnvironment(string configSetting, bool expected) + { + // Arrange + const string envVarName = "APP_INSIGHTS_ENABLED"; + string envVarValue = configSetting; + // Set up the environment variable + Environment.SetEnvironmentVariable(envVarName, envVarValue); + + try + { + TestTelemetryApplicationInsightsEnabledInternal("\"@env('APP_INSIGHTS_ENABLED')\"", expected); + } + finally + { + // Cleanup + Environment.SetEnvironmentVariable(envVarName, null); + } + + } + public static void TestTelemetryApplicationInsightsEnabledInternal(string configValue, bool expected) + { + const string AppInsightsConnectionString = "InstrumentationKey=test-key"; + + string configJson = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;"" + }, + ""runtime"": { + ""telemetry"": { + ""application-insights"": { + ""enabled"": " + configValue + @", + ""connection-string"": """ + AppInsightsConnectionString + @""" + } + } + }, + ""entities"": { } + }"; + + // Act + bool IsParsed = RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig runtimeConfig, + replacementSettings: new DeserializationVariableReplacementSettings( + azureKeyVaultOptions: null, + doReplaceEnvVar: true, + doReplaceAkvVar: false)); + + // Assert + Assert.IsTrue(IsParsed); + Assert.AreEqual(AppInsightsConnectionString, runtimeConfig.Runtime.Telemetry.ApplicationInsights.ConnectionString, "Connection string should be preserved"); + Assert.AreEqual(expected, runtimeConfig.Runtime.Telemetry.ApplicationInsights.Enabled, "ApplicationInsights enabled value should match expected value"); + } + + /// + /// + /// + /// Value to set in the config to cause error + /// Error message + [TestMethod] + [DataRow("somenonboolean", "Invalid boolean value: somenonboolean. Specify either true or 1 for true, false or 0 for false", DisplayName = "ApplicationInsights.Enabled invalid value should error")] + public void TestTelemetryApplicationInsightsEnabledShouldError(string configValue, string message) + { + string configJson = @"{ + ""$schema"": ""https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json"", + ""data-source"": { + ""database-type"": ""mssql"", + ""connection-string"": ""Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;"" + }, + ""runtime"": { + ""telemetry"": { + ""application-insights"": { + ""enabled"": """ + configValue + @""", + ""connection-string"": ""InstrumentationKey=test-key"" + } + } + }, + ""entities"": { } + }"; + + // Arrange + Mock mockLogger = new(); + + // Act + bool isParsed = RuntimeConfigLoader.TryParseConfig( + configJson, + out RuntimeConfig runtimeConfig, + replacementSettings: new DeserializationVariableReplacementSettings( + azureKeyVaultOptions: null, + doReplaceEnvVar: true, + doReplaceAkvVar: false), + logger: mockLogger.Object); + + // Assert + Assert.IsFalse(isParsed); + Assert.IsNull(runtimeConfig); + + Assert.AreEqual(1, mockLogger.Invocations.Count, "Should raise 1 exception"); + Assert.AreEqual(5, mockLogger.Invocations[0].Arguments.Count, "Log should have 4 arguments"); + var ConfigException = mockLogger.Invocations[0].Arguments[3] as JsonException; + Assert.IsInstanceOfType(ConfigException, typeof(JsonException), "Should have raised a Json Exception"); + Assert.AreEqual(message, ConfigException.Message); + } + /// /// Method to validate that comments are skipped in config file (and are ignored during deserialization). /// @@ -240,6 +377,7 @@ public void CheckCommentParsingInConfigFile() /// but have the effect of default values when deserialized. /// It starts with a minimal config and incrementally /// adds the optional subproperties. At each step, tests for valid deserialization. + /// [TestMethod] public void TestNullableOptionalProps() { @@ -302,7 +440,7 @@ public void CheckConfigEnvParsingThrowExceptions(string invalidEnvVarName) { string json = @"{ ""foo"" : ""@env('envVarName'), @env('" + invalidEnvVarName + @"')"" }"; SetEnvVariables(); - StringJsonConverterFactory stringConverterFactory = new(EnvironmentVariableReplacementFailureMode.Throw); + StringJsonConverterFactory stringConverterFactory = new(new(doReplaceEnvVar: true, envFailureMode: EnvironmentVariableReplacementFailureMode.Throw)); JsonSerializerOptions options = new() { PropertyNameCaseInsensitive = true }; options.Converters.Add(stringConverterFactory); Assert.ThrowsException(() => JsonSerializer.Deserialize(json, options)); @@ -324,7 +462,7 @@ public void TestDataSourceDeserializationFailures(string dbType, string connecti ""entities"":{ } }"; // replaceEnvVar: true is needed to make sure we do post-processing for the connection string case - Assert.IsFalse(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig, replaceEnvVar: true)); + Assert.IsFalse(RuntimeConfigLoader.TryParseConfig(configJson, out RuntimeConfig deserializedConfig, replacementSettings: new DeserializationVariableReplacementSettings(azureKeyVaultOptions: null, doReplaceEnvVar: true, doReplaceAkvVar: true))); Assert.IsNull(deserializedConfig); } @@ -343,7 +481,8 @@ public void TestLoadRuntimeConfigFailures( MockFileSystem fileSystem = new(); FileSystemRuntimeConfigLoader loader = new(fileSystem); - Assert.IsFalse(loader.TryLoadConfig(configFileName, out RuntimeConfig _)); + // Use null replacement settings for this test + Assert.IsFalse(loader.TryLoadConfig(configFileName, out RuntimeConfig _, replacementSettings: null)); } /// @@ -430,7 +569,7 @@ public static string GetModifiedJsonString(string[] reps, string enumString) ""host"": { ""mode"": ""development"", ""cors"": { - ""origins"": [ """ + reps[++index % reps.Length] + @""", """ + reps[++index % reps.Length] + @""" ], + ""origins"": [ """ + reps[++index % reps.Length] + @""", """ + reps[++index % reps.Length] + @"""], ""allow-credentials"": true }, ""authentication"": { @@ -653,7 +792,7 @@ private static bool TryParseAndAssertOnDefaults(string json, out RuntimeConfig p Assert.AreEqual(McpRuntimeOptions.DEFAULT_PATH, parsedConfig.McpPath); Assert.IsTrue(parsedConfig.AllowIntrospection); Assert.IsFalse(parsedConfig.IsDevelopmentMode()); - Assert.IsTrue(parsedConfig.IsStaticWebAppsIdentityProvider); + Assert.IsTrue(parsedConfig.IsAppServiceIdentityProvider); Assert.IsTrue(parsedConfig.IsRequestBodyStrict); Assert.IsTrue(parsedConfig.IsLogLevelNull()); Assert.IsTrue(parsedConfig.Runtime?.Telemetry?.ApplicationInsights is null @@ -670,5 +809,179 @@ private static bool TryParseAndAssertOnDefaults(string json, out RuntimeConfig p #endregion Helper Functions record StubJsonType(string Foo); + + /// + /// Test to verify Azure Key Vault variable replacement from local .akv file. + /// + [TestMethod] + public void TestAkvVariableReplacementFromLocalFile() + { + // Arrange: create a temporary .akv secrets file + string akvFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".akv"); + string secretConnectionString = "Server=tcp:127.0.0.1,1433;Persist Security Info=False;Trusted_Connection=True;TrustServerCertificate=True;MultipleActiveResultSets=False;Connection Timeout=5;"; + File.WriteAllText(akvFilePath, $"DBCONN={secretConnectionString}\nAPI_KEY=abcd\n# Comment line should be ignored\n MALFORMEDLINE \n"); + + // Escape backslashes for JSON + string escapedPath = akvFilePath.Replace("\\", "\\\\"); + + string jsonConfig = $$""" + { + "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "@akv('DBCONN')" + }, + "azure-key-vault": { + "endpoint": "{{escapedPath}}" + }, + "entities": { } + } + """; + + try + { + // Act + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: null, + doReplaceEnvVar: false, + doReplaceAkvVar: true); + bool parsed = RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig config, replacementSettings: replacementSettings); + + // Assert + Assert.IsTrue(parsed, "Config should parse successfully with local AKV file replacement."); + Assert.IsNotNull(config, "Config should not be null."); + Assert.AreEqual(secretConnectionString, config.DataSource.ConnectionString, "Connection string should be replaced from AKV local file secret."); + } + finally + { + // Cleanup + if (File.Exists(akvFilePath)) + { + File.Delete(akvFilePath); + } + } + } + + /// + /// Validates that when an AKV secret's value itself contains an @env('...') pattern, it is NOT further resolved + /// because replacement only runs once per original JSON token. Demonstrates that nested env patterns inside + /// AKV secret values are left intact. + /// + [TestMethod] + public void TestAkvSecretValueContainingEnvPatternIsNotEnvExpanded() + { + string akvFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".akv"); + // Valid MSSQL connection string which embeds an @env('env') pattern in the Database value. + // This pattern should NOT be expanded because replacement only runs once on the original JSON token (@akv('DBCONN')). + string secretValueWithEnvPattern = "Server=localhost;Database=@env('env');User Id=sa;Password=XXXX;"; + File.WriteAllText(akvFilePath, $"DBCONN={secretValueWithEnvPattern}\n"); + string escapedPath = akvFilePath.Replace("\\", "\\\\"); + + // Set env variable to prove it would be different if expansion occurred. + Environment.SetEnvironmentVariable("env", "SHOULD_NOT_APPEAR"); + + string jsonConfig = $$""" + { + "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "@akv('DBCONN')" + }, + "azure-key-vault": { + "endpoint": "{{escapedPath}}" + }, + "entities": { } + } + """; + + try + { + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: null, + doReplaceEnvVar: true, + doReplaceAkvVar: true); + bool parsed = RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig config, replacementSettings: replacementSettings); + Assert.IsTrue(parsed, "Config should parse successfully."); + Assert.IsNotNull(config); + + string actual = config.DataSource.ConnectionString; + Assert.IsTrue(actual.Contains("@env('env')"), "Nested @env pattern inside AKV secret should remain unexpanded."); + Assert.IsFalse(actual.Contains("SHOULD_NOT_APPEAR"), "Env var value should not be expanded inside AKV secret."); + Assert.IsTrue(actual.Contains("Application Name="), "Application Name should be appended for MSSQL when env replacement is enabled."); + + var builderOriginal = new SqlConnectionStringBuilder(secretValueWithEnvPattern.Replace("Server=", "Data Source=").Replace("Database=", "Initial Catalog=")); + var builderActual = new SqlConnectionStringBuilder(actual); + Assert.AreEqual(builderOriginal["Data Source"], builderActual["Data Source"], "Server/Data Source should match."); + Assert.AreEqual(builderOriginal["Initial Catalog"], builderActual["Initial Catalog"], "Database/Initial Catalog should match (with env pattern retained)."); + Assert.AreEqual(builderOriginal["User ID"], builderActual["User ID"], "User Id should match."); + Assert.AreEqual(builderOriginal["Password"], builderActual["Password"], "Password should match."); + } + finally + { + if (File.Exists(akvFilePath)) + { + File.Delete(akvFilePath); + } + + Environment.SetEnvironmentVariable("env", null); + } + } + + /// + /// Validates two-pass replacement where an env var resolves to an AKV pattern which then resolves to the secret value. + /// connection-string = @env('env_variable'), env_variable value = @akv('DBCONN'), AKV secret DBCONN holds the final connection string. + /// + [TestMethod] + public void TestEnvVariableResolvingToAkvPatternIsExpandedInSecondPass() + { + string akvFilePath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString() + ".akv"); + string finalSecretValue = "Server=localhost;Database=Test;User Id=sa;Password=XXXX;"; + File.WriteAllText(akvFilePath, $"DBCONN={finalSecretValue}\n"); + string escapedPath = akvFilePath.Replace("\\", "\\\\"); + Environment.SetEnvironmentVariable("env_variable", "@akv('DBCONN')"); + + string jsonConfig = $$""" + { + "$schema": "https://github.com/Azure/data-api-builder/releases/download/vmajor.minor.patch-alpha/dab.draft.schema.json", + "data-source": { + "database-type": "mssql", + "connection-string": "@env('env_variable')" + }, + "azure-key-vault": { + "endpoint": "{{escapedPath}}" + }, + "entities": { } + } + """; + + try + { + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: null, + doReplaceEnvVar: true, + doReplaceAkvVar: true); + bool parsed = RuntimeConfigLoader.TryParseConfig(jsonConfig, out RuntimeConfig config, replacementSettings: replacementSettings); + Assert.IsTrue(parsed, "Config should parse successfully."); + Assert.IsNotNull(config); + + string expected = RuntimeConfigLoader.GetConnectionStringWithApplicationName(finalSecretValue); + var builderExpected = new SqlConnectionStringBuilder(expected); + var builderActual = new SqlConnectionStringBuilder(config.DataSource.ConnectionString); + Assert.AreEqual(builderExpected["Data Source"], builderActual["Data Source"], "Data Source should match."); + Assert.AreEqual(builderExpected["Initial Catalog"], builderActual["Initial Catalog"], "Initial Catalog should match."); + Assert.AreEqual(builderExpected["User ID"], builderActual["User ID"], "User ID should match."); + Assert.AreEqual(builderExpected["Password"], builderActual["Password"], "Password should match."); + Assert.IsTrue(builderActual.ApplicationName?.Contains("dab_"), "Application Name should be appended including product identifier."); + } + finally + { + if (File.Exists(akvFilePath)) + { + File.Delete(akvFilePath); + } + + Environment.SetEnvironmentVariable("env_variable", null); + } + } } } diff --git a/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs b/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs index 44978cd6aa..12ae3ee993 100644 --- a/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs +++ b/src/Service.Tests/UnitTests/SerializationDeserializationTests.cs @@ -8,6 +8,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Serialization; +using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Config.DatabasePrimitives; using Azure.DataApiBuilder.Config.ObjectModel; using Azure.DataApiBuilder.Core.Services.MetadataProviders.Converters; @@ -187,7 +188,7 @@ public void TestSourceDefinitionCyclicObjectsSerializationDeserialization() _sourceDefinition.SourceEntityRelationshipMap.Add("persons", metadata); - // In serialization options we need ReferenceHandler = ReferenceHandler.Preserve, or else it doesnot seialize objects with cycle references + // In serialization options we need ReferenceHandler = ReferenceHandler.Preserve, or else it does not serialize objects with cycle references // SourceDefinition -> RelationShipMetadata -> ForeignKeyDefinition RelationshipPair ->DatabaseTable -> SourceDefinition Assert.ThrowsException(() => { @@ -276,8 +277,114 @@ public void TestDictionaryDatabaseObjectSerializationDeserialization() VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.TableDefinition, _databaseTable.TableDefinition, "FirstName"); } - private void InitializeObjects() + /// + /// Validates serialization and deserilization of Dictionary containing DatabaseTable + /// The table will have dollar sign prefix ($) in the column name + /// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict. + /// + [TestMethod] + public void TestDictionaryDatabaseObjectSerializationDeserialization_WithDollarColumn() + { + InitializeObjects(generateDollaredColumn: true); + + _options = new() + { + Converters = { + new DatabaseObjectConverter(), + new TypeConverter() + }, + ReferenceHandler = ReferenceHandler.Preserve + }; + + Dictionary dict = new() { { "person", _databaseTable } }; + + string serializedDict = JsonSerializer.Serialize(dict, _options); + // Assert that the serialized JSON contains the escaped dollar sign in column name + Assert.IsTrue(serializedDict.Contains("DAB_ESCAPE$FirstName"), + "Serialized JSON should contain the dollar-prefixed column name in SourceDefinition's Columns."); + + Dictionary deserializedDict = JsonSerializer.Deserialize>(serializedDict, _options)!; + DatabaseTable deserializedDatabaseTable = (DatabaseTable)deserializedDict["person"]; + + Assert.AreEqual(deserializedDatabaseTable.SourceType, _databaseTable.SourceType); + Assert.AreEqual(deserializedDatabaseTable.FullName, _databaseTable.FullName); + deserializedDatabaseTable.Equals(_databaseTable); + VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.SourceDefinition, _databaseTable.SourceDefinition, "$FirstName"); + VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseTable.TableDefinition, _databaseTable.TableDefinition, "$FirstName"); + } + + /// + /// Validates serialization and deserilization of Dictionary containing DatabaseView + /// The table will have dollar sign prefix ($) in the column name + /// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict. + /// + [TestMethod] + public void TestDatabaseViewSerializationDeserialization_WithDollarColumn() + { + InitializeObjects(generateDollaredColumn: true); + + TestTypeNameChanges(_databaseView, "DatabaseView"); + + Dictionary dict = new(); + dict.Add("person", _databaseView); + + // Test to catch if there is change in number of properties/fields + // Note: On Addition of property make sure it is added in following object creation _databaseView and include in serialization + // and deserialization test. + int fields = typeof(DatabaseView).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length; + Assert.AreEqual(fields, 6); + + string serializedDatabaseView = JsonSerializer.Serialize(dict, _options); + // Assert that the serialized JSON contains the escaped dollar sign in column name + Assert.IsTrue(serializedDatabaseView.Contains("DAB_ESCAPE$FirstName"), + "Serialized JSON should contain the dollar-prefixed column name in SourceDefinition's Columns."); + Dictionary deserializedDict = JsonSerializer.Deserialize>(serializedDatabaseView, _options)!; + + DatabaseView deserializedDatabaseView = (DatabaseView)deserializedDict["person"]; + + Assert.AreEqual(deserializedDatabaseView.SourceType, _databaseView.SourceType); + deserializedDatabaseView.Equals(_databaseView); + VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseView.SourceDefinition, _databaseView.SourceDefinition, "$FirstName"); + VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseView.ViewDefinition, _databaseView.ViewDefinition, "$FirstName"); + } + + /// + /// Validates serialization and deserilization of Dictionary containing DatabaseStoredProcedure + /// The table will have dollar sign prefix ($) in the column name + /// this is how we serialize and deserialize metadataprovider.EntityToDatabaseObject dict. + /// + [TestMethod] + public void TestDatabaseStoredProcedureSerializationDeserialization_WithDollarColumn() { + InitializeObjects(generateDollaredColumn: true); + + TestTypeNameChanges(_databaseStoredProcedure, "DatabaseStoredProcedure"); + + Dictionary dict = new(); + dict.Add("person", _databaseStoredProcedure); + + // Test to catch if there is change in number of properties/fields + // Note: On Addition of property make sure it is added in following object creation _databaseStoredProcedure and include in serialization + // and deserialization test. + int fields = typeof(DatabaseStoredProcedure).GetProperties(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance).Length; + Assert.AreEqual(fields, 6); + + string serializedDatabaseSP = JsonSerializer.Serialize(dict, _options); + // Assert that the serialized JSON contains the escaped dollar sign in column name + Assert.IsTrue(serializedDatabaseSP.Contains("DAB_ESCAPE$FirstName"), + "Serialized JSON should contain the dollar-prefixed column name in SourceDefinition's Columns."); + Dictionary deserializedDict = JsonSerializer.Deserialize>(serializedDatabaseSP, _options)!; + DatabaseStoredProcedure deserializedDatabaseSP = (DatabaseStoredProcedure)deserializedDict["person"]; + + Assert.AreEqual(deserializedDatabaseSP.SourceType, _databaseStoredProcedure.SourceType); + deserializedDatabaseSP.Equals(_databaseStoredProcedure); + VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseSP.SourceDefinition, _databaseStoredProcedure.SourceDefinition, "$FirstName", true); + VerifySourceDefinitionSerializationDeserialization(deserializedDatabaseSP.StoredProcedureDefinition, _databaseStoredProcedure.StoredProcedureDefinition, "$FirstName", true); + } + + private void InitializeObjects(bool generateDollaredColumn = false) + { + string columnName = generateDollaredColumn ? "$FirstName" : "FirstName"; _options = new() { // ObjectConverter behavior different in .NET8 most likely due to @@ -289,10 +396,11 @@ private void InitializeObjects() new DatabaseObjectConverter(), new TypeConverter() } + }; _columnDefinition = GetColumnDefinition(typeof(string), DbType.String, true, false, false, new string("John"), false); - _sourceDefinition = GetSourceDefinition(false, false, new List() { "FirstName" }, _columnDefinition); + _sourceDefinition = GetSourceDefinition(false, false, new List() { columnName }, _columnDefinition); _databaseTable = new DatabaseTable() { @@ -311,10 +419,10 @@ private void InitializeObjects() { IsInsertDMLTriggerEnabled = false, IsUpdateDMLTriggerEnabled = false, - PrimaryKey = new List() { "FirstName" }, + PrimaryKey = new List() { columnName }, }, }; - _databaseView.ViewDefinition.Columns.Add("FirstName", _columnDefinition); + _databaseView.ViewDefinition.Columns.Add(columnName, _columnDefinition); _parameterDefinition = new() { @@ -331,10 +439,10 @@ private void InitializeObjects() SourceType = EntitySourceType.StoredProcedure, StoredProcedureDefinition = new() { - PrimaryKey = new List() { "FirstName" }, + PrimaryKey = new List() { columnName }, } }; - _databaseStoredProcedure.StoredProcedureDefinition.Columns.Add("FirstName", _columnDefinition); + _databaseStoredProcedure.StoredProcedureDefinition.Columns.Add(columnName, _columnDefinition); _databaseStoredProcedure.StoredProcedureDefinition.Parameters.Add("Id", _parameterDefinition); } @@ -489,5 +597,68 @@ private RelationShipPair GetRelationShipPair() }; return new(_databaseTable, table2); } + + /// + /// Verifies that when merging multiple runtime configs, if the child config omits + /// the azure-key-vault section, the merged result still contains the AzureKeyVaultOptions (including retry-policy) + /// inherited from the parent config. + /// + [TestMethod] + public void TestMergedConfigInheritsAzureKeyVaultOptions() + { + // Arrange + + // Parent config with AKV section. + string parentConfig = @"{ + ""data-source"": { ""database-type"": ""mssql"", ""connection-string"": ""Server=.;Database=Parent;Trusted_Connection=True;"" }, + ""runtime"": { ""rest"": { ""enabled"": true }, ""graphql"": { ""enabled"": true } }, + ""entities"": {}, + ""azure-key-vault"": { + ""endpoint"": ""https://myvault.vault.azure.net/"", + ""retry-policy"": { + ""mode"": ""fixed"", + ""max-count"": 7, + ""delay-seconds"": 3, + ""max-delay-seconds"": 15, + ""network-timeout-seconds"": 20 + } + } +}"; + + // Child config overrides some properties but omits azure-key-vault entirely. + string childConfig = @"{ + ""data-source"": { ""database-type"": ""mssql"", ""connection-string"": ""Server=.;Database=Child;Trusted_Connection=True;"" }, + ""runtime"": { ""rest"": { ""enabled"": true }, ""graphql"": { ""enabled"": true } }, + ""entities"": {} +}"; + // Act + + // Merge child over parent. + string mergedJson = MergeJsonProvider.Merge(parentConfig, childConfig); + + // Parse with AKV replacement enabled so extraction path executes. + DeserializationVariableReplacementSettings replacementSettings = new( + azureKeyVaultOptions: null, + doReplaceEnvVar: false, + doReplaceAkvVar: true); + + // Assert + + Assert.IsTrue(RuntimeConfigLoader.TryParseConfig(mergedJson, out RuntimeConfig mergedConfig, replacementSettings: replacementSettings), "Merged runtime config failed to parse."); + Assert.IsNotNull(mergedConfig, "Merged runtime config is null."); + + // Validate AKV inheritance. + Assert.IsNotNull(mergedConfig.AzureKeyVault, "AzureKeyVaultOptions should be inherited from base config."); + Assert.AreEqual("https://myvault.vault.azure.net/", mergedConfig.AzureKeyVault!.Endpoint, "Inherited AKV endpoint mismatch."); + Assert.IsNotNull(mergedConfig.AzureKeyVault.RetryPolicy, "RetryPolicy should be inherited."); + Assert.AreEqual(AKVRetryPolicyMode.Fixed, mergedConfig.AzureKeyVault.RetryPolicy!.Mode, "Inherited retry-policy mode mismatch."); + Assert.AreEqual(7, mergedConfig.AzureKeyVault.RetryPolicy.MaxCount, "Inherited retry-policy max-count mismatch."); + Assert.AreEqual(3, mergedConfig.AzureKeyVault.RetryPolicy.DelaySeconds, "Inherited retry-policy delay-seconds mismatch."); + Assert.AreEqual(15, mergedConfig.AzureKeyVault.RetryPolicy.MaxDelaySeconds, "Inherited retry-policy max-delay-seconds mismatch."); + Assert.AreEqual(20, mergedConfig.AzureKeyVault.RetryPolicy.NetworkTimeoutSeconds, "Inherited retry-policy network-timeout-seconds mismatch."); + + // Ensure child override for connection-string applied while AKV remained from base. + Assert.AreEqual("Server=.;Database=Child;Trusted_Connection=True;", mergedConfig.DataSource.ConnectionString, "Child connection-string override not applied."); + } } } diff --git a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs index 908b7019c4..b3782950f9 100644 --- a/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs +++ b/src/Service.Tests/UnitTests/SqlQueryExecutorUnitTests.cs @@ -115,7 +115,8 @@ await provider.Initialize( provider.GetConfig().ToJson(), graphQLSchema: null, connectionString: connectionString, - accessToken: CONFIG_TOKEN); + accessToken: CONFIG_TOKEN, + replacementSettings: new()); msSqlQueryExecutor = new(provider, dbExceptionParser.Object, queryExecutorLogger.Object, httpContextAccessor.Object); } } @@ -623,6 +624,51 @@ public void ValidateStreamingLogicForStoredProcedures(int readDataLoops, bool ex } } + /// + /// Makes sure the stream logic handles cells with empty strings correctly. + /// + [DataTestMethod, TestCategory(TestCategory.MSSQL)] + public void ValidateStreamingLogicForEmptyCellsAsync() + { + TestHelper.SetupDatabaseEnvironment(TestCategory.MSSQL); + FileSystem fileSystem = new(); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfig runtimeConfig = new( + Schema: "UnitTestSchema", + DataSource: new DataSource(DatabaseType: DatabaseType.MSSQL, "", Options: null), + Runtime: new( + Rest: new(), + GraphQL: new(), + Mcp: new(), + Host: new(Cors: null, Authentication: null, MaxResponseSizeMB: 5) + ), + Entities: new(new Dictionary())); + + RuntimeConfigProvider runtimeConfigProvider = TestHelper.GenerateInMemoryRuntimeConfigProvider(runtimeConfig); + + Mock>> queryExecutorLogger = new(); + Mock httpContextAccessor = new(); + DbExceptionParser dbExceptionParser = new MsSqlDbExceptionParser(runtimeConfigProvider); + + // Instantiate the MsSqlQueryExecutor and Setup parameters for the query + MsSqlQueryExecutor msSqlQueryExecutor = new(runtimeConfigProvider, dbExceptionParser, queryExecutorLogger.Object, httpContextAccessor.Object); + + Mock dbDataReader = new(); + dbDataReader.Setup(d => d.HasRows).Returns(true); + + // Make sure GetChars returns 0 when buffer is null + dbDataReader.Setup(x => x.GetChars(It.IsAny(), It.IsAny(), null, It.IsAny(), It.IsAny())).Returns(0); + + // Make sure available size is set to > 0 + int availableSize = (int)runtimeConfig.MaxResponseSizeMB() * 1024 * 1024; + + // Stream char data should not return an exception + availableSize -= msSqlQueryExecutor.StreamCharData( + dbDataReader: dbDataReader.Object, availableSize: availableSize, resultJsonString: new(), ordinal: 0); + + Assert.AreEqual(availableSize, (int)runtimeConfig.MaxResponseSizeMB() * 1024 * 1024); + } + [TestCleanup] public void CleanupAfterEachTest() { diff --git a/src/Service.Tests/dab-config.CosmosDb_NoSql.json b/src/Service.Tests/dab-config.CosmosDb_NoSql.json index 5704dc19be..26f2e3fc3c 100644 --- a/src/Service.Tests/dab-config.CosmosDb_NoSql.json +++ b/src/Service.Tests/dab-config.CosmosDb_NoSql.json @@ -28,7 +28,7 @@ "allow-credentials": false }, "authentication": { - "provider": "StaticWebApps" + "provider": "AppService" }, "mode": "development" }, diff --git a/src/Service.Tests/dab-config.DwSql.json b/src/Service.Tests/dab-config.DwSql.json index 78f9e91480..bb0d6a5893 100644 --- a/src/Service.Tests/dab-config.DwSql.json +++ b/src/Service.Tests/dab-config.DwSql.json @@ -26,7 +26,7 @@ "allow-credentials": false }, "authentication": { - "provider": "StaticWebApps" + "provider": "AppService" }, "mode": "development" } diff --git a/src/Service.Tests/dab-config.MsSql.json b/src/Service.Tests/dab-config.MsSql.json index d5e903d4f3..e7f73702ba 100644 --- a/src/Service.Tests/dab-config.MsSql.json +++ b/src/Service.Tests/dab-config.MsSql.json @@ -36,7 +36,7 @@ "allow-credentials": false }, "authentication": { - "provider": "StaticWebApps" + "provider": "AppService" }, "mode": "development" } diff --git a/src/Service.Tests/dab-config.MySql.json b/src/Service.Tests/dab-config.MySql.json index a0b36b0388..c08069a5c8 100644 --- a/src/Service.Tests/dab-config.MySql.json +++ b/src/Service.Tests/dab-config.MySql.json @@ -24,7 +24,7 @@ "allow-credentials": false }, "authentication": { - "provider": "StaticWebApps" + "provider": "AppService" }, "mode": "development" } diff --git a/src/Service.Tests/dab-config.PostgreSql.json b/src/Service.Tests/dab-config.PostgreSql.json index e9a8ddf5ab..48f9700754 100644 --- a/src/Service.Tests/dab-config.PostgreSql.json +++ b/src/Service.Tests/dab-config.PostgreSql.json @@ -24,7 +24,7 @@ "allow-credentials": false }, "authentication": { - "provider": "StaticWebApps" + "provider": "AppService" }, "mode": "development" } diff --git a/src/Service/Azure.DataApiBuilder.Service.csproj b/src/Service/Azure.DataApiBuilder.Service.csproj index 6ea9c8dad2..5cf762ca57 100644 --- a/src/Service/Azure.DataApiBuilder.Service.csproj +++ b/src/Service/Azure.DataApiBuilder.Service.csproj @@ -1,7 +1,7 @@  - net8.0 + net8.0 Debug;Release;Docker $(BaseOutputPath)\engine win-x64;linux-x64;osx-x64 diff --git a/src/Service/Controllers/ConfigurationController.cs b/src/Service/Controllers/ConfigurationController.cs index be3f9bd727..4ad8fb40f4 100644 --- a/src/Service/Controllers/ConfigurationController.cs +++ b/src/Service/Controllers/ConfigurationController.cs @@ -91,8 +91,8 @@ public async Task Index([FromBody] ConfigurationPostParameters con configuration.Schema, configuration.ConnectionString, configuration.AccessToken, - replaceEnvVar: false, - replacementFailureMode: Config.Converters.EnvironmentVariableReplacementFailureMode.Ignore); + replacementSettings: new(azureKeyVaultOptions: null, doReplaceEnvVar: false, doReplaceAkvVar: false, envFailureMode: Config.Converters.EnvironmentVariableReplacementFailureMode.Ignore) + ); if (initResult && _configurationProvider.TryGetConfig(out _)) { diff --git a/src/Service/HealthCheck/HealthCheckHelper.cs b/src/Service/HealthCheck/HealthCheckHelper.cs index 452cb803a9..ab19756195 100644 --- a/src/Service/HealthCheck/HealthCheckHelper.cs +++ b/src/Service/HealthCheck/HealthCheckHelper.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Data.Common; using System.Diagnostics; using System.Linq; using System.Threading.Tasks; @@ -161,7 +162,7 @@ private async Task UpdateDataSourceHealthCheckResultsAsync(ComprehensiveHealthCh if (comprehensiveHealthCheckReport.Checks != null && runtimeConfig.DataSource.IsDatasourceHealthEnabled) { string query = Utilities.GetDatSourceQuery(runtimeConfig.DataSource.DatabaseType); - (int, string?) response = await ExecuteDatasourceQueryCheckAsync(query, runtimeConfig.DataSource.ConnectionString); + (int, string?) response = await ExecuteDatasourceQueryCheckAsync(query, runtimeConfig.DataSource.ConnectionString, Utilities.GetDbProviderFactory(runtimeConfig.DataSource.DatabaseType)); bool isResponseTimeWithinThreshold = response.Item1 >= 0 && response.Item1 < runtimeConfig.DataSource.DatasourceThresholdMs; // Add DataSource Health Check Results @@ -181,14 +182,14 @@ private async Task UpdateDataSourceHealthCheckResultsAsync(ComprehensiveHealthCh } // Executes the DB Query and keeps track of the response time and error message. - private async Task<(int, string?)> ExecuteDatasourceQueryCheckAsync(string query, string connectionString) + private async Task<(int, string?)> ExecuteDatasourceQueryCheckAsync(string query, string connectionString, DbProviderFactory dbProviderFactory) { string? errorMessage = null; if (!string.IsNullOrEmpty(query) && !string.IsNullOrEmpty(connectionString)) { Stopwatch stopwatch = new(); stopwatch.Start(); - errorMessage = await _httpUtility.ExecuteDbQueryAsync(query, connectionString); + errorMessage = await _httpUtility.ExecuteDbQueryAsync(query, connectionString, dbProviderFactory); stopwatch.Stop(); return string.IsNullOrEmpty(errorMessage) ? ((int)stopwatch.ElapsedMilliseconds, errorMessage) : (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage); } @@ -198,10 +199,11 @@ private async Task UpdateDataSourceHealthCheckResultsAsync(ComprehensiveHealthCh // Updates the Entity Health Check Results in the response. // Goes through the entities one by one and executes the rest and graphql checks (if enabled). + // Stored procedures are excluded from health checks because they require parameters and are not guaranteed to be deterministic. private async Task UpdateEntityHealthCheckResultsAsync(ComprehensiveHealthCheckReport report, RuntimeConfig runtimeConfig) { List> enabledEntities = runtimeConfig.Entities.Entities - .Where(e => e.Value.IsEntityHealthEnabled) + .Where(e => e.Value.IsEntityHealthEnabled && e.Value.Source.Type != EntitySourceType.StoredProcedure) .ToList(); if (enabledEntities.Count == 0) diff --git a/src/Service/HealthCheck/HttpUtilities.cs b/src/Service/HealthCheck/HttpUtilities.cs index a3195d0d8a..9da596ae30 100644 --- a/src/Service/HealthCheck/HttpUtilities.cs +++ b/src/Service/HealthCheck/HttpUtilities.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Data.Common; using System.Linq; using System.Net.Http; using System.Text; @@ -14,7 +15,6 @@ using Azure.DataApiBuilder.Core.Services; using Azure.DataApiBuilder.Core.Services.MetadataProviders; using Azure.DataApiBuilder.Service.GraphQLBuilder; -using Microsoft.Data.SqlClient; using Microsoft.Extensions.Logging; namespace Azure.DataApiBuilder.Service.HealthCheck @@ -49,19 +49,32 @@ public HttpUtilities( } // Executes the DB query by establishing a connection to the DB. - public async Task ExecuteDbQueryAsync(string query, string connectionString) + public async Task ExecuteDbQueryAsync(string query, string connectionString, DbProviderFactory providerFactory) { string? errorMessage = null; // Execute the query on DB and return the response time. - using (SqlConnection connection = new(connectionString)) + DbConnection? connection = providerFactory.CreateConnection(); + if (connection == null) + { + errorMessage = "Failed to create database connection."; + _logger.LogError(errorMessage); + return errorMessage; + } + + using (connection) { try { - SqlCommand command = new(query, connection); - connection.Open(); - SqlDataReader reader = await command.ExecuteReaderAsync(); - _logger.LogTrace("The health check query for datasource executed successfully."); - reader.Close(); + connection.ConnectionString = connectionString; + using (DbCommand command = connection.CreateCommand()) + { + command.CommandText = query; + await connection.OpenAsync(); + using (DbDataReader reader = await command.ExecuteReaderAsync()) + { + _logger.LogTrace("The health check query for datasource executed successfully."); + } + } } catch (Exception ex) { @@ -145,7 +158,7 @@ public HttpUtilities( List columnNames = dbObject.SourceDefinition.Columns.Keys.ToList(); // In case of GraphQL API, use the plural value specified in [entity.graphql.type.plural]. - // Further, we need to camel case this plural value to match the GraphQL object name. + // Further, we need to camel case this plural value to match the GraphQL object name. string graphqlObjectName = GraphQLNaming.GenerateListQueryName(entityName, entity); // In case any primitive column names are present, execute the query diff --git a/src/Service/HealthCheck/Utilities.cs b/src/Service/HealthCheck/Utilities.cs index 4b61346736..290410291e 100644 --- a/src/Service/HealthCheck/Utilities.cs +++ b/src/Service/HealthCheck/Utilities.cs @@ -1,9 +1,13 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System; using System.Collections.Generic; +using System.Data.Common; using System.Text.Json; using Azure.DataApiBuilder.Config.ObjectModel; +using Microsoft.Data.SqlClient; +using Npgsql; namespace Azure.DataApiBuilder.Service.HealthCheck { @@ -32,6 +36,20 @@ public static string GetDatSourceQuery(DatabaseType dbType) } } + public static DbProviderFactory GetDbProviderFactory(DatabaseType dbType) + { + switch (dbType) + { + case DatabaseType.PostgreSQL: + return NpgsqlFactory.Instance; + case DatabaseType.MSSQL: + case DatabaseType.DWSQL: + return SqlClientFactory.Instance; + default: + throw new NotSupportedException($"Database type '{dbType}' is not supported."); + } + } + public static string CreateHttpGraphQLQuery(string entityName, List columnNames, int first) { var payload = new diff --git a/src/Service/Program.cs b/src/Service/Program.cs index 1059fd52ff..e23fb98cd9 100644 --- a/src/Service/Program.cs +++ b/src/Service/Program.cs @@ -5,16 +5,19 @@ using System.CommandLine; using System.CommandLine.Parsing; using System.Runtime.InteropServices; +using System.Text; using System.Text.RegularExpressions; using System.Threading.Tasks; using Azure.DataApiBuilder.Config; using Azure.DataApiBuilder.Service.Exceptions; using Azure.DataApiBuilder.Service.Telemetry; +using Azure.DataApiBuilder.Service.Utilities; using Microsoft.ApplicationInsights; using Microsoft.AspNetCore; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.ApplicationInsights; @@ -33,6 +36,14 @@ public class Program public static void Main(string[] args) { + bool runMcpStdio = McpStdioHelper.ShouldRunMcpStdio(args, out string? mcpRole); + + if (runMcpStdio) + { + Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + Console.InputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false); + } + if (!ValidateAspNetCoreUrls()) { Console.Error.WriteLine("Invalid ASPNETCORE_URLS format. e.g.: ASPNETCORE_URLS=\"http://localhost:5000;https://localhost:5001\""); @@ -40,20 +51,26 @@ public static void Main(string[] args) return; } - if (!StartEngine(args)) + if (!StartEngine(args, runMcpStdio, mcpRole)) { Environment.ExitCode = -1; } } - public static bool StartEngine(string[] args) + public static bool StartEngine(string[] args, bool runMcpStdio, string? mcpRole) { - // Unable to use ILogger because this code is invoked before LoggerFactory - // is instantiated. Console.WriteLine("Starting the runtime engine..."); try { - CreateHostBuilder(args).Build().Run(); + IHost host = CreateHostBuilder(args, runMcpStdio, mcpRole).Build(); + + if (runMcpStdio) + { + return McpStdioHelper.RunMcpStdioHost(host); + } + + // Normal web mode + host.Run(); return true; } // Catch exception raised by explicit call to IHostApplicationLifetime.StopApplication() @@ -72,17 +89,28 @@ public static bool StartEngine(string[] args) } } - public static IHostBuilder CreateHostBuilder(string[] args) + // Compatibility overload used by external callers that do not pass the runMcpStdio flag. + public static bool StartEngine(string[] args) + { + bool runMcpStdio = McpStdioHelper.ShouldRunMcpStdio(args, out string? mcpRole); + return StartEngine(args, runMcpStdio, mcpRole: mcpRole); + } + + public static IHostBuilder CreateHostBuilder(string[] args, bool runMcpStdio, string? mcpRole) { return Host.CreateDefaultBuilder(args) .ConfigureAppConfiguration(builder => { AddConfigurationProviders(builder, args); + if (runMcpStdio) + { + McpStdioHelper.ConfigureMcpStdio(builder, mcpRole); + } }) .ConfigureWebHostDefaults(webBuilder => { Startup.MinimumLogLevel = GetLogLevelFromCommandLineArgs(args, out Startup.IsLogLevelOverriddenByCli); - ILoggerFactory loggerFactory = GetLoggerFactoryForLogLevel(Startup.MinimumLogLevel); + ILoggerFactory loggerFactory = GetLoggerFactoryForLogLevel(Startup.MinimumLogLevel, stdio: runMcpStdio); ILogger startupLogger = loggerFactory.CreateLogger(); DisableHttpsRedirectionIfNeeded(args); webBuilder.UseStartup(builder => new Startup(builder.Configuration, startupLogger)); @@ -140,7 +168,14 @@ private static ParseResult GetParseResult(Command cmd, string[] args) /// Telemetry client /// Hot-reloadable log level /// Core Serilog logging pipeline - public static ILoggerFactory GetLoggerFactoryForLogLevel(LogLevel logLevel, TelemetryClient? appTelemetryClient = null, LogLevelInitializer? logLevelInitializer = null, Logger? serilogLogger = null) + /// Whether the logger is for stdio mode + /// ILoggerFactory + public static ILoggerFactory GetLoggerFactoryForLogLevel( + LogLevel logLevel, + TelemetryClient? appTelemetryClient = null, + LogLevelInitializer? logLevelInitializer = null, + Logger? serilogLogger = null, + bool stdio = false) { return LoggerFactory .Create(builder => @@ -229,7 +264,19 @@ public static ILoggerFactory GetLoggerFactoryForLogLevel(LogLevel logLevel, Tele } } - builder.AddConsole(); + // In stdio mode, route console logs to STDERR to keep STDOUT clean for MCP JSON + if (stdio) + { + builder.ClearProviders(); + builder.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Trace; + }); + } + else + { + builder.AddConsole(); + } }); } diff --git a/src/Service/Properties/launchSettings.json b/src/Service/Properties/launchSettings.json index acd7ab6646..becf6cd238 100644 --- a/src/Service/Properties/launchSettings.json +++ b/src/Service/Properties/launchSettings.json @@ -1,22 +1,6 @@ { - "iisSettings": { - "windowsAuthentication": false, - "anonymousAuthentication": true, - "iisExpress": { - "applicationUrl": "http://localhost:35704", - "sslPort": 44353 - } - }, "$schema": "http://json.schemastore.org/launchsettings.json", "profiles": { - "IIS Express": { - "commandName": "IISExpress", - "launchBrowser": true, - "launchUrl": "graphql", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "Azure.DataApiBuilder.Service": { "commandName": "Project", "launchBrowser": true, @@ -24,7 +8,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "MsSql" }, - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "applicationUrl": "https://localhost:5001;http://localhost:5000" }, "Development": { @@ -34,7 +18,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "applicationUrl": "https://localhost:5001;http://localhost:5000" }, "PostgreSql": { @@ -44,7 +28,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "PostgreSql" }, - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "applicationUrl": "https://localhost:5001;http://localhost:5000" }, "MsSql": { @@ -54,7 +38,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "MsSql" }, - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "applicationUrl": "https://localhost:5001;http://localhost:5000" }, "MySql": { @@ -64,7 +48,7 @@ "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "MySql" }, - "dotnetRunMessages": "true", + "dotnetRunMessages": true, "applicationUrl": "https://localhost:5001;http://localhost:5000" }, "CosmosDb_NoSql": { diff --git a/src/Service/Startup.cs b/src/Service/Startup.cs index 48a39d31d0..333bf57234 100644 --- a/src/Service/Startup.cs +++ b/src/Service/Startup.cs @@ -348,7 +348,16 @@ public void ConfigureServices(IServiceCollection services) return handler; }); - if (runtimeConfig is not null && runtimeConfig.Runtime?.Host?.Mode is HostMode.Development) + bool isMcpStdio = Configuration.GetValue("MCP:StdioMode"); + + if (isMcpStdio) + { + // Explicitly force Simulator when running in MCP stdio mode. + services.AddAuthentication( + defaultScheme: SimulatorAuthenticationDefaults.AUTHENTICATIONSCHEME) + .AddSimulatorAuthentication(); + } + else if (runtimeConfig is not null && runtimeConfig.Runtime?.Host?.Mode is HostMode.Development) { // Development mode implies support for "Hot Reload". The V2 authentication function // wires up all DAB supported authentication providers (schemes) so that at request time, @@ -456,6 +465,8 @@ public void ConfigureServices(IServiceCollection services) services.AddDabMcpServer(configProvider); + services.AddSingleton(); + services.AddControllers(); } @@ -785,21 +796,18 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP if (easyAuthType == EasyAuthType.AppService && !appServiceEnvironmentDetected) { - if (isProductionMode) - { - throw new DataApiBuilderException( - message: AppServiceAuthenticationInfo.APPSERVICE_PROD_MISSING_ENV_CONFIG, - statusCode: System.Net.HttpStatusCode.ServiceUnavailable, - subStatusCode: DataApiBuilderException.SubStatusCodes.ErrorInInitialization); - } - else - { - _logger.LogWarning(AppServiceAuthenticationInfo.APPSERVICE_DEV_MISSING_ENV_CONFIG); - } + _logger.LogWarning(AppServiceAuthenticationInfo.APPSERVICE_DEV_MISSING_ENV_CONFIG); } - services.AddAuthentication(EasyAuthAuthenticationDefaults.AUTHENTICATIONSCHEME) - .AddEasyAuthAuthentication(easyAuthAuthenticationProvider: easyAuthType); + string defaultScheme = easyAuthType == EasyAuthType.AppService + ? EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME + : EasyAuthAuthenticationDefaults.SWAAUTHSCHEME; + + services.AddAuthentication(defaultScheme) + .AddEnvDetectedEasyAuth(); + + _logger.LogInformation("Registered EasyAuth scheme: {Scheme}", defaultScheme); + } else if (mode == HostMode.Development && authOptions.IsAuthenticationSimulatorEnabled()) { @@ -820,7 +828,7 @@ private void ConfigureAuthentication(IServiceCollection services, RuntimeConfigP { // Sets EasyAuth as the default authentication scheme when runtime configuration // is not present. - SetStaticWebAppsAuthentication(services); + SetAppServiceAuthentication(services); } } @@ -1012,13 +1020,13 @@ private void ConfigureFileSink(IApplicationBuilder app, RuntimeConfig runtimeCon } /// - /// Sets Static Web Apps EasyAuth as the authentication scheme for the engine. + /// Sets App Service EasyAuth as the authentication scheme for the engine. /// /// The service collection where authentication services are added. - private static void SetStaticWebAppsAuthentication(IServiceCollection services) + private static void SetAppServiceAuthentication(IServiceCollection services) { - services.AddAuthentication(EasyAuthAuthenticationDefaults.AUTHENTICATIONSCHEME) - .AddEasyAuthAuthentication(EasyAuthType.StaticWebApps); + services.AddAuthentication(EasyAuthAuthenticationDefaults.APPSERVICEAUTHSCHEME) + .AddEnvDetectedEasyAuth(); } /// diff --git a/src/Service/Utilities/McpStdioHelper.cs b/src/Service/Utilities/McpStdioHelper.cs new file mode 100644 index 0000000000..043e9dd85d --- /dev/null +++ b/src/Service/Utilities/McpStdioHelper.cs @@ -0,0 +1,102 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; + +namespace Azure.DataApiBuilder.Service.Utilities +{ + /// + /// Helper methods for configuring and running MCP in stdio mode. + /// + internal static class McpStdioHelper + { + /// + /// Determines if MCP stdio mode should be run based on command line arguments. + /// + /// The command line arguments. + /// The role for MCP stdio mode. When this method returns true, the role defaults to anonymous. + /// True when MCP stdio mode should be enabled; otherwise false. + public static bool ShouldRunMcpStdio(string[] args, [NotNullWhen(true)] out string? mcpRole) + { + mcpRole = null; + + bool runMcpStdio = Array.Exists( + args, + a => string.Equals(a, "--mcp-stdio", StringComparison.OrdinalIgnoreCase)); + + if (!runMcpStdio) + { + return false; + } + + string? roleArg = Array.Find( + args, + a => a != null && a.StartsWith("role:", StringComparison.OrdinalIgnoreCase)); + + if (!string.IsNullOrEmpty(roleArg)) + { + string roleValue = roleArg[(roleArg.IndexOf(':') + 1)..]; + if (!string.IsNullOrWhiteSpace(roleValue)) + { + mcpRole = roleValue; + } + } + + // Ensure that when MCP stdio is enabled, mcpRole is always non-null. + // This matches the NotNullWhen(true) contract and avoids nullable warnings + // for callers while still allowing an implicit default when no role is provided. + mcpRole ??= "anonymous"; + + return true; + } + + /// + /// Configures the IConfigurationBuilder for MCP stdio mode. + /// + /// + /// + public static void ConfigureMcpStdio(IConfigurationBuilder builder, string? mcpRole) + { + builder.AddInMemoryCollection(new Dictionary + { + ["MCP:StdioMode"] = "true", + ["MCP:Role"] = mcpRole ?? "anonymous", + ["Runtime:Host:Authentication:Provider"] = "Simulator" + }); + } + + /// + /// Runs the MCP stdio host. + /// + /// The host to run. + public static bool RunMcpStdioHost(IHost host) + { + host.Start(); + + Mcp.Core.McpToolRegistry registry = + host.Services.GetRequiredService(); + IEnumerable tools = + host.Services.GetServices(); + + foreach (Mcp.Model.IMcpTool tool in tools) + { + registry.RegisterTool(tool); + } + + IHostApplicationLifetime lifetime = + host.Services.GetRequiredService(); + Mcp.Core.IMcpStdioServer stdio = + host.Services.GetRequiredService(); + + stdio.RunAsync(lifetime.ApplicationStopping).GetAwaiter().GetResult(); + host.StopAsync().GetAwaiter().GetResult(); + + return true; + } + } +} diff --git a/src/Service/dab-config.json b/src/Service/dab-config.json new file mode 100644 index 0000000000..859267100e --- /dev/null +++ b/src/Service/dab-config.json @@ -0,0 +1,231 @@ +{ + "data-source": { + "database-type": "@env('db-type')", + "connection-string": "@env('ConnectionStrings__Database')", + "options": { + "set-session-context": false + } + }, + "runtime": { + "rest": { + "enabled": true, + "path": "/api", + "request-body-strict": true + }, + "graphql": { + "enabled": true, + "path": "/graphql", + "allow-introspection": true + }, + "host": { + "authentication": { + "provider": "StaticWebApps" + }, + "cors": { + "origins": [], + "allow-credentials": false + }, + "mode": "development" + }, + "telemetry": { + "open-telemetry": { + "enabled": true, + "endpoint": "@env('OTEL_EXPORTER_OTLP_ENDPOINT')", + "headers": "@env('OTEL_EXPORTER_OTLP_HEADERS')", + "exporter-protocol": "@env('OTEL_EXPORTER_OTLP_PROTOCOL')", + "service-name": "@env('OTEL_SERVICE_NAME')" + } + }, + "cache": { + "enabled": true, + "ttl-seconds": 60 + } + }, + "entities": { + "Actor": { + "source": { + "object": "Actor", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "Actor", + "plural": "Actors" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + "*" + ] + } + ], + "relationships": { + "character": { + "cardinality": "many", + "target.entity": "Character", + "source.fields": [ + "Id" + ], + "target.fields": [ + "ActorId" + ], + "linking.source.fields": [], + "linking.target.fields": [] + } + } + }, + "Character": { + "source": { + "object": "Character", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "Character", + "plural": "Characters" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "actor": { + "cardinality": "one", + "target.entity": "Actor", + "source.fields": [ + "ActorId" + ], + "target.fields": [ + "Id" + ], + "linking.source.fields": [], + "linking.target.fields": [] + }, + "series": { + "cardinality": "many", + "target.entity": "Series", + "source.fields": [ + "Id" + ], + "target.fields": [ + "Id" + ], + "linking.object": "series_character", + "linking.source.fields": [ + "CharacterId" + ], + "linking.target.fields": [ + "SeriesId" + ] + } + } + }, + "Series": { + "source": { + "object": "Series", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "Series", + "plural": "Series" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "character": { + "cardinality": "many", + "target.entity": "Character", + "source.fields": [ + "Id" + ], + "target.fields": [ + "Id" + ], + "linking.object": "series_character", + "linking.source.fields": [ + "SeriesId" + ], + "linking.target.fields": [ + "CharacterId" + ] + } + } + }, + "Species": { + "source": { + "object": "Species", + "type": "table" + }, + "graphql": { + "enabled": true, + "type": { + "singular": "Species", + "plural": "Species" + } + }, + "rest": { + "enabled": true + }, + "permissions": [ + { + "role": "anonymous", + "actions": [ + { + "action": "*" + } + ] + } + ], + "relationships": { + "character": { + "cardinality": "many", + "target.entity": "Character", + "source.fields": [ + "Id" + ], + "target.fields": [ + "Id" + ], + "linking.object": "character_species", + "linking.source.fields": [ + "SpeciesId" + ], + "linking.target.fields": [ + "CharacterId" + ] + } + } + } + } + }