Skip to content
Merged
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
2 changes: 1 addition & 1 deletion admin/build/index.asset.php
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '3a8e65d912ca9ca5e467');
<?php return array('dependencies' => array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '8404554d959f53432915');
2 changes: 1 addition & 1 deletion admin/build/index.js

Large diffs are not rendered by default.

21 changes: 20 additions & 1 deletion admin/src/components/McpDiagnostics.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export default function McpDiagnostics() {
<tr>
<th>{ __( 'When', 'saddle' ) }</th>
<th>{ __( 'Asked for', 'saddle' ) }</th>
<th>{ __( 'Signed in with', 'saddle' ) }</th>
<th>{ __( 'Result', 'saddle' ) }</th>
<th>{ __( 'App', 'saddle' ) }</th>
</tr>
Expand All @@ -202,7 +203,25 @@ export default function McpDiagnostics() {
<td>
{ ( entry.methods || [] ).join(
', '
) || '—' }
) ||
entry.method ||
'—' }
</td>
<td>
{ /* The column that answers "was it
refused because the key was wrong,
or because none arrived?" — which a
401 alone cannot. */ }
{ entry.auth === 'absent' ? (
<Badge tone="warning">
{ __(
'nothing sent',
'saddle'
) }
</Badge>
) : (
entry.scheme || '—'
) }
</td>
<td>
{ entry.status >= 200 &&
Expand Down
59 changes: 54 additions & 5 deletions includes/class-saddle-mcp-diagnostics.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,15 @@ public static function note_compat_missing() {

/**
* How many requests the ring buffer keeps.
*
* Was 25, which sounds ample and was not: paired with the panel recording
* its own 5-second poll (see targets_mcp()), the buffer turned over every
* ~125 seconds. A customer's capture of a failing connection spanned 114
* seconds and held two real rows among twenty-three of the panel watching
* itself. The poll is fixed; this is the margin, so a trace outlives the
* round trip of someone reading it, copying it and sending it on.
*/
const MAX_ENTRIES = 25;
const MAX_ENTRIES = 100;

/**
* Default recording window, in minutes.
Expand Down Expand Up @@ -283,12 +290,38 @@ public static function snapshot( $response, $handler, $request ) {
}
}

// 'scheme' and 'auth' are the pair that answers "was this refused because
// the credential was rejected, or because none arrived?" — the question
// a 401 cannot answer on its own, and the one a host that strips the
// Authorization header turns into a fortnight of guesswork. Both report
// the SHAPE of the credential and never a byte of it; see
// tests/mcp-diagnostics-test.php.
//
// 'method' matters because a row with no MCP method in it is ambiguous
// between a GET, a POST with the wrong content type, and an empty body.
// Those need different answers and used to look identical here.
// credential_scheme() returns '' for "nothing arrived" and 'unknown' only
// when Saddle_Connection is somehow absent. Those are different answers
// and neither is "present" — collapsing them would put a confident word
// on the row that decides whether the owner goes to their host.
$scheme = class_exists( 'Saddle_Connection' ) ? Saddle_Connection::credential_scheme() : 'unknown';
if ( '' === $scheme ) {
$scheme = 'none';
$auth = 'absent';
} elseif ( 'unknown' === $scheme ) {
$auth = 'unknown';
} else {
$auth = 'present';
}

self::$pending = array(
'time' => time(),
'method' => $request->get_method(),
'methods' => $methods,
'session' => is_string( $session ) && '' !== $session ? 'sent' : 'absent',
'protocol' => self::header_or_absent( $request, 'Mcp-Protocol-Version' ),
'scheme' => class_exists( 'Saddle_Connection' ) ? Saddle_Connection::credential_scheme() : 'unknown',
'scheme' => $scheme,
'auth' => $auth,
'user' => get_current_user_id(),
'client' => self::client_name( $request, $body ),
);
Expand Down Expand Up @@ -430,11 +463,18 @@ public static function report() {
$lines[] = '';
$lines[] = 'Recent requests (newest first):';

// auth + scheme come BEFORE session and protocol, because on a refused
// row they are the answer and the other two are trivia. Their absence
// from this line is the whole reason a customer had to ask us what his
// own trace already knew.
foreach ( array_reverse( $entries ) as $entry ) {
$lines[] = sprintf(
'%s %-28s session:%-7s protocol:%-11s status:%-4s%s%s %s',
'%s %-6s %-28s auth:%-8s scheme:%-8s session:%-7s protocol:%-11s status:%-4s%s%s %s',
gmdate( 'Y-m-d H:i:s', isset( $entry['time'] ) ? (int) $entry['time'] : 0 ),
isset( $entry['method'] ) ? $entry['method'] : '?',
implode( ',', isset( $entry['methods'] ) ? $entry['methods'] : array() ),
isset( $entry['auth'] ) ? $entry['auth'] : '?',
isset( $entry['scheme'] ) ? ( '' === $entry['scheme'] ? 'none' : $entry['scheme'] ) : '?',
isset( $entry['session'] ) ? $entry['session'] : '?',
isset( $entry['protocol'] ) ? $entry['protocol'] : '?',
isset( $entry['status'] ) ? $entry['status'] : '?',
Expand Down Expand Up @@ -483,13 +523,22 @@ private static function transport_description() {
/**
* Whether the request is aimed at the MCP endpoint.
*
* The route, or something below it — never merely something that starts
* with the same characters. This was a bare strpos() prefix test, and the
* admin API shares the `saddle/v1` namespace, so `/saddle/v1/mcp-diagnostics`
* matched: the panel below recorded its own 5-second poll as MCP traffic and
* pushed the real rows out of the ring buffer within about two minutes. A
* customer's capture of a failing connection came back twenty-three parts
* panel to two parts evidence.
*
* @param WP_REST_Request $request The request.
* @return bool
*/
private static function targets_mcp( $request ) {
$route = '/' . Saddle_MCP::REST_NAMESPACE . Saddle_MCP::ROUTE;
$mcp = '/' . Saddle_MCP::REST_NAMESPACE . Saddle_MCP::ROUTE;
$route = (string) $request->get_route();

return 0 === strpos( (string) $request->get_route(), $route );
return $route === $mcp || 0 === strpos( $route, $mcp . '/' );
}

/**
Expand Down
20 changes: 14 additions & 6 deletions languages/saddle.pot
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@
# This file is distributed under the GPL-2.0-or-later.
msgid ""
msgstr ""
"Project-Id-Version: Saddle – Control Your Site with AI (MCP Server) 1.0.0-rc5\n"
"Project-Id-Version: Saddle – Control Your Site with AI (MCP Server) 1.0.0-rc6\n"
"Report-Msgid-Bugs-To: https://wordpress.org/support/plugin/saddle\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"POT-Creation-Date: 2026-08-17T19:37:27+00:00\n"
"POT-Creation-Date: 2026-08-18T03:18:22+00:00\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"X-Generator: WP-CLI 2.12.0\n"
"X-Domain: saddle\n"
Expand Down Expand Up @@ -5331,25 +5331,33 @@ msgid "Asked for"
msgstr ""

#: admin/src/components/McpDiagnostics.jsx:190
msgid "Result"
msgid "Signed in with"
msgstr ""

#: admin/src/components/McpDiagnostics.jsx:191
msgid "Result"
msgstr ""

#: admin/src/components/McpDiagnostics.jsx:192
msgid "App"
msgstr ""

#: admin/src/components/McpDiagnostics.jsx:217
msgid "nothing sent"
msgstr ""

#. translators: %d: number of tools sent.
#: admin/src/components/McpDiagnostics.jsx:214
#: admin/src/components/McpDiagnostics.jsx:233
#, js-format
msgid "%d tools sent"
msgstr ""

#: admin/src/components/McpDiagnostics.jsx:220
#: admin/src/components/McpDiagnostics.jsx:239
msgid "OK"
msgstr ""

#. translators: %d: HTTP status code.
#: admin/src/components/McpDiagnostics.jsx:226
#: admin/src/components/McpDiagnostics.jsx:245
#, js-format
msgid "refused (%d)"
msgstr ""
Expand Down
86 changes: 86 additions & 0 deletions tests/mcp-diagnostics-test.php
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,16 @@ private function rpc( $method, array $headers = array() ) {

foreach ( $headers as $name => $value ) {
$request->set_header( $name, $value );

// And into $_SERVER, because that is where the credential actually
// lives as far as every consumer is concerned. Saddle_Connection
// reads $_SERVER on purpose: the failure this whole surface exists
// to diagnose is a host stripping the header BEFORE PHP, and only
// $_SERVER can tell you that. A WP_REST_Request built in a test is
// synthetic and populates neither.
if ( 0 === strcasecmp( $name, 'Authorization' ) ) {
$_SERVER['HTTP_AUTHORIZATION'] = $value;
}
}

$request->set_body(
Expand Down Expand Up @@ -171,6 +181,82 @@ public function test_a_recorded_request_contains_no_credential() {

$this->assertStringNotContainsString( 'super-secret-token-value', $serialized );
$this->assertStringNotContainsString( 'Authorization', $serialized );

// The row now names the SHAPE of the credential, which is the whole
// point — and must still name nothing else. Asserted here rather than
// in its own test so the two can never drift apart: whatever new field
// describes a credential gets added above this line and is covered by
// the assertions above it.
$entry = Saddle_MCP_Diagnostics::entries()[0];
$this->assertSame( 'bearer', $entry['scheme'] );
$this->assertSame( 'present', $entry['auth'] );
}

/**
* The pair that answers a 401. "The key was rejected" and "no key arrived"
* are the same HTTP status and opposite fixes — one is reconnect the app,
* the other is talk to your host about a stripped Authorization header. A
* customer lost two weeks inside that ambiguity.
*/
public function test_a_request_with_no_credential_is_recorded_as_such() {
Saddle_MCP_Diagnostics::start_recording();

$this->rpc( 'tools/list' );

$entry = Saddle_MCP_Diagnostics::entries()[0];

$this->assertSame( 'absent', $entry['auth'], 'No Authorization header must read as absent, never as present.' );
$this->assertSame( 'none', $entry['scheme'] );
$this->assertSame( 'POST', $entry['method'], 'The HTTP method disambiguates a row that carried no MCP method.' );
}

/**
* Both facts have to survive into the text that actually gets pasted into a
* support reply. They were recorded on every row for a month and rendered
* nowhere, so the one question the trace could answer was the one question
* we kept asking the customer to answer for us.
*/
public function test_the_report_names_the_credential_scheme() {
Saddle_MCP_Diagnostics::start_recording();

$this->rpc( 'tools/list', array( 'Authorization' => 'Bearer super-secret-token-value' ) );

$report = Saddle_MCP_Diagnostics::report();

$this->assertStringContainsString( 'auth:present', $report );
$this->assertStringContainsString( 'scheme:bearer', $report );
$this->assertStringNotContainsString( 'super-secret-token-value', $report );
}

/**
* The panel polls this route every 5 seconds while recording, and the admin
* API shares the `saddle/v1` namespace — so a prefix test matched it and the
* instrument filled its own ring buffer with itself. At 25 entries that was
* a ~125-second memory; a customer's capture of a failing connection came
* back 23 parts panel to 2 parts evidence.
*/
public function test_the_diagnostics_route_is_not_recorded_as_mcp_traffic() {
Saddle_MCP_Diagnostics::start_recording();

// Through rest_post_dispatch, not rest_do_request() alone. The recorder
// closes an entry on that filter, and dispatch() does not fire it — so
// the obvious version of this test passes against the BUG, because
// nothing ever gets written either way. Verified red before the fix
// only in this form.
$request = new WP_REST_Request( 'GET', '/saddle/v1/mcp-diagnostics' );
$response = rest_do_request( $request );
apply_filters( 'rest_post_dispatch', $response, rest_get_server(), $request );

$this->assertSame(
array(),
Saddle_MCP_Diagnostics::entries(),
'The panel must not record its own polling as MCP traffic.'
);

// And the real endpoint still is recorded — a filter that records
// nothing would also pass the assertion above.
$this->rpc( 'tools/list' );
$this->assertCount( 1, Saddle_MCP_Diagnostics::entries() );
}

/**
Expand Down
Loading