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 .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,4 @@ jobs:
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90af4856ee69a657050a5670eb98513188ba # v4.0.5
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4.0.5
15 changes: 11 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -354,13 +354,20 @@ docs-serve:
## Playwright through every portal page. Requires the binary to
## be running on $(SHOTS_BASE_URL) (default http://localhost:8080).
## Re-run after any portal UI change.
##
## Sources .env.dev for MCPTEST_DEV_KEY so the captured-portal API
## key matches the running binary's accepted file key. Override
## SHOTS_API_KEY explicitly to point at a different deployment
## (e.g. staging); that wins over .env.dev.
SHOTS_BASE_URL ?= http://localhost:8080
SHOTS_API_KEY ?= devkey-please-change
screenshots:
screenshots: dev-secrets
@command -v node >/dev/null || { echo "node is required"; exit 1; }
@cd scripts/screenshots && \
@. ./.env.dev && \
KEY="$${SHOTS_API_KEY:-$$MCPTEST_DEV_KEY}" && \
if [ -z "$$KEY" ]; then echo "no API key: set SHOTS_API_KEY or run make dev-secrets"; exit 1; fi && \
cd scripts/screenshots && \
(test -d node_modules || npm install) && \
MCPTEST_BASE_URL=$(SHOTS_BASE_URL) MCPTEST_DEV_KEY=$(SHOTS_API_KEY) node screenshots.mjs
MCPTEST_BASE_URL=$(SHOTS_BASE_URL) MCPTEST_DEV_KEY="$$KEY" node screenshots.mjs

## run: Build and run
run: build
Expand Down
Binary file added docs/images/portal/audit-compare-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/portal/audit-compare-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/audit-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/portal/audit-drawer-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/portal/audit-drawer-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/portal/audit-jsonb-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/portal/audit-jsonb-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/audit-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/portal/audit-livetail-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/images/portal/audit-livetail-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/config-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/config-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/dashboard-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/dashboard-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/keys-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/keys-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/tools-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/tools-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/tools-tryit-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/tools-tryit-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/wellknown-dark.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified docs/images/portal/wellknown-light.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
187 changes: 176 additions & 11 deletions docs/javascripts/shots.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,17 @@
/**
* Portal-screenshots carousel. Slides the track via `transform: translateX`
* with a CSS transition for the actual animation; JS just tracks the
* current index and computes the offset.
* Portal-screenshots carousel + lightbox.
*
* Wraparound: clicking next at the last slide returns to 0; prev at 0
* jumps to the last slide. Buttons stay enabled the whole time.
* Carousel: slides the track via `transform: translateX` with a CSS
* transition for the actual animation; JS just tracks the current index
* and computes the offset. Wrap-around is implicit; buttons stay enabled.
*
* Lightbox: clicking any frame opens a near-full-screen modal showing
* the screenshot at full size. ESC closes; arrow keys step between
* shots. Backdrop click closes. Focus is captured on open and restored
* on close.
*
* Material's instant-nav can re-mount the page; we guard re-binding via
* data attributes on each subscribed root.
*/
(function () {
function init() {
Expand All @@ -20,6 +27,9 @@
var slides = Array.from(track.querySelectorAll(".plex-shots__slide"));
if (slides.length === 0) return;

var counter = root.querySelector("[data-shots-counter]");
var lightbox = root.querySelector("[data-shots-lightbox]");

var index = 0;

function step() {
Expand All @@ -34,15 +44,33 @@
slides.forEach(function (s, i) {
s.classList.toggle("is-active", i === index);
});
if (counter) {
counter.innerHTML =
"<strong>" + String(index + 1).padStart(2, "0") + "</strong>" +
" / " + String(slides.length).padStart(2, "0");
}
}

next.addEventListener("click", function () {
index = (index + 1) % slides.length;
apply();
});
prev.addEventListener("click", function () {
index = (index - 1 + slides.length) % slides.length;
function go(delta) {
index = (index + delta + slides.length) % slides.length;
apply();
}

next.addEventListener("click", function () { go(1); });
prev.addEventListener("click", function () { go(-1); });

// Keyboard nav on the carousel itself: when focus lives within the
// stage, arrow keys advance. The lightbox owns its own keyboard
// handler when open, so this only fires while it's closed.
root.addEventListener("keydown", function (e) {
if (lightbox && !lightbox.hidden && lightbox.classList.contains("is-open")) return;
if (e.target.closest(".plex-shots__frame")) {
// Don't hijack Enter/Space on the frame button: those
// legitimately open the lightbox.
if (e.key === "Enter" || e.key === " ") return;
}
if (e.key === "ArrowLeft") { go(-1); }
else if (e.key === "ArrowRight") { go(1); }
});

// Keep the offset correct across resizes (slide widths are vw-based).
Expand All @@ -53,6 +81,143 @@
});

apply();
bindLightbox(root, slides, lightbox, function (newIndex) {
// When the lightbox navigates, sync the carousel so closing
// returns the user to the slide they were last viewing.
index = newIndex;
apply();
}, function () { return index; });
});
}

function bindLightbox(root, slides, lightbox, onIndex, getIndex) {
if (!lightbox || lightbox.dataset.lightboxBound === "1") return;
lightbox.dataset.lightboxBound = "1";

var imgLight = lightbox.querySelector("[data-lightbox-img-light]");
var imgDark = lightbox.querySelector("[data-lightbox-img-dark]");
var titleEl = lightbox.querySelector("#plex-lightbox-title");
var bodyEl = lightbox.querySelector("[data-lightbox-body]");
var countEl = lightbox.querySelector("[data-lightbox-count]");
var prevBtn = lightbox.querySelector("[data-lightbox-prev]");
var nextBtn = lightbox.querySelector("[data-lightbox-next]");
// Note: data-shots-close lives on BOTH the backdrop div and the close
// button; the close button is what we focus on open (the backdrop
// isn't focusable). The dismiss handler iterates closeBtns to attach
// click handlers to both.
var closeBtns = lightbox.querySelectorAll("[data-shots-close]");
var closeBtn = lightbox.querySelector("button[data-shots-close]");

var lastFocus = null;
var current = 0;

function fillFromSlide(slideIndex) {
var slide = slides[slideIndex];
var frame = slide && slide.querySelector(".plex-shots__frame");
if (!frame) return;
current = slideIndex;

var title = frame.getAttribute("data-zoom-title") || "";
var body = frame.getAttribute("data-zoom-body") || "";
var light = frame.getAttribute("data-zoom-light") || "";
var dark = frame.getAttribute("data-zoom-dark") || "";

if (titleEl) titleEl.textContent = title;
if (bodyEl) bodyEl.textContent = body;
if (imgLight) {
imgLight.src = light;
imgLight.alt = "Portal " + title + " screen, light theme";
}
if (imgDark) {
imgDark.src = dark;
imgDark.alt = "Portal " + title + " screen, dark theme";
}
if (countEl) {
countEl.textContent =
String(slideIndex + 1).padStart(2, "0") +
" / " +
String(slides.length).padStart(2, "0");
}
}

function open(slideIndex) {
// If a previous close() is still in its 260ms post-fade hide
// window, cancel the pending hide so the freshly-opened modal
// doesn't get yanked back to hidden mid-display.
if (lightbox._hideTimer) {
clearTimeout(lightbox._hideTimer);
lightbox._hideTimer = null;
}
lastFocus = document.activeElement;
fillFromSlide(slideIndex);
lightbox.hidden = false;
// Force a reflow so the transition runs on the next paint.
void lightbox.offsetWidth;
lightbox.classList.add("is-open");
document.documentElement.classList.add("plex-lightbox-open");
// Focus the close button so ESC and Enter both work immediately
// and screen-reader users land inside the dialog.
if (closeBtn) closeBtn.focus();
window.addEventListener("keydown", onKey, true);
}

function close() {
lightbox.classList.remove("is-open");
document.documentElement.classList.remove("plex-lightbox-open");
window.removeEventListener("keydown", onKey, true);
// After the fade completes, hide outright so the modal can't
// catch tab focus or screen-reader attention.
lightbox._hideTimer = setTimeout(function () {
lightbox.hidden = true;
}, 260);
onIndex(current);
if (lastFocus && typeof lastFocus.focus === "function") {
lastFocus.focus();
}
lastFocus = null;
}

function onKey(e) {
if (e.key === "Escape") {
e.stopPropagation();
close();
} else if (e.key === "ArrowLeft") {
e.stopPropagation();
e.preventDefault();
fillFromSlide((current - 1 + slides.length) % slides.length);
} else if (e.key === "ArrowRight") {
e.stopPropagation();
e.preventDefault();
fillFromSlide((current + 1) % slides.length);
}
}

// Click handlers on the carousel frames.
slides.forEach(function (slide, i) {
var frame = slide.querySelector("[data-shots-zoom]");
if (!frame) return;
frame.addEventListener("click", function (e) {
e.preventDefault();
// Sync the carousel to the clicked slide first so the
// background reflects the lightbox's starting frame. open()
// handles its own pending-hide-timer cleanup.
if (i !== getIndex()) onIndex(i);
open(i);
});
});

// Lightbox buttons.
if (prevBtn) prevBtn.addEventListener("click", function () {
fillFromSlide((current - 1 + slides.length) % slides.length);
});
if (nextBtn) nextBtn.addEventListener("click", function () {
fillFromSlide((current + 1) % slides.length);
});
closeBtns.forEach(function (btn) {
btn.addEventListener("click", function (e) {
e.preventDefault();
close();
});
});
}

Expand Down
38 changes: 27 additions & 11 deletions docs/operations/inspection.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Inspection workflow
description: End-to-end walkthrough of the audit inspection utility capture a call, open the drawer, replay it, compare to a baseline, filter via JSONB paths, and export.
description: End-to-end walkthrough of the audit inspection utility (capture a call, open the drawer, replay it, compare to a baseline, filter via JSONB paths, and export).
---

# Inspection workflow
Expand All @@ -17,20 +17,24 @@ The audit pipeline records every tool call. The inspection utility is the operat

The pipeline captures every `tools/call` automatically when `audit.enabled: true` (default). Two tables are written in one transaction:

- `audit_events` indexed summary (timestamp, tool, user, success, duration). Used for browsing and filtering.
- `audit_payloads` full request / response envelope (parameters, headers, response result, response error, notifications, replay linkage). Optional; `capture_payloads: false` keeps the summary only.
- `audit_events`: indexed summary (timestamp, tool, user, success, duration). Used for browsing and filtering.
- `audit_payloads`: full request / response envelope (parameters, headers, response result, response error, notifications, replay linkage). Optional; `capture_payloads: false` keeps the summary only.

To produce a fresh row to inspect, fire any tool. The portal's Try-It page (`/portal/tools/<name>`) is the easiest way; any MCP client works too.

## 2. Open the drawer

In the portal, navigate to `Audit`. Each row in the events table is clickable; the click opens a side drawer with four tabs:

![Audit drawer with the four tabs (Overview / Request / Response / Notifications) open over the events table](../images/portal/audit-drawer-light.png#only-light)
![Audit drawer with the four tabs (Overview / Request / Response / Notifications) open over the events table](../images/portal/audit-drawer-dark.png#only-dark)


### Overview tab
Timing, identity, request id, session id, source (`mcp` for real client calls, `portal-tryit` for /admin/tryit invocations, `portal-replay` for replays), and the replay linkage (`Replayed from`) when present.

### Request tab
The captured `request_params` (sanitized via `audit.redact_keys`, with redacted values shown as `"[redacted]"`). Captured request headers when `audit.capture_headers: true` credential-bearing names (`Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-API-Key`) are stored as `"[redacted]"` regardless of the redact-keys config; the names remain visible so an operator can confirm "this request carried an Authorization header" without seeing the token. A truncation warning when the request body exceeded `audit.max_payload_bytes`.
The captured `request_params` (sanitized via `audit.redact_keys`, with redacted values shown as `"[redacted]"`). Captured request headers when `audit.capture_headers: true`; credential-bearing names (`Authorization`, `Cookie`, `Set-Cookie`, `Proxy-Authorization`, `X-API-Key`) are stored as `"[redacted]"` regardless of the redact-keys config; the names remain visible so an operator can confirm "this request carried an Authorization header" without seeing the token. A truncation warning when the request body exceeded `audit.max_payload_bytes`.

### Response tab
The full `CallToolResult` content blocks (text, image, audio, structured) plus `response_error` when the call errored. The shape matches what the SDK serializes to the wire so you can see what the client saw. A truncation warning fires when the response body was too large.
Expand All @@ -41,7 +45,7 @@ Chronological list of every `notifications/*` (progress, log message) the tool d
Drawer interactions:

- The browser URL gets `?id=<event-id>` appended so the drawer is deep-linkable; share the URL and the recipient lands on the same row.
- The **Compare** button stashes the open event id in `localStorage`. Open another row's drawer and you'll see "Compare with selected" clicking opens the comparison page with both events.
- The **Compare** button stashes the open event id in `localStorage`. Open another row's drawer and you'll see "Compare with selected"; clicking opens the comparison page with both events.
- The **Replay** button is the next step.
- `Esc` and the backdrop close the drawer.

Expand All @@ -59,11 +63,15 @@ Per-identity rate limit (scoped by API key id or OIDC subject): 5 burst, one tok

Two events you stashed via the drawer's Compare button can be opened side-by-side at `/portal/audit/compare?a=<id>&b=<id>`. The page renders:

![Comparison page showing the structural diff of two audit events with summary, request_params, response_result, and notifications panels](../images/portal/audit-compare-light.png#only-light)
![Comparison page showing the structural diff of two audit events with summary, request_params, response_result, and notifications panels](../images/portal/audit-compare-dark.png#only-dark)


- A summary block (tool, source, result, duration, user, auth type) with diffs highlighted.
- Per-payload diff trees for `request_params`, `response_result`, `response_error`, plus a count comparison for notifications.
- Each leaf in the tree is annotated: same (muted), differ (warning color, `before → after`), only-in-A (red `-`), only-in-B (green `+`).

The diff is JSON-path-aware: it walks objects and arrays by key/index instead of doing a text diff, so reordered keys (a Postgres read returning fields in any order) don't show as changes, and a string-vs-object swap appears as one diff at the path it happened not as a wall of red lines.
The diff is JSON-path-aware: it walks objects and arrays by key/index instead of doing a text diff, so reordered keys (a Postgres read returning fields in any order) don't show as changes, and a string-vs-object swap appears as one diff at the path it happened, not as a wall of red lines.

Common compare workflows:

Expand All @@ -75,11 +83,15 @@ Common compare workflows:

The Audit page has a **JSONB filters** toggle that opens an editor for the path-aware filters the server compiles to JSONB containment queries. Operators routinely live with these set:

- `param.user.id=alice` — every call where the request param at the dotted path `user.id` equals `alice`.
- `response.isError=true` — every call whose response had `IsError=true` (matches the JSON literal `true`, not the string `"true"`; values are type-detected).
- `header.User-Agent=curl/8.0` — every call from a specific User-Agent. Header names are canonicalized (`user-agent` matches `User-Agent`).
- `has=response_error` — every call that recorded a transport-level error.
- `has=notifications` — every call that fired any notification.
![JSONB filter editor expanded with one applied filter (`param.user.id=alice`) above the events table](../images/portal/audit-jsonb-light.png#only-light)
![JSONB filter editor expanded with one applied filter (`param.user.id=alice`) above the events table](../images/portal/audit-jsonb-dark.png#only-dark)


- `param.user.id=alice`: every call where the request param at the dotted path `user.id` equals `alice`.
- `response.isError=true`: every call whose response had `IsError=true` (matches the JSON literal `true`, not the string `"true"`; values are type-detected).
- `header.User-Agent=curl/8.0`: every call from a specific User-Agent. Header names are canonicalized (`user-agent` matches `User-Agent`).
- `has=response_error`: every call that recorded a transport-level error.
- `has=notifications`: every call that fired any notification.

Filters are AND-combined with each other and with the indexed-column filters (tool, user, success, etc.). They run against `audit_payloads` via `EXISTS` subqueries that hit the existing GIN indexes on `request_params` and `response_result`; `request_headers` is unindexed today so pair `header.*` with a time-range filter on busy deployments.

Expand All @@ -89,6 +101,10 @@ Filters are AND-combined with each other and with the indexed-column filters (to

The **Live tail** toggle on the Audit page opens an SSE connection to `/api/v1/portal/audit/stream`. New audit events appear in a fixed-cap most-recent-first list (cap 20) above the table as they're written; clicking one opens the drawer. The table itself stays a historical-filter view so the live tail doesn't blow away your filtered context.

![Audit page with Live tail toggled on, showing the buffer of recent SSE-delivered events above the historical filter view](../images/portal/audit-livetail-light.png#only-light)
![Audit page with Live tail toggled on, showing the buffer of recent SSE-delivered events above the historical filter view](../images/portal/audit-livetail-dark.png#only-dark)


The stream sends an opening `: connected` comment on connect, an `event: audit\ndata: <json>` per write, and a `: keepalive` comment every 30 seconds. Slow consumers see per-subscriber drops; the producer never blocks.

## 7. Export
Expand Down
Loading
Loading