Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added mcp_svc/__init__.py
Empty file.
10 changes: 10 additions & 0 deletions mcp_svc/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from django.apps import AppConfig


class MCPSvcConfig(AppConfig):
name = 'mcp_svc'

def ready(self):
# Importing this registers the tool functions onto the shared
# MCPServer instance via their @mcp.tool() decorators.
from . import tools # noqa: F401
10 changes: 10 additions & 0 deletions mcp_svc/server.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
from mcp.server.mcpserver import MCPServer

mcp = MCPServer(
name='orthocal',
instructions=(
'Look up Eastern Orthodox liturgical calendar data: feasts, fasting '
'rules, scripture readings, and lives of the saints for a given day, '
'or search for a saint by name.'
),
)
Empty file added mcp_svc/tests/__init__.py
Empty file.
77 changes: 77 additions & 0 deletions mcp_svc/tests/test_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
from django.test import TestCase

from calendarium.datetools import Tradition

from ..tools import get_day, search_saints


class GetDayTestCase(TestCase):
fixtures = ['calendarium.json', 'commemorations.json']

async def test_normal_date(self):
result = await get_day(2026, 7, 31)

self.assertEqual(result['year'], 2026)
self.assertEqual(result['month'], 7)
self.assertEqual(result['day'], 31)
self.assertIn('readings', result)
self.assertTrue(result['readings'])
self.assertIn('saints', result)
self.assertIn('fast_level_desc', result)

async def test_out_of_range_date_raises(self):
with self.assertRaises(ValueError):
await get_day(2026, 2, 30)

async def test_translation_changes_passage_content(self):
kjv_result = await get_day(2022, 1, 7, translation='kjv')
lxx_result = await get_day(2022, 1, 7, translation='lxx2012-web')

kjv_gospel = kjv_result['readings'][2]
lxx_gospel = lxx_result['readings'][2]

self.assertEqual(kjv_gospel['display'], 'John 1.29-34')
self.assertEqual(lxx_gospel['display'], 'John 1.29-34')
self.assertNotEqual(kjv_gospel['passage'][0]['content'], lxx_gospel['passage'][0]['content'])

async def test_default_translation_is_lxx2012_web(self):
default_result = await get_day(2022, 1, 7)
lxx_result = await get_day(2022, 1, 7, translation='lxx2012-web')

self.assertEqual(
default_result['readings'][2]['passage'][0]['content'],
lxx_result['readings'][2]['passage'][0]['content'],
)


class SearchSaintsTestCase(TestCase):
fixtures = ['calendarium.json', 'commemorations.json']

async def test_finds_matching_saint(self):
results = await search_saints('Seraphim of Sarov')

self.assertTrue(any('Seraphim of Sarov' in r['title'] for r in results))
for r in results:
self.assertIn('month', r)
self.assertIn('day', r)

async def test_full_name_present_and_occasion_independent(self):
results = await search_saints('Seraphim of Sarov')

# Both occasions (repose, relics-uncovering) share one Saint identity
# since the saint-dedup pass, so full_name should be identical across
# both results even though title differs per occasion.
full_names = {r['full_name'] for r in results}
self.assertEqual(full_names, {'St Seraphim of Sarov (1833)'})

async def test_no_match_returns_empty_list(self):
results = await search_saints('Nonexistent Saint Name Xyz')

self.assertEqual(results, [])

async def test_tradition_filtering_excludes_other_traditions_saint(self):
greek_results = await search_saints('Zenia', tradition=Tradition.Greek)
self.assertTrue(greek_results)

slavic_results = await search_saints('Zenia', tradition=Tradition.Slavic)
self.assertEqual(slavic_results, [])
71 changes: 71 additions & 0 deletions mcp_svc/tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
from django.db.models import Q

from calendarium import liturgics
from calendarium.api import DaySchema
from calendarium.datetools import Calendar, Translation, Tradition
from commemorations.models import DayCommemoration

from .server import mcp


@mcp.tool()
async def get_day(
year: int,
month: int,
day: int,
calendar: Calendar = Calendar.Gregorian,
tradition: Tradition = Tradition.Slavic,
translation: Translation = Translation.LXX2012WEB,
) -> dict:
"""Look up feasts, fasting rules, scripture readings, and lives of the
saints for a single day in the Eastern Orthodox liturgical calendar.

calendar selects Gregorian (New) or Julian (Old) reckoning. tradition
selects Slavic (OCA/ROCOR) or Greek (Antiochian/GOARCH) practice.
translation selects the Bible translation for English readings --
lxx2012-web (the default, a modern-English pairing of the Brenton
Septuagint and the World English Bible) or kjv (King James Version);
it has no effect on non-English content.
"""

try:
liturgical_day = liturgics.Day(year, month, day, calendar=calendar, tradition=tradition, translation=translation)
except ValueError as exc:
raise ValueError(f'{year}-{month}-{day} is not a valid date: {exc}')

await liturgical_day.ainitialize()
await liturgical_day.aget_readings(fetch_content=True)
await liturgical_day.aget_abbreviated_readings()

return DaySchema.model_validate(liturgical_day, from_attributes=True).model_dump()


@mcp.tool()
async def search_saints(query: str, tradition: Tradition = Tradition.Slavic) -> list[dict]:
"""Search for a saint or commemoration by name, returning the fixed
month/day each match is commemorated on (not a specific year's civil
date -- the Orthodox calendar's fixed commemorations repeat every year
on the same church-calendar day). Each result includes both the
occasion-specific title (why they're commemorated on this particular
day -- repose, translation of relics, etc.) and, when available, the
saint's full_name -- a plainer, occasion-independent form of their
identity.
"""

commemorations = [
commemoration
async for commemoration in DayCommemoration.objects.filter(
Q(saint__name__icontains=query) | Q(saint__full_name__icontains=query) | Q(title__icontains=query),
tradition__in=(tradition, 'common'),
).select_related('day', 'saint').order_by('day__month', 'day__day')
]

return [
{
'month': commemoration.day.month,
'day': commemoration.day.day,
'title': commemoration.title or commemoration.saint.name,
'full_name': commemoration.saint.full_name if commemoration.saint else None,
}
for commemoration in commemorations
]
19 changes: 18 additions & 1 deletion orthocal/asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,21 @@
from django.conf import settings
from django.core.asgi import get_asgi_application

application = get_asgi_application()
django_application = get_asgi_application()

# mcp_svc.server.mcp imports Django models, so it can only be constructed
# after get_asgi_application() has set up Django.
from mcp_svc.server import mcp

# MCPServer.streamable_http_app() returns a complete Starlette app -- it
# owns its own /mcp route and lifespan (which starts/stops its session
# manager's background task). Django's ASGIHandler doesn't implement the
# lifespan protocol at all, so lifespan scope is only ever handled here.
mcp_application = mcp.streamable_http_app()


async def application(scope, receive, send):
if scope['type'] == 'lifespan' or (scope['type'] == 'http' and scope['path'].startswith('/mcp')):
await mcp_application(scope, receive, send)
else:
await django_application(scope, receive, send)
1 change: 1 addition & 0 deletions orthocal/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@
'calendarium',
'alexa',
'commemorations',
'mcp_svc',
# This should be last since apps.OrthocalConfig.ready() enables the startup probe.
'orthocal',
]
Expand Down
2 changes: 1 addition & 1 deletion orthocal/sitemaps.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ class StaticViewSitemap(sitemaps.Sitemap):
priority = 1.0

def items(self):
return ['index', 'alexa', 'api', 'feeds', 'about', 'api:openapi-view']
return ['index', 'alexa', 'api', 'ai-assistant', 'feeds', 'about', 'api:openapi-view']

def location(self, item):
return reverse(item)
Expand Down
2 changes: 1 addition & 1 deletion orthocal/templates/about.html
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ <h2>About</h2>
the practice of <a href="https://www.oca.org">the Orthodox Church in America (OCA)</a> and
<a href="https://www.synod.com/synod/indexeng.htm#gsc.tab=0">the Russian Orthodox Church Outside of Russia (ROCOR).</a>
<strong>Greek</strong> reflects the practice of <a href="https://antiochian.org">the Antiochian Archdiocese</a>
and the <a href="https://goarch.org">Greek Orthodox Archdiocese of America (GOARCH)</a>; this option is
and the <a href="https://goarch.org">Greek Orthodox Archdiocese (GOA)</a>; this option is
still in beta while its data continues to be verified. Either tradition can
be viewed on the (New) Gregorian or (Old) Julian calendar.
</p>
Expand Down
60 changes: 60 additions & 0 deletions orthocal/templates/ai_assistant.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
{% extends "content_base.html" %}

{% block title %}AI Assistant (MCP Server) for the Orthodox Calendar{% endblock %}

{% block head %}
<meta name="description" content="Orthocal.info provides an MCP server so AI assistants like Claude can look up feasts, fasts, scripture readings, and lives of the saints directly.">
{% endblock %}

{% block content %}
<h2>AI Assistant</h2>
<p>Orthocal.info provides a server implementing the
<a href="https://modelcontextprotocol.io/">Model Context Protocol (MCP)</a>,
an open standard that lets AI assistants call an application's tools
directly instead of guessing at what's on the page. Connect an
MCP-aware assistant (Claude, or any other MCP client) to Orthocal, and
you can just ask it things like “what are today's Orthodox readings?”
or “when is St Seraphim of Sarov commemorated?” and it will look up
the real data rather than making something up.</p>

<p>The server is new and still growing; today it exposes two tools:</p>

<ul>
<li><strong>Look up a day.</strong> Feasts, fasting rules, scripture
readings, and the lives of the saints for any date, in either the
Slavic (OCA/ROCOR) or Greek (Antiochian/GOA) tradition, on the
Gregorian or Julian calendar, with the readings in the modern
LXX2012+WEB pairing by default, or the King James Version on
request.</li>
<li><strong>Search for a saint.</strong> Find which day(s) a saint
is commemorated on by name.</li>
</ul>

<h2>Connecting</h2>

<p>The server speaks MCP over Streamable HTTP at
<a href="https://orthocal.info/mcp">https://orthocal.info/mcp</a>.
No account or API key is needed — Orthocal's data is fully public.</p>

<h3>Claude Desktop</h3>

<p>Open Claude Desktop, go to <strong>Settings &rarr; Connectors</strong>,
click <strong>Add custom connector</strong>, and paste in the URL above.
No further configuration is needed.</p>

<h3>ChatGPT</h3>

<p>In ChatGPT on the web, go to <strong>Settings &rarr; Apps &rarr;
Advanced settings</strong> and turn on <strong>Developer mode</strong>
(requires a Plus, Pro, Business, Enterprise, or Edu plan). Then go to
<strong>Settings &rarr; Connectors</strong>, click <strong>Create</strong>,
and enter the URL above.</p>

<h3>Claude Code</h3>

<pre><code>claude mcp add --transport http orthocal https://orthocal.info/mcp</code></pre>

<p>Other MCP-compatible clients generally have a similar way to add a
remote server by URL — consult your client's documentation for the
exact steps.</p>
{% endblock %}
1 change: 1 addition & 0 deletions orthocal/templates/base.html
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
<li><a href="{% url "calendar-default" %}">Calendar</a></li>
<li><a href="{% url "alexa" %}">Alexa</a></li>
<li><a href="{% url "api" %}">API</a></li>
<li><a href="{% url "ai-assistant" %}">AI Assistant</a></li>
<li><a href="{% url "feeds" %}">Feeds</a></li>
<li><a href="{% url "about" %}">About</a></li>
</ul>
Expand Down
19 changes: 19 additions & 0 deletions orthocal/templates/llms.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Orthocal.info

> Eastern Orthodox liturgical calendar service: daily scripture readings, fasting rules, feasts, and lives of the saints, for both the Slavic (OCA/ROCOR) and Greek (Antiochian/GOARCH) traditions, on the Gregorian or Julian calendar.

## For AI assistants and agents

- [AI Assistant](/ai-assistant/): connect an MCP client to `/mcp` (Streamable HTTP) for two tools -- look up a day's feasts/fasting/readings/saints, and search for a saint by name.
- [MCP Server Card](/.well-known/mcp/server-cards.json): machine-readable server metadata.

## For developers

- [API Documentation](/api/): REST API for calendar days and months, with code examples.
- [Alexa Skill](/alexa/): the Orthodox Daily Alexa skill.

## General

- [Daily Readings](/): today's feasts, fasting rules, scripture readings, and commemorations.
- [Calendar](/calendar/): browse any month.
- [About](/about/): project background, data sources, and licensing.
3 changes: 3 additions & 0 deletions orthocal/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
urlpatterns = [
path('alexa/', views.alexa, name='alexa'),
path('api/', views.api, name='api'),
path('ai-assistant/', views.ai_assistant, name='ai-assistant'),
path('.well-known/mcp/server-cards.json', views.mcp_server_card, name='mcp-server-card'),
path('llms.txt', views.llms_txt, name='llms-txt'),
path('ical/', RedirectView.as_view(permanent=True, pattern_name='feeds')),
path('feeds/', views.feeds, name='feeds'),
path('about/', views.about, name='about'),
Expand Down
33 changes: 32 additions & 1 deletion orthocal/views.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import logging

from django.apps import apps
from django.apps import apps
from django.conf import settings
from django.http import JsonResponse
from django.template.response import TemplateResponse
from django.views import generic
Expand All @@ -25,6 +26,36 @@ async def alexa(request):
async def api(request):
return TemplateResponse(request, 'api.html')

@etag
async def ai_assistant(request):
return TemplateResponse(request, 'ai_assistant.html')

@etag
async def mcp_server_card(request):
"""MCP Server Card discovery metadata (SEP-2127, still a draft proposal as
of 2026-08 -- field names/path may still change before the spec finalizes).
Lets a client learn the server's name/tools/capabilities before opening a
full MCP connection."""

from mcp_svc.server import mcp

return JsonResponse({
'name': 'info.orthocal.mcp',
'title': mcp.name,
'description': mcp.instructions,
'websiteUrl': settings.ORTHOCAL_PUBLIC_URL,
'remotes': [
{
'url': f'{settings.ORTHOCAL_PUBLIC_URL}/mcp',
'transport': 'streamable-http',
},
],
})

@etag
async def llms_txt(request):
return TemplateResponse(request, 'llms.txt', content_type='text/plain')

@etag
async def feeds(request):
return TemplateResponse(request, 'feeds.html')
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ google-cloud-logging==3.16.1
icalendar==7.2.2
jdcal==1.4.1
Jinja2==3.1.6 # Typogrify imports this even though we're not using it
mcp==2.0.0
newrelic==13.3.0
python-dateutil==2.9.0.post0
requests==2.34.2
Expand Down
6 changes: 5 additions & 1 deletion server.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,11 @@ def main():
app='orthocal.asgi:application',
host='0.0.0.0',
port=port,
lifespan='off',
# The composed application in orthocal/asgi.py now includes the MCP
# server, which needs the lifespan protocol to start/stop its
# session manager's background task group; Django's own ASGI app
# still ignores lifespan, so this doesn't affect it.
lifespan='on',
log_level='debug',
reload=reload,
)
Expand Down