This document describes how the AGS API MCP Server is designed and why, including the security mechanisms it relies on.
- Overview
- Stateless Design
- Analytics & Visualization Architecture
- Performance Characteristics
- Security
- Design Rationale
The server is built around:
- Stateless architecture — no server-side sessions or token storage
- HTTP-only transport — single POST endpoint for MCP messages
- Zod validation — runtime type safety throughout
- MCP specification compliance — full spec adherence
- Structured responses — both text content and
structuredContent
┌─────────────────────────────────────┐
│ No Server-Side State │
│ - Token in Authorization header │
│ - Client manages refresh │
│ - Per-request validation │
│ - Factory pattern for MCP servers │
└─────────────────────────────────────┘
Trade-offs:
- Simpler deployment (no state to manage)
- Horizontal scaling (no session affinity)
- One exception, until AFT-215: the AFS provenance bridge
(
src/v2/mcp/session-cache.ts) is an in-process map keyed by a mintedMcp-Session-Id. Both provenance members it feeds — the MCP client identity and the catalog version — are written on one request and read on a later one, so across replicas without affinity they are populated only when both requests land on the same process. Attribution degrades to absent, never wrong, and no request fails. Nothing else in the request path reads it.
- One exception, until AFT-215: the AFS provenance bridge
(
- No memory leaks from session accumulation
- Client must handle token refresh
The server uses client-provided bearer tokens. Clients obtain a JWT externally from AccelByte IAM and pass it on each request:
Authorization: Bearer <jwt>
The server extracts and uses the token per request. No tokens are stored.
- Zod schema validation for all tool inputs and outputs
McpErrorwith properErrorCodeenums- Structured error responses
- Output schemas defined for all tools
The server has two tool families alongside the core OpenAPI tools:
run-apisexposes raw HTTP access to all loaded specs, includingafs.json- 16 provider-backed
render_*tools resolve tabular data through a provider registry and emit strictstructuredContentpayloads for the browser bundle render_text_editoris the one input tool: it is not provider-backed — its value originates in the webview (see below)
Each request-scoped server instance builds its own provider registry:
facaderesolvesquery_id+namespaceagainst the Athena Facade using the per-request effective AGS base URLdirectaccepts inlinedata_columns+data_rowsfor small datasets and tests
Because a fresh McpServer is created per HTTP POST, provider registration stays request-scoped and hosted-mode tenant routing is preserved.
The analytics UI is shipped as ui://renderer/index.html:
- Registered via
@modelcontextprotocol/ext-apps - Built by Vite into
dist/v2/renderer/index.html - Loaded once and memoized on the server
- Advertises
_meta["ags/bundleVersion"] - Validated by the browser bundle against the shared
BUNDLE_VERSION
The renderer source lives under src/v2/renderer/**, while tsconfig.renderer.json isolates DOM/browser typing from the server tsconfig.json.
The 16 provider-backed render tools share the same input model: a data source (provider="facade" with query_id + namespace, or provider="direct" with inline data_columns + data_rows), optional title / description / column_hints / filters, and tool-specific options. Every render tool returns strict structured content keyed by chart_type.
| Tool | chart_type |
Required options | Optional options |
|---|---|---|---|
render_bar_chart |
bar |
x, y |
color, bar_mode, orientation, label, facet_col, facet_row, x_label, y_label, tooltip |
render_line_chart |
line |
x, y |
color, show_points, curve, facet_col, facet_row, x_label, y_label, tooltip |
render_area_chart |
area |
x, y |
color, stack_mode, curve, facet_col, facet_row, x_label, y_label, tooltip |
render_scatter_chart |
scatter |
x, y |
color, size, trend_line, facet_col, facet_row, x_label, y_label, tooltip |
render_histogram_chart |
histogram |
column |
bin_count, normalize, color, facet_col, facet_row, x_label, y_label |
render_box_chart |
box |
x, y |
color, facet_col, facet_row, x_label, y_label |
render_heatmap_chart |
heatmap |
x, y, value |
color_scheme, show_values, x_label, y_label |
render_pie_chart |
pie |
category, value |
show_labels, other_threshold |
render_donut_chart |
donut |
category, value |
show_labels, other_threshold, center_label, hole |
render_waterfall_chart |
waterfall |
category, value |
is_total, x_label, y_label |
render_funnel_chart |
funnel |
stage, value |
orientation, show_conversion |
render_gauge_chart |
gauge |
value, max |
min, thresholds, unit |
render_state_timeline_chart |
state_timeline |
entity, start, end, state |
— |
render_table |
table |
— | columns_order, page_size |
render_metric |
metric |
value |
compare, label, unit, format |
render_meter |
meter |
value |
max, label, color, unit, format |
render_text_editor does not use a provider. It loads an editable document into the webview
(CodeMirror 6) with syntax highlighting and a language picker, plus a markdown preview.
| Tool | chart_type |
Required input | Optional input |
|---|---|---|---|
render_text_editor |
text_editor |
content |
language (markdown|json|yaml|javascript|text), title |
- Display mode: fullscreen → editor; inline/pip → preview (rendered markdown, or read-only
highlighted code with folding). The webview reads
hostContext.displayModeand can toggle viarequestDisplayMode. - Exits (both verbatim, both stateless): the edited bytes leave the webview by value, never
regenerated by the model —
updateModelContext(embedded resource → assistant reads it this turn) anddownloadFile(embedded resource → durable file that survives chat compaction). A clipboard copy is the universal fallback. Each action is capability-gated viagetHostCapabilities(). - The server stores nothing; persistence to an API (e.g. AFS
…/context) stays downstream and model-driven.
For write-side API calls (POST/PUT/PATCH/DELETE through run-apis), the tool uses MCP elicitation to request user approval before execution.
The renderer resource has two modes, one bundle: the default single-result mode (each tool call replaceChildren) and a dashboard mode (chart_type:"dashboard") that accumulates pinned queries. The app-shell dispatches the dashboard branch before the chart fallthrough; the dashboard view (src/v2/renderer/views/dashboard.ts) owns a usage header plus a pinned-query grid and rebuilds each card body with the same renderChart/renderTable/renderMetric views.
| Tool | Visibility | Purpose |
|---|---|---|
open_dashboard |
model-facing | Returns a metadata-only dashboard payload (pins + quota/usage, no rows) bound to the renderer resource. Small and safe to persist in chat history. |
load_dashboard |
app-only | The widget's self-load: resolves each pin's cached rows via the existing GET .../queries/{id} path and the usage header, returning full RenderOutputs. Rows reach the widget here, never the transcript. |
get_quota_usage |
app-only | Proxies GET .../quota/usage for the header. |
pin_query / unpin_query |
app-only | Create/delete a pin in the downstream .../pinned-queries store. |
update_pinned_query |
app-only | Edit a pin's layout/label — title (rename), span (grid width 1–12), position — via the facade PATCH (partial update; SQL/query_id/render-spec immutable; not billable). |
refresh_pinned_query / refresh_all_pinned |
app-only | Force a SQL re-run (the only billing path); refresh-all is budget-gated and stops on 429. |
A pin stores SQL (source of truth) + last query_id (cache pointer) + render spec (render_tool + opaque render_options) — never rows. Card bodies are rebuilt from the stored render_tool via a registry in renderers/define.ts (buildPinRenderOutput), so any of the 16 charts re-renders with no bespoke code.
Live vs. static pins. A pin's source is inferred per pin (no provider field): a live pin carries a query_id and resolves rows from the facade (refreshable, durable downstream); a static pin carries inline data_columns/data_rows — those rows are the data (a snapshot, built via buildPinRenderOutput(..., dataSource:"direct"), capped at MAX_ROWS_DEFAULT). Static pins are transient (model-held, never persisted, never billed): resolvePinCard builds them without touching the facade, the refresh tools return them unchanged, and the view shows a "Snapshot" badge with no Refresh. The inline rows ride along on both PinnedQueryMeta and PinnedQueryCard so a self-load/focus reload round-trips them instead of dropping to stale.
Dev-only. Static/direct pins are gated behind
DASHBOARD_ALLOW_DIRECT_PINS(defaultfalse) — a development flag, not a customer-facing option. In production this is off, so dashboards are live-only.
Layout. Each pin has an optional span (integer 1–12, clamped; default 4) placing it on a fixed 12-column grid in fullscreen; cards are a fixed height (--dashboard-card-height) with the body scrolling inside. Compact/inline and containers narrower than 720px collapse to a single column (spans ignored). span is layout-only. In fullscreen a live pin's span is user-editable via Wider/Narrower kebab items (disabled at the 1/12 bounds) and its title via inline click-to-edit; both persist through update_pinned_query → facade PATCH, applied optimistically and rolled back on failure. Static/snapshot pins are read-only here — they have no durable row, so a snapshot's width/title is changed by re-prompting the model to re-open the dashboard.
Lifecycle invariants:
- Open never re-runs SQL.
load_dashboardresolves cached results only; an expired/absentquery_idyields a stale card ("click Refresh"), so a dashboard of moving-window queries can't bill on every open (denial-of-wallet defense). - Refresh is the only re-run path and is always an explicit user click.
- Restart survival without trusting replay: the widget self-calls
load_dashboardon mount and onvisibilitychange, reconstructing from the stateless server even if the host evicted the original result (context compaction). Sizing readshostContext.containerDimensions.height(fixed body, scroll inside — never report content height); display modes are read from the mergedgetHostContext(). - Abuse resistance: all card text is set via
textContent(neverinnerHTML);render_optionsis re-validated against the chart schema in both the server and the bundle, and each card renders inside its owntry/catchso a hostile pin shows an inline error instead of executing or blanking the grid.
Pin persistence lives downstream in athena-facade-api (.../pinned-queries); the MCP server stays a stateless proxy (src/v2/mcp/tools/providers/pinned-queries.ts). These endpoints are now in the bundled afs spec, so open_dashboard/load_dashboard/get_quota_usage and the save/refresh tools call them directly. Where an environment's facade hasn't deployed them, the call returns an HTTP error: save/refresh surface it and load_dashboard falls back to model-held pins with a notice. A server build whose bundled spec predates these endpoints still degrades with a clear PINNED_QUERIES_UNAVAILABLE error (the runApi lookup miss).
The renderer's buttons, badges, and footer notes — Pin, Refresh, Remove, the spend header, the "source: inline" and scanned-bytes notes — are not hand-wired per view. They are chrome: small declarative affordances resolved from one catalog and placed by data-driven eligibility, so the standalone result view and the dashboard (header + cards) stay consistent and adding an affordance is a one-file change. The system is client-owned and lives entirely in the webview bundle (src/v2/renderer/chrome/**): the server emits only pure context (provider data, stats, capabilities, data_source) as JSON on the render output, and the bundle owns the registry, the resolver (placement), and the binder (behavior). Nothing here touches the stateless server contract.
buildChromeContext() (chrome/context.ts) assembles a ChromeContext for the surface being rendered, split into four namespaces so unrelated concerns can't bleed together (shared/chrome-context.ts):
core— a sealedCoreCtx(container,renderType,permissions, optionalpin): the only general-purpose context every chrome may read. It is held shut by a compile-time witness (Record<keyof CoreCtx, true>) plus a runtime allowlist and a guardrail test, so growing it is a deliberate, reviewed change rather than a casual diff.provider— a discriminatedContextFragmentunion:facade(canRefresh: true, carriessql/stats) ordirect(canRefresh: false, an inline snapshot). Refreshability is a consequence of the discriminant, not an independent flag; a new provider adds a union member, never a field oncore.render— the on-screen payload a chrome forwards when it acts (title, opaqueoptions,renderTool).surface— live state of the surrounding surface (interactive/fullscreen, pin count, usage snapshot, namespace) read for eligibility.
Each chrome is declared once via defineChrome<Slice> (chrome/types.ts):
select(ctx) → Slice | nullfuses "does this apply here?" with "do I have my data?" —nullmeans not shown; a non-null slice is exactly whatrender/intentread. Eligibility therefore can't drift out of sync with the data a chrome needs.render(slice) → HTMLElementis pure presentation.intent?(slice) → Intentemits one of a closedIntentTypeunion (pin|remove|refresh-all|reload); a chrome never calls a server tool itself. Presentational chrome (footer notes) omits it.effect?(slice, host) → Disposeis lifecycle/event behavior (e.g. the focus-reload subscription), driven through the binder via anEffectHostand torn down by the surface'sMountScope.region(header-start|header-end|overflow|footer-start|footer-end),scope(item|container), andprioritygovern placement.
CHROMES (chrome/registry.ts) is the single catalog; every surface resolves from it and no surface keeps its own hardcoded list. resolve(CHROMES, ctx) (chrome/resolve.ts) is the single placement/presence choke point: it runs each chrome's select, groups the survivors by region in a fixed REGION_ORDER, and is the seam where a future persisted reorder/hide layer would apply (a documented no-op today). The frame (chrome/frame.ts) mounts the resolved chrome into the surface's DOM and opens a MountScope that owns every effect disposer, so a repaint or teardown removes listeners deterministically. Behavior is mapped in the binder (chrome/binder.ts): createDashboardBinder translates an action to a single callServerTool — pin → pin_query, refresh → refresh_pinned_query, remove → unpin_query, refreshAll → refresh_all_pinned, quotaRefresh → get_quota_usage, update → update_pinned_query — and cross-cutting guards (the refresh-all cost confirmation) wrap an entry as middleware (withCostConfirm), short-circuiting to a Cancelled sentinel when declined. Because the binder is the one place an intent becomes a tool call, the same pin/refresh intent can map to different tools on different surfaces.
The eight chromes registered today (chrome/chromes/*):
| Chrome | Scope | Region | Shown when | Acts via |
|---|---|---|---|---|
source-note |
item | footer-start | provider is direct (inline snapshot) |
— (presentational) |
stats-note |
item | footer-end | facade returned scan/timing stats | — (presentational) |
pin |
item | header-end | standalone result, pinnable, canManagePins |
pin intent → pin_query |
sync |
container | header-start | dashboard surface | reload on visibilitychange (effect) |
refresh-all |
container | header-end | dashboard, fullscreen, ≥ 1 pin | refresh-all intent → refresh_all_pinned (budget-gated) |
quota |
container | header-end | dashboard with a usage snapshot | spend bar; effect refreshes it via get_quota_usage |
refresh |
item | header-end | dashboard card, fullscreen | → refresh_pinned_query |
grow / shrink |
item | overflow (kebab) | dashboard card, fullscreen, live pin | "Wider"/"Narrower" — update intent → update_pinned_query (disabled at span 12/1) |
remove |
item | overflow (kebab) | dashboard card, fullscreen | remove intent → unpin_query |
- One catalog, data-driven placement — adding an affordance is one registry entry + one chrome file; it then appears wherever its
selectmatches, with no per-surface list to keep in sync. - Sealed shared context — uncontrolled growth of
CoreCtxis a build break (witness) and a test failure (allowlist), not a silent diff. - Chrome never calls tools — it emits a typed
Intent; the binder is the only place an action becomes acallServerTool. - Deterministic effect lifecycle — every subscription a chrome opens is owned by the
MountScopeand disposed on repaint/teardown; re-rendering a dashboard over an existing one disposes the prior scope first, so exactly one focus listener is ever live. - XSS-safe — all chrome DOM is built with
textContent/createElement; noinnerHTML.
The Athena Facade spec is loaded like any other OpenAPI spec and exposed through search-apis, describe-apis, and run-apis. The relevant operations:
| Operation | Purpose |
|---|---|
POST /afs/v1/admin/namespaces/{namespace}/queries |
Submit an Athena SQL query |
GET /afs/v1/admin/namespaces/{namespace}/queries/{id} |
Poll status; fetch rows when terminal |
GET /afs/v1/admin/namespaces/{namespace}/tables |
List/search tables in a database |
GET /afs/v1/admin/namespaces/{namespace}/tables/{database}/{table} |
Fetch full table metadata |
If you intend to render via provider="facade", submit with wait_ms=0 and poll the GET endpoint until status="SUCCEEDED". If the submit returns 200 with inline rows on the fast path, render those columns and rows directly with provider="direct" instead of reusing the returned query_id.
openapi-specs/afs.json joins the existing spec set, so Athena Facade access stays on the same contract as the rest of the OpenAPI-backed API surface.
- Memory: constant per request (no accumulation)
- Connections: short-lived HTTP requests
- Cleanup: automatic per-request cleanup
- Scaling: stateless, scales horizontally freely
The server stores no tokens, has no sessions, and trusts client-provided JWTs after verifying their signatures.
All Bearer tokens are cryptographically verified using JWKS (JSON Web Key Set):
- Discovery: The JWKS URI is fetched from
{agsBaseUrl}/.well-known/oauth-authorization-server - Verification: Token signatures are validated against the public keys from the JWKS endpoint
- Algorithm: Only RS256 is accepted (prevents algorithm confusion attacks)
- Issuer Validation: The token's
issclaim is verified against the expected AGS base URL - Expiration: Tokens must be within their
expwindow (30s clock tolerance) - Audience: Optional
audclaim validation (configurable via middleware options)
Invalid or forged tokens receive a 401 Unauthorized response.
To minimize latency and reduce load on the authorization server:
- JWKS URI discovery is cached for 10 minutes (configurable via
JWKS_CACHE_TTL_MS) - Signing keys are cached for 10 minutes (configurable via
JWKS_CACHE_MAX_AGE) - Rate limiting: Maximum 10 JWKS requests per minute (configurable via
JWKS_RATE_LIMIT) - Cache size: Limited to 50 entries to prevent unbounded memory growth
- Pre-warming: JWKS cache is pre-warmed on server startup when auth is enabled
In hosted (multi-tenant) mode, the AGS base URL is derived from the request's Host header. Additional protections include:
- Token issuer is validated against the derived host URL
- Mismatched issuers return
403 Forbidden
The serverUrl parameter has been removed from the run-apis tool across all implementations (V1 HTTP, V1 stdio, V2 MCP). Server URLs now come from:
AB_BASE_URLenvironment variable (or hosted-mode per-request AGS base URL)- OpenAPI specification
servers/ Swagger 2hostmetadata when no environment base URL is configured
As defense-in-depth, all outbound requests are checked against private/internal IP ranges:
IPv4:
127.0.0.0/8(loopback)10.0.0.0/8(RFC 1918 Class A)172.16.0.0/12(RFC 1918 Class B)192.168.0.0/16(RFC 1918 Class C)169.254.0.0/16(link-local / cloud metadata)100.64.0.0/10(CGNAT)198.18.0.0/15(benchmarking)0.0.0.0(unspecified)255.255.255.255(broadcast)
IPv6:
::1(loopback)fe80::/10(link-local)fc00::/7(unique local)fd00::/8(unique local)- IPv4-mapped IPv6 (
::ffff:x.x.x.x) — normalized and checked against IPv4 patterns
Hostnames:
localhostand*.localhostmetadata.google.internal(GCP metadata service)metadata.azure.com(Azure metadata service)
Blocked requests are logged with event: "ssrf_blocked" for security monitoring.
Hostnames are resolved via dns.resolve4/dns.resolve6 and all resolved IP addresses are validated against the private IP patterns before the HTTP request is made. This prevents attackers from returning a public IP during initial validation and a private IP when the actual connection is established.
Structured security events are logged for monitoring and incident response:
| Event | Level | Description |
|---|---|---|
auth_failure |
WARN | Authentication failure (invalid token, expired, wrong issuer) |
auth_success |
DEBUG | Successful authentication |
suspicious_request |
WARN | Suspicious activity (issuer mismatch, missing claims) |
rate_limit_exceeded |
WARN | Rate limit threshold exceeded |
ssrf_blocked |
WARN | Outbound request to private/internal address blocked |
All security events include the client IP address. Configure TRUST_PROXY when behind a reverse proxy to ensure accurate IP logging.
Security events are emitted as structured JSON logs. Forward them to your log aggregation/SIEM system and configure alerts for the following conditions:
| Alert | Filter | Threshold | Action |
|---|---|---|---|
| High auth failure rate | event = "auth_failure" |
> 10% of total auth requests over 5 min | Investigate for credential stuffing or misconfigured clients |
| SSRF attempts detected | event = "ssrf_blocked" |
Any occurrence (should be rare) | Review source IP, inspect request patterns |
| Suspicious requests | event = "suspicious_request" |
> 5 events from single IP in 1 min | Review auth logs, consider IP blocking |
| Rate limit exceeded | event = "rate_limit_exceeded" |
Frequent from legitimate users | Review rate limit configuration |
Log aggregation filter:
event IN ("auth_failure", "ssrf_blocked", "suspicious_request", "rate_limit_exceeded")
Dashboard suggestions:
- Event counts by type over time
- Top source IPs by auth failure count
- Auth failure reasons breakdown (expired, wrong issuer, invalid signature)
- Configurable via
RATE_LIMIT_ENABLED,RATE_LIMIT_WINDOW_MINS, andRATE_LIMIT_MAX - Defaults: 1000 requests per 15-minute window per IP
- Rate limit violations are logged as security events
Helmet middleware provides:
Content-Security-PolicyStrict-Transport-Security(HSTS)X-Frame-OptionsX-Content-Type-Options- And other standard security headers
See ENVIRONMENT_VARIABLES.md for configuration of the security-related env vars.
- Stateless — aligns with modern API design (JWT bearer tokens); no session bugs, leaks, or race conditions.
- HTTP POST-only — most deployments are HTTP-based; MCP spec permits
405forGET/DELETE; simpler to debug, monitor, and load-balance. - No server-managed OAuth — clients control token lifecycle and can use any auth method; keeps the server stateless.