Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/add-mcp-tool.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,11 +87,12 @@ The following are intentionally excluded from MCP:

- Site creation (Pressable or WPCOM)
- User deletion
- WP-CLI command execution
- Deployment triggers

If the user requests one, explain that these operations are excluded by design and must be run via the CLI.

**Sanctioned exception — command execution with guardrails.** WP-CLI and SSH execution are exposed, but only because they ship with guardrails: WP-CLI tools (`*_run_wp_cli_command`) use a restrictive allowlist (`is_allowed_wp_cli_command()`); SSH tools (`*_run_ssh_command`) use a catastrophic-command denylist (`is_blocked_ssh_command()`). Both audit-log every call (`audit_wp_cli_command()` / `audit_ssh_command()`) and carry a `destructiveHint: true` annotation. The MCP server cannot itself enforce human approval (no elicitation support in `php-mcp/server`), so per-command approval relies entirely on the client's approval prompt — these tools must never be auto-approved/allowlisted in the client. Follow this same pattern (guardrail + audit + destructive annotation) for any new execution-style tool; do not add an unguarded one.

## Verification

1. Restart the MCP server: `team51 --mcp` (Ctrl+C to stop).
Expand Down
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ See `.agents/subagents/add-cli-command.md` for a detailed runbook.
4. Use existing `get_*` functions from includes.
5. Return arrays (or error arrays with `'error' => '...'`). No STDOUT — reserved for JSON-RPC.

**MUST**: Do not add high-risk tools (site creation, user deletion, WP-CLI execution, deployments). See README MCP section.
**MUST**: Do not add high-risk tools (site creation, user deletion, deployments). See README MCP section.

Command execution (WP-CLI and SSH) is the one sanctioned exception, and only *with guardrails*: `pressable_run_wp_cli_command`/`wpcom_run_wp_cli_command` use an allowlist + audit log; `pressable_run_ssh_command`/`wpcom_run_ssh_command` use a denylist + audit log + `destructiveHint` annotation. The MCP server cannot enforce human approval itself (no elicitation support), so these tools depend on the client's per-command approval prompt and must never be auto-approved/allowlisted in the client.

**For full details**: `.agents/skills/add-mcp-tool.md`

Expand Down
15 changes: 13 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ claude mcp add team51 -- team51 --mcp

### Available Tools

The MCP server currently exposes 67 tools across all services. The table below shows a subset of commonly used tools:
The MCP server currently exposes 69 tools across all services. The table below shows a subset of commonly used tools:

| Service | Read Tools | Write Tools |
|---------|-----------|-------------|
Expand All @@ -109,7 +109,18 @@ The MCP server currently exposes 67 tools across all services. The table below s

Write tools are annotated with MCP `ToolAnnotations` (`destructiveHint`, `readOnlyHint`, etc.) so clients prompt for confirmation before executing destructive actions.

High-risk operations (site creation, user deletion, WP-CLI execution, deployments) are intentionally excluded.
#### High-risk command execution (WP-CLI and SSH)

Command-execution tools are exposed but guarded:

| Tool | Guardrail |
|------|-----------|
| `pressable_run_wp_cli_command`, `wpcom_run_wp_cli_command` | Restrictive **allowlist** of read-only WP-CLI subcommands + audit log |
| `pressable_run_ssh_command`, `wpcom_run_ssh_command` | Arbitrary commands allowed, but a **denylist** blocks catastrophic patterns (`rm -rf /`, fork bombs, `dd`/`mkfs`, `reboot`, …) + audit log |

**Human approval is required for every SSH command.** The MCP protocol cannot enforce this server-side (the `php-mcp/server` version in use has no elicitation support), so each call relies on the **client's per-command approval prompt** — guaranteed only by the `destructiveHint` annotation and by **never adding these tools to the client's auto-approve allowlist**. The denylist is defense-in-depth, not a hard boundary; the human prompt is the real gate. Interactive shell sessions remain unsupported over MCP (use the `pressable:open-site-shell` CLI command for those).

Other high-risk operations (site creation, user deletion, deployments) are intentionally excluded — run them via the CLI.

Comment on lines +123 to 124

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Align exclusion claim with the actual MCP tool surface.

Line 123 says other high-risk operations are excluded from MCP, but mcp/Team51McpTools.php still exposes legacy high-risk tools (e.g., site creation and deployment-related operations). Please reword this to avoid a false security posture.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@README.md` around lines 123 - 124, The README.md statement at line 123-124
claims that high-risk operations like site creation and deployments are excluded
from MCP, but this contradicts the actual tool surface exposed in
mcp/Team51McpTools.php which still includes these legacy high-risk operations.
Reword the README section to accurately reflect which operations are actually
available through the MCP interface, either by removing the false exclusion
claim or by updating it to match the real tool implementation.

### Extending

Expand Down
3 changes: 2 additions & 1 deletion commands/Pressable_Site_WP_CLI_Command_Run.php
Original file line number Diff line number Diff line change
Expand Up @@ -128,10 +128,11 @@ protected function execute( InputInterface $input, OutputInterface $output ): in

try {
$ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time.
$GLOBALS['wp_cli_output'] = ''; // Reset before each run; the callback appends each chunk so multi-packet output is captured in full.
$ssh->exec(
"wp $this->wp_command",
function ( string $str ): void {
$GLOBALS['wp_cli_output'] = $str;
$GLOBALS['wp_cli_output'] .= $str;
if ( ! $this->skip_output ) {
echo "$str\n";
}
Expand Down
11 changes: 8 additions & 3 deletions commands/WPCOM_Site_WP_CLI_Command_Run.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,14 +60,18 @@ protected function configure(): void {

/**
* {@inheritDoc}
*
* @throws \InvalidArgumentException If the site is not a WordPress.com Atomic site.
*/
protected function initialize( InputInterface $input, OutputInterface $output ): void {
$this->site = get_wpcom_site_input( $input, fn() => $this->prompt_site_input( $input, $output ) );
$input->setArgument( 'site', $this->site );

if ( ! $this->site->is_wpcom_atomic ) {
$output->writeln( '<error>This command is only available for WordPress.com Atomic sites.</error>' );
exit( 1 );
// Do not exit() here: this command is hosted inside the long-lived MCP server
// (via run_app_command), where exit() would terminate the whole server process.
// Throwing lets Symfony (CLI) and the MCP tool layer surface a clean error instead.
throw new \InvalidArgumentException( 'This command is only available for WordPress.com Atomic sites.' );
}

$this->wp_command = get_string_input( $input, 'wp-cli-command', fn() => $this->prompt_command_input( $input, $output ) );
Expand Down Expand Up @@ -107,10 +111,11 @@ protected function execute( InputInterface $input, OutputInterface $output ): in

try {
$ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time.
$GLOBALS['wp_cli_output'] = ''; // Reset before each run; the callback appends each chunk so multi-packet output is captured in full.
$ssh->exec(
"wp $this->wp_command",
function ( string $str ): void {
$GLOBALS['wp_cli_output'] = $str;
$GLOBALS['wp_cli_output'] .= $str;
if ( ! $this->skip_output ) {
echo "$str\n";
}
Expand Down
44 changes: 44 additions & 0 deletions includes/functions-pressable.php
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,50 @@ function run_pressable_site_wp_cli_command( string $site_id_or_url, string $wp_c
);
}

/**
* Runs an arbitrary, non-interactive shell command on the specified Pressable site over SSH and returns the captured output.
*
* Unlike run_pressable_site_wp_cli_command(), the command is not prefixed with `wp` and is executed verbatim. This is a
* high-risk operation: callers are responsible for guarding/approving the command (e.g. the MCP layer requires human approval).
*
* @param string $site_id_or_url The ID or URL of the site to run the command on.
* @param string $ssh_command The raw shell command to run.
*
* @return string|null The captured command output, or null if the site could not be resolved or the SSH connection failed.
*/
function run_pressable_site_ssh_command( string $site_id_or_url, string $ssh_command ): ?string {
// Accept a full URL, a bare domain, or a numeric ID. Normalize URLs to a host, mirroring get_site_input().
if ( str_contains( $site_id_or_url, 'http' ) ) {
$host = parse_url( $site_id_or_url, PHP_URL_HOST );
$site_id_or_url = empty( $host ) ? $site_id_or_url : $host;
}

$site = get_pressable_site( $site_id_or_url );
if ( is_null( $site ) ) {
return null;
}

$ssh = Pressable_Connection_Helper::get_ssh_connection( $site->id );
if ( is_null( $ssh ) ) {
return null;
}

$output = '';
try {
$ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time.
$ssh->exec(
$ssh_command,
static function ( string $str ) use ( &$output ): void {
$output .= $str;
}
);
} finally {
$ssh->disconnect();
}

return $output;
}

/**
* Creates a new DeployHQ project for the specified Pressable site.
*
Expand Down
45 changes: 45 additions & 0 deletions includes/functions-wpcom.php
Original file line number Diff line number Diff line change
Expand Up @@ -971,6 +971,51 @@ function run_wpcom_site_wp_cli_command( string $site_id_or_url, string $wp_cli_c
);
}

/**
* Runs an arbitrary, non-interactive shell command on the specified WordPress.com Atomic site over SSH and returns the captured output.
*
* Unlike run_wpcom_site_wp_cli_command(), the command is not prefixed with `wp` and is executed verbatim. SSH is only available on
* Atomic sites. This is a high-risk operation: callers are responsible for guarding/approving the command (e.g. the MCP layer
* requires human approval).
*
* @param string $site_id_or_url The ID or URL of the site to run the command on.
* @param string $ssh_command The raw shell command to run.
*
* @return string|null The captured command output, or null if the site could not be resolved, is not Atomic, or the SSH connection failed.
*/
function run_wpcom_site_ssh_command( string $site_id_or_url, string $ssh_command ): ?string {
// Accept a full URL, a bare domain, or a numeric ID. Normalize URLs to a host, mirroring get_site_input().
if ( str_contains( $site_id_or_url, 'http' ) ) {
$host = parse_url( $site_id_or_url, PHP_URL_HOST );
$site_id_or_url = empty( $host ) ? $site_id_or_url : $host;
}

$site = get_wpcom_site( $site_id_or_url );
if ( is_null( $site ) || empty( $site->is_wpcom_atomic ) ) {
return null;
}

$ssh = WPCOM_Connection_Helper::get_ssh_connection( $site->ID ); // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
if ( is_null( $ssh ) ) {
return null;
}

$output = '';
try {
$ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time.
$ssh->exec(
$ssh_command,
static function ( string $str ) use ( &$output ): void {
$output .= $str;
}
Comment on lines +1005 to +1010

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

In phpseclib3 Net\SSH2, does setTimeout(0) disable exec() timeout indefinitely, and what timeout strategy is recommended for production command execution?

💡 Result:

In phpseclib3, setting setTimeout(0) or setTimeout(false) indicates that there is no timeout, meaning the exec() call will block indefinitely until the remote command finishes or the underlying connection is lost [1][2][3]. However, "no timeout" at the library level does not protect the process from external constraints. For production command execution, a robust strategy should include the following considerations: 1. Application-Level Timeouts: Always define a reasonable maximum duration for your command execution using setTimeout($seconds). Relying on infinite blocking can lead to hung worker processes, exhausted connection pools, or PHP script execution limit errors if the remote command hangs or the network connection drops silently [3][4]. 2. Keepalives: For long-running commands, use setKeepAlive($interval) to prevent intermediate network infrastructure or the remote sshd from closing the connection due to inactivity [4]. Note that this only maintains the SSH connection; it does not stop the remote command if it runs longer than your intended logic [4]. 3. Remote-Side Timeouts: If you need to enforce a strict duration on the command itself, it is safer to use the remote system's tools, such as the GNU timeout utility (e.g., $ssh->exec('timeout 300 your_command')), rather than relying solely on the client-side library timeout [5]. 4. Resource Cleanup: If a phpseclib timeout occurs, the underlying channel may remain in an inconsistent state. Always call $ssh->reset() if you intend to reuse the SSH connection for subsequent commands after a timeout has been triggered [4][6]. 5. External Monitoring: For critical production tasks, ensure your architecture monitors for hanging processes at the OS level, as killing a phpseclib process may not necessarily terminate the command process currently running on the remote server [6].

Citations:


Bound SSH runtime and output size to avoid hanging MCP requests.

Line 1005 disables timeouts (setTimeout(0)) and lines 1008-1009 append output without a cap. According to phpseclib3 documentation, setTimeout(0) causes exec() to block indefinitely until the remote command finishes or the connection is lost. A streaming or stuck command can block a worker indefinitely and exhaust memory. Implement a finite timeout (configurable via environment variable) and bounded output capture. Apply the same fix to includes/functions-pressable.php lines 655-660.

Suggested hardening sketch
-		$ssh->setTimeout( 0 ); // Disable timeout in case the command takes a long time.
+		$timeout_seconds = (int) ( getenv( 'TEAM51_MCP_SSH_TIMEOUT_SECONDS' ) ?: 300 );
+		$max_output_bytes = (int) ( getenv( 'TEAM51_MCP_SSH_MAX_OUTPUT_BYTES' ) ?: 262144 );
+		$captured_bytes = 0;
+		$truncated = false;
+		$ssh->setTimeout( $timeout_seconds );

 		$ssh->exec(
 			$ssh_command,
-			static function ( string $str ) use ( &$output ): void {
-				$output .= $str;
+			static function ( string $str ) use ( &$output, &$captured_bytes, &$truncated, $max_output_bytes ): void {
+				if ( $captured_bytes >= $max_output_bytes ) {
+					$truncated = true;
+					return;
+				}
+				$remaining = $max_output_bytes - $captured_bytes;
+				$chunk = substr( $str, 0, $remaining );
+				$output .= $chunk;
+				$captured_bytes += strlen( $chunk );
 			}
 		);
+		if ( $truncated ) {
+			$output .= "\n[output truncated]";
+		}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@includes/functions-wpcom.php` around lines 1005 - 1010, The setTimeout(0)
call disables the timeout indefinitely, causing the exec() method to block until
the remote command completes or the connection is lost, which can hang worker
processes. Additionally, the output callback appends data to the $output
variable without any size limit, risking memory exhaustion on long-running
commands. Replace the hardcoded setTimeout(0) with a finite configurable timeout
value read from an environment variable (with a sensible default), and implement
bounded output capture by tracking the accumulated output size and stopping the
append operation once a configurable maximum output size is exceeded. Apply the
same timeout and output bounding fix pattern to other locations where exec() is
called with unbounded output streaming.

);
} finally {
$ssh->disconnect();
}

return $output;
}

// endregion

// region CONSOLE
Expand Down
23 changes: 23 additions & 0 deletions mcp-server.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,14 @@
* }
* }
* }
*
* @package WPCOMSpecialProjects\CLI
*/

use PhpMcp\Server\Server;
use PhpMcp\Server\Transports\StdioServerTransport;
use Symfony\Component\Console\Application;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\StreamOutput;

// Set up constants needed by the CLI environment.
Expand All @@ -47,6 +51,25 @@
// Mark as non-autocomplete so that identity loading proceeds when needed.
$GLOBALS['team51_is_autocomplete'] = false;

// Build the Symfony Console application and register ONLY the commands the MCP tools
// dispatch internally via run_app_command(). Without an app, run_app_command() fatals with
// "Call to a member function find() on null" in MCP mode — team51-cli.php builds the app for
// normal CLI runs, but its `--mcp` branch returns before reaching that code. The SSH tools
// connect directly and need no command; only the WP-CLI execution tools delegate to one.
// Register a command here only if a new MCP tool needs to run it via run_app_command().
$team51_cli_app = new Application();
$team51_mcp_command_classes = array(
\WPCOMSpecialProjects\CLI\Command\Pressable_Site_WP_CLI_Command_Run::class,
\WPCOMSpecialProjects\CLI\Command\WPCOM_Site_WP_CLI_Command_Run::class,
);
foreach ( $team51_mcp_command_classes as $team51_command_class ) {
$team51_command = new $team51_command_class();
// The commands' interactive site-prompt fallback reads --no-autocomplete; define it so
// getOption() always resolves. (MCP supplies the site argument, so the prompt isn't hit.)
$team51_command->addOption( '--no-autocomplete', null, InputOption::VALUE_NONE, 'Do not provide options to initialization questions.' );
$team51_cli_app->add( $team51_command );
}

// Identity (1Password credentials) is loaded lazily on first tool call,
// not at startup. This prevents Cursor from prompting for 1Password unlock
// every time a project is opened. See Team51McpTools::ensure_identity().
Expand Down
Loading
Loading