PnP PowerShell

io.github.pnpv0.1.7-betaUpdated Oct 5, 2026

Manage Microsoft 365 in natural language with PnP PowerShell and author PnP PowerShell scripts.

Overview

AI-generated overview

Lets an assistant run PnP PowerShell commands against a connected Microsoft 365 tenant and find, adapt, and save PnP PowerShell scripts.

What it does
The server exposes tools to search a compiled-in index of PnP PowerShell cmdlets, fetch cmdlet documentation, and run PnP PowerShell in a persistent session against a tenant the user has already signed in to. It can check connection status, diagnose missing prerequisites, reset sessions, and page through large result sets. It also searches community and personal script samples, retrieves their code, suggests samples for a task, and saves working scripts as .ps1 files.
When to use it
Use it when you want an assistant to manage SharePoint Online, Teams, or other Microsoft 365 resources in natural language, or to author and reuse PnP PowerShell scripts. It suits users who already work with PnP PowerShell and want an agent to drive it.
Requirements
Runs locally over stdio as a .NET global tool; PowerShell 7.4 or later (pwsh) must be on PATH, and the PnP.PowerShell module must be installed. The server does not authenticate: the user must first connect with Connect-PnPOnline, and the server reuses that connection. Optional environment variables include PNP_MCP_READONLY, PNP_MCP_ALLOW_SETUP, PNP_MCP_CONFIRM_DESTRUCTIVE, PNP_MCP_COMMAND_TIMEOUT_SECONDS, PNP_MCP_MAX_OUTPUT_CHARS, and PNP_SCRIPT_SAMPLES_PATH.
Before you install
Commands run against a real Microsoft 365 tenant and can change or delete data; destructive commands require confirmation unless PNP_MCP_CONFIRM_DESTRUCTIVE is set to false, which removes that gate. PNP_MCP_READONLY=true refuses changing commands. PNP_MCP_ALLOW_SETUP=true lets the server install the PnP.PowerShell module for the current user. Saved scripts are whatever the model wrote, so review them before sharing the folder. Git sample URLs use existing Git credentials.

Installation

In SourceWeft

  1. Open PnP PowerShell in the dashboard and add it to a workspace.
  2. Enable the server for the chats that should use its tools.

Desktop only via STDIO. STDIO servers start a local process, so they need the SourceWeft desktop host.

Other MCP clients

Follow the launch instructions in the repository.

README

PnP PowerShell MCP Server

πŸ’‘ Description

This MCP server allows the use of natural language to run PnP 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 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 (only required to build/run from source β€” published tool releases are self-contained)

  • PowerShell 7.4 or above (pwsh) installed and available on PATH

  • The 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 module β€” it does not do any authentication for you. Authenticate first using Connect-PnPOnline (see Best Practices for the recommended auth methods), then the MCP server will reuse the same PnP PowerShell connection context.

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. Maintainers: see 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, you may add the PnP PowerShell MCP server to Copilot by doing the following:

  1. Start the Copilot CLI:

    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 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

ToolDescription
pnp_search_commandsFinds 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_docsGets 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_commandRuns 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_pageReturns 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_statusChecks whether the session is signed in, to which site, and as which account.
pnp_diagnose_connectionChecks 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_sessionEnds a session and its PnP connection. Use it to sign out, switch accounts, or recover a session that has stopped responding.
pnp_get_best_practicesReturns 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_samplesLists community PnP 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_sampleRetrieves 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_scriptFinds 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_sampleSaves 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_pingReturns 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_sessionsLists 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_environmentInstalls 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 against the published descriptions and fails the build if the right tool is not ranked in the top three. See 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.

URIContents
pnp://best-practicesThe 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 sessionIdWith sessionId
Session useddefaultthe name you pass
Connectionone, sharedone per session name
Variables ($sites, ...)sharedisolated 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. 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:

EntryRead as
A full folder path, e.g. C:\scripts\pnpEvery .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 URLA 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 cloneIts 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
<#.SYNOPSISReport sites with no activity in the last 180 days.DESCRIPTIONLists 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 variableDefaultDescription
PNP_MCP_COMMAND_TIMEOUT_SECONDS600Wall-clock limit for a single pnp_run_command call. On timeout the session is terminated and the connection is lost.
PNP_MCP_CONFIRM_DESTRUCTIVEtrueSet 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_READONLYfalseSet 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 for the full table. Local file output (Out-File, Export-*) is still permitted.
PNP_MCP_ALLOW_SETUPfalseSet 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_CHARS50000Largest 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.
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 clones, and https:// or ssh:// Git URLs. pnp_save_script_sample writes to the first plain folder listed. See 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 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.

VS Code β€” .vscode/mcp.json (or the user-level mcp.json)
json
{    "servers": {        "PnP PowerShell MCP Server": {            "type": "stdio",            "command": "pnp-powershell-mcp-server",            "env": {                "PNP_MCP_READONLY": "true",                "PNP_MCP_COMMAND_TIMEOUT_SECONDS": "1800"            }        }    }}
Claude Desktop β€” claude_desktop_config.json
json
{  "mcpServers": {    "PnP-PowerShell": {      "command": "pnp-powershell-mcp-server",      "env": {        "PNP_MCP_READONLY": "true"      }    }  }}
Cursor β€” mcp.json
json
{  "mcpServers": {    "PnP PowerShell MCP Server": {      "type": "stdio",      "command": "pnp-powershell-mcp-server",      "env": {        "PNP_MCP_READONLY": "true"      }    }  }}
Claude Code β€” claude mcp add
bash
claude mcp add pnp-powershell --scope user \  --env PNP_MCP_READONLY=true \  --env PNP_MCP_COMMAND_TIMEOUT_SECONDS=1800 \  -- pnp-powershell-mcp-server

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 / LinuxPNP_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
GoalSetting
Let an agent explore a production tenant without being able to change itPNP_MCP_READONLY=true
Tenant-wide reports that take longer than 10 minutesPNP_MCP_COMMAND_TIMEOUT_SECONDS=3600
Unattended automation where the commands are already reviewedPNP_MCP_CONFIRM_DESTRUCTIVE=false
Search and save your own scripts, plus your team's repositoryPNP_SCRIPT_SAMPLES_PATH=C:\scripts;https://github.com/contoso/pnp-scripts.git
Work against a script-samples clone newer than the vendored indexPNP_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 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:

FileContentsUsed by
data/script-samples.jsonThe PnP Script Samples catalogue β€” name, title, description, tags, authorspnp_search_script_samples, pnp_get_script_sample, pnp_suggest_script
data/pnp-commands.jsonEvery PnP.PowerShell cmdlet name, with the URL templates for its markdown and HTML documentationpnp_get_command_docs
data/pnp-index.jsonThe search corpus β€” synopsis, description, parameters, parameter sets and examples per cmdlet, plus the superseded-alias mappnp_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 clone listed there also serves contributors working against a newer catalogue.

Tool selection

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, 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 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:

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 (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 and use the Release workflow. The same workflow then lists the release on the Official MCP Registry from .mcp/server.json.

Contributing to PnP PowerShell MCP Server

Follow the getting started contributing 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.

πŸ”— Resources

Source: README.md at commit 2f279fd

Tools

0
Tool metadata has not been indexed yet.

Version history

1
  1. v0.1.7-betaLatestOct 5, 2026