PnP PowerShell MCP Server
OfficialClick on "Deploy Server".
Wait a few minutes for the server to deploy. Once ready, it will show a "Started" state.
In the chat, type
@followed by the MCP server name and your instructions, e.g., "@PnP PowerShell MCP Serverlist all SharePoint sites in my tenant"
That's it! The server will respond to your query, and you can continue using it as needed.
Here is a step-by-step guide with screenshots.
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.
Related MCP server: Selis MCP Server
📦 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 onPATHThe
PnP.PowerShellmodule installed: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.
TYPE:
Local(stdio)INSTALL:
The one-click buttons above register the server under the name
pnp-powershelland point it at thepnp-powershell-mcp-servercommand, so install the tool first (below) — otherwise the client will register a server it cannot start.
Install as a .NET global tool
dotnet tool install --global PnP.PowerShell.MCPServer --prereleaseThis 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:
dotnet tool update --global PnP.PowerShell.MCPServer --prereleaseHitting
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 affects0.1.1-betaand earlier — install0.1.3-betaor later, or build and run from source. Maintainers: see RELEASING.md.
Add to VS Code
Open the Command Palette (Ctrl+Shift+P or Cmd+Shift+P on macOS) and type
MCP: Add Server.Select
Command (stdio)as the server type.Enter the command to run the MCP server:
pnp-powershell-mcp-serverName the server (e.g.,
PnP PowerShell MCP Server).
As a result, you should have the following configuration in your .vscode/mcp.json file:
{
"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:
Start the Copilot CLI:
copilotUse the copilot mcp command to add the MCP server:
/mcp addFill in the MCP form:
Server name: whatever you like, without spaces, e.g.
pnp-powershell-mcp-serverServer type:
LocalCommand:
pnp-powershell-mcp-serverArguments: 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
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
In Claude Desktop, open Settings by clicking on the hamburger icon in the top left corner.
Select File > Settings (or press
Ctrl + ,).In the Developer tab, click Edit Config. Note: If you don't see the Developer tab, enable it first from Help > Enable Developer Mode.
This opens explorer; edit
claude_desktop_config.jsonin your favorite text editor and add:{ "mcpServers": { "PnP-PowerShell": { "command": "pnp-powershell-mcp-server" } } }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
From the chat option pick the
Agent settingsoption.Go to
Tools & MCPtab and click onNew MCP server.Modify the
mcp.jsonconfiguration as follows:{ "mcpServers": { "PnP PowerShell MCP Server": { "type": "stdio", "command": "pnp-powershell-mcp-server" } } }Save and enable the
PnP PowerShell MCP Serverin theTools & MCPtab 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:
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:
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:
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:
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:
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:
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 |
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 |
pnp_get_result_page | Returns the next page of a result set |
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: |
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 |
pnp_search_script_samples | Lists community PnP Script Samples, plus your own from |
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 |
pnp_ping | Returns the server version, uptime, read-only mode status, and active session count, and — unless |
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 |
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.
URI | Contents |
| The whole guidance document. |
| One section: |
| Help text for one cmdlet, preceded by its published documentation URL — e.g. |
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 | With | |
Session used |
| the name you pass |
Connection | one, shared | one per session name |
Variables ( | 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:
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:
// 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:
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
sessionIdvalues.Reuse the connection. Do not re-run
Connect-PnPOnlinebefore every command; checkpnp_get_connection_statusfirst. 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.
{
"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. | Every |
An | 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 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:
<#
.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 -NoTypeInformationIts 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:
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 |
|
| Wall-clock limit for a single |
|
| Set to |
|
| Set to |
|
| Set to |
|
| 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 |
| (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. |
| (unset) | Testing only. Writes a scrubbed fixture for every command the server runs, into this directory. |
| (unset) | Your own script samples, searched alongside the community index and ranked ahead of a community sample that matches about as well. A |
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.
{
"servers": {
"PnP PowerShell MCP Server": {
"type": "stdio",
"command": "pnp-powershell-mcp-server",
"env": {
"PNP_MCP_READONLY": "true",
"PNP_MCP_COMMAND_TIMEOUT_SECONDS": "1800"
}
}
}
}{
"mcpServers": {
"PnP-PowerShell": {
"command": "pnp-powershell-mcp-server",
"env": {
"PNP_MCP_READONLY": "true"
}
}
}
}{
"mcpServers": {
"PnP PowerShell MCP Server": {
"type": "stdio",
"command": "pnp-powershell-mcp-server",
"env": {
"PNP_MCP_READONLY": "true"
}
}
}
}claude mcp add pnp-powershell --scope user \
--env PNP_MCP_READONLY=true \
--env PNP_MCP_COMMAND_TIMEOUT_SECONDS=1800 \
-- pnp-powershell-mcp-serverIn 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:
# macOS / Linux
PNP_MCP_READONLY=true code .# 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:
# 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 |
|
Tenant-wide reports that take longer than 10 minutes |
|
Unattended automation where the commands are already reviewed |
|
Search and save your own scripts, plus your team's repository |
|
Work against a script-samples clone newer than the vendored index |
|
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:
dotnet buildRunning 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:
dotnet run --project FULL_PATH_TO_YOUR_PROJECT/PnPPowerShell.MCPServer.csprojName 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:
File | Contents | Used by |
The PnP Script Samples catalogue — name, title, description, tags, authors |
| |
Every |
| |
The search corpus — synopsis, description, parameters, parameter sets and examples per cmdlet, plus the superseded-alias map |
|
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:
# 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.ps1Because 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:
$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 RecordedPlaybackTestsRead 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:
npx @modelcontextprotocol/inspector dotnet run --project ./PnPPowerShell.MCPServer.csprojWait 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
dotnet publish -c Release -r win-x64 --self-containedReplace 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
This server cannot be deployed
Maintenance
Related MCP Connectors
Official Microsoft MCP Server to query Microsoft Entra data using natural language
Use AI models for chat, image, and video generation from Claude Code and other MCP hosts.
Search & install 13,000+ AI agent skills from skills-hub.ai inside any MCP tool.
Personal assistant MCP server with search, execute, packages, jobs, secrets, and integrations.
Related MCP Servers
- AlicenseBqualityAmaintenanceAn MCP server that enables running CLI for Microsoft 365 commands through GitHub Copilot Agent, allowing users to interact with Microsoft 365 services using natural language.4861 npm132MIT
- AlicenseNot gradedqualityDmaintenanceEnables management of PMO entities such as actions, risks, issues, projects, deliverables, decisions, KPIs, and objectives through natural language from any MCP-compatible agent.52 npmServer Side Public , v 1
- FlicenseAqualityFmaintenanceA skill-based Microsoft 365 admin MCP server that exposes 4 base tools to execute Graph and Power Platform API calls, enabling administrative tasks via natural language.4-
- AlicenseNot gradedqualityAmaintenanceMCP server for advanced PowerShell integration with AI agents, enabling autonomous script execution, code analysis, and IntelliSense.3MIT