使用说明
PyJHora MCP Server
An MCP server exposing PyJHora Vedic astrology calculations as typed LLM tools, built on FastMCP.
Pinned to PyJHora 4.8.7 with the Swiss Ephemeris.
Tools
| Category | Tools |
|---|---|
| Panchanga | get_panchanga, get_sunrise_sunset, get_rahu_kala, get_muhurtha, get_planet_positions, get_retrograde_planets, get_vedic_date, get_festivals |
| Horoscope | get_rasi_chart, get_divisional_chart, get_special_lagnas, get_ashtakavarga, get_bhava_chart |
| Dasha | get_vimsottari_dasha, get_yogini_dasha, get_ashtottari_dasha, get_narayana_dasha, get_running_dasha, get_dasha_children |
| Compatibility | get_compatibility |
| Yoga & Dosha | get_yogas, get_doshas, get_raja_yogas |
| Strength | get_shadbala, get_bhava_bala, get_vimsopaka_bala |
Every tool takes an explicit place (latitude, longitude, UTC offset) and local date/time — nothing
is geocoded, and no network calls are made during a request. Ayanamsa defaults to LAHIRI.
Prerequisites
- Python 3.12+
- A C toolchain.
pyswissephpublishes source-only distributions on PyPI, so it must be compiled:build-essential+swigon Debian/Ubuntu,build-base+swigon Alpine, or Visual Studio Build Tools on Windows. Docker avoids this entirely. - Ephemeris data files — see below.
Ephemeris data (required)
The PyJHora wheel does not include the ~105MB of .se1 Swiss Ephemeris files. Without them every
calculation fails, so setup-ephe.sh fetches just that directory at the tag matching the pinned
PyJHora version:
./setup-ephe.sh
The Dockerfile and deploy-service.sh both do this for you. The server checks for the files at
startup and fails with an actionable message rather than letting each tool fail separately.
Quick start
cp .env.example .env && docker compose up -d
Caddy terminates TLS and is the only service published to the host; the app listens on the
internal network only. With DOMAIN unset it serves https://localhost using Caddy's internal
CA, so this works offline with no DNS. Set DOMAIN to a hostname pointing at the machine and
Caddy provisions a Let's Encrypt certificate on its own. More in Docker.
Or without Docker, which needs the C toolchain above:
uv venv
uv pip install -e ".[dev]"
./setup-ephe.sh
uv run pyjhora-mcp
Running the server
fastmcp run src/pyjhora_mcp/server.py
That serves stdio, for Claude Desktop and similar. For HTTP:
fastmcp run src/pyjhora_mcp/server.py --transport http --port 8000
pyjhora-mcp (the installed script) runs HTTP directly and honours PORT.
Docker
The image compiles pyswisseph and downloads the ephemeris itself, so nothing is needed on the
host beyond Docker. The build fails fast if the ephemeris does not land correctly.
With Compose (recommended)
Runs the app behind Caddy, which terminates TLS and is the only thing published to the host.
cp .env.example .env
docker compose up -d --build
docker compose logs -f
docker compose down
down -v also drops the named volumes, which discards issued TLS certificates — avoid it on a
live host or you risk hitting Let's Encrypt rate limits on the next start. To rebuild just the app
after a source change:
docker compose up -d --build pyjhora-mcp
Caddy briefly returns 502 while the app restarts and recovers on its own; give it a few seconds before concluding anything is wrong.
To poke at the tools in a browser, start the MCP Inspector:
docker compose --profile debug up -d mcp-inspector
It prints a URL carrying a one-time token — open that rather than bare localhost:5173, which
the Inspector will reject:
docker compose logs mcp-inspector | grep MCP_INSPECTOR_API_TOKEN
The port is published to 127.0.0.1 only, so it is reachable from this machine and not from the
network. That matters: the Inspector's backend can spawn local processes.
From a published image
docker-compose.hub.yml is the same stack but pulls redlord/pyjhora-mcp instead of building
locally — no toolchain, no ephemeris download, just a pull. It needs .env too:
cp .env.example .env && docker compose -f docker-compose.hub.yml up -d
It tracks :latest, so docker compose -f docker-compose.hub.yml pull picks up new releases
without editing anything. Pin the version tag instead (redlord/pyjhora-mcp:4.8.7) if you would
rather upgrade deliberately. It omits the Inspector service; use the main docker-compose.yml
for that.
Published images carry both tags: :latest and the PyJHora version they are built against.
Plain Docker
Build once:
docker build -t pyjhora-mcp:latest .
Run it over HTTP on port 8000. API_KEY makes the server require
Authorization: Bearer <token> — without it the port is open to anyone who can reach it, so only
omit it when the port is bound to loopback:
docker run --rm -p 8000:8000 -e API_KEY=your-secret-token pyjhora-mcp:latest
For stdio instead — what Claude Desktop and other local clients expect — override the command, since the image defaults to HTTP:
docker run --rm -i pyjhora-mcp:latest fastmcp run src/pyjhora_mcp/server.py --transport stdio
Run the test suite in the image, mounting the working tree over it. The image carries only runtime dependencies, so the test tooling is added in the container:
docker run --rm -v "$PWD:/app" -w /app pyjhora-mcp:latest sh -c "pip install -q '.[dev]' && python -m pytest tests/ -q"
A quick health probe — an unauthenticated request should be rejected, which is what the Compose healthcheck asserts:
curl -i -X POST http://localhost:8000/mcp -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{"jsonrpc":"2.0","id":1,"method":"tools/list"}'
Configuration
Copy .env.example to .env and edit it — it documents every variable the server and the deploy
script read.
cp .env.example .env
Set API_KEY and the server requires Authorization: Bearer <token> on every request. Leave it
unset and the server is unauthenticated — fine for local stdio, but an exposed HTTP port without
it is open to anyone who can reach it. .env is gitignored; .env.example is not.
Claude Desktop
{
"mcpServers": {
"pyjhora": {
"command": "uv",
"args": ["run", "--project", "/path/to/pyjhora-mcp", "fastmcp", "run", "src/pyjhora_mcp/server.py"]
}
}
}
Deploying as a service
deploy-service.sh installs the app, a service unit, and Caddy as a reverse proxy. It detects the
platform and supports both systemd + apt (Debian/Ubuntu) and OpenRC + apk (Alpine):
sudo ./deploy-service.sh install
sudo ./deploy-service.sh status
Also accepts uninstall, reinstall, start, stop, restart and logs. Override the domain
and port with DOMAIN= and APP_PORT=. The installer writes /opt/pyjhora-mcp/.env; set
API_KEY there and restart. Uninstall preserves that file unless you pass PURGE_ENV=1.
Reverse proxy
Two Caddy configs, kept in step with each other:
deploy/Caddyfile— the Compose stack, proxying to thepyjhora-mcpservice.deploy/pyjhora-mcp.Caddyfile— the host install, proxying to127.0.0.1:8000. This is the reference template;deploy-service.shwrites the same content withDOMAINandAPP_PORTfilled in.
Both matter for correctness, not just convenience. MCP replies on /mcp with
Content-Type: text/event-stream, so the proxy has to leave the stream alone:
flush_interval -1disables response buffering. Without it, events are held back and a call appears to hang.read_timeout 0/write_timeout 0— an MCP session's stream stays open for its lifetime, so any deadline eventually truncates it mid-session.- The
encodematch list omitstext/event-stream. Compressing an event stream re-introduces the buffering thatflush_intervaljust removed.
Authorization and Mcp-Session-Id pass through untouched.
If you put a different proxy in front (nginx, Traefik, a cloud load balancer), it needs the same
three properties — nginx in particular buffers by default and needs proxy_buffering off;.
Tests
uv run pytest tests/ -v
213 integration tests, run against real PyJHora — no mocks. Values are asserted against the reference chart PyJHora uses in its own suite (7 December 1996, 10:34, Chennai), so the assertions check agreement with the library's published output rather than merely with itself. Every tool is also exercised on a worker thread, since that is where FastMCP actually runs them and pytest alone would not.
Without a local C toolchain, run them in the image — see Docker.
Example call
{
"tool": "get_rasi_chart",
"arguments": {
"birth_data": {
"place": {"name": "Chennai,IN", "latitude": 13.0878, "longitude": 80.2785, "timezone": 5.5},
"date_time": {"year": 1996, "month": 12, "day": 7, "hour": 10, "minute": 34, "second": 0},
"ayanamsa": "LAHIRI"
}
}
}
Working with dashas
Dasha trees grow ninefold per level — depth 3 is 729 periods, depth 4 over six thousand. Rather than requesting a deep tree:
get_running_dashaanswers "which period am I in now" directly.get_dasha_childrenwalks one level at a time, e.g.lords: [4]for Jupiter's Maha Dasha sub-periods, thenlords: [4, 1]for Jupiter/Moon.
Ayanamsa modes
The ayanamsa field accepts any mode PyJHora supports — the enum is generated from
const.available_ayanamsa_modes at import, so it cannot drift out of step with the library. The
common ones:
| Mode | Notes |
|---|---|
LAHIRI |
The Indian government standard, and the default here |
TRUE_CITRA |
Chitrapaksha; matches Jagannatha Hora's default |
KP |
Krishnamurti Paddhati |
RAMAN |
BV Raman's ayanamsa |
Layout
src/pyjhora_mcp/
├── server.py FastMCP entry point, auth, tool mounting
├── core.py Input models, PyJHora type conversion, name lookups, formatting
└── tools/ One module per category, each a mounted FastMCP sub-server
tests/ Integration tests, one file per tool module
Notes on PyJHora 4.8.7
core.initialise() calls swe.set_ephe_path for the calling thread, not just once at import.
pyswisseph holds Swiss Ephemeris state per-thread in this build, and FastMCP runs synchronous
tools on a worker thread — an unbound thread computes from no ephemeris, which sends
drik.samvatsara's backwards sankranti scan looping into 4800 BC rather than failing cleanly.
Further library issues worked around in the tool layer, all documented at the call site:
drik.planetary_positionsraises —planet_listbecame a dict in 4.8.x, but the function still calls.index()on it.charts.rasi_chartis used instead.drik.planets_in_stationarypasses Ketu's sentinel id to swisseph wheneverconst._use_true_nodes_for_rahu_ketuis on, which is the default. Motion is classified fromdaily_planet_speedinstead.drik.lunar_monthcomputes its index as(solar_month + 1) % 12, so Phalguna comes back as 0 — outside its own documented 1..12 range, and disagreeing withlunar_month_dateon 23 of 252 sampled dates between 1980 and 2000.get_panchangareadslunar_month_date, which is what PyJHora's ownvedic_date()uses, so our two tools agree.- A divisor outside 1–60 and an unknown bhava method are both accepted silently, returning D1 and method 1 respectively. Both are validated at the tool boundary so a typo errors instead of quietly returning the wrong chart.
Known limitation: polar latitudes
Above roughly 66.5° N/S, the chart and panchanga tools fail. At Tromsø (69.6°N) in midsummer
drik.sunrise returns nonsense (-58841195.0, '11:00:00 (-2451717)') which then crashes
PyJHora's own DMS parser, and drik.ascendant raises swisseph.houses_ex: error. Sunrise and
sunset do not occur on those days, so there is nothing sensible to compute; it surfaces as a
raised MCP error rather than a wrong answer. Everything from the equator to ~66° is unaffected.
Ayanamsa applies per call
Every sidereal tool sets the ayanamsa on the thread that runs it. That matters because
pyswisseph keeps the sidereal mode per-thread and FastMCP dispatches tools to a thread pool — a
tool that skipped it would inherit whatever the previous request on that worker had set, or
swisseph's Fagan/Bradley default, and return different answers for identical input.
get_sunrise_sunset, get_rahu_kala and get_muhurtha deliberately take no ayanamsa
parameter: they are purely geometric, and a test asserts their output is identical across modes.
Separately, islam and hijridate are declared here because PyJHora needs them but does not
declare them itself. They are not incidental: PyJHora's festival database is a multi-faith Indian
calendar, and of its 355 entries 18 fall on the Hijri calendar (Eid al-Fitr, Eid al-Adha, Ramadan,
Muharram) and 72 on the Gregorian one (Ambedkar Jayanti, Christmas, civic holidays) alongside the
265 Hindu ones — the same mix a printed Indian panchang carries. jhora.panchanga.hijri imports
pyIslam at module scope, so get_festivals cannot load without it.
Without these two packages, 24 of the 26 tools still work: only get_festivals and the
HIJRI_ISLAMIC option of get_vedic_date fail.