PnP PowerShell MCP Server
Officialby pnp
README.md
# PnP PowerShell MCP Server
<!-- mcp-name: io.github.pnp/pnp-powershell-mcp-server -->
## 💡 Description
This MCP server allows the use of natural language to run [PnP PowerShell](https://pnp.github.io/powershell/) commands and to author complex PnP PowerShell scripts. It may handle complex prompts that are executed as a chain of PnP PowerShell cmdlets that try to fulfill the user's request, and it can search the community's [PnP Script Samples](https://pnp.github.io/script-samples/) library for ready-to-adapt scripts. This way you can manage many different areas of Microsoft 365 — SharePoint Online, Microsoft Teams, Entra ID, OneDrive, Planner, Power Platform, Microsoft 365 Groups, taxonomy, search, and tenant administration — straight from your MCP client, and use it as a jump-start for writing your own automation scripts.
## 📦 Prerequisites
- [.NET 10 SDK](https://dotnet.microsoft.com/download) (only required to build/run from source — published tool releases are self-contained)
- [PowerShell 7.4 or above](https://aka.ms/powershell) (`pwsh`) installed and available on `PATH`
- The [`PnP.PowerShell`](https://www.powershellgallery.com/packages/PnP.PowerShell) module installed:
```powershell
Install-Module -Name PnP.PowerShell -Scope CurrentUser -Force -AllowClobber
```
## 🚀 Installation & Usage
This MCP server shells out to the locally installed [PnP PowerShell](https://pnp.github.io/powershell/) module — it does not do any authentication for you. Authenticate first using `Connect-PnPOnline` (see [Best Practices](./best-practices.md) for the recommended auth methods), then the MCP server will reuse the same PnP PowerShell connection context.
- **TYPE**: `Local` (stdio)
- **INSTALL**: [](https://insiders.vscode.dev/redirect/mcp/install?name=pnp-powershell&config=%7B%22type%22%3A%22stdio%22%2C%22command%22%3A%22pnp-powershell-mcp-server%22%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=pnp-powershell&config=%7B%22type%22%3A%22stdio%22%2C%22command%22%3A%22pnp-powershell-mcp-server%22%7D&quality=insiders) [](https://aka.ms/vs/mcp-install?%7B%22name%22%3A%22pnp-powershell%22%2C%22type%22%3A%22stdio%22%2C%22command%22%3A%22pnp-powershell-mcp-server%22%7D) [](https://cursor.com/install-mcp?name=pnp-powershell&config=eyJ0eXBlIjoic3RkaW8iLCJjb21tYW5kIjoicG5wLXBvd2Vyc2hlbGwtbWNwLXNlcnZlciJ9) [](#add-to-claude-code)
> The one-click buttons above register the server under the name `pnp-powershell` and point it at the `pnp-powershell-mcp-server` command, so **install the tool first** (below) — otherwise the client will register a server it cannot start.
### Install as a .NET global tool
```bash
dotnet tool install --global PnP.PowerShell.MCPServer --prerelease
```
This installs a self-contained, native AOT executable named `pnp-powershell-mcp-server` on your `PATH`. Supported platforms: Windows (x64, arm64), macOS (arm64, x64) and Linux (x64, arm64, musl x64).
To update an existing install:
```bash
dotnet tool update --global PnP.PowerShell.MCPServer --prerelease
```
> **Hitting `Version <x> of package PnP.PowerShell.MCPServer.<rid> is not found in NuGet feeds`?**
> This tool ships as a small wrapper package plus one package per platform, and that error means the platform package for your machine was never published for that version. It affects `0.1.1-beta` and earlier — install `0.1.3-beta` or later, or [build and run from source](#-how-to-build-and-run-it-locally). Maintainers: see [RELEASING.md](./RELEASING.md).
### Add to VS Code
1. Open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P on macOS) and type `MCP: Add Server`.
2. Select `Command (stdio)` as the server type.
3. Enter the command to run the MCP server:
```text
pnp-powershell-mcp-server
```
4. Name the server (e.g., `PnP PowerShell MCP Server`).
As a result, you should have the following configuration in your `.vscode/mcp.json` file:
```json
{
"servers": {
"PnP PowerShell MCP Server": {
"type": "stdio",
"command": "pnp-powershell-mcp-server"
}
}
}
```
Now when you open the GitHub Copilot chat in VS Code, you should be able to select the `PnP PowerShell MCP Server` from the list of available MCP servers and start using it to manage Microsoft 365 using natural language. In the prompt specify that "Using PnP PowerShell, I want you to..." and GitHub Copilot Agent will use the MCP server to execute your request.
### Add to GitHub Copilot CLI
If you are using [GitHub Copilot CLI](https://docs.github.com/en/copilot/concepts/agents/about-copilot-cli), you may add the PnP PowerShell MCP server to Copilot by doing the following:
1. Start the [Copilot CLI](https://www.npmjs.com/package/@github/copilot):
```bash
copilot
```
2. Use the copilot mcp command to add the MCP server:
```text
/mcp add
```
3. Fill in the MCP form:
- Server name: whatever you like, without spaces, e.g. `pnp-powershell-mcp-server`
- Server type: `Local`
- Command: `pnp-powershell-mcp-server`
- Arguments: leave empty
After that click `Ctrl+S` to save and `q` to exit the MCP form. You can now use the PnP PowerShell MCP server in GitHub Copilot CLI, e.g. "Using PnP PowerShell, I want you to...".
### Add to Claude Code
```bash
claude mcp add pnp-powershell --scope user -- pnp-powershell-mcp-server
```
`--scope user` makes the server available in every project; drop it to register it for the current project only. Check it was picked up with `claude mcp list`.
### Add to Claude Desktop
1. In Claude Desktop, open Settings by clicking on the hamburger icon in the top left corner.
2. Select File > Settings (or press `Ctrl + ,`).
3. In the Developer tab, click Edit Config.
Note: If you don't see the Developer tab, enable it first from Help > Enable Developer Mode.
4. This opens explorer; edit `claude_desktop_config.json` in your favorite text editor and add:
```json
{
"mcpServers": {
"PnP-PowerShell": {
"command": "pnp-powershell-mcp-server"
}
}
}
```
5. Restart Claude Desktop for the changes to take effect.
> Note: On Windows, Claude doesn't exit when you close the window — it keeps running in the background. Find it in the system tray, right-click and select Quit to exit completely.
### Add to Cursor
1. From the chat option pick the `Agent settings` option.
2. Go to `Tools & MCP` tab and click on `New MCP server`.
3. Modify the `mcp.json` configuration as follows:
```json
{
"mcpServers": {
"PnP PowerShell MCP Server": {
"type": "stdio",
"command": "pnp-powershell-mcp-server"
}
}
}
```
4. Save and enable the `PnP PowerShell MCP Server` in the `Tools & MCP` tab and wait for the tools to load.
## 📷 Use Cases
The below use cases are only a few examples of how you may use this MCP server. It is capable of handling many different tasks, so feel free to experiment and manage Microsoft 365 using natural language.
### Manage SharePoint Online
prompt:
```text
Add a new list to this site with title 'awesome ducks'. Then add new columns to that list including them in the default view. The first should be a text description column and the second one should be a user column. Then add 3 items to this list with some funny jokes about ducks added in the description column and my user in the user column.
```
### Manage Microsoft Teams
prompt:
```text
Create a new Team on Teams with name 'Awesome Ducks' and in the General channel add a welcome post.
```
### Bootstrap a script from a community sample
prompt:
```text
I need a PnP PowerShell script that exports all SharePoint list items to a CSV file — find a community sample and adapt it for the 'Documents' list on my site.
```
### Reuse your own scripts
prompt:
```text
Do I have a script in my samples that reports inactive sites? If so, run it for the last 90 days.
```
Then, once a new script works:
```text
Save that script to my samples as inactive-sites-report.
```
See [Your own script samples](#your-own-script-samples) for the one-time setup.
### Report on tenant state
prompt:
```text
Can you check if I have a Power Automate flow called 'HoursReportingReminder' and if so disable it?
```
## 🛠️ Tools
| Tool | Description |
| --- | --- |
| pnp_search_commands | Finds which cmdlet does a job. Scores a compiled-in index of every cmdlet — name, verb, noun, synopsis, description, parameters and examples — with field-weighted BM25, so a plain-language question like "add a column to a list" finds `Add-PnPField`. Answers entirely in process: no `pwsh`, no session and no network, so it works on a machine that is not set up yet. Returns structured content alongside the text, and states the module version it was indexed from. |
| pnp_get_command_docs | Gets the reference documentation for one named cmdlet — syntax, parameters, parameter sets and examples — preceded by links to both the raw markdown source of its documentation page and the rendered HTML page. The markdown is the same content for a fraction of the tokens. |
| pnp_run_command | Runs PnP PowerShell against the connected tenant and returns the result. Runs in a persistent session, so a `Connect-PnPOnline` connection is reused across calls. Destructive commands require confirmation first. A result set too large for the output cap is summarised and paged rather than truncated. |
| pnp_get_result_page | Returns the next page of a result set `pnp_run_command` summarised. Pages over rows already fetched, so it costs nothing against the tenant and returns exactly the rows the original command saw. |
| pnp_get_connection_status | Checks whether the session is signed in, to which site, and as which account. |
| pnp_diagnose_connection | Checks everything that has to be true before a command can run: `pwsh` on `PATH`, the `PnP.PowerShell` module, what connection the session holds, and — when it holds none — which app registration, persisted login or certificate this machine can actually sign in with. Every failing check names its cause and the exact next command, with no placeholder left in it where the facts can fill one in. Pass `targetUrl` to get the command for a specific site. The `pwsh`, module and auth-material checks need no tenant and no network, so it still works on a machine that is not set up yet; once a connection exists it also inspects that connection, which asks PnP for a Graph token and so reaches Entra ID. |
| pnp_reset_session | Ends a session and its PnP connection. Use it to sign out, switch accounts, or recover a session that has stopped responding. |
| pnp_get_best_practices | Returns best practices for using PnP PowerShell via this MCP server. Takes an optional `section` (`workflow`, `docs`, `sessions`, `config`, `readonly`, `output`, `destructive`, `auth`, `execution`, `patterns`) to retrieve one topic instead of the whole guide, which keeps the response small. |
| pnp_search_script_samples | Lists community [PnP Script Samples](https://pnp.github.io/script-samples/), plus your own from `PNP_SCRIPT_SAMPLES_PATH`, matching a keyword — titles, descriptions and links, no code. The community index is compiled into the server, so it needs no network; a Git URL in `PNP_SCRIPT_SAMPLES_PATH` is fetched on first use. |
| pnp_get_script_sample | Retrieves the full PnP PowerShell script code for one named script sample. The index entry is local; a community script body is fetched from GitHub, and your own is read from disk. |
| pnp_suggest_script | Finds the most relevant script samples for a task, favouring your own, and returns their full script code plus adaptation guidance, in one call. |
| pnp_save_script_sample | Saves a script that worked as a `.ps1` in the first plain folder of `PNP_SCRIPT_SAMPLES_PATH`, with its one-line summary as `.SYNOPSIS`, so searches and suggestions find it at once. Never overwrites an existing file. |
| pnp_ping | Returns the server version, uptime, read-only mode status, and active session count, and — unless `includeReadiness` is `false` — whether `pwsh` and the `PnP.PowerShell` module are present. Use this as a lightweight health check to confirm the server is responsive and the machine is ready. |
| pnp_list_sessions | Lists all active PowerShell sessions with their status and last activity time. Use this to see what sessions exist before deciding which to connect, reset, or reuse. |
| pnp_setup_environment | Installs the `PnP.PowerShell` module for the current user so PnP cmdlets can run, choosing the released or the latest pre-release build. It installs that one module only — it never signs in, touches the tenant, or creates an app registration — and only when `PNP_MCP_ALLOW_SETUP=true`; otherwise it returns the exact `Install-Module` command to run by hand. |
Every tool declares its `readOnlyHint`, `idempotentHint` and `openWorldHint` annotations, and the
tools that are not read-only also declare `destructiveHint` — `true` for the two that can change
Microsoft 365 (`pnp_run_command`, `pnp_reset_session`) and `false` for the current-user module
install (`pnp_setup_environment`) — so a client can decide what to auto-approve without guessing.
Tool descriptions are gated on whether they actually select: `ToolSelectionEvaluatorTests` scores every
prompt in [e2eTestPrompts.md](./tests/PnPPowerShell.MCPServer.Tests/e2eTestPrompts.md) against the
published descriptions and fails the build if the right tool is not ranked in the top three. See
[Tool selection](#tool-selection).
### 📚 Resources
The same guidance and cmdlet documentation is also exposed as MCP **resources**, so a client that
supports them can browse and cache the content instead of spending a tool call on it.
| URI | Contents |
| --- | --- |
| `pnp://best-practices` | The whole guidance document. |
| `pnp://best-practices/{section}` | One section: `workflow`, `docs`, `sessions`, `config`, `readonly`, `output`, `destructive`, `auth`, `execution`, `patterns`. |
| `pnp://cmdlet/{name}` | Help text for one cmdlet, preceded by its published documentation URL — e.g. `pnp://cmdlet/Get-PnPWeb`. |
### Sessions and `sessionId`
Commands run in a persistent `pwsh` session, so a connection made with `Connect-PnPOnline` stays
alive across tool calls — you connect once rather than on every command.
**You normally never set `sessionId`.** Leave it out and everything shares the session named
`default`. It exists for one situation: working against **two tenants (or two accounts) at the same
time**, because a single PnP session can only hold one connection.
| | Without `sessionId` | With `sessionId` |
| --- | --- | --- |
| Session used | `default` | the name you pass |
| Connection | one, shared | one per session name |
| Variables (`$sites`, ...) | shared | isolated per session |
Three tools accept it: `pnp_run_command`, `pnp_get_connection_status` and `pnp_reset_session`.
`pnp_search_commands` uses no session at all — it is answered from the compiled-in index — and
`pnp_get_command_docs` always uses `default`, since a cmdlet's help does not depend on which tenant
you are connected to.
#### When to use it
You are asking the agent for something in natural language, so you set this by *saying* it rather
than by editing config. Two tenants in one conversation:
```text
Connect to contoso in a session called "contoso" and to fabrikam in a session called "fabrikam",
then list the site count in each and tell me which is larger.
```
The agent then makes calls equivalent to:
```jsonc
// tool: pnp_run_command
{ "sessionId": "contoso", "command": "Connect-PnPOnline -Url https://contoso.sharepoint.com -Interactive" }
{ "sessionId": "fabrikam", "command": "Connect-PnPOnline -Url https://fabrikam.sharepoint.com -Interactive" }
{ "sessionId": "contoso", "command": "(Get-PnPTenantSite).Count" }
{ "sessionId": "fabrikam", "command": "(Get-PnPTenantSite).Count" }
```
For everything else — including multi-step work against a single tenant — omit it:
```text
Connect to contoso, find all site collections with no owner, and export them to a CSV.
```
#### Things worth knowing
- **Sign out or switch account** with `pnp_reset_session`. It ends that session and discards its
connection and variables; the next call starts fresh.
- **Idle sessions end after 30 minutes.** A session busy running a command is never reclaimed, however
long it takes — just reconnect if one does expire.
- **One command at a time per session.** A second call against a busy session waits, then reports the
session is busy. To genuinely run two things at once, use two different `sessionId` values.
- **Reuse the connection.** Do not re-run `Connect-PnPOnline` before every command; check
`pnp_get_connection_status` first. It reports which session it inspected.
### Your own script samples
Out of the box, the sample tools know the ~320 community [PnP Script Samples](https://pnp.github.io/script-samples/).
Point `PNP_SCRIPT_SAMPLES_PATH` at your own scripts and they are searched, suggested and fetched the same
way, ranked ahead of a community sample that matches about as well.
```json
{
"servers": {
"PnP PowerShell MCP Server": {
"type": "stdio",
"command": "pnp-powershell-mcp-server",
"env": {
"PNP_SCRIPT_SAMPLES_PATH": "C:\\scripts\\pnp;https://github.com/contoso/pnp-scripts.git"
}
}
}
}
```
Entries are separated by `;`, and each one is:
| Entry | Read as |
| --- | --- |
| A full folder path, e.g. `C:\scripts\pnp` | Every `.ps1` in it and its subfolders, up to 5,000. OneDrive folders work. Hidden and system items, files over 1 MB, and symbolic links and junctions, whether to a file or a folder, are skipped. |
| An `https://` or `ssh://` Git URL | A shallow clone under local app data, refreshed once per server start, on the first sample call. It uses your existing Git credentials and never prompts, so clone the repository once yourself first. A copy that cannot be updated is replaced by a fresh clone, and if that fails too, the last copy is used. |
| A [pnp/script-samples](https://github.com/pnp/script-samples) clone | Its samples, in place of the compiled-in copies of the same name. |
Anything else, such as a relative path or `git@host:repo` (write it as `ssh://git@host/repo` instead), is ignored.
A script is found by its comment-based help block (`<# … #>`), so a `.SYNOPSIS` is worth writing. Without
one, the file name is its title:
```powershell
<#
.SYNOPSIS
Report sites with no activity in the last 180 days
.DESCRIPTION
Lists every site collection whose content has not changed recently, oldest first, as a CSV.
#>
param([int]$Days = 180)
Get-PnPTenantSite | Where-Object LastContentModifiedDate -lt (Get-Date).AddDays(-$Days) |
Sort-Object LastContentModifiedDate | Select-Object Url, Title, LastContentModifiedDate |
Export-Csv inactive-sites.csv -NoTypeInformation
```
Its name is its path inside the folder, with anything but ASCII letters, digits, `_` and `.` turned into
`-`, so `C:\scripts\pnp\sites\Inactive Sites.ps1` becomes `sites-Inactive-Sites`. When two scripts end up
with the same name, the first one found wins, and a script whose name is left empty is skipped.
Prompts that use it:
```text
What scripts do I have for site permissions?
Find one of my samples that exports list items, and adapt it for the Documents list.
Show me the full code of sites-Inactive-Sites.
Save the script we just ran to my samples as monthly-storage-report.
```
`pnp_save_script_sample` writes to the first plain folder listed (never a Git copy or a clone) under a
lower-case file name, adds the summary you give it as `.SYNOPSIS`, and refuses to overwrite an existing
file or reuse a sample's name. The saved script can be
found at once. Scripts you add or edit by hand show up after the next save or server restart, and changes
pushed to a Git repository after a restart. A saved script is whatever the model wrote, so review it before sharing that folder with
people who run its scripts.
### Configuration
| Environment variable | Default | Description |
| --- | --- | --- |
| `PNP_MCP_COMMAND_TIMEOUT_SECONDS` | `600` | Wall-clock limit for a single `pnp_run_command` call. On timeout the session is terminated and the connection is lost. |
| `PNP_MCP_CONFIRM_DESTRUCTIVE` | `true` | Set to `false` to run destructive commands (`Remove-*`, `Clear-*`, ...) without asking for confirmation. This is the only way to bypass the gate: there is no tool parameter that lets the model approve its own destructive command, so on a client that cannot show a confirmation prompt, destructive commands are simply blocked. |
| `PNP_MCP_READONLY` | `false` | Set to `true` to refuse any command that would change Microsoft 365. Allowed verbs: `Get-`, `Export-`, `Test-`, `Convert-`/`ConvertTo-`/`ConvertFrom-`, `Read-`, `Measure-`, `Connect-`/`Disconnect-`, `Find-`, `Format-`, `Resolve-`, `Write-`, `Search-`, `Show-`, `Compare-`, plus pipeline shaping (`Select-`, `Where-`, `Sort-`, `Group-`, `ForEach-`, `Out-`, `Join-`, `Split-`). Refused: `Set-`, `Remove-`, `Add-`, `New-`, `Clear-`, `Invoke-`, `Update-`, `Move-`, `Enable-`/`Disable-`, `Grant-`/`Revoke-`, `Copy-`, `Import-`, `Restore-`, `Reset-`, `Rename-`, `Start-`/`Stop-`, `Register-`/`Unregister-`, and every other change verb — along with indirectly invoked commands, native executables, and state-changing method calls such as `ExecuteQuery`. See [Best Practices](./best-practices.md#read-only-mode) for the full table. Local file output (`Out-File`, `Export-*`) is still permitted. |
| `PNP_MCP_ALLOW_SETUP` | `false` | Set to `true` to let `pnp_setup_environment` install the `PnP.PowerShell` module for the current user. Left unset, that tool changes nothing and returns the `Install-Module` command for you to run by hand. It never installs anything else, signs in, or touches the tenant. |
| `PNP_MCP_MAX_OUTPUT_CHARS` | `50000` | Largest tool response returned, in characters. A JSON result set over the cap is summarised — true row count, field names, and as many whole rows as fit, plus a cursor for `pnp_get_result_page` — so the response stays complete and parseable. Anything else is truncated to its first whole lines with a note saying how much was dropped. Values below 2000 are ignored, since the note itself would leave no room for output. |
| `PNP_MCP_REPLAY_DIR` | _(unset)_ | **Testing only.** Answers every command from recorded fixtures in this directory instead of running it, so the server never reaches Microsoft 365. It announces itself on stderr when set. See [Recorded-playback tests](#recorded-playback-tests). |
| `PNP_MCP_RECORD_DIR` | _(unset)_ | **Testing only.** Writes a scrubbed fixture for every command the server runs, into this directory. |
| `PNP_SCRIPT_SAMPLES_PATH` | _(unset)_ | Your own script samples, searched alongside the community index and ranked ahead of a community sample that matches about as well. A `;`-separated list of full folder paths of `.ps1` files, [pnp/script-samples](https://github.com/pnp/script-samples) clones, and `https://` or `ssh://` Git URLs. `pnp_save_script_sample` writes to the first plain folder listed. See [Your own script samples](#your-own-script-samples). |
The client passes the environment in when it launches the server process, so where you set them decides
both who they apply to and that a **server restart** is needed for a change to take effect.
Installing from the [MCP Registry](https://registry.modelcontextprotocol.io/) or the NuGet.org MCP tab asks for
`PNP_MCP_READONLY` and `PNP_MCP_ALLOW_SETUP` only, both defaulting to `false`. Add any other variable to the
`env` block the client writes.
#### Where to set them
**In your MCP client config** — the usual choice. This is the only place that applies to the server no
matter how the client was launched, and it survives a reboot.
<details>
<summary>VS Code — <code>.vscode/mcp.json</code> (or the user-level <code>mcp.json</code>)</summary>
```json
{
"servers": {
"PnP PowerShell MCP Server": {
"type": "stdio",
"command": "pnp-powershell-mcp-server",
"env": {
"PNP_MCP_READONLY": "true",
"PNP_MCP_COMMAND_TIMEOUT_SECONDS": "1800"
}
}
}
}
```
</details>
<details>
<summary>Claude Desktop — <code>claude_desktop_config.json</code></summary>
```json
{
"mcpServers": {
"PnP-PowerShell": {
"command": "pnp-powershell-mcp-server",
"env": {
"PNP_MCP_READONLY": "true"
}
}
}
}
```
</details>
<details>
<summary>Cursor — <code>mcp.json</code></summary>
```json
{
"mcpServers": {
"PnP PowerShell MCP Server": {
"type": "stdio",
"command": "pnp-powershell-mcp-server",
"env": {
"PNP_MCP_READONLY": "true"
}
}
}
}
```
</details>
<details>
<summary>Claude Code — <code>claude mcp add</code></summary>
```bash
claude mcp add pnp-powershell --scope user \
--env PNP_MCP_READONLY=true \
--env PNP_MCP_COMMAND_TIMEOUT_SECONDS=1800 \
-- pnp-powershell-mcp-server
```
</details>
**In your shell**, when you want a one-off run — for example to try read-only mode without editing
config. The client must be started *from that shell* for it to inherit the value:
```bash
# macOS / Linux
PNP_MCP_READONLY=true code .
```
```powershell
# Windows PowerShell
$env:PNP_MCP_READONLY = 'true'; code .
```
**Machine-wide**, if every tool on the box should behave the same way. Note this affects other
processes too, so prefer the client config unless that is what you want:
```powershell
# Windows, persists across reboots
[Environment]::SetEnvironmentVariable('PNP_MCP_READONLY', 'true', 'User')
```
#### Worked examples
| Goal | Setting |
| --- | --- |
| Let an agent explore a production tenant without being able to change it | `PNP_MCP_READONLY=true` |
| Tenant-wide reports that take longer than 10 minutes | `PNP_MCP_COMMAND_TIMEOUT_SECONDS=3600` |
| Unattended automation where the commands are already reviewed | `PNP_MCP_CONFIRM_DESTRUCTIVE=false` |
| Search and save your own scripts, plus your team's repository | `PNP_SCRIPT_SAMPLES_PATH=C:\scripts;https://github.com/contoso/pnp-scripts.git` |
| Work against a script-samples clone newer than the vendored index | `PNP_SCRIPT_SAMPLES_PATH=C:\src\script-samples` |
After changing any of these, **restart the MCP server** (in most clients, reload the window or toggle
the server off and on) — the client passes the environment in when it launches the process, so an
already-running server keeps the old values.
Two cautions: `PNP_MCP_CONFIRM_DESTRUCTIVE=false` removes the only thing standing between an agent
and `Remove-PnPTenantSite`, so set it only where the commands are reviewed some other way. And both
booleans are matched exactly — `PNP_MCP_READONLY` enables only on the literal string `true`
(case-insensitive), and `PNP_MCP_CONFIRM_DESTRUCTIVE` disables only on `false`; anything else, `1` and
`yes` included, leaves the default in place.
Clients that support the MCP **Tasks** extension can run `pnp_run_command` as a task and poll for the
result, rather than holding the request open for the duration of a long tenant operation.
## 🏗️ How to build and run it locally
Before anything, restore and build the project:
```bash
dotnet build
```
### Running MCP in VS Code from local build
Start the MCP server from source so it may be used by GitHub Copilot Agent. In VS Code GitHub Copilot Agent mode, click the tools icon, select `Add more tools` → `Add MCP server` → `Command (stdio)`, and enter:
```bash
dotnet run --project FULL_PATH_TO_YOUR_PROJECT/PnPPowerShell.MCPServer.csproj
```
Name it however you like. It's recommended to add it to `workspace` scope for testing. This repo's [.mcp.json](./.mcp.json) already contains an equivalent configuration you can adapt.
### Vendored data
Three indexes are compiled into the assembly as embedded resources, so the tools that use them work with
no network, no VS Code extension and no tenant:
| File | Contents | Used by |
| --- | --- | --- |
| [data/script-samples.json](./data/script-samples.json) | The PnP Script Samples catalogue — name, title, description, tags, authors | `pnp_search_script_samples`, `pnp_get_script_sample`, `pnp_suggest_script` |
| [data/pnp-commands.json](./data/pnp-commands.json) | Every `PnP.PowerShell` cmdlet name, with the URL templates for its markdown and HTML documentation | `pnp_get_command_docs` |
| [data/pnp-index.json](./data/pnp-index.json) | The search corpus — synopsis, description, parameters, parameter sets and examples per cmdlet, plus the superseded-alias map | `pnp_search_commands` |
The two whose *content* can go stale print their provenance with every answer, so a stale index is
visible rather than silent: `pnp_search_script_samples` names the sample catalogue's commit, and
`pnp_search_commands` names the module version it was indexed from. `pnp-commands.json` supplies only
documentation URL templates — `pnp_get_command_docs` reads the help itself from the module you have
installed — so there is no stale content there to warn about. Refresh all three before a release:
```powershell
# Sample and cmdlet-name indexes, from pnp/vscode-pnp-powershell.
pwsh ./build/Update-VendoredData.ps1
# Search corpus, read from the PnP.PowerShell module installed on this machine, whose version it
# records. Requires PnP.PowerShell; takes a few seconds.
pwsh ./build/Update-CommandIndex.ps1
```
Because the corpus is built from an installed module rather than the caller's, `pnp_search_commands`
describes the cmdlets that existed when the server was built. It states that version in every answer,
and `pnp_get_command_docs` reads the module you actually have — use it to confirm syntax before
running anything.
The script fails rather than guessing if either upstream file stops matching the URL templates.
The PnP PowerShell VS Code extension's own `samples.json` replaces the compiled-in catalogue when that
extension is installed. Samples from `PNP_SCRIPT_SAMPLES_PATH` are then added on top, replacing any of
the same name, so a [pnp/script-samples](https://github.com/pnp/script-samples) clone listed there
also serves contributors working against a newer catalogue.
### Tool selection
[e2eTestPrompts.md](./tests/PnPPowerShell.MCPServer.Tests/e2eTestPrompts.md) holds natural-language
prompts per tool. `ToolSelectionEvaluatorTests` ranks every tool against each prompt using BM25 over
the published descriptions — no model, no network, no tenant — and fails if the expected tool is not
in the top three. Ranking is the only thing asserted: a confidence score lived here briefly and was
removed, having never caught a regression. **Adding a tool means adding prompts for it**; the test fails
on any tool with none, and when a prompt regresses the fix is usually the tool's `[Description]`, not
the prompt.
`Bm25_agrees_with_the_model_that_read_the_same_descriptions` is the check on the checker: it compares
BM25s top pick against [modelSelections.md](./tests/PnPPowerShell.MCPServer.Tests/modelSelections.md),
where a language model labelled the same prompts from the published descriptions alone. They agree on
93 %. If that falls, the lexical scorer has stopped predicting selection and it is the scorer that needs
replacing, not the prose.
One counter-intuitive rule, learned the hard way: selection is zero-sum between tools, so broadening a
description to win a prompt costs every other tool. Only more *distinctive* wording helps.
### Protocol tests
`StdioProtocolTests` spawns the built server as a real process and speaks newline-delimited JSON-RPC
to it — `initialize`, `tools/list`, `tools/call` — with a hand-rolled client rather than the SDKs,
so the wire format is exercised rather than the SDK talking to itself. It asserts the tool surface, the
annotations as published, and that the destructive-command gate blocks a client which cannot be
prompted. Everything but that last check is hermetic; run `dotnet build` first, since the tests launch
the servers own build output.
### Recorded-playback tests
Tenant-dependent behaviour is recorded once against a dev tenant and replayed offline forever after, so
CI needs neither `pwsh` nor a tenant. Each fixture is filed under the *operation* it records — `run`
plus the command, `command-docs` plus the cmdlet — rather than a hash of the generated script, so
rewording that script does not silently orphan every fixture. The filename says so too:
`run-get-pnplist-select-object-title-itemcount-ca7f2242b91c2383.transcript` is that operation, slugged,
followed by the key. Only the key identifies the fixture — lookup falls back to matching on it — so the readable
half can be corrected by hand without breaking playback. Fixtures live in
[tests/PnPPowerShell.MCPServer.Tests/fixtures](./tests/PnPPowerShell.MCPServer.Tests/fixtures) and are
scrubbed on the way in by `TranscriptScrubber` — tenant hostnames, UPNs, GUIDs, tokens, secrets,
thumbprints and certificate blocks, including inside the base64 payload a command is wrapped in.
To re-record, from a machine with a connected dev tenant:
```powershell
$env:PNP_MCP_RECORD_FIXTURES = '1'
$env:PNP_MCP_RECORD_TENANT_URL = 'https://<tenant>.sharepoint.com/sites/<site>'
$env:PNP_MCP_RECORD_CLIENT_ID = '<app id>'
dotnet test --filter RecordedPlaybackTests
```
**Read every fixture before committing it.** The scrubber cannot detect a display name in free text,
and a recorded fixture is a tenant data leak waiting to be committed.
### Running MCP from local build using the inspector (Debugging)
One of the ways to test the MCP server is by using the [MCP Inspector](https://github.com/modelcontextprotocol/inspector):
```bash
npx @modelcontextprotocol/inspector dotnet run --project ./PnPPowerShell.MCPServer.csproj
```
Wait for the inspector to start and open it in your browser. You should see the MCP server running, and you can query and execute its tools locally.
### Publishing a native AOT build
```bash
dotnet publish -c Release -r win-x64 --self-contained
```
Replace `win-x64` with your target [RuntimeIdentifier](https://learn.microsoft.com/dotnet/core/rid-catalog) (`linux-x64`, `osx-arm64`, etc.). The output is a single native executable with no .NET runtime dependency.
Native AOT needs a platform toolchain: the "Desktop development with C++" workload on Windows, Xcode command line tools on macOS, or `clang` and `zlib1g-dev` on Linux.
### Releasing to NuGet
A release is **eight** packages — a small wrapper plus one per platform — and a plain `dotnet pack` builds only the wrapper. Do not publish by hand; see [RELEASING.md](./RELEASING.md) and use the [Release workflow](./.github/workflows/release.yml). The same workflow then lists the release on the [Official MCP Registry](https://registry.modelcontextprotocol.io/) from [`.mcp/server.json`](./.mcp/server.json).
## Contributing to PnP PowerShell MCP Server
Follow the [getting started contributing](/CONTRIBUTING.md) guidelines to help out. Sharing is caring!
## Supportability and SLA
This library is open-source and community provided library with active community providing support for it. This is not Microsoft provided module so there's no SLA or direct support for this open-source component from Microsoft. For more information about the PnP initiative, check out the official website: [Microsoft 365 & Power Platform Community](https://pnp.github.io).
## 🔗 Resources
- [PnP PowerShell documentation](https://pnp.github.io/powershell/)
- [PnP Script Samples](https://pnp.github.io/script-samples/)
- [MCP C# SDK](https://github.com/modelcontextprotocol/csharp-sdk)
- [MCP servers](https://github.com/modelcontextprotocol/servers?tab=readme-ov-file)
- [MCP inspector](https://github.com/modelcontextprotocol/inspector)
This server cannot be deployed
Maintenance
ActivityActive
ResponsivenessResponsive