Reference card · Python · Model Context Protocol
How a tool call becomes end-of-day data — every class, protocol, and seam in the server, plus the configuration matrix and one request traced end to end.
From pyproject.toml.
Live at pse.sakayandgo.com,
published to ghcr.io/phdwight/pse-edge-mcp.
Four layers, one direction of dependency. Every read goes through
FreezeService.get() with an explicit per-domain policy — no tool
ever touches the HTTP client directly. ★ marks the invariant the whole design exists
to protect, and since 0.13.0 it guards prices only.
Market-boundary freeze — prices only (narrowed in 0.13.0). A cached stock
price is never refetched while the market is open (09:30–15:00 Asia/Manila, trading days) —
the last close answers, flagged stale. A price nobody has ever asked for
is the one exception: fetched once mid-session and served as identity +
previous_close only (every session-moving field withheld), with
stale: true plus a meta.note saying it is not a realtime
value; the settled figures replace it after the close. Every other domain is fetch-once-then-persist: a
miss may hit PSE Edge at any hour — once, single-flighted — and repeats of the same query are
served from the database until the next close. If PSE Edge is down, an expired entry is served
flagged stale rather than discarded.
get_stock_quote · get_price_history. A cached
price is never refetched during a session — expired entries serve stale until the
close. A never-cached key is fetched once mid-session, surfaces only
previous_close, and is labelled via meta.note as not realtime for
the whole session. Also the default policy, so an unlabelled read can only
over-protect PSE Edge.
Companies, disclosures, profiles, financials, dividends, indices, summary. First ask fetches at any hour; every repeat of the same query answers from storage until the next 15:00 close — at most one upstream hit per unique query per boundary window.
Disclosure detail by edge_no — the object never changes upstream.
Fetched once, at any hour, cached forever; valid_until: null.
Validates arguments, delegates to a repository, shapes the reply. Error mapping happens
once in reply(); action tools go through act() instead (no freshness meta).
Never: domain logic, cache keys, parsing, endpoint choices.
One repository per data domain. Owns the cache key, the freeze read, the parse, and the Pydantic model. Endpoint routing lives here.
Depends only on the dashed protocols below — testable with a few-line fake, no HTTP mocking.
FreezeService enforces the per-read policy (EOD-frozen · daily-refresh ·
immutable) and wraps every result in Served[T] (value + as_of,
valid_until, from_cache, stale).
sources.py declares the five narrow per-domain source protocols;
FrozenCache is the cache seam.
PseEdgeClient is pure HTTP, MCP-agnostic: token-bucket throttle, single-flight,
retries. Two request dialects — JSON-body POST for chart .ax endpoints, form-encoded
POST returning HTML fragments for search.ax.
parsers.py turns HTML/JSON into dicts; any shape drift raises
EndpointChangedError — loud, never partial.
Five repositories cover the whole tool surface. Each one names the source protocol it consumes and the models it returns — the concrete client satisfies all five protocols, but no repository knows that.
valid_until: null| Storage | InMemoryStorage or PostgresStorage |
| Archive | NullArchive or PostgresArchive |
| UsageSink | NullUsageRecorder or PostgresUsageSink |
| AuthStore | PostgresAuthStore |
| EmailSender | ConsoleEmailSender or ZeptoMailSender |
| FrozenCache | FreezeService (what repositories actually see) |
One switch picks the column:
DATABASE_URL unset → the in-memory / Null column; set → the Postgres column.
Postgres modules import lazily, so a lean install never pays for them.
Behind AuthApp:
OAuthService (DCR · PKCE-only · refresh families), PasskeyService (WebAuthn + web sessions),
TokenService (opaque pse_ tokens, SHA-256 at rest).
Nightly job that fetches live pages, bypassing the cache, and validates the same Pydantic models the repositories build — a 200 with a restyled table is exactly the failure it exists to catch.
Still refuses to run while the market is open
(the ★ invariant outranks it). Emails PSE_OPERATOR_EMAIL only on failure;
exits non-zero so cron notices.
Everything is environment-sourced into one frozen Settings object.
Two variables change the shape of the system: DATABASE_URL picks the storage column
in §02, and PSE_AUTH_REQUIRED turns on the whole auth stack (and the
send_email tool with it).
| Variable | Default | What it governs |
|---|---|---|
| Upstream · protect PSE Edge | ||
| PSE_EDGE_BASE_URL | https://edge.pse.com.ph | Upstream portal root |
| PSE_THROTTLE_RPS / PSE_THROTTLE_BURST | 1.0 / 2 | Token-bucket rate toward Edge |
| PSE_TIMEOUT_SEC / PSE_RETRY_ATTEMPTS | 20 / 3 | Per-request timeout and retries |
| Storage · the one switch | ||
| DATABASE_URL | unset | Unset → in-memory cache + NullArchive. Set → shared Postgres cache + archive + auth tables (schema via Alembic only) |
| PSE_DB_POOL_SIZE / PSE_DB_MAX_OVERFLOW | 5 / 10 | Connection pool |
| Auth · opt-in, needs DATABASE_URL | ||
| PSE_AUTH_REQUIRED | 0 | Bearer auth + quotas + OAuth/passkeys; stdio never authenticates |
| PSE_TOKEN_CACHE_TTL | 60 s | The revocation-latency budget — nothing else |
| PSE_QUOTA_PER_MIN / PSE_QUOTA_PER_DAY | 60 / 2000 | Per-user quotas, counted in-process (per worker) |
| PSE_PUBLIC_URL | http://localhost:8000 | Real external https URL — drives WebAuthn rp_id, email links, OAuth issuer; wrong value breaks passkeys |
| PSE_ACCESS_TTL_MIN / PSE_REFRESH_TTL_DAYS | 30 / 30 | Token lifetimes; refresh reuse revokes the family |
| PSE_ADMIN_EMAILS | empty | Operator allowlist: the /account machine-client panel; never derived from user input |
| Email & operations | ||
| ZEPTOMAIL_API_KEY | unset | Unset → ConsoleEmailSender; set → ZeptoMail |
| PSE_EMAIL_FROM | no-reply@localhost | Sender address |
| PSE_OPERATOR_EMAIL | unset | Canary failure alerts — failures only, never “all fine” |
| PSE_USAGE_RETENTION_DAYS | 90 | Usage log retention (aggregated per user-hour, never per request) |
| Server | ||
| PSE_STATEFUL / PSE_SSE | 0 / 0 | MCP session & response mode |
| PSE_LOG_JSON / PSE_LOG_LEVEL | 0 / INFO | Both formatters timestamp and redact; INFO logs refusals only |
One request, traced through every layer:
get_stock_quote("SM") after market close, cold cache.
validation.py checks the symbol shape (bad input →
INVALID_ARGUMENT), then reply() wraps the repository call —
the only place errors become MCP error payloads.
Resolves SM → company_id through
CompanyRepository, picks the endpoint, and builds the cache key. Tools never
see any of this.
Fresh cache entry → serve it. This is a price read, so the ★ gate
applies: market open + cached → serve the last close flagged stale, never
refetch; market open + never cached → fetch once and label the result
stale: true + note (“not a realtime value”) for the whole session.
Market closed + miss → fetch. A daily-refresh read (any other tool) fetches
here at any hour — once — then serves repeats from the database. Fetch fails but an expired
entry exists → serve it flagged stale.
Token bucket (1 req/s), single-flight dedupe, retries. Wire dates are
MM-dd-yyyy; the JSON-vs-form dialect is chosen per endpoint.
HTML → dict → validated Pydantic model. Any drift in Edge's markup raises
EndpointChangedError — never silently partial data.
The entry freezes until the next market close. Archive writes are opportunistic — a dead database never fails a read.
// every data tool returns this envelope — meta is the freshness contract { "data": { …StockQuote… }, "meta": { "as_of": "2026-08-06T15:00:00+08:00", // ISO-8601, Asia/Manila "valid_until": "2026-08-07T15:00:00+08:00", // null when immutable "from_cache": false, "stale": false, // true = not a settled EOD value "data_policy": "EOD-frozen", // prices; "daily-refresh" elsewhere; "immutable" "note": null // freshness caveat, e.g. "not a realtime value" } }