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)
- 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.