Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e8ab66e
Initial plan
Copilot Nov 5, 2025
ca33b2e
feat: add MCP Server project with PowerShell execution tools
Copilot Nov 5, 2025
62b6365
docs: add README and improve module path resolution
Copilot Nov 5, 2025
5b31215
fix: address code review feedback on event handlers
Copilot Nov 5, 2025
8a82a8b
feat: add cmdlet discovery tools and default connection support
Copilot Nov 5, 2025
fdfea90
feat: add persistent sessions, language mode control, and provider re…
Copilot Nov 5, 2025
813cd6c
feat: package MCP server as .NET global tool with bundled module
Copilot Nov 5, 2025
0be1bb3
fix: prefix tool command name with rnwood-
Copilot Nov 6, 2025
89b8519
feat: set connection as default in MCP sessions and add e2e test
Copilot Nov 6, 2025
5655e0a
refactor: split MCP server e2e test into separate file
Copilot Nov 6, 2025
866b526
feat: add HTTP mode for MCP server with e2e test
Copilot Nov 6, 2025
f419fb0
refactor: use ModelContextProtocol.AspNetCore for HTTP mode
Copilot Nov 6, 2025
33f55a9
Tweaks
web-flow Nov 6, 2025
7643de9
feat: remove HTTP mode, add URL allowlist restriction in cmdlet, remo…
Copilot Feb 1, 2026
c97b946
feat: add URL validation to Get-DataverseConnection cmdlet
Copilot Feb 1, 2026
19fd39a
docs: add MCP Server as major feature in main README with examples
Copilot Feb 1, 2026
a9c0876
docs: make dotnet exec the preferred launch method (no installation n…
Copilot Feb 1, 2026
79e41a1
docs: update to use dnx command instead of dotnet exec
Copilot Feb 1, 2026
ceb45fa
Fix
rnwood Feb 1, 2026
0c90000
WIP
rnwood Feb 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -530,6 +530,56 @@ jobs:
if-no-files-found: ignore
retention-days: 1

# Build and publish MCP Server as .NET Global Tool
- if: ${{ matrix.publish }}
name: Build MCP Server Tool
shell: pwsh
run: |
# Get version from the module manifest or CI version file
if (Test-Path "ci-version.txt") {
$version = Get-Content "ci-version.txt" -Raw | ForEach-Object { $_.Trim() }
Write-Host "Using CI version: $version"
} elseif ($env:GITHUB_REF -like "refs/tags/*") {
$version = $env:GITHUB_REF -replace "refs/tags/v?", ""
Write-Host "Using release version: $version"
} else {
$version = "1.0.0"
Write-Host "Using default version: $version"
}

# Update version in MCP Server project file
$projectPath = "Rnwood.Dataverse.Data.PowerShell.McpServer/Rnwood.Dataverse.Data.PowerShell.McpServer.csproj"
$content = Get-Content $projectPath -Raw
$content = $content -replace '<Version>.*?</Version>', "<Version>$version</Version>"
$content | Set-Content $projectPath -Encoding UTF8

# Build the main module first (required for bundling)
dotnet build -c Release ./Rnwood.Dataverse.Data.PowerShell/Rnwood.Dataverse.Data.PowerShell.csproj

# Pack the MCP Server as a global tool
dotnet pack -c Release ./Rnwood.Dataverse.Data.PowerShell.McpServer/Rnwood.Dataverse.Data.PowerShell.McpServer.csproj -o ./nupkgs

Write-Host "Package created successfully"
Get-ChildItem ./nupkgs

- if: ${{ matrix.publish && github.event_name == 'release' && github.event.action == 'published' }}
name: Publish MCP Server to NuGet.org
env:
NUGET_KEY: ${{ secrets.NUGET_KEY }}
shell: pwsh
run: |
# Push stable release to NuGet.org
dotnet nuget push ./nupkgs/*.nupkg --api-key $env:NUGET_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate

- if: ${{ matrix.publish && github.ref == 'refs/heads/main' && github.event_name == 'push' }}
name: Publish MCP Server CI Build to NuGet.org
env:
NUGET_KEY: ${{ secrets.NUGET_KEY }}
shell: pwsh
run: |
# Push prerelease to NuGet.org
dotnet nuget push ./nupkgs/*.nupkg --api-key $env:NUGET_KEY --source https://api.nuget.org/v3/index.json --skip-duplicate

# Upload test failure reports as artifacts
- name: Upload test failure report
if: failure() && github.event_name == 'pull_request'
Expand Down
162 changes: 162 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ This module works in PowerShell Desktop and PowerShell Core, supporting Windows,
- Automatic data type conversion using metadata - use friendly labels for choices and names for lookups
- Automatic lookup conversion - use record names instead of GUIDs (when unique)
- On behalf of (delegation) support for create/update operations
- **🤖 MCP Server for AI Assistants**: Model Context Protocol server that enables AI assistants like Claude to execute PowerShell scripts with Dataverse module. Features URL allowlist security, auto-connection, and persistent sessions. [Learn More ⬇](#mcp-server-for-ai-assistants)
- Duplicate detection support for create/update/upsert operations
- Full support for automatic paging
- Concise PowerShell-friendly hashtable-based filters with grouped logical expressions (and/or/not/xor) and arbitrary nesting
Expand Down Expand Up @@ -259,6 +260,167 @@ For operations not covered by the cmdlets above, use [`Invoke-DataverseRequest`]

See the [Invoke-DataverseRequest documentation](Rnwood.Dataverse.Data.PowerShell/docs/Invoke-DataverseRequest.md) for details on response conversion and parameter sets.

## MCP Server for AI Assistants

The **Model Context Protocol (MCP) Server** enables AI assistants like Claude Desktop to execute PowerShell scripts with the Dataverse module pre-loaded. This powerful integration allows AI to:

- Query and analyze Dataverse data
- Create, update, and delete records
- Work with metadata and schema
- Execute complex data operations
- All with enterprise-grade security controls

### Quick Start

**1. No Installation Required - Use `dnx` (Recommended):**

Configure in Claude Desktop by editing `claude_desktop_config.json` (location varies by platform):

```json
{
"mcpServers": {
"dataverse": {
"command": "dnx",
"args": [
"rnwood-dataverse-mcp",
"--allowed-urls",
"https://yourorg.crm.dynamics.com"
]
}
}
}
```

The `dnx` command (new in .NET 10 SDK) automatically downloads and runs the latest version from NuGet without any installation.

**Alternative: Install as Global Tool:**

```bash
dotnet tool install --global Rnwood.Dataverse.Data.PowerShell.McpServer
```

Then configure with simpler syntax:
```json
{
"mcpServers": {
"dataverse": {
"command": "rnwood-dataverse-mcp",
"args": [
"--allowed-urls",
"https://yourorg.crm.dynamics.com"
]
}
}
}
```

**2. Restart Claude Desktop**

The server will auto-connect to your Dataverse environment when first used.

### Example Use Cases

Once configured, you can ask Claude to help with Dataverse tasks:

**"Show me all active contacts in our CRM"**
```powershell
Get-DataverseRecord -TableName contact -FilterValues @{ statecode = 0 } |
Select-Object fullname, emailaddress1, telephone1
```

**"Create a new account for Contoso Ltd"**
```powershell
Set-DataverseRecord -TableName account -InputObject @{
name = 'Contoso Ltd'
telephone1 = '555-0100'
websiteurl = 'https://contoso.com'
} -CreateOnly
```

**"Find all opportunities worth more than $50,000"**
```powershell
Get-DataverseRecord -TableName opportunity -FilterValues @{
estimatedvalue = @{ GreaterThan = 50000 }
statecode = 0 # Active
} | Select-Object name, estimatedvalue, customeridname
```

**"Generate a report of accounts created this month"**
```powershell
$startOfMonth = Get-Date -Day 1 -Hour 0 -Minute 0 -Second 0
Get-DataverseRecord -TableName account -FilterValues @{
createdon = @{ GreaterThanOrEqual = $startOfMonth }
} | Group-Object owneridname |
Select-Object Name, Count |
Sort-Object Count -Descending
```

**"Update all contacts at Fabrikam to have a new category"**
```powershell
# First, find the account
$fabrikam = Get-DataverseRecord -TableName account -FilterValues @{ name = 'Fabrikam' }

# Then update all related contacts
Get-DataverseRecord -TableName contact -FilterValues @{
parentcustomerid = $fabrikam.accountid
} | ForEach-Object {
Set-DataverseRecord -TableName contact -Id $_.contactid -InputObject @{
customertypecode = 3 # Strategic partner
}
}
```

### Security Features

The MCP Server includes enterprise-grade security:

- **URL Allowlist**: Connections restricted to approved Dataverse environments only
- **Restricted Language Mode**: Prevents .NET type access by default
- **Provider Restrictions**: Filesystem and registry access disabled by default
- **Auto-Connection**: Automatically connects to first allowed URL using interactive auth
- **Session Isolation**: Each AI session runs in an isolated PowerShell environment

### Advanced Configuration

**Multiple Environments (using `dnx`):**
```json
{
"mcpServers": {
"dataverse": {
"command": "dnx",
"args": [
"rnwood-dataverse-mcp",
"--allowed-urls",
"https://dev.crm.dynamics.com",
"https://test.crm.dynamics.com",
"https://prod.crm.dynamics.com"
]
}
}
}
```

**Unrestricted Mode (for trusted environments, using `dnx`):**
```json
{
"mcpServers": {
"dataverse": {
"command": "dnx",
"args": [
"rnwood-dataverse-mcp",
"--allowed-urls",
"https://yourorg.crm.dynamics.com",
"--unrestricted-mode",
"--enable-providers"
]
}
}
}
```

For complete documentation including all MCP tools, security considerations, and troubleshooting, see the [**MCP Server Documentation**](Rnwood.Dataverse.Data.PowerShell.McpServer/README.md).


## Support and Contributing

- Report issues: [GitHub Issues](https://github.com/rnwood/Rnwood.Dataverse.Data.PowerShell/issues)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -271,8 +271,47 @@ public GetDataverseConnectionCmdlet()
[Parameter]
public Guid? TenantId { get; set; }

// Cancellation token source that is cancelled when the user hits Ctrl+C (StopProcessing)
private CancellationTokenSource _userCancellationCts;
private void ValidateUrlIfRestricted(Uri url)
{
if (url == null)
{
return;
}

var allowedUrlsVar = SessionState.PSVariable.Get("Global:AllowedDataverseUrls");
if (allowedUrlsVar == null || allowedUrlsVar.Value == null)
{
return;
}

var allowedUrls = allowedUrlsVar.Value as object[];
if (allowedUrls == null || allowedUrls.Length == 0)
{
return;
}

string normalizedInputUrl = url.ToString().TrimEnd('/').ToLowerInvariant();

foreach (var allowedUrl in allowedUrls)
{
if (allowedUrl == null)
{
continue;
}

string normalizedAllowedUrl = allowedUrl.ToString().TrimEnd('/').ToLowerInvariant();
if (normalizedInputUrl == normalizedAllowedUrl)
{
return;
}
}

ThrowTerminatingError(new ErrorRecord(
new UnauthorizedAccessException($"Access to URL '{url}' is not allowed. The URL is not in the list of allowed Dataverse URLs."),
"UrlNotAllowed",
ErrorCategory.PermissionDenied,
url));
}

/// <summary>
/// Initializes the cmdlet processing.
Expand Down Expand Up @@ -308,6 +347,8 @@ protected override void EndProcessing()
_userCancellationCts = null;
}

private CancellationTokenSource _userCancellationCts;

private CancellationTokenSource CreateLinkedCts(TimeSpan timeout)
{
var timeoutCts = new CancellationTokenSource(timeout);
Expand Down Expand Up @@ -401,6 +442,7 @@ protected override void ProcessRecord()

// Restore connection parameters from metadata
Url = new Uri(metadata.Url);
ValidateUrlIfRestricted(Url);
ClientId = string.IsNullOrEmpty(metadata.ClientId) ? ClientId : new Guid(metadata.ClientId);
Username = metadata.Username;
ManagedIdentityClientId = metadata.ManagedIdentityClientId;
Expand Down Expand Up @@ -520,6 +562,8 @@ protected override void ProcessRecord()
Url = new Uri(discoveryUrl);
}

ValidateUrlIfRestricted(Url);

result = new ServiceClientWithTokenProvider(Url, url => GetTokenInteractive(publicClient, url));

// Save connection metadata if a name was provided
Expand Down Expand Up @@ -561,6 +605,7 @@ protected override void ProcessRecord()
var discoveryUrl = PromptToSelectEnvironmentUrl(url => GetTokenWithUsernamePassword(publicClient, url)).GetAwaiter().GetResult();
Url = new Uri(discoveryUrl);
}
ValidateUrlIfRestricted(Url);

result = new ServiceClientWithTokenProvider(Url, url => GetTokenWithUsernamePassword(publicClient, url));

Expand Down Expand Up @@ -602,6 +647,7 @@ protected override void ProcessRecord()
.WithRedirectUri("http://localhost")
.Build();

ValidateUrlIfRestricted(Url);
// Register MSAL cache if saving a named connection
if (!string.IsNullOrEmpty(Name))
{
Expand Down Expand Up @@ -657,6 +703,8 @@ protected override void ProcessRecord()
Url = new Uri(discoveryUrl);
}

ValidateUrlIfRestricted(Url);

// Now get the authority for the selected environment
string authority = GetAuthority();

Expand Down Expand Up @@ -706,6 +754,7 @@ protected override void ProcessRecord()

break;
}
ValidateUrlIfRestricted(Url);

case PARAMSET_CLIENTCERTIFICATE:
{
Expand Down Expand Up @@ -747,6 +796,8 @@ protected override void ProcessRecord()
store.RegisterCache(confApp);
}

ValidateUrlIfRestricted(Url);

result = new ServiceClientWithTokenProvider(Url, url => GetTokenWithClientCertificate(confApp, url));

// Save connection metadata if a name was provided
Expand Down Expand Up @@ -786,7 +837,7 @@ protected override void ProcessRecord()

break;
}

ValidateUrlIfRestricted(Url);
case PARAMSET_DEFAULTAZURECREDENTIAL:
{
var credential = new Azure.Identity.DefaultAzureCredential();
Expand All @@ -805,6 +856,7 @@ protected override void ProcessRecord()

result = new ServiceClientWithTokenProvider(Url, url => GetTokenWithAzureCredential(credential, url));

ValidateUrlIfRestricted(Url);
// Save connection metadata if a name was provided
if (!string.IsNullOrEmpty(Name))
{
Expand Down
Loading