Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
42e0a6a
docs: add MCP-first pivot design spec and implementation plan
devlikebear Aug 22, 2026
3dcd45c
refactor(engine): extract storycontext from internal/ai
devlikebear Aug 22, 2026
bbf249e
refactor(engine): extract storyops applier from internal/companion
devlikebear Aug 22, 2026
89fa1e0
feat(engine): merge facts, memories, and references into the story brief
devlikebear Aug 22, 2026
4fb67a1
refactor(engine): isolate the summarizer's LLM surface in one file
devlikebear Aug 22, 2026
505e5f2
docs: mark MCP pivot Phase 1 complete in the implementation plan
devlikebear Aug 22, 2026
b4ee65f
chore: gate the story core against LLM dependencies in make test-go
devlikebear Aug 22, 2026
7e32a97
docs: lock in Phase 0 decisions for the MCP-first pivot
devlikebear Aug 23, 2026
1e0a9c7
feat(engine): add MCP settings keys and secret-stored bearer token
devlikebear Aug 23, 2026
c604964
feat(engine): add the loopback MCP host with bearer auth and discovery
devlikebear Aug 23, 2026
e2d3d4d
feat(engine): wire the MCP host into engineapp behind a build-tag pair
devlikebear Aug 23, 2026
ecaf07e
feat(engine): serve the nine MCP read tools with an audit trail
devlikebear Aug 23, 2026
8fe8eb6
docs: mark MCP pivot Phase 2 implementation complete
devlikebear Aug 23, 2026
f700dbf
fix(settings): persist and reload MCP settings
devlikebear Aug 23, 2026
a8ba48e
fix(mcp): return an empty body for an untouched scene
devlikebear Aug 23, 2026
8cd4e3b
fix(mcp): only the owning host retracts the discovery file
devlikebear Aug 23, 2026
cac84ed
docs: correct the Phase 3 undo contract before building the write tools
devlikebear Aug 23, 2026
bbd64bd
feat(engine): add the MCP scene and summary write tools
devlikebear Aug 23, 2026
7b974cf
feat(engine): add the MCP batch, checkpoint, and undo tools
devlikebear Aug 23, 2026
998ffba
feat: cap MCP tool call rate and refresh the UI on agent changes
devlikebear Aug 23, 2026
575a7c0
feat(engine): add linetta_revise_scene for targeted text edits
devlikebear Aug 23, 2026
a6f3593
docs: mark MCP pivot Phase 3 complete
devlikebear Aug 23, 2026
2100cb8
fix(settings): keep MCP usable on platforms without a secure secret s…
devlikebear Aug 23, 2026
5c9d93c
Merge Phase 2 Linux secret-store fix into Phase 3
devlikebear Aug 23, 2026
793a031
refactor(engine): share the plaintext helpers instead of duplicating …
devlikebear Aug 23, 2026
903c186
Merge Phase 1 helper dedup into Phase 2
devlikebear Aug 23, 2026
030d31e
Merge Phase 1 helper dedup into Phase 3
devlikebear Aug 23, 2026
e6c91bd
test(mcp): wait for the OS to release the port instead of asserting i…
devlikebear Aug 23, 2026
c93d435
Merge Phase 2 port-release test fix into Phase 3
devlikebear Aug 23, 2026
75c6edd
Merge main into Phase 3 after the Phase 2 squash merge
devlikebear Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apps/desktop/src-tauri/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -230,6 +230,9 @@ fn notification_event(method: &str) -> Option<&'static str> {
"companion.preview" => Some("companion-preview"),
"companion.thinking" => Some("companion-thinking"),
"companion.reasoning" => Some("companion-reasoning"),
// An external MCP client changed the manuscript. Without this the
// writer would keep looking at text the agent already replaced.
"mcp.changed" => Some("mcp-changed"),
_ => None,
}
}
Expand Down Expand Up @@ -395,6 +398,14 @@ mod tests {
assert!(error.request_id.is_some());
}

#[test]
fn mcp_changed_is_forwarded_to_the_renderer() {
// A notification the renderer never receives is a writer staring at
// text an external agent already replaced.
assert_eq!(notification_event("mcp.changed"), Some("mcp-changed"));
assert_eq!(notification_event("mcp.unmapped"), None);
}

#[cfg(target_os = "windows")]
#[test]
fn windows_engine_dll_is_discoverable() {
Expand Down
90 changes: 90 additions & 0 deletions apps/desktop/src/hooks/useMcpChanges.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { act, renderHook } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";

const ev = vi.hoisted(() => ({
listeners: new Map<string, (e: { payload: unknown }) => void>(),
}));

vi.mock("@tauri-apps/api/event", () => ({
listen: (event: string, cb: (e: { payload: unknown }) => void) => {
ev.listeners.set(event, cb);
return Promise.resolve(() => ev.listeners.delete(event));
},
}));

import { useMcpChanges, type McpChangedPayload } from "./useMcpChanges";

async function emit(payload: McpChangedPayload) {
const cb = ev.listeners.get("mcp-changed");
if (!cb) throw new Error("mcp-changed listener was never registered");
await act(async () => {
cb({ payload });
});
}

function setup(overrides: Partial<Parameters<typeof useMcpChanges>[0]> = {}) {
const onOutlineChanged = vi.fn();
const onSceneChanged = vi.fn();
const view = renderHook(() =>
useMcpChanges({
projectId: "p1",
openNodeId: "n1",
editorDirty: false,
onOutlineChanged,
onSceneChanged,
...overrides,
}),
);
return { view, onOutlineChanged, onSceneChanged };
}

describe("useMcpChanges", () => {
beforeEach(() => {
ev.listeners.clear();
});

it("refetches the outline and reloads the open scene when the buffer is clean", async () => {
const { onOutlineChanged, onSceneChanged } = setup();
await emit({ project_id: "p1", tool: "linetta_write_scene", node_ids: ["n1"] });

expect(onOutlineChanged).toHaveBeenCalledTimes(1);
expect(onSceneChanged).toHaveBeenCalledWith("n1");
});

it("never replaces a dirty buffer; it surfaces the change instead", async () => {
const { view, onOutlineChanged, onSceneChanged } = setup({ editorDirty: true });
await emit({ project_id: "p1", tool: "linetta_write_scene", node_ids: ["n1"] });

// The outline is still safe to refresh — it is not what the writer is typing into.
expect(onOutlineChanged).toHaveBeenCalledTimes(1);
expect(onSceneChanged).not.toHaveBeenCalled();
expect(view.result.current.conflictNodeId).toBe("n1");

act(() => view.result.current.dismissConflict());
expect(view.result.current.conflictNodeId).toBeNull();
});

it("ignores a change to a scene the writer does not have open", async () => {
const { onOutlineChanged, onSceneChanged } = setup();
await emit({ project_id: "p1", tool: "linetta_write_scene", node_ids: ["other"] });

expect(onOutlineChanged).toHaveBeenCalledTimes(1);
expect(onSceneChanged).not.toHaveBeenCalled();
});

it("ignores changes to another work entirely", async () => {
const { onOutlineChanged, onSceneChanged } = setup();
await emit({ project_id: "p2", tool: "linetta_write_scene", node_ids: ["n1"] });

expect(onOutlineChanged).not.toHaveBeenCalled();
expect(onSceneChanged).not.toHaveBeenCalled();
});

it("refetches the outline for a structural batch that names no scenes", async () => {
const { onOutlineChanged, onSceneChanged } = setup();
await emit({ project_id: "p1", tool: "linetta_apply_story_ops", batch_id: "b1" });

expect(onOutlineChanged).toHaveBeenCalledTimes(1);
expect(onSceneChanged).not.toHaveBeenCalled();
});
});
64 changes: 64 additions & 0 deletions apps/desktop/src/hooks/useMcpChanges.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useCallback, useState } from "react";

import { useEngineEvent } from "./useEngineEvent";

/** What an external MCP client just changed. Emitted by the engine after every
* applied mutation so the workspace can refetch instead of showing text the
* agent already replaced. */
export type McpChangedPayload = {
project_id?: string;
tool?: string;
node_ids?: string[];
batch_id?: string;
};

export type McpChangeOptions = {
/** The work currently open, so changes to other works are ignored. */
projectId: string | null;
/** The scene currently open in the editor, or null. */
openNodeId: string | null;
/** Whether the editor holds unsaved edits for the open scene. */
editorDirty: boolean;
/** Refetch the outline tree. */
onOutlineChanged: () => void;
/** Reload the open scene's body from the engine. */
onSceneChanged: (nodeId: string) => void;
};

/** Keeps the workspace in step with an external agent.
*
* The one rule that matters: when the editor has unsaved edits for the scene
* the agent touched, the buffer is NOT replaced. The writer's in-progress
* sentence outranks the agent's version, so the change is surfaced as a
* banner they can act on instead. */
export function useMcpChanges({
projectId,
openNodeId,
editorDirty,
onOutlineChanged,
onSceneChanged,
}: McpChangeOptions) {
const [conflictNodeId, setConflictNodeId] = useState<string | null>(null);

const dismissConflict = useCallback(() => setConflictNodeId(null), []);

useEngineEvent<McpChangedPayload>("mcp-changed", (payload) => {
// A change to a work the writer is not looking at needs no UI reaction.
if (payload.project_id && projectId && payload.project_id !== projectId) {
return;
}
onOutlineChanged();

const touched = payload.node_ids ?? [];
if (!openNodeId || !touched.includes(openNodeId)) {
return;
}
if (editorDirty) {
setConflictNodeId(openNodeId);
return;
}
onSceneChanged(openNodeId);
});

return { conflictNodeId, dismissConflict };
}
56 changes: 35 additions & 21 deletions docs/superpowers/plans/2026-08-22-mcp-first-pivot.md
Original file line number Diff line number Diff line change
Expand Up @@ -156,53 +156,67 @@

**파일:** `engine/internal/mcphost/tools_write.go`, `+ 테스트`

- [ ] 쓰기 전 스냅샷 저장소로 자동 스냅샷을 만들고 `nodes.UpdateContentIfVersion`을 호출한다.
- [ ] `ErrContentConflict`는 "씬을 다시 읽고 최신 `content_version`으로 재시도하라"는 문구의 툴 에러가 된다.
- [ ] 크기 상한을 넘는 본문은 명확한 메시지로 거부한다.
- [ ] 테스트: 정상 경로가 쓰기와 스냅샷을 남김, 낡은 버전 → 충돌 에러이며 DB 불변, 초과 크기 → 거부.
- [x] 쓰기 전 스냅샷 저장소로 자동 스냅샷을 만들고 `nodes.UpdateContentIfVersion`을 호출한다.
- [x] `ErrContentConflict`는 "씬을 다시 읽고 최신 `content_version`으로 재시도하라"는 문구의 툴 에러가 된다.
- [x] 크기 상한을 넘는 본문은 명확한 메시지로 거부한다.
- [x] 테스트: 정상 경로가 쓰기와 스냅샷을 남김, 낡은 버전 → 충돌 에러이며 DB 불변, 초과 크기 → 거부.

### Task 3.2 — `linetta_revise_scene`

- [ ] `manuscriptedit`의 미리보기 + 적용을 감싸 씬 전체를 재전송하지 않고 부분 수정한다.
- [ ] 테스트: 정확한 범위에 적용되고 스냅샷이 남음, 일치 항목 없음은 쓸모 있는 에러를 반환.
- [x] `manuscriptedit`의 미리보기 + 적용을 감싸 씬 전체를 재전송하지 않고 부분 수정한다.
- [x] 테스트: 정확한 범위에 적용되고 스냅샷이 남음, 일치 항목 없음은 쓸모 있는 에러를 반환.

### Task 3.3 — `linetta_apply_story_ops`

- [ ] 기존 `Proposal` 옵 어휘를 받아 `storyops.ApplyOps`를 그대로 호출한다.
- [ ] `batch_id`, 생성된 id, 옵별 실패를 컴패니언 결과와 동일한 형태로 반환한다.
- [ ] 테스트: 아웃라인 배치가 적용되고 되돌릴 수 있음, 잘못된 옵은 배치를 실패시키고 아웃라인을 복원함.
- [x] 기존 `Proposal` 옵 어휘를 받아 `storyops.ApplyOps`를 그대로 호출한다.
- [x] **`set_scene_text` 옵은 거부하고 `linetta_write_scene`으로 안내한다.** 적용기의 `set_scene_text`는 `nodes.UpdateContent`(무조건 덮어쓰기)를 쓰므로, 이 툴로 통과시키면 `write_scene`의 버전 검사 계약을 우회하게 된다. 변경 종류마다 문은 하나여야 한다.
- [x] `batch_id`, 생성된 id, 옵별 실패를 컴패니언 결과와 동일한 형태로 반환한다.
- [x] 테스트: 아웃라인 배치가 적용되고 되돌릴 수 있음, 잘못된 옵은 배치를 실패시키고 아웃라인을 복원함.

### Task 3.4 — `linetta_write_summary`

**전환의 급소다.** 설계 문서 6절 참조.

- [ ] 대상을 셋 받는다: 씬(leaf) 요약, 컨테이너(부/장) 요약 — 계층 컨텍스트의 재료 — 그리고 작품 시놉시스(`project.Update`의 `Synopsis` 경유).
- [ ] 노드 요약은 에이전트가 읽은 시점의 `content_version`을 인자로 받아 `nodes.SetSummary(id, summary, contentVersion)`에 그대로 넘긴다. 이 낡음 감지는 **씬(leaf) 전용이다** — 컨테이너는 자식 편집을 추적하는 버전이 없다(기존 코드도 컨테이너에는 버전 0을 쓴다). 컨테이너 요약의 버전 의미는 구현 시 확정한다.
- [ ] 테스트: 요약 저장 후 `SummaryForVersion == ContentVersion`, 이후 사람이 본문을 고치면 요약이 다시 낡은 것으로 표시됨, 낡은 `content_version`으로 온 요약은 거부됨, 시놉시스가 저장됨.
- [x] 대상을 셋 받는다: 씬(leaf) 요약, 컨테이너(부/장) 요약 — 계층 컨텍스트의 재료 — 그리고 작품 시놉시스(`project.Update`의 `Synopsis` 경유).
- [x] **버전 계약 확정:** 씬(leaf)만 `content_version`을 요구한다. 컨테이너와 시놉시스는 자식 편집을 추적하는 버전이 없으므로 요구하지 않고 마지막 쓰기가 이긴다 — 툴 설명에 명시한다.
- [x] 노드 요약은 에이전트가 읽은 시점의 `content_version`을 인자로 받아 `nodes.SetSummary(id, summary, contentVersion)`에 그대로 넘긴다. 이 낡음 감지는 **씬(leaf) 전용이다** — 컨테이너는 자식 편집을 추적하는 버전이 없다(기존 코드도 컨테이너에는 버전 0을 쓴다). 컨테이너 요약의 버전 의미는 구현 시 확정한다.
- [x] 테스트: 요약 저장 후 `SummaryForVersion == ContentVersion`, 이후 사람이 본문을 고치면 요약이 다시 낡은 것으로 표시됨, 낡은 `content_version`으로 온 요약은 거부됨, 시놉시스가 저장됨.

### Task 3.5 — 체크포인트와 되돌리기

- [ ] `linetta_create_checkpoint`는 에이전트가 준 라벨로 `snapshots.create_manual`을 감싼다.
- [ ] `linetta_undo_last_change`는 `storyops.UndoApply`를 감싸고, 만료된 배치는 "되돌리기 기간이 지났습니다"라는 평이한 메시지를 반환한다.
- [x] `linetta_create_checkpoint`는 에이전트가 준 라벨로 `snapshots.create_manual`을 감싼다.
- [x] `linetta_undo_last_change`는 `batch_id`(구조 변경)와 `snapshot_id`(본문 변경) 두 가지를 받는다. 만료된 배치는 "되돌리기 기간이 지났습니다"라는 평이한 메시지를 반환한다.

> **구현 중 확정 (계획 정정):** `storyops.UndoApply` → `nodes.RestoreOutline`은 `parent_id·ordinal·label·title·status`만 되돌리고 **`content_doc`은 건드리지 않는다**(삭제됐다가 복원되는 노드만 예외). 즉 구조 변경 undo는 씬 본문을 되돌리지 못한다. 본문 되돌리기는 스냅샷(버전 기록) 경로다. 툴이 이 차이를 감추면 에이전트에게 거짓 약속을 하게 되므로, `linetta_write_scene`은 결과에 `snapshot_id`를 실어 보내고 `linetta_undo_last_change`가 두 종류를 모두 받는다.

### Task 3.6 — 호출 한도와 모드 강제

- [ ] 분당 호출 상한과 호출당 본문 상한을 기본값이 있는 상수로 둔다.
- [ ] `read_only` 모드는 3.1~3.5의 어떤 툴도 등록하지 않는다. 모드별 `tools/list` 길이를 테스트로 못 박는다.
- [x] 분당 호출 상한과 호출당 본문 상한을 기본값이 있는 상수로 둔다.
- [x] `read_only` 모드는 3.1~3.5의 어떤 툴도 등록하지 않는다. 모드별 `tools/list` 길이를 테스트로 못 박는다.

### Task 3.7 — 변경 알림

**파일:** `engine/internal/mcphost/*`, `apps/desktop/src-tauri/src/ffi.rs`, `apps/desktop/src/hooks/*`

- [ ] 적용된 모든 변경 후 `mcp.changed`(`{project_id, node_ids, tool, batch_id}`)를 발신한다.
- [ ] `notification_event`에 `"mcp.changed" => Some("mcp-changed")`를 추가한다.
- [ ] 프론트엔드 리스너가 아웃라인 트리를 다시 가져오고, 열려 있는 씬이 영향을 받았고 편집 버퍼가 깨끗하면 본문도 갱신한다. **버퍼가 더러우면 덮어쓰지 않고 "에이전트가 이 씬을 변경했습니다" 배너를 띄운다.**
- [ ] 테스트: 매핑에 대한 Rust 단위 테스트, 깨끗/더러움 분기에 대한 Vitest.
- [x] 적용된 모든 변경 후 `mcp.changed`(`{project_id, node_ids, tool, batch_id}`)를 발신한다.
- [x] `notification_event`에 `"mcp.changed" => Some("mcp-changed")`를 추가한다.
- [x] 프론트엔드 리스너가 아웃라인 트리를 다시 가져오고, 열려 있는 씬이 영향을 받았고 편집 버퍼가 깨끗하면 본문도 갱신한다. **버퍼가 더러우면 덮어쓰지 않고 "에이전트가 이 씬을 변경했습니다" 배너를 띄운다.**
- [x] 테스트: 매핑에 대한 Rust 단위 테스트, 깨끗/더러움 분기에 대한 Vitest.

- [x] 씬 쓰기 전 스냅샷은 당분간 `snapshot.ReasonCompanionBefore`를 재사용한다. 새 reason을 추가하려면 `ValidReason`과 프론트엔드 버전 시트 라벨을 함께 손봐야 하고, 컴패니언이 Phase 6에서 사라지면 이 reason은 사실상 "에이전트 변경 전"이 된다. 전용 reason 도입은 Phase 4의 UI 작업과 함께 판단한다.

**3단계 종료 조건:** 인메모리 종단 테스트가 `initialize` → `tools/call linetta_write_scene` → `tools/call linetta_undo_last_change`를 구동하고 원고가 원래 바이트로 돌아온다.
**3단계 종료 조건:** 인메모리 종단 테스트가 `initialize` → `tools/call linetta_write_scene` → `tools/call linetta_undo_last_change`(반환된 `snapshot_id`로)를 구동하고 원고가 원래 바이트로 돌아온다. 구조 변경은 `linetta_apply_story_ops` → `undo_last_change`(`batch_id`)로 별도 검증한다.

---

> **완료 (2026-08-23):** 툴 15개 완성(읽기 9 + 쓰기 6). `make test` 전체 통과, 엔진 42/42, `mas`/`mobile` 빌드 통과, mobile SDK 의존 0건.
>
> **계획에서 이탈한 두 가지:**
> - `expected_content_version`을 `int`가 아니라 `*int`로 받는다. 새 씬은 버전이 0이라 "미제공"과 "0"을 구분하지 못하면 **첫 원고를 영영 쓸 수 없다.** 테스트가 잡았다.
> - 씬 쓰기 전 스냅샷 reason은 `companion-before`를 재사용했다(위 결정 참조).
>
> **아직 남은 것:** `linetta_where_does_appear`는 명시적 @멘션만 집계하므로, 에이전트가 쓴 평문 원고에서는 등장인물 추적이 비게 된다. 이슈 #32와 같은 문제이고 Phase 3 이후 별도로 다룬다.

## Phase 4 — 브리지, 설정 UX, MAS

### Task 4.1 — `cmd/linetta-mcp` 브리지
Expand Down
15 changes: 15 additions & 0 deletions engine/internal/engineapp/engineapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import (
"github.com/devlikebear/linetta/engine/internal/stats"
"github.com/devlikebear/linetta/engine/internal/store"
"github.com/devlikebear/linetta/engine/internal/storycontext"
"github.com/devlikebear/linetta/engine/internal/storyops"
"github.com/devlikebear/linetta/engine/internal/summarizer"
"github.com/devlikebear/linetta/engine/internal/thread"
)
Expand Down Expand Up @@ -224,6 +225,14 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error
// The tool layer gets its OWN context builder, wired with the fact,
// memory, and reference sources. The builder above stays untouched so
// ai.run and ai.preview_context keep producing byte-identical prompts.
// A storyops instance of its own: undo batches live in memory on the
// service, so an agent can undo only what it applied — never the writer's
// own companion batch.
mcpStory := storyops.New(projects, nodes, threads, beats, entities, relationships).
WithFacts(facts).
WithSnapshots(snaps).
WithMemory(companionSvc)

mcpContextBuilder := storycontext.NewContextBuilder(projects, nodes, mentions, threads, beats, notes, relationships).
WithSummaryRefresher(summ).
WithFactSource(companionSvc).
Expand All @@ -242,6 +251,12 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error
plot: plotBuilder,
manuscript: manuscriptSearcher,
context: mcpContextBuilder,
snapshots: snaps,
story: mcpStory,
msEdit: manuscriptEditor,
enqueue: summ.Enqueue,
notify: func(method string, params any) { _ = s.Notifier().Notify(method, params) },
clock: clock,
db: st.DB(),
},
})
Expand Down
Loading
Loading