diff --git a/servers/Fabric.Mcp.Server/README.md b/servers/Fabric.Mcp.Server/README.md index a414ea4274..c51d6cc578 100644 --- a/servers/Fabric.Mcp.Server/README.md +++ b/servers/Fabric.Mcp.Server/README.md @@ -30,6 +30,9 @@ A local-first Model Context Protocol (MCP) server that provides AI agents with c - [Available Tools](#available-tools) - [API Documentation & Best Practices](#api-documentation--best-practices) - [OneLake Data Operations](#onelake-data-operations) + - [OneLake Security — Data Access Roles](#onelake-security--data-access-roles) + - [OneLake Shortcuts](#onelake-shortcuts) + - [OneLake Settings](#onelake-settings) - [Core Fabric Operations](#core-fabric-operations) - [Data Factory Operations](#data-factory-operations) - [Support and Reference](#support-and-reference) @@ -252,6 +255,40 @@ The Fabric MCP Server exposes tools organized into three categories: | `onelake_list_tables` | Lists tables published within a namespace. | | `onelake_get_table` | Retrieves the definition for a specific table. | +### OneLake Security — Data Access Roles + +| Tool Name | Description | +|-----------|-------------| +| `onelake_list_data_access_roles` | Lists all data access roles defined on a single item. | +| `onelake_get_data_access_role` | Gets the full definition of a single data access role (members, permissions, decision rules). | +| `onelake_create_or_update_data_access_role` | Upserts a single data access role on a single item. | +| `onelake_delete_data_access_role` | Deletes a single data access role from an item. | + +### OneLake Shortcuts + +| Tool Name | Description | +|-----------|-------------| +| `onelake_list_shortcuts` | Lists shortcuts defined within an item. Hides DW-managed shortcuts by default (`--include-managed` to show). | +| `onelake_get_shortcut` | Gets the properties of a single shortcut. | +| `onelake_create_shortcut_onelake` | Creates a shortcut pointing to another OneLake location. | +| `onelake_create_shortcut_adls_gen2` | Creates a shortcut pointing to Azure Data Lake Storage Gen2. | +| `onelake_create_shortcut_amazon_s3` | Creates a shortcut pointing to Amazon S3. | +| `onelake_create_shortcut_azure_blob` | Creates a shortcut pointing to Azure Blob Storage. | +| `onelake_create_shortcut_gcs` | Creates a shortcut pointing to Google Cloud Storage. | +| `onelake_create_shortcut_s3_compatible` | Creates a shortcut pointing to S3-compatible storage. | +| `onelake_create_shortcut_dataverse` | Creates a shortcut pointing to a Dataverse environment. | +| `onelake_create_shortcut_onedrive_sharepoint` | Creates a shortcut pointing to OneDrive/SharePoint Online. | +| `onelake_delete_shortcut` | Deletes a single shortcut from an item (preserves destination data). | +| `onelake_reset_shortcut_cache` | Drops cached shortcut reads, forcing re-resolution from destination. | + +### OneLake Settings + +| Tool Name | Description | +|-----------|-------------| +| `onelake_get_settings` | Gets OneLake settings for a workspace (diagnostics + immutability policy). | +| `onelake_modify_diagnostics` | Modifies diagnostic logging configuration (status, destination lakehouse) at workspace scope. | +| `onelake_modify_immutability_policy` | Modifies the workspace-level OneLake immutability policy (scope, retention days). | + ### Core Fabric Operations | Tool Name | Description | diff --git a/servers/Fabric.Mcp.Server/changelog-entries/1778530260238.yaml b/servers/Fabric.Mcp.Server/changelog-entries/1778530260238.yaml new file mode 100644 index 0000000000..81dcf6a9e1 --- /dev/null +++ b/servers/Fabric.Mcp.Server/changelog-entries/1778530260238.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Features Added" + description: "Added 12 new OneLake tools: Data Access Security (list, get, create-or-update, delete roles), Shortcuts (list, get, create-or-update, delete, reset-cache), and Settings (get, modify-diagnostics, modify-immutability-policy)" diff --git a/servers/Fabric.Mcp.Server/changelog-entries/1778530268679.yaml b/servers/Fabric.Mcp.Server/changelog-entries/1778530268679.yaml new file mode 100644 index 0000000000..89f4e16b88 --- /dev/null +++ b/servers/Fabric.Mcp.Server/changelog-entries/1778530268679.yaml @@ -0,0 +1,3 @@ +changes: + - section: "Bugs Fixed" + description: "Refactored OneLake DFS ListPath methods to follow ADLS Gen2 Path List API specification (directory as query parameter)" diff --git a/servers/Fabric.Mcp.Server/changelog-entries/1780766915548.yaml b/servers/Fabric.Mcp.Server/changelog-entries/1780766915548.yaml new file mode 100644 index 0000000000..d2d3e1d80a --- /dev/null +++ b/servers/Fabric.Mcp.Server/changelog-entries/1780766915548.yaml @@ -0,0 +1,7 @@ +changes: + - section: "Features Added" + description: "Added 9 per-target shortcut creation tools (OneLake, ADLS Gen2, Amazon S3, Azure Blob, GCS, S3-compatible, Dataverse, OneDrive/SharePoint, External Data Share) with flat typed options for better LLM ergonomics" + - section: "Features Added" + description: "Flattened JSON-string options into discrete typed parameters for diagnostics, immutability policy, and data access role commands" + - section: "Bugs Fixed" + description: "Fixed OneLake diagnostics and immutability settings models to match the Fabric REST API contract" diff --git a/tools/Fabric.Mcp.Tools.OneLake/README.md b/tools/Fabric.Mcp.Tools.OneLake/README.md index fcdca41afe..7fa8e2293e 100644 --- a/tools/Fabric.Mcp.Tools.OneLake/README.md +++ b/tools/Fabric.Mcp.Tools.OneLake/README.md @@ -7,18 +7,24 @@ Microsoft Fabric OneLake MCP (Model Context Protocol) Tools - Manage and interac OneLake is Microsoft Fabric's built-in data lake that provides unified storage for all analytics workloads. This MCP tool provides operations for working with OneLake resources within your Fabric tenant, enabling AI agents to: - Manage OneLake folders and files -- Configure data access and permissions -- Monitor OneLake storage usage and performance -- Integrate with other Fabric workloads through OneLake +- Browse items, tables and namespaces +- Configure OneLake data access security (role-based) +- Create, list and manage shortcuts (per-target typed tools + cache reset) +- Read and modify workspace-level OneLake settings (diagnostics, immutability) **Features:** -- 19 comprehensive OneLake commands with full MCP integration +- 40 comprehensive OneLake commands with full MCP integration - Complete coverage for OneLake table APIs: configuration, namespace discovery, and table metadata -- Friendly-name support for workspaces and items across data-plane commands ( `item-create` currently requires GUID IDs ) +- Data access security management: list, get, create/update, and delete roles +- Shortcut management: list, get, create (8 per-target typed tools), delete, and cache reset +- Workspace-level settings: diagnostics and immutability policy configuration +- Friendly-name support for workspaces and items across all commands - Robust error handling and authentication -- Production-ready with 100% test coverage (132 tests) +- Production-ready with 100% test coverage (309 tests) - Clean, focused API design optimized for AI agent interactions +> **Note:** Item creation has moved to the [Fabric.Mcp.Tools.Core](https://github.com/microsoft/mcp/tree/main/tools/Fabric.Mcp.Tools.Core) toolset as `core_create_item`. See [Core tools](https://github.com/microsoft/mcp/tree/main/tools/Fabric.Mcp.Tools.Core) for item creation operations. + ## Prerequisites - Microsoft Fabric workspace with OneLake enabled @@ -41,12 +47,14 @@ The OneLake MCP tools are configured to use the Microsoft Fabric production envi ``` OneLake Data Plane: https://api.onelake.fabric.microsoft.com -OneLake DFS API: https://onelake.dfs.fabric.microsoft.com -OneLake Blob API: https://onelake.blob.fabric.microsoft.com -OneLake Table API: https://onelake.table.fabric.microsoft.com -Fabric API: https://api.fabric.microsoft.com/v1 +OneLake DFS API: https://onelake.dfs.fabric.microsoft.com +OneLake Blob API: https://onelake.blob.fabric.microsoft.com +OneLake Table API: https://onelake.table.fabric.microsoft.com +Fabric Core API: https://api.fabric.microsoft.com/v1 ``` +> **Endpoint routing:** Security, shortcut and settings tools call the **Fabric Core API** (`api.fabric.microsoft.com`). All other tools target the OneLake data plane / DFS / Blob / Table endpoints. + ### Getting Started Simply use the commands without any environment configuration: @@ -90,10 +98,10 @@ You can verify which environment you're targeting by checking the endpoints in t ### Workspace and Item Identifiers -All commands except `item create` accept either GUID identifiers or friendly names via the `--workspace` and `--item` options. The existing `--workspace-id` and `--item-id` switches remain available for scripts that already depend on them. Friendly-name inputs are sent directly to the OneLake APIs without local GUID resolution; when using names, specify the item as `.` (for example, `SalesLakehouse.lakehouse`). `item create` currently requires the GUID-based `--workspace-id` option. Table-based commands additionally accept schema identifiers through `--namespace` or its alias `--schema`. +The Fabric Core REST APIs used by the Security, Shortcuts, and Settings commands require GUID identifiers. Use `--workspace-id` for workspace scope and `--item-id` for item scope. Table-based commands additionally accept schema identifiers through `--namespace` or its alias `--schema`. ```bash -dotnet run -- onelake file list --workspace "Analytics Workspace" --item "SalesLakehouse.lakehouse" --path "Files" +dotnet run -- onelake shortcut list --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" ``` ## Available Commands @@ -172,18 +180,7 @@ dotnet run -- onelake item list-data --workspace-id "47242da5-ff3b-46fb-a94f-977 #### Create Item -Creates a new item (Lakehouse, Notebook, etc.) in a Microsoft Fabric workspace using the Fabric API. - -```bash -dotnet run -- onelake item create --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --display-name "NewLakehouse" --type "Lakehouse" -``` - -> **Note:** `item create` currently requires the GUID-based `--workspace-id` switch; friendly workspace names are not supported for this command yet. - -**Parameters:** -- `--workspace-id`: The ID of the Microsoft Fabric workspace -- `--display-name`: Display name for the new item -- `--type`: Type of item to create (e.g., Lakehouse, Notebook) +> **Moved:** The `item create` command has been moved to [Fabric.Mcp.Tools.Core](https://github.com/microsoft/mcp/tree/main/tools/Fabric.Mcp.Tools.Core) as `core_create_item`. Use the Core toolset for creating new items (Lakehouse, Notebook, etc.) in a Microsoft Fabric workspace. ### File Operations @@ -629,6 +626,221 @@ dotnet run -- onelake table get --workspace-id "47242da5-ff3b-46fb-a94f-977909b7 - `--namespace`/`--schema`: Namespace (schema) name - `--table`: Table name to retrieve +### Security — Data Access Roles + +These tools manage role-based data access policies on OneLake items (Lakehouse / Warehouse). All security tools call the **Fabric Core API** and require the caller to be a workspace Admin or Member. + +#### List Data Access Roles + +Lists all data access roles defined on a single item. Supports pagination via `--continuation-token`. + +```bash +dotnet run -- onelake security list --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) +- `--item-id`: Item ID (GUID) +- `--continuation-token`: (Optional) Token for retrieving the next page of results + +#### Get Data Access Role + +Gets the full definition of a single data access role — members, permissions, decision rules. + +```bash +dotnet run -- onelake security get --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" --role-name "DataAnalysts" +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) +- `--item-id`: Item ID (GUID) +- `--role-name`: Name of the data access role to retrieve + +#### Create or Update Data Access Role + +Upserts a single data access role on a single item. Scoped to one role per call — does not affect other roles. The role name is derived from the `name` field in the JSON definition. + +```bash +dotnet run -- onelake security create-or-update --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" --role-definition '{"name":"DataAnalysts","members":{"fabricItemMembers":[{"itemAccessType":"ReadAll"}]},"decisionRules":[{"effect":"Permit","permission":[{"attributeName":"Path","attributeValueIncludedIn":["Tables/*"]}]}]}' +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) +- `--item-id`: Item ID (GUID) +- `--role-definition`: JSON definition of the role (must include `name`, `members`, and `decisionRules`) + +#### Delete Data Access Role + +Deletes a single data access role from a single item. Destructive — principals that gained access only via this role lose it. + +```bash +dotnet run -- onelake security delete --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" --role-name "TempRole" +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) +- `--item-id`: Item ID (GUID) +- `--role-name`: Name of the data access role to delete + +### Shortcut Operations + +Shortcuts are references to data stored in external or internal locations (ADLS Gen2, S3, GCS, OneLake, Dataverse, etc.). These tools call the **Fabric Core API**. + +#### List Shortcuts + +Lists shortcuts defined within an item, recursing through subfolders. Supports pagination via `--continuation-token`. By default, DW-managed shortcuts (which can number in the hundreds of thousands per Warehouse) are hidden — use `--include-managed` to show them. + +```bash +dotnet run -- onelake shortcut list --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) +- `--item-id`: Item ID (GUID) +- `--parent-path`: (Optional) Parent path to scope the listing +- `--continuation-token`: (Optional) Token for retrieving the next page of results +- `--include-managed`: (Optional) Include DW-managed shortcuts in results (default: false) + +#### Get Shortcut + +Gets the properties of a single shortcut (name, path, target, configuration). + +```bash +dotnet run -- onelake shortcut get --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" --shortcut-name "ExternalData" --shortcut-path "Tables/ExternalData" +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) +- `--item-id`: Item ID (GUID) +- `--shortcut-name`: Name of the shortcut +- `--shortcut-path`: Path of the shortcut within the item + +#### Create Shortcut (Per-Target — Recommended for AI agents) + +Eight per-target tools provide flat, typed options instead of requiring a JSON blob. Use the tool matching your target type: + +| Tool | Target Type | +|------|-------------| +| `create_shortcut_onelake` | Another OneLake location | +| `create_shortcut_adls_gen2` | Azure Data Lake Storage Gen2 | +| `create_shortcut_amazon_s3` | Amazon S3 | +| `create_shortcut_azure_blob` | Azure Blob Storage | +| `create_shortcut_gcs` | Google Cloud Storage | +| `create_shortcut_s3_compatible` | S3-compatible storage | +| `create_shortcut_dataverse` | Dataverse environment | +| `create_shortcut_onedrive_sharepoint` | OneDrive / SharePoint Online | + +**Common parameters (all per-target tools):** +- `--workspace-id`: Workspace ID (GUID) +- `--item-id`: Item ID (GUID) +- `--shortcut-path`: Path within the item where the shortcut lives +- `--shortcut-name`: Display name for the shortcut +- `--shortcut-conflict-policy`: (Optional) Abort, CreateOrOverwrite, OverwriteOnly, GenerateUniqueName + +**OneLake target additional parameters:** +- `--target-workspace-id`: Target workspace ID +- `--target-item-id`: Target item ID +- `--target-path`: Path within the target item (required) +- `--target-connection-id`: (Optional) Connection ID + +**ADLS Gen2 / Amazon S3 / Azure Blob / GCS target parameters:** +- `--target-location`: Storage URL +- `--target-subpath`: Subpath within the location (required for ADLS Gen2) +- `--target-connection-id`: Connection ID for authentication + +**S3-compatible target additional parameters:** +- `--target-bucket`: Bucket name (in addition to location, subpath, connection-id) + +**Dataverse target parameters:** +- `--target-environment-domain`: Dataverse environment URI +- `--target-connection-id`: Connection ID +- `--target-deltalake-folder`: Delta Lake folder path +- `--target-table-name`: (Optional) Table name + +**OneDrive/SharePoint target parameters:** +- `--target-location`: Storage URL +- `--target-subpath`: (Optional) Subpath +- `--target-connection-id`: Connection ID +- `--target-update-fabric-item-sensitivity`: (Optional) Update sensitivity label from source + +**Example — Create an ADLS Gen2 shortcut:** +```bash +dotnet run -- onelake shortcut create-adls-gen2 \ + --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" \ + --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" \ + --shortcut-path "Tables" --shortcut-name "ExternalData" \ + --target-location "https://myaccount.dfs.core.windows.net/container" \ + --target-subpath "/data/tables" \ + --target-connection-id "a1b2c3d4-5678-9012-3456-789012345678" +``` + +#### Delete Shortcut + +Deletes a single shortcut from an item. The destination data is preserved — only the shortcut reference is removed. + +```bash +dotnet run -- onelake shortcut delete --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" --shortcut-name "ExternalData" --shortcut-path "Tables/ExternalData" +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) +- `--item-id`: Item ID (GUID) +- `--shortcut-name`: Name of the shortcut to delete +- `--shortcut-path`: Path of the shortcut + +#### Reset Shortcut Cache + +Drops cached shortcut reads for a workspace, forcing the next read to re-resolve from the destination. Use sparingly — primarily for debugging stale-cache issues. + +```bash +dotnet run -- onelake shortcut reset-cache --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) + +### Settings Operations + +Workspace-level OneLake settings for diagnostics and immutability policies. These tools call the **Fabric Core API**. + +#### Get Settings + +Gets the OneLake settings for a workspace — diagnostics configuration and immutability policy. + +```bash +dotnet run -- onelake settings get --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) + +#### Modify Diagnostics + +Modifies the diagnostic logging configuration for OneLake at the workspace scope using flat options. + +```bash +dotnet run -- onelake settings modify-diagnostics --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --status "Enabled" --destination-lakehouse-workspace-id "ws-guid" --destination-lakehouse-item-id "item-guid" +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) +- `--status`: Diagnostic status (Enabled or Disabled) +- `--destination-lakehouse-workspace-id`: (Optional) Workspace ID of the destination lakehouse +- `--destination-lakehouse-item-id`: (Optional) Item ID of the destination lakehouse + +#### Modify Immutability Policy + +Modifies the workspace-level OneLake immutability policy. **Warning:** Once enabled, immutability cannot be disabled — confirm with the user before applying. + +```bash +dotnet run -- onelake settings modify-immutability --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --scope "Workspace" --retention-days 30 +``` + +**Parameters:** +- `--workspace-id`: Workspace ID (GUID) +- `--scope`: Immutability scope (e.g. Workspace) +- `--retention-days`: (Optional) Retention period in days + ## Quick Reference - fabmcp.exe Commands For users with the compiled `fabmcp.exe` executable, here are ready-to-use commands: @@ -699,7 +911,46 @@ fabmcp.exe onelake table list --workspace "Analytics Workspace" --item "SalesLak fabmcp.exe onelake table get --workspace "Analytics Workspace" --item "SalesLakehouse.lakehouse" --schema "sales" --table "transactions" ``` -**Note:** Replace the workspace identifier (`47242da5-ff3b-46fb-a94f-977909b773d5`) and item identifier (`0e67ed13-2bb6-49be-9c87-a1105a4ea342`) with your actual Fabric workspace and item values (names or IDs). +### Security Operations +```cmd +# List data access roles on an item +fabmcp.exe onelake security list --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" + +# Get a specific role definition +fabmcp.exe onelake security get --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" --role-name "DataAnalysts" + +# Delete a data access role +fabmcp.exe onelake security delete --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" --role-name "TempRole" +``` + +### Shortcut Operations +```cmd +# List shortcuts on an item +fabmcp.exe onelake shortcut list --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" + +# Get a specific shortcut +fabmcp.exe onelake shortcut get --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" --shortcut-name "ExternalData" --shortcut-path "Tables/ExternalData" + +# Delete a shortcut +fabmcp.exe onelake shortcut delete --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --item-id "0e67ed13-2bb6-49be-9c87-a1105a4ea342" --shortcut-name "ExternalData" --shortcut-path "Tables/ExternalData" + +# Reset shortcut cache +fabmcp.exe onelake shortcut reset-cache --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" +``` + +### Settings Operations +```cmd +# Get workspace OneLake settings +fabmcp.exe onelake settings get --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" + +# Modify diagnostics configuration +fabmcp.exe onelake settings modify-diagnostics --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --diagnostics-config '{"logAnalyticsWorkspaceId":"","level":"Verbose"}' + +# Modify immutability policy +fabmcp.exe onelake settings modify-immutability --workspace-id "47242da5-ff3b-46fb-a94f-977909b773d5" --immutability-policy '{"state":"Enabled"}' +``` + +**Note:** Replace the workspace ID (`47242da5-ff3b-46fb-a94f-977909b773d5`) and item ID (`0e67ed13-2bb6-49be-9c87-a1105a4ea342`) with your actual Fabric GUIDs. ## Common Usage Patterns @@ -831,9 +1082,9 @@ This tool is part of the Microsoft MCP (Model Context Protocol) project. Please The OneLake MCP Tools include a comprehensive test suite with 100% command coverage: #### Test Structure -- **Total Tests**: 76 tests (all passing) -- **Command Tests**: 70 tests covering all 11 OneLake MCP commands -- **Service Architecture Tests**: 6 tests demonstrating testable patterns with dependency injection +- **Total Tests**: 309 tests (all passing) +- **Command Tests**: Covering all OneLake MCP commands including security, shortcuts, and settings +- **Service Architecture Tests**: Demonstrating testable patterns with dependency injection #### Running Tests ```bash @@ -863,4 +1114,4 @@ The test suite includes examples of testable service architecture patterns follo - Follow the established command patterns (see existing commands as examples) - Use proper error handling with meaningful HTTP status codes - Include comprehensive parameter validation -- Write tests that verify both success and error scenarios \ No newline at end of file +- Write tests that verify both success and error scenarios diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/File/BlobGetCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/File/BlobGetCommand.cs index 1d0a333cfe..994eee7381 100644 --- a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/File/BlobGetCommand.cs +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/File/BlobGetCommand.cs @@ -14,6 +14,8 @@ using Microsoft.Mcp.Core.Models.Option; using Microsoft.Mcp.Core.Options; +namespace Fabric.Mcp.Tools.OneLake.Commands.File; + [CommandMetadata( Id = "75d6cb4c-4e81-4e69-a4ec-eca53a7dacd9", Name = "download_file", diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleCreateOrUpdateCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleCreateOrUpdateCommand.cs new file mode 100644 index 0000000000..d7f005e587 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleCreateOrUpdateCommand.cs @@ -0,0 +1,229 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Security; + +[CommandMetadata( + Id = "a1b2c3d4-1001-4000-8000-000000000003", + Name = "create_or_update_data_access_role", + Title = "Create or Update OneLake Data Access Role", + Description = """ + Upsert a single data access role on a single item. Use flat options (--name, + --entra-members, --permitted-paths, --permitted-actions) for the common case + of granting Read access. For advanced scenarios (multiple decision rules, + column/row constraints), pass the full JSON via --role-definition instead. + When flat options are provided, --role-definition is ignored. + Members can be specified by Entra object ID (GUID), email address, or UPN — + non-GUID values are automatically resolved via Microsoft Graph. + Caller must be a workspace Admin or Member. Requires OneLake.ReadWrite.All and + User.Read.All + GroupMember.Read.All for principal resolution. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class DataAccessRoleCreateOrUpdateCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsOptional()); + command.Options.Add(FabricOptionDefinitions.Workspace.AsOptional()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.RoleName.AsOptional()); + command.Options.Add(FabricOptionDefinitions.EntraMembers.AsOptional()); + command.Options.Add(FabricOptionDefinitions.FabricItemMembers.AsOptional()); + command.Options.Add(FabricOptionDefinitions.PermittedPaths.AsOptional()); + command.Options.Add(FabricOptionDefinitions.PermittedActions.AsOptional()); + command.Options.Add(FabricOptionDefinitions.RoleDefinition.AsOptional()); + command.Validators.Add(result => + { + var workspaceId = result.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = result.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + if (string.IsNullOrWhiteSpace(workspaceId) && string.IsNullOrWhiteSpace(workspace)) + { + result.AddError("Workspace identifier is required. Provide --workspace or --workspace-id."); + } + + var effectiveValue = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId : workspace; + if (!string.IsNullOrWhiteSpace(effectiveValue) && !Guid.TryParse(effectiveValue, out _)) + { + result.AddError("Workspace must be a valid GUID. Name-based resolution is not supported for this command."); + } + + var roleName = result.GetValueOrDefault(FabricOptionDefinitions.RoleName.Name); + var entraMembers = result.GetValueOrDefault(FabricOptionDefinitions.EntraMembers.Name); + var fabricItemMembers = result.GetValueOrDefault(FabricOptionDefinitions.FabricItemMembers.Name); + var roleDefinition = result.GetValueOrDefault(FabricOptionDefinitions.RoleDefinition.Name); + + var hasFlat = !string.IsNullOrWhiteSpace(roleName) || + !string.IsNullOrWhiteSpace(entraMembers) || + !string.IsNullOrWhiteSpace(fabricItemMembers); + + if (!hasFlat && string.IsNullOrWhiteSpace(roleDefinition)) + { + result.AddError("Provide either flat options (--role-name + --entra-members/--fabric-item-members) or --role-definition."); + } + + if (hasFlat) + { + if (string.IsNullOrWhiteSpace(roleName)) + result.AddError("--role-name is required when using flat options."); + + if (string.IsNullOrWhiteSpace(entraMembers) && string.IsNullOrWhiteSpace(fabricItemMembers)) + result.AddError("At least one of --entra-members or --fabric-item-members is required."); + + if (!string.IsNullOrWhiteSpace(entraMembers)) + { + foreach (var member in entraMembers.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!Guid.TryParse(member, out _) && !member.Contains('@')) + result.AddError($"Invalid --entra-members value '{member}'. Must be a GUID, email, or UPN."); + } + } + + var permittedActions = result.GetValueOrDefault(FabricOptionDefinitions.PermittedActions.Name); + if (!string.IsNullOrWhiteSpace(permittedActions)) + { + foreach (var action in permittedActions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (!string.Equals(action, "Read", StringComparison.OrdinalIgnoreCase)) + result.AddError($"Unsupported --permitted-actions value '{action}'. Only 'Read' is currently supported."); + } + } + } + }); + } + + protected override DataAccessRoleCreateOrUpdateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + var workspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = parseResult.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + options.WorkspaceId = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId! : workspace ?? string.Empty; + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.Name = parseResult.GetValueOrDefault(FabricOptionDefinitions.RoleName.Name); + options.EntraMembers = parseResult.GetValueOrDefault(FabricOptionDefinitions.EntraMembers.Name); + options.FabricItemMembers = parseResult.GetValueOrDefault(FabricOptionDefinitions.FabricItemMembers.Name); + options.PermittedPaths = parseResult.GetValueOrDefault(FabricOptionDefinitions.PermittedPaths.Name); + options.PermittedActions = parseResult.GetValueOrDefault(FabricOptionDefinitions.PermittedActions.Name); + options.RoleDefinition = parseResult.GetValueOrDefault(FabricOptionDefinitions.RoleDefinition.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + DataAccessRole result; + if (!string.IsNullOrWhiteSpace(options.Name)) + { + // Build role from flat options + var roleDefinitionJson = BuildRoleDefinitionJson(options); + result = await _oneLakeService.CreateOrUpdateDataAccessRoleAsync(options.WorkspaceId!, options.ItemId!, roleDefinitionJson, cancellationToken); + } + else + { + // Use raw JSON escape hatch + result = await _oneLakeService.CreateOrUpdateDataAccessRoleAsync(options.WorkspaceId!, options.ItemId!, options.RoleDefinition!, cancellationToken); + } + + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.DataAccessRole); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating/updating data access role. Workspace: {Workspace}, Item: {Item}.", + options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } + + private static string BuildRoleDefinitionJson(DataAccessRoleCreateOrUpdateOptions options) + { + var members = new DataAccessRoleMembers(); + + if (!string.IsNullOrWhiteSpace(options.EntraMembers)) + { + members.MicrosoftEntraMembers = options.EntraMembers + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(m => new MicrosoftEntraMember { ObjectId = m }) + .ToList(); + } + + if (!string.IsNullOrWhiteSpace(options.FabricItemMembers)) + { + members.FabricItemMembers = options.FabricItemMembers + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(m => + { + var parts = m.Split(':', 2); + return new FabricItemMember + { + SourcePath = parts[0], + ItemAccess = parts.Length > 1 ? [parts[1]] : ["Read"] + }; + }) + .ToList(); + } + + var actions = "Read"; + if (!string.IsNullOrWhiteSpace(options.PermittedActions)) + { + actions = options.PermittedActions; + } + + var permissions = new List + { + new() { AttributeName = "Action", AttributeValueIncludedIn = actions.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList() } + }; + + if (!string.IsNullOrWhiteSpace(options.PermittedPaths)) + { + permissions.Add(new DecisionRuleScope + { + AttributeName = "Path", + AttributeValueIncludedIn = options.PermittedPaths.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList() + }); + } + + var role = new DataAccessRole + { + Name = options.Name!, + Members = members, + DecisionRules = + [ + new DecisionRule + { + Effect = "Permit", + Permission = permissions + } + ] + }; + + return System.Text.Json.JsonSerializer.Serialize(role, OneLakeJsonContext.Default.DataAccessRole); + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleDeleteCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleDeleteCommand.cs new file mode 100644 index 0000000000..c387c5462f --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleDeleteCommand.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Security; + +[CommandMetadata( + Id = "a1b2c3d4-1001-4000-8000-000000000004", + Name = "delete_data_access_role", + Title = "Delete OneLake Data Access Role", + Description = """ + Delete a single data access role from a single item. Scoped to one role + on one item per call. Destructive — principals that gained access only + via this role lose it on this item. Does not affect roles on other items. + Caller must be a workspace Admin or Member on the item's workspace. + Requires OneLake.ReadWrite.All. + """, + Destructive = true, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class DataAccessRoleDeleteCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsOptional()); + command.Options.Add(FabricOptionDefinitions.Workspace.AsOptional()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.RoleName.AsRequired()); + command.Validators.Add(result => + { + var workspaceId = result.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = result.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + if (string.IsNullOrWhiteSpace(workspaceId) && string.IsNullOrWhiteSpace(workspace)) + { + result.AddError("Workspace identifier is required. Provide --workspace or --workspace-id."); + } + + var effectiveValue = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId : workspace; + if (!string.IsNullOrWhiteSpace(effectiveValue) && !Guid.TryParse(effectiveValue, out _)) + { + result.AddError("Workspace must be a valid GUID. Name-based resolution is not supported for this command."); + } + }); + } + + protected override DataAccessRoleDeleteOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + var workspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = parseResult.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + options.WorkspaceId = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId! : workspace ?? string.Empty; + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.RoleName = parseResult.GetValueOrDefault(FabricOptionDefinitions.RoleName.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + await _oneLakeService.DeleteDataAccessRoleAsync(options.WorkspaceId!, options.ItemId!, options.RoleName!, cancellationToken); + var result = new DataAccessRoleDeleteCommandResult(options.RoleName!, "Data access role deleted successfully."); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.DataAccessRoleDeleteCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting data access role. Workspace: {Workspace}, Item: {Item}, Role: {Role}.", + options.WorkspaceId, options.ItemId, options.RoleName); + HandleException(context, ex); + } + + return context.Response; + } + + public sealed record DataAccessRoleDeleteCommandResult(string RoleName, string Message); +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleGetCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleGetCommand.cs new file mode 100644 index 0000000000..5a53e25972 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleGetCommand.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Security; + +[CommandMetadata( + Id = "a1b2c3d4-1001-4000-8000-000000000002", + Name = "get_data_access_role", + Title = "Get OneLake Data Access Role", + Description = """ + Get the full definition of a single data access role on a single item — + members, permissions, decision rules. Scoped to one role on one item per + call. Use after onelake_list_data_access_roles once you know which role + you need on which item. Distinct from onelake_get_principal_access, + which returns the effective (resolved) access for a given principal + across all roles on an item. Caller must be a workspace Admin or Member + on the item's workspace. Requires OneLake.Read.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = true, + Secret = false)] +public sealed class DataAccessRoleGetCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsOptional()); + command.Options.Add(FabricOptionDefinitions.Workspace.AsOptional()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.RoleName.AsRequired()); + command.Validators.Add(result => + { + var workspaceId = result.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = result.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + if (string.IsNullOrWhiteSpace(workspaceId) && string.IsNullOrWhiteSpace(workspace)) + { + result.AddError("Workspace identifier is required. Provide --workspace or --workspace-id."); + } + + var effectiveValue = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId : workspace; + if (!string.IsNullOrWhiteSpace(effectiveValue) && !Guid.TryParse(effectiveValue, out _)) + { + result.AddError("Workspace must be a valid GUID. Name-based resolution is not supported for this command."); + } + }); + } + + protected override DataAccessRoleGetOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + var workspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = parseResult.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + options.WorkspaceId = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId! : workspace ?? string.Empty; + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.RoleName = parseResult.GetValueOrDefault(FabricOptionDefinitions.RoleName.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + + + var result = await _oneLakeService.GetDataAccessRoleAsync(options.WorkspaceId!, options.ItemId!, options.RoleName!, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.DataAccessRole); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting data access role. Workspace: {Workspace}, Item: {Item}, Role: {Role}.", + options.WorkspaceId, options.ItemId, options.RoleName); + HandleException(context, ex); + } + + return context.Response; + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleListCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleListCommand.cs new file mode 100644 index 0000000000..d3f6411dfc --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Security/DataAccessRoleListCommand.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Security; + +[CommandMetadata( + Id = "a1b2c3d4-1001-4000-8000-000000000001", + Name = "list_data_access_roles", + Title = "List OneLake Data Access Roles", + Description = """ + List all data access roles defined on a single item (Lakehouse / Warehouse) — + the role-based policies that gate Tables/Files access for that item. Scoped + to one item per call; to inspect roles across multiple items, call once per + item. For looking up a specific role by name, fetch the list and pick by + name; there is no server-side search. Caller must be a workspace Admin + or Member on the item's workspace. Requires OneLake.Read.All. + Note: Built-in roles (e.g. DefaultReader) may include fabricItemMembers with + a 'sourcePath' field formatted as '/' — this is NOT a + OneLake file path; it identifies the workspace/item granting inherited access. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = true, + Secret = false)] +public sealed class DataAccessRoleListCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsOptional()); + command.Options.Add(FabricOptionDefinitions.Workspace.AsOptional()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ContinuationToken.AsOptional()); + command.Validators.Add(result => + { + var workspaceId = result.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = result.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + if (string.IsNullOrWhiteSpace(workspaceId) && string.IsNullOrWhiteSpace(workspace)) + { + result.AddError("Workspace identifier is required. Provide --workspace or --workspace-id."); + } + + var effectiveValue = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId : workspace; + if (!string.IsNullOrWhiteSpace(effectiveValue) && !Guid.TryParse(effectiveValue, out _)) + { + result.AddError("Workspace must be a valid GUID. Name-based resolution is not supported for this command."); + } + }); + } + + protected override DataAccessRoleListOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + var workspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = parseResult.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + options.WorkspaceId = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId! : workspace ?? string.Empty; + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.ContinuationToken = parseResult.GetValueOrDefault(FabricOptionDefinitions.ContinuationTokenName); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + + var result = await _oneLakeService.ListDataAccessRolesAsync(options.WorkspaceId!, options.ItemId!, options.ContinuationToken, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.DataAccessRoleListResponse); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing data access roles. Workspace: {Workspace}, Item: {Item}.", options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Settings/DiagnosticsModifyCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Settings/DiagnosticsModifyCommand.cs new file mode 100644 index 0000000000..dbd4dfce10 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Settings/DiagnosticsModifyCommand.cs @@ -0,0 +1,140 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Settings; + +[CommandMetadata( + Id = "a1b2c3d4-3001-4000-8000-000000000002", + Name = "modify_diagnostics", + Title = "Modify OneLake Diagnostics", + Description = """ + Enable or disable workspace-level OneLake diagnostic logging. When enabling, + specify the destination lakehouse where logs will be stored. When disabling, + destination options must be omitted. This is an LRO — the server may return + 202 Accepted. Requires OneLake.ReadWrite.All. Caller must be a workspace Admin + on the source workspace and Contributor+ on the destination workspace. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class DiagnosticsModifyCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsOptional()); + command.Options.Add(FabricOptionDefinitions.Workspace.AsOptional()); + command.Options.Add(FabricOptionDefinitions.DiagnosticsStatus.AsRequired()); + command.Options.Add(FabricOptionDefinitions.DestinationLakehouseWorkspaceId.AsOptional()); + command.Options.Add(FabricOptionDefinitions.DestinationLakehouseItemId.AsOptional()); + command.Validators.Add(result => + { + var workspaceId = result.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = result.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + if (string.IsNullOrWhiteSpace(workspaceId) && string.IsNullOrWhiteSpace(workspace)) + { + result.AddError("Workspace identifier is required. Provide --workspace or --workspace-id."); + } + + var effectiveValue = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId : workspace; + if (!string.IsNullOrWhiteSpace(effectiveValue) && !Guid.TryParse(effectiveValue, out _)) + { + result.AddError("Workspace must be a valid GUID. Name-based resolution is not supported for this command."); + } + + var status = result.GetValueOrDefault(FabricOptionDefinitions.DiagnosticsStatus.Name); + if (!string.Equals(status, "Enabled", StringComparison.OrdinalIgnoreCase) && + !string.Equals(status, "Disabled", StringComparison.OrdinalIgnoreCase)) + { + result.AddError("--status must be 'Enabled' or 'Disabled'."); + } + + var destWorkspaceId = result.GetValueOrDefault(FabricOptionDefinitions.DestinationLakehouseWorkspaceId.Name); + var destItemId = result.GetValueOrDefault(FabricOptionDefinitions.DestinationLakehouseItemId.Name); + + if (string.Equals(status, "Enabled", StringComparison.OrdinalIgnoreCase)) + { + if (string.IsNullOrWhiteSpace(destWorkspaceId)) + result.AddError("--destination-lakehouse-workspace-id is required when --status is Enabled."); + else if (!Guid.TryParse(destWorkspaceId, out _)) + result.AddError("--destination-lakehouse-workspace-id must be a valid GUID."); + + if (string.IsNullOrWhiteSpace(destItemId)) + result.AddError("--destination-lakehouse-item-id is required when --status is Enabled."); + else if (!Guid.TryParse(destItemId, out _)) + result.AddError("--destination-lakehouse-item-id must be a valid GUID."); + } + else if (string.Equals(status, "Disabled", StringComparison.OrdinalIgnoreCase)) + { + if (!string.IsNullOrWhiteSpace(destWorkspaceId) || !string.IsNullOrWhiteSpace(destItemId)) + result.AddError("Destination options must be omitted when --status is Disabled."); + } + }); + } + + protected override DiagnosticsModifyOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + var workspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = parseResult.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + options.WorkspaceId = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId! : workspace ?? string.Empty; + options.Status = parseResult.GetValueOrDefault(FabricOptionDefinitions.DiagnosticsStatus.Name); + options.DestinationLakehouseWorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.DestinationLakehouseWorkspaceId.Name); + options.DestinationLakehouseItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.DestinationLakehouseItemId.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + var settings = new OneLakeDiagnosticSettings { Status = options.Status }; + if (string.Equals(options.Status, "Enabled", StringComparison.OrdinalIgnoreCase)) + { + settings.Destination = new LakehouseDiagnosticDestination + { + Lakehouse = new ItemReferenceById + { + ItemId = options.DestinationLakehouseItemId, + WorkspaceId = options.DestinationLakehouseWorkspaceId + } + }; + } + + await _oneLakeService.ModifyDiagnosticsAsync(options.WorkspaceId!, settings, cancellationToken); + context.Response.Results = ResponseResult.Create(new("Diagnostics settings modified successfully."), OneLakeJsonContext.Default.DiagnosticsModifyCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error modifying OneLake diagnostics. Workspace: {Workspace}.", options.WorkspaceId); + HandleException(context, ex); + } + + return context.Response; + } + + public sealed record DiagnosticsModifyCommandResult(string Message); +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Settings/ImmutabilityPolicyModifyCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Settings/ImmutabilityPolicyModifyCommand.cs new file mode 100644 index 0000000000..cf5b30e34f --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Settings/ImmutabilityPolicyModifyCommand.cs @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Settings; + +[CommandMetadata( + Id = "a1b2c3d4-3001-4000-8000-000000000003", + Name = "modify_immutability_policy", + Title = "Modify OneLake Immutability Policy", + Description = """ + Modify the workspace-level OneLake immutability policy. Once enabled, + immutability cannot be disabled — confirm with the user before applying. + Retention days cannot be reduced below the current value. Requires + OneLake.ReadWrite.All. Caller must be a workspace Admin. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ImmutabilityPolicyModifyCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsOptional()); + command.Options.Add(FabricOptionDefinitions.Workspace.AsOptional()); + command.Options.Add(FabricOptionDefinitions.ImmutabilityScope.AsRequired()); + command.Options.Add(FabricOptionDefinitions.RetentionDays.AsRequired()); + command.Validators.Add(result => + { + var workspaceId = result.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = result.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + if (string.IsNullOrWhiteSpace(workspaceId) && string.IsNullOrWhiteSpace(workspace)) + { + result.AddError("Workspace identifier is required. Provide --workspace or --workspace-id."); + } + + var effectiveValue = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId : workspace; + if (!string.IsNullOrWhiteSpace(effectiveValue) && !Guid.TryParse(effectiveValue, out _)) + { + result.AddError("Workspace must be a valid GUID. Name-based resolution is not supported for this command."); + } + + var scope = result.GetValueOrDefault(FabricOptionDefinitions.ImmutabilityScope.Name); + if (!string.Equals(scope, "DiagnosticLogs", StringComparison.OrdinalIgnoreCase)) + { + result.AddError("--scope must be 'DiagnosticLogs'. No other scopes are currently supported."); + } + + var retentionDays = result.GetValueOrDefault(FabricOptionDefinitions.RetentionDays.Name); + if (retentionDays < 1) + { + result.AddError("--retention-days must be at least 1."); + } + }); + } + + protected override ImmutabilityPolicyModifyOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + var workspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = parseResult.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + options.WorkspaceId = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId! : workspace ?? string.Empty; + options.Scope = parseResult.GetValueOrDefault(FabricOptionDefinitions.ImmutabilityScope.Name); + options.RetentionDays = parseResult.GetValueOrDefault(FabricOptionDefinitions.RetentionDays.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + var policy = new ImmutabilityPolicy + { + Scope = options.Scope, + RetentionDays = options.RetentionDays + }; + + await _oneLakeService.ModifyImmutabilityPolicyAsync(options.WorkspaceId!, policy, cancellationToken); + context.Response.Results = ResponseResult.Create(new("Immutability policy modified successfully."), OneLakeJsonContext.Default.ImmutabilityPolicyModifyCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error modifying OneLake immutability policy. Workspace: {Workspace}.", options.WorkspaceId); + HandleException(context, ex); + } + + return context.Response; + } + + public sealed record ImmutabilityPolicyModifyCommandResult(string Message); +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Settings/SettingsGetCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Settings/SettingsGetCommand.cs new file mode 100644 index 0000000000..a5261eaf76 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Settings/SettingsGetCommand.cs @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Settings; + +[CommandMetadata( + Id = "a1b2c3d4-3001-4000-8000-000000000001", + Name = "get_settings", + Title = "Get OneLake Settings", + Description = """ + Get the OneLake settings for a workspace — diagnostics configuration and + immutability policy. Requires OneLake.Read.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = true, + Secret = false)] +public sealed class SettingsGetCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsOptional()); + command.Options.Add(FabricOptionDefinitions.Workspace.AsOptional()); + command.Validators.Add(result => + { + var workspaceId = result.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = result.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + if (string.IsNullOrWhiteSpace(workspaceId) && string.IsNullOrWhiteSpace(workspace)) + { + result.AddError("Workspace identifier is required. Provide --workspace or --workspace-id."); + } + + var effectiveValue = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId : workspace; + if (!string.IsNullOrWhiteSpace(effectiveValue) && !Guid.TryParse(effectiveValue, out _)) + { + result.AddError("Workspace must be a valid GUID. Name-based resolution is not supported for this command."); + } + }); + } + + protected override SettingsGetOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + var workspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + var workspace = parseResult.GetValueOrDefault(FabricOptionDefinitions.Workspace.Name); + options.WorkspaceId = !string.IsNullOrWhiteSpace(workspaceId) ? workspaceId! : workspace ?? string.Empty; + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + + var result = await _oneLakeService.GetSettingsAsync(options.WorkspaceId!, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeSettings); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting OneLake settings. Workspace: {Workspace}.", options.WorkspaceId); + HandleException(context, ex); + } + + return context.Response; + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateAdlsGen2Command.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateAdlsGen2Command.cs new file mode 100644 index 0000000000..5c16379f4a --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateAdlsGen2Command.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000011", + Name = "create_shortcut_adls_gen2", + Title = "Create OneLake Shortcut (ADLS Gen2 Target)", + Description = """ + Create a shortcut pointing to an Azure Data Lake Storage Gen2 location. + Requires a connection ID for authentication and a target URL. Requires + OneLake.ReadWrite.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutCreateAdlsGen2Command( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutConflictPolicy.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetLocation.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetSubpath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetConnectionId.AsRequired()); + } + + protected override ShortcutCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.Path = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.Name = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + options.ConflictPolicy = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutConflictPolicy.Name); + options.TargetLocation = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetLocation.Name); + options.TargetSubpath = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetSubpath.Name); + options.TargetConnectionId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetConnectionId.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + return context.Response; + + var options = BindOptions(parseResult); + try + { + var shortcut = new OneLakeShortcut + { + Path = options.Path, + Name = options.Name, + Target = new ShortcutTarget + { + AdlsGen2 = new AdlsGen2ShortcutTarget + { + Location = options.TargetLocation, + Subpath = options.TargetSubpath, + ConnectionId = options.TargetConnectionId + } + } + }; + + var result = await _oneLakeService.CreateShortcutAsync(options.WorkspaceId!, options.ItemId!, shortcut, options.ConflictPolicy, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeShortcut); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating ADLS Gen2 shortcut. Workspace: {Workspace}, Item: {Item}.", options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateAmazonS3Command.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateAmazonS3Command.cs new file mode 100644 index 0000000000..aaaab61087 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateAmazonS3Command.cs @@ -0,0 +1,96 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000012", + Name = "create_shortcut_amazon_s3", + Title = "Create OneLake Shortcut (Amazon S3 Target)", + Description = """ + Create a shortcut pointing to an Amazon S3 location. Requires a connection + ID for authentication and a target URL. Requires OneLake.ReadWrite.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutCreateAmazonS3Command( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutConflictPolicy.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetLocation.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetSubpath.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetConnectionId.AsRequired()); + } + + protected override ShortcutCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.Path = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.Name = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + options.ConflictPolicy = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutConflictPolicy.Name); + options.TargetLocation = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetLocation.Name); + options.TargetSubpath = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetSubpath.Name); + options.TargetConnectionId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetConnectionId.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + return context.Response; + + var options = BindOptions(parseResult); + try + { + var shortcut = new OneLakeShortcut + { + Path = options.Path, + Name = options.Name, + Target = new ShortcutTarget + { + AmazonS3 = new AmazonS3ShortcutTarget + { + Location = options.TargetLocation, + Subpath = options.TargetSubpath, + ConnectionId = options.TargetConnectionId + } + } + }; + + var result = await _oneLakeService.CreateShortcutAsync(options.WorkspaceId!, options.ItemId!, shortcut, options.ConflictPolicy, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeShortcut); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating Amazon S3 shortcut. Workspace: {Workspace}, Item: {Item}.", options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateAzureBlobCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateAzureBlobCommand.cs new file mode 100644 index 0000000000..f920b684d1 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateAzureBlobCommand.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000013", + Name = "create_shortcut_azure_blob", + Title = "Create OneLake Shortcut (Azure Blob Storage Target)", + Description = """ + Create a shortcut pointing to an Azure Blob Storage location. Requires a + connection ID for authentication and a target URL. Requires + OneLake.ReadWrite.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutCreateAzureBlobCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutConflictPolicy.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetLocation.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetSubpath.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetConnectionId.AsRequired()); + } + + protected override ShortcutCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.Path = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.Name = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + options.ConflictPolicy = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutConflictPolicy.Name); + options.TargetLocation = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetLocation.Name); + options.TargetSubpath = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetSubpath.Name); + options.TargetConnectionId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetConnectionId.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + return context.Response; + + var options = BindOptions(parseResult); + try + { + var shortcut = new OneLakeShortcut + { + Path = options.Path, + Name = options.Name, + Target = new ShortcutTarget + { + AzureBlobStorage = new AzureBlobStorageShortcutTarget + { + Location = options.TargetLocation, + Subpath = options.TargetSubpath, + ConnectionId = options.TargetConnectionId + } + } + }; + + var result = await _oneLakeService.CreateShortcutAsync(options.WorkspaceId!, options.ItemId!, shortcut, options.ConflictPolicy, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeShortcut); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating Azure Blob shortcut. Workspace: {Workspace}, Item: {Item}.", options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateDataverseCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateDataverseCommand.cs new file mode 100644 index 0000000000..7eb14ba4aa --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateDataverseCommand.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000016", + Name = "create_shortcut_dataverse", + Title = "Create OneLake Shortcut (Dataverse Target)", + Description = """ + Create a shortcut pointing to a Dataverse environment. Requires the + environment domain, connection ID, and Delta Lake folder. Requires + OneLake.ReadWrite.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutCreateDataverseCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutConflictPolicy.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetEnvironmentDomain.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetConnectionId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetDeltaLakeFolder.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetTableName.AsOptional()); + } + + protected override ShortcutCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.Path = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.Name = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + options.ConflictPolicy = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutConflictPolicy.Name); + options.TargetEnvironmentDomain = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetEnvironmentDomain.Name); + options.TargetConnectionId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetConnectionId.Name); + options.TargetDeltaLakeFolder = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetDeltaLakeFolder.Name); + options.TargetTableName = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetTableNameOptionName); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + return context.Response; + + var options = BindOptions(parseResult); + try + { + var shortcut = new OneLakeShortcut + { + Path = options.Path, + Name = options.Name, + Target = new ShortcutTarget + { + Dataverse = new DataverseShortcutTarget + { + EnvironmentDomain = options.TargetEnvironmentDomain, + ConnectionId = options.TargetConnectionId, + DeltaLakeFolder = options.TargetDeltaLakeFolder + } + } + }; + + var result = await _oneLakeService.CreateShortcutAsync(options.WorkspaceId!, options.ItemId!, shortcut, options.ConflictPolicy, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeShortcut); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating Dataverse shortcut. Workspace: {Workspace}, Item: {Item}.", options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateGcsCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateGcsCommand.cs new file mode 100644 index 0000000000..699ce7d33b --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateGcsCommand.cs @@ -0,0 +1,97 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000014", + Name = "create_shortcut_gcs", + Title = "Create OneLake Shortcut (Google Cloud Storage Target)", + Description = """ + Create a shortcut pointing to a Google Cloud Storage location. Requires a + connection ID for authentication and a target URL. Requires + OneLake.ReadWrite.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutCreateGcsCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutConflictPolicy.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetLocation.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetSubpath.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetConnectionId.AsRequired()); + } + + protected override ShortcutCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.Path = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.Name = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + options.ConflictPolicy = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutConflictPolicy.Name); + options.TargetLocation = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetLocation.Name); + options.TargetSubpath = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetSubpath.Name); + options.TargetConnectionId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetConnectionId.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + return context.Response; + + var options = BindOptions(parseResult); + try + { + var shortcut = new OneLakeShortcut + { + Path = options.Path, + Name = options.Name, + Target = new ShortcutTarget + { + GoogleCloudStorage = new GoogleCloudStorageShortcutTarget + { + Location = options.TargetLocation, + Subpath = options.TargetSubpath, + ConnectionId = options.TargetConnectionId + } + } + }; + + var result = await _oneLakeService.CreateShortcutAsync(options.WorkspaceId!, options.ItemId!, shortcut, options.ConflictPolicy, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeShortcut); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating GCS shortcut. Workspace: {Workspace}, Item: {Item}.", options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateOneDriveSharePointCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateOneDriveSharePointCommand.cs new file mode 100644 index 0000000000..3f5fa5f2a9 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateOneDriveSharePointCommand.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000017", + Name = "create_shortcut_onedrive_sharepoint", + Title = "Create OneLake Shortcut (OneDrive/SharePoint Target)", + Description = """ + Create a shortcut pointing to a OneDrive or SharePoint Online location. + Requires a connection ID and target URL. Optionally updates the Fabric + item sensitivity label from the source. Requires OneLake.ReadWrite.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutCreateOneDriveSharePointCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutConflictPolicy.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetLocation.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetSubpath.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetConnectionId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetUpdateFabricItemSensitivity.AsOptional()); + } + + protected override ShortcutCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.Path = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.Name = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + options.ConflictPolicy = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutConflictPolicy.Name); + options.TargetLocation = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetLocation.Name); + options.TargetSubpath = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetSubpath.Name); + options.TargetConnectionId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetConnectionId.Name); + options.TargetUpdateFabricItemSensitivity = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetUpdateFabricItemSensitivityName); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + return context.Response; + + var options = BindOptions(parseResult); + try + { + var shortcut = new OneLakeShortcut + { + Path = options.Path, + Name = options.Name, + Target = new ShortcutTarget + { + OneDriveSharePoint = new OneDriveSharePointShortcutTarget + { + Location = options.TargetLocation, + Subpath = options.TargetSubpath, + ConnectionId = options.TargetConnectionId, + UpdateFabricItemSensitivity = options.TargetUpdateFabricItemSensitivity ? true : null + } + } + }; + + var result = await _oneLakeService.CreateShortcutAsync(options.WorkspaceId!, options.ItemId!, shortcut, options.ConflictPolicy, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeShortcut); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating OneDrive/SharePoint shortcut. Workspace: {Workspace}, Item: {Item}.", options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateOneLakeCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateOneLakeCommand.cs new file mode 100644 index 0000000000..77009f7edf --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateOneLakeCommand.cs @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000010", + Name = "create_shortcut_onelake", + Title = "Create OneLake Shortcut (OneLake Target)", + Description = """ + Create a shortcut pointing to another OneLake location. Specify the target + workspace, item, and optional path within the target item. Requires + OneLake.ReadWrite.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutCreateOneLakeCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutConflictPolicy.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetWorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetConnectionId.AsOptional()); + } + + protected override ShortcutCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.Path = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.Name = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + options.ConflictPolicy = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutConflictPolicy.Name); + options.TargetWorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetWorkspaceId.Name); + options.TargetItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetItemId.Name); + options.TargetPath = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetPath.Name); + options.TargetConnectionId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetConnectionId.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + return context.Response; + + var options = BindOptions(parseResult); + try + { + var shortcut = new OneLakeShortcut + { + Path = options.Path, + Name = options.Name, + Target = new ShortcutTarget + { + OneLake = new OneLakeShortcutTarget + { + WorkspaceId = options.TargetWorkspaceId, + ItemId = options.TargetItemId, + Path = options.TargetPath, + ConnectionId = options.TargetConnectionId + } + } + }; + + var result = await _oneLakeService.CreateShortcutAsync(options.WorkspaceId!, options.ItemId!, shortcut, options.ConflictPolicy, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeShortcut); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating OneLake shortcut. Workspace: {Workspace}, Item: {Item}.", options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateS3CompatibleCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateS3CompatibleCommand.cs new file mode 100644 index 0000000000..d8f8895fc8 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutCreateS3CompatibleCommand.cs @@ -0,0 +1,99 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000015", + Name = "create_shortcut_s3_compatible", + Title = "Create OneLake Shortcut (S3 Compatible Target)", + Description = """ + Create a shortcut pointing to an S3-compatible storage location. Requires + a connection ID, target URL, and bucket name. Requires OneLake.ReadWrite.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutCreateS3CompatibleCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutConflictPolicy.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetLocation.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetSubpath.AsOptional()); + command.Options.Add(FabricOptionDefinitions.TargetConnectionId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.TargetBucket.AsRequired()); + } + + protected override ShortcutCreateOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.Path = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.Name = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + options.ConflictPolicy = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutConflictPolicy.Name); + options.TargetLocation = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetLocation.Name); + options.TargetSubpath = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetSubpath.Name); + options.TargetConnectionId = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetConnectionId.Name); + options.TargetBucket = parseResult.GetValueOrDefault(FabricOptionDefinitions.TargetBucket.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + return context.Response; + + var options = BindOptions(parseResult); + try + { + var shortcut = new OneLakeShortcut + { + Path = options.Path, + Name = options.Name, + Target = new ShortcutTarget + { + S3Compatible = new S3CompatibleShortcutTarget + { + Location = options.TargetLocation, + Subpath = options.TargetSubpath, + ConnectionId = options.TargetConnectionId, + Bucket = options.TargetBucket + } + } + }; + + var result = await _oneLakeService.CreateShortcutAsync(options.WorkspaceId!, options.ItemId!, shortcut, options.ConflictPolicy, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeShortcut); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating S3-compatible shortcut. Workspace: {Workspace}, Item: {Item}.", options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutDeleteCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutDeleteCommand.cs new file mode 100644 index 0000000000..86dff0f210 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutDeleteCommand.cs @@ -0,0 +1,83 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000004", + Name = "delete_shortcut", + Title = "Delete OneLake Shortcut", + Description = """ + Delete a single shortcut from an item. Destructive but the destination + data is preserved — only the shortcut reference is removed. Requires + OneLake.ReadWrite.All. + """, + Destructive = true, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutDeleteCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + } + + protected override ShortcutDeleteOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.ShortcutPath = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.ShortcutName = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + + + await _oneLakeService.DeleteShortcutAsync(options.WorkspaceId!, options.ItemId!, options.ShortcutPath!, options.ShortcutName!, cancellationToken); + var result = new ShortcutDeleteCommandResult(options.ShortcutPath!, options.ShortcutName!, "Shortcut deleted successfully."); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.ShortcutDeleteCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error deleting shortcut. Workspace: {Workspace}, Item: {Item}, Path: {Path}, Name: {Name}.", + options.WorkspaceId, options.ItemId, options.ShortcutPath, options.ShortcutName); + HandleException(context, ex); + } + + return context.Response; + } + + public sealed record ShortcutDeleteCommandResult(string ShortcutPath, string ShortcutName, string Message); +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutGetCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutGetCommand.cs new file mode 100644 index 0000000000..bf3355f3ee --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutGetCommand.cs @@ -0,0 +1,79 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000002", + Name = "get_shortcut", + Title = "Get OneLake Shortcut", + Description = """ + Get the properties of a single shortcut (name, path, target, + configuration). Requires OneLake.Read.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = true, + Secret = false)] +public sealed class ShortcutGetCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutPath.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ShortcutName.AsRequired()); + } + + protected override ShortcutGetOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.ShortcutPath = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutPath.Name); + options.ShortcutName = parseResult.GetValueOrDefault(FabricOptionDefinitions.ShortcutName.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + + + var result = await _oneLakeService.GetShortcutAsync(options.WorkspaceId!, options.ItemId!, options.ShortcutPath!, options.ShortcutName!, cancellationToken); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.OneLakeShortcut); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error getting shortcut. Workspace: {Workspace}, Item: {Item}, Path: {Path}, Name: {Name}.", + options.WorkspaceId, options.ItemId, options.ShortcutPath, options.ShortcutName); + HandleException(context, ex); + } + + return context.Response; + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutListCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutListCommand.cs new file mode 100644 index 0000000000..ec6fb5dada --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutListCommand.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000001", + Name = "list_shortcuts", + Title = "List OneLake Shortcuts", + Description = """ + List shortcuts defined within an item, recursing through subfolders. + Returns each shortcut's path and target. Requires OneLake.Read.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = true, + Secret = false)] +public sealed class ShortcutListCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ItemId.AsRequired()); + command.Options.Add(FabricOptionDefinitions.ParentPath.AsOptional()); + command.Options.Add(FabricOptionDefinitions.ContinuationToken.AsOptional()); + command.Options.Add(FabricOptionDefinitions.IncludeManaged.AsOptional()); + } + + protected override ShortcutListOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + options.ItemId = parseResult.GetValueOrDefault(FabricOptionDefinitions.ItemId.Name); + options.ParentPath = parseResult.GetValueOrDefault(FabricOptionDefinitions.ParentPath.Name); + options.ContinuationToken = parseResult.GetValueOrDefault(FabricOptionDefinitions.ContinuationTokenName); + options.IncludeManaged = parseResult.GetValueOrDefault(FabricOptionDefinitions.IncludeManagedName); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + + var result = await _oneLakeService.ListShortcutsAsync(options.WorkspaceId!, options.ItemId!, options.ParentPath, options.ContinuationToken, cancellationToken); + + if (!options.IncludeManaged && result.Value is not null) + { + result.Value = result.Value + .Where(s => !IsManagedShortcut(s)) + .ToList(); + } + + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.ShortcutListResponse); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error listing shortcuts. Workspace: {Workspace}, Item: {Item}.", + options.WorkspaceId, options.ItemId); + HandleException(context, ex); + } + + return context.Response; + } + + /// + /// DW-managed shortcuts are created internally by Warehouse/SQL endpoints and can number + /// in the hundreds of thousands, drowning user-visible shortcuts. They typically reside + /// under well-known managed paths (e.g. "Tables/dbo.*" with OneLake-internal targets). + /// + private static bool IsManagedShortcut(OneLakeShortcut shortcut) + { + // Managed shortcuts are internal OneLake-to-OneLake references under DW table paths. + // Heuristic: shortcuts whose path starts with "Tables/" and target is OneLake are DW-managed. + if (shortcut.Path is null || shortcut.Target?.OneLake is null) + return false; + + return shortcut.Path.StartsWith("Tables/", StringComparison.OrdinalIgnoreCase); + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutResetCacheCommand.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutResetCacheCommand.cs new file mode 100644 index 0000000000..f23f114ae4 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Commands/Shortcut/ShortcutResetCacheCommand.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Options; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using Microsoft.Mcp.Core.Extensions; +using Microsoft.Mcp.Core.Models.Option; + +namespace Fabric.Mcp.Tools.OneLake.Commands.Shortcut; + +[CommandMetadata( + Id = "a1b2c3d4-2001-4000-8000-000000000005", + Name = "reset_shortcut_cache", + Title = "Reset OneLake Shortcut Cache", + Description = """ + Drop cached shortcut reads for a workspace, forcing the next read to + re-resolve from the destination. Use sparingly — primarily for debugging + stale-cache issues. Requires OneLake.ReadWrite.All. + """, + Destructive = false, + Idempotent = true, + LocalRequired = false, + OpenWorld = false, + ReadOnly = false, + Secret = false)] +public sealed class ShortcutResetCacheCommand( + ILogger logger, + IOneLakeService oneLakeService) : GlobalCommand() +{ + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IOneLakeService _oneLakeService = oneLakeService ?? throw new ArgumentNullException(nameof(oneLakeService)); + + protected override void RegisterOptions(Command command) + { + base.RegisterOptions(command); + command.Options.Add(FabricOptionDefinitions.WorkspaceId.AsRequired()); + } + + protected override ShortcutResetCacheOptions BindOptions(ParseResult parseResult) + { + var options = base.BindOptions(parseResult); + options.WorkspaceId = parseResult.GetValueOrDefault(FabricOptionDefinitions.WorkspaceId.Name); + return options; + } + + public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken) + { + if (!Validate(parseResult.CommandResult, context.Response).IsValid) + { + return context.Response; + } + + var options = BindOptions(parseResult); + try + { + + await _oneLakeService.ResetShortcutCacheAsync(options.WorkspaceId!, cancellationToken); + var result = new ShortcutResetCacheCommandResult("Shortcut cache reset successfully."); + context.Response.Results = ResponseResult.Create(result, OneLakeJsonContext.Default.ShortcutResetCacheCommandResult); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error resetting shortcut cache. Workspace: {Workspace}.", + options.WorkspaceId); + HandleException(context, ex); + } + + return context.Response; + } + + public sealed record ShortcutResetCacheCommandResult(string Message); +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/FabricOneLakeSetup.cs b/tools/Fabric.Mcp.Tools.OneLake/src/FabricOneLakeSetup.cs index ccb1f7235d..efd388ff17 100644 --- a/tools/Fabric.Mcp.Tools.OneLake/src/FabricOneLakeSetup.cs +++ b/tools/Fabric.Mcp.Tools.OneLake/src/FabricOneLakeSetup.cs @@ -3,6 +3,9 @@ using Fabric.Mcp.Tools.OneLake.Commands.File; using Fabric.Mcp.Tools.OneLake.Commands.Item; +using Fabric.Mcp.Tools.OneLake.Commands.Security; +using Fabric.Mcp.Tools.OneLake.Commands.Settings; +using Fabric.Mcp.Tools.OneLake.Commands.Shortcut; using Fabric.Mcp.Tools.OneLake.Commands.Table; using Fabric.Mcp.Tools.OneLake.Commands.Workspace; using Fabric.Mcp.Tools.OneLake.Services; @@ -51,6 +54,31 @@ public void ConfigureServices(IServiceCollection services) services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + + // Register data access security commands + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register shortcut commands + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Register settings commands + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); } public CommandGroup RegisterCommands(IServiceProvider serviceProvider) @@ -83,6 +111,31 @@ Microsoft Fabric OneLake Operations - Manage and interact with OneLake data lake fabricOneLake.AddCommand(serviceProvider); fabricOneLake.AddCommand(serviceProvider); + // Register data access security commands + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + + // Register shortcut commands + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + + // Register settings commands + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + fabricOneLake.AddCommand(serviceProvider); + return fabricOneLake; } } diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Models/DataAccessRoleModels.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Models/DataAccessRoleModels.cs new file mode 100644 index 0000000000..61b0200446 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Models/DataAccessRoleModels.cs @@ -0,0 +1,171 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Fabric.Mcp.Tools.OneLake.Models; + +/// +/// Represents a data access role defined on a OneLake item. +/// +public class DataAccessRole +{ + [JsonPropertyName("name")] + public string Name { get; set; } = string.Empty; + + [JsonPropertyName("id")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Id { get; set; } + + [JsonPropertyName("eTag")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ETag { get; set; } + + [JsonPropertyName("kind")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Kind { get; set; } + + [JsonPropertyName("decisionRules")] + public List? DecisionRules { get; set; } + + [JsonPropertyName("members")] + public DataAccessRoleMembers? Members { get; set; } +} + +/// +/// Decision rule within a data access role defining access permissions. +/// +public class DecisionRule +{ + [JsonPropertyName("effect")] + public string? Effect { get; set; } + + [JsonPropertyName("permission")] + public List? Permission { get; set; } + + [JsonPropertyName("constraints")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public Constraints? Constraints { get; set; } +} + +/// +/// Row and column constraints for a decision rule (row/column level security). +/// +public class Constraints +{ + [JsonPropertyName("columns")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Columns { get; set; } + + [JsonPropertyName("rows")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? Rows { get; set; } +} + +/// +/// Column-level security constraint for a specific table. +/// +public class ColumnConstraint +{ + [JsonPropertyName("tablePath")] + public string? TablePath { get; set; } + + [JsonPropertyName("columnNames")] + public List? ColumnNames { get; set; } + + [JsonPropertyName("columnEffect")] + public string? ColumnEffect { get; set; } + + [JsonPropertyName("columnAction")] + public List? ColumnAction { get; set; } +} + +/// +/// Row-level security constraint for a specific table using a T-SQL predicate. +/// +public class RowConstraint +{ + [JsonPropertyName("tablePath")] + public string? TablePath { get; set; } + + [JsonPropertyName("value")] + public string? Value { get; set; } +} + +/// +/// Scope definition for a decision rule permission. +/// +public class DecisionRuleScope +{ + [JsonPropertyName("attributeName")] + public string? AttributeName { get; set; } + + [JsonPropertyName("attributeValueIncludedIn")] + public List? AttributeValueIncludedIn { get; set; } +} + +/// +/// Members of a data access role. +/// +public class DataAccessRoleMembers +{ + [JsonPropertyName("fabricItemMembers")] + public List? FabricItemMembers { get; set; } + + [JsonPropertyName("microsoftEntraMembers")] + public List? MicrosoftEntraMembers { get; set; } +} + +/// +/// A Fabric item member in a data access role. +/// +public class FabricItemMember +{ + [JsonPropertyName("sourcePath")] + public string? SourcePath { get; set; } + + [JsonPropertyName("itemAccess")] + public List? ItemAccess { get; set; } +} + +/// +/// A Microsoft Entra member in a data access role. +/// +public class MicrosoftEntraMember +{ + [JsonPropertyName("objectId")] + public string? ObjectId { get; set; } + + [JsonPropertyName("objectType")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ObjectType { get; set; } + + [JsonPropertyName("tenantId")] + public string? TenantId { get; set; } +} + +/// +/// Response from the List Data Access Roles API. +/// +public class DataAccessRoleListResponse +{ + [JsonPropertyName("value")] + public List? Value { get; set; } + + [JsonPropertyName("continuationToken")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContinuationToken { get; set; } + + [JsonPropertyName("continuationUri")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ContinuationUri { get; set; } +} + +/// +/// Request body for the PUT Data Access Roles API (bulk replace). Only 'value' is accepted. +/// +public class DataAccessRolePutRequest +{ + [JsonPropertyName("value")] + public List Value { get; set; } = []; +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Models/LroModels.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Models/LroModels.cs new file mode 100644 index 0000000000..d19c2d0fd4 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Models/LroModels.cs @@ -0,0 +1,40 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Fabric.Mcp.Tools.OneLake.Models; + +/// +/// Describes the current state of a Fabric long running operation. +/// Returned by GET /v1/operations/{operationId}. +/// +public class OperationState +{ + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("percentComplete")] + public int? PercentComplete { get; set; } + + [JsonPropertyName("createdTimeUtc")] + public string? CreatedTimeUtc { get; set; } + + [JsonPropertyName("lastUpdatedTimeUtc")] + public string? LastUpdatedTimeUtc { get; set; } + + [JsonPropertyName("error")] + public OperationError? Error { get; set; } +} + +/// +/// Error details returned when a long running operation fails. +/// +public class OperationError +{ + [JsonPropertyName("errorCode")] + public string? ErrorCode { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Models/OneLakeJsonContext.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Models/OneLakeJsonContext.cs index 7c336dfddd..9675ea0714 100644 --- a/tools/Fabric.Mcp.Tools.OneLake/src/Models/OneLakeJsonContext.cs +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Models/OneLakeJsonContext.cs @@ -5,6 +5,9 @@ using System.Text.Json.Serialization; using Fabric.Mcp.Tools.OneLake.Commands.File; using Fabric.Mcp.Tools.OneLake.Commands.Item; +using Fabric.Mcp.Tools.OneLake.Commands.Security; +using Fabric.Mcp.Tools.OneLake.Commands.Settings; +using Fabric.Mcp.Tools.OneLake.Commands.Shortcut; using Fabric.Mcp.Tools.OneLake.Commands.Table; using Fabric.Mcp.Tools.OneLake.Commands.Workspace; @@ -62,5 +65,55 @@ namespace Fabric.Mcp.Tools.OneLake.Models; [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] [JsonSerializable(typeof(List))] +// Data Access Security types +[JsonSerializable(typeof(DataAccessRole))] +[JsonSerializable(typeof(DataAccessRoleListResponse))] +[JsonSerializable(typeof(DataAccessRolePutRequest))] +[JsonSerializable(typeof(DataAccessRoleMembers))] +[JsonSerializable(typeof(DecisionRule))] +[JsonSerializable(typeof(DecisionRuleScope))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(Constraints))] +[JsonSerializable(typeof(ColumnConstraint))] +[JsonSerializable(typeof(RowConstraint))] +[JsonSerializable(typeof(FabricItemMember))] +[JsonSerializable(typeof(MicrosoftEntraMember))] +[JsonSerializable(typeof(DataAccessRoleDeleteCommand.DataAccessRoleDeleteCommandResult))] +// Shortcut types +[JsonSerializable(typeof(OneLakeShortcut))] +[JsonSerializable(typeof(ShortcutTarget))] +[JsonSerializable(typeof(OneLakeShortcutTarget))] +[JsonSerializable(typeof(AdlsGen2ShortcutTarget))] +[JsonSerializable(typeof(AmazonS3ShortcutTarget))] +[JsonSerializable(typeof(GoogleCloudStorageShortcutTarget))] +[JsonSerializable(typeof(DataverseShortcutTarget))] +[JsonSerializable(typeof(S3CompatibleShortcutTarget))] +[JsonSerializable(typeof(ExternalDataShareShortcutTarget))] +[JsonSerializable(typeof(AzureBlobStorageShortcutTarget))] +[JsonSerializable(typeof(OneDriveSharePointShortcutTarget))] +[JsonSerializable(typeof(ShortcutListResponse))] +[JsonSerializable(typeof(BulkCreateShortcutsRequest))] +[JsonSerializable(typeof(CreateShortcutWithTransformRequest))] +[JsonSerializable(typeof(CsvToDeltaTransform))] +[JsonSerializable(typeof(CsvToDeltaTransformProperties))] +[JsonSerializable(typeof(BulkCreateShortcutResponse))] +[JsonSerializable(typeof(CreateShortcutResponse))] +[JsonSerializable(typeof(ShortcutRequestInfo))] +[JsonSerializable(typeof(ShortcutCreateError))] +[JsonSerializable(typeof(ShortcutDeleteCommand.ShortcutDeleteCommandResult))] +[JsonSerializable(typeof(ShortcutResetCacheCommand.ShortcutResetCacheCommandResult))] +[JsonSerializable(typeof(DiagnosticsModifyCommand.DiagnosticsModifyCommandResult))] +[JsonSerializable(typeof(ImmutabilityPolicyModifyCommand.ImmutabilityPolicyModifyCommandResult))] +// Settings types +[JsonSerializable(typeof(OneLakeSettings))] +[JsonSerializable(typeof(OneLakeDiagnosticSettings))] +[JsonSerializable(typeof(LakehouseDiagnosticDestination))] +[JsonSerializable(typeof(ItemReferenceById))] +[JsonSerializable(typeof(ImmutabilityPolicy))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(LifecycleSettings))] +// Long running operation types +[JsonSerializable(typeof(OperationState))] +[JsonSerializable(typeof(OperationError))] [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase, WriteIndented = true)] internal partial class OneLakeJsonContext : JsonSerializerContext; diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Models/SettingsModels.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Models/SettingsModels.cs new file mode 100644 index 0000000000..81c9efe5f3 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Models/SettingsModels.cs @@ -0,0 +1,84 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Fabric.Mcp.Tools.OneLake.Models; + +/// GET /workspaces/{id}/onelake/settings response (swagger: GetOneLakeSettingsResponse). +public class OneLakeSettings +{ + [JsonPropertyName("diagnostics")] + public OneLakeDiagnosticSettings? Diagnostics { get; set; } + + /// Swagger field is plural "immutabilityPolicies" and is an array. + [JsonPropertyName("immutabilityPolicies")] + public List? ImmutabilityPolicies { get; set; } + + [JsonPropertyName("lifecycle")] + public LifecycleSettings? Lifecycle { get; set; } +} + +/// +/// Body of POST /onelake/settings/modifyDiagnostics AND the diagnostics block in GET. +/// Swagger: OneLakeDiagnosticSettings. +/// +public class OneLakeDiagnosticSettings +{ + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// Required when Status == Enabled; omitted when Disabled. + [JsonPropertyName("destination")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public LakehouseDiagnosticDestination? Destination { get; set; } +} + +/// +/// Swagger: LakehouseOneLakeDiagnosticSettingsDestination (discriminator type="Lakehouse"). +/// Single-variant today — promote to polymorphic when Fabric adds another destination type. +/// +public sealed class LakehouseDiagnosticDestination +{ + [JsonPropertyName("type")] + public string Type { get; set; } = "Lakehouse"; + + [JsonPropertyName("lakehouse")] + public ItemReferenceById? Lakehouse { get; set; } +} + +/// Swagger: ItemReferenceById (discriminator referenceType="ById"). +public sealed class ItemReferenceById +{ + [JsonPropertyName("referenceType")] + public string ReferenceType { get; set; } = "ById"; + + [JsonPropertyName("itemId")] + public string? ItemId { get; set; } + + [JsonPropertyName("workspaceId")] + public string? WorkspaceId { get; set; } +} + +/// +/// Body of POST /onelake/settings/modifyImmutabilityPolicy AND items in GET response. +/// Swagger: ImmutabilityPolicyRequest / ImmutabilityPolicy. +/// +public class ImmutabilityPolicy +{ + [JsonPropertyName("scope")] + public string? Scope { get; set; } + + [JsonPropertyName("retentionDays")] + public int? RetentionDays { get; set; } +} + +/// Lifecycle management settings for a workspace. +public class LifecycleSettings +{ + [JsonPropertyName("defaultTier")] + public string? DefaultTier { get; set; } + + [JsonPropertyName("policy")] + public string? Policy { get; set; } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Models/ShortcutModels.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Models/ShortcutModels.cs new file mode 100644 index 0000000000..b563210de8 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Models/ShortcutModels.cs @@ -0,0 +1,323 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Fabric.Mcp.Tools.OneLake.Models; + +/// +/// Represents a OneLake shortcut. +/// +public class OneLakeShortcut +{ + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("target")] + public ShortcutTarget? Target { get; set; } +} + +/// +/// Target configuration for a shortcut. +/// +public class ShortcutTarget +{ + /// + /// Discriminator returned by GET responses. Must NOT be sent on create/update requests. + /// + [JsonPropertyName("type")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? Type { get; set; } + + [JsonPropertyName("oneLake")] + public OneLakeShortcutTarget? OneLake { get; set; } + + [JsonPropertyName("adlsGen2")] + public AdlsGen2ShortcutTarget? AdlsGen2 { get; set; } + + [JsonPropertyName("amazonS3")] + public AmazonS3ShortcutTarget? AmazonS3 { get; set; } + + [JsonPropertyName("googleCloudStorage")] + public GoogleCloudStorageShortcutTarget? GoogleCloudStorage { get; set; } + + [JsonPropertyName("dataverse")] + public DataverseShortcutTarget? Dataverse { get; set; } + + [JsonPropertyName("s3Compatible")] + public S3CompatibleShortcutTarget? S3Compatible { get; set; } + + [JsonPropertyName("externalDataShare")] + public ExternalDataShareShortcutTarget? ExternalDataShare { get; set; } + + [JsonPropertyName("azureBlobStorage")] + public AzureBlobStorageShortcutTarget? AzureBlobStorage { get; set; } + + [JsonPropertyName("oneDriveSharePoint")] + public OneDriveSharePointShortcutTarget? OneDriveSharePoint { get; set; } +} + +/// +/// OneLake shortcut target pointing to another OneLake location. +/// +public class OneLakeShortcutTarget +{ + [JsonPropertyName("workspaceId")] + public string? WorkspaceId { get; set; } + + [JsonPropertyName("itemId")] + public string? ItemId { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("connectionId")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? ConnectionId { get; set; } +} + +/// +/// ADLS Gen2 shortcut target. +/// +public class AdlsGen2ShortcutTarget +{ + [JsonPropertyName("location")] + public string? Location { get; set; } + + [JsonPropertyName("subpath")] + public string? Subpath { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } +} + +/// +/// Amazon S3 shortcut target. +/// +public class AmazonS3ShortcutTarget +{ + [JsonPropertyName("location")] + public string? Location { get; set; } + + [JsonPropertyName("subpath")] + public string? Subpath { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } +} + +/// +/// Google Cloud Storage shortcut target. +/// +public class GoogleCloudStorageShortcutTarget +{ + [JsonPropertyName("location")] + public string? Location { get; set; } + + [JsonPropertyName("subpath")] + public string? Subpath { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } +} + +/// +/// Dataverse shortcut target. +/// +public class DataverseShortcutTarget +{ + [JsonPropertyName("environmentDomain")] + public string? EnvironmentDomain { get; set; } + + [JsonPropertyName("deltaLakeFolder")] + public string? DeltaLakeFolder { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } +} + +/// +/// S3-compatible shortcut target. +/// +public class S3CompatibleShortcutTarget +{ + [JsonPropertyName("location")] + public string? Location { get; set; } + + [JsonPropertyName("subpath")] + public string? Subpath { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } + + [JsonPropertyName("bucket")] + public string? Bucket { get; set; } +} + +/// +/// External data share shortcut target. +/// +public class ExternalDataShareShortcutTarget +{ + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } +} + +/// +/// Azure Blob Storage shortcut target. +/// +public class AzureBlobStorageShortcutTarget +{ + [JsonPropertyName("location")] + public string? Location { get; set; } + + [JsonPropertyName("subpath")] + public string? Subpath { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } +} + +/// +/// OneDrive / SharePoint Online shortcut target. +/// +public class OneDriveSharePointShortcutTarget +{ + [JsonPropertyName("location")] + public string? Location { get; set; } + + [JsonPropertyName("subpath")] + public string? Subpath { get; set; } + + [JsonPropertyName("connectionId")] + public string? ConnectionId { get; set; } + + [JsonPropertyName("updateFabricItemSensitivity")] + public bool? UpdateFabricItemSensitivity { get; set; } +} + +/// +/// Response from the List Shortcuts API. +/// +public class ShortcutListResponse +{ + [JsonPropertyName("value")] + public List? Value { get; set; } + + [JsonPropertyName("continuationToken")] + public string? ContinuationToken { get; set; } + + [JsonPropertyName("continuationUri")] + public string? ContinuationUri { get; set; } +} + +/// +/// Request body for the bulk create shortcuts API (POST /shortcuts/bulkCreate). +/// +public class BulkCreateShortcutsRequest +{ + [JsonPropertyName("createShortcutRequests")] + public List? CreateShortcutRequests { get; set; } +} + +/// +/// A single shortcut creation request (with optional transform). +/// +public class CreateShortcutWithTransformRequest +{ + [JsonPropertyName("path")] + public string? Path { get; set; } + + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("target")] + public ShortcutTarget? Target { get; set; } + + [JsonPropertyName("transform")] + public CsvToDeltaTransform? Transform { get; set; } +} + +/// +/// CSV-to-Delta transform definition. +/// +public class CsvToDeltaTransform +{ + [JsonPropertyName("type")] + public string? Type { get; set; } + + [JsonPropertyName("includeSubfolders")] + public bool? IncludeSubfolders { get; set; } + + [JsonPropertyName("properties")] + public CsvToDeltaTransformProperties? Properties { get; set; } +} + +/// +/// Properties for the CSV-to-Delta transform. +/// +public class CsvToDeltaTransformProperties +{ + [JsonPropertyName("delimiter")] + public string? Delimiter { get; set; } + + [JsonPropertyName("skipFilesWithErrors")] + public bool? SkipFilesWithErrors { get; set; } + + [JsonPropertyName("useFirstRowAsHeader")] + public bool? UseFirstRowAsHeader { get; set; } +} + +/// +/// Response from POST /shortcuts/bulkCreate. +/// +public class BulkCreateShortcutResponse +{ + [JsonPropertyName("value")] + public List? Value { get; set; } +} + +/// +/// Per-shortcut result in a bulk create response. +/// +public class CreateShortcutResponse +{ + [JsonPropertyName("request")] + public ShortcutRequestInfo? Request { get; set; } + + [JsonPropertyName("result")] + public OneLakeShortcut? Result { get; set; } + + [JsonPropertyName("status")] + public string? Status { get; set; } + + [JsonPropertyName("error")] + public ShortcutCreateError? Error { get; set; } +} + +/// +/// Original name/path echoed back in a bulk create response item. +/// +public class ShortcutRequestInfo +{ + [JsonPropertyName("name")] + public string? Name { get; set; } + + [JsonPropertyName("path")] + public string? Path { get; set; } +} + +/// +/// Error details for a failed shortcut in a bulk create response. +/// +public class ShortcutCreateError +{ + [JsonPropertyName("errorCode")] + public string? ErrorCode { get; set; } + + [JsonPropertyName("message")] + public string? Message { get; set; } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleCreateOrUpdateOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleCreateOrUpdateOptions.cs new file mode 100644 index 0000000000..284c5e340c --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleCreateOrUpdateOptions.cs @@ -0,0 +1,19 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class DataAccessRoleCreateOrUpdateOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? ItemId { get; set; } + public string? RoleDefinition { get; set; } + public string? Name { get; set; } + public string? EntraMembers { get; set; } + public string? FabricItemMembers { get; set; } + public string? PermittedPaths { get; set; } + public string? PermittedActions { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleDeleteOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleDeleteOptions.cs new file mode 100644 index 0000000000..ad4a0e5805 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleDeleteOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class DataAccessRoleDeleteOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? ItemId { get; set; } + public string? RoleName { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleGetOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleGetOptions.cs new file mode 100644 index 0000000000..1a983a5bd1 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleGetOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class DataAccessRoleGetOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? ItemId { get; set; } + public string? RoleName { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleListOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleListOptions.cs new file mode 100644 index 0000000000..b0971180be --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DataAccessRoleListOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class DataAccessRoleListOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? ItemId { get; set; } + public string? ContinuationToken { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/DiagnosticsModifyOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DiagnosticsModifyOptions.cs new file mode 100644 index 0000000000..b306e6383a --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/DiagnosticsModifyOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class DiagnosticsModifyOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? Status { get; set; } + public string? DestinationLakehouseWorkspaceId { get; set; } + public string? DestinationLakehouseItemId { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/FabricOptionDefinitions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/FabricOptionDefinitions.cs index d1f1576b3c..d17ecc0728 100644 --- a/tools/Fabric.Mcp.Tools.OneLake/src/Options/FabricOptionDefinitions.cs +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/FabricOptionDefinitions.cs @@ -98,7 +98,7 @@ public static class FabricOptionDefinitions }; public const string LocalFilePathName = "local-file-path"; - public static readonly Option LocalFilePath = new($"--{LocalFilePathName}") + public static readonly Option LocalFilePath = new($"--{LocalFilePathName}", "--local-path") { Description = "The path to a local file to upload.", Required = false @@ -178,4 +178,231 @@ public static class FabricOptionDefinitions Description = "The table name exposed by the OneLake table API.", Required = true }; + + // Data access security options + public const string RoleNameName = "role-name"; + public static readonly Option RoleName = new($"--{RoleNameName}") + { + Description = "The name of the data access role.", + Required = true + }; + + public const string RoleDefinitionName = "role-definition"; + public static readonly Option RoleDefinition = new($"--{RoleDefinitionName}") + { + Description = """ + JSON definition of the data access role. Must include 'name', 'members' + (with microsoftEntraMembers), and 'decisionRules'. + members.microsoftEntraMembers[].objectId accepts EITHER an Entra object ID + (GUID) OR an email address / UPN — non-GUID values are automatically + resolved to object IDs via Microsoft Graph (tries /users first, then + /groups by mail, so mail-enabled groups and DLs work too). Do NOT call + Graph yourself to convert emails to GUIDs first; pass the email or UPN + directly. tenantId may be omitted — it is filled in during resolution. + To scope access to a specific folder, include a Path attribute in + decisionRules. Omitting Path grants access to the entire item. + Example with emails (preferred when you know the address, not the GUID): + {"name":"ImagesReadOnly", + "members":{"microsoftEntraMembers":[ + {"objectId":"alice@contoso.com"}, + {"objectId":"data-readers@contoso.com"}]}, + "decisionRules":[{"effect":"Permit","permission":[ + {"attributeName":"Action","attributeValueIncludedIn":["Read"]}, + {"attributeName":"Path","attributeValueIncludedIn":["Files/images/*"]}]}]} + Example with GUIDs (use when you already have the object ID): + {"name":"ImagesReadOnly", + "members":{"microsoftEntraMembers":[ + {"objectId":"514402e2-4238-4672-b021-ff9000307b66"}]}, + "decisionRules":[{"effect":"Permit","permission":[ + {"attributeName":"Action","attributeValueIncludedIn":["Read"]}]}]} + """, + Required = false + }; + + public const string EntraMembersName = "entra-members"; + public static readonly Option EntraMembers = new($"--{EntraMembersName}") + { + Description = "Comma-separated Entra member identifiers (object IDs, emails, or UPNs). Non-GUID values are resolved via Microsoft Graph.", + Required = false + }; + + public const string FabricItemMembersName = "fabric-item-members"; + public static readonly Option FabricItemMembers = new($"--{FabricItemMembersName}") + { + Description = "Comma-separated Fabric item member references in format 'itemId:permission' (e.g. 'dfbe1234-...:Read').", + Required = false + }; + + public const string PermittedPathsName = "permitted-paths"; + public static readonly Option PermittedPaths = new($"--{PermittedPathsName}") + { + Description = "Comma-separated paths to grant access to (e.g. 'Files/images/*,Tables/sales'). Omit to grant access to the entire item.", + Required = false + }; + + public const string PermittedActionsName = "permitted-actions"; + public static readonly Option PermittedActions = new($"--{PermittedActionsName}") + { + Description = "Comma-separated actions to permit. Currently only 'Read' is supported. Defaults to 'Read' if omitted.", + Required = false + }; + + // Shortcut options + public const string ShortcutNameName = "shortcut-name"; + public static readonly Option ShortcutName = new($"--{ShortcutNameName}") + { + Description = "The name of the shortcut.", + Required = true + }; + + public const string ShortcutPathName = "shortcut-path"; + public static readonly Option ShortcutPath = new($"--{ShortcutPathName}") + { + Description = "The path of the shortcut within the item.", + Required = true + }; + + public const string ParentPathName = "parent-path"; + public static readonly Option ParentPath = new($"--{ParentPathName}") + { + Description = "The parent path under which to list shortcuts.", + Required = false + }; + + public const string ShortcutConflictPolicyName = "shortcut-conflict-policy"; + public static readonly Option ShortcutConflictPolicy = CreateShortcutConflictPolicyOption(); + + private static Option CreateShortcutConflictPolicyOption() + { + var option = new Option($"--{ShortcutConflictPolicyName}") + { + Description = "Action when a shortcut with the same name and path already exists. Default: Abort.", + Required = false + }; + option.AcceptOnlyFromAmong("Abort", "CreateOrOverwrite", "OverwriteOnly", "GenerateUniqueName"); + return option; + } + + // Settings options + // Diagnostics flat options + public const string DiagnosticsStatusName = "status"; + public static readonly Option DiagnosticsStatus = new($"--{DiagnosticsStatusName}") + { + Description = "The status of diagnostics: Enabled or Disabled.", + Required = true + }; + + public const string DestinationLakehouseWorkspaceIdName = "destination-lakehouse-workspace-id"; + public static readonly Option DestinationLakehouseWorkspaceId = new($"--{DestinationLakehouseWorkspaceIdName}") + { + Description = "The workspace ID (GUID) of the destination lakehouse for diagnostic logs. Required when --status is Enabled.", + Required = false + }; + + public const string DestinationLakehouseItemIdName = "destination-lakehouse-item-id"; + public static readonly Option DestinationLakehouseItemId = new($"--{DestinationLakehouseItemIdName}") + { + Description = "The item ID (GUID) of the destination lakehouse for diagnostic logs. Required when --status is Enabled.", + Required = false + }; + + // Immutability policy flat options + public const string ImmutabilityScopeName = "scope"; + public static readonly Option ImmutabilityScope = new($"--{ImmutabilityScopeName}") + { + Description = "The scope of the immutability policy. Currently only 'DiagnosticLogs' is supported.", + Required = true + }; + + public const string RetentionDaysName = "retention-days"; + public static readonly Option RetentionDays = new($"--{RetentionDaysName}") + { + Description = "Number of days to retain diagnostic logs (minimum 1). Cannot be reduced below the current value.", + Required = true + }; + + // Shortcut target options + public const string TargetWorkspaceIdName = "target-workspace-id"; + public static readonly Option TargetWorkspaceId = new($"--{TargetWorkspaceIdName}") + { + Description = "The workspace ID (GUID) of the target OneLake item.", + Required = true + }; + + public const string TargetItemIdName = "target-item-id"; + public static readonly Option TargetItemId = new($"--{TargetItemIdName}") + { + Description = "The item ID (GUID) of the target OneLake item.", + Required = true + }; + + public const string TargetPathName = "target-path"; + public static readonly Option TargetPath = new($"--{TargetPathName}") + { + Description = "The path within the target item (e.g. 'Files/data').", + Required = false + }; + + public const string TargetLocationName = "target-location"; + public static readonly Option TargetLocation = new($"--{TargetLocationName}") + { + Description = "The target storage URL (e.g. 'https://myaccount.dfs.core.windows.net/container').", + Required = true + }; + + public const string TargetSubpathName = "target-subpath"; + public static readonly Option TargetSubpath = new($"--{TargetSubpathName}") + { + Description = "The subpath within the target storage location.", + Required = false + }; + + public const string TargetConnectionIdName = "target-connection-id"; + public static readonly Option TargetConnectionId = new($"--{TargetConnectionIdName}") + { + Description = "The connection ID (GUID) for authenticating to the target.", + Required = true + }; + + public const string TargetBucketName = "target-bucket"; + public static readonly Option TargetBucket = new($"--{TargetBucketName}") + { + Description = "The bucket name for S3-compatible targets.", + Required = true + }; + + public const string TargetEnvironmentDomainName = "target-environment-domain"; + public static readonly Option TargetEnvironmentDomain = new($"--{TargetEnvironmentDomainName}") + { + Description = "The Dataverse environment domain URI (e.g. 'https://orgname.crm.dynamics.com').", + Required = true + }; + + public const string TargetDeltaLakeFolderName = "target-deltalake-folder"; + public static readonly Option TargetDeltaLakeFolder = new($"--{TargetDeltaLakeFolderName}") + { + Description = "The Delta Lake folder path in Dataverse.", + Required = true + }; + + public const string TargetTableNameOptionName = "target-table-name"; + public static readonly Option TargetTableName = new($"--{TargetTableNameOptionName}") + { + Description = "The Dataverse table name.", + Required = false + }; + + public const string TargetUpdateFabricItemSensitivityName = "target-update-fabric-item-sensitivity"; + public static readonly Option TargetUpdateFabricItemSensitivity = new($"--{TargetUpdateFabricItemSensitivityName}") + { + Description = "Whether to update Fabric item sensitivity from OneDrive/SharePoint. Default: false.", + Required = false + }; + + public const string IncludeManagedName = "include-managed"; + public static readonly Option IncludeManaged = new($"--{IncludeManagedName}") + { + Description = "Include DW-managed shortcuts in the results. Default: false (managed shortcuts are hidden to avoid overwhelming output).", + Required = false + }; } diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/ImmutabilityPolicyModifyOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ImmutabilityPolicyModifyOptions.cs new file mode 100644 index 0000000000..709ba45bed --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ImmutabilityPolicyModifyOptions.cs @@ -0,0 +1,14 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class ImmutabilityPolicyModifyOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? Scope { get; set; } + public int? RetentionDays { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/SettingsGetOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/SettingsGetOptions.cs new file mode 100644 index 0000000000..d7434f053e --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/SettingsGetOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class SettingsGetOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutCreateOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutCreateOptions.cs new file mode 100644 index 0000000000..e595ad5268 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutCreateOptions.cs @@ -0,0 +1,26 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class ShortcutCreateOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? ItemId { get; set; } + public string? Path { get; set; } + public string? Name { get; set; } + public string? ConflictPolicy { get; set; } + public string? TargetWorkspaceId { get; set; } + public string? TargetItemId { get; set; } + public string? TargetPath { get; set; } + public string? TargetLocation { get; set; } + public string? TargetSubpath { get; set; } + public string? TargetConnectionId { get; set; } + public string? TargetBucket { get; set; } + public string? TargetEnvironmentDomain { get; set; } + public string? TargetDeltaLakeFolder { get; set; } + public string? TargetTableName { get; set; } + public bool TargetUpdateFabricItemSensitivity { get; set; } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutDeleteOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutDeleteOptions.cs new file mode 100644 index 0000000000..e85afb81f5 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutDeleteOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class ShortcutDeleteOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? ItemId { get; set; } + public string? ShortcutName { get; set; } + public string? ShortcutPath { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutGetOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutGetOptions.cs new file mode 100644 index 0000000000..befe963f94 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutGetOptions.cs @@ -0,0 +1,15 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class ShortcutGetOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? ItemId { get; set; } + public string? ShortcutName { get; set; } + public string? ShortcutPath { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutListOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutListOptions.cs new file mode 100644 index 0000000000..53e3f4f9c1 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutListOptions.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class ShortcutListOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } + public string? ItemId { get; set; } + public string? ParentPath { get; set; } + public string? ContinuationToken { get; set; } + public bool IncludeManaged { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutResetCacheOptions.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutResetCacheOptions.cs new file mode 100644 index 0000000000..aa2bd0a77d --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Options/ShortcutResetCacheOptions.cs @@ -0,0 +1,12 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Mcp.Core.Options; + +namespace Fabric.Mcp.Tools.OneLake.Options; + +public sealed class ShortcutResetCacheOptions : GlobalOptions +{ + public string? WorkspaceId { get; set; } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Services/IOneLakeService.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Services/IOneLakeService.cs index f80dd8e7a2..d2f3edb12a 100644 --- a/tools/Fabric.Mcp.Tools.OneLake/src/Services/IOneLakeService.cs +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Services/IOneLakeService.cs @@ -46,4 +46,23 @@ public interface IOneLakeService Task GetTableNamespaceAsync(string workspaceIdentifier, string itemIdentifier, string namespaceName, CancellationToken cancellationToken = default); Task ListTablesAsync(string workspaceIdentifier, string itemIdentifier, string namespaceName, CancellationToken cancellationToken = default); Task GetTableAsync(string workspaceIdentifier, string itemIdentifier, string namespaceName, string tableName, CancellationToken cancellationToken = default); + + // Data Access Security Operations + Task ListDataAccessRolesAsync(string workspaceId, string itemId, string? continuationToken = null, CancellationToken cancellationToken = default); + Task GetDataAccessRoleAsync(string workspaceId, string itemId, string roleName, CancellationToken cancellationToken = default); + Task CreateOrUpdateDataAccessRoleAsync(string workspaceId, string itemId, string roleDefinitionJson, CancellationToken cancellationToken = default); + Task DeleteDataAccessRoleAsync(string workspaceId, string itemId, string roleName, CancellationToken cancellationToken = default); + + // Shortcut Operations + Task ListShortcutsAsync(string workspaceId, string itemId, string? parentPath = null, string? continuationToken = null, CancellationToken cancellationToken = default); + Task GetShortcutAsync(string workspaceId, string itemId, string shortcutPath, string shortcutName, CancellationToken cancellationToken = default); + Task CreateOrUpdateShortcutsAsync(string workspaceId, string itemId, string shortcutsJson, string? shortcutConflictPolicy = null, CancellationToken cancellationToken = default); + Task CreateShortcutAsync(string workspaceId, string itemId, OneLakeShortcut shortcut, string? shortcutConflictPolicy = null, CancellationToken cancellationToken = default); + Task DeleteShortcutAsync(string workspaceId, string itemId, string shortcutPath, string shortcutName, CancellationToken cancellationToken = default); + Task ResetShortcutCacheAsync(string workspaceId, CancellationToken cancellationToken = default); + + // Settings Operations + Task GetSettingsAsync(string workspaceId, CancellationToken cancellationToken = default); + Task ModifyDiagnosticsAsync(string workspaceId, OneLakeDiagnosticSettings settings, CancellationToken cancellationToken = default); + Task ModifyImmutabilityPolicyAsync(string workspaceId, ImmutabilityPolicy policy, CancellationToken cancellationToken = default); } diff --git a/tools/Fabric.Mcp.Tools.OneLake/src/Services/OneLakeService.cs b/tools/Fabric.Mcp.Tools.OneLake/src/Services/OneLakeService.cs index d55ab423e7..89e646a120 100644 --- a/tools/Fabric.Mcp.Tools.OneLake/src/Services/OneLakeService.cs +++ b/tools/Fabric.Mcp.Tools.OneLake/src/Services/OneLakeService.cs @@ -232,34 +232,10 @@ public async Task> ListBlobsAsync(string workspaceI return await ListBlobsIntelligentAsync(normalizedWorkspaceId, normalizedItemId, recursive, cancellationToken); } - // Use the OneLake blob endpoint to list files for specific path - var url = $"{OneLakeEndpoints.OneLakeDataPlaneBaseUrl}/{normalizedWorkspaceId}/{normalizedItemId}"; - - // If path is specified, check if it's a top-level folder (Tables, Files, etc.) - // or a sub-path within Files - var trimmedPath = path.TrimStart('/'); - if (trimmedPath.StartsWith("Files/", StringComparison.OrdinalIgnoreCase)) - { - // Path already includes Files prefix - url += $"/{trimmedPath}"; - } - else if (trimmedPath.Equals("Files", StringComparison.OrdinalIgnoreCase)) - { - // Explicitly requesting Files folder - url += "/Files"; - } - else if (IsTopLevelFolder(trimmedPath)) - { - // Top-level folder like Tables, Files, etc. - url += $"/{trimmedPath}"; - } - else - { - // Assume it's a sub-path within Files for backward compatibility - url += $"/Files/{trimmedPath}"; - } - - url += $"?restype=container&comp=list"; + // Blob API: path is part of the URL path per Blob Storage List Blobs spec + var directory = ResolveDirectoryPath(path); + var url = $"{OneLakeEndpoints.OneLakeDataPlaneBaseUrl}/{normalizedWorkspaceId}/{normalizedItemId}/{directory}"; + url += "?restype=container&comp=list"; if (recursive) { url += "&recursive=true"; @@ -600,8 +576,7 @@ public async Task> ListPathIntelligentAsync(string workspac { try { - var url = $"{OneLakeEndpoints.OneLakeDataPlaneDfsBaseUrl}/{normalizedWorkspaceId}/{normalizedItemId}/{folder}"; - url += $"?resource=filesystem&recursive={recursive.ToString().ToLowerInvariant()}"; + var url = BuildAdlsListPathUrl(normalizedWorkspaceId, normalizedItemId, directory: folder, recursive: recursive); var response = await SendDataPlaneRequestAsync(HttpMethod.Get, url, cancellationToken: cancellationToken); var content = await response.Content.ReadAsStringAsync(cancellationToken); @@ -719,34 +694,9 @@ public async Task> ListPathAsync(string workspaceId, string return await ListPathIntelligentAsync(normalizedWorkspaceId, normalizedItemId, recursive, cancellationToken); } - // Use ADLS Gen2 filesystem API format instead of blob container format - var url = $"{OneLakeEndpoints.OneLakeDataPlaneDfsBaseUrl}/{normalizedWorkspaceId}/{normalizedItemId}"; - - // If path is specified, check if it's a top-level folder (Tables, Files, etc.) - // or a sub-path within Files - var trimmedPath = path.TrimStart('/'); - if (trimmedPath.StartsWith("Files/", StringComparison.OrdinalIgnoreCase)) - { - // Path already includes Files prefix - url += $"/{trimmedPath}"; - } - else if (trimmedPath.Equals("Files", StringComparison.OrdinalIgnoreCase)) - { - // Explicitly requesting Files folder - url += "/Files"; - } - else if (IsTopLevelFolder(trimmedPath)) - { - // Top-level folder like Tables, Files, etc. - url += $"/{trimmedPath}"; - } - else - { - // Assume it's a sub-path within Files for backward compatibility - url += $"/Files/{trimmedPath}"; - } - - url += $"?resource=filesystem&recursive={recursive.ToString().ToLowerInvariant()}"; + // Build ADLS Gen2 compliant URL with directory as a query parameter + var directory = ResolveDirectoryPath(path); + var url = BuildAdlsListPathUrl(normalizedWorkspaceId, normalizedItemId, directory: directory, recursive: recursive); var response = await SendDataPlaneRequestAsync(HttpMethod.Get, url, cancellationToken: cancellationToken); var content = await response.Content.ReadAsStringAsync(cancellationToken); @@ -894,7 +844,7 @@ public async Task ListBlobsRawAsync(string workspaceId, string itemId, s try { var url = $"{OneLakeEndpoints.OneLakeDataPlaneBaseUrl}/{normalizedWorkspaceId}/{normalizedItemId}/{folder}"; - url += $"?restype=container&comp=list"; + url += "?restype=container&comp=list"; if (recursive) { url += "&recursive=true"; @@ -913,34 +863,10 @@ public async Task ListBlobsRawAsync(string workspaceId, string itemId, s return string.Join("\n\n", allResponses); } - // Use the OneLake blob endpoint to list files for specific path - var singleUrl = $"{OneLakeEndpoints.OneLakeDataPlaneBaseUrl}/{normalizedWorkspaceId}/{normalizedItemId}"; - - // If path is specified, check if it's a top-level folder (Tables, Files, etc.) - // or a sub-path within Files - var trimmedPath = path.TrimStart('/'); - if (trimmedPath.StartsWith("Files/", StringComparison.OrdinalIgnoreCase)) - { - // Path already includes Files prefix - singleUrl += $"/{trimmedPath}"; - } - else if (trimmedPath.Equals("Files", StringComparison.OrdinalIgnoreCase)) - { - // Explicitly requesting Files folder - singleUrl += "/Files"; - } - else if (IsTopLevelFolder(trimmedPath)) - { - // Top-level folder like Tables, Files, etc. - singleUrl += $"/{trimmedPath}"; - } - else - { - // Assume it's a sub-path within Files for backward compatibility - singleUrl += $"/Files/{trimmedPath}"; - } - - singleUrl += $"?restype=container&comp=list"; + // Blob API: path is part of the URL path per Blob Storage List Blobs spec + var directory = ResolveDirectoryPath(path); + var singleUrl = $"{OneLakeEndpoints.OneLakeDataPlaneBaseUrl}/{normalizedWorkspaceId}/{normalizedItemId}/{directory}"; + singleUrl += "?restype=container&comp=list"; if (recursive) { singleUrl += "&recursive=true"; @@ -966,8 +892,7 @@ public async Task ListPathRawAsync(string workspaceId, string itemId, st { try { - var url = $"{OneLakeEndpoints.OneLakeDataPlaneDfsBaseUrl}/{normalizedWorkspaceId}/{normalizedItemId}/{folder}"; - url += $"?resource=filesystem&recursive={recursive.ToString().ToLowerInvariant()}"; + var url = BuildAdlsListPathUrl(normalizedWorkspaceId, normalizedItemId, directory: folder, recursive: recursive); var response = await SendDataPlaneRequestAsync(HttpMethod.Get, url, cancellationToken: cancellationToken); var content = await response.Content.ReadAsStringAsync(cancellationToken); @@ -982,34 +907,9 @@ public async Task ListPathRawAsync(string workspaceId, string itemId, st return string.Join("\n\n", allResponses); } - // Use ADLS Gen2 filesystem API format instead of blob container format - var singleUrl = $"{OneLakeEndpoints.OneLakeDataPlaneDfsBaseUrl}/{normalizedWorkspaceId}/{normalizedItemId}"; - - // If path is specified, check if it's a top-level folder (Tables, Files, etc.) - // or a sub-path within Files - var trimmedPath = path.TrimStart('/'); - if (trimmedPath.StartsWith("Files/", StringComparison.OrdinalIgnoreCase)) - { - // Path already includes Files prefix - singleUrl += $"/{trimmedPath}"; - } - else if (trimmedPath.Equals("Files", StringComparison.OrdinalIgnoreCase)) - { - // Explicitly requesting Files folder - singleUrl += "/Files"; - } - else if (IsTopLevelFolder(trimmedPath)) - { - // Top-level folder like Tables, Files, etc. - singleUrl += $"/{trimmedPath}"; - } - else - { - // Assume it's a sub-path within Files for backward compatibility - singleUrl += $"/Files/{trimmedPath}"; - } - - singleUrl += $"?resource=filesystem&recursive={recursive.ToString().ToLowerInvariant()}"; + // Build ADLS Gen2 compliant URL with directory as a query parameter + var directory = ResolveDirectoryPath(path); + var singleUrl = BuildAdlsListPathUrl(normalizedWorkspaceId, normalizedItemId, directory: directory, recursive: recursive); var singleResponse = await SendDataPlaneRequestAsync(HttpMethod.Get, singleUrl, cancellationToken: cancellationToken); return await singleResponse.Content.ReadAsStringAsync(cancellationToken); @@ -1651,6 +1551,73 @@ public async Task CreateDirectoryAsync(string workspaceId, string itemId, string } // Private helper methods + + /// + /// Builds an ADLS Gen2 Path List API-compliant URL. + /// Per https://learn.microsoft.com/en-us/rest/api/storageservices/datalakestoragegen2/path/list + /// the directory is a query parameter, not part of the URL path. + /// URL format: GET {dfsBase}/{filesystem}?resource=filesystem&directory={dir}&recursive={recursive} + /// In OneLake, filesystem = workspaceId; directory = itemId/path. + /// + private static string BuildAdlsListPathUrl( + string workspaceId, + string itemId, + string? directory, + bool recursive, + int? maxResults = null, + string? continuation = null, + bool? upn = null) + { + // filesystem = workspaceId + var url = $"{OneLakeEndpoints.OneLakeDataPlaneDfsBaseUrl}/{workspaceId}?resource=filesystem"; + + // Build the directory path: itemId + optional subdirectory + var directoryPath = itemId; + if (!string.IsNullOrEmpty(directory)) + { + directoryPath = $"{itemId}/{directory.TrimStart('/')}"; + } + + url += $"&directory={Uri.EscapeDataString(directoryPath)}"; + url += $"&recursive={recursive.ToString().ToLowerInvariant()}"; + + if (maxResults.HasValue) + { + url += $"&maxResults={maxResults.Value}"; + } + + if (!string.IsNullOrEmpty(continuation)) + { + url += $"&continuation={Uri.EscapeDataString(continuation)}"; + } + + if (upn.HasValue) + { + url += $"&upn={upn.Value.ToString().ToLowerInvariant()}"; + } + + return url; + } + + /// + /// Resolves a user-provided path to the ADLS directory value relative to the item root. + /// Handles top-level folders (Files, Tables) and defaults to Files/ prefix for backward compatibility. + /// + private static string ResolveDirectoryPath(string path) + { + var trimmedPath = path.TrimStart('/'); + + if (trimmedPath.StartsWith("Files/", StringComparison.OrdinalIgnoreCase) || + trimmedPath.Equals("Files", StringComparison.OrdinalIgnoreCase) || + IsTopLevelFolder(trimmedPath)) + { + return trimmedPath; + } + + // Assume it's a sub-path within Files for backward compatibility + return $"Files/{trimmedPath}"; + } + private static void ValidatePathForTraversal(string path, string paramName) { // Decode percent-encoding so that %2e%2e or %2E%2E variants are caught @@ -1694,7 +1661,115 @@ private async Task SendFabricApiRequestAsync(HttpMethod method, string u } var response = await _httpClient.SendAsync(request, cancellationToken); - response.EnsureSuccessStatusCode(); + + // Handle long running operations (202 Accepted) + if (response.StatusCode == System.Net.HttpStatusCode.Accepted) + { + var locationUrl = response.Headers.Location?.ToString(); + if (!string.IsNullOrEmpty(locationUrl)) + { + return await PollFabricLroAsync(locationUrl, cancellationToken) ?? Stream.Null; + } + return Stream.Null; + } + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"Response status code does not indicate success: {(int)response.StatusCode} ({response.ReasonPhrase}). Error details: {errorBody}", + null, + response.StatusCode); + } + + return await response.Content.ReadAsStreamAsync(cancellationToken); + } + + /// + /// Polls a Fabric long running operation URL until it succeeds or fails, + /// then fetches and returns the result stream. + /// + private async Task PollFabricLroAsync(string operationUrl, CancellationToken cancellationToken) + { + const int MaxAttempts = 120; // ~10 minutes at 5s default + var retryDelay = TimeSpan.FromSeconds(5); + + for (var attempt = 0; attempt < MaxAttempts; attempt++) + { + await Task.Delay(retryDelay, cancellationToken); + + var tokenContext = new TokenRequestContext(new[] { OneLakeEndpoints.GetFabricScope() }); + var token = await _credential.GetTokenAsync(tokenContext, cancellationToken); + + using var pollRequest = new HttpRequestMessage(HttpMethod.Get, operationUrl); + pollRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + ApplyUserAgent(pollRequest); + + var pollResponse = await _httpClient.SendAsync(pollRequest, cancellationToken); + if (!pollResponse.IsSuccessStatusCode) + { + var errorBody = await pollResponse.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException( + $"LRO polling failed: {(int)pollResponse.StatusCode} ({pollResponse.ReasonPhrase}). {errorBody}", + null, + pollResponse.StatusCode); + } + + // Update retry delay from Retry-After header + if (pollResponse.Headers.TryGetValues("Retry-After", out var retryAfterValues) + && int.TryParse(retryAfterValues.FirstOrDefault(), out var retryAfterSecs)) + { + retryDelay = TimeSpan.FromSeconds(Math.Max(1, retryAfterSecs)); + } + + var stateStream = await pollResponse.Content.ReadAsStreamAsync(cancellationToken); + var state = await JsonSerializer.DeserializeAsync(stateStream, OneLakeJsonContext.Default.OperationState, cancellationToken); + + switch (state?.Status) + { + case "Succeeded": + // Location header on the completed poll response points to the result URL + var resultUrl = pollResponse.Headers.Location?.ToString(); + if (!string.IsNullOrEmpty(resultUrl)) + { + return await GetFabricLroResultAsync(resultUrl, cancellationToken); + } + return Stream.Null; + + case "Failed": + var errorMessage = state.Error?.Message ?? "Long running operation failed."; + var errorCode = state.Error?.ErrorCode ?? "Unknown"; + throw new HttpRequestException( + $"Long running operation failed ({errorCode}): {errorMessage}", + null, + System.Net.HttpStatusCode.InternalServerError); + } + // NotStarted / Running / Undefined: keep polling + } + + throw new HttpRequestException( + "Long running operation timed out after maximum polling attempts.", + null, + System.Net.HttpStatusCode.RequestTimeout); + } + + /// + /// GETs the result URL of a completed Fabric long running operation. + /// + private async Task GetFabricLroResultAsync(string resultUrl, CancellationToken cancellationToken) + { + var tokenContext = new TokenRequestContext(new[] { OneLakeEndpoints.GetFabricScope() }); + var token = await _credential.GetTokenAsync(tokenContext, cancellationToken); + + using var request = new HttpRequestMessage(HttpMethod.Get, resultUrl); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + ApplyUserAgent(request); + + var response = await _httpClient.SendAsync(request, cancellationToken); + if (!response.IsSuccessStatusCode) + { + return Stream.Null; + } return await response.Content.ReadAsStreamAsync(cancellationToken); } @@ -1839,6 +1914,344 @@ public void Dispose() // DefaultAzureCredential doesn't need disposal } + // Data Access Security Operations + public async Task ListDataAccessRolesAsync(string workspaceId, string itemId, string? continuationToken = null, CancellationToken cancellationToken = default) + { + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/items/{itemId}/dataAccessRoles"; + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + url += $"?continuationToken={Uri.EscapeDataString(continuationToken)}"; + } + + var response = await SendFabricApiRequestAsync(HttpMethod.Get, url, cancellationToken: cancellationToken); + return await JsonSerializer.DeserializeAsync(response, OneLakeJsonContext.Default.DataAccessRoleListResponse, cancellationToken) ?? new DataAccessRoleListResponse(); + } + + public async Task GetDataAccessRoleAsync(string workspaceId, string itemId, string roleName, CancellationToken cancellationToken = default) + { + var encodedRoleName = Uri.EscapeDataString(roleName); + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/items/{itemId}/dataAccessRoles/{encodedRoleName}?preview=true"; + var response = await SendFabricApiRequestAsync(HttpMethod.Get, url, cancellationToken: cancellationToken); + return await JsonSerializer.DeserializeAsync(response, OneLakeJsonContext.Default.DataAccessRole, cancellationToken) ?? new DataAccessRole(); + } + + public async Task CreateOrUpdateDataAccessRoleAsync(string workspaceId, string itemId, string roleDefinitionJson, CancellationToken cancellationToken = default) + { + DataAccessRole roleDefinition; + try + { + roleDefinition = JsonSerializer.Deserialize(roleDefinitionJson, OneLakeJsonContext.Default.DataAccessRole) + ?? throw new ArgumentException("Invalid role definition JSON: deserialized to null.", nameof(roleDefinitionJson)); + } + catch (JsonException ex) + { + throw new ArgumentException($"Invalid role definition JSON: {ex.Message}", nameof(roleDefinitionJson), ex); + } + + if (string.IsNullOrWhiteSpace(roleDefinition.Name)) + { + throw new ArgumentException("Role definition must include a non-empty 'name' property.", nameof(roleDefinitionJson)); + } + + // Default tenantId on Entra members when omitted — saves callers from having to + // look up their own tenant ID for the most common single-tenant scenario. + if (roleDefinition.Members?.MicrosoftEntraMembers is { Count: > 0 } entraMembers + && entraMembers.Any(m => string.IsNullOrWhiteSpace(m.TenantId))) + { + var tenantId = await GetTenantIdFromTokenAsync(cancellationToken); + if (!string.IsNullOrWhiteSpace(tenantId)) + { + foreach (var member in entraMembers) + { + member.TenantId ??= tenantId; + } + } + } + + // Resolve any email/UPN-based principals to Entra object IDs via Graph API. + if (roleDefinition.Members?.MicrosoftEntraMembers is { Count: > 0 } membersToResolve) + { + await ResolvePrincipalsAsync(membersToResolve, cancellationToken); + } + + // Use the single-role POST endpoint (preview API) with Overwrite conflict policy. + // This creates the role if it doesn't exist, or replaces it if it does — without + // touching other roles on the item (unlike the bulk PUT approach). + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/items/{itemId}/dataAccessRoles?preview=true&dataAccessRoleConflictPolicy=Overwrite"; + var requestBody = JsonSerializer.Serialize(roleDefinition, OneLakeJsonContext.Default.DataAccessRole); + var responseStream = await SendFabricApiRequestAsync(HttpMethod.Post, url, requestBody, cancellationToken: cancellationToken); + return await JsonSerializer.DeserializeAsync(responseStream, OneLakeJsonContext.Default.DataAccessRole, cancellationToken) ?? roleDefinition; + } + + public async Task DeleteDataAccessRoleAsync(string workspaceId, string itemId, string roleName, CancellationToken cancellationToken = default) + { + var encodedRoleName = Uri.EscapeDataString(roleName); + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/items/{itemId}/dataAccessRoles/{encodedRoleName}?preview=true"; + await SendFabricApiDeleteRequestAsync(url, cancellationToken); + } + + // Shortcut Operations + public async Task ListShortcutsAsync(string workspaceId, string itemId, string? parentPath = null, string? continuationToken = null, CancellationToken cancellationToken = default) + { + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/items/{itemId}/shortcuts"; + var queryParams = new List(); + if (!string.IsNullOrWhiteSpace(parentPath)) + { + queryParams.Add($"parentPath={Uri.EscapeDataString(parentPath)}"); + } + + if (!string.IsNullOrWhiteSpace(continuationToken)) + { + queryParams.Add($"continuationToken={Uri.EscapeDataString(continuationToken)}"); + } + + if (queryParams.Count > 0) + { + url += $"?{string.Join("&", queryParams)}"; + } + + var response = await SendFabricApiRequestAsync(HttpMethod.Get, url, cancellationToken: cancellationToken); + return await JsonSerializer.DeserializeAsync(response, OneLakeJsonContext.Default.ShortcutListResponse, cancellationToken) ?? new ShortcutListResponse(); + } + + public async Task GetShortcutAsync(string workspaceId, string itemId, string shortcutPath, string shortcutName, CancellationToken cancellationToken = default) + { + var encodedPath = Uri.EscapeDataString(shortcutPath); + var encodedName = Uri.EscapeDataString(shortcutName); + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/items/{itemId}/shortcuts/{encodedPath}/{encodedName}"; + var response = await SendFabricApiRequestAsync(HttpMethod.Get, url, cancellationToken: cancellationToken); + return await JsonSerializer.DeserializeAsync(response, OneLakeJsonContext.Default.OneLakeShortcut, cancellationToken) ?? new OneLakeShortcut(); + } + + public async Task CreateOrUpdateShortcutsAsync(string workspaceId, string itemId, string shortcutsJson, string? shortcutConflictPolicy = null, CancellationToken cancellationToken = default) + { + BulkCreateShortcutsRequest request; + try + { + request = JsonSerializer.Deserialize(shortcutsJson, OneLakeJsonContext.Default.BulkCreateShortcutsRequest) + ?? throw new ArgumentException("Invalid shortcuts JSON: deserialized to null.", nameof(shortcutsJson)); + } + catch (JsonException ex) + { + throw new ArgumentException($"Invalid shortcuts JSON: {ex.Message}", nameof(shortcutsJson), ex); + } + + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/items/{itemId}/shortcuts/bulkCreate"; + if (!string.IsNullOrWhiteSpace(shortcutConflictPolicy)) + url += $"?shortcutConflictPolicy={Uri.EscapeDataString(shortcutConflictPolicy)}"; + + var body = JsonSerializer.Serialize(request, OneLakeJsonContext.Default.BulkCreateShortcutsRequest); + var response = await SendFabricApiRequestAsync(HttpMethod.Post, url, body, cancellationToken: cancellationToken); + using var reader = new StreamReader(response); + var responseBody = await reader.ReadToEndAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(responseBody)) + return new BulkCreateShortcutResponse(); + return JsonSerializer.Deserialize(responseBody, OneLakeJsonContext.Default.BulkCreateShortcutResponse) + ?? new BulkCreateShortcutResponse(); + } + + public async Task CreateShortcutAsync(string workspaceId, string itemId, OneLakeShortcut shortcut, string? shortcutConflictPolicy = null, CancellationToken cancellationToken = default) + { + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/items/{itemId}/shortcuts"; + if (!string.IsNullOrWhiteSpace(shortcutConflictPolicy)) + url += $"?shortcutConflictPolicy={Uri.EscapeDataString(shortcutConflictPolicy)}"; + + var body = JsonSerializer.Serialize(shortcut, OneLakeJsonContext.Default.OneLakeShortcut); + var response = await SendFabricApiRequestAsync(HttpMethod.Post, url, body, cancellationToken: cancellationToken); + using var reader = new StreamReader(response); + var responseBody = await reader.ReadToEndAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(responseBody)) + return shortcut; + return JsonSerializer.Deserialize(responseBody, OneLakeJsonContext.Default.OneLakeShortcut) ?? shortcut; + } + + public async Task DeleteShortcutAsync(string workspaceId, string itemId, string shortcutPath, string shortcutName, CancellationToken cancellationToken = default) + { + var encodedPath = Uri.EscapeDataString(shortcutPath); + var encodedName = Uri.EscapeDataString(shortcutName); + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/items/{itemId}/shortcuts/{encodedPath}/{encodedName}"; + await SendFabricApiDeleteRequestAsync(url, cancellationToken); + } + + public async Task ResetShortcutCacheAsync(string workspaceId, CancellationToken cancellationToken = default) + { + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/onelake/resetShortcutCache"; + await SendFabricApiRequestAsync(HttpMethod.Post, url, cancellationToken: cancellationToken); + } + + // Settings Operations + public async Task GetSettingsAsync(string workspaceId, CancellationToken cancellationToken = default) + { + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/onelake/settings"; + var response = await SendFabricApiRequestAsync(HttpMethod.Get, url, cancellationToken: cancellationToken); + return await JsonSerializer.DeserializeAsync(response, OneLakeJsonContext.Default.OneLakeSettings, cancellationToken) ?? new OneLakeSettings(); + } + + public async Task ModifyDiagnosticsAsync(string workspaceId, OneLakeDiagnosticSettings settings, CancellationToken cancellationToken = default) + { + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/onelake/settings/modifyDiagnostics"; + var jsonContent = JsonSerializer.Serialize(settings, OneLakeJsonContext.Default.OneLakeDiagnosticSettings); + await SendFabricApiRequestAsync(HttpMethod.Post, url, jsonContent, cancellationToken: cancellationToken); + } + + public async Task ModifyImmutabilityPolicyAsync(string workspaceId, ImmutabilityPolicy policy, CancellationToken cancellationToken = default) + { + var url = $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{workspaceId}/onelake/settings/modifyImmutabilityPolicy"; + var jsonContent = JsonSerializer.Serialize(policy, OneLakeJsonContext.Default.ImmutabilityPolicy); + await SendFabricApiRequestAsync(HttpMethod.Post, url, jsonContent, cancellationToken: cancellationToken); + } + + private async Task SendFabricApiDeleteRequestAsync(string url, CancellationToken cancellationToken) + { + var tokenContext = new TokenRequestContext(new[] { OneLakeEndpoints.GetFabricScope() }); + var token = await _credential.GetTokenAsync(tokenContext, cancellationToken); + + using var request = new HttpRequestMessage(HttpMethod.Delete, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); + ApplyUserAgent(request); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + response.EnsureSuccessStatusCode(); + } + + private async Task GetTenantIdFromTokenAsync(CancellationToken cancellationToken) + { + try + { + var tokenContext = new TokenRequestContext(new[] { OneLakeEndpoints.GetFabricScope() }); + var token = await _credential.GetTokenAsync(tokenContext, cancellationToken); + + // JWT is three base64url segments separated by dots; the payload is segment[1]. + var parts = token.Token.Split('.'); + if (parts.Length < 2) + { + return null; + } + + var payload = parts[1]; + // Pad base64url to standard base64 + switch (payload.Length % 4) + { + case 2: + payload += "=="; + break; + case 3: + payload += "="; + break; + } + + var bytes = Convert.FromBase64String(payload.Replace('-', '+').Replace('_', '/')); + using var doc = JsonDocument.Parse(bytes); + if (doc.RootElement.TryGetProperty("tid", out var tidElement)) + { + return tidElement.GetString(); + } + } + catch + { + // Best-effort: if token parsing fails, caller should provide tenantId explicitly. + } + + return null; + } + + /// + /// Resolves non-GUID objectId values (email/UPN) to Entra object IDs via Microsoft Graph. + /// Resolution order: /users/{x} → /groups?$filter=mail eq '{x}'. + /// + private async Task ResolvePrincipalsAsync(List members, CancellationToken cancellationToken) + { + var membersToResolve = members.Where(m => !string.IsNullOrWhiteSpace(m.ObjectId) && !Guid.TryParse(m.ObjectId, out _)).ToList(); + if (membersToResolve.Count == 0) + { + return; + } + + const string graphScope = "https://graph.microsoft.com/.default"; + var tokenContext = new TokenRequestContext(new[] { graphScope }); + var token = await _credential.GetTokenAsync(tokenContext, cancellationToken); + + var errors = new List(); + + foreach (var member in membersToResolve) + { + var principal = member.ObjectId!.Trim(); + var resolved = await TryResolveUserAsync(principal, token.Token, cancellationToken) + ?? await TryResolveGroupByMailAsync(principal, token.Token, cancellationToken); + + if (resolved == null) + { + errors.Add($"No Entra principal matched '{principal}'. Ensure the email/UPN is correct and you have User.Read.All and GroupMember.Read.All permissions."); + continue; + } + + member.ObjectId = resolved.Value.ObjectId; + member.ObjectType ??= resolved.Value.ObjectType; + } + + if (errors.Count > 0) + { + throw new ArgumentException("Failed to resolve one or more principals:\n" + string.Join("\n", errors)); + } + } + + private async Task<(string ObjectId, string ObjectType)?> TryResolveUserAsync(string principalValue, string accessToken, CancellationToken cancellationToken) + { + try + { + var url = $"https://graph.microsoft.com/v1.0/users/{Uri.EscapeDataString(principalValue)}?$select=id"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + var id = doc.RootElement.GetProperty("id").GetString(); + return id != null ? (id, "User") : null; + } + catch (HttpRequestException) + { + return null; + } + } + + private async Task<(string ObjectId, string ObjectType)?> TryResolveGroupByMailAsync(string mail, string accessToken, CancellationToken cancellationToken) + { + try + { + var filter = Uri.EscapeDataString($"mail eq '{mail}'"); + var url = $"https://graph.microsoft.com/v1.0/groups?$filter={filter}&$select=id,displayName&$top=1"; + using var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", accessToken); + + using var response = await _httpClient.SendAsync(request, cancellationToken); + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + response.EnsureSuccessStatusCode(); + using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken); + var values = doc.RootElement.GetProperty("value"); + if (values.GetArrayLength() == 0) + { + return null; + } + + var id = values[0].GetProperty("id").GetString(); + return id != null ? (id, "Group") : null; + } + catch (HttpRequestException) + { + return null; + } + } + private static string ExtractWarehouseQueryValue(string warehousePrefix) { const string WarehousePrefixRoot = "warehouse/"; diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleCreateOrUpdateCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleCreateOrUpdateCommandTests.cs new file mode 100644 index 0000000000..a87360ab28 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleCreateOrUpdateCommandTests.cs @@ -0,0 +1,126 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Fabric.Mcp.Tools.OneLake.Commands.Security; +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Security; + +public class DataAccessRoleCreateOrUpdateCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("create_or_update_data_access_role", Command.Name); + Assert.Equal("Create or Update OneLake Data Access Role", Command.Title); + Assert.Contains("Upsert a single data access role", Command.Description); + Assert.False(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("create_or_update_data_access_role", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new DataAccessRoleCreateOrUpdateCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new DataAccessRoleCreateOrUpdateCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.False(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.False(metadata.ReadOnly); + Assert.False(metadata.Secret); + } + + private const string ValidRoleJson = "{\"name\":\"TestRole\",\"decisionRules\":[{\"effect\":\"Permit\",\"permission\":[{\"attributeName\":\"Action\",\"attributeValueIncludedIn\":[\"Read\"]},{\"attributeName\":\"Path\",\"attributeValueIncludedIn\":[\"*\"]}]}],\"members\":{\"fabricItemMembers\":[],\"microsoftEntraMembers\":[]}}"; + + [Theory] + [InlineData("--workspace-id ws1 --item-id item1", true)] + [InlineData("--item-id item1", false)] // missing workspace + [InlineData("--workspace-id ws1", false)] // missing item + [InlineData("", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + var roleJson = ValidRoleJson; + if (shouldSucceed) + { + Service.CreateOrUpdateDataAccessRoleAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(new DataAccessRole { Name = "TestRole" }); + } + + var fullArgs = string.IsNullOrWhiteSpace(args) + ? $"--role-definition {roleJson}" + : $"{args} --role-definition {roleJson}"; + + var response = await ExecuteCommandAsync(fullArgs); + + Assert.NotNull(response); + if (shouldSucceed) + Assert.Equal(HttpStatusCode.OK, response.Status); + else + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_SuccessfulUpsert_ReturnsRole() + { + var expected = new DataAccessRole + { + Name = "TestRole", + DecisionRules = [new DecisionRule { Effect = "Permit" }] + }; + + Service.CreateOrUpdateDataAccessRoleAsync("ws1", "item1", Arg.Any(), Arg.Any()) + .Returns(expected); + + var response = await ExecuteCommandAsync( + "--workspace-id", "ws1", + "--item-id", "item1", + "--role-definition", ValidRoleJson); + + var result = ValidateAndDeserializeResponse(response, OneLakeJsonContext.Default.DataAccessRole); + Assert.Equal("TestRole", result.Name); + await Service.Received(1).CreateOrUpdateDataAccessRoleAsync("ws1", "item1", Arg.Any(), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.CreateOrUpdateDataAccessRoleAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpRequestException("Bad request")); + + var response = await ExecuteCommandAsync( + "--workspace-id", "ws1", + "--item-id", "item1", + "--role-definition", ValidRoleJson); + + Assert.NotNull(response); + Assert.NotEqual(HttpStatusCode.OK, response.Status); + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleDeleteCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleDeleteCommandTests.cs new file mode 100644 index 0000000000..217b43d380 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleDeleteCommandTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Commands.Security; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Security; + +public class DataAccessRoleDeleteCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("delete_data_access_role", Command.Name); + Assert.Equal("Delete OneLake Data Access Role", Command.Title); + Assert.Contains("Delete a single data access role", Command.Description); + Assert.False(Command.Metadata.ReadOnly); + Assert.True(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("delete_data_access_role", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new DataAccessRoleDeleteCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new DataAccessRoleDeleteCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.True(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.False(metadata.ReadOnly); + Assert.False(metadata.Secret); + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleGetCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleGetCommandTests.cs new file mode 100644 index 0000000000..6b913499a3 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleGetCommandTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Commands.Security; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Security; + +public class DataAccessRoleGetCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("get_data_access_role", Command.Name); + Assert.Equal("Get OneLake Data Access Role", Command.Title); + Assert.Contains("Get the full definition of a single data access role", Command.Description); + Assert.True(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("get_data_access_role", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new DataAccessRoleGetCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new DataAccessRoleGetCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.False(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.True(metadata.ReadOnly); + Assert.False(metadata.Secret); + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleListCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleListCommandTests.cs new file mode 100644 index 0000000000..4518bfe475 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Security/DataAccessRoleListCommandTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Commands.Security; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Security; + +public class DataAccessRoleListCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("list_data_access_roles", Command.Name); + Assert.Equal("List OneLake Data Access Roles", Command.Title); + Assert.Contains("List all data access roles", Command.Description); + Assert.True(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("list_data_access_roles", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void GetCommand_RegistersContinuationTokenOption() + { + var option = CommandDefinition.Options.FirstOrDefault(o => o.Name == "--continuation-token"); + Assert.NotNull(option); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new DataAccessRoleListCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new DataAccessRoleListCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.False(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.True(metadata.ReadOnly); + Assert.False(metadata.Secret); + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Settings/SettingsGetCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Settings/SettingsGetCommandTests.cs new file mode 100644 index 0000000000..ed369ac407 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Settings/SettingsGetCommandTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Commands.Settings; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Settings; + +public class SettingsGetCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("get_settings", Command.Name); + Assert.Equal("Get OneLake Settings", Command.Title); + Assert.Contains("Get the OneLake settings for a workspace", Command.Description); + Assert.True(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("get_settings", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new SettingsGetCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new SettingsGetCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.False(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.True(metadata.ReadOnly); + Assert.False(metadata.Secret); + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutCreateCommandVariantsTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutCreateCommandVariantsTests.cs new file mode 100644 index 0000000000..2280f22f8d --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutCreateCommandVariantsTests.cs @@ -0,0 +1,271 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Fabric.Mcp.Tools.OneLake.Commands.Shortcut; +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Mcp.Core.Commands; +using NSubstitute; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Shortcut; + +public class ShortcutCreateCommandVariantsTests +{ + [Fact] + public async Task ExecuteAsync_OneLakeCommand_MapsTargetValues() + { + var service = CreateShortcutService(); + var command = new ShortcutCreateOneLakeCommand(Substitute.For>(), service); + + var response = await ExecuteAsync(command, + "--workspace-id", "ws1", + "--item-id", "item1", + "--shortcut-path", "Files/landing", + "--shortcut-name", "shortcut1", + "--target-workspace-id", "target-ws", + "--target-item-id", "target-item", + "--target-path", "Files/data"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await service.Received(1).CreateShortcutAsync( + "ws1", + "item1", + Arg.Is(shortcut => + shortcut.Path == "Files/landing" && + shortcut.Name == "shortcut1" && + shortcut.Target.OneLake!.WorkspaceId == "target-ws" && + shortcut.Target.OneLake.ItemId == "target-item" && + shortcut.Target.OneLake.Path == "Files/data"), + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_AdlsGen2Command_MapsTargetValues() + { + var service = CreateShortcutService(); + var command = new ShortcutCreateAdlsGen2Command(Substitute.For>(), service); + + var response = await ExecuteAsync(command, + "--workspace-id", "ws1", + "--item-id", "item1", + "--shortcut-path", "Files/landing", + "--shortcut-name", "shortcut1", + "--target-location", "https://account.dfs.core.windows.net/container", + "--target-subpath", "/folder", + "--target-connection-id", "connection-1"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await service.Received(1).CreateShortcutAsync( + "ws1", + "item1", + Arg.Is(shortcut => + shortcut.Target.AdlsGen2!.Location == "https://account.dfs.core.windows.net/container" && + shortcut.Target.AdlsGen2.Subpath == "/folder" && + shortcut.Target.AdlsGen2.ConnectionId == "connection-1"), + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_AmazonS3Command_MapsTargetValues() + { + var service = CreateShortcutService(); + var command = new ShortcutCreateAmazonS3Command(Substitute.For>(), service); + + var response = await ExecuteAsync(command, + "--workspace-id", "ws1", + "--item-id", "item1", + "--shortcut-path", "Files/landing", + "--shortcut-name", "shortcut1", + "--target-location", "https://bucket.s3.us-west-2.amazonaws.com", + "--target-subpath", "/folder", + "--target-connection-id", "connection-1"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await service.Received(1).CreateShortcutAsync( + "ws1", + "item1", + Arg.Is(shortcut => + shortcut.Target.AmazonS3!.Location == "https://bucket.s3.us-west-2.amazonaws.com" && + shortcut.Target.AmazonS3.Subpath == "/folder" && + shortcut.Target.AmazonS3.ConnectionId == "connection-1"), + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_AzureBlobCommand_MapsTargetValues() + { + var service = CreateShortcutService(); + var command = new ShortcutCreateAzureBlobCommand(Substitute.For>(), service); + + var response = await ExecuteAsync(command, + "--workspace-id", "ws1", + "--item-id", "item1", + "--shortcut-path", "Files/landing", + "--shortcut-name", "shortcut1", + "--target-location", "https://account.blob.core.windows.net/container", + "--target-subpath", "/folder", + "--target-connection-id", "connection-1"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await service.Received(1).CreateShortcutAsync( + "ws1", + "item1", + Arg.Is(shortcut => + shortcut.Target.AzureBlobStorage!.Location == "https://account.blob.core.windows.net/container" && + shortcut.Target.AzureBlobStorage.Subpath == "/folder" && + shortcut.Target.AzureBlobStorage.ConnectionId == "connection-1"), + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_GcsCommand_MapsTargetValues() + { + var service = CreateShortcutService(); + var command = new ShortcutCreateGcsCommand(Substitute.For>(), service); + + var response = await ExecuteAsync(command, + "--workspace-id", "ws1", + "--item-id", "item1", + "--shortcut-path", "Files/landing", + "--shortcut-name", "shortcut1", + "--target-location", "https://bucket.storage.googleapis.com", + "--target-subpath", "/folder", + "--target-connection-id", "connection-1"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await service.Received(1).CreateShortcutAsync( + "ws1", + "item1", + Arg.Is(shortcut => + shortcut.Target.GoogleCloudStorage!.Location == "https://bucket.storage.googleapis.com" && + shortcut.Target.GoogleCloudStorage.Subpath == "/folder" && + shortcut.Target.GoogleCloudStorage.ConnectionId == "connection-1"), + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_S3CompatibleCommand_MapsBucketValue() + { + var service = CreateShortcutService(); + var command = new ShortcutCreateS3CompatibleCommand(Substitute.For>(), service); + + var response = await ExecuteAsync(command, + "--workspace-id", "ws1", + "--item-id", "item1", + "--shortcut-path", "Files/landing", + "--shortcut-name", "shortcut1", + "--target-location", "https://s3endpoint.contoso.com", + "--target-subpath", "/folder", + "--target-connection-id", "connection-1", + "--target-bucket", "bucket-1"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await service.Received(1).CreateShortcutAsync( + "ws1", + "item1", + Arg.Is(shortcut => + shortcut.Target.S3Compatible!.Location == "https://s3endpoint.contoso.com" && + shortcut.Target.S3Compatible.Subpath == "/folder" && + shortcut.Target.S3Compatible.ConnectionId == "connection-1" && + shortcut.Target.S3Compatible.Bucket == "bucket-1"), + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_DataverseCommand_MapsTargetValues() + { + var service = CreateShortcutService(); + var command = new ShortcutCreateDataverseCommand(Substitute.For>(), service); + + var response = await ExecuteAsync(command, + "--workspace-id", "ws1", + "--item-id", "item1", + "--shortcut-path", "Files/landing", + "--shortcut-name", "shortcut1", + "--target-environment-domain", "https://org.crm.dynamics.com", + "--target-connection-id", "connection-1", + "--target-deltalake-folder", "Tables/account", + "--target-table-name", "account"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await service.Received(1).CreateShortcutAsync( + "ws1", + "item1", + Arg.Is(shortcut => + shortcut.Target.Dataverse!.EnvironmentDomain == "https://org.crm.dynamics.com" && + shortcut.Target.Dataverse.ConnectionId == "connection-1" && + shortcut.Target.Dataverse.DeltaLakeFolder == "Tables/account"), + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_OneDriveSharePointCommand_MapsSensitivityFlag() + { + var service = CreateShortcutService(); + var command = new ShortcutCreateOneDriveSharePointCommand(Substitute.For>(), service); + + var response = await ExecuteAsync(command, + "--workspace-id", "ws1", + "--item-id", "item1", + "--shortcut-path", "Files/landing", + "--shortcut-name", "shortcut1", + "--target-location", "https://contoso.sharepoint.com/sites/site", + "--target-subpath", "/Documents", + "--target-connection-id", "connection-1", + "--target-update-fabric-item-sensitivity", "true"); + + Assert.Equal(HttpStatusCode.OK, response.Status); + await service.Received(1).CreateShortcutAsync( + "ws1", + "item1", + Arg.Is(shortcut => + shortcut.Target.OneDriveSharePoint!.Location == "https://contoso.sharepoint.com/sites/site" && + shortcut.Target.OneDriveSharePoint.Subpath == "/Documents" && + shortcut.Target.OneDriveSharePoint.ConnectionId == "connection-1" && + shortcut.Target.OneDriveSharePoint.UpdateFabricItemSensitivity == true), + null, + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_MissingRequiredArguments_ReturnsBadRequest() + { + var command = new ShortcutCreateOneLakeCommand( + Substitute.For>(), + CreateShortcutService()); + + var response = await ExecuteAsync(command, + "--item-id", "item1", + "--shortcut-path", "Files/landing", + "--shortcut-name", "shortcut1", + "--target-workspace-id", "target-ws", + "--target-item-id", "target-item"); + + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + + private static IOneLakeService CreateShortcutService() + { + var service = Substitute.For(); + service.CreateShortcutAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(callInfo => callInfo.ArgAt(2)); + return service; + } + + private static async Task ExecuteAsync(IBaseCommand command, params string[] args) + { + using var serviceProvider = new ServiceCollection().BuildServiceProvider(); + var context = new CommandContext(serviceProvider); + return await command.ExecuteAsync(context, command.GetCommand().Parse(args), CancellationToken.None); + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutDeleteCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutDeleteCommandTests.cs new file mode 100644 index 0000000000..cc39c18ca1 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutDeleteCommandTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Commands.Shortcut; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Shortcut; + +public class ShortcutDeleteCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("delete_shortcut", Command.Name); + Assert.Equal("Delete OneLake Shortcut", Command.Title); + Assert.Contains("Delete a single shortcut from an item", Command.Description); + Assert.False(Command.Metadata.ReadOnly); + Assert.True(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("delete_shortcut", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new ShortcutDeleteCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new ShortcutDeleteCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.True(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.False(metadata.ReadOnly); + Assert.False(metadata.Secret); + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutGetCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutGetCommandTests.cs new file mode 100644 index 0000000000..ee4e308f24 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutGetCommandTests.cs @@ -0,0 +1,55 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Commands.Shortcut; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Shortcut; + +public class ShortcutGetCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("get_shortcut", Command.Name); + Assert.Equal("Get OneLake Shortcut", Command.Title); + Assert.Contains("Get the properties of a single shortcut", Command.Description); + Assert.True(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("get_shortcut", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new ShortcutGetCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new ShortcutGetCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.False(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.True(metadata.ReadOnly); + Assert.False(metadata.Secret); + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutListCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutListCommandTests.cs new file mode 100644 index 0000000000..e6ad3d0ab9 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutListCommandTests.cs @@ -0,0 +1,62 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Fabric.Mcp.Tools.OneLake.Commands.Shortcut; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Shortcut; + +public class ShortcutListCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("list_shortcuts", Command.Name); + Assert.Equal("List OneLake Shortcuts", Command.Title); + Assert.Contains("List shortcuts defined within an item", Command.Description); + Assert.True(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("list_shortcuts", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void GetCommand_RegistersContinuationTokenOption() + { + var option = CommandDefinition.Options.FirstOrDefault(o => o.Name == "--continuation-token"); + Assert.NotNull(option); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new ShortcutListCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new ShortcutListCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.False(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.True(metadata.ReadOnly); + Assert.False(metadata.Secret); + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutResetCacheCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutResetCacheCommandTests.cs new file mode 100644 index 0000000000..74cd20fea3 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Commands/Shortcut/ShortcutResetCacheCommandTests.cs @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Fabric.Mcp.Tools.OneLake.Commands.Shortcut; +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Shortcut; + +public class ShortcutResetCacheCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("reset_shortcut_cache", Command.Name); + Assert.Equal("Reset OneLake Shortcut Cache", Command.Title); + Assert.Contains("Drop cached shortcut reads", Command.Description); + Assert.False(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("reset_shortcut_cache", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new ShortcutResetCacheCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new ShortcutResetCacheCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.False(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.False(metadata.ReadOnly); + Assert.False(metadata.Secret); + } + + [Theory] + [InlineData("--workspace-id ws1", true)] + [InlineData("", false)] + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.ResetShortcutCacheAsync(Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + } + + var response = await ExecuteCommandAsync(args); + + Assert.NotNull(response); + if (shouldSucceed) + Assert.Equal(HttpStatusCode.OK, response.Status); + else + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_NoItemRequired_SucceedsWithWorkspaceOnly() + { + Service.ResetShortcutCacheAsync("ws1", Arg.Any()) + .Returns(Task.CompletedTask); + + var response = await ExecuteCommandAsync("--workspace-id", "ws1"); + + var result = ValidateAndDeserializeResponse(response, OneLakeJsonContext.Default.ShortcutResetCacheCommandResult); + Assert.Contains("successfully", result.Message, StringComparison.OrdinalIgnoreCase); + await Service.Received(1).ResetShortcutCacheAsync("ws1", Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.ResetShortcutCacheAsync(Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpRequestException("Service unavailable")); + + var response = await ExecuteCommandAsync("--workspace-id", "ws1"); + + Assert.NotNull(response); + Assert.NotEqual(HttpStatusCode.OK, response.Status); + } + + [Fact] + public void BindOptions_RequiresWorkspaceId() + { + var parseResult = CommandDefinition.Parse(string.Empty); + var isValid = Command.Validate(parseResult.CommandResult); + Assert.False(isValid.IsValid); + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/BlobGetCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/BlobGetCommandTests.cs index 9935fb1ff7..b5c8920e10 100644 --- a/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/BlobGetCommandTests.cs +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/BlobGetCommandTests.cs @@ -3,6 +3,7 @@ using System.Net; using System.Text; +using Fabric.Mcp.Tools.OneLake.Commands.File; using Fabric.Mcp.Tools.OneLake.Models; using Fabric.Mcp.Tools.OneLake.Services; using Microsoft.Extensions.DependencyInjection; diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/Settings/DiagnosticsModifyCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/Settings/DiagnosticsModifyCommandTests.cs new file mode 100644 index 0000000000..6f977a9559 --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/Settings/DiagnosticsModifyCommandTests.cs @@ -0,0 +1,136 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Fabric.Mcp.Tools.OneLake.Commands.Settings; +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Settings; + +public class DiagnosticsModifyCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("modify_diagnostics", Command.Name); + Assert.Equal("Modify OneLake Diagnostics", Command.Title); + Assert.Contains("Enable or disable workspace-level OneLake diagnostic logging", Command.Description); + Assert.False(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("modify_diagnostics", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new DiagnosticsModifyCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new DiagnosticsModifyCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.False(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.False(metadata.ReadOnly); + Assert.False(metadata.Secret); + } + + [Theory] + [InlineData("--workspace-id 85173301-af01-49c9-b667-03edc44517da --status Disabled", true)] + [InlineData("--workspace-id 85173301-af01-49c9-b667-03edc44517da --status Enabled --destination-lakehouse-workspace-id 85173301-af01-49c9-b667-03edc44517da --destination-lakehouse-item-id eceb53c6-6227-41f1-a649-62ebe7ee9eb1", true)] + [InlineData("--status Disabled", false)] // missing workspace + [InlineData("--workspace-id 85173301-af01-49c9-b667-03edc44517da --status Enabled", false)] // missing destination when enabled + [InlineData("--workspace-id 85173301-af01-49c9-b667-03edc44517da --status Disabled --destination-lakehouse-workspace-id 85173301-af01-49c9-b667-03edc44517da --destination-lakehouse-item-id eceb53c6-6227-41f1-a649-62ebe7ee9eb1", false)] // destination provided when disabled + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.ModifyDiagnosticsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + } + + var response = await ExecuteCommandAsync(args); + + Assert.NotNull(response); + if (shouldSucceed) + Assert.Equal(HttpStatusCode.OK, response.Status); + else + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_DisablesDiagnostics_ReturnsSuccessMessage() + { + Service.ModifyDiagnosticsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var response = await ExecuteCommandAsync( + "--workspace-id", "85173301-af01-49c9-b667-03edc44517da", + "--status", "Disabled"); + + var result = ValidateAndDeserializeResponse(response, OneLakeJsonContext.Default.DiagnosticsModifyCommandResult); + Assert.Contains("successfully", result.Message, StringComparison.OrdinalIgnoreCase); + await Service.Received(1).ModifyDiagnosticsAsync("85173301-af01-49c9-b667-03edc44517da", Arg.Is(s => s.Status == "Disabled" && s.Destination == null), Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_EnablesDiagnostics_BuildsCorrectModel() + { + Service.ModifyDiagnosticsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var response = await ExecuteCommandAsync( + "--workspace-id", "85173301-af01-49c9-b667-03edc44517da", + "--status", "Enabled", + "--destination-lakehouse-workspace-id", "85173301-af01-49c9-b667-03edc44517da", + "--destination-lakehouse-item-id", "eceb53c6-6227-41f1-a649-62ebe7ee9eb1"); + + var result = ValidateAndDeserializeResponse(response, OneLakeJsonContext.Default.DiagnosticsModifyCommandResult); + Assert.Contains("successfully", result.Message, StringComparison.OrdinalIgnoreCase); + await Service.Received(1).ModifyDiagnosticsAsync("85173301-af01-49c9-b667-03edc44517da", + Arg.Is(s => + s.Status == "Enabled" && + s.Destination != null && + s.Destination.Lakehouse != null && + s.Destination.Lakehouse.WorkspaceId == "85173301-af01-49c9-b667-03edc44517da" && + s.Destination.Lakehouse.ItemId == "eceb53c6-6227-41f1-a649-62ebe7ee9eb1"), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.ModifyDiagnosticsAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpRequestException("Forbidden")); + + var response = await ExecuteCommandAsync( + "--workspace-id", "85173301-af01-49c9-b667-03edc44517da", + "--status", "Disabled"); + + Assert.NotNull(response); + Assert.NotEqual(HttpStatusCode.OK, response.Status); + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/Settings/ImmutabilityPolicyModifyCommandTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/Settings/ImmutabilityPolicyModifyCommandTests.cs new file mode 100644 index 0000000000..58cc467b6a --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Commands/Settings/ImmutabilityPolicyModifyCommandTests.cs @@ -0,0 +1,115 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using Fabric.Mcp.Tools.OneLake.Commands.Settings; +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Services; +using Microsoft.Mcp.Tests.Client; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Commands.Settings; + +public class ImmutabilityPolicyModifyCommandTests : CommandUnitTestsBase +{ + [Fact] + public void Constructor_InitializesCommandCorrectly() + { + Assert.Equal("modify_immutability_policy", Command.Name); + Assert.Equal("Modify OneLake Immutability Policy", Command.Title); + Assert.Contains("Modify the workspace-level OneLake immutability policy", Command.Description); + Assert.False(Command.Metadata.ReadOnly); + Assert.False(Command.Metadata.Destructive); + Assert.True(Command.Metadata.Idempotent); + } + + [Fact] + public void GetCommand_ReturnsValidCommand() + { + Assert.Equal("modify_immutability_policy", CommandDefinition.Name); + Assert.NotNull(CommandDefinition.Description); + Assert.NotEmpty(CommandDefinition.Options); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenLoggerIsNull() + { + Assert.Throws(() => new ImmutabilityPolicyModifyCommand(null!, Service)); + } + + [Fact] + public void Constructor_ThrowsArgumentNullException_WhenOneLakeServiceIsNull() + { + Assert.Throws(() => new ImmutabilityPolicyModifyCommand(Logger, null!)); + } + + [Fact] + public void Metadata_HasCorrectProperties() + { + var metadata = Command.Metadata; + + Assert.False(metadata.Destructive); + Assert.True(metadata.Idempotent); + Assert.False(metadata.LocalRequired); + Assert.False(metadata.OpenWorld); + Assert.False(metadata.ReadOnly); + Assert.False(metadata.Secret); + } + + [Theory] + [InlineData("--workspace-id 85173301-af01-49c9-b667-03edc44517da --scope DiagnosticLogs --retention-days 7", true)] + [InlineData("--scope DiagnosticLogs --retention-days 7", false)] // missing workspace + [InlineData("--workspace-id 85173301-af01-49c9-b667-03edc44517da --scope DiagnosticLogs --retention-days 0", false)] // retention < 1 + [InlineData("--workspace-id 85173301-af01-49c9-b667-03edc44517da --scope Invalid --retention-days 7", false)] // bad scope + public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed) + { + if (shouldSucceed) + { + Service.ModifyImmutabilityPolicyAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + } + + var response = await ExecuteCommandAsync(args); + + Assert.NotNull(response); + if (shouldSucceed) + Assert.Equal(HttpStatusCode.OK, response.Status); + else + Assert.Equal(HttpStatusCode.BadRequest, response.Status); + } + + [Fact] + public async Task ExecuteAsync_SuccessfulModification_ReturnsSuccessMessage() + { + Service.ModifyImmutabilityPolicyAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Task.CompletedTask); + + var response = await ExecuteCommandAsync( + "--workspace-id", "85173301-af01-49c9-b667-03edc44517da", + "--scope", "DiagnosticLogs", + "--retention-days", "7"); + + var result = ValidateAndDeserializeResponse(response, OneLakeJsonContext.Default.ImmutabilityPolicyModifyCommandResult); + Assert.Contains("successfully", result.Message, StringComparison.OrdinalIgnoreCase); + await Service.Received(1).ModifyImmutabilityPolicyAsync("85173301-af01-49c9-b667-03edc44517da", + Arg.Is(p => p.Scope == "DiagnosticLogs" && p.RetentionDays == 7), + Arg.Any()); + } + + [Fact] + public async Task ExecuteAsync_HandlesServiceErrors() + { + Service.ModifyImmutabilityPolicyAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .ThrowsAsync(new HttpRequestException("Forbidden")); + + var response = await ExecuteCommandAsync( + "--workspace-id", "85173301-af01-49c9-b667-03edc44517da", + "--scope", "DiagnosticLogs", + "--retention-days", "7"); + + Assert.NotNull(response); + Assert.NotEqual(HttpStatusCode.OK, response.Status); + } +} + diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/FabricOneLakeSetupTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/FabricOneLakeSetupTests.cs index 80ae08526e..59bbbbd5f3 100644 --- a/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/FabricOneLakeSetupTests.cs +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/FabricOneLakeSetupTests.cs @@ -61,5 +61,19 @@ public void RegisterCommands_RegistersAllOneLakeCommands() Assert.True(rootGroup.Commands.ContainsKey("get_table"), "Should have get_table command"); Assert.True(rootGroup.Commands.ContainsKey("list_table_namespaces"), "Should have list_table_namespaces command"); Assert.True(rootGroup.Commands.ContainsKey("get_table_namespace"), "Should have get_table_namespace command"); + + // Shortcut commands + Assert.True(rootGroup.Commands.ContainsKey("list_shortcuts"), "Should have list_shortcuts command"); + Assert.True(rootGroup.Commands.ContainsKey("get_shortcut"), "Should have get_shortcut command"); + Assert.True(rootGroup.Commands.ContainsKey("delete_shortcut"), "Should have delete_shortcut command"); + Assert.True(rootGroup.Commands.ContainsKey("reset_shortcut_cache"), "Should have reset_shortcut_cache command"); + Assert.True(rootGroup.Commands.ContainsKey("create_shortcut_onelake"), "Should have create_shortcut_onelake command"); + Assert.True(rootGroup.Commands.ContainsKey("create_shortcut_adls_gen2"), "Should have create_shortcut_adls_gen2 command"); + Assert.True(rootGroup.Commands.ContainsKey("create_shortcut_amazon_s3"), "Should have create_shortcut_amazon_s3 command"); + Assert.True(rootGroup.Commands.ContainsKey("create_shortcut_azure_blob"), "Should have create_shortcut_azure_blob command"); + Assert.True(rootGroup.Commands.ContainsKey("create_shortcut_gcs"), "Should have create_shortcut_gcs command"); + Assert.True(rootGroup.Commands.ContainsKey("create_shortcut_s3_compatible"), "Should have create_shortcut_s3_compatible command"); + Assert.True(rootGroup.Commands.ContainsKey("create_shortcut_dataverse"), "Should have create_shortcut_dataverse command"); + Assert.True(rootGroup.Commands.ContainsKey("create_shortcut_onedrive_sharepoint"), "Should have create_shortcut_onedrive_sharepoint command"); } } diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Services/OneLakeServiceLroTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Services/OneLakeServiceLroTests.cs new file mode 100644 index 0000000000..a312044f6b --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Services/OneLakeServiceLroTests.cs @@ -0,0 +1,296 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using System.Text.Json; +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Services; +using Fabric.Mcp.Tools.OneLake.Tests.TestSupport; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Services; + +/// +/// Tests that OneLakeService correctly handles Fabric long running operations (LRO) +/// that return 202 Accepted with a Location header. +/// +public class OneLakeServiceLroTests +{ + private const string WorkspaceId = "ws-lro-test"; + private const string ItemId = "item-lro-test"; + private const string OperationId = "op-lro-12345"; + private const string OperationUrl = $"https://dailyapi.fabric.microsoft.com/v1/operations/{OperationId}"; + private const string ResultUrl = $"https://dailyapi.fabric.microsoft.com/v1/operations/{OperationId}/result"; + private const string ShortcutsUrl = $"https://dailyapi.fabric.microsoft.com/v1/workspaces/{WorkspaceId}/items/{ItemId}/shortcuts/bulkCreate"; + + private static OneLakeService CreateService(Func handler) + { + var httpClient = new HttpClient(new CapturingHttpMessageHandler(handler)); + return new OneLakeService(httpClient, new FakeTokenCredential()); + } + + private static string RunningStateJson => JsonSerializer.Serialize( + new { status = "Running", percentComplete = 50 }); + + private static string SucceededStateJson => JsonSerializer.Serialize( + new { status = "Succeeded", percentComplete = 100 }); + + private static string FailedStateJson => JsonSerializer.Serialize( + new { status = "Failed", percentComplete = 0, error = new { errorCode = "InternalError", message = "Something went wrong" } }); + + private static string BulkCreateResultJson => JsonSerializer.Serialize( + new + { + value = new[] + { + new { status = "Succeeded", result = new { path = "/Files", name = "lro-test-shortcut" } } + } + }); + + /// + /// Single-poll success: 202 → poll returns Succeeded → result fetched. + /// + [Fact] + public async Task CreateOrUpdateShortcutsAsync_202WithLocation_PollsAndReturnsResult() + { + var callCount = 0; + var service = CreateService(request => + { + callCount++; + var url = request.RequestUri!.ToString(); + + // First call: the bulk create → 202 with Location + if (url.Contains("bulkCreate") && callCount == 1) + { + var accepted = new HttpResponseMessage(HttpStatusCode.Accepted); + accepted.Headers.Location = new Uri(OperationUrl); + return accepted; + } + + // Second call: polling → Succeeded with Location pointing to result + if (url == OperationUrl) + { + var poll = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(SucceededStateJson, Encoding.UTF8, "application/json") + }; + poll.Headers.Location = new Uri(ResultUrl); + return poll; + } + + // Third call: result fetch + if (url == ResultUrl) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(BulkCreateResultJson, Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var result = await service.CreateOrUpdateShortcutsAsync( + WorkspaceId, ItemId, + "{\"createShortcutRequests\":[{\"path\":\"/Files\",\"name\":\"lro-test-shortcut\",\"target\":{\"oneLake\":{\"workspaceId\":\"ws2\",\"itemId\":\"item2\",\"path\":\"Files\"}}}]}", + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Value); + Assert.Single(result.Value); + Assert.Equal("Succeeded", result.Value[0].Status); + Assert.Equal(3, callCount); // bulk create + poll + result + } + + /// + /// Multi-poll: 202 → Running → Succeeded → result fetched. + /// + [Fact] + public async Task CreateOrUpdateShortcutsAsync_202_MultiplePolls_EventuallySucceeds() + { + var pollCount = 0; + var service = CreateService(request => + { + var url = request.RequestUri!.ToString(); + + if (url.Contains("bulkCreate")) + { + var accepted = new HttpResponseMessage(HttpStatusCode.Accepted); + accepted.Headers.Location = new Uri(OperationUrl); + return accepted; + } + + if (url == OperationUrl) + { + pollCount++; + if (pollCount < 3) + { + // First two polls: still running + var running = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(RunningStateJson, Encoding.UTF8, "application/json") + }; + running.Headers.Location = new Uri(OperationUrl); + return running; + } + // Third poll: succeeded + var succeeded = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(SucceededStateJson, Encoding.UTF8, "application/json") + }; + succeeded.Headers.Location = new Uri(ResultUrl); + return succeeded; + } + + if (url == ResultUrl) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(BulkCreateResultJson, Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var result = await service.CreateOrUpdateShortcutsAsync( + WorkspaceId, ItemId, + "{\"createShortcutRequests\":[{\"path\":\"/Files\",\"name\":\"lro-test-shortcut\",\"target\":{\"oneLake\":{\"workspaceId\":\"ws2\",\"itemId\":\"item2\",\"path\":\"Files\"}}}]}", + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Value); + Assert.Equal(3, pollCount); + } + + /// + /// 202 → poll returns Succeeded but no Location header → returns empty response (no crash). + /// + [Fact] + public async Task CreateOrUpdateShortcutsAsync_202_SucceededWithNoResultLocation_ReturnsEmpty() + { + var service = CreateService(request => + { + var url = request.RequestUri!.ToString(); + + if (url.Contains("bulkCreate")) + { + var accepted = new HttpResponseMessage(HttpStatusCode.Accepted); + accepted.Headers.Location = new Uri(OperationUrl); + return accepted; + } + + if (url == OperationUrl) + { + // Succeeded but no Location header for result + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(SucceededStateJson, Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var result = await service.CreateOrUpdateShortcutsAsync( + WorkspaceId, ItemId, + "{\"createShortcutRequests\":[{\"path\":\"/Files\",\"name\":\"lro-test-shortcut\",\"target\":{\"oneLake\":{\"workspaceId\":\"ws2\",\"itemId\":\"item2\",\"path\":\"Files\"}}}]}", + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.Null(result.Value); // empty response from Stream.Null + } + + /// + /// 202 → poll returns Failed → HttpRequestException thrown. + /// + [Fact] + public async Task CreateOrUpdateShortcutsAsync_202_OperationFails_ThrowsHttpRequestException() + { + var service = CreateService(request => + { + var url = request.RequestUri!.ToString(); + + if (url.Contains("bulkCreate")) + { + var accepted = new HttpResponseMessage(HttpStatusCode.Accepted); + accepted.Headers.Location = new Uri(OperationUrl); + return accepted; + } + + if (url == OperationUrl) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(FailedStateJson, Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var ex = await Assert.ThrowsAsync(() => + service.CreateOrUpdateShortcutsAsync( + WorkspaceId, ItemId, + "{\"createShortcutRequests\":[{\"path\":\"/Files\",\"name\":\"lro-test-shortcut\",\"target\":{\"oneLake\":{\"workspaceId\":\"ws2\",\"itemId\":\"item2\",\"path\":\"Files\"}}}]}", + cancellationToken: CancellationToken.None)); + + Assert.Contains("InternalError", ex.Message); + Assert.Contains("Something went wrong", ex.Message); + } + + /// + /// 202 with no Location header → returns empty response gracefully. + /// + [Fact] + public async Task CreateOrUpdateShortcutsAsync_202_NoLocationHeader_ReturnsEmpty() + { + var service = CreateService(request => + { + if (request.RequestUri!.ToString().Contains("bulkCreate")) + { + // 202 but without Location header + return new HttpResponseMessage(HttpStatusCode.Accepted); + } + return new HttpResponseMessage(HttpStatusCode.NotFound); + }); + + var result = await service.CreateOrUpdateShortcutsAsync( + WorkspaceId, ItemId, + "{\"createShortcutRequests\":[{\"path\":\"/Files\",\"name\":\"lro-test-shortcut\",\"target\":{\"oneLake\":{\"workspaceId\":\"ws2\",\"itemId\":\"item2\",\"path\":\"Files\"}}}]}", + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.Null(result.Value); + } + + /// + /// Synchronous 200 response (non-LRO path) still works correctly. + /// + [Fact] + public async Task CreateOrUpdateShortcutsAsync_200_ReturnsResultDirectly() + { + var resultJson = JsonSerializer.Serialize(new + { + value = new[] + { + new { status = "Succeeded", result = new { path = "/Files", name = "sync-shortcut" } } + } + }); + + var service = CreateService(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(resultJson, Encoding.UTF8, "application/json") + }); + + var result = await service.CreateOrUpdateShortcutsAsync( + WorkspaceId, ItemId, + "{\"createShortcutRequests\":[{\"path\":\"/Files\",\"name\":\"sync-shortcut\",\"target\":{\"oneLake\":{\"workspaceId\":\"ws2\",\"itemId\":\"item2\",\"path\":\"Files\"}}}]}", + cancellationToken: CancellationToken.None); + + Assert.NotNull(result); + Assert.NotNull(result.Value); + Assert.Single(result.Value); + Assert.Equal("Succeeded", result.Value[0].Status); + } +} diff --git a/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Services/OneLakeServiceShortcutTests.cs b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Services/OneLakeServiceShortcutTests.cs new file mode 100644 index 0000000000..cf8a7d111d --- /dev/null +++ b/tools/Fabric.Mcp.Tools.OneLake/tests/Fabric.Mcp.Tools.OneLake.Tests/Services/OneLakeServiceShortcutTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#pragma warning disable xUnit1051 + +using System.Net; +using System.Text; +using System.Text.Json; +using Fabric.Mcp.Tools.OneLake.Models; +using Fabric.Mcp.Tools.OneLake.Services; +using Fabric.Mcp.Tools.OneLake.Tests.TestSupport; + +namespace Fabric.Mcp.Tools.OneLake.Tests.Services; + +public class OneLakeServiceShortcutTests +{ + private const string WorkspaceId = "ws-shortcut-test"; + private const string ItemId = "item-shortcut-test"; + + [Fact] + public async Task CreateShortcutAsync_WithConflictPolicy_SendsExpectedRequestAndReturnsResponse() + { + HttpRequestMessage? capturedRequest = null; + string? capturedBody = null; + var expectedShortcut = new OneLakeShortcut + { + Path = "Files/landing", + Name = "shortcut1", + Target = new ShortcutTarget + { + Type = "OneLake", + OneLake = new OneLakeShortcutTarget + { + WorkspaceId = "target-ws", + ItemId = "target-item", + Path = "Files/data" + } + } + }; + + var handler = new CapturingHttpMessageHandler(request => + { + capturedRequest = request; + capturedBody = request.Content?.ReadAsStringAsync().GetAwaiter().GetResult(); + var responseJson = JsonSerializer.Serialize(expectedShortcut, OneLakeJsonContext.Default.OneLakeShortcut); + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(responseJson, Encoding.UTF8, "application/json") + }; + }); + + using var httpClient = new HttpClient(handler); + var service = new OneLakeService(httpClient, new FakeTokenCredential()); + + var result = await service.CreateShortcutAsync(WorkspaceId, ItemId, expectedShortcut, "CreateOrOverwrite"); + + Assert.NotNull(capturedRequest); + Assert.Equal(HttpMethod.Post, capturedRequest!.Method); + Assert.Equal( + $"{OneLakeEndpoints.GetFabricApiBaseUrl()}/workspaces/{WorkspaceId}/items/{ItemId}/shortcuts?shortcutConflictPolicy=CreateOrOverwrite", + capturedRequest.RequestUri!.ToString()); + + Assert.NotNull(capturedBody); + var sentShortcut = JsonSerializer.Deserialize(capturedBody, OneLakeJsonContext.Default.OneLakeShortcut); + Assert.NotNull(sentShortcut); + Assert.Equal(expectedShortcut.Name, sentShortcut!.Name); + Assert.Equal(expectedShortcut.Target!.Type, sentShortcut.Target!.Type); + Assert.Equal(expectedShortcut.Target.OneLake!.WorkspaceId, sentShortcut.Target.OneLake!.WorkspaceId); + + Assert.Equal(expectedShortcut.Name, result.Name); + Assert.Equal(expectedShortcut.Target.OneLake.ItemId, result.Target!.OneLake!.ItemId); + } + + [Fact] + public async Task CreateShortcutAsync_WithEmptyResponse_ReturnsOriginalShortcut() + { + var shortcut = new OneLakeShortcut + { + Path = "Files/landing", + Name = "shortcut2", + Target = new ShortcutTarget + { + Type = "ExternalDataShare", + ExternalDataShare = new ExternalDataShareShortcutTarget + { + ConnectionId = "connection-1" + } + } + }; + + var handler = new CapturingHttpMessageHandler(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(string.Empty, Encoding.UTF8, "application/json") + }); + + using var httpClient = new HttpClient(handler); + var service = new OneLakeService(httpClient, new FakeTokenCredential()); + + var result = await service.CreateShortcutAsync(WorkspaceId, ItemId, shortcut); + + Assert.Same(shortcut, result); + } +}