Files
turnstone/tests/test_mcp_prompt_sync.py
T
Patrick Buckley 2f7f70825b feat: wire prompt templates into session startup with full creation-p… (#47)
* feat: wire prompt templates into session startup with full creation-path support

Prompt templates (prompt_templates table) now have runtime effect:

- is_default=true templates auto-apply as system message content,
  concatenated in name order before user instructions
- Per-workstream template selection via --template CLI flag, template
  field on POST /v1/api/workstreams/new, console creation modal dropdown,
  scheduled task config, and channel adapter config
- {{model}}, {{ws_id}}, {{node_id}} variable substitution via single-pass
  regex (prevents cross-variable injection)
- /template slash command for runtime switching, persisted across resume
- set_template() public API on ChatSession

Security hardening:
- MCP sync resets is_default=False on content update (prevents compromised
  server from injecting defaults)
- 32KB content cap on template create/update + defensive truncation
- Template existence validation returns 400 before workstream creation
- Single-pass regex eliminates cross-variable expansion

Template field plumbed through all creation paths: CLI, server API, MQ
protocol/bridge, console backend, scheduler dispatch, channel router,
MQ client. Migration 010 adds template column to scheduled_tasks.

Frontend: console workstream modal template dropdown, scheduler
create/edit template field, governance template UI variables auto-detected
from content (read-only display replaces editable input). Focus trap and
Enter-key accessibility fixes in workstream modal.

Docs: governance.md template runtime section, api-reference.md template
field, governance + MCP architecture diagrams updated.

Python + TypeScript SDKs, Pydantic schemas all updated. 29 new tests.

* fix: address PR #47 review feedback

- Defer template validation until after resume_ws — a bad template name
  no longer 400s when resume would have ignored it anyway
- Add template field to OpenAPI JSON specs (openapi-server.json,
  openapi-console.json) for SDK/docs consistency
- Validate template existence in schedule create and update endpoints —
  reject unknown template names with 400 instead of allowing schedules
  that would silently fail at dispatch time
2026-03-12 16:57:31 -07:00

273 lines
9.3 KiB
Python

"""Tests for MCP prompt → governance template sync and readonly API guards."""
from __future__ import annotations
from unittest.mock import MagicMock
import pytest
from turnstone.core.mcp_client import MCPClientManager
@pytest.fixture()
def mgr() -> MCPClientManager:
"""Create an MCPClientManager with no real servers (no start())."""
return MCPClientManager({})
def _make_storage() -> MagicMock:
"""Create a mock storage backend with prompt template methods."""
storage = MagicMock()
storage.get_prompt_template_by_name.return_value = None
storage.list_prompt_templates_by_origin.return_value = []
storage.create_prompt_template.return_value = None
storage.update_prompt_template.return_value = True
storage.delete_prompt_template.return_value = True
return storage
class TestSyncPromptsToStorage:
def test_sync_no_storage(self, mgr: MCPClientManager) -> None:
"""Without storage set, sync returns empty stats."""
result = mgr.sync_prompts_to_storage()
assert result == {"added": [], "removed": [], "skipped": []}
def test_sync_creates_mcp_templates(self, mgr: MCPClientManager) -> None:
"""New MCP prompts are created as templates."""
storage = _make_storage()
mgr.set_storage(storage)
# Populate internal prompts list directly
mgr._prompts = [
{
"name": "mcp__test__greeting",
"original_name": "greeting",
"server": "test",
"description": "Say hello",
"arguments": [
{"name": "name", "description": "Who to greet", "required": True},
],
},
]
result = mgr.sync_prompts_to_storage()
assert result["added"] == ["mcp__test__greeting"]
assert result["removed"] == []
assert result["skipped"] == []
storage.create_prompt_template.assert_called_once()
call_kwargs = storage.create_prompt_template.call_args
assert call_kwargs[1]["name"] == "mcp__test__greeting"
assert call_kwargs[1]["origin"] == "mcp"
assert call_kwargs[1]["mcp_server"] == "test"
assert call_kwargs[1]["readonly"] is True
assert call_kwargs[1]["category"] == "mcp"
assert '"name"' in call_kwargs[1]["variables"]
def test_sync_skips_manual_overrides(self, mgr: MCPClientManager) -> None:
"""A manual template with the same name is not overwritten."""
storage = _make_storage()
storage.get_prompt_template_by_name.return_value = {
"template_id": "existing-id",
"name": "mcp__test__greeting",
"origin": "manual",
"readonly": False,
}
mgr.set_storage(storage)
mgr._prompts = [
{
"name": "mcp__test__greeting",
"original_name": "greeting",
"server": "test",
"description": "Say hello",
"arguments": [],
},
]
result = mgr.sync_prompts_to_storage()
assert result["skipped"] == ["mcp__test__greeting"]
assert result["added"] == []
storage.create_prompt_template.assert_not_called()
storage.update_prompt_template.assert_not_called()
def test_sync_updates_existing_mcp_template(self, mgr: MCPClientManager) -> None:
"""An existing MCP template gets its content/variables updated."""
storage = _make_storage()
storage.get_prompt_template_by_name.return_value = {
"template_id": "existing-id",
"name": "mcp__test__greeting",
"origin": "mcp",
"mcp_server": "test",
"readonly": True,
}
mgr.set_storage(storage)
mgr._prompts = [
{
"name": "mcp__test__greeting",
"original_name": "greeting",
"server": "test",
"description": "Updated description",
"arguments": [
{"name": "user", "description": "The user", "required": False},
],
},
]
result = mgr.sync_prompts_to_storage()
assert result["added"] == []
assert result["skipped"] == []
storage.create_prompt_template.assert_not_called()
storage.update_prompt_template.assert_called_once()
call_args = storage.update_prompt_template.call_args
assert call_args[0][0] == "existing-id"
assert "Updated description" in call_args[1]["content"]
assert "user" in call_args[1]["variables"]
# Security: is_default must be reset to prevent compromised MCP server
# from injecting content into a previously admin-promoted default
assert call_args[1]["is_default"] is False
def test_sync_resets_is_default_on_promoted_template(self, mgr: MCPClientManager) -> None:
"""An MCP template promoted to default by admin gets is_default reset on sync."""
storage = _make_storage()
storage.get_prompt_template_by_name.return_value = {
"template_id": "promoted-id",
"name": "mcp__test__greeting",
"origin": "mcp",
"mcp_server": "test",
"readonly": True,
"is_default": True, # admin toggled this
}
mgr.set_storage(storage)
mgr._prompts = [
{
"name": "mcp__test__greeting",
"original_name": "greeting",
"server": "test",
"description": "Potentially compromised content",
"arguments": [],
},
]
mgr.sync_prompts_to_storage()
call_args = storage.update_prompt_template.call_args
assert call_args[1]["is_default"] is False
def test_sync_removes_deleted_prompts(self, mgr: MCPClientManager) -> None:
"""MCP templates in storage with no matching prompt are deleted."""
storage = _make_storage()
storage.list_prompt_templates_by_origin.return_value = [
{
"template_id": "old-id",
"name": "mcp__test__old_prompt",
"origin": "mcp",
"mcp_server": "test",
},
]
mgr.set_storage(storage)
mgr._prompts = [] # No prompts at all
result = mgr.sync_prompts_to_storage()
assert result["removed"] == ["mcp__test__old_prompt"]
storage.delete_prompt_template.assert_called_once_with("old-id")
class TestSetStorageAutoSync:
"""set_storage() triggers an immediate sync when servers are already connected."""
def test_set_storage_syncs_when_connected(self, mgr) -> None:
storage = _make_storage()
mgr._prompts = [
{
"name": "mcp__srv__p1",
"original_name": "p1",
"server": "srv",
"description": "A prompt",
"arguments": [],
}
]
mgr._connected.set()
mgr.set_storage(storage)
# Should have called create_prompt_template for the discovered prompt
storage.create_prompt_template.assert_called_once()
call_kwargs = storage.create_prompt_template.call_args
assert call_kwargs[1]["name"] == "mcp__srv__p1"
assert call_kwargs[1]["origin"] == "mcp"
def test_set_storage_no_sync_when_not_connected(self, mgr) -> None:
storage = _make_storage()
mgr._prompts = [
{
"name": "mcp__srv__p1",
"original_name": "p1",
"server": "srv",
"description": "A prompt",
"arguments": [],
}
]
# _connected is NOT set
mgr.set_storage(storage)
# Should not have synced
storage.create_prompt_template.assert_not_called()
class TestReadonlyAPIGuards:
"""Test that the console server API guards reject edits to readonly templates."""
@pytest.fixture()
def db(self, tmp_path):
"""Create a fresh SQLite backend for each test."""
from turnstone.core.storage._sqlite import SQLiteBackend
return SQLiteBackend(str(tmp_path / "test.db"))
def test_readonly_guard_update(self, db) -> None:
"""Readonly templates cannot be updated via storage guard logic."""
db.create_prompt_template(
"t1",
"mcp__srv__prompt",
"mcp",
"content",
variables="[]",
is_default=False,
org_id="",
created_by="",
origin="mcp",
mcp_server="srv",
readonly=True,
)
tpl = db.get_prompt_template("t1")
assert tpl is not None
assert tpl["readonly"] is True
# Simulate API guard check
assert tpl.get("readonly") is True
def test_readonly_guard_delete(self, db) -> None:
"""Readonly templates are flagged for API-level rejection."""
db.create_prompt_template(
"t1",
"mcp__srv__prompt",
"mcp",
"content",
variables="[]",
is_default=False,
org_id="",
created_by="",
origin="mcp",
mcp_server="srv",
readonly=True,
)
existing = db.get_prompt_template("t1")
assert existing is not None
assert existing.get("readonly") is True