CI & Build / Python lint (push) Successful in 4s
CI & Build / Plugin hooks (push) Successful in 7s
CI & Build / TypeScript typecheck (push) Successful in 11s
CI & Build / integration (push) Successful in 18s
CI & Build / Python tests (push) Successful in 48s
CI & Build / Build & push image (push) Successful in 41s
Survey pass 3 (#2250) tabulated capabilities per record kind. Processes came out lowest on every column, and they are the kind with the most authority: build_process_manifest turns each one into a skill file on the operator's machine that auto-surfaces and is followed as written — its own docstring calls it "the most consequential passive surface Scribe has." Three gaps closed. NO PULL TELEMETRY (#2476). get_process recorded nothing, while the auto-inject menu header names get_process as the way to open that kind. Every note is embedded regardless of note_type, so a Process is surfaceable — and the getter the product points at was the one getter that recorded nothing, leaving every Process permanently at zero pulls and looking like dead weight beside kinds that merely had a counter. get_note's own comment already listed processes as a reason to record pulls. The fix for #2245 covered notes, tasks and snippets: it enumerated the kinds someone thought of rather than the kinds that exist. NO DEDUP GATE. create_process had no near-duplicate check and no force flag, while notes, tasks, snippets and rules all have both. It matters more here than elsewhere: two near-identical procedures don't just bloat the corpus, they compete to be followed, and which one wins is decided by a slug collision. NO DELETE. list/create/get/update, no delete — a kind that reads as one you cannot retire. Deletion was always possible via delete_note, since a Process is a note and the trash is kind-agnostic, so this was discoverability rather than capability. delete_process checks note_type before trashing: the tool is reached for by name, and letting it destroy an ordinary note whose id happened to resolve would be a destructive action taken on a mistyped argument. THE GUARD, which is the part that stops a fourth repeat. tests/test_mcp_pull_telemetry.py discovers every get_* MCP tool by AST and requires a record_pulled from any that loads a single note. Not a list of getters — a get_<newkind> added tomorrow is covered the moment it loads a note the way the others do. get_milestone is correctly excluded: it calls list_notes for a milestone's steps, which is a surfacing, not an opening. The loader NAMES are a list, and that residual weakness is pinned against a rename rather than papered over. An earlier draft tried to discover new loaders by return annotation and would have failed on create_note — which also returns a Note. Readers and writers aren't distinguishable by type, so the honest version is a pinned list, a non-empty assertion, and a docstring saying which hole remains. test_register_attaches_four_tools became a derived check of the module's public coroutines, so the next tool added can't be left unregistered. MCP _INSTRUCTIONS updated: product behaviour belongs in the instruction surfaces, not in a rule (rule #119). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UaYUaouG9jjhATyuxCKrQs
207 lines
8.4 KiB
Python
207 lines
8.4 KiB
Python
"""Tests for MCP process tools — patches the service layer."""
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from scribe.mcp._context import _user_id_ctx
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _bind_user():
|
|
token = _user_id_ctx.set(7)
|
|
yield
|
|
_user_id_ctx.reset(token)
|
|
|
|
|
|
def _fake_note(id=1, title="Drift Audit", note_type="process", user_id=7):
|
|
n = MagicMock()
|
|
n.id = id
|
|
n.title = title
|
|
n.note_type = note_type
|
|
# Must be a real int, not an auto-attribute: the provenance check compares it
|
|
# against the bound caller (7) to decide whether the record is shared.
|
|
n.user_id = user_id
|
|
n.to_dict.return_value = {"id": id, "title": title, "note_type": note_type}
|
|
return n
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_process_requires_title_and_body():
|
|
from scribe.mcp.tools.processes import create_process
|
|
with pytest.raises(ValueError):
|
|
await create_process(title="", body="something")
|
|
with pytest.raises(ValueError):
|
|
await create_process(title="X", body=" ")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_process_sets_note_type():
|
|
created = _fake_note()
|
|
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
|
AsyncMock(return_value=None)), \
|
|
patch("scribe.services.notes.create_note",
|
|
AsyncMock(return_value=created)) as mock_create:
|
|
from scribe.mcp.tools.processes import create_process
|
|
out = await create_process(title="Drift Audit", body="the prompt", tags=["audit"])
|
|
assert out["note_type"] == "process"
|
|
# the service was asked to create a process
|
|
assert mock_create.await_args.kwargs["note_type"] == "process"
|
|
assert mock_create.await_args.kwargs["title"] == "Drift Audit"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_process_blocks_a_near_duplicate():
|
|
"""The gate matters more for processes than for other kinds: each one becomes
|
|
a skill file that auto-surfaces, so two near-identical procedures don't just
|
|
bloat the corpus — they compete to be followed (#2250)."""
|
|
from scribe.services.dedup import DuplicateMatch
|
|
|
|
# The real dataclass, not a MagicMock: a mock answers every attribute, so it
|
|
# would pass whatever field names this test happened to guess and prove
|
|
# nothing about the payload the tool actually returns.
|
|
match = DuplicateMatch(id=42, title="Drift Audit", similarity=0.94, reason="semantic")
|
|
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
|
AsyncMock(return_value=match)), \
|
|
patch("scribe.services.notes.create_note", AsyncMock()) as mock_create:
|
|
from scribe.mcp.tools.processes import create_process
|
|
out = await create_process(title="Drift Audit", body="the prompt")
|
|
assert out["duplicate"] is True
|
|
assert out["existing_id"] == 42
|
|
mock_create.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_create_process_force_bypasses_the_gate():
|
|
created = _fake_note()
|
|
with patch("scribe.mcp.tools.processes.dedup_svc.find_duplicate_note",
|
|
AsyncMock()) as find_mock, \
|
|
patch("scribe.services.notes.create_note",
|
|
AsyncMock(return_value=created)):
|
|
from scribe.mcp.tools.processes import create_process
|
|
await create_process(title="Drift Audit", body="the prompt", force=True)
|
|
find_mock.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_process_returns_body_and_candidates():
|
|
note = _fake_note(id=7)
|
|
with patch("scribe.services.notes.resolve_process",
|
|
AsyncMock(return_value=(note, [{"id": 9, "title": "Drift Audit Notes"}]))):
|
|
from scribe.mcp.tools.processes import get_process
|
|
out = await get_process("drift")
|
|
assert out["id"] == 7
|
|
assert out["other_matches"] == [{"id": 9, "title": "Drift Audit Notes"}]
|
|
# The caller's own process carries no provenance marker — absence of the flag
|
|
# is what makes it read as theirs.
|
|
assert "shared" not in out
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_process_flags_another_users_process():
|
|
"""A shared Process must arrive labelled. get_process's contract is 'follow
|
|
the returned body', so an unlabelled one would put someone else's procedure
|
|
in charge of the session."""
|
|
note = _fake_note(id=7, user_id=9)
|
|
with patch("scribe.services.notes.resolve_process",
|
|
AsyncMock(return_value=(note, []))), \
|
|
patch("scribe.services.access.describe_provenance",
|
|
AsyncMock(return_value={"shared": True, "owner": "alex",
|
|
"permission": "viewer"})):
|
|
from scribe.mcp.tools.processes import get_process
|
|
out = await get_process("deploy")
|
|
assert out["shared"] is True
|
|
assert out["owner"] == "alex"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_get_process_not_found_raises():
|
|
with patch("scribe.services.notes.resolve_process",
|
|
AsyncMock(return_value=(None, []))):
|
|
from scribe.mcp.tools.processes import get_process
|
|
with pytest.raises(ValueError):
|
|
await get_process("missing")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_process_rejects_non_process_note():
|
|
# Resolves share-aware now, so the patch target is get_note_for_user, which
|
|
# returns (note, permission).
|
|
plain = _fake_note(id=3, note_type="note")
|
|
plain.deleted_at = None
|
|
with patch("scribe.services.notes.get_note_for_user",
|
|
AsyncMock(return_value=(plain, "owner"))):
|
|
from scribe.mcp.tools.processes import update_process
|
|
with pytest.raises(ValueError):
|
|
await update_process(process_id=3, title="x")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_process_refuses_a_read_only_share_with_the_reason():
|
|
"""An editor grant lets the holder edit someone else's process; a viewer
|
|
grant must be refused, and saying "not found" about a process the caller can
|
|
open would just send them looking for a missing id."""
|
|
theirs = _fake_note(id=5, user_id=9)
|
|
theirs.deleted_at = None
|
|
with patch("scribe.services.notes.get_note_for_user",
|
|
AsyncMock(return_value=(theirs, "viewer"))), \
|
|
patch("scribe.services.access.can_write_note", AsyncMock(return_value=False)), \
|
|
patch("scribe.services.notes.update_note", AsyncMock()) as mock_update:
|
|
from scribe.mcp.tools.processes import update_process
|
|
with pytest.raises(ValueError, match="read-only"):
|
|
await update_process(process_id=5, title="x")
|
|
mock_update.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_process_trashes_it_recoverably():
|
|
proc = _fake_note(id=4)
|
|
proc.deleted_at = None
|
|
with patch("scribe.services.notes.get_note_for_user",
|
|
AsyncMock(return_value=(proc, "owner"))), \
|
|
patch("scribe.mcp.tools.processes.trash_svc.delete",
|
|
AsyncMock(return_value="batch-1")) as mock_delete:
|
|
from scribe.mcp.tools.processes import delete_process
|
|
out = await delete_process(process_id=4)
|
|
assert out["deleted_batch_id"] == "batch-1"
|
|
# Through the trash, not a hard delete — restorable like every other kind.
|
|
assert mock_delete.await_args.args[1] == "note"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_delete_process_refuses_a_plain_note():
|
|
"""This tool is reached for by name. Letting it trash an ordinary note
|
|
because the id happened to resolve would be a destructive action taken on a
|
|
mistyped argument."""
|
|
plain = _fake_note(id=3, note_type="note")
|
|
plain.deleted_at = None
|
|
with patch("scribe.services.notes.get_note_for_user",
|
|
AsyncMock(return_value=(plain, "owner"))), \
|
|
patch("scribe.mcp.tools.processes.trash_svc.delete", AsyncMock()) as mock_delete:
|
|
from scribe.mcp.tools.processes import delete_process
|
|
with pytest.raises(ValueError):
|
|
await delete_process(process_id=3)
|
|
mock_delete.assert_not_awaited()
|
|
|
|
|
|
def test_register_attaches_every_tool_in_the_module():
|
|
"""Derived from the module rather than listed: a tool written but never
|
|
registered is invisible to an agent, and nothing else would notice."""
|
|
import inspect
|
|
|
|
from scribe.mcp.tools import processes
|
|
names: list[str] = []
|
|
|
|
class FakeMcp:
|
|
def tool(self, name):
|
|
names.append(name)
|
|
def deco(fn):
|
|
return fn
|
|
return deco
|
|
|
|
processes.register(FakeMcp())
|
|
public = {
|
|
name for name, obj in vars(processes).items()
|
|
if inspect.iscoroutinefunction(obj) and not name.startswith("_")
|
|
}
|
|
assert set(names) == public
|