diff --git a/mcp_svc/__init__.py b/mcp_svc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_svc/apps.py b/mcp_svc/apps.py new file mode 100644 index 0000000..5f2e358 --- /dev/null +++ b/mcp_svc/apps.py @@ -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 diff --git a/mcp_svc/server.py b/mcp_svc/server.py new file mode 100644 index 0000000..1db171a --- /dev/null +++ b/mcp_svc/server.py @@ -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.' + ), +) diff --git a/mcp_svc/tests/__init__.py b/mcp_svc/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/mcp_svc/tests/test_tools.py b/mcp_svc/tests/test_tools.py new file mode 100644 index 0000000..db692a2 --- /dev/null +++ b/mcp_svc/tests/test_tools.py @@ -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, []) diff --git a/mcp_svc/tools.py b/mcp_svc/tools.py new file mode 100644 index 0000000..22bb708 --- /dev/null +++ b/mcp_svc/tools.py @@ -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 + ] diff --git a/orthocal/asgi.py b/orthocal/asgi.py index 550328e..e5c1238 100644 --- a/orthocal/asgi.py +++ b/orthocal/asgi.py @@ -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) diff --git a/orthocal/settings.py b/orthocal/settings.py index 0e63140..2814f1e 100644 --- a/orthocal/settings.py +++ b/orthocal/settings.py @@ -80,6 +80,7 @@ 'calendarium', 'alexa', 'commemorations', + 'mcp_svc', # This should be last since apps.OrthocalConfig.ready() enables the startup probe. 'orthocal', ] diff --git a/orthocal/sitemaps.py b/orthocal/sitemaps.py index fe18c91..9963c20 100644 --- a/orthocal/sitemaps.py +++ b/orthocal/sitemaps.py @@ -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) diff --git a/orthocal/templates/about.html b/orthocal/templates/about.html index c54ca3c..ed30a2f 100644 --- a/orthocal/templates/about.html +++ b/orthocal/templates/about.html @@ -17,7 +17,7 @@

About

the practice of the Orthodox Church in America (OCA) and the Russian Orthodox Church Outside of Russia (ROCOR). Greek reflects the practice of the Antiochian Archdiocese - and the Greek Orthodox Archdiocese of America (GOARCH); this option is + and the Greek Orthodox Archdiocese (GOA); 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.

diff --git a/orthocal/templates/ai_assistant.html b/orthocal/templates/ai_assistant.html new file mode 100644 index 0000000..68b0096 --- /dev/null +++ b/orthocal/templates/ai_assistant.html @@ -0,0 +1,60 @@ +{% extends "content_base.html" %} + +{% block title %}AI Assistant (MCP Server) for the Orthodox Calendar{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +

AI Assistant

+

Orthocal.info provides a server implementing the + Model Context Protocol (MCP), + 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.

+ +

The server is new and still growing; today it exposes two tools:

+ + + +

Connecting

+ +

The server speaks MCP over Streamable HTTP at + https://orthocal.info/mcp. + No account or API key is needed — Orthocal's data is fully public.

+ +

Claude Desktop

+ +

Open Claude Desktop, go to Settings → Connectors, + click Add custom connector, and paste in the URL above. + No further configuration is needed.

+ +

ChatGPT

+ +

In ChatGPT on the web, go to Settings → Apps → + Advanced settings and turn on Developer mode + (requires a Plus, Pro, Business, Enterprise, or Edu plan). Then go to + Settings → Connectors, click Create, + and enter the URL above.

+ +

Claude Code

+ +
claude mcp add --transport http orthocal https://orthocal.info/mcp
+ +

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.

+{% endblock %} diff --git a/orthocal/templates/base.html b/orthocal/templates/base.html index 4f909c8..dfb359a 100644 --- a/orthocal/templates/base.html +++ b/orthocal/templates/base.html @@ -57,6 +57,7 @@
  • Calendar
  • Alexa
  • API
  • +
  • AI Assistant
  • Feeds
  • About
  • diff --git a/orthocal/templates/llms.txt b/orthocal/templates/llms.txt new file mode 100644 index 0000000..9189bad --- /dev/null +++ b/orthocal/templates/llms.txt @@ -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. diff --git a/orthocal/urls.py b/orthocal/urls.py index 38e647b..3740e95 100644 --- a/orthocal/urls.py +++ b/orthocal/urls.py @@ -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'), diff --git a/orthocal/views.py b/orthocal/views.py index 62bd10b..e766e45 100644 --- a/orthocal/views.py +++ b/orthocal/views.py @@ -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 @@ -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') diff --git a/requirements.txt b/requirements.txt index 80c89ef..528e638 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/server.py b/server.py index 776ef50..29c8b7c 100755 --- a/server.py +++ b/server.py @@ -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, )