Server data from the Official MCP Registry
Philippine Stock Exchange data via PSE Edge: EOD quotes, price history, disclosures, financials.
About
Philippine Stock Exchange data via PSE Edge: EOD quotes, price history, disclosures, financials.
Remote endpoints: streamable-http: https://pse.sakayandgo.com/mcp
Security Report
This is a well-engineered MCP server with thoughtful security architecture, including OAuth 2.1, passkey-based authentication, and proper token handling. However, several medium-severity issues exist: the code contains a Python syntax error (except without parens), incomplete/truncated files that prevent full validation, and some areas of input validation and error handling that need hardening. The permissions are appropriate for a financial data server accessing PSE Edge APIs. Supply chain analysis found 5 known vulnerabilities in dependencies (0 critical, 5 high severity). Package verification found 1 issue.
3 files analyzed · 13 issues found
Security scores are indicators to help you make informed decisions, not guarantees. Always review permissions before connecting any MCP server.
Permissions Required
This plugin requests these system permissions. Most are normal for its category.
How to Install & Connect
Available as Local & Remote
This plugin can run on your machine or connect to a hosted endpoint. during install.
Documentation
View on GitHubFrom the project's GitHub README.
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 onlyprevious_close(the last settled price), flaggedstale: truewith ameta.notesaying 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.
- The client
POSTs to/mcpwith no token and gets 401 carryingWWW-Authenticate: Bearer resource_metadata="…/.well-known/oauth-protected-resource". That header is the entire bootstrap: it tells the client where to look next. - It fetches that document, learns which authorization server guards this resource, then
reads
/.well-known/oauth-authorization-serverfor the endpoints. - It registers itself at
/oauth/register(RFC 7591) — no client secret, no operator involvement, no pre-shared credentials. It gets back aclient_id. - It opens
/oauth/authorizein a browser with a PKCE challenge (S256 required). - 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/loginand use the passkey they already have. No password exists anywhere in the system. - The user approves the client on a consent screen naming it.
- The browser returns to the client with a single-use code; the client exchanges it at
/oauth/tokenwith its PKCE verifier and receives an access token (30 min) and a refresh token (30 days). - The client calls
/mcpwithAuthorization: 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_EMAILSto your account's email, sign in, and a Machine clients panel appears on/accountwith 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/registeris open to the internet, so a client that registers itself — even declaringgrant_types: ["client_credentials"]and sending a secret — is refused withunauthorized_client. Authorization comes from aclient_typecolumn 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:
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
MCP Registry identity: mcp-name: io.github.phdwight/pse-edge-mcp
Reviews
No reviews yet
Be the first to review this server!
More Developer Tools MCP Servers
Git
Freeby Modelcontextprotocol · Developer Tools
Read, search, and manipulate Git repositories programmatically
Toleno
Freeby Toleno · Developer Tools
Toleno Network MCP Server — Manage your Toleno mining account with Claude AI using natural language.
mcp-creator-python
Freeby mcp-marketplace · Developer Tools
Create, build, and publish Python MCP servers to PyPI — conversationally.
MarkItDown
Freeby Microsoft · Content & Media
Convert files (PDF, Word, Excel, images, audio) to Markdown for LLM consumption
MCP Marketplace
Freeby mcp-marketplace · Developer Tools
Search and install MCP servers from inside your AI client.
FinAgent
Freeby mcp-marketplace · Finance
Free stock data and market news for any MCP-compatible AI assistant.
