feat(engine): MCP 우선 전환 Phase 2 — 읽기 전용 MCP 서버 - #49
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>
…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>
Conflict was the plan doc alone: main carries the squashed Phase 1 version, this branch carries the same doc with Phase 2 marked complete on top. Kept the branch version, which is a superset. 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 2: 읽기 전용 MCP 서버입니다. 이 PR 이후 Claude Code가 실제로 붙어 작품을 읽을 수 있습니다.
쓰기 툴이 없으므로 이 단계의 위험 반경은 0입니다. 외부 에이전트는 아무것도 바꿀 수 없습니다.
아키텍처
internal/mcphost가 공식 Go SDK의 Streamable HTTP 핸들러를127.0.0.1:7391(고정 포트) 에 올립니다. 앱 안에서 호스팅하므로 DB 소유자는 여전히 하나입니다.빌드 게이트는
//go:build !mobile입니다. git sync와 달리mas를 제외하지 않습니다 — 컴패니언이 제거되면 MAS 빌드의 유일한 AI 경로가 MCP이기 때문입니다. 모바일은 서버를 호스팅할 수 없어 비활성 쌍둥이를 받고, SDK가 아예 링크되지 않습니다(확인: 의존 0건).포트가 고정인 이유: 클라이언트 설정은 한 번 쓰고 몇 달을 씁니다. 임의 포트를 쓰면 재시작마다 저장된 설정이 죽고, Claude Code에는 URL 변경을 흡수할 수단이 없습니다. 포트가 점유되면 조용히 다른 포트로 넘어가지 않고 사용자가 볼 수 있는 에러를 냅니다.
안전 속성 (전부 테스트로 고정)
off— 사용자가 켜기 전에는 어떤 리스너도 바인딩하지 않고 파일도 쓰지 않습니다mcp_consent_version) 없이 켜면ErrConsentRequired로 거부settings.get은 존재 플래그만 노출Origin/Host는 403 — 임의 웹페이지가127.0.0.1로 POST하는 DNS 리바인딩 경로 차단requireProject/requireNode한 쌍을 통과 — 다른 작품의 씬 id로 우회하는 것까지 차단ErrPortInUse,app.Close()가 포트 반납mcp_activity)에 기록읽기 툴 9개
list_works·get_outline·get_story_context·read_scene·search_manuscript·list_characters·where_does_appear·get_plot·get_fact_cardsread_scene은 평문 본문과content_version을 반환 — 이후 쓰기가 제시해야 할 값입니다get_story_context는 렌더된 브리프와 함께 included/empty 섹션 리포트를 반환합니다. 에이전트가 "요약이 비었다"를 인지하고 채워 넣을 수 있게 하는 장치입니다MCP 툴 레이어는 팩트·메모리·레퍼런스 소스가 연결된 자체 storycontext 빌더를 씁니다. AI 러너의 빌더는 건드리지 않아
ai.run프롬프트는 바이트 단위로 동일합니다.Task 2.6(활동 로그)을 뒤로 미루지 않고 함께 넣었습니다. 툴 등록을 기록 데코레이터로 감싸므로 어떤 툴도 자기 보고를 빠뜨릴 수 없습니다. 보존은 스냅샷 정리 잡에 얹는 대신 삽입 직후 자체 트리밍(500행)으로 처리했습니다 — 계획서에서 의도적으로 이탈했고 문서에 기록했습니다.
실사용 테스트에서 잡은 버그 3개
자동 테스트가 전부 통과한 뒤, 실제 데이터로 서버를 띄우고 MCP 클라이언트로 붙어 9개 툴을 돌렸습니다. 단위 테스트로는 잡히지 않던 버그 3개가 나왔고 전부 회귀 테스트와 함께 고쳤습니다.
f700dbfsettings.Set이 응답에는 새 모드를 담으면서persist()/load()가mcp_*필드를 전부 버림 → 재시작마다 MCP가 꺼짐a8ba48ebody가""이 아닌"\n"→ 에이전트가 내용으로 오인8cd4e3bStop()이 디스커버리 파일을 무조건 삭제 → MCP를 켠 적 없는 엔진이 살아 있는 서버의 엔드포인트를 지움세 번째는 Phase 4의 브리지가 이 파일을 읽으므로 stdio 클라이언트가 끊기는 문제였습니다.
검증
테스트가 실제 HTTP 엔드포인트를 외부 클라이언트처럼 구동합니다 —
initialize→tools/list→tools/call, SSE 파싱까지. 아홉 개 정확한 툴 목록, 전체 읽기 왕복, 알 수 없는 id의 tool error, 제한 하 교차 작품 거부, 양쪽 결과의 감사 로그 기록을 덮습니다.실제 데이터 검증에서는 3화 분량 원고·인물 4·관계·플롯 비트·팩트 카드를 넣고 9개 툴을 전부 확인했습니다.
get_story_context가 9개 섹션 1,720자 브리프를 반환하고, 섹션 토글도 실제로 동작합니다.알려진 한계
linetta_where_does_appear는 명시적 @멘션만 집계합니다. 에이전트가 쓰는 원고는 평문이므로, 쓰기 툴 도입 후에는 이 툴이 에이전트 작성분에서 비게 됩니다. 열려 있는 #32와 같은 문제이고, 전환 이후 오히려 중요해집니다. Phase 3 이후 별도로 다룹니다.다음
Phase 3(쓰기 툴 6개 + 자동 스냅샷 + 낙관적 동시성 +
mcp.changedUI 갱신)이 이 브랜치 위에 쌓입니다.🤖 Generated with Claude Code