Skip to content

feat(engine): MCP 우선 전환 Phase 3 — 쓰기 툴 - #50

Merged
devlikebear merged 30 commits into
mainfrom
feat/mcp-pivot-phase3
Aug 23, 2026
Merged

feat(engine): MCP 우선 전환 Phase 3 — 쓰기 툴#50
devlikebear merged 30 commits into
mainfrom
feat/mcp-pivot-phase3

Conversation

@devlikebear

Copy link
Copy Markdown
Owner

MCP 우선 전환(#47) Phase 3: 쓰기 툴입니다. 이 PR 이후 외부 에이전트가 실제로 원고를 씁니다.

Base: #49(Phase 2) 위에 쌓여 있습니다. 아래 PR이 머지되면 base를 정리합니다.

툴 15개 완성 — 읽기 9 + 쓰기 6. 설계 문서의 예산과 정확히 일치합니다.

코드보다 먼저 잡은 것: 계획서의 거짓 계약

구현 전에 storyops.UndoApply가 실제로 무엇을 되돌리는지 읽었습니다. nodes.RestoreOutlineparent_id·ordinal·label·title·status만 복원하고 content_doc은 건드리지 않습니다(삭제됐다 복원되는 노드만 예외).

계획서의 Phase 3 종료 조건은 "write_sceneundo_last_change → 원고가 원래 바이트로 돌아온다"였습니다. 그대로 만들었다면 "되돌릴 수 있다"고 설명하면서 실제로는 되돌리지 못하는 툴이 에이전트에게 나갔을 겁니다.

경로를 분리했습니다:

  • 본문 되돌리기 → 스냅샷. write_scenesnapshot_id를 반환합니다.
  • 구조 되돌리기 → undo_batch_id.
  • undo_last_change가 둘 다 받고, 설명에 차이를 명시합니다.

종료 조건 테스트가 이제 진짜로 원래 바이트 복원을 확인합니다.

쓰기 툴 6개

계약
write_scene expected_content_version 필수 · 쓰기 전 스냅샷 · 읽기검증 · 요약 큐 · snapshot_id 반환
revise_scene 정확한 문자열 치환 · dry_run 미리보기 · 씬별 스냅샷
apply_story_ops 기존 Proposal 어휘 · 전부-아니면-전무 롤백 · undo_batch_id
write_summary 씬/챕터 요약과 시놉시스 · 씬만 버전 요구
create_checkpoint 큰 개작 전 복원 지점
undo_last_change batch_id(구조) 또는 snapshot_id(본문)

설계 판단 세 가지

write_scenestoryops를 거치지 않습니다. "적용기를 재사용하라"는 규칙과 모순처럼 보이지만 아닙니다 — 적용기의 set_scene_textUpdateContent(무조건 덮어쓰기)를 씁니다. 컴패니언은 "작가가 보고 있는 씬"을 대상으로 하니 그게 맞습니다. MCP 계약은 정반대라 UpdateContentIfVersion에 직접 붙였고, 스냅샷·읽기검증·요약 큐는 그대로 수행합니다.

apply_story_opsset_scene_text를 거부합니다. 통과시키면 write_scene의 버전 검사를 배치로 우회할 수 있습니다. 변경 종류마다 문은 하나여야 합니다.

MCP는 자체 storyops 인스턴스를 씁니다. undo 배치가 서비스 메모리에 살기 때문에, 에이전트는 자기가 적용한 것만 되돌리고 작가의 컴패니언 배치는 건드릴 수 없습니다.

테스트가 잡은 버그

새 씬에 첫 원고를 영영 쓸 수 없었습니다. 새 씬은 content_version0이라 expected_content_version <= 0 가드가 첫 집필을 막았습니다. *int로 바꿔 "미제공"과 "0"을 구분합니다. 리뷰가 아니라 실제 tools/call 트래픽을 구동하는 테스트가 잡았습니다.

안전장치

  • 호출 한도가 등록 데코레이터 안에 있습니다 — 활동 로그와 같은 자리라, 툴을 추가하며 빠뜨리는 게 구조적으로 불가능합니다. 분당 120회 토큰 버킷, 리필 상한이 있어 오래 쉬어도 무제한 버스트가 쌓이지 않습니다.
  • read_only는 쓰기 툴이 tools/list아예 없습니다(거부가 아니라 부재).
  • 씬 본문 크기 상한 6만자.

UI 갱신: 작가 우선

mcp.changedffi.rs 매핑 → useMcpChanges 훅.

에디터에 저장되지 않은 수정이 있으면 버퍼를 절대 교체하지 않습니다. 작가가 쓰던 문장이 에이전트 버전보다 우선이므로, 배너로 알리고 판단을 맡깁니다. 다른 작품 변경은 무시하고, 구조 변경은 아웃라인만 갱신합니다. Vitest 5개로 고정했습니다.

검증

make test                    # 엔진 42/42 + 프론트엔드 60파일 + Rust 15
go build -tags mas ./...     # OK
go list -deps -tags mobile | grep modelcontextprotocol   # 0건

종단 테스트가 실제 HTTP 엔드포인트를 외부 클라이언트처럼 구동합니다. 쓰기 왕복, 낡은 버전 거부(작가 텍스트 보존 확인), 컨테이너 거부, 배치 롤백, set_scene_text 차단, dry-run 무변경, 스냅샷 기록을 덮습니다.

알려진 한계

linetta_where_does_appear는 명시적 @멘션만 집계합니다. 에이전트가 쓰는 원고는 평문이라 이 툴이 에이전트 작성분에서 비게 됩니다. 이슈 #32와 같은 문제이며, 쓰기 툴이 생긴 지금 제대로 재현·검증할 수 있게 됐습니다. 별도로 다룹니다.

다음

Phase 4 — 브리지 바이너리(Claude Desktop), 설정 UI, MAS 엔타이틀먼트. 그래야 설정 화면 토글 하나로 켜는 실사용 흐름이 완성됩니다.

🤖 Generated with Claude Code

devlikebear and others added 29 commits August 22, 2026 23:16
Linetta becomes a pure writing tool; AI collaboration moves to external
MCP clients (Claude Code / Claude Desktop). Tracking issue: #47.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move the story-brief assembly - Context types, ContextSelection,
ContextBuilder, prompt rendering, token estimates - into a new
internal/storycontext package that performs no LLM calls and cannot
import LLM client code (verified: zero tars deps).

The renderer now returns plain strings (storycontext.Render); the only
prompt logic left in internal/ai is the BuildMessages adapter wrapping
Render into llm.ChatMessage for the runner, covered by new adapter
tests. RPC method names and notification literals (ai.run, ai.delta,
...) are unchanged.

Part of the MCP-first pivot (#47), Phase 1 Task 1.1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Move the story-mutation vocabulary and its applier - Op/Proposal types,
validation with lenient kind normalization, ApplyOps with all-or-nothing
structural rollback, the one-step undo batches, and outline change
counting - into a new internal/storyops package with zero tars
dependencies (memory is injected via a MemoryRecorder interface, so a
future MCP applier without companion memory fails the remember op with
a clear message instead of a panic).

The companion now delegates ApplyOps/UndoApply to storyops and keeps
only what is chat-specific: proposal-fence parsing, intent gating, the
tool registry, and the outline-approval preview. Type aliases keep the
RPC handlers and existing tests unchanged, and those tests now exercise
the delegation end-to-end; new storyops tests cover apply/undo/rollback,
the companion-before snapshot, and the missing-memory guard directly.

Part of the MCP-first pivot (#47), Phase 1 Task 1.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The engine has had two context assembly paths: the scene-centric
ai.ContextBuilder (hierarchical summaries, prev-scene summary, related
scenes) and the companion's gatherContext (the only place facts,
memories, and references were collected) - so the ContextSelection
toggles for those three sections existed without a builder that honored
them. storycontext now completes the merge: optional FactSource /
MemorySource / ReferenceSource interfaces populate Context fields,
ApplyContextSelection clears them when toggled off, and the renderer
carries the sections using the companion's established prompt format.

Nothing wires the sources yet - engineapp is untouched, so ai.run and
ai.preview_context behave exactly as before. The MCP story-context tool
(Phase 2) is the first consumer. Tests cover section rendering, empty
omission, toggle clearing, and a provider-less BuildFull returning a
complete brief.

Part of the MCP-first pivot (#47), Phase 1 Task 1.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Split provider-driven summarization (client factory, Chat calls, both
prompts) into llm_path.go behind a single summarizeViaLLM helper. The
short-scene plain-text path and the queue/recursion orchestration stay
in summarizer.go and no longer reference tars at all, so the pivot's
removal phase deletes the LLM file wholesale while nodes.update_content
keeps its postUpdate hook and short summaries keep working.

Part of the MCP-first pivot (#47), Phase 1 Task 1.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
internal/storycontext and internal/storyops must stay free of tars
llm/agentloop/session linkage so the MCP pivot's removal phase can
delete the LLM loop without touching the code the MCP tools stand on.

Part of the MCP-first pivot (#47), Phase 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All five open questions resolved as recommended: remove the companion
only after MCP is validated in real use (Phase 5 -> 6), accept that
mobile loses AI entirely, drop the web_search setting while keeping
web_fetch for Fact Book URL capture, defer a hand-written summary UI to
Phase 7, and target 1.0.0. Downstream tasks that were written
conditionally now state the decision outright.

Part of the MCP-first pivot (#47), Phase 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mcp_mode (off|read_only|full, default off), mcp_port (default 7391),
mcp_project_id, and a dedicated MCP consent pair. The bearer token
follows the existing api_key convention: it lives in the secret store,
settings.get returns only the mcp_token_set presence flag, and the flag
is never written to settings.json.

Two safety-by-construction choices: an unrecognized mode normalizes to
off rather than to an open server, and the port is a fixed setting
rather than ephemeral so saved client configs survive restarts - an
out-of-range value falls back to the default instead of silently
binding elsewhere.

Also vendors github.com/modelcontextprotocol/go-sdk v1.7.0, first
imported by the host in the next commit.

Part of the MCP-first pivot (#47), Phase 2 Tasks 2.1-2.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
internal/mcphost serves the MCP Streamable HTTP endpoint on
127.0.0.1:<mcp_port> inside the running app, gated //go:build !mobile
(MAS is included on purpose: once the companion goes away, MCP is the
MAS build's only AI path).

Safety properties the tests pin down:
- mode off binds nothing and writes nothing;
- starting without MCP consent fails with ErrConsentRequired;
- a busy port returns ErrPortInUse instead of silently binding elsewhere,
  because every saved client config points at the configured port;
- the bearer token is compared in constant time;
- a non-loopback Origin or Host is rejected with 403 even when the token
  is valid - a web page on any site can otherwise POST to 127.0.0.1
  (the DNS-rebinding case the MCP spec calls out for HTTP transports);
- an authorized initialize returns 200 from the real MCP handler;
- the 0600 discovery file carries port/token/pid and is removed on Stop
  so a stale endpoint is never advertised.

Build-tag linkage verified: mas and default link the SDK, mobile links
zero of it.

Part of the MCP-first pivot (#47), Phase 2 Tasks 2.1 and 2.3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mcp_enabled.go (!mobile) starts the host honoring the persisted mode and
registers mcp.status/enable/disable/regenerate_token/activity;
mcp_disabled.go (mobile) supplies a controller that reports a disabled
state, so the SDK is never linked into the mobile engine (verified: 0
deps). Unlike git sync, MAS is NOT excluded - after the companion is
removed, MCP is the MAS build's only AI path.

diagnostics now reports mcp_available so the settings pane can hide
itself on builds without MCP, and the host's Stop joins app.closers.
Port-in-use and missing-consent travel to the renderer as reason codes
(mcp_port_in_use / mcp_consent_required) rather than raw English, using
the mechanism added in #43.

Tests drive real JSONRPC through the app: a fresh engine binds nothing,
enabling without consent is refused with its reason code, enable
actually binds the configured port, app.Close releases it, and
mcp.disable works as the kill switch.

Part of the MCP-first pivot (#47), Phase 2 Task 2.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The read surface external agents build against: list_works, get_outline,
get_story_context, read_scene, search_manuscript, list_characters,
where_does_appear, get_plot, get_fact_cards. Every payload carries stable
ids, read_scene returns plain prose plus the content_version a later
write must present, and get_story_context returns the rendered brief with
an included/empty section report so an agent can tell that summaries are
missing and offer to write them.

Scoping runs through one requireProject/requireNode pair, so the
single-work restriction cannot be bypassed by reaching a scene by id.
Bad arguments come back as tool errors with actionable text rather than
transport failures.

The MCP tool layer gets its own storycontext builder wired with fact,
memory, and reference sources (adapters added to companion, which owns
those repos today and hands them over in the removal phase). The AI
runner's builder is untouched, so ai.run prompts do not change.

Task 2.6 lands with it rather than after: every tool is registered
through a recording decorator, so no tool can forget to report itself,
and the mcp_activity table trims itself to 500 rows instead of leaning
on the nightly job. mcp.activity now returns real entries.

Tests drive the live HTTP endpoint as an external client would -
initialize, tools/list, tools/call over SSE - covering the exact
nine-tool surface, a full read round trip, tool errors for unknown ids,
cross-work refusal under restriction, and both outcomes reaching the
audit trail.

Part of the MCP-first pivot (#47), Phase 2 Tasks 2.5 and 2.6.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
settings.Set returned the new mode in its response while persist() and
load() silently dropped every mcp_* field - both copy an explicit field
list and the new keys were never added to either. MCP came back off
after each restart, which is how it surfaced: a live server on real data
reported mode=off the next time the engine started.

No in-memory assertion could catch this, so the regression test writes
through Set and then reads back with a freshly constructed Store.
load() also normalizes on the way in, keeping the guarantee that an
unrecognized mode degrades to off rather than to an open server.

Part of the MCP-first pivot (#47), Phase 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The brief's plaintext walker appends a newline per paragraph, so a scene
that has never been written reads as "\n" - an agent can mistake that
for content. read_scene trims at the tool boundary rather than in
PlainText, because the brief renderer depends on that function's exact
output and Phase 1 promised its prompts would not change.

Found by driving the live server against real data.

Part of the MCP-first pivot (#47), Phase 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stop() removed the discovery file unconditionally, so any engine that
shared LINETTA_HOME erased a live server's endpoint on its way out -
even one with MCP off that never bound a listener. The server kept
serving while the bridge had nothing left to read.

Two guards: Stop only retracts when this host actually served, and
removeDiscoveryFile refuses to delete a file whose pid is not ours.

Found in operation, not in review: a second engine instance left running
against the same data directory deleted the live server's mcp.json when
its process exited.

Part of the MCP-first pivot (#47), Phase 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
RestoreOutline - what storyops.UndoApply calls - restores parent/ordinal/
label/title/status and leaves content_doc alone, so undoing a structural
batch does not revert scene prose. The plan's exit criterion claimed it
did, which would have shipped a false promise to agents.

write_scene now carries snapshot_id and undo_last_change accepts both a
batch_id (structure) and a snapshot_id (prose). Also records two
decisions found while reading the write path: apply_story_ops rejects
set_scene_text so version checking cannot be bypassed, and write_summary
requires content_version for scenes only.

Part of the MCP-first pivot (#47), Phase 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
linetta_write_scene replaces a scene body; linetta_write_summary writes
a scene, chapter, or synopsis summary. Both register only in
settings.MCPModeFull, so read_only does not merely refuse writes - the
tools are absent from tools/list.

write_scene is built directly on nodes.UpdateContentIfVersion rather
than routed through storyops. That looks like it contradicts the
reuse-the-applier rule but does not: the applier's set_scene_text calls
UpdateContent unconditionally (last writer wins) because the companion
targets the scene the writer is looking at. The MCP contract is the
opposite - expected_content_version is required and a stale write is
refused with a message telling the agent to re-read and merge. The
pre-write snapshot, the readback verify, and the summarizer enqueue all
still happen.

expected_content_version is a pointer, not an int: a scene that has
never been written has version 0, so absent and zero must stay
distinguishable or the first draft could never be written. Caught by
the tests, which drive real tools/call traffic.

Scene summaries require the version too - a summary of text that has
since changed would make the story brief lie. Chapters and the synopsis
have no version tracking their children, so they take none and last
write wins, stated in the tool description.

write_scene returns snapshot_id: reverting prose goes through the
snapshot, because undoing a structural batch restores the outline and
leaves bodies alone (see the plan's corrected undo contract).

Part of the MCP-first pivot (#47), Phase 3 Tasks 3.1 and 3.4.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
linetta_apply_story_ops routes the existing Proposal vocabulary through
storyops.ApplyOps, so structural batches stay all-or-nothing and return
an undo_batch_id. linetta_create_checkpoint saves a restore point;
linetta_undo_last_change takes either a batch_id or a snapshot_id.

Two decisions worth naming:

set_scene_text is refused by the batch tool with a message pointing at
linetta_write_scene. The applier writes scene bodies unconditionally
because the companion targets the scene the writer is looking at;
letting that through here would route around write_scene's version
check entirely. One door per mutation type.

The MCP tool layer gets its own storyops instance. Undo batches live in
memory on the service, so an agent can undo only what it applied and
never the writer's own companion batch.

The phase exit criterion is now a real test: write prose, undo with the
returned snapshot_id, and the scene matches the original bytes. Undoing
a structural batch is tested separately, because it restores the
outline and leaves bodies alone.

Part of the MCP-first pivot (#47), Phase 3 Tasks 3.3 and 3.5.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The rate limit lives inside the record decorator, next to the activity
log, so a tool cannot be registered without one. A single token bucket
covers reads and writes at 120/minute: generous for a human-paced
session, a wall for a runaway loop. Refill is capped so a long idle
cannot bank an unbounded burst.

mcp.changed now travels engine -> ffi.rs -> useMcpChanges. The rule that
matters: when the editor holds unsaved edits for the scene the agent
touched, the buffer is never replaced - the writer's in-progress
sentence outranks the agent's version, so the change surfaces as a
banner instead. Changes to another work are ignored, and a structural
batch that names no scenes refreshes only the outline.

Part of the MCP-first pivot (#47), Phase 3 Tasks 3.6 and 3.7.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wraps manuscriptedit's plan/apply pair so an agent can rename a
character or correct a term across scenes without resending whole
bodies. Every touched scene is snapshotted by the existing applier.

dry_run returns the matches and the resulting text without changing
anything, which is how an agent checks the blast radius of a common
phrase before committing. Named node_ids are checked against the
allowed work, so a restricted server cannot be steered into another
work by id. No match returns a tool error pointing at
linetta_search_manuscript rather than silently succeeding.

This completes the 15-tool surface: nine read, six write.

Part of the MCP-first pivot (#47), Phase 3 Task 3.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records the two deviations found while building: expected_content_version
is a pointer so a brand-new scene (version 0) can receive its first
draft, and the pre-write snapshot reuses the companion-before reason
rather than adding one the frontend version sheet cannot label yet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…tore

Linux has no secret backend (secrets_unsupported.go), so EnsureMCPToken
failed with "secure secret storage is only available on macOS and
Windows" and the MCP server could not start at all. Linux ships as
AppImage/deb/rpm, so this was a broken feature on a shipping platform,
not just a red test.

The MCP token falls back to a 0600 file in LINETTA_HOME. That is
acceptable for this secret specifically: while the server runs, mcp.json
already carries the same token at 0600 so the bridge can find it, and
any process running as this user can read library.db directly. Provider
API keys deliberately do NOT get this fallback - they are long-lived
third-party credentials, and quietly storing them in plaintext is not a
change a writer opted into.

The presence flag goes through a stat-only check, preserving the
existing invariant that settings.get never reads secret values (on macOS
that can prompt the Keychain).

Caught by CI on Linux; a Windows-only local run could never have found
it.

Part of the MCP-first pivot (#47), Phase 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…them

The storyops extraction copied plainTextFromDoc and trimRunes out of
companion/query.go with a comment saying the companion copy would die in
the removal phase. SonarCloud failed the quality gate on it (9.3%
duplication on new code, limit 3%), and it was right to: carrying two
byte-identical implementations through three phases is a real
maintenance hazard, not just a metric.

storyops now exports PlainTextFromDoc and TrimRunes as the canonical
pair; the companion keeps one-line local wrappers so none of its call
sites change. Behavior is identical - the implementations were already
the same bytes.

Part of the MCP-first pivot (#47), Phase 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…nstantly

TestMCPDisableStopsListener failed on the Windows CI runner while
passing five consecutive local runs. The assertion was the problem:
http.Server.Shutdown returning does not guarantee the socket is
immediately rebindable, and on a loaded machine that teardown lags. An
instantaneous check is a flake, not a stronger guarantee.

Both port assertions now poll with a 3s deadline, so a port that never
frees still fails the test.

Part of the MCP-first pivot (#47), Phase 2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@devlikebear
devlikebear changed the base branch from feat/mcp-pivot-phase2 to main August 23, 2026 10:59
Conflicts were add/add on the files both phases touch: main carries the
Phase 2 shape, this branch the same files with the write-tool fields and
imports added on top. Kept the branch version in each case and verified
nothing from main was dropped - the only line not carried forward is the
'write tools land in Phase 3' placeholder, which the real registration
now replaces.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@devlikebear
devlikebear merged commit 5a1004e into main Aug 23, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant