吠陀占星计算服务器

一个基于FastMCP的MCP服务器,提供PyJHora吠陀占星计算功能,支持多种占星工具和方法。

  • by chinmay-sh
  • Python
  • 本地部署
  • 4 星标
  • 更新于
在 GitHub 查看 chinmay-sh/pyjhora-mcp

使用说明

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. pyswisseph publishes source-only distributions on PyPI, so it must be compiled: build-essential + swig on Debian/Ubuntu, build-base + swig on 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 the pyjhora-mcp service.
  • deploy/pyjhora-mcp.Caddyfile — the host install, proxying to 127.0.0.1:8000. This is the reference template; deploy-service.sh writes the same content with DOMAIN and APP_PORT filled 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 -1 disables 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 encode match list omits text/event-stream. Compressing an event stream re-introduces the buffering that flush_interval just 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_dasha answers "which period am I in now" directly.
  • get_dasha_children walks one level at a time, e.g. lords: [4] for Jupiter's Maha Dasha sub-periods, then lords: [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_positions raises — planet_list became a dict in 4.8.x, but the function still calls .index() on it. charts.rasi_chart is used instead.
  • drik.planets_in_stationary passes Ketu's sentinel id to swisseph whenever const._use_true_nodes_for_rahu_ketu is on, which is the default. Motion is classified from daily_planet_speed instead.
  • drik.lunar_month computes its index as (solar_month + 1) % 12, so Phalguna comes back as 0 — outside its own documented 1..12 range, and disagreeing with lunar_month_date on 23 of 252 sampled dates between 1980 and 2000. get_panchanga reads lunar_month_date, which is what PyJHora's own vedic_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.

References