---
title: "PowerShell MCP Server: Automate Windows Admin Tasks"
description: "Build a PowerShell MCP server with PSMCP, register it in Claude Desktop, and expose four read-only Windows tools the model can call safely."
canonical: "https://adamtheautomator.com/powershell-mcp-server-automate-windows-admin-tasks/"
---

# PowerShell MCP Server: Automate Windows Admin Tasks

> Build a PowerShell MCP server with PSMCP, register it in Claude Desktop, and expose four read-only Windows tools the model can call safely.

Source: https://adamtheautomator.com/powershell-mcp-server-automate-windows-admin-tasks/

---

ATA Learning

Tap to hide

[

ATA Learning

](/)

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Search for:  

*   [](https://twitter.com/adbertram)
*   [](https://github.com/Adam-the-Automator)
*   [](https://www.linkedin.com/company/adam-the-automator-llc)
*   [](/feed/)

![PowerShell MCP Server: Automate Windows Admin Tasks](https://adamtheautomator.com/wp-content/uploads/publisher/4065d9c85b2b812bba2df8cb767fbe34/9ae655c39d56002e696384ad1bf2fb8f518c75940da65a91ac2668477e001dd3.webp)

# PowerShell MCP Server: Automate Windows Admin Tasks

[![](https://secure.gravatar.com/avatar/d0b9d42e21e5622713f8b693aa5c0f9244d5f7dd200ed29b8398f52dee5de337?s=192&d=mm&r=g)Adam Bertram](https://adamtheautomator.com/author/adam-bertram/)5 November 202617 min. read

Categories: [AI Agents](/category/ai-agents/)

Tags:[PowerShell](/tag/powershell/)[Windows](/tag/windows/)[AI](/tag/ai/)[Automation](/tag/automation/)

Table of Contents

*   [What a PowerShell MCP Server Actually Does](#what-a-powershell-mcp-server-actually-does)
*   [The Client, the Child Process, and the Loop](#the-client-the-child-process-and-the-loop)
*   [Who Owns the Security Model](#who-owns-the-security-model)
*   [Meet PSMCP, the Module Behind This Build](#meet-psmcp-the-module-behind-this-build)
*   [Three Modules, One Search Query](#three-modules-one-search-query)
*   [Prerequisites](#prerequisites)
*   [Step 1: Install PSMCP and Scaffold the Project](#step-1-install-psmcp-and-scaffold-the-project)
*   [Install the Module Without Hitting a Prompt](#install-the-module-without-hitting-a-prompt)
*   [Scaffold With New-MCP](#scaffold-with-new-mcp)
*   [The Four Conventions in the Entry Point](#the-four-conventions-in-the-entry-point)
*   [Step 2: Write Tools the Model Can Call Correctly](#step-2-write-tools-the-model-can-call-correctly)
*   [Comment-Based Help Is the Tool Contract](#comment-based-help-is-the-tool-contract)
*   [Four Read-Only Windows Tools](#four-read-only-windows-tools)
*   [Handle Your Own Exceptions](#handle-your-own-exceptions)
*   [Step 3: Register the Server in Claude Desktop](#step-3-register-the-server-in-claude-desktop)
*   [Three Fields That Break on Paste](#three-fields-that-break-on-paste)
*   [Quit and Relaunch](#quit-and-relaunch)
*   [Step 4: Prove the Protocol Before Claude Touches It](#step-4-prove-the-protocol-before-claude-touches-it)
*   [Send One Initialize Request](#send-one-initialize-request)
*   [Read What Comes Back](#read-what-comes-back)
*   [Five Deviations That Decide Whether It Works](#five-deviations-that-decide-whether-it-works)
*   [The Array That Becomes an Object](#the-array-that-becomes-an-object)
*   [The Call That Never Answers](#the-call-that-never-answers)
*   [The Security Boundary Is the Function List](#the-security-boundary-is-the-function-list)
*   [What the Policy Does and Does Not Cover](#what-the-policy-does-and-does-not-cover)
*   [Where Confirmation Actually Lives](#where-confirmation-actually-lives)
*   [Four Rules That Hold](#four-rules-that-hold)
*   [Troubleshooting the Claude Desktop Connector](#troubleshooting-the-claude-desktop-connector)
*   [The Connector Never Appears](#the-connector-never-appears)
*   [The Tools List Is Empty](#the-tools-list-is-empty)
*   [Calls Time Out With No Error](#calls-time-out-with-no-error)
*   [Where This Leaves You](#where-this-leaves-you)

Open `claude_desktop_config.json` and add one object to it. That object tells [Claude Desktop](https://claude.com/download) to start a PowerShell process of your choosing, keep it alive in the background, and hand every request the model makes to that process over standard input. Get the object right and Claude stops writing PowerShell at you and starts running it on the machine in front of you.

This post builds that server on Windows with PSMCP, the PowerShell MCP module by Doug Finke. You install the module, scaffold a project, replace the sample function with four read-only Windows tools, and register the result in Claude Desktop. Then you prove the protocol works by hand, before you trust anything the model reports about your servers. The second half covers four defects in the module that you will hit in the first hour, plus one platform trap for readers who deploy from macOS, and the security decisions that keep a natural-language console from becoming an unlogged remote shell.

## What a PowerShell MCP Server Actually Does

A PowerShell MCP server is a script that speaks the [Model Context Protocol](https://modelcontextprotocol.io/docs/learn/architecture) over standard input and standard output. MCP is JSON-RPC 2.0 with a fixed vocabulary. The client sends `initialize`, asks for the tool list with `tools/list`, and invokes one tool at a time with `tools/call`. Claude Desktop plays the client role: it spawns your script as a child process at startup and reads one JSON line back per response.

### The Client, the Child Process, and the Loop

The mechanics stay dull in PowerShell, which is the useful part. Your entry-point script defines functions, passes their names to a blocking read loop, and that loop writes one JSON object to stdout for each request it handles. The loop never returns, so the script has to be the last thing in the file and Claude Desktop owns the process lifecycle.

The model sees three things per tool: a name, a JSON schema describing the parameters, and the text your function returns. There is no shell in that loop and no command string to parse. A tool call is a function invocation with typed arguments, and that distinction is the whole reason a purpose-built PowerShell server beats a generic `run_command` wrapper with a model attached. PSMCP implements the pattern in seven functions, and its rough edges appear exactly where you would expect in a 0.1.x module.

### Who Owns the Security Model

The protocol carries no permission system of its own. The host asks the user to approve a tool call, and the server executes the function it was told to execute, under the token of the account that launched it. Everything the model can do on your Windows box is the set of functions your script publishes. That fact drives most of the decisions in the second half of this post. That is the local form of the protocol; when a team needs the same tools without your laptop running, the hosting side is covered in [A Practical Guide to Hosting and Managing Remote MCP Servers on Azure](https://adamtheautomator.com/practical-guide-hosting-managing-remote-mcp/).

## Meet PSMCP, the Module Behind This Build

PSMCP (PowerShell Model Context Protocol) supplies the request loop, a schema generator, and a JSON-line logger. Version [0.1.2](https://www.powershellgallery.com/packages/PSMCP/0.1.2) was published on April 28, 2025, and it is the second release in the package’s history. The Gallery listing reports about 460 downloads for that version. One maintainer, two releases, a few hundred installs: you are evaluating an early tool, so read [the source](https://github.com/dfinke/PSMCP) and pin the version in your deployment notes.

### Three Modules, One Search Query

Three projects collide in the same search results, and they are not interchangeable. PSMCP is the one installed here. [PoshMCP](https://github.com/cezarypiatek/PoshMCP), by Cezary Piątek, takes the opposite design stance with an explicit allow-list of approved cmdlets. The third project, [PowerShell.MCP](https://github.com/yotsuda/PowerShell.MCP), ships a ready-made server whose tool list you inherit. Search for “PowerShell MCP server” and you will find all three, so check the repository owner before you run `Install-Module`.

PSMCP is not a Microsoft product and is not supported by the PowerShell team. Its manifest still declares `PowerShellVersion = '5.1'`, even though the module installs and runs on PowerShell 7.6. That combination tells you what you are holding: a working prototype with a small surface area. `Get-Command -Module PSMCP` lists seven functions after you install 0.1.2, and the module exports nothing else: `New-MCP` scaffolds a project, `Register-MCPTool` emits the tool schemas, `Start-McpServer` runs the blocking loop, `Invoke-HandleRequest` routes the four request types, `Get-MCPTemplate` lists the templates, and `Write-Log` with `Set-LogFile` handle the JSON-line log.

Those seven functions are the whole public surface. When a behavior here surprises you, one of them is the place to look.

## Prerequisites

If you want to follow along on a real machine, you need the following in place before the first command:

*   **PowerShell 7 on Windows.** Claude Desktop launches the executable you name in its config, so install [PowerShell 7](https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows?view=powershell-7.6) and note the absolute path to `pwsh.exe`, which defaults to `C:\Program Files\PowerShell\7\pwsh.exe`. These commands were tested with PSMCP 0.1.2 on PowerShell 7.6.
    
*   **Claude Desktop**, current version. Skip it if you only want to drive the server by hand from a terminal.
    
*   **A Windows machine you are allowed to change.** The four tools below are read-only, but the child process inherits your token, so use a lab VM for the first run.
    
*   **An execution policy that permits a local script.** [about\_Execution\_Policies](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.6) covers the scopes; `RemoteSigned` in the `CurrentUser` scope is enough on a workstation.
    
*   **A standard user account.** The module narrows nothing about your rights, so the account you launch from sets the blast radius.
    
*   **Optional: VS Code**, only if you want to use the `.vscode/mcp.json` the scaffold writes.
    

## Step 1: Install PSMCP and Scaffold the Project

The module gives you the request loop and the schema generator. The scaffold gives you a working entry point with logging already wired, which is worth more than it sounds once you are debugging a connector that will not start.

### Install the Module Without Hitting a Prompt

```powershell
Set-PSRepository -Name PSGallery -InstallationPolicy Trusted
Install-Module -Name PSMCP -Scope CurrentUser -Force
```

The first Gallery install in a session can stop on two prompts: whether to install the NuGet provider and whether to trust PSGallery. Either one hangs an unattended script. The repository line answers the trust question for the current user, and `-Force` suppresses the version and confirmation prompts.

Confirm the install produced the seven functions before you go further:

```powershell
Get-Command -Module PSMCP | Select-Object -ExpandProperty Name
```

A healthy install prints all seven names, one per line, as the capture below shows.

![PSMCP exported functions](https://adamtheautomator.com/wp-content/uploads/publisher/f1c31320f618d535113b5c128e866908e71d0dd02d2b0e6725b5bf87c7b90a2c.jpg)

If that command returns nothing, the module landed in a path your current session does not search. Check `$env:PSModulePath` against the location `Install-Module` reported. Keep that mismatch in mind, because the same problem is the most common cause of a connector that works in your terminal and fails inside Claude Desktop.

### Scaffold With New-MCP

```powershell
New-MCP -Path C:\MCPServers\OpsServer -ServerName OpsServer
```

`-Path` is the directory to create, and `-ServerName` names the entry-point script; it defaults to `MCPServer`. `New-MCP` is declared with `SupportsShouldProcess`, so it supports `-WhatIf` and `-Confirm`, and `-Force` overwrites a directory that already holds an entry point. Two files appear: `OpsServer.ps1` and a `.vscode\mcp.json`. The second file is a VS Code document, and pasting it into Claude Desktop unchanged is the mistake covered in Step 3.

### The Four Conventions in the Entry Point

The generated script is short, and each line encodes a rule you have to keep while you edit it. These four matter most:

1.  `#Requires -Module PSMCP` sits at the top so the process fails loudly when the module is missing.
    
2.  `Set-LogFile "$PSScriptRoot\mcp_server.log"` runs before anything else, because the module’s built-in default is the hardcoded path `D:\testmcp\mcp_server.log`. On a machine without a `D:` drive, every log write fails with `Add-Content: Cannot find drive. A drive with the name 'D' does not exist.` That is a non-terminating error, so the server keeps answering requests while recording nothing at all.
    
3.  Functions are declared `function Global:` because module-scoped functions are unreachable from the dispatcher inside [Invoke-HandleRequest](https://github.com/dfinke/PSMCP/blob/main/Public/Invoke-HandleRequest.ps1), which calls your tool as `& $toolName`.
    
4.  `Start-McpServer` takes the function names positionally and sits at the end of the file, since it blocks until the parent process dies.
    

The scaffold’s `Invoke-Addition` function is the “hello world” of this module. Its body is the part you replace.

## Step 2: Write Tools the Model Can Call Correctly

The model gets one line of semantics per tool, and in this module that line is the function name. Everything else it knows about your tool comes from the generated parameter schema, which makes the help block the interface.

### Comment-Based Help Is the Tool Contract

[Register-MCPTool](https://github.com/dfinke/PSMCP/blob/main/Public/Register-MCPTool.ps1) calls `Get-Command` and `Get-Help`, maps each parameter to a JSON Schema type, and fills each property description from the parameter help text. A function with no `.SYNOPSIS` or `.DESCRIPTION` still registers. `Get-Help` synthesises a synopsis from the signature, so the guard that looks for an empty description never fires and the server starts. A missing help block costs you the parameter descriptions and nothing else.

The tool `description` field is computed from `Get-Help` and then overwritten with the command name, so a captured `tools/list` shows `"description":"Invoke-Addition"` instead of “Adds two numbers together”. Register that consequence in your naming choices. `Get-DiskFreeSpace` is discoverable by a model reading a tool list, and `gdfs` is a guess.

### Four Read-Only Windows Tools

These four cover most of the questions that generate a ticket, and each takes two parameters or fewer, which keeps the generated schema small and the model’s job easy. Save this over the body of `OpsServer.ps1`:

```powershell
#Requires -Module PSMCP

Set-LogFile "$PSScriptRoot\mcp_server.log"

function Global:Get-DiskFreeSpace {
    <#
    .SYNOPSIS
        Reports free space on fixed local volumes.
    .DESCRIPTION
        Returns one record per fixed volume with total size, free space, and percent free.
    .PARAMETER DriveLetter
        Drive letter to query, for example C. Omit it to return every fixed volume.
    #>
    [CmdletBinding()]
    param(
        [string]$DriveLetter
    )

    try {
        $volumes = Get-Volume -ErrorAction Stop |
            Where-Object { $_.DriveType -eq 'Fixed' -and $_.DriveLetter }

        if ($DriveLetter) {
            $target = $DriveLetter.TrimEnd(':')
            $volumes = $volumes | Where-Object { $_.DriveLetter -eq $target }
        }

        $output = foreach ($volume in $volumes) {
            [pscustomobject]@{
                Drive       = "$($volume.DriveLetter):"
                FileSystem  = $volume.FileSystemType
                SizeGB      = [math]::Round($volume.Size / 1GB, 1)
                FreeGB      = [math]::Round($volume.SizeRemaining / 1GB, 1)
                PercentFree = [math]::Round(($volume.SizeRemaining / $volume.Size) * 100, 1)
            }
        }

        $output | ConvertTo-Json -Depth 3 -Compress
    }
    catch {
        "Get-DiskFreeSpace failed: $($_.Exception.Message)"
    }
}

function Global:Get-ServiceHealth {
    <#
    .SYNOPSIS
        Reports the state of a Windows service.
    .DESCRIPTION
        Returns name, display name, status, and start type for services matching a name.
    .PARAMETER Name
        Service name or wildcard pattern, for example Spooler or Win*.
    #>
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$Name
    )

    try {
        $output = Get-Service -Name $Name -ErrorAction Stop | ForEach-Object {
            [pscustomobject]@{
                Name        = $_.Name
                DisplayName = $_.DisplayName
                Status      = "$($_.Status)"
                StartType   = "$($_.StartType)"
            }
        }
        $output | ConvertTo-Json -Depth 3 -Compress
    }
    catch {
        "Get-ServiceHealth failed for '$Name': $($_.Exception.Message)"
    }
}

function Global:Get-RecentSystemEvent {
    <#
    .SYNOPSIS
        Returns recent entries from the System event log.
    .DESCRIPTION
        Reads the System log at or above a severity level and returns the newest entries first.
    .PARAMETER Count
        Number of entries to return. Defaults to 20.
    .PARAMETER Level
        Minimum severity: Critical, Error, Warning, or Information. Defaults to Error.
    #>
    [CmdletBinding()]
    param(
        [int]$Count = 20,
        [ValidateSet('Critical', 'Error', 'Warning', 'Information')]
        [string]$Level = 'Error'
    )

    try {
        $levelMap = @{ Critical = 1; Error = 2; Warning = 3; Information = 4 }
        $levels = @($levelMap[$Level]..4)

        $output = Get-WinEvent -FilterHashtable @{ LogName = 'System'; Level = $levels } -MaxEvents $Count -ErrorAction Stop |
            ForEach-Object {
                $text = if ($_.Message) { $_.Message.Substring(0, [math]::Min(300, $_.Message.Length)) } else { '' }
                [pscustomobject]@{
                    TimeCreated = $_.TimeCreated.ToString('s')
                    Id          = $_.Id
                    Severity    = $_.LevelDisplayName
                    Provider    = $_.ProviderName
                    Message     = $text
                }
            }

        $output | ConvertTo-Json -Depth 3 -Compress
    }
    catch {
        "Get-RecentSystemEvent failed: $($_.Exception.Message)"
    }
}

function Global:Get-PendingReboot {
    <#
    .SYNOPSIS
        Reports whether the computer is waiting on a restart.
    .DESCRIPTION
        Checks servicing, Windows Update, and pending file rename keys for restart state.
    #>
    [CmdletBinding()]
    param()

    try {
        $checks = [ordered]@{
            ComponentBasedServicing = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Component Based Servicing\RebootPending'
            WindowsUpdate           = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\WindowsUpdate\Auto Update\RebootRequired'
            PendingFileRename       = Test-Path 'HKLM:\SYSTEM\CurrentControlSet\Control\Session Manager\PendingFileRenameOperations'
        }

        [pscustomobject]@{
            ComputerName = $env:COMPUTERNAME
            Pending      = ($checks.Values -contains $true)
            Checks       = $checks
        } | ConvertTo-Json -Depth 3 -Compress
    }
    catch {
        "Get-PendingReboot failed: $($_.Exception.Message)"
    }
}

Start-McpServer Get-DiskFreeSpace, Get-ServiceHealth, Get-RecentSystemEvent, Get-PendingReboot
```

`ConvertTo-Json -Compress` returns one line, which keeps the protocol stream clean and the model’s context small. `Get-RecentSystemEvent` truncates each message at 300 characters, because one verbose `Get-WinEvent` record can run past a page of text and flood the conversation. And `Get-PendingReboot` publishes a single boolean the model can act on directly.

### Handle Your Own Exceptions

Every function above wraps its body in `try`/`catch` and returns a string on failure, because this module requires it. [Start-McpServer](https://github.com/dfinke/PSMCP/blob/main/Public/Start-McpServer.ps1) wraps request handling in `try { ... } catch { }`, and an exception in your tool is swallowed there. The server writes nothing to stdout, and the client waits until its own timeout expires with no error object and no partial result. A tool that throws is a tool that hangs, as far as the model can tell.

Returning `"Get-ServiceHealth failed for 'Spooler': ..."` gives the model something to read and act on. Anything you cannot express that way, such as a command that must prompt a human, does not belong in this server.

## Step 3: Register the Server in Claude Desktop

Claude Desktop reads a single JSON file and spawns every server listed in it at startup. On Windows that file is `%APPDATA%\Claude\claude_desktop_config.json`. Choose **Settings**, then **Developer**, then **Edit Config** to open or create it, as described in the MCP guide to [connecting local servers](https://modelcontextprotocol.io/docs/develop/connect-local-servers).

### Three Fields That Break on Paste

The scaffolded `.vscode\mcp.json` does not paste into that file unchanged. Three things need to change. The VS Code key is `"servers"` and Claude Desktop reads `"mcpServers"`. VS Code expands `${workspaceFolder}` and Claude Desktop does not, so every path has to be absolute. And the scaffold’s `"type": "stdio"` key comes from VS Code: the `mcpServers` schema in Claude Desktop’s documentation lists `command`, `args`, and `env` only, so leave `type` out of the desktop file.

This is the block that works for a server scaffolded as `C:\MCPServers\OpsServer`:

```json
{
  "mcpServers": {
    "ops-powershell": {
      "command": "C:\\Program Files\\PowerShell\\7\\pwsh.exe",
      "args": [
        "-NoProfile",
        "-NoLogo",
        "-NonInteractive",
        "-File",
        "C:\\MCPServers\\OpsServer\\OpsServer.ps1"
      ],
      "env": {
        "PSModulePath": "C:\\Program Files\\PowerShell\\7\\Modules"
      }
    }
  }
}
```

`command` is absolute because Claude Desktop launches the child itself and does not resolve `pwsh` through a login shell. A bare `pwsh` can land on Windows PowerShell 5.1 or on nothing at all.

`-NoProfile` is mandatory. PowerShell profiles print banners and status lines, and anything written to stdout by a profile corrupts the JSON-RPC stream before the first request is answered, which is the classic cause of a connector that initializes and immediately drops.

`-File` passes the script path literally, so arguments containing spaces or quotes cannot be re-parsed as PowerShell syntax later. `-Command` with the same path also works and is what the scaffold generates, but `-File` is the safer choice once your tool signatures accept free text. `-NonInteractive` stops PowerShell from ever prompting on the child’s stdin, which is the protocol channel.

`env` pins `PSModulePath` because Claude Desktop does not inherit your interactive session, and a module that `Get-Command -Module PSMCP` finds in your terminal can still be invisible to the child. The single path above assumes a machine-wide install. If you used `-Scope CurrentUser` in Step 1, the module sits in `%USERPROFILE%\Documents\PowerShell\Modules`, so add that directory to the value and separate the two paths with a semicolon.

### Quit and Relaunch

1.  Save the file and fully quit Claude Desktop from the tray icon. Closing the window leaves the process running, and the manifest is only parsed at startup.
    
2.  Relaunch the application.
    
3.  Open the connector list from the **Add files, connectors, and more** control in the composer.
    
4.  Expect `ops-powershell` to appear in that list with the four tools you registered.
    

![Connector with four tools](https://adamtheautomator.com/wp-content/uploads/publisher/0efa227e0e18a5f2d5e0e5b9b055c2a094ca2b2bf31823e1e4b3c0e029d1262e.jpg)

A listed server with an empty tool list means the process started and `Start-McpServer` received nothing it could publish. Go back to the `Start-McpServer` line in `OpsServer.ps1`.

## Step 4: Prove the Protocol Before Claude Touches It

A server that throws during startup dies before it can speak protocol, and its stack trace goes to a log file you have to find. Testing the protocol by hand first moves that failure into a terminal where you can read it.

### Send One Initialize Request

Write one request to a file, start the entry point with its stdin and stdout redirected, give it three seconds, then kill it:

```powershell
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"probe","version":"0.0.1"}}}' |
    Set-Content -Path C:\MCPServers\initialize.jsonl -Encoding utf8NoBOM

$probe = Start-Process -FilePath 'C:\Program Files\PowerShell\7\pwsh.exe' `
    -ArgumentList '-NoProfile', '-NoLogo', '-File', 'C:\MCPServers\OpsServer\OpsServer.ps1' `
    -RedirectStandardInput 'C:\MCPServers\initialize.jsonl' `
    -RedirectStandardOutput 'C:\MCPServers\initialize.out' `
    -PassThru

Start-Sleep -Seconds 3
Stop-Process -Id $probe.Id -Force
Get-Content C:\MCPServers\initialize.out
```

The kill is deliberate. When stdin closes, `ReadLine` returns `$null` forever and the loop spins at full CPU instead of exiting, so nothing ends the process except the parent-process watchdog. Run by hand with redirected input, it will sit there burning a core until you stop it.

### Read What Comes Back

A healthy server answers with one line. This response came from a scaffolded PSMCP 0.1.2 template server driven over stdio:

```json
{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"0.3.0","capabilities":{"tools":{"listChanged":false}},"serverInfo":{"name":"PowerShell MCP Server (Template)","version":"0.1.0"}}}
```

Note `0.3.0`. The handler returns that string regardless of the version the client asked for. It is a pre-release number; current specification versions are date-based. The [lifecycle rules](https://modelcontextprotocol.io/specification/2025-06-18/basic/lifecycle) say the server must answer with a version it supports, and that the client should disconnect when it does not support that version. Claude Desktop accepts this in practice, so do not describe the server as negotiating a current protocol version. If a connector initializes and then drops, start your investigation here.

A `tools/call` against the template’s `Invoke-Addition` with `a: 2` and `b: 40` returned exactly this:

```json
{"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"42\n"}],"isError":false}}
```

`isError` tells you nothing about the tool. `Invoke-HandleRequest` hard-codes it to `false` and never sets it true, so a tool that fails and catches its own exception still returns `"isError":false` with the error text sitting in `content`. The text inside `content` is the only signal you get, and a failed tool comes back to the model as an ordinary result.

A healthy session returns both, one JSON line each, as the probe capture below shows.

![Probe output, two responses](https://adamtheautomator.com/wp-content/uploads/publisher/0d8b7cddb40e79c2945e379977f774a86f155834bf4186791b3ee16689f5f203.jpg)

* * *

_**Pro Tip: Read one `tools/list` response before you write a second tool. The schema it prints is the only description of your function the model ever reads, and a parameter with no help text gets `No description available for this parameter.` as its description, which leaves the model working from the parameter name alone.**_

* * *

## Five Deviations That Decide Whether It Works

The module diverges from the specification in four places. Those four change how you write code. The fifth row below is a Windows-only trap rather than a module defect: PowerShell itself rejects two Windows-only parameters off Windows, and it only matters if you deploy from macOS.

| What you see | Why it happens | What to do |
| --- | --- | --- |
| One registered tool returns `"tools": {...}` instead of an array | `ConvertTo-Json` serializes a single-element array as an object | Register at least two functions, always |
| A failing tool call produces no response at all | The handler catches and ignores exceptions | Catch inside every tool and return an error string |
| The client reports protocol version `0.3.0` | The value is hardcoded in the initialize handler | Expect it, and do not claim version negotiation |
| The process pins a CPU core after stdin closes | The read loop never exits on end-of-input | Let the parent watchdog end it, or kill it yourself |
| `Set-ExecutionPolicy` and `Start-Process -WindowStyle` throw on macOS | A Windows-only trap: PowerShell rejects both parameters off Windows | Skip the execution-policy step and drop the `-WindowStyle` switch outside Windows |

### The Array That Becomes an Object

The [tools specification](https://modelcontextprotocol.io/specification/2025-06-18/server/tools) defines the result as `"tools": [ ... ]`, an array, and a strict client is entitled to reject anything else. With two registered functions PSMCP emits that array; with one it emits a bare object, because the tail of `Register-MCPTool` builds an array and then pipes it through `ConvertTo-Json`. Registering a second trivial function costs you four lines and removes the risk.

### The Call That Never Answers

A tool that throws is indistinguishable, from the client’s side, from a tool that is still running. No error object ever comes back. Every function you write needs its own `catch`.

* * *

_**Warning: Do not run the entry-point script interactively to see what it does. It has no prompt, it reads stdin as protocol, and it will consume a core until you kill it. Nothing about that failure looks like a bug in your tool code, and people lose an afternoon to it.**_

* * *

## The Security Boundary Is the Function List

The child process runs as the signed-in user with that user’s full token. PSMCP narrows nothing: no allow-list, no working-directory jail, and no argument validation beyond PowerShell’s own parameter binding. The set of functions in your entry point is the security boundary, which is the entire argument for publishing narrow verbs. One `Invoke-AnyCommand` tool turns the deployment into remote code execution with a language model in the loop.

### What the Policy Does and Does Not Cover

Microsoft is unusually direct about the control most people reach for first. Microsoft’s [execution policy reference](https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_execution_policies?view=powershell-7.6) states: “The execution policy isn’t a security boundary, it’s defense in depth.” It stops accidental script runs. A determined operator, including a model holding your token, gets past it.

That control is also Windows-only. On macOS the same cmdlet throws `Operation is not supported on this platform.`, so keep an execution-policy step under a Windows heading. A Linux or macOS reader who runs it gets an error that reads like a broken install.

### Where Confirmation Actually Lives

`SupportsShouldProcess` provides less protection here than its reputation suggests. `Register-MCPTool` strips the common parameters, including `Verbose`, `Confirm`, and `WhatIf`, out of the generated schema, so the model never sees those switches and cannot choose a dry run. A function decorated with `CmdletBinding(SupportsShouldProcess=$true)` still helps a human running it by hand, and the patterns are documented in [Requesting confirmation from cmdlets](https://learn.microsoft.com/en-us/powershell/scripting/developer/cmdlet/requesting-confirmation-from-cmdlets?view=powershell-7.6). If the guardrail has to hold against the model, put the check inside the function body.

### Four Rules That Hold

*   Publish verbs that name one outcome, and keep write operations in a separate script from read operations so you can swap the entry point.
    
*   Require an explicit target parameter for anything that changes state, and reject a wildcard before the mutation runs.
    
*   Write the intended change to the log before you make it, then log the result, so a failed half-change leaves a record.
    
*   Treat `mcp_server.log` as sensitive. [`Write-Log`](https://adamtheautomator.com/powershell-write-log-tutorial/) serializes the full request and the tool result, so any token or connection string your tool returns lands in that file in clear text.
    

The `env` block in the manifest is a plaintext credential store on disk, so keep secrets out of it. Anything sensitive should come from an authenticated session the user already has, such as an existing `Connect-MgGraph` context.

* * *

_**Reality Check: This module writes an audit trail, and that trail is the strongest practical argument for it. Every request lands as one JSON object with `RequestId`, `Method`, and, for a tool call, `ToolName`, `Arguments`, and `Result`. You can reconstruct exactly what the model ran, which is what an auditor asks for and what a chat transcript cannot prove.**_

* * *

## Troubleshooting the Claude Desktop Connector

Most of the failures here announce themselves in one of two log files, and finding the right one saves the guessing. Connection-level events go to `%APPDATA%\Claude\logs\mcp.log`; the child’s stderr goes to `mcp-server-<name>.log` in the same folder.

| Symptom | Look here first | Fix |
| --- | --- | --- |
| Server missing from the connector list | `claude_desktop_config.json` | Confirm the key is `mcpServers` and the JSON parses |
| Server listed with no tools | `Start-McpServer` line in the entry point | List every function name, comma separated |
| Process exits immediately | `mcp-server-ops-powershell.log` | Read the parameter-binding error and fix the manifest |
| Every call times out | `mcp_server.log` | The tool threw; add a `catch` and return text |
| Works in a terminal, fails from Claude | `PSModulePath` in the manifest | Pin the module path in `env` |

### The Connector Never Appears

A missing connector usually means the JSON parses for you and not for the application, or the key is `servers` because the scaffold was pasted from VS Code. A trailing comma in the `args` array is the other common cause, and it fails the whole file rather than one server.

### The Tools List Is Empty

An empty tool list has two causes: a function declared without the `Global:` prefix, which the dispatcher cannot reach, or a function that never made it onto the `Start-McpServer` line. [The module README](https://github.com/dfinke/PSMCP) states the `Global:` requirement directly. Both produce a server that looks healthy until you ask it to do something.

### Calls Time Out With No Error

This is the swallowed-exception failure from Step 2, and `mcp_server.log` shows the request with no matching result. The entry carries a `RequestId` and the message `Processing request`; `ToolName`, `Arguments` and `Result` are written only after the tool returns, so a call that never returns leaves those three fields out, and the request itself is still recorded in full in the `Request` field. Add the `catch` block, restart the application, and the same call returns text the model can read. If the log itself is missing or empty, check the `Set-LogFile` path first; a missing `D:` drive leaves you with a server that works and records nothing.

![Hung call in the log](https://adamtheautomator.com/wp-content/uploads/publisher/a4cb9b4e8c78a0f3c6a169fe6665c54e10eddc1195f4cb2d0fc696d7753b8818.jpg)

## Where This Leaves You

You now have a Windows server that answers four real questions about a machine and a Claude Desktop configuration that can reach it. Before you add a fifth tool, tighten the two things this module leaves open. Move the entry point under a standard user account if it is not already there, and confirm the log file captures requests with the arguments you expect, because that log is the difference between an audit trail and an incident.

From here the useful next step is a write tool that takes an explicit target parameter. Add one that restarts a named service, log the intended change before the call, and verify the new state with the read tool you already built. When the model can restart one service and prove it came back, you have a workflow you can defend to whoever asks what the agent did on the server last night. Several of the deviations above are undocumented, so report what you find in [the PSMCP issue tracker](https://github.com/dfinke/PSMCP/issues).

Share this article

[Share on X](https://twitter.com/intent/tweet?url=https%3A%2F%2Fadamtheautomator.com%2Fpowershell-mcp-server-automate-windows-admin-tasks%2F&text=PowerShell%20MCP%20Server%3A%20Automate%20Windows%20Admin%20Tasks)[Share on Facebook](https://www.facebook.com/sharer/sharer.php?u=https%3A%2F%2Fadamtheautomator.com%2Fpowershell-mcp-server-automate-windows-admin-tasks%2F)[Share on LinkedIn](https://www.linkedin.com/sharing/share-offsite/?url=https%3A%2F%2Fadamtheautomator.com%2Fpowershell-mcp-server-automate-windows-admin-tasks%2F)

## Related Posts

![](https://adamtheautomator.com/wp-content/uploads/publisher/3f25d9c85b2b813e8a0dea451dc89138/6fb0067ffdaeca7dafae4e68bbfde0f73ea197247f36bcc3d64e68dd24a4df0d.webp)

### [Build a Slack Help Desk with Notion Custom Agents](/build-a-slack-help-desk-with-notion-custom-agents/)

Connect Slack to Notion Custom Agents and build an answer library from pages your team trusts, with source lines, honest refusals, and a human queue.

![](https://adamtheautomator.com/wp-content/uploads/publisher/3f25d9c85b2b81c4811ed7ed9b290871/d4ebe25fb13196c5dffbe9c4668d57fe178690406552541f4e2f3828e040cc6f.webp)

### [Notion Custom Agents: Automate Your Weekly Project Report](/notion-custom-agents-weekly-report/)

Build a Notion Custom Agent that writes a dated weekly project report from your task database, then test it against an empty week and a repeat run.

![](https://adamtheautomator.com/wp-content/uploads/publisher/3f25d9c85b2b81c28cc6c39755dac3c7/423d6296450a064f65dba039d6aec0339ac2ba3b86b2f0ef3706c81ff1e57223.webp)

### [Notion Automation: Pick the Right Tier Every Time](/notion-automation-agents-vs-workers/)

Pick the right Notion automation tier: compare database automations, Custom Agents, and Workers, and split ticket routing to cut credit costs.

## Categories

*   [IT Ops](/category/it-ops/)
*   [Cloud](/category/cloud/)
*   [DevOps](/category/devops/)
*   [Home Ops](/category/home-ops/)
*   [Information Security](/category/infosec/)
*   [Software Development](/category/software-development/)

## Site

*   [Home](/)
*   [Tutorials](/tutorials/)
*   [Instructors](/author/)
*   [Advertising](/advertising/)
*   [Recommended Resources](/resources/)
*   [About Adam](/about-adam/)

Copyright 2026© ATA Learning | [Privacy Policy](/privacy/)
