From 546a378db1e31d903dafce5d3c9541282093972d Mon Sep 17 00:00:00 2001 From: Abdul Rafey Date: Fri, 14 Aug 2026 00:38:59 +0500 Subject: [PATCH 1/2] feat(mcp): accept a language for course generation get_course_generation_prompt_tool takes an optional BCP 47 language tag and names the language in the prompt, covering titles, lesson text, assessment questions, answer options and feedback. The MCP server has no identity layer, so there is no user to resolve a stored preference from and the calling agent supplies the tag instead. It is validated by falling back, not by erroring: an omitted, misspelled or withdrawn tag resolves to the platform default, so a bad tag never fails a whole generation run. The published field description is the calling agent's only instruction, so it states the fallback explicitly. Replaces "in the user's language", which was undefined on a path that has no user. Co-Authored-By: Claude Opus 5 (1M context) --- .../mcp/prompts/course_generation_prompt.txt | 8 +- sparkth/mcp/prompts/prompt.py | 16 +++- sparkth/mcp/server.py | 11 ++- sparkth/mcp/types.py | 11 ++- tests/mcp/test_course_generation_prompt.py | 96 +++++++++++++++++++ 5 files changed, 136 insertions(+), 6 deletions(-) create mode 100644 tests/mcp/test_course_generation_prompt.py diff --git a/sparkth/mcp/prompts/course_generation_prompt.txt b/sparkth/mcp/prompts/course_generation_prompt.txt index 504246743..0941513b1 100644 --- a/sparkth/mcp/prompts/course_generation_prompt.txt +++ b/sparkth/mcp/prompts/course_generation_prompt.txt @@ -2,6 +2,12 @@ You are a learning design assistant trained in effective course creation. Your goal is to help users create high-quality online courses that are clear, engaging, and instructionally sound. Always write in a natural, conversational tone so the course feels authored by a human. +OUTPUT LANGUAGE +Write the entire course — titles, descriptions, lesson text, assessment questions, answer options, and feedback — in {language_name}. +Do this regardless of the language of the request or of any source material. +Keep proper nouns, code identifiers, and established technical terms that have no accepted {language_name} equivalent in their original form. +Use natural, idiomatic {language_name} appropriate to the target audience — not a literal translation of English phrasing. + Step 1. Gather Audience & Goals Before creating anything, ask concise questions one at a time to understand the target audience: a. What is their background? @@ -19,7 +25,7 @@ Create the course outline and prompt for user's approval. Step 3. Develop Course Content 1. Once the outline is approved, expand it into full course content, titled {course_name} and having description: {course_description}. -2. The course should be self-paced, online, and in the user's language. +2. The course should be self-paced and online. 3. Balance these dimensions: 3a. Concept clarity ↔ Content clarity 3b. Narrow skill ↔ Broad skill diff --git a/sparkth/mcp/prompts/prompt.py b/sparkth/mcp/prompts/prompt.py index 6cde01093..6f171338a 100644 --- a/sparkth/mcp/prompts/prompt.py +++ b/sparkth/mcp/prompts/prompt.py @@ -1,8 +1,20 @@ from pathlib import Path +from sparkth.lib.language import SUPPORTED_LANGUAGES -def get_course_generation_prompt(course_name: str, course_description: str) -> str: + +def get_course_generation_prompt(course_name: str, course_description: str, language: str) -> str: + """Render the course-generation prompt for a course written in ``language``. + + ``language`` is an already-resolved, allowlisted BCP 47 tag — the caller resolves the + agent-supplied value with :func:`sparkth.lib.language.resolve_language` first. The + tag's English name reaches the model, matching the chat system prompt. + """ prompt_path = Path(__file__).parent / "course_generation_prompt.txt" template = prompt_path.read_text(encoding="utf-8") - return template.format(course_name=course_name, course_description=course_description) + return template.format( + course_name=course_name, + course_description=course_description, + language_name=SUPPORTED_LANGUAGES[language].name, + ) diff --git a/sparkth/mcp/server.py b/sparkth/mcp/server.py index d4088102b..2efcc451e 100644 --- a/sparkth/mcp/server.py +++ b/sparkth/mcp/server.py @@ -4,6 +4,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from sparkth.lib.audit import audited_tool +from sparkth.lib.language import resolve_language from sparkth.lib.log import get_logger from sparkth.lib.mcp.hooks import MCP_TOOLS, Tool from sparkth.mcp.audit import ToolCallAuditMiddleware @@ -37,9 +38,15 @@ async def get_course_generation_prompt_tool( """ Generates a prompt for creating a course. Figure out the course name and description from the context and information. - Seek clarification whenever user responses are unclear or incomplete + Seek clarification whenever user responses are unclear or incomplete. + Pass `language` when the course must be written in a specific language; omit it to + use the platform default. """ - return get_course_generation_prompt(course_params.course_name, course_params.course_description) + return get_course_generation_prompt( + course_params.course_name, + course_params.course_description, + resolve_language(course_params.language), + ) class MCPToolDefinition(BaseModel): diff --git a/sparkth/mcp/types.py b/sparkth/mcp/types.py index 696cc0f08..2553fd6c7 100644 --- a/sparkth/mcp/types.py +++ b/sparkth/mcp/types.py @@ -1,6 +1,15 @@ -from pydantic import BaseModel +from pydantic import BaseModel, Field class CourseGenerationPromptRequest(BaseModel): course_name: str course_description: str + language: str | None = Field( + default=None, + description=( + "BCP 47 language tag for the generated course, e.g. 'en', 'es', 'fr'. " + "The whole course — titles, descriptions, lesson text, assessment questions, " + "answer options and feedback — is written in it. Omit it, or pass a tag the " + "platform does not support, and the platform default language is used." + ), + ) diff --git a/tests/mcp/test_course_generation_prompt.py b/tests/mcp/test_course_generation_prompt.py new file mode 100644 index 000000000..701694f1d --- /dev/null +++ b/tests/mcp/test_course_generation_prompt.py @@ -0,0 +1,96 @@ +"""The language handling of the MCP course-generation prompt and its tool. + +The MCP server has no identity layer, so the language cannot be resolved from a user — +the calling agent supplies it, and anything unusable falls back to the platform default. +""" + +import pytest +from fastmcp import Client + +from sparkth.lib.language import SUPPORTED_LANGUAGES +from sparkth.lib.settings import get_settings +from sparkth.mcp.prompts.prompt import get_course_generation_prompt +from sparkth.mcp.server import mcp +from sparkth.mcp.types import CourseGenerationPromptRequest + + +class TestCourseGenerationPromptLanguage: + @pytest.mark.parametrize("tag", sorted(SUPPORTED_LANGUAGES)) + def test_names_the_language_for_every_supported_tag(self, tag: str) -> None: + prompt = get_course_generation_prompt("Data Privacy", "An intro course", tag) + assert SUPPORTED_LANGUAGES[tag].name in prompt + + @pytest.mark.parametrize("tag", sorted(SUPPORTED_LANGUAGES)) + def test_no_unrendered_placeholder_remains(self, tag: str) -> None: + prompt = get_course_generation_prompt("Data Privacy", "An intro course", tag) + assert "{language_name}" not in prompt + + def test_ambiguous_language_instruction_is_gone(self) -> None: + """ "in the user's language" is undefined on a path that has no user at all. + + Asserts on the exact deleted phrase, so rewording the new directive cannot + cause a false failure.""" + prompt = get_course_generation_prompt("Data Privacy", "An intro course", "es") + assert "in the user's language" not in prompt + + def test_course_name_and_description_still_render(self) -> None: + prompt = get_course_generation_prompt("Data Privacy", "An intro course", "en") + assert "Data Privacy" in prompt + assert "An intro course" in prompt + + +class TestCourseGenerationPromptRequest: + def test_language_is_optional(self) -> None: + request = CourseGenerationPromptRequest( + course_name="Data Privacy", + course_description="An intro course", + ) + assert request.language is None + + def test_language_is_accepted(self) -> None: + request = CourseGenerationPromptRequest( + course_name="Data Privacy", + course_description="An intro course", + language="es", + ) + assert request.language == "es" + + +class TestCourseGenerationPromptTool: + """End-to-end through the FastMCP in-memory client, the way an agent calls it.""" + + @staticmethod + async def _call(course_params: dict[str, str]) -> str: + async with Client(mcp) as client: + result = await client.call_tool( + "get_course_generation_prompt_tool", + {"course_params": course_params}, + ) + # The tool is annotated `-> str`, so FastMCP puts the string on `.data` + # (verified against this repo's fastmcp version). + return str(result.data) + + async def test_explicit_language_is_honoured(self) -> None: + prompt = await self._call({"course_name": "Privacidad", "course_description": "Un curso", "language": "es"}) + assert SUPPORTED_LANGUAGES["es"].name in prompt + + async def test_omitted_language_falls_back_to_the_platform_default(self) -> None: + prompt = await self._call({"course_name": "Privacy", "course_description": "A course"}) + assert SUPPORTED_LANGUAGES[get_settings().DEFAULT_LANGUAGE].name in prompt + + async def test_unsupported_language_falls_back_rather_than_erroring(self) -> None: + """A misspelled or withdrawn tag must not fail a whole generation run.""" + prompt = await self._call({"course_name": "Privacy", "course_description": "A course", "language": "klingon"}) + assert SUPPORTED_LANGUAGES[get_settings().DEFAULT_LANGUAGE].name in prompt + + async def test_the_tool_schema_publishes_the_language_field(self) -> None: + """FastMCP inlines the request model into the tool's input schema, and the + published description is the calling agent's only instruction about the + parameter — so assert it is there, and that it is optional.""" + async with Client(mcp) as client: + tools = await client.list_tools() + tool = next(t for t in tools if t.name == "get_course_generation_prompt_tool") + course_params = tool.inputSchema["properties"]["course_params"] + + assert "BCP 47" in course_params["properties"]["language"]["description"] + assert "language" not in course_params["required"] From 1ab8a92adca40e0680cbf6f026791a375e8f28d7 Mon Sep 17 00:00:00 2001 From: Abdul Rafey Date: Fri, 14 Aug 2026 00:57:00 +0500 Subject: [PATCH 2/2] test(mcp): add negative controls to the language fallback tests The fallback tests asserted only that the platform default's name appeared in the prompt. The template's OUTPUT LANGUAGE section also carries the brief-mandated, unconditional literal "...not a literal translation of English phrasing", so that positive assertion alone could not tell the true default apart from the tool wrongly resolving to some other valid tag. Add a negative control to both the omitted- and unsupported-language tests: assert that no OTHER supported language's name appears, derived from get_settings() and SUPPORTED_LANGUAGES so it stays correct if either changes. Also pin the schema test's fallback-promise sentence, not just its "BCP 47" prefix, since that sentence is the calling agent's only instruction that a bad tag is safe rather than an error. Co-Authored-By: Claude Opus 5 (1M context) --- tests/mcp/test_course_generation_prompt.py | 34 ++++++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/tests/mcp/test_course_generation_prompt.py b/tests/mcp/test_course_generation_prompt.py index 701694f1d..f6e9c05b9 100644 --- a/tests/mcp/test_course_generation_prompt.py +++ b/tests/mcp/test_course_generation_prompt.py @@ -14,6 +14,18 @@ from sparkth.mcp.types import CourseGenerationPromptRequest +def _other_supported_language_names(excluding_tag: str) -> set[str]: + """Names of every supported language other than ``excluding_tag``. + + Backs a negative control for the fallback tests: the template's OUTPUT LANGUAGE + section also carries the brief-mandated, unconditional literal "...not a literal + translation of English phrasing", so asserting only that the expected language's + name is present cannot tell the true default apart from some other valid tag + landing here by mistake. Asserting that none of these names appear catches that. + """ + return {info.name for tag, info in SUPPORTED_LANGUAGES.items() if tag != excluding_tag} + + class TestCourseGenerationPromptLanguage: @pytest.mark.parametrize("tag", sorted(SUPPORTED_LANGUAGES)) def test_names_the_language_for_every_supported_tag(self, tag: str) -> None: @@ -76,12 +88,23 @@ async def test_explicit_language_is_honoured(self) -> None: async def test_omitted_language_falls_back_to_the_platform_default(self) -> None: prompt = await self._call({"course_name": "Privacy", "course_description": "A course"}) - assert SUPPORTED_LANGUAGES[get_settings().DEFAULT_LANGUAGE].name in prompt + default_tag = get_settings().DEFAULT_LANGUAGE + assert SUPPORTED_LANGUAGES[default_tag].name in prompt + + # Negative control: see _other_supported_language_names for why the + # positive assertion alone cannot tell the true default from a + # valid-but-wrong resolution. + assert not any(name in prompt for name in _other_supported_language_names(default_tag)) async def test_unsupported_language_falls_back_rather_than_erroring(self) -> None: """A misspelled or withdrawn tag must not fail a whole generation run.""" prompt = await self._call({"course_name": "Privacy", "course_description": "A course", "language": "klingon"}) - assert SUPPORTED_LANGUAGES[get_settings().DEFAULT_LANGUAGE].name in prompt + default_tag = get_settings().DEFAULT_LANGUAGE + assert SUPPORTED_LANGUAGES[default_tag].name in prompt + + # Same discrimination gap as above: assert no OTHER supported language's + # name is present, so a valid-but-wrong fallback resolution is caught. + assert not any(name in prompt for name in _other_supported_language_names(default_tag)) async def test_the_tool_schema_publishes_the_language_field(self) -> None: """FastMCP inlines the request model into the tool's input schema, and the @@ -91,6 +114,11 @@ async def test_the_tool_schema_publishes_the_language_field(self) -> None: tools = await client.list_tools() tool = next(t for t in tools if t.name == "get_course_generation_prompt_tool") course_params = tool.inputSchema["properties"]["course_params"] + description = course_params["properties"]["language"]["description"] - assert "BCP 47" in course_params["properties"]["language"]["description"] + assert "BCP 47" in description + # Pin the fallback promise itself, not just the "BCP 47" format prefix: + # this sentence is the calling agent's only instruction that a bad or + # omitted tag is safe rather than an error. + assert "the platform default language is used" in description assert "language" not in course_params["required"]