Metadata-Version: 2.4
Name: pse-edge-mcp
Version: 0.16.1
Summary: MCP server exposing Philippine Stock Exchange (PSE Edge) data — quotes, price history, disclosures, and more
Project-URL: Homepage, https://github.com/phdwight/pse-edge-mcp
Project-URL: Issues, https://github.com/phdwight/pse-edge-mcp/issues
Author: Dwight
License-Expression: MIT
License-File: LICENSE
Keywords: finance,mcp,philippine-stock-exchange,pse,stocks
Classifier: Development Status :: 3 - Alpha
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Office/Business :: Financial :: Investment
Requires-Python: >=3.14
Requires-Dist: httpx>=0.28.0
Requires-Dist: mcp>=1.2.0
Requires-Dist: pydantic>=2.10.0
Requires-Dist: selectolax>=0.3.27
Requires-Dist: tenacity>=9.0.0
Provides-Extra: auth
Requires-Dist: webauthn>=3.0.0; extra == 'auth'
Provides-Extra: postgres
Requires-Dist: alembic>=1.14.0; extra == 'postgres'
Requires-Dist: asyncpg>=0.30.0; extra == 'postgres'
Requires-Dist: sqlalchemy[asyncio]>=2.0.36; extra == 'postgres'
Description-Content-Type: text/markdown

# pse-edge-mcp

An MCP server exposing **Philippine Stock Exchange** data from the [PSE Edge portal](https://edge.pse.com.ph/) — 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)

```bash
uvx pse-edge-mcp
```

Claude Desktop config:

```json
{
  "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.

```json
{
  "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 `POST`s 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:**

```bash
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
```

```json
{"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:**

```bash
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<2` —
`langchain-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:

```bash
pse-edge-admin create-user you@example.com
pse-edge-admin issue-token you@example.com --note laptop   # plaintext shown once
```

```bash
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)

```bash
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](#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.

```bash
# 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`](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:

```bash
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:

```bash
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:

```bash
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](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

```bash
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](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](docs/walkthrough.pdf). For a one-page visual map — classes, protocols, the data
path, the config matrix — open [docs/reference-card.html](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

---

<sub>MCP Registry identity: `mcp-name: io.github.phdwight/pse-edge-mcp`</sub>
