Skip to content

feat(engine): MCP 우선 전환 Phase 2 — 읽기 전용 MCP 서버 - #49

Merged
devlikebear merged 21 commits into
mainfrom
feat/mcp-pivot-phase2
Aug 23, 2026
Merged

feat(engine): MCP 우선 전환 Phase 2 — 읽기 전용 MCP 서버#49
devlikebear merged 21 commits into
mainfrom
feat/mcp-pivot-phase2

Conversation

@devlikebear

Copy link
Copy Markdown
Owner

MCP 우선 전환(#47) Phase 2: 읽기 전용 MCP 서버입니다. 이 PR 이후 Claude Code가 실제로 붙어 작품을 읽을 수 있습니다.

Base: #48(Phase 1) 위에 쌓여 있습니다. #48이 머지되면 base를 main으로 바꿉니다.

쓰기 툴이 없으므로 이 단계의 위험 반경은 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_cards

  • 모든 페이로드가 안정적인 id를 담습니다
  • read_scene은 평문 본문과 content_version 을 반환 — 이후 쓰기가 제시해야 할 값입니다
  • get_story_context는 렌더된 브리프와 함께 included/empty 섹션 리포트를 반환합니다. 에이전트가 "요약이 비었다"를 인지하고 채워 넣을 수 있게 하는 장치입니다
  • 잘못된 인자는 tool error로 반환(transport error 아님) — 에이전트가 복구할 수 있습니다

MCP 툴 레이어는 팩트·메모리·레퍼런스 소스가 연결된 자체 storycontext 빌더를 씁니다. AI 러너의 빌더는 건드리지 않아 ai.run 프롬프트는 바이트 단위로 동일합니다.

Task 2.6(활동 로그)을 뒤로 미루지 않고 함께 넣었습니다. 툴 등록을 기록 데코레이터로 감싸므로 어떤 툴도 자기 보고를 빠뜨릴 수 없습니다. 보존은 스냅샷 정리 잡에 얹는 대신 삽입 직후 자체 트리밍(500행)으로 처리했습니다 — 계획서에서 의도적으로 이탈했고 문서에 기록했습니다.

실사용 테스트에서 잡은 버그 3개

자동 테스트가 전부 통과한 뒤, 실제 데이터로 서버를 띄우고 MCP 클라이언트로 붙어 9개 툴을 돌렸습니다. 단위 테스트로는 잡히지 않던 버그 3개가 나왔고 전부 회귀 테스트와 함께 고쳤습니다.

커밋 버그 왜 못 잡았나
f700dbf settings.Set이 응답에는 새 모드를 담으면서 persist()/load()mcp_* 필드를 전부 버림 → 재시작마다 MCP가 꺼짐 두 함수 모두 필드를 명시적으로 나열해 복사. 인메모리 단언으로는 절대 안 잡힘 — 디스크 왕복이 필요
a8ba48e 빈 씬의 body""이 아닌 "\n" → 에이전트가 내용으로 오인 시드 데이터에는 항상 본문이 있었음
8cd4e3b Stop()이 디스커버리 파일을 무조건 삭제 → MCP를 켠 적 없는 엔진이 살아 있는 서버의 엔드포인트를 지움 테스트는 인스턴스를 하나만 띄움. 같은 홈을 공유하는 두 번째 프로세스가 종료될 때만 발생

세 번째는 Phase 4의 브리지가 이 파일을 읽으므로 stdio 클라이언트가 끊기는 문제였습니다.

검증

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

테스트가 실제 HTTP 엔드포인트를 외부 클라이언트처럼 구동합니다 — initializetools/listtools/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.changed UI 갱신)이 이 브랜치 위에 쌓입니다.

🤖 Generated with Claude Code

devlikebear and others added 16 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>
devlikebear and others added 4 commits August 23, 2026 19:01
…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-phase1 to main August 23, 2026 10:44
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>
@sonarqubecloud

Copy link
Copy Markdown

@devlikebear
devlikebear merged commit 53324bd 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