Reference card · Python · Model Context Protocol

PSE Edge MCP

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.

v0.16.0

From pyproject.toml.
Live at pse.sakayandgo.com,
published to ghcr.io/phdwight/pse-edge-mcp.

01

Architecture & layering

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.

MCP client
tool call in
validates
server.py
reply() · boundary
delegates
Repository
one of five
freeze read
FreezeService
3 policies
miss → fetch once
PseEdgeClient
throttled https
2 dialects
PSE Edge
edge.pse.com.ph
EOD-frozen (prices), market open: cached → serve the last close,
never refetch; never cached → fetch once, serve previous_close only,
flagged “not realtime”
. Every other policy fetches at any hour — once.
Fig. 1 — the read path every data tool follows

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.

EOD-frozen
prices · the ★ gate

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.

daily-refresh
everything else

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.

immutable
never refetched

Disclosure detail by edge_no — the object never changes upstream. Fetched once, at any hour, cached forever; valid_until: null.

server.py
MCP boundary

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.

search_companiesvalidate_symbol get_stock_quoteget_price_history search_disclosuressearch_disclosure_fulltext get_disclosureget_company_profile get_financial_highlightsget_dividends_and_rights get_indicesget_market_summary get_server_version send_email · auth only
repositories.py
domain layer

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.

service.py · sources.py
policy & seams

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.

client.py · parsers.py
edge of the world

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.

02

Core class map

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.

Repositorycompanies
CompanyRepository
  • search(query) list[CompanyHit]
  • resolve(symbol) CompanyHit
  • try_resolve(symbol) CompanyHit | None
consumes CompanySource · policy daily-refresh · resolves symbol → company_id for every other repo
Repositoryprices
QuoteRepository
  • quote(symbol) StockQuote
  • history(symbol, start, end) PriceHistory
consumes QuoteSource · policy EOD-frozen ★ — the only market-gated domain · bars archived via Archive
Repositorydisclosures
DisclosureRepository
  • search(…) DisclosureSearchResult
  • fulltext(…) KeywordSearchResult
  • detail(edge_no, max_files) DisclosureDetail
  • attachment(file_id) bytes · MCP resource
consumes DisclosureSource · searches daily-refresh; detail is immutable: cached forever, valid_until: null
Repositorycompany info
CompanyInfoRepository
  • profile(symbol) CompanyProfile
  • financials(symbol) FinancialHighlights
  • dividends_and_rights(symbol) DividendsAndRights
consumes CompanyInfoSource · policy daily-refresh · financial units passed through verbatim, never rescaled
Repositorymarket-wide
MarketRepository
  • indices() MarketIndices
  • summary() MarketSummary
consumes MarketSource · policy daily-refresh — an intraday snapshot is served until the close · index signs derived from the ▲/▼ glyph
Actionthe only one
NotificationService
  • send(user, subject, body) SentEmail
recipient comes from the bearer token, never an argument — an address parameter would be an open mail relay and an injection exfiltration path

Protocols & swappable implementations

StorageInMemoryStorage or PostgresStorage
ArchiveNullArchive or PostgresArchive
UsageSinkNullUsageRecorder or PostgresUsageSink
AuthStorePostgresAuthStore
EmailSenderConsoleEmailSender or ZeptoMailSender
FrozenCacheFreezeService (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.

HTTP composition — built once, in asgi.py

HealthApp
/health liveness (no DB) · /health/ready readiness
AuthApp
/oauth/* · passkey signup · /account · /privacy — reachable without a token
AuthMiddleware
bearer validation (TokenService) · quotas (QuotaTracker) · usage
MCP app
the tool surface from §01

Behind AuthApp: OAuthService (DCR · PKCE-only · refresh families), PasskeyService (WebAuthn + web sessions), TokenService (opaque pse_ tokens, SHA-256 at rest).

Error family — one root, mapped once in reply()

PseEdgeMcpError
SymbolNotFoundError · SYMBOL_NOT_FOUND
InvalidArgumentError · INVALID_ARGUMENT
EndpointChangedError · Edge redesigned — loud, never partial
EdgeUnavailableError · unreachable and nothing cached
MarketOpenNoCacheError · retry after 15:00 Manila
ActionUnavailableError · action needs auth
ActionRateLimitedError · 20 emails / user / day

Watchdog — canary.py · pse-edge-canary

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.

03

Configuration matrix

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

VariableDefaultWhat it governs
Upstream · protect PSE Edge
PSE_EDGE_BASE_URLhttps://edge.pse.com.phUpstream portal root
PSE_THROTTLE_RPS / PSE_THROTTLE_BURST1.0 / 2Token-bucket rate toward Edge
PSE_TIMEOUT_SEC / PSE_RETRY_ATTEMPTS20 / 3Per-request timeout and retries
Storage · the one switch
DATABASE_URLunsetUnset → in-memory cache + NullArchive. Set → shared Postgres cache + archive + auth tables (schema via Alembic only)
PSE_DB_POOL_SIZE / PSE_DB_MAX_OVERFLOW5 / 10Connection pool
Auth · opt-in, needs DATABASE_URL
PSE_AUTH_REQUIRED0Bearer auth + quotas + OAuth/passkeys; stdio never authenticates
PSE_TOKEN_CACHE_TTL60 sThe revocation-latency budget — nothing else
PSE_QUOTA_PER_MIN / PSE_QUOTA_PER_DAY60 / 2000Per-user quotas, counted in-process (per worker)
PSE_PUBLIC_URLhttp://localhost:8000Real external https URL — drives WebAuthn rp_id, email links, OAuth issuer; wrong value breaks passkeys
PSE_ACCESS_TTL_MIN / PSE_REFRESH_TTL_DAYS30 / 30Token lifetimes; refresh reuse revokes the family
PSE_ADMIN_EMAILSemptyOperator allowlist: the /account machine-client panel; never derived from user input
Email & operations
ZEPTOMAIL_API_KEYunsetUnset → ConsoleEmailSender; set → ZeptoMail
PSE_EMAIL_FROMno-reply@localhostSender address
PSE_OPERATOR_EMAILunsetCanary failure alerts — failures only, never “all fine”
PSE_USAGE_RETENTION_DAYS90Usage log retention (aggregated per user-hour, never per request)
Server
PSE_STATEFUL / PSE_SSE0 / 0MCP session & response mode
PSE_LOG_JSON / PSE_LOG_LEVEL0 / INFOBoth formatters timestamp and redact; INFO logs refusals only
04

Golden path

One request, traced through every layer: get_stock_quote("SM") after market close, cold cache.

1
get_stock_quote(symbol="SM") · server.py

validation.py checks the symbol shape (bad input → INVALID_ARGUMENT), then reply() wraps the repository call — the only place errors become MCP error payloads.

2
QuoteRepository.quote("SM") · repositories.py

Resolves SMcompany_id through CompanyRepository, picks the endpoint, and builds the cache key. Tools never see any of this.

3
FreezeService.get(key, fetch, policy="EOD-frozen") · service.py · ★

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.

4
PseEdgeClient.fetch_stock_data_page(company_id) · client.py

Token bucket (1 req/s), single-flight dedupe, retries. Wire dates are MM-dd-yyyy; the JSON-vs-form dialect is chosen per endpoint.

5
parse → StockQuote · parsers.py · models.py

HTML → dict → validated Pydantic model. Any drift in Edge's markup raises EndpointChangedError — never silently partial data.

6
cache until next 15:00 close → archive bars · cache.py · archive.py

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"
  }
}
Legend concrete class / module protocol — a swap point the MCP core the freeze invariant