feat(engine): MCP 우선 전환 Phase 3 — 쓰기 툴 - #50
Merged
Conversation
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>
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>
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



MCP 우선 전환(#47) Phase 3: 쓰기 툴입니다. 이 PR 이후 외부 에이전트가 실제로 원고를 씁니다.
툴 15개 완성 — 읽기 9 + 쓰기 6. 설계 문서의 예산과 정확히 일치합니다.
코드보다 먼저 잡은 것: 계획서의 거짓 계약
구현 전에
storyops.UndoApply가 실제로 무엇을 되돌리는지 읽었습니다.nodes.RestoreOutline은parent_id·ordinal·label·title·status만 복원하고content_doc은 건드리지 않습니다(삭제됐다 복원되는 노드만 예외).계획서의 Phase 3 종료 조건은 "
write_scene→undo_last_change→ 원고가 원래 바이트로 돌아온다"였습니다. 그대로 만들었다면 "되돌릴 수 있다"고 설명하면서 실제로는 되돌리지 못하는 툴이 에이전트에게 나갔을 겁니다.경로를 분리했습니다:
write_scene이snapshot_id를 반환합니다.undo_batch_id.undo_last_change가 둘 다 받고, 설명에 차이를 명시합니다.종료 조건 테스트가 이제 진짜로 원래 바이트 복원을 확인합니다.
쓰기 툴 6개
write_sceneexpected_content_version필수 · 쓰기 전 스냅샷 · 읽기검증 · 요약 큐 ·snapshot_id반환revise_scenedry_run미리보기 · 씬별 스냅샷apply_story_opsProposal어휘 · 전부-아니면-전무 롤백 ·undo_batch_idwrite_summarycreate_checkpointundo_last_changebatch_id(구조) 또는snapshot_id(본문)설계 판단 세 가지
write_scene은storyops를 거치지 않습니다. "적용기를 재사용하라"는 규칙과 모순처럼 보이지만 아닙니다 — 적용기의set_scene_text는UpdateContent(무조건 덮어쓰기)를 씁니다. 컴패니언은 "작가가 보고 있는 씬"을 대상으로 하니 그게 맞습니다. MCP 계약은 정반대라UpdateContentIfVersion에 직접 붙였고, 스냅샷·읽기검증·요약 큐는 그대로 수행합니다.apply_story_ops가set_scene_text를 거부합니다. 통과시키면write_scene의 버전 검사를 배치로 우회할 수 있습니다. 변경 종류마다 문은 하나여야 합니다.MCP는 자체
storyops인스턴스를 씁니다. undo 배치가 서비스 메모리에 살기 때문에, 에이전트는 자기가 적용한 것만 되돌리고 작가의 컴패니언 배치는 건드릴 수 없습니다.테스트가 잡은 버그
새 씬에 첫 원고를 영영 쓸 수 없었습니다. 새 씬은
content_version이 0이라expected_content_version <= 0가드가 첫 집필을 막았습니다.*int로 바꿔 "미제공"과 "0"을 구분합니다. 리뷰가 아니라 실제tools/call트래픽을 구동하는 테스트가 잡았습니다.안전장치
read_only는 쓰기 툴이tools/list에 아예 없습니다(거부가 아니라 부재).UI 갱신: 작가 우선
mcp.changed→ffi.rs매핑 →useMcpChanges훅.에디터에 저장되지 않은 수정이 있으면 버퍼를 절대 교체하지 않습니다. 작가가 쓰던 문장이 에이전트 버전보다 우선이므로, 배너로 알리고 판단을 맡깁니다. 다른 작품 변경은 무시하고, 구조 변경은 아웃라인만 갱신합니다. Vitest 5개로 고정했습니다.
검증
종단 테스트가 실제 HTTP 엔드포인트를 외부 클라이언트처럼 구동합니다. 쓰기 왕복, 낡은 버전 거부(작가 텍스트 보존 확인), 컨테이너 거부, 배치 롤백,
set_scene_text차단, dry-run 무변경, 스냅샷 기록을 덮습니다.알려진 한계
linetta_where_does_appear는 명시적 @멘션만 집계합니다. 에이전트가 쓰는 원고는 평문이라 이 툴이 에이전트 작성분에서 비게 됩니다. 이슈 #32와 같은 문제이며, 쓰기 툴이 생긴 지금 제대로 재현·검증할 수 있게 됐습니다. 별도로 다룹니다.다음
Phase 4 — 브리지 바이너리(Claude Desktop), 설정 UI, MAS 엔타이틀먼트. 그래야 설정 화면 토글 하나로 켜는 실사용 흐름이 완성됩니다.
🤖 Generated with Claude Code