From fe342fb010352047fdb3fdfb986c8df546073138 Mon Sep 17 00:00:00 2001 From: fahimreza-dev Date: Tue, 18 Aug 2026 09:18:22 +0600 Subject: [PATCH] fix(diagnostics): make the trace answer the question it exists for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three defects, all found by reading a customer's capture of a failing ChatGPT connection rather than by reading the code. The panel recorded its own polling. targets_mcp() was a bare strpos() prefix test and the admin API shares the saddle/v1 namespace, so /saddle/v1/mcp-diagnostics matched — and the panel polls it every five seconds while recording. At 25 entries the ring buffer therefore turned over every ~125 seconds. The capture that arrived spanned 114 seconds and held two real rows among twenty-three of the panel watching itself. The instrument was evicting the evidence it exists to collect. Matched exactly now, and the buffer raised to 100 for margin. The credential scheme was recorded and rendered nowhere. Every row has carried Saddle_Connection::credential_scheme() since the recorder shipped; neither report() nor the table printed it. So the one field that distinguishes "the key was rejected" from "no key arrived" — a 401 either way, and opposite fixes — was written to the option and shown to no one, and a customer had to ask us what his own trace already knew. Now first on the row, ahead of session and protocol, which are trivia by comparison. Paired with an explicit auth present/absent/unknown, because '' and 'unknown' are different answers and neither is "present". And the HTTP method is recorded, so a row carrying no MCP method is no longer ambiguous between a GET, a POST with the wrong content type, and an empty body. The credential-leak test was passing for the wrong reason: it set an Authorization header on a synthetic WP_REST_Request, which populates no $_SERVER, so the recorder never saw a credential to leak. The helper now sets both, which is what made the two new scheme assertions meaningful — and what proves the existing assertion was load-bearing rather than vacuous. Closes #120 --- admin/build/index.asset.php | 2 +- admin/build/index.js | 2 +- admin/src/components/McpDiagnostics.jsx | 21 +++++- includes/class-saddle-mcp-diagnostics.php | 59 ++++++++++++++-- languages/saddle.pot | 20 ++++-- tests/mcp-diagnostics-test.php | 86 +++++++++++++++++++++++ 6 files changed, 176 insertions(+), 14 deletions(-) diff --git a/admin/build/index.asset.php b/admin/build/index.asset.php index b4dab81..75e65d2 100644 --- a/admin/build/index.asset.php +++ b/admin/build/index.asset.php @@ -1 +1 @@ - array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '3a8e65d912ca9ca5e467'); + array('react', 'react-dom', 'wp-api-fetch', 'wp-element', 'wp-hooks', 'wp-i18n'), 'version' => '8404554d959f53432915'); diff --git a/admin/build/index.js b/admin/build/index.js index 3e7d281..e6a44b4 100644 --- a/admin/build/index.js +++ b/admin/build/index.js @@ -12,7 +12,7 @@ (0,Op.__)("Forget “%s”?","saddle"),e.key),description:(0,Op.__)("This cannot be undone.","saddle"),danger:!0,confirmLabel:(0,Op.__)("Forget","saddle"),cancelLabel:(0,Op.__)("Cancel","saddle")})&&w(`memory/${e.key}`,{method:"DELETE"})})(e)},(0,Op.__)("Delete","saddle")))}),p===e.key&&(0,a.createElement)(Qu,{className:"saddle-rows__body",code:e.text,copy:!1})))))),(0,a.createElement)(iu,null,(0,a.createElement)(lu,{title:(0,Op.__)("Add something to remember","saddle"),description:(0,Op.__)("Your own notes are told to every session automatically.","saddle")}),(0,a.createElement)(cu,null,(0,a.createElement)("div",{className:"saddle-memory__compose"},(0,a.createElement)(eu,{label:(0,Op.__)("What to remember","saddle")},e=>(0,a.createElement)(nu,{...e,value:g.text,onChange:e=>y(t=>({...t,text:e.target.value})),rows:3,placeholder:(0,Op.__)("e.g. The pricing page is “Plans” (page 42) — update it, never create a new one.","saddle")})),(0,a.createElement)(eu,{label:(0,Op.__)("Name (optional)","saddle")},e=>(0,a.createElement)(tu,{...e,value:g.key,onChange:e=>y(t=>({...t,key:e.target.value})),placeholder:(0,Op.__)("e.g. pricing-page","saddle")})),(0,a.createElement)(Jd,{variant:"secondary",onClick:()=>{g.text.trim()&&(b(!0),w("memory",{method:"POST",data:{key:g.key,text:g.text}}).finally(()=>{b(!1),y({key:"",text:""})}))},loading:v,disabled:v||!g.text.trim()},(0,Op.__)("Remember this","saddle"))))),s&&(0,a.createElement)(iu,null,(0,a.createElement)(lu,{title:(0,Op.__)("AI-written memory","saddle"),description:(0,Op.__)("Entries an AI saved on its own are only found when it searches — unless you pin them, or turn this on.","saddle")}),(0,a.createElement)(cu,null,(0,a.createElement)("div",{className:"saddle-guide__actions saddle-guide__actions--stack"},(0,a.createElement)("label",{className:"saddle-toggle-row",htmlFor:"saddle-memory-autoinject"},(0,a.createElement)(ru,{id:"saddle-memory-autoinject",checked:s.autoinject_agent,onChange:e=>w("memory-settings",{method:"POST",data:{autoinject_agent:e}}),"aria-label":(0,Op.__)("Auto-include AI-written memory","saddle")}),(0,a.createElement)("span",null,(0,Op.__)("Auto-include AI-written memory (off is safest — pin entries instead)","saddle"))),x>0&&(0,a.createElement)(Jd,{variant:"secondary",className:"saddle-link-danger",onClick:()=>(async e=>{await t({title:(0,Op.__)("Clear AI-written memory?","saddle"),description:(0,Op.sprintf)(/* translators: %d: entry count. */ /* translators: %d: entry count. */ (0,Op.__)("Delete all %d AI-written memory entries? Your own entries are kept.","saddle"),e),danger:!0,confirmLabel:(0,Op.__)("Clear it","saddle"),cancelLabel:(0,Op.__)("Cancel","saddle")})&&w("memory-clear-agent",{method:"POST"})})(x)},(0,Op.__)("Clear AI-written memory","saddle"))))),""!==l&&(0,a.createElement)(iu,null,(0,a.createElement)(lu,{title:(0,Op.__)("What every session is told","saddle"),description:(0,Op.__)("The exact memory block a new AI session starts with.","saddle")}),(0,a.createElement)(cu,null,(0,a.createElement)(ju,{trigger:(0,Op.__)("Show the exact text","saddle")},(0,a.createElement)(Qu,{className:"saddle-guide__system",code:l,copy:!1})))),(0,a.createElement)(dp,{open:h,onOpenChange:f,title:(0,Op.__)("How memory works","saddle"),size:"md"},(0,a.createElement)("div",{className:"saddle-doc saddle-doc--bare"},(0,a.createElement)("p",{className:"saddle-doc__p"},(0,Op.__)("Memory is background information your AI carries between sessions — saved by you on this page, or noted by an AI with its memory tools while it works. It saves you re-explaining your site every time.","saddle")),(0,a.createElement)("h3",{className:"saddle-doc__h"},(0,Op.__)("What every session is told","saddle")),(0,a.createElement)("ul",{className:"saddle-doc__list"},(0,a.createElement)("li",null,(0,Op.__)("Your own entries — always included.","saddle")),(0,a.createElement)("li",null,(0,Op.__)("Pinned entries — always included, whoever wrote them.","saddle")),(0,a.createElement)("li",null,(0,Op.__)("Everything else — only found when an AI searches its memory.","saddle"))),(0,a.createElement)("h3",{className:"saddle-doc__h"},(0,Op.__)("AI-written entries","saddle")),(0,a.createElement)("p",{className:"saddle-doc__p"},(0,Op.__)("Entries an AI saved on its own are never served to future sessions automatically — you stay in control of what becomes standing knowledge. Pin the ones worth keeping, or turn on auto-include if you trust the whole set (off is safest). “Clear AI-written memory” removes them all at once; your own entries are kept.","saddle")),(0,a.createElement)("h3",{className:"saddle-doc__h"},(0,Op.__)("Safety","saddle")),(0,a.createElement)("p",{className:"saddle-doc__p"},(0,Op.__)("Memory is background information only. It can never change what your AI is allowed to do — the access level and per-tool permissions always win.","saddle")))))}function Nm(){const[e,t]=(0,i.useState)(null),[n,r]=(0,i.useState)(!0),[s,o]=(0,i.useState)(!1),[l,c]=(0,i.useState)(null),[d,u]=(0,i.useState)(!1),p=()=>{r(!0),Gp("self-check").then(t).catch(()=>t({status:"unknown"})).finally(()=>r(!1))};if((0,i.useEffect)(p,[]),n&&!e)return(0,a.createElement)("p",{className:"saddle-health saddle-health--checking"},(0,a.createElement)(Nu,null),(0,Op.__)("Checking your server setup…","saddle"));if(!e||"app_passwords_off"===e.status)return null;if("ok"===e.status||"fixed"===l)return(0,a.createElement)("p",{className:"saddle-health saddle-health--ok"},"fixed"===l?(0,Op.__)("✓ Fixed — sign-in details now reach WordPress. Your AI apps can connect.","saddle"):(0,Op.__)("✓ Server check passed — sign-in details reach WordPress correctly.","saddle"));if("unknown"===e.status)return(0,a.createElement)("p",{className:"saddle-health saddle-health--muted"},(0,Op.__)("We couldn’t verify your server automatically. If AI apps report “unauthorized” even with the right password, ask your host to pass the Authorization header through to WordPress.","saddle"));if("nonce_header_stripped"===e.status)return(0,a.createElement)(Eu,{className:"saddle-health",tone:"default",title:(0,Op.__)("Your host is removing one of WordPress’s headers","saddle"),description:(0,Op.__)("Something on your hosting — usually a security or firewall layer — strips the X-WP-Nonce header from requests. Saddle works around it, so this dashboard is fine. But the same layer may interfere with other plugins’ settings screens, so it’s worth asking your host to let that header through.","saddle")},(0,a.createElement)(Jd,{variant:"link",onClick:p,disabled:n},n?(0,Op.__)("Checking…","saddle"):(0,Op.__)("Check again","saddle")));const m=e.fix_snippet||{};return(0,a.createElement)(Eu,{className:"saddle-health",tone:"warning",title:(0,Op.__)("Your server is blocking app sign-ins","saddle"),description:(0,Op.__)("When an AI app connects, it sends its password in a sign-in header. Your web server removes that header before WordPress can see it, so every connection will fail as “unauthorized” — even with the right password. (The test above can still pass, because your browser signs in a different way.)","saddle")},e.htaccess_fixable&&"still_stripped"!==l&&(0,a.createElement)("div",{className:"saddle-health__actions"},(0,a.createElement)(Jd,{variant:"primary",onClick:()=>{o(!0),c(null),Gp("fix-auth-header",{method:"POST"}).then(e=>{"ok"===e.auth_header?c("fixed"):(c("still_stripped"),u(!0)),p()}).catch(e=>{c(e.message||(0,Op.__)("The automatic fix didn’t work.","saddle")),u(!0)}).finally(()=>o(!1))},loading:s,disabled:s},s?(0,Op.__)("Fixing…","saddle"):(0,Op.__)("Fix it for me","saddle")),(0,a.createElement)(Jd,{variant:"link",onClick:()=>u(!d)},d?(0,Op.__)("Hide the rule","saddle"):(0,Op.__)("See what this adds","saddle"))),"still_stripped"===l&&(0,a.createElement)("p",{className:"saddle-health__body"},(0,Op.__)("Saddle added the rule, but the header still isn’t arriving — something earlier in the chain (a proxy or your host’s own config) is removing it. Send the rule below to your hosting support and ask them to allow the Authorization header.","saddle")),"string"==typeof l&&"fixed"!==l&&"still_stripped"!==l&&(0,a.createElement)("p",{className:"saddle-health__body saddle-health__error"},l),!e.htaccess_fixable&&(0,a.createElement)("p",{className:"saddle-health__body"},(0,Op.__)("Saddle can’t edit this server’s configuration automatically. Add the matching rule below yourself, or send it to your hosting support.","saddle")),(d||!e.htaccess_fixable)&&(0,a.createElement)(a.Fragment,null,m.apache&&(0,a.createElement)(Qu,{dark:!0,label:(0,Op.__)("Apache / LiteSpeed — add to .htaccess","saddle"),code:m.apache}),m.nginx&&(0,a.createElement)(Qu,{dark:!0,label:(0,Op.__)("nginx — add to the PHP location block","saddle"),code:m.nginx})),(0,a.createElement)(Jd,{variant:"link",onClick:p,disabled:n},n?(0,Op.__)("Checking…","saddle"):(0,Op.__)("Check again","saddle")))}function Mm(){const[e,t]=(0,i.useState)(null),[n,r]=(0,i.useState)(!1),[s,o]=(0,i.useState)(!1),[l,c]=(0,i.useState)(!1),d=()=>Gp("mcp-diagnostics").then(t).catch(()=>t(null));(0,i.useEffect)(()=>{d()},[]),(0,i.useEffect)(()=>{if(!e?.recording)return;const t=setInterval(d,5e3);return()=>clearInterval(t)},[e?.recording]);const u=e=>{r(!0),Gp("mcp-diagnostics",{method:"POST",data:e}).then(t).catch(()=>{}).finally(()=>r(!1))};if(!e)return(0,a.createElement)("p",{className:"saddle-health saddle-health--checking"},(0,a.createElement)(Nu,null),(0,Op.__)("Loading client traffic…","saddle"));const p=e.health||{},m=e.entries||[];return(0,a.createElement)(iu,null,(0,a.createElement)(lu,{title:(0,Op.__)("Client traffic","saddle"),description:(0,Op.__)("If a connected app says it can’t see any tools, record its next attempt here — this shows what it asked for and what Saddle sent back.","saddle")}),(0,a.createElement)(cu,null,(0,a.createElement)("p",{className:"saddle-mcp-diag__health"},void 0===p.registered?(0,Op.__)("No app has asked for the tool list yet. Requests are recorded below either way.","saddle"):(0,Op.sprintf)(/* translators: 1: number of tools that loaded, 2: number installed. */ /* translators: 1: number of tools that loaded, 2: number installed. */ (0,Op.__)("%1$d of the %2$d tools installed here loaded correctly. How many an app is offered depends on its access level — the requests below show that number.","saddle"),p.registered,p.expected)),p.degraded&&(0,a.createElement)("p",{className:"saddle-mcp-diag__degraded"},p.missing?.length?(0,Op.sprintf)(/* translators: %s: comma-separated tool names. */ /* translators: %s: comma-separated tool names. */ -(0,Op.__)("These tools didn’t load, so apps can’t call them: %s. Reload this page; if they’re still missing, send the report below.","saddle"),p.missing.join(", ")):(0,Op.__)("No tools loaded at all, which is never normal. Reload this page; if it persists, send the report below.","saddle")),(0,a.createElement)("div",{className:"saddle-mcp-diag__actions"},(0,a.createElement)(Jd,{variant:e.recording?"secondary":"primary",disabled:n,onClick:()=>u({recording:!e.recording})},e.recording?(0,Op.__)("Stop recording","saddle"):(0,Op.__)("Record the next hour","saddle")),m.length>0&&(0,a.createElement)(a.Fragment,null,(0,a.createElement)(Jd,{variant:"link",onClick:()=>{e?.report&&window.navigator.clipboard?.writeText(e.report).then(()=>{o(!0),window.setTimeout(()=>o(!1),2e3)})}},s?(0,Op.__)("Copied","saddle"):(0,Op.__)("Copy report","saddle")),(0,a.createElement)(Jd,{variant:"link",onClick:()=>c(!l)},l?(0,Op.__)("Hide report","saddle"):(0,Op.__)("Show report","saddle")),(0,a.createElement)(Jd,{variant:"link",disabled:n,onClick:()=>u({clear:!0})},(0,Op.__)("Clear","saddle")))),e.recording&&(0,a.createElement)("p",{className:"saddle-mcp-diag__hint"},(0,Op.__)("Recording. Now ask the app to refresh its actions, or run any request from it — then come back here.","saddle")),0===m.length?(0,a.createElement)("p",{className:"saddle-mcp-diag__empty"},e.recording?(0,Op.__)("Nothing has arrived yet.","saddle"):(0,Op.__)("Nothing recorded. If an app is misbehaving, start recording and then retry it from the app.","saddle")):(0,a.createElement)("table",{className:"saddle-mcp-diag__table"},(0,a.createElement)("thead",null,(0,a.createElement)("tr",null,(0,a.createElement)("th",null,(0,Op.__)("When","saddle")),(0,a.createElement)("th",null,(0,Op.__)("Asked for","saddle")),(0,a.createElement)("th",null,(0,Op.__)("Result","saddle")),(0,a.createElement)("th",null,(0,Op.__)("App","saddle")))),(0,a.createElement)("tbody",null,m.map((e,t)=>(0,a.createElement)("tr",{key:`${e.time}-${t}`},(0,a.createElement)("td",null,new Date(1e3*e.time).toLocaleTimeString()),(0,a.createElement)("td",null,(e.methods||[]).join(", ")||"—"),(0,a.createElement)("td",null,e.status>=200&&e.status<300?(0,a.createElement)(mu,{tone:"success"},void 0!==e.tools?(0,Op.sprintf)(/* translators: %d: number of tools sent. */ /* translators: %d: number of tools sent. */ +(0,Op.__)("These tools didn’t load, so apps can’t call them: %s. Reload this page; if they’re still missing, send the report below.","saddle"),p.missing.join(", ")):(0,Op.__)("No tools loaded at all, which is never normal. Reload this page; if it persists, send the report below.","saddle")),(0,a.createElement)("div",{className:"saddle-mcp-diag__actions"},(0,a.createElement)(Jd,{variant:e.recording?"secondary":"primary",disabled:n,onClick:()=>u({recording:!e.recording})},e.recording?(0,Op.__)("Stop recording","saddle"):(0,Op.__)("Record the next hour","saddle")),m.length>0&&(0,a.createElement)(a.Fragment,null,(0,a.createElement)(Jd,{variant:"link",onClick:()=>{e?.report&&window.navigator.clipboard?.writeText(e.report).then(()=>{o(!0),window.setTimeout(()=>o(!1),2e3)})}},s?(0,Op.__)("Copied","saddle"):(0,Op.__)("Copy report","saddle")),(0,a.createElement)(Jd,{variant:"link",onClick:()=>c(!l)},l?(0,Op.__)("Hide report","saddle"):(0,Op.__)("Show report","saddle")),(0,a.createElement)(Jd,{variant:"link",disabled:n,onClick:()=>u({clear:!0})},(0,Op.__)("Clear","saddle")))),e.recording&&(0,a.createElement)("p",{className:"saddle-mcp-diag__hint"},(0,Op.__)("Recording. Now ask the app to refresh its actions, or run any request from it — then come back here.","saddle")),0===m.length?(0,a.createElement)("p",{className:"saddle-mcp-diag__empty"},e.recording?(0,Op.__)("Nothing has arrived yet.","saddle"):(0,Op.__)("Nothing recorded. If an app is misbehaving, start recording and then retry it from the app.","saddle")):(0,a.createElement)("table",{className:"saddle-mcp-diag__table"},(0,a.createElement)("thead",null,(0,a.createElement)("tr",null,(0,a.createElement)("th",null,(0,Op.__)("When","saddle")),(0,a.createElement)("th",null,(0,Op.__)("Asked for","saddle")),(0,a.createElement)("th",null,(0,Op.__)("Signed in with","saddle")),(0,a.createElement)("th",null,(0,Op.__)("Result","saddle")),(0,a.createElement)("th",null,(0,Op.__)("App","saddle")))),(0,a.createElement)("tbody",null,m.map((e,t)=>(0,a.createElement)("tr",{key:`${e.time}-${t}`},(0,a.createElement)("td",null,new Date(1e3*e.time).toLocaleTimeString()),(0,a.createElement)("td",null,(e.methods||[]).join(", ")||e.method||"—"),(0,a.createElement)("td",null,"absent"===e.auth?(0,a.createElement)(mu,{tone:"warning"},(0,Op.__)("nothing sent","saddle")):e.scheme||"—"),(0,a.createElement)("td",null,e.status>=200&&e.status<300?(0,a.createElement)(mu,{tone:"success"},void 0!==e.tools?(0,Op.sprintf)(/* translators: %d: number of tools sent. */ /* translators: %d: number of tools sent. */ (0,Op.__)("%d tools sent","saddle"),e.tools):(0,Op.__)("OK","saddle")):(0,a.createElement)(mu,{tone:"danger"},(0,Op.sprintf)(/* translators: %d: HTTP status code. */ /* translators: %d: HTTP status code. */ (0,Op.__)("refused (%d)","saddle"),e.status))),(0,a.createElement)("td",null,e.client||"—"))))),l&&e.report&&(0,a.createElement)(Qu,null,e.report)))}const km=Zp.mcpUrl||"",Im=Zp.user||"",Em=Zp.serverSlug||"saddle",Cm=/\s/g,Sm=(0,Op.__)("What can you see on my WordPress site?","saddle"),Lm=[{key:"claude",label:(0,Op.__)("Claude","saddle"),kind:(0,Op.__)("Desktop app","saddle"),how:(0,Op.__)("In Claude: Settings → Developer → Edit Config. Paste this inside, save, and restart the app.","saddle"),next:(0,Op.__)("Restart Claude, then ask it about your site.","saddle")},{key:"chatgpt",label:(0,Op.__)("ChatGPT","saddle"),kind:(0,Op.__)("Chat and Work","saddle"),auth:"oauth",how:(0,Op.__)("In ChatGPT on the web: turn on Developer mode (Settings → Apps & Connectors → Advanced settings), then create a connector. Paste the address, choose OAuth, and leave the client ID and secret blank. ChatGPT sends you here to approve it — the connector then works in the desktop app too.","saddle"),next:(0,Op.__)("Enable the connector in a ChatGPT chat and ask it about your site. If it can read but refuses to change anything, check your ChatGPT plan — write-capable custom connectors have been limited to Business, Enterprise and Edu workspaces.","saddle")},{key:"codex",label:(0,Op.__)("Codex","saddle"),kind:(0,Op.__)("In the ChatGPT app","saddle"),how:(0,Op.__)("Codex reads a settings file. Open ~/.codex/config.toml, paste this at the end, save, then restart the app. The codex terminal command reads the same file.","saddle"),next:(0,Op.__)("Open Codex in the ChatGPT app and ask it about your site.","saddle")},{key:"claude-code",label:(0,Op.__)("Claude Code","saddle"),kind:(0,Op.__)("Terminal","saddle"),how:(0,Op.__)("Paste this into your terminal and press Enter. That’s the whole setup.","saddle"),next:(0,Op.__)("Run claude in any folder and ask it about your site.","saddle")},{key:"cursor",label:(0,Op.__)("Cursor","saddle"),kind:(0,Op.__)("Code editor","saddle"),how:(0,Op.__)("In Cursor: Settings → MCP → Add new server. Paste this (or save it as .cursor/mcp.json).","saddle"),next:(0,Op.__)("Open Cursor’s chat and ask it about your site.","saddle")},{key:"gemini-cli",label:(0,Op.__)("Gemini CLI","saddle"),kind:(0,Op.__)("Terminal","saddle"),how:(0,Op.__)("Paste this into your terminal and press Enter. That’s the whole setup.","saddle"),next:(0,Op.__)("Run gemini in any folder and ask it about your site.","saddle")},{key:"vscode",label:(0,Op.__)("VS Code","saddle"),kind:(0,Op.__)("Copilot (agent mode)","saddle"),how:(0,Op.__)("Save this as .vscode/mcp.json in your project, then start the server from the MCP: List Servers command.","saddle"),next:(0,Op.__)("Open Copilot Chat in agent mode and ask it about your site.","saddle")},{key:"other",label:(0,Op.__)("Any MCP app","saddle"),kind:(0,Op.__)("Everything else","saddle"),how:(0,Op.__)("Most AI apps accept this standard setup — look for “Add MCP server” in their settings and paste it there.","saddle"),next:(0,Op.__)("Open your app and ask it about your site.","saddle")}];function Dm(e,t){const n=`Authorization: Basic ${t}`;switch(e){case"claude-code":return`claude mcp add ${Em} --scope user --transport http ${km} \\\n --header "${n}"`;case"gemini-cli":return`gemini mcp add --scope user --transport http ${Em} ${km} \\\n --header "${n}"`;case"vscode":return JSON.stringify({servers:{[Em]:{type:"http",url:km,headers:{Authorization:`Basic ${t}`}}}},null,2);case"chatgpt":return[`${(0,Op.__)("Name","saddle")}: ${Em}`,`${(0,Op.__)("Address","saddle")}: ${km}`,`${(0,Op.__)("Authentication","saddle")}: ${(0,Op.__)("OAuth (leave client ID and secret blank)","saddle")}`].join("\n");case"codex":return[`[mcp_servers.${Em}]`,`url = "${km}"`,`http_headers = { Authorization = "Basic ${t}" }`,"startup_timeout_sec = 30"].join("\n");case"cursor":case"other":return JSON.stringify({mcpServers:{[Em]:{url:km,headers:{Authorization:`Basic ${t}`}}}},null,2);default:return JSON.stringify({mcpServers:{[Em]:{command:"npx",args:["-y","mcp-remote",km,"--header",n]}}},null,2)}}function Tm(e,t){return t?Dm(e,btoa(`${Im}:${t.replace(Cm,"")}`)):Am(e)}function Am(e){return Dm(e,"PASTE-YOUR-KEY-HERE")}function Rm({open:e,onOpenChange:t,app:n,label:r,password:s}){const{copied:o,copy:i}=Ru(),l=Lm.find(e=>e.key===n)||Lm[Lm.length-1],c=!!s,d=c?Tm(l.key,s):Am(l.key);return(0,a.createElement)(dp,{open:e,onOpenChange:t,title:(0,Op.sprintf)(/* translators: %s: the app name. */ /* translators: %s: the app name. */ (0,Op.__)("Set up %s","saddle"),r||l.label),size:"lg"},(0,a.createElement)("div",{className:"saddle-setup-guide"},(0,a.createElement)("div",{className:"saddle-setup-guide__app"},(0,a.createElement)(im,{app:l.key}),(0,a.createElement)("span",null,l.label)),c?(0,a.createElement)(Tu,{tone:"warning"},(0,Op.__)("This fresh key appears only this once — paste it into the app before closing. Saddle keeps just its last four characters.","saddle")):(0,a.createElement)(Eu,{title:(0,Op.__)("Keys are shown only once","saddle"),description:(0,Op.__)("This guide uses a placeholder where the key goes. To get a real, ready-to-paste setup, use “Rotate key” on the connection — the old key stops working and a fresh one appears here.","saddle")}),(0,a.createElement)("section",{className:"saddle-setup-guide__step"},(0,a.createElement)("h3",null,(0,Op.__)("1. Where it goes","saddle")),(0,a.createElement)("p",null,l.how)),(0,a.createElement)("section",{className:"saddle-setup-guide__step"},(0,a.createElement)("h3",null,(0,Op.__)("2. The setup","saddle")),(0,a.createElement)(Qu,{dark:!0,copy:!1,label:c?(0,Op.__)("Ready to paste","saddle"):(0,Op.__)("Template — replace the placeholder with your key","saddle"),code:d}),c&&(0,a.createElement)(Jd,{variant:"primary",onClick:()=>i(d)},o?(0,Op.__)("Copied ✓","saddle"):(0,Op.__)("Copy setup","saddle")),(0,a.createElement)(Hu,{label:(0,Op.__)("Endpoint","saddle"),value:km})),(0,a.createElement)("section",{className:"saddle-setup-guide__step"},(0,a.createElement)("h3",null,(0,Op.__)("3. Say hello","saddle")),(0,a.createElement)("p",null,l.next),(0,a.createElement)(Hu,{label:(0,Op.__)("Try asking","saddle"),value:Sm}))))}const zm=Zp.mcpUrl||"",Pm=({children:e,help:t})=>(0,a.createElement)("span",{className:"saddle-apps__titled"},e,t&&(0,a.createElement)(Bu,null,t)),Om=new Intl.DateTimeFormat(void 0,{dateStyle:"medium",timeStyle:"short"}),Zm=e=>!e.last_used&&e.created&&Date.now()/1e3-e.created>172800;function Wm({clients:e,loading:t,onConnect:n,onClientsChanged:r,onClientRemoved:s,siteTier:l}){const c=tp(),[d,u]=(0,i.useState)(!1),[p,m]=(0,i.useState)(null),[h,f]=(0,i.useState)(null),[g,y]=(0,i.useState)([]),v=()=>Gp("oauth-connections").then(e=>y(e||[])).catch(()=>y([]));(0,i.useEffect)(()=>{v()},[]);return(0,a.createElement)("div",{className:"saddle-apps"},(0,a.createElement)(_p,{title:(0,a.createElement)(Pm,{help:(0,Op.__)("Every app here has its own sign-in you can take away at any moment, and they all follow the same rules you set on Permissions.","saddle")},(0,Op.__)("Connected apps","saddle")),description:(0,Op.__)("Apps you’ve let talk to this site.","saddle"),actions:(0,a.createElement)(Jd,{variant:"primary",onClick:n},(0,Op.__)("Connect an app","saddle"))}),t&&(0,a.createElement)(Nu,null),!t&&0===e.length&&0===g.length&&(0,a.createElement)(gu,{icon:(0,a.createElement)(am,null),title:(0,Op.__)("Nothing connected yet","saddle"),description:(0,Op.__)("Connect Claude, Cursor, or another AI app — it takes about a minute.","saddle"),actions:(0,a.createElement)(Jd,{variant:"primary",onClick:n},(0,Op.__)("Connect an app","saddle"))}),!t&&(e.length>0||g.length>0)&&(0,a.createElement)(iu,{className:"saddle-apps__card"},(0,a.createElement)(cu,{className:"saddle-apps__cardbody"},e.length>0&&(0,a.createElement)(_u,null,e.map(e=>{return(0,a.createElement)(wu,{key:e.uuid,icon:(0,a.createElement)(im,{app:lm(e.label||e.name)}),title:(0,a.createElement)(a.Fragment,null,(0,a.createElement)(hu,{tone:e.last_used?"success":"neutral"})," ",e.label||e.name),description:(e.last_used?(0,Op.sprintf)(/* translators: %s: date and time. */ /* translators: %s: date and time. */ diff --git a/admin/src/components/McpDiagnostics.jsx b/admin/src/components/McpDiagnostics.jsx index c85081a..f79996e 100644 --- a/admin/src/components/McpDiagnostics.jsx +++ b/admin/src/components/McpDiagnostics.jsx @@ -187,6 +187,7 @@ export default function McpDiagnostics() { { __( 'When', 'saddle' ) } { __( 'Asked for', 'saddle' ) } + { __( 'Signed in with', 'saddle' ) } { __( 'Result', 'saddle' ) } { __( 'App', 'saddle' ) } @@ -202,7 +203,25 @@ export default function McpDiagnostics() { { ( entry.methods || [] ).join( ', ' - ) || '—' } + ) || + entry.method || + '—' } + + + { /* The column that answers "was it + refused because the key was wrong, + or because none arrived?" — which a + 401 alone cannot. */ } + { entry.auth === 'absent' ? ( + + { __( + 'nothing sent', + 'saddle' + ) } + + ) : ( + entry.scheme || '—' + ) } { entry.status >= 200 && diff --git a/includes/class-saddle-mcp-diagnostics.php b/includes/class-saddle-mcp-diagnostics.php index ee87692..25b524b 100644 --- a/includes/class-saddle-mcp-diagnostics.php +++ b/includes/class-saddle-mcp-diagnostics.php @@ -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. @@ -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 ), ); @@ -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'] : '?', @@ -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 . '/' ); } /** diff --git a/languages/saddle.pot b/languages/saddle.pot index 707b43d..27ecb3e 100644 --- a/languages/saddle.pot +++ b/languages/saddle.pot @@ -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 \n" "Language-Team: LANGUAGE \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" @@ -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 "" diff --git a/tests/mcp-diagnostics-test.php b/tests/mcp-diagnostics-test.php index 47874e7..251d57d 100644 --- a/tests/mcp-diagnostics-test.php +++ b/tests/mcp-diagnostics-test.php @@ -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( @@ -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() ); } /**