Skip to main content

pse-edge-mcp

An MCP server exposing Philippine Stock Exchange data from the PSE Edge portal — quotes, price history, disclosures, financial reports, and market data — to Claude and any other MCP client.

Unofficial. PSE Edge has no public API; this project speaks to the same endpoints the portal's own pages use. It is not affiliated with or endorsed by the PSE. Data is provided as-is for personal/research use, with no warranty.

Design: end-of-day prices, fetch-once everything else

To keep load on PSE Edge minimal, every unique query hits it at most once per day, and prices follow the stricter market-boundary freeze:

  • Prices (get_stock_quote, get_price_history) are end-of-day. A cached price is never refetched while the market is open (Asia/Manila); the first query after the 15:00 close fetches that day's final numbers, and everything until the next boundary is served from cache — shared across all users. If the market is open and a symbol was never cached, one fetch happens and the quote serves only previous_close (the last settled price), flagged stale: true with a meta.note saying it is not a realtime value.
  • Everything else (disclosures, profiles, financials, dividends, indices) is fetch-once-then-persist (data_policy: "daily-refresh"): a cache miss may hit PSE Edge at any hour — once, deduplicated across concurrent callers — and repeats of the same query are served from storage until the next close.
  • Every result carries meta.as_of, meta.valid_until, meta.stale, and (when there is a caveat) meta.note, so clients always know exactly how fresh the data is.

Install (Claude Desktop / Claude Code, stdio)

uvx pse-edge-mcp

Claude Desktop config:

{
  "mcpServers": {
    "pse-edge": { "command": "uvx", "args": ["pse-edge-mcp"] }
  }
}

Connecting to a hosted server

A deployment with auth on is a normal OAuth 2.1 protected resource, so a modern MCP client needs only the URL — it discovers everything else and drives the whole flow itself.

{
  "mcpServers": {
    "pse-edge": { "url": "https://your-host.example.com/mcp" }
  }
}

What happens on first connect

Nothing here is manual except the two browser steps in bold.

  1. The client POSTs to /mcp with no token and gets 401 carrying WWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource". That header is the entire bootstrap: it tells the client where to look next.
  2. It fetches that document, learns which authorization server guards this resource, then reads /.well-known/oauth-authorization-server for the endpoints.
  3. It registers itself at /oauth/register (RFC 7591) — no client secret, no operator involvement, no pre-shared credentials. It gets back a client_id.
  4. It opens /oauth/authorize in a browser with a PKCE challenge (S256 required).
  5. The user signs up or signs in. New users land on /signup, give an email, and receive a link; following it enrolls a passkey at /enroll. Returning users hit /login and use the passkey they already have. No password exists anywhere in the system.
  6. The user approves the client on a consent screen naming it.
  7. The browser returns to the client with a single-use code; the client exchanges it at /oauth/token with its PKCE verifier and receives an access token (30 min) and a refresh token (30 days).
  8. The client calls /mcp with Authorization: Bearer … and refreshes silently from then on. The user is not asked again.
client ──POST /mcp──────────────▶ 401 + WWW-Authenticate
       ──GET  /.well-known/… ───▶ metadata
       ──POST /oauth/register ──▶ client_id
       ──GET  /oauth/authorize ─▶ browser: signup/login → passkey → consent
       ◀─────────────────────────  ?code=…
       ──POST /oauth/token ─────▶ access (30m) + refresh (30d)
       ──POST /mcp + Bearer ────▶ tools

Refresh tokens rotate on every use, and replaying a rotated one revokes that whole session family (RFC 9700 §4.14) — a stolen refresh token gets one use before the theft is detected and the session dies.

Headless agents (client_credentials)

For a LangGraph app, the Anthropic Messages API MCP connector, or any agent that cannot open a browser. No redirect, no passkey, no consent screen — a client id and secret.

1. Provision. Two routes, same result:

  • From the web (needs no shell — the practical choice on a NAS): set PSE_ADMIN_EMAILS to your account's email, sign in, and a Machine clients panel appears on /account with create and revoke controls. Access is gated to that allowlist — a normal signup never sees it.
  • From the CLI: pse-edge-admin create-machine-client --name langgraph-app.

Either way client_id and client_secret are shown once. Only the secret's SHA-256 is stored, so it cannot be recovered — only revoked and reissued (from the same account page, or pse-edge-admin revoke-machine-client <client_id>).

2. Mint a token:

curl -s -X POST https://pse.sakayandgo.com/oauth/token \
  -d grant_type=client_credentials \
  -d client_id=$CLIENT_ID -d client_secret=$CLIENT_SECRET \
  -d scope=mcp -d resource=https://pse.sakayandgo.com/mcp
{"access_token": "pse_…", "token_type": "Bearer", "expires_in": 3600, "scope": "mcp"}

HTTP Basic works too (curl -u "$CLIENT_ID:$CLIENT_SECRET"), which is what most SDKs send. No refresh token is issued — the client already holds a long-lived secret and simply re-requests when the hour is up.

3. Use it:

curl -s -X POST https://pse.sakayandgo.com/mcp \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"curl","version":"1.0"}}}'

Revoke with pse-edge-admin revoke-machine-client <client_id>, which kills the secret, every token it minted, and the backing service account in one step.

Registering does not grant this. /oauth/register is open to the internet, so a client that registers itself — even declaring grant_types: ["client_credentials"] and sending a secret — is refused with unauthorized_client. Authorization comes from a client_type column only the admin CLI writes, never from anything a registrant says about itself.

Give each agent its own machine client: quotas are per client, so a runaway job throttles itself, and revoking one does not touch the others.

Building an app on top of this? examples/langgraph_client.py is a working client for the multi-tenant case — your app authenticates as itself with one machine client, your users never see this server. It carries an httpx.Auth that mints and refreshes the 1-hour token (verified: concurrent calls mint once; a stale token recovers on 401), plus the agent instructions worth pasting into a system prompt. Note it needs mcp<2langchain-mcp-adapters does not yet import against the 2.x SDK.

If your client does not do OAuth yet

The operator issues a token directly, and the user pastes it into a header. Same server, no browser:

pse-edge-admin create-user you@example.com
pse-edge-admin issue-token you@example.com --note laptop   # plaintext shown once
curl -X POST https://your-host.example.com/mcp \
  -H "Authorization: Bearer pse_..." \
  -H 'Content-Type: application/json' \
  -H 'Accept: application/json, text/event-stream' \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'

This is also the only route on a LAN-only deployment: passkeys need a secure context, so plain http cannot enroll one.

What a user can see and remove

/account shows everything held about them — email, passkeys, active tokens, hourly usage counts. POST /account/delete erases it immediately and completely, with no approval step. /privacy states what is collected and for how long. Usage counts are deleted after 90 days.

Run with Docker Compose (HTTP + Postgres)

cp .env.example .env   # set POSTGRES_PASSWORD
docker compose up --build

Serves streamable HTTP on :8000, with Postgres 18 as shared cache and archive. A one-shot migrate service applies the Alembic schema before the app starts.

HTTP mode is stateless with plain JSON responses by default. This server is read-only tools over data the freeze policy holds still, and it uses none of the features MCP sessions exist to enable — no notifications, no resource subscriptions, no sampling, no elicitation, no progress — so every request is self-contained. That means any replica can serve any request behind plain round-robin: no sticky routing, no per-session memory, no event store. Without SSE, idle clients hold no connection either, so N users stop meaning N concurrent connections. Combined with all shared state living in Postgres, that is the whole of "any replica, any request".

Use --stateful if you need MCP sessions (resumability or server-initiated messages) and --sse for event-stream framing; they are independent flags. Note --stateful requires clients to complete the initialize handshake and forces sticky routing behind a balancer.

Bearer auth and quotas (opt-in)

Set PSE_AUTH_REQUIRED=1 (needs DATABASE_URL) and every HTTP request must carry Authorization: Bearer <token>. Users arrive either way described in Connecting to a hosted server — self-service through OAuth 2.1 and passkeys, or an operator-issued token. PKCE is mandatory (S256 only) and no password exists anywhere in the system.

Operators get pse-edge-admin delete-user and purge-usage (cron the latter daily), and delete-user uses the same erasure code path as the user's own delete button, so the two cannot drift apart.

Tokens are opaque and stored only as SHA-256 hashes. Revocation (pse-edge-admin revoke-token … / disable-user …) takes effect within the validation cache's TTL — 60 s by default (PSE_TOKEN_CACHE_TTL), which is precisely the revocation-latency budget. Per-user quotas (default 60/min, 2,000/day, overridable per user) are counted in-process and answer HTTP 429 with Retry-After; with N replicas the effective ceiling is up to N× nominal, which is fine for abuse prevention. stdio mode never authenticates — it runs on your own machine.

Postgres is optional. Without DATABASE_URL the server uses an in-memory cache and keeps no archive — the zero-config path for local stdio use, and it needs neither the postgres extra nor a database. With DATABASE_URL set you get two things: replicas share one cache, so the market-boundary freeze still means one upstream fetch per boundary however many processes run; and every read accumulates into an EOD archive (daily bars and disclosures) that deepens over time at zero extra cost to PSE Edge, which serves only limited history itself. Nothing crawls — the archive fills solely from fetches you already made.

# applying the schema by hand, outside compose
DATABASE_URL=postgresql+asyncpg://user:pass@host/db uv run alembic upgrade head

Tools

Tool Description
search_companies(query) Find PSE-listed companies by name or ticker
validate_symbol(symbol) Cheap yes/no check that a ticker exists, with its company name and id
get_stock_quote(symbol) Latest EOD quote: price, change, 52-wk range, market cap, full field set
get_price_history(symbol, start_date?, end_date?) Daily OHLC series from Edge's chart endpoint
search_disclosures(symbol?, start_date?, end_date?, template?, page?) Disclosure metadata, market-wide or per company; 50/page with exact totals
search_disclosure_fulltext(keyword, ...) Search the text inside disclosure attachments, with snippets
get_disclosure(edge_no, max_files?) One disclosure's details plus attachment and body-HTML links; attachments capped at max_files (default 20) with an honest truncation flag. Each attachment carries a resource_uri — read the file's bytes via MCP resources/read (pse-edge://attachment/<file_id>, cached immutably, 10 MB cap)
get_company_profile(symbol) Sector, incorporation, auditor, transfer agent, contacts
get_financial_highlights(symbol) Annual + quarterly balance sheet and income statement
get_dividends_and_rights(symbol) Declared dividends and stock rights, linked to their disclosures
get_indices() PSEi and the 7 sector indices, with signed daily change
get_market_summary() Index levels plus PSE Edge's homepage disclosure feeds
get_server_version() The deployed version of this MCP server itself (matches /health)
send_email(subject, body) Email yourself a note (auth-enabled deployments only)

Beyond tools, the server exposes the attachment resource above, two prompts (market_recap, company_briefing(symbol) — the symbol argument autocompletes from PSE Edge's own lookup), and MCP tool annotations so hosts can auto-approve the read-only tools. It is described for the MCP Registry in server.json.

send_email is the only tool that acts rather than reads. It has no recipient argument: the message always goes to the account that authenticated the session, so it cannot be used as a relay and there is nothing for prompt injection to redirect — which matters because this server returns disclosure text the operator does not control. It appears only on deployments with auth enabled (there is no verified address otherwise), the body is escaped rather than rendered as HTML, and it is capped at 20 messages per user per day.

Disclosure tools return metadata and links only — this server never downloads or parses attachments, so your MCP client can fetch the returned URLs itself if it needs the files. Note that Edge's own full-text index is partial (roughly 2023–2025 at last check), so search_disclosure_fulltext is not a substitute for search_disclosures; it reports this limit in its results.

Financial figures are returned exactly as PSE Edge prints them and are never rescaled — Edge's own units labels are inconsistent between its annual and quarterly sections, so each period reports its currency_units for you to check. Index changes are signed here even though Edge prints them unsigned (it shows direction only as a colour and an arrow).

Everything planned has shipped; see docs/plan.md for what each decision settled.

Container image

Every merge to main publishes an image:

docker pull ghcr.io/phdwight/pse-edge-mcp:latest      # or :<version>, :sha-<sha>
# multi-arch: linux/amd64 and linux/arm64
docker run --rm -p 8000:8000 ghcr.io/phdwight/pse-edge-mcp:latest   # streamable HTTP
docker run --rm -i --entrypoint pse-edge-mcp ghcr.io/phdwight/pse-edge-mcp:latest  # stdio

Both architectures are gated before publishing, on native runners. The rule is necessity, not size: the image must contain exactly the resolved runtime dependency closure and nothing else — no build toolchain, no package manager, no dev dependencies, no bytecode caches, no source tree — plus a secret scan and a smoke test that the server starts and registers its tools. A stray dependency fails the build; a large but genuinely required one does not. Image size is reported for information and never gated.

Production

Two topologies, chosen by how the host is reached. Both pull the published image rather than building, so production runs the artifact CI gated.

A host reachable on ports 80 and 443 — Caddy terminates TLS and renews certificates automatically. It publishes on 8280/8243 to stay clear of a NAS's own web UI, so the router must forward 80 → 8280 and 443 → 8243; certificate authorities always validate on 80/443, so that forwarding is required, not optional:

cp .env.example .env    # PSE_DOMAIN, PSE_ACME_EMAIL, POSTGRES_PASSWORD, ZEPTOMAIL_API_KEY, PSE_IMAGE_TAG
docker compose -f compose.prod.yaml up -d

A NAS or any host behind a home router, in two stages. Stage 1 is LAN-only and needs nothing from Cloudflare:

docker compose -f compose.nas.yaml up -d                                  # http://<nas-ip>:8200
docker compose -f compose.nas.yaml -f compose.tunnel.yaml up -d           # + public hostname

The tunnel overlay starts cloudflared, which dials out — so there is no port forwarding and nothing for CGNAT to break. Set PSE_LAN_BIND=127.0.0.1 alongside it to move the stage 1 LAN port onto loopback; Compose merges ports additively, so an overlay can add a mapping but never remove one.

Both give auth on by default, daily backups, a daily retention purge, and no published database port. Health probes are /health (liveness) and /health/ready (readiness). The app is importable for other servers: uvicorn pse_edge_mcp.asgi:app --workers 4.

See docs/deploy.md for both guides, including the two settings most worth getting right: pin PSE_IMAGE_TAG rather than tracking :latest, and make PSE_PUBLIC_URL the real external https URL, because WebAuthn binds every passkey to the origin it was enrolled under.

Development

uv sync --all-extras
uv run pytest
uv run ruff check .

Tests run entirely against recorded fixtures — CI never touches PSE Edge.

A nightly canary watches for upstream drift. PSE Edge can restyle its HTML at any time, and without this the first person to notice is a user whose tool call just failed. The canary service checks one endpoint per family every night, validates each against the model the tool would build, and emails PSE_OPERATOR_EMAIL only on failure. Run one by hand with pse-edge-canary; it exits non-zero if anything broke.

If PSE Edge is unreachable and cached data exists, tools return that data flagged meta.stale: true rather than an error — the data is real, it is simply past its boundary. EDGE_UNAVAILABLE means unreachable and nothing cached.

New to the codebase? docs/walkthrough.md is the developer and architect walkthrough: the request lifecycle, the freeze policy, the layering, how to add a tool or a whole data domain, and a symptom-to-cause debugging table. Also available as a PDF. For a one-page visual map — classes, protocols, the data path, the config matrix — open docs/reference-card.html in any browser; it is fully self-contained and works offline.

Work lands on develop and reaches main by pull request; main is protected and requires all three CI checks (test, image (amd64), image (arm64)). Bumping version in pyproject.toml makes the next merge cut a GitHub Release with a matching immutable image tag.

License

MIT

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

pse_edge_mcp-0.16.0.tar.gz (887.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

pse_edge_mcp-0.16.0-py3-none-any.whl (140.4 kB view details)

Uploaded Python 3

File details

Details for the file pse_edge_mcp-0.16.0.tar.gz.

File metadata

  • Download URL: pse_edge_mcp-0.16.0.tar.gz
  • Upload date:
  • Size: 887.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pse_edge_mcp-0.16.0.tar.gz
Algorithm Hash digest
SHA256 15159f6f4c929ffd4b56e93c16d883e2399bed5422ed9a6a24afc573923645a5
MD5 4b372dcbffb286aa26a33b54fdbba3f8
BLAKE2b-256 b8a6491f4e94196ea83beb87664225f5692655e9d7beb5a2ae55264dba461350

See more details on using hashes here.

Provenance

The following attestation bundles were made for pse_edge_mcp-0.16.0.tar.gz:

Publisher: release.yml on phdwight/pse-edge-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file pse_edge_mcp-0.16.0-py3-none-any.whl.

File metadata

  • Download URL: pse_edge_mcp-0.16.0-py3-none-any.whl
  • Upload date:
  • Size: 140.4 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for pse_edge_mcp-0.16.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2d1199774c9ef58fadb19da845123406d53cb4e44995c8ae176b50d588b95127
MD5 7959bd3cde0ce01b8b25b0f2da07dd24
BLAKE2b-256 a5aa8cbfe09750e791d8d1ec83e9368d7946c86c7c08e571e4d52316f86e3256

See more details on using hashes here.

Provenance

The following attestation bundles were made for pse_edge_mcp-0.16.0-py3-none-any.whl:

Publisher: release.yml on phdwight/pse-edge-mcp

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page