diff --git a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md index bd381e36..918a4ae8 100644 --- a/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md +++ b/docs/superpowers/plans/2026-08-22-mcp-first-pivot.md @@ -92,60 +92,64 @@ **파일:** `engine/go.mod`, `engine/go.sum` -- [ ] `cd engine && go get github.com/modelcontextprotocol/go-sdk@v1.7.0` -- [ ] `go build -tags mas ./...`가 SDK를 **링크하는지** 확인한다(MAS도 MCP를 쓴다). -- [ ] `go test -tags mobile ./...`는 SDK를 링크하지 않아야 한다. `go list -deps -tags mobile ./... | grep modelcontextprotocol`이 비어야 한다. +- [x] `cd engine && go get github.com/modelcontextprotocol/go-sdk@v1.7.0` +- [x] `go build -tags mas ./...`가 SDK를 **링크하는지** 확인한다(MAS도 MCP를 쓴다). +- [x] `go test -tags mobile ./...`는 SDK를 링크하지 않아야 한다. `go list -deps -tags mobile ./... | grep modelcontextprotocol`이 비어야 한다. **확인: 0건.** ### Task 2.2 — 설정 키와 시크릿 토큰 **파일:** `engine/internal/settings/settings.go`, `secrets.go`, `+ 테스트` -- [ ] `MCPMode`(`off`|`read_only`|`full`, 기본 `off`), `MCPPort`(기본 `7391`), `MCPProjectID`, `MCPConsentVersion`, `MCPConsentedAt`를 `Settings`와 `SettingsPatch`에 추가한다. -- [ ] `MCPTokenSet bool`(읽기용 존재 플래그)과 시크릿 저장소를 통해 쓰는 `RegenerateMCPToken()`을 추가한다. 토큰 값 자체는 `settings.get`이 절대 반환하지 않는다 — `api_key` 처리 방식과 동일하다. -- [ ] 테스트: `settings.get`이 토큰을 가리고 존재 플래그만 노출한다. 모드가 왕복한다. 알 수 없는 모드는 `off`로 떨어진다. +- [x] `MCPMode`(`off`|`read_only`|`full`, 기본 `off`), `MCPPort`(기본 `7391`), `MCPProjectID`, `MCPConsentVersion`, `MCPConsentedAt`를 `Settings`와 `SettingsPatch`에 추가한다. +- [x] `MCPTokenSet bool`(읽기용 존재 플래그)과 시크릿 저장소를 통해 쓰는 `RegenerateMCPToken()`을 추가한다. 토큰 값 자체는 `settings.get`이 절대 반환하지 않는다 — `api_key` 처리 방식과 동일하다. +- [x] 테스트: `settings.get`이 토큰을 가리고 존재 플래그만 노출한다. 모드가 왕복한다. 알 수 없는 모드는 `off`로 떨어진다. ### Task 2.3 — `mcphost` 골격, 인증, 수명 주기 **파일:** `engine/internal/mcphost/host.go`, `auth.go`, `discovery.go`, `+ 테스트` -- [ ] `mcphost.New(deps)`가 `*mcp.Server`와 `http.Server`를 만들고 설정된 포트로 `net.Listen("tcp", "127.0.0.1:"+port)` 한다. 저장된 클라이언트 설정이 재시작을 견디도록 포트는 고정이다. -- [ ] 포트가 이미 사용 중이면 설정 화면이 "7391 포트가 사용 중입니다 — 다른 포트를 선택하세요"로 렌더링할 수 있는 타입 에러를 반환한다. **다른 포트로 조용히 넘어가지 않는다.** -- [ ] 인증 미들웨어: 상수 시간 베어러 비교, `Origin`이 있는데 루프백이 아니면 거부, `Host`가 루프백이 아니면 거부. -- [ ] `Start()`가 `$LINETTA_HOME/mcp.json`(권한 0600, `{port, token, pid, started_at}`)을 쓰고, `Stop()`이 삭제하며 리스너를 내린다. 설정 파일 `settings.json`과는 별개 파일이다. -- [ ] 테스트: 토큰 없음 → 401, 토큰 틀림 → 401, `Origin: https://evil.test` → 403, 포트 점유 → 타입 에러, POSIX에서 디스커버리 파일 권한 0600, `Stop` 후 파일 삭제. +- [x] `mcphost.New(deps)`가 `*mcp.Server`와 `http.Server`를 만들고 설정된 포트로 `net.Listen("tcp", "127.0.0.1:"+port)` 한다. 저장된 클라이언트 설정이 재시작을 견디도록 포트는 고정이다. +- [x] 포트가 이미 사용 중이면 설정 화면이 "7391 포트가 사용 중입니다 — 다른 포트를 선택하세요"로 렌더링할 수 있는 타입 에러를 반환한다. **다른 포트로 조용히 넘어가지 않는다.** +- [x] 인증 미들웨어: 상수 시간 베어러 비교, `Origin`이 있는데 루프백이 아니면 거부, `Host`가 루프백이 아니면 거부. +- [x] `Start()`가 `$LINETTA_HOME/mcp.json`(권한 0600, `{port, token, pid, started_at}`)을 쓰고, `Stop()`이 삭제하며 리스너를 내린다. 설정 파일 `settings.json`과는 별개 파일이다. +- [x] 테스트: 토큰 없음 → 401, 토큰 틀림 → 401, `Origin: https://evil.test` → 403, 포트 점유 → 타입 에러, POSIX에서 디스커버리 파일 권한 0600, `Stop` 후 파일 삭제. ### Task 2.4 — `engineapp` 연결 **파일:** `engine/internal/engineapp/mcp_enabled.go`(`//go:build !mobile`), `mcp_disabled.go`(`//go:build mobile`), `engineapp.go`, `+ 테스트` -- [ ] `gitsync_enabled.go` / `gitsync_disabled.go` 패턴을 그대로 따른다: `const mcpAvailable`, `setupMCP(deps) mcpController`. -- [ ] RPC `mcp.status`, `mcp.enable`, `mcp.disable`, `mcp.regenerate_token`, `mcp.activity`를 등록한다. 비활성 쌍둥이는 `CodeMethodNotFound`를 반환한다. -- [ ] 호스트의 `Stop`을 `a.closers`에 넣어 앱과 함께 리스너가 죽게 한다. -- [ ] `handlers.Capabilities`에 `MCPAvailable`을 추가하고 `diagnostics.version` / `diagnostics.get`으로 노출한다. -- [ ] 테스트: 모드 `off`면 아무것도 바인딩하지 않음, `mcp.enable` 후 `mcp.status`가 포트를 보고함, `Close()`가 포트를 반납함. +- [x] `gitsync_enabled.go` / `gitsync_disabled.go` 패턴을 그대로 따른다: `const mcpAvailable`, `setupMCP(deps) mcpController`. +- [x] RPC `mcp.status`, `mcp.enable`, `mcp.disable`, `mcp.regenerate_token`, `mcp.activity`를 등록한다. 비활성 쌍둥이는 `CodeMethodNotFound`를 반환한다. +- [x] 호스트의 `Stop`을 `a.closers`에 넣어 앱과 함께 리스너가 죽게 한다. +- [x] `handlers.Capabilities`에 `MCPAvailable`을 추가하고 `diagnostics.version` / `diagnostics.get`으로 노출한다. +- [x] 테스트: 모드 `off`면 아무것도 바인딩하지 않음, `mcp.enable` 후 `mcp.status`가 포트를 보고함, `Close()`가 포트를 반납함. ### Task 2.5 — 읽기 툴 9개 **파일:** `engine/internal/mcphost/tools_read.go`, `+ 테스트` -- [ ] 설계 문서 5절의 읽기 툴 9개를 `mcp.AddTool`로 등록한다. 입출력을 타입 구조체로 선언해 스키마가 생성되게 한다. -- [ ] `linetta_get_story_context`는 병합된 `storycontext` 빌더(Task 1.3 완료가 전제)로 브리프를 조립하고, 평문 렌더러로 마크다운을 만들어 "무엇이 포함됐는지" 요약과 함께 반환한다. -- [ ] `linetta_read_scene`은 `content_version`을 반환하고, 설명에 쓰기에는 이 값이 필요하다고 명시한다. -- [ ] `MCPProjectID` 범위 제한은 툴마다가 아니라 공용 헬퍼 한 곳에서 강제한다. -- [ ] 테스트: 씨드된 임시 스토어로 각 툴 검증, 범위 밖 `project_id` 차단, `read_only` 모드에서 정확히 이 9개만 등록됨, **LLM 프로바이더가 설정되지 않은 상태에서 `linetta_get_story_context`가 요약만 빈 채 팩트·메모리를 포함한 완전한 브리프를 에러 없이 반환함**(전환의 전제가 이 테스트에 달려 있다). +- [x] 설계 문서 5절의 읽기 툴 9개를 `mcp.AddTool`로 등록한다. 입출력을 타입 구조체로 선언해 스키마가 생성되게 한다. +- [x] `linetta_get_story_context`는 병합된 `storycontext` 빌더(Task 1.3 완료가 전제)로 브리프를 조립하고, 평문 렌더러로 마크다운을 만들어 "무엇이 포함됐는지" 요약과 함께 반환한다. +- [x] `linetta_read_scene`은 `content_version`을 반환하고, 설명에 쓰기에는 이 값이 필요하다고 명시한다. +- [x] `MCPProjectID` 범위 제한은 툴마다가 아니라 공용 헬퍼 한 곳에서 강제한다. +- [x] 테스트: 씨드된 임시 스토어로 각 툴 검증, 범위 밖 `project_id` 차단, `read_only` 모드에서 정확히 이 9개만 등록됨, **LLM 프로바이더가 설정되지 않은 상태에서 `linetta_get_story_context`가 요약만 빈 채 팩트·메모리를 포함한 완전한 브리프를 에러 없이 반환함**(전환의 전제가 이 테스트에 달려 있다). ### Task 2.6 — 활동 로그 **파일:** `engine/internal/store/migrations/*`, `engine/internal/mcphost/activity.go`, `+ 테스트` -- [ ] `mcp_activity` 테이블 마이그레이션(`id, at, tool, project_id, target_id, ok, detail`). -- [ ] 성공·실패 관계없이 모든 툴 호출을 기록하고, 기존 스냅샷 정리 잡에 보존 한도를 얹는다. -- [ ] `mcp.activity` RPC가 최근 기록을 반환한다. +- [x] `mcp_activity` 테이블 마이그레이션(`id, at, tool, project_id, target_id, ok, detail`). +- [x] 성공·실패 관계없이 모든 툴 호출을 기록한다. **계획에서 이탈:** 보존 한도를 스냅샷 정리 잡에 얹지 않고 삽입 직후 자체 트리밍(500행)으로 처리했다 — 움직이는 부품이 하나 줄고, 앱이 유휴 상태가 되지 않아도 상한이 지켜진다. +- [x] `mcp.activity` RPC가 최근 기록을 반환한다. **2단계 종료 조건:** `claude mcp add --transport http linetta http://127.0.0.1:7391/mcp --header "Authorization: Bearer "`로 연결되고, 실제 Claude Code 세션이 작품 구조를 설명할 수 있다. --- +> **완료 (2026-08-22):** 엔진 42개 패키지 통과, `mas`/`mobile` 빌드 통과, mobile의 SDK 의존 0건. 테스트가 실제 HTTP 엔드포인트를 외부 클라이언트처럼 구동한다(initialize → tools/list → tools/call, SSE 파싱). 툴 등록은 활동 로그 데코레이터로 감싸 Task 2.6을 같이 끝냈고, 범위 제한은 씬 id 우회까지 막는 것을 확인했다. +> +> **남은 종료 조건:** 실제 Claude Code에서 `claude mcp add`로 붙여 작품 구조를 읽는 왕복 — 사용자 기기에서 직접 해야 하는 단계다. + ## Phase 3 — 쓰기 툴과 안전장치 ### Task 3.1 — `linetta_write_scene` diff --git a/engine/go.mod b/engine/go.mod index f95bcea7..fab8a619 100644 --- a/engine/go.mod +++ b/engine/go.mod @@ -5,6 +5,7 @@ go 1.26.6 require ( github.com/devlikebear/tars v0.34.3 github.com/google/uuid v1.6.0 + github.com/modelcontextprotocol/go-sdk v1.7.0 golang.org/x/sys v0.44.0 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.50.1 @@ -12,12 +13,19 @@ require ( require ( github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.22 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rs/zerolog v1.33.0 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/time v0.15.0 // indirect modernc.org/libc v1.72.3 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/engine/go.sum b/engine/go.sum index ee593464..01d52f3d 100644 --- a/engine/go.sum +++ b/engine/go.sum @@ -4,6 +4,12 @@ github.com/devlikebear/tars v0.34.3/go.mod h1:VO3aJQ+y1ou9pWuhU76AbAOOR8MEb4XWYV github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -16,6 +22,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= +github.com/modelcontextprotocol/go-sdk v1.7.0 h1:yqjY2dsbKAC0LSuWZVBMrHgiG8ukXv6NRo0JiALay44= +github.com/modelcontextprotocol/go-sdk v1.7.0/go.mod h1:dL7u98E/zjJTGzEq+j30jQ8K2k1mb6LeAH4inEcSGts= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -26,8 +34,16 @@ github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzG github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8= github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -35,6 +51,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/engine/internal/companion/context_sources.go b/engine/internal/companion/context_sources.go new file mode 100644 index 00000000..81dfc86f --- /dev/null +++ b/engine/internal/companion/context_sources.go @@ -0,0 +1,104 @@ +package companion + +import ( + "context" + "strings" + + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/storycontext" +) + +// referenceContextLimit matches gatherContext's own cap on injected references. +const referenceContextLimit = 40 + +// The companion is currently the only place that gathers Fact Book cards, +// remembered facts, and writer-attached references. storycontext grew optional +// source interfaces for those sections in Phase 1 of the MCP-first pivot (#47); +// these adapters connect the two so the MCP story brief carries everything the +// companion's own prompt does. +// +// When this package is removed (pivot Phase 6), these three methods move to +// whatever owns the underlying repos — the fact and reference repos are +// already independent, and memory recall follows the remember op into +// storyops. + +var ( + _ storycontext.FactSource = (*Service)(nil) + _ storycontext.MemorySource = (*Service)(nil) + _ storycontext.ReferenceSource = (*Service)(nil) +) + +// ContextFacts returns Fact Book cards for the brief, preferring cards +// attached to the current scene, exactly as gatherContext does. +func (s *Service) ContextFacts(ctx context.Context, projectID, nodeID string) ([]storycontext.FactBrief, error) { + if s.facts == nil { + return nil, nil + } + filter := fact.ListFilter{ProjectID: projectID, Limit: factContextLimit} + if strings.TrimSpace(nodeID) != "" { + filter.NodeID = &nodeID + } + cards, err := s.facts.List(ctx, filter) + if err != nil { + return nil, err + } + out := make([]storycontext.FactBrief, 0, len(cards)) + for _, c := range cards { + brief := storycontext.FactBrief{ + ID: c.ID, + Status: c.Status, + Claim: c.Claim, + Category: c.Category, + Result: c.Result, + } + for _, src := range c.Sources { + if strings.TrimSpace(src.URL) == "" { + continue + } + brief.Sources = append(brief.Sources, storycontext.FactSourceBrief{ + Title: src.Title, + URL: src.URL, + }) + } + out = append(out, brief) + } + return out, nil +} + +// ContextMemories returns recent remembered facts for the brief. +func (s *Service) ContextMemories(projectID string) []string { + return s.Recall(projectID, "", recallLimit) +} + +// ContextReferences returns the writer-attached material for the brief, +// skipping disabled entries and using the same prompt text (summary vs full +// content) the companion sends. +func (s *Service) ContextReferences(ctx context.Context, projectID, nodeID string) ([]storycontext.ReferenceBrief, error) { + if s.references == nil { + return nil, nil + } + refs, err := s.ListReferences(ctx, ReferenceQuery{ + ProjectID: projectID, + NodeID: nodeID, + Limit: referenceContextLimit, + }) + if err != nil { + return nil, err + } + out := make([]storycontext.ReferenceBrief, 0, len(refs)) + for _, r := range refs { + if r.Status == ReferenceStatusDisabled { + continue + } + text := strings.TrimSpace(referencePromptText(r)) + if text == "" { + continue + } + out = append(out, storycontext.ReferenceBrief{ + Title: strings.TrimSpace(r.Title), + Purpose: strings.TrimSpace(r.Purpose), + Body: text, + }) + } + return out, nil +} diff --git a/engine/internal/engineapp/engineapp.go b/engine/internal/engineapp/engineapp.go index 81553452..e90a9ea5 100644 --- a/engine/internal/engineapp/engineapp.go +++ b/engine/internal/engineapp/engineapp.go @@ -218,9 +218,39 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error WithManuscript(manuscriptSearcher). WithSnapshots(snaps) + // The MCP host serves story tools to external agents. It binds only when + // the writer has turned MCP on and accepted its consent. + // + // 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. + mcpContextBuilder := storycontext.NewContextBuilder(projects, nodes, mentions, threads, beats, notes, relationships). + WithSummaryRefresher(summ). + WithFactSource(companionSvc). + WithMemorySource(companionSvc). + WithReferenceSource(companionSvc) + + mcpCtrl, stopMCP := setupMCP(mcpDeps{ + settingsStore: settingsStore, + home: home, + repos: mcpToolRepos{ + projects: projects, + nodes: nodes, + entities: entities, + mentions: mentions, + facts: facts, + plot: plotBuilder, + manuscript: manuscriptSearcher, + context: mcpContextBuilder, + db: st.DB(), + }, + }) + a.closers = append(a.closers, stopMCP) + caps := handlers.Capabilities{ UnavailableProviders: ai.UnavailableProviders(), GitSyncAvailable: gitSyncAvailable, + MCPAvailable: mcpAvailable, } s.Handle("ping", handlers.Ping) s.Handle("diagnostics.version", handlers.DiagnosticsVersion(st, home, DefaultVersion, caps)) @@ -319,6 +349,11 @@ func (a *App) register(ctx context.Context, home string, st *store.Store) error s.Handle("companion.references.delete", handlers.CompanionReferencesDelete(companionSvc)) s.Handle("settings.get", handlers.GetSettings(settingsStore)) s.Handle("settings.set", handlers.SetSettings(settingsStore)) + s.Handle("mcp.status", handlers.MCPStatus(mcpCtrl)) + s.Handle("mcp.enable", handlers.MCPEnable(mcpCtrl)) + s.Handle("mcp.disable", handlers.MCPDisable(mcpCtrl)) + s.Handle("mcp.regenerate_token", handlers.MCPRegenerateToken(mcpCtrl)) + s.Handle("mcp.activity", handlers.MCPActivity(mcpCtrl)) openRouterOAuth := openrouter.NewOAuthManager(openrouter.OAuthConfig{}) s.Handle("providers.list_models", handlers.ListModels(settingsStore, modelcatalog.Default())) s.Handle("providers.detect_cli", handlers.DetectCLI()) diff --git a/engine/internal/engineapp/mcp_disabled.go b/engine/internal/engineapp/mcp_disabled.go new file mode 100644 index 00000000..d1edce9d --- /dev/null +++ b/engine/internal/engineapp/mcp_disabled.go @@ -0,0 +1,73 @@ +//go:build mobile + +package engineapp + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/mention" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/plot" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/storycontext" +) + +// Mobile builds cannot host a local server, so MCP is compiled out entirely — +// the SDK is never linked into the mobile engine. +const mcpAvailable = false + +type mcpDeps struct { + settingsStore *settings.Store + home string + repos mcpToolRepos +} + +// mcpToolRepos mirrors the enabled build's shape so register() compiles +// unchanged; mobile never reads these. +type mcpToolRepos struct { + projects *project.Repo + nodes *node.Repo + entities *entity.Repo + mentions *mention.Repo + facts *fact.Repo + plot *plot.Builder + manuscript *manuscript.Searcher + context *storycontext.ContextBuilder + db *sql.DB +} + +// mcpController answers status queries with a disabled state and refuses +// mutations, so the frontend gets a clear message rather than a missing method +// if it ever calls through on a build where the pane should be hidden. +type mcpController struct{} + +var errMCPUnavailable = errors.New("mcp is not available in this build") + +func setupMCP(mcpDeps) (*mcpController, func() error) { + return &mcpController{}, func() error { return nil } +} + +func (c *mcpController) Status() (json.RawMessage, error) { + return json.Marshal(struct { + Running bool `json:"running"` + Mode string `json:"mode"` + }{Running: false, Mode: "off"}) +} + +func (c *mcpController) Enable(context.Context) error { return errMCPUnavailable } +func (c *mcpController) Disable(context.Context) error { return nil } + +func (c *mcpController) RegenerateToken(context.Context) (json.RawMessage, error) { + return nil, errMCPUnavailable +} + +func (c *mcpController) Activity(context.Context, int) (json.RawMessage, error) { + return json.Marshal([]struct{}{}) +} diff --git a/engine/internal/engineapp/mcp_enabled.go b/engine/internal/engineapp/mcp_enabled.go new file mode 100644 index 00000000..20eaed43 --- /dev/null +++ b/engine/internal/engineapp/mcp_enabled.go @@ -0,0 +1,143 @@ +//go:build !mobile + +package engineapp + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + + "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/mcphost" + "github.com/devlikebear/linetta/engine/internal/mention" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/plot" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/rpc/handlers" + "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/storycontext" +) + +// MCP ships on desktop and on the Mac App Store. It is deliberately NOT gated +// on `mas` the way git sync is: once the companion is removed, MCP is the only +// AI path a MAS build has. Mobile cannot host a server, so it gets the +// disabled twin. +const mcpAvailable = true + +// mcpDeps is what setupMCP needs from register(). +type mcpDeps struct { + settingsStore *settings.Store + home string + repos mcpToolRepos +} + +// mcpToolRepos collects what the tool layer reads from. The context builder is +// a second instance wired with fact/memory/reference sources — the builder the +// AI runner uses stays untouched so its prompts do not change. +type mcpToolRepos struct { + projects *project.Repo + nodes *node.Repo + entities *entity.Repo + mentions *mention.Repo + facts *fact.Repo + plot *plot.Builder + manuscript *manuscript.Searcher + context *storycontext.ContextBuilder + db *sql.DB +} + +// mcpController adapts *mcphost.Host to handlers.MCPController, translating +// host errors into the sentinels the RPC layer turns into reason codes. +type mcpController struct { + host *mcphost.Host + set *settings.Store + activity *mcphost.ActivityRepo +} + +func setupMCP(deps mcpDeps) (*mcpController, func() error) { + activity := mcphost.NewActivityRepo(deps.repos.db) + tools := mcphost.ToolDeps{ + Projects: deps.repos.projects, + Nodes: deps.repos.nodes, + Entities: deps.repos.entities, + Mentions: deps.repos.mentions, + Facts: deps.repos.facts, + Plot: deps.repos.plot, + Manuscript: deps.repos.manuscript, + Context: deps.repos.context, + Settings: deps.settingsStore, + Activity: activity, + } + host := mcphost.New(mcphost.Deps{ + Settings: deps.settingsStore, + Home: deps.home, + Tools: tools.Register, + }) + ctrl := &mcpController{host: host, set: deps.settingsStore, activity: activity} + // Start honors the persisted mode: a writer who left MCP on finds it + // running after a restart, and mode off binds nothing. + if err := host.Start(context.Background()); err != nil { + fmt.Printf("mcp: start skipped: %v\n", err) + } + return ctrl, host.Stop +} + +func (c *mcpController) Status() (json.RawMessage, error) { + return json.Marshal(c.host.Status()) +} + +func (c *mcpController) Enable(ctx context.Context) error { + if err := c.host.Restart(ctx); err != nil { + return translateMCPError(err) + } + return nil +} + +func (c *mcpController) Disable(ctx context.Context) error { + return c.host.Stop() +} + +func (c *mcpController) RegenerateToken(ctx context.Context) (json.RawMessage, error) { + token, err := c.set.RegenerateMCPToken() + if err != nil { + return nil, err + } + // The listener holds the old token in memory and the discovery file still + // advertises it, so a running server must be cycled for the new token to + // take effect everywhere. + if c.host.Status().Running { + if err := c.host.Restart(ctx); err != nil { + return nil, translateMCPError(err) + } + } + return json.Marshal(struct { + Token string `json:"token"` + Status mcphost.Status `json:"status"` + }{Token: token, Status: c.host.Status()}) +} + +func (c *mcpController) Activity(ctx context.Context, limit int) (json.RawMessage, error) { + if c.activity == nil { + return json.Marshal([]mcphost.ActivityEntry{}) + } + entries, err := c.activity.List(ctx, limit) + if err != nil { + return nil, err + } + return json.Marshal(entries) +} + +func translateMCPError(err error) error { + switch { + case errors.Is(err, mcphost.ErrPortInUse): + return fmt.Errorf("%w: %v", handlers.ErrMCPPortInUse, err) + case errors.Is(err, mcphost.ErrConsentRequired): + return fmt.Errorf("%w: %v", handlers.ErrMCPConsentRequired, err) + default: + return err + } +} diff --git a/engine/internal/engineapp/mcp_tools_test.go b/engine/internal/engineapp/mcp_tools_test.go new file mode 100644 index 00000000..e8c863fc --- /dev/null +++ b/engine/internal/engineapp/mcp_tools_test.go @@ -0,0 +1,381 @@ +//go:build !mobile + +package engineapp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "net/http" + "sort" + "strings" + "testing" + + "github.com/devlikebear/linetta/engine/internal/mcphost" +) + +// mcpClient drives the live HTTP endpoint the way an external agent does: +// initialize, then real MCP calls. Responses come back as SSE, so each call +// reads the first data: line. +type mcpClient struct { + t *testing.T + url string + token string + sessionID string + nextID int +} + +func startMCPServer(t *testing.T) (*App, *mcpClient) { + t.Helper() + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + app, err := Open(context.Background(), Options{Home: home}) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = app.Close() }) + + port := freeTestPort(t) + patch := fmt.Sprintf( + `{"mcp_mode":"read_only","mcp_port":%d,"mcp_consent_version":1,"mcp_consented_at":1}`, port) + if _, rpcErr := call(t, app, "settings.set", patch); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + if _, rpcErr := call(t, app, "mcp.enable", ""); rpcErr != nil { + t.Fatalf("mcp.enable: %+v", rpcErr) + } + d, err := mcphost.ReadDiscoveryFile(home) + if err != nil { + t.Fatalf("ReadDiscoveryFile: %v", err) + } + c := &mcpClient{t: t, url: fmt.Sprintf("http://127.0.0.1:%d/mcp", d.Port), token: d.Token} + c.initialize() + return app, c +} + +func (c *mcpClient) rpc(method string, params any) map[string]any { + c.t.Helper() + c.nextID++ + payload := map[string]any{"jsonrpc": "2.0", "id": c.nextID, "method": method} + if params != nil { + payload["params"] = params + } + raw, err := json.Marshal(payload) + if err != nil { + c.t.Fatalf("marshal %s: %v", method, err) + } + req, err := http.NewRequest(http.MethodPost, c.url, strings.NewReader(string(raw))) + if err != nil { + c.t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + req.Header.Set("Authorization", "Bearer "+c.token) + if c.sessionID != "" { + req.Header.Set("Mcp-Session-Id", c.sessionID) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + c.t.Fatalf("%s: %v", method, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + c.t.Fatalf("%s: status %d", method, resp.StatusCode) + } + if id := resp.Header.Get("Mcp-Session-Id"); id != "" { + c.sessionID = id + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 8*1024*1024) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data:") { + continue + } + var envelope map[string]any + if err := json.Unmarshal([]byte(strings.TrimSpace(line[len("data:"):])), &envelope); err != nil { + c.t.Fatalf("decode %s event: %v", method, err) + } + return envelope + } + c.t.Fatalf("%s: no data event in response", method) + return nil +} + +func (c *mcpClient) initialize() { + c.t.Helper() + c.rpc("initialize", map[string]any{ + "protocolVersion": "2025-06-18", + "capabilities": map[string]any{}, + "clientInfo": map[string]any{"name": "engineapp-test", "version": "1"}, + }) +} + +func (c *mcpClient) toolNames() []string { + c.t.Helper() + envelope := c.rpc("tools/list", map[string]any{}) + result, _ := envelope["result"].(map[string]any) + tools, _ := result["tools"].([]any) + names := make([]string, 0, len(tools)) + for _, raw := range tools { + tool, _ := raw.(map[string]any) + if name, ok := tool["name"].(string); ok { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +func (c *mcpClient) callTool(name string, args map[string]any) map[string]any { + c.t.Helper() + envelope := c.rpc("tools/call", map[string]any{"name": name, "arguments": args}) + if errObj, ok := envelope["error"]; ok { + c.t.Fatalf("tools/call %s transport error: %v", name, errObj) + } + result, _ := envelope["result"].(map[string]any) + return result +} + +// The read surface is a contract external agents build against: it must be +// exactly the nine documented tools, and read_only must expose no others. +func TestMCPReadOnlyExposesExactlyTheReadTools(t *testing.T) { + _, c := startMCPServer(t) + + got := c.toolNames() + want := append([]string{}, mcphost.ReadToolNames...) + sort.Strings(want) + + if len(got) != len(want) { + t.Fatalf("tools/list returned %d tools (%v), want %d", len(got), got, len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("tools/list = %v, want %v", got, want) + } + } +} + +// An end-to-end read: list works, walk the outline, read a scene, and confirm +// the content_version a later write would have to present. +func TestMCPReadToolsRoundTrip(t *testing.T) { + app, c := startMCPServer(t) + + created, rpcErr := call(t, app, "projects.create", `{"title":"MCP 왕복","genres":["fantasy"],"length_target":"short","default_pov":"first"}`) + if rpcErr != nil { + t.Fatalf("projects.create: %+v", rpcErr) + } + var proj struct { + ID string `json:"id"` + LastOpenedNodeID *string `json:"last_opened_node_id"` + } + if err := json.Unmarshal(created, &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + + works := c.callTool("linetta_list_works", map[string]any{}) + if isToolError(works) { + t.Fatalf("linetta_list_works errored: %v", works) + } + if !strings.Contains(structuredJSON(t, works), proj.ID) { + t.Fatalf("new work missing from linetta_list_works: %s", structuredJSON(t, works)) + } + + outline := c.callTool("linetta_get_outline", map[string]any{"project_id": proj.ID}) + if isToolError(outline) { + t.Fatalf("linetta_get_outline errored: %v", outline) + } + + scene := c.callTool("linetta_read_scene", map[string]any{"node_id": *proj.LastOpenedNodeID}) + if isToolError(scene) { + t.Fatalf("linetta_read_scene errored: %v", scene) + } + var readOut struct { + NodeID string `json:"node_id"` + ContentVersion int `json:"content_version"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, scene)), &readOut); err != nil { + t.Fatalf("decode read_scene: %v", err) + } + if readOut.NodeID != *proj.LastOpenedNodeID { + t.Fatalf("read_scene returned node %q, want %q", readOut.NodeID, *proj.LastOpenedNodeID) + } + + // The brief must come back complete and error-free with no LLM provider + // reachable — the whole bring-your-own-agent premise rests on this. + brief := c.callTool("linetta_get_story_context", map[string]any{"node_id": *proj.LastOpenedNodeID}) + if isToolError(brief) { + t.Fatalf("linetta_get_story_context errored: %v", brief) + } + var briefOut struct { + Brief string `json:"brief"` + IncludedSections []string `json:"included_sections"` + EmptySections []string `json:"empty_sections"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, brief)), &briefOut); err != nil { + t.Fatalf("decode story context: %v", err) + } + if len(briefOut.IncludedSections)+len(briefOut.EmptySections) == 0 { + t.Fatal("story context reported no sections at all") + } +} + +// A bad id is a tool error the agent can act on, never a transport failure. +func TestMCPUnknownIDsReturnToolErrors(t *testing.T) { + _, c := startMCPServer(t) + for _, tc := range []struct { + tool string + args map[string]any + }{ + {"linetta_get_outline", map[string]any{"project_id": "no-such-work"}}, + {"linetta_read_scene", map[string]any{"node_id": "no-such-scene"}}, + {"linetta_get_story_context", map[string]any{"node_id": "no-such-scene"}}, + {"linetta_where_does_appear", map[string]any{"entity_id": "no-such-entity"}}, + } { + result := c.callTool(tc.tool, tc.args) + if !isToolError(result) { + t.Errorf("%s with a bad id should return a tool error, got %v", tc.tool, result) + } + } +} + +// Every call lands in the audit trail, successes and failures alike. +func TestMCPCallsAreRecordedInActivity(t *testing.T) { + app, c := startMCPServer(t) + c.callTool("linetta_list_works", map[string]any{}) + c.callTool("linetta_read_scene", map[string]any{"node_id": "no-such-scene"}) + + raw, rpcErr := call(t, app, "mcp.activity", `{"limit":10}`) + if rpcErr != nil { + t.Fatalf("mcp.activity: %+v", rpcErr) + } + var entries []struct { + Tool string `json:"tool"` + OK bool `json:"ok"` + } + if err := json.Unmarshal(raw, &entries); err != nil { + t.Fatalf("decode activity: %v", err) + } + if len(entries) < 2 { + t.Fatalf("activity has %d entries, want the two calls just made", len(entries)) + } + sawOK, sawFail := false, false + for _, e := range entries { + if e.Tool == "linetta_list_works" && e.OK { + sawOK = true + } + if e.Tool == "linetta_read_scene" && !e.OK { + sawFail = true + } + } + if !sawOK || !sawFail { + t.Fatalf("activity must record both outcomes; entries = %+v", entries) + } +} + +func isToolError(result map[string]any) bool { + v, _ := result["isError"].(bool) + return v +} + +// structuredJSON returns the tool's structured output as JSON text. +func structuredJSON(t *testing.T, result map[string]any) string { + t.Helper() + if sc, ok := result["structuredContent"]; ok { + raw, err := json.Marshal(sc) + if err != nil { + t.Fatalf("marshal structured content: %v", err) + } + return string(raw) + } + content, _ := result["content"].([]any) + for _, raw := range content { + block, _ := raw.(map[string]any) + if text, ok := block["text"].(string); ok { + return text + } + } + return "" +} + +// A server restricted to one work must not read another work's data, even +// when the agent supplies a valid id for it. +func TestMCPProjectRestrictionBlocksOtherWorks(t *testing.T) { + app, c := startMCPServer(t) + + mk := func(title string) (string, string) { + t.Helper() + raw, rpcErr := call(t, app, "projects.create", + fmt.Sprintf(`{"title":%q,"genres":["fantasy"],"length_target":"short","default_pov":"first"}`, title)) + if rpcErr != nil { + t.Fatalf("projects.create: %+v", rpcErr) + } + var p struct { + ID string `json:"id"` + LastOpenedNodeID *string `json:"last_opened_node_id"` + } + if err := json.Unmarshal(raw, &p); err != nil { + t.Fatalf("decode project: %v", err) + } + return p.ID, *p.LastOpenedNodeID + } + allowed, _ := mk("허용된 작품") + blocked, blockedNode := mk("차단된 작품") + + if _, rpcErr := call(t, app, "settings.set", + fmt.Sprintf(`{"mcp_project_id":%q}`, allowed)); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + + if result := c.callTool("linetta_get_outline", map[string]any{"project_id": blocked}); !isToolError(result) { + t.Error("a restricted server must refuse another work's outline") + } + // Node ids must be checked too: reaching a scene by id would bypass the + // project-level check entirely. + if result := c.callTool("linetta_read_scene", map[string]any{"node_id": blockedNode}); !isToolError(result) { + t.Error("a restricted server must refuse a scene from another work") + } + if result := c.callTool("linetta_get_outline", map[string]any{"project_id": allowed}); isToolError(result) { + t.Errorf("the allowed work must still be readable: %v", result) + } + + works := c.callTool("linetta_list_works", map[string]any{}) + body := structuredJSON(t, works) + if strings.Contains(body, blocked) { + t.Errorf("linetta_list_works leaked a restricted work: %s", body) + } + if !strings.Contains(body, allowed) { + t.Errorf("linetta_list_works dropped the allowed work: %s", body) + } +} + +// An untouched scene holds a doc with one empty paragraph, which the brief's +// walker renders as "\n". read_scene must hand the agent "" instead, or an +// empty scene reads as if it had content. +func TestMCPReadSceneTrimsEmptyBody(t *testing.T) { + app, c := startMCPServer(t) + created, rpcErr := call(t, app, "projects.create", + `{"title":"빈 씬","genres":["fantasy"],"length_target":"short","default_pov":"first"}`) + if rpcErr != nil { + t.Fatalf("projects.create: %+v", rpcErr) + } + var proj struct { + LastOpenedNodeID *string `json:"last_opened_node_id"` + } + if err := json.Unmarshal(created, &proj); err != nil { + t.Fatalf("decode project: %v", err) + } + + result := c.callTool("linetta_read_scene", map[string]any{"node_id": *proj.LastOpenedNodeID}) + var out struct { + Body string `json:"body"` + } + if err := json.Unmarshal([]byte(structuredJSON(t, result)), &out); err != nil { + t.Fatalf("decode read_scene: %v", err) + } + if out.Body != "" { + t.Fatalf("empty scene body = %q, want an empty string", out.Body) + } +} diff --git a/engine/internal/engineapp/mcp_wiring_test.go b/engine/internal/engineapp/mcp_wiring_test.go new file mode 100644 index 00000000..91d2d5ea --- /dev/null +++ b/engine/internal/engineapp/mcp_wiring_test.go @@ -0,0 +1,196 @@ +//go:build !mobile + +package engineapp + +import ( + "context" + "encoding/json" + "fmt" + "net" + "testing" + "time" +) + +// call sends one JSONRPC request through the app and returns the raw result. +func call(t *testing.T, app *App, method string, params string) (json.RawMessage, *rpcError) { + t.Helper() + if params == "" { + params = "null" + } + req := fmt.Sprintf(`{"jsonrpc":"2.0","id":1,"method":%q,"params":%s}`, method, params) + raw, err := app.Handle(context.Background(), []byte(req)) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + var envelope struct { + Result json.RawMessage `json:"result"` + Error *rpcError `json:"error"` + } + if err := json.Unmarshal(raw, &envelope); err != nil { + t.Fatalf("decode %s response: %v", method, err) + } + return envelope.Result, envelope.Error +} + +type rpcError struct { + Code int `json:"code"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +type mcpStatus struct { + Running bool `json:"running"` + Mode string `json:"mode"` + Port int `json:"port"` + TokenSet bool `json:"token_set"` +} + +func openApp(t *testing.T) *App { + t.Helper() + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + app, err := Open(context.Background(), Options{Home: home}) + if err != nil { + t.Fatalf("Open: %v", err) + } + t.Cleanup(func() { _ = app.Close() }) + return app +} + +func portFree(t *testing.T, port int) bool { + t.Helper() + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return false + } + _ = ln.Close() + return true +} + +// waitPortFree polls until the OS has actually released the port. Shutdown +// returning does not guarantee the socket is instantly rebindable — on a +// loaded machine the teardown lags — so asserting instantaneously is a flake, +// not a stronger check. A port that never frees still fails here. +func waitPortFree(t *testing.T, port int) bool { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for { + if portFree(t, port) { + return true + } + if time.Now().After(deadline) { + return false + } + time.Sleep(20 * time.Millisecond) + } +} + +// A fresh install must not open a port. MCP is opt-in. +func TestMCPDefaultsToNoListener(t *testing.T) { + app := openApp(t) + result, rpcErr := call(t, app, "mcp.status", "") + if rpcErr != nil { + t.Fatalf("mcp.status: %+v", rpcErr) + } + var st mcpStatus + if err := json.Unmarshal(result, &st); err != nil { + t.Fatalf("decode status: %v", err) + } + if st.Running { + t.Fatal("a fresh engine must not be serving MCP") + } + if st.Mode != "off" { + t.Fatalf("mode = %q, want off", st.Mode) + } +} + +// Enabling without consent must be refused with a reason code the UI can +// localize, not a generic internal error. +func TestMCPEnableWithoutConsentIsRefused(t *testing.T) { + app := openApp(t) + if _, rpcErr := call(t, app, "settings.set", `{"mcp_mode":"read_only"}`); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + _, rpcErr := call(t, app, "mcp.enable", "") + if rpcErr == nil { + t.Fatal("enable without consent should fail") + } + if got := string(rpcErr.Data); got != `{"reason":"mcp_consent_required"}` { + t.Fatalf("error data = %s, want an mcp_consent_required reason", got) + } +} + +// The full loop: consent + mode, enable, verify the port is really bound, then +// confirm Close releases it — a leaked listener would block the next launch. +func TestMCPEnableBindsAndCloseReleasesPort(t *testing.T) { + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + app, err := Open(context.Background(), Options{Home: home}) + if err != nil { + t.Fatalf("Open: %v", err) + } + + free := freeTestPort(t) + patch := fmt.Sprintf(`{"mcp_mode":"read_only","mcp_port":%d,"mcp_consent_version":1,"mcp_consented_at":1}`, free) + if _, rpcErr := call(t, app, "settings.set", patch); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + + result, rpcErr := call(t, app, "mcp.enable", "") + if rpcErr != nil { + t.Fatalf("mcp.enable: %+v", rpcErr) + } + var st mcpStatus + if err := json.Unmarshal(result, &st); err != nil { + t.Fatalf("decode status: %v", err) + } + if !st.Running || st.Port != free { + t.Fatalf("status = %+v, want running on port %d", st, free) + } + if !st.TokenSet { + t.Error("enabling must ensure a bearer token exists") + } + if portFree(t, free) { + t.Fatal("mcp.enable reported running but nothing is listening") + } + + if err := app.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if !waitPortFree(t, free) { + t.Fatal("Close must release the MCP port") + } +} + +// mcp.disable is the kill switch: the listener goes away immediately. +func TestMCPDisableStopsListener(t *testing.T) { + app := openApp(t) + free := freeTestPort(t) + patch := fmt.Sprintf(`{"mcp_mode":"full","mcp_port":%d,"mcp_consent_version":1,"mcp_consented_at":1}`, free) + if _, rpcErr := call(t, app, "settings.set", patch); rpcErr != nil { + t.Fatalf("settings.set: %+v", rpcErr) + } + if _, rpcErr := call(t, app, "mcp.enable", ""); rpcErr != nil { + t.Fatalf("mcp.enable: %+v", rpcErr) + } + if portFree(t, free) { + t.Fatal("expected a bound port after enable") + } + if _, rpcErr := call(t, app, "mcp.disable", ""); rpcErr != nil { + t.Fatalf("mcp.disable: %+v", rpcErr) + } + if !waitPortFree(t, free) { + t.Fatal("mcp.disable must drop the listener") + } +} + +func freeTestPort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("probe port: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return port +} diff --git a/engine/internal/mcphost/activity.go b/engine/internal/mcphost/activity.go new file mode 100644 index 00000000..50226074 --- /dev/null +++ b/engine/internal/mcphost/activity.go @@ -0,0 +1,113 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "database/sql" + "time" + + "github.com/google/uuid" +) + +// activityRetention caps how many rows the log keeps. The table trims itself +// after each insert rather than leaning on the nightly snapshot-thinning job: +// one fewer moving part, and the cap holds even if the app never idles. +const activityRetention = 500 + +// DefaultActivityLimit is how many entries mcp.activity returns when the +// caller does not ask for a specific count. +const DefaultActivityLimit = 100 + +// ActivityEntry is one recorded tool call. +type ActivityEntry struct { + ID string `json:"id"` + At int64 `json:"at"` + Tool string `json:"tool"` + ProjectID string `json:"project_id,omitempty"` + TargetID string `json:"target_id,omitempty"` + OK bool `json:"ok"` + Detail string `json:"detail,omitempty"` +} + +// ActivityRepo persists the MCP audit trail. +type ActivityRepo struct { + db *sql.DB + now func() int64 +} + +// NewActivityRepo returns a repo over db. +func NewActivityRepo(db *sql.DB) *ActivityRepo { + return &ActivityRepo{db: db, now: func() int64 { return time.Now().UnixMilli() }} +} + +// Record appends one entry and trims the table to the retention cap. Recording +// is best-effort from the caller's perspective — a logging failure must never +// fail the tool call itself — so callers log the error and continue. +func (r *ActivityRepo) Record(ctx context.Context, e ActivityEntry) error { + if r == nil || r.db == nil { + return nil + } + if e.ID == "" { + e.ID = uuid.NewString() + } + if e.At == 0 { + e.At = r.now() + } + if _, err := r.db.ExecContext(ctx, + `INSERT INTO mcp_activity (id, at, tool, project_id, target_id, ok, detail) + VALUES (?, ?, ?, ?, ?, ?, ?)`, + e.ID, e.At, e.Tool, e.ProjectID, e.TargetID, boolToInt(e.OK), truncate(e.Detail, 500), + ); err != nil { + return err + } + _, err := r.db.ExecContext(ctx, + `DELETE FROM mcp_activity WHERE id NOT IN ( + SELECT id FROM mcp_activity ORDER BY at DESC, id DESC LIMIT ? + )`, activityRetention) + return err +} + +// List returns the most recent entries, newest first. +func (r *ActivityRepo) List(ctx context.Context, limit int) ([]ActivityEntry, error) { + if r == nil || r.db == nil { + return []ActivityEntry{}, nil + } + if limit <= 0 || limit > activityRetention { + limit = DefaultActivityLimit + } + rows, err := r.db.QueryContext(ctx, + `SELECT id, at, tool, project_id, target_id, ok, detail + FROM mcp_activity ORDER BY at DESC, id DESC LIMIT ?`, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + out := []ActivityEntry{} + for rows.Next() { + var e ActivityEntry + var ok int + if err := rows.Scan(&e.ID, &e.At, &e.Tool, &e.ProjectID, &e.TargetID, &ok, &e.Detail); err != nil { + return nil, err + } + e.OK = ok != 0 + out = append(out, e) + } + return out, rows.Err() +} + +func boolToInt(b bool) int { + if b { + return 1 + } + return 0 +} + +func truncate(s string, max int) string { + r := []rune(s) + if len(r) <= max { + return s + } + return string(r[:max]) + "…" +} diff --git a/engine/internal/mcphost/auth.go b/engine/internal/mcphost/auth.go new file mode 100644 index 00000000..33057a3d --- /dev/null +++ b/engine/internal/mcphost/auth.go @@ -0,0 +1,113 @@ +//go:build !mobile + +package mcphost + +import ( + "crypto/subtle" + "errors" + "fmt" + "net" + "net/http" + "net/url" + "os" + "strings" + "syscall" +) + +// authMiddleware gates the MCP endpoint. Two independent checks: +// +// 1. A bearer token, compared in constant time. This is what actually +// authorizes the caller. +// 2. Origin/Host validation. Without it any web page the writer visits could +// POST to 127.0.0.1 and drive their manuscript — the DNS-rebinding case +// the MCP spec calls out for HTTP transports. A browser always sends +// Origin on cross-origin requests; a legitimate MCP client sends none. +func authMiddleware(token string, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !originAllowed(r.Header.Get("Origin")) { + http.Error(w, "forbidden origin", http.StatusForbidden) + return + } + if !hostAllowed(r.Host) { + http.Error(w, "forbidden host", http.StatusForbidden) + return + } + if !tokenMatches(token, r.Header.Get("Authorization")) { + w.Header().Set("WWW-Authenticate", `Bearer realm="linetta"`) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + next.ServeHTTP(w, r) + }) +} + +// tokenMatches accepts only "Bearer " with an exact, constant-time match. +func tokenMatches(want, header string) bool { + if want == "" { + return false + } + const prefix = "Bearer " + if len(header) <= len(prefix) || !strings.EqualFold(header[:len(prefix)], prefix) { + return false + } + got := strings.TrimSpace(header[len(prefix):]) + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +// originAllowed permits a missing Origin (native MCP clients send none) and +// loopback origins; everything else is rejected. +func originAllowed(origin string) bool { + origin = strings.TrimSpace(origin) + if origin == "" { + return true + } + u, err := url.Parse(origin) + if err != nil { + return false + } + return isLoopbackHost(u.Hostname()) +} + +// hostAllowed rejects a Host header pointing anywhere but loopback, so a +// rebound DNS name cannot be used to reach the server. +func hostAllowed(host string) bool { + host = strings.TrimSpace(host) + if host == "" { + return false + } + name, _, err := net.SplitHostPort(host) + if err != nil { + name = host + } + return isLoopbackHost(name) +} + +func isLoopbackHost(name string) bool { + name = strings.TrimSpace(strings.Trim(name, "[]")) + if name == "" { + return false + } + if strings.EqualFold(name, "localhost") { + return true + } + ip := net.ParseIP(name) + return ip != nil && ip.IsLoopback() +} + +// isAddrInUse reports whether err is the OS "address already in use" error. +// Windows uses WSAEADDRINUSE (10048) rather than the POSIX constant. +func isAddrInUse(err error) bool { + if errors.Is(err, syscall.EADDRINUSE) { + return true + } + var errno syscall.Errno + if errors.As(err, &errno) && uintptr(errno) == 10048 { + return true + } + return strings.Contains(strings.ToLower(err.Error()), "address already in use") || + strings.Contains(strings.ToLower(err.Error()), "only one usage of each socket address") +} + +func logf(format string, args ...any) { + fmt.Fprintf(os.Stderr, "mcphost: "+format+"\n", args...) +} diff --git a/engine/internal/mcphost/discovery.go b/engine/internal/mcphost/discovery.go new file mode 100644 index 00000000..67aee618 --- /dev/null +++ b/engine/internal/mcphost/discovery.go @@ -0,0 +1,78 @@ +//go:build !mobile + +package mcphost + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +// DiscoveryFileName is the file the stdio bridge reads to find the running +// server. It sits next to library.db and settings.json in $LINETTA_HOME. +// +// Trust boundary: a process running as this user can already read library.db +// and the secret store directly, so carrying the token here does not lower the +// bar — it just spares the writer from pasting it into a bridge config. +const DiscoveryFileName = "mcp.json" + +// Discovery is the on-disk contents of the discovery file. +type Discovery struct { + Port int `json:"port"` + Token string `json:"token"` + PID int `json:"pid"` + StartedAt int64 `json:"started_at"` +} + +func discoveryPath(home string) string { + return filepath.Join(home, DiscoveryFileName) +} + +// writeDiscoveryFile records the live endpoint at 0600. Written after the +// listener is up so a reader that finds the file can connect. +func writeDiscoveryFile(home string, port int, token string) error { + if home == "" { + return nil + } + raw, err := json.Marshal(Discovery{ + Port: port, + Token: token, + PID: os.Getpid(), + StartedAt: time.Now().UnixMilli(), + }) + if err != nil { + return err + } + return os.WriteFile(discoveryPath(home), raw, 0o600) +} + +// removeDiscoveryFile deletes the file on shutdown so a stale endpoint is +// never advertised — but only when this process is the one it points at. +// Another engine instance shutting down must not retract a live server's +// endpoint. A missing file is not an error. +func removeDiscoveryFile(home string) { + if home == "" { + return + } + if d, err := ReadDiscoveryFile(home); err == nil && d.PID != os.Getpid() { + return + } + if err := os.Remove(discoveryPath(home)); err != nil && !os.IsNotExist(err) { + logf("remove discovery file: %v", err) + } +} + +// ReadDiscoveryFile loads the endpoint written by a running app. The bridge +// binary uses this; exported so cmd/linetta-mcp does not duplicate the format. +func ReadDiscoveryFile(home string) (Discovery, error) { + raw, err := os.ReadFile(discoveryPath(home)) + if err != nil { + return Discovery{}, err + } + var d Discovery + if err := json.Unmarshal(raw, &d); err != nil { + return Discovery{}, err + } + return d, nil +} diff --git a/engine/internal/mcphost/host.go b/engine/internal/mcphost/host.go new file mode 100644 index 00000000..b3b16d73 --- /dev/null +++ b/engine/internal/mcphost/host.go @@ -0,0 +1,193 @@ +//go:build !mobile + +// Package mcphost serves Linetta's story tools to external MCP clients +// (Claude Code, Claude Desktop) over a loopback-only Streamable HTTP endpoint. +// +// It is hosted inside the running app rather than in a separate process +// because engineapp.Open unconditionally starts background jobs (backup, +// snapshot thinning, summarizer, folder/git sync) and because the UI refresh +// path — Go notifier → C callback → Tauri emit → useEngineEvent — is +// in-process only. A second process would double the jobs and leave the writer +// staring at a stale scene. +// +// Part of the MCP-first pivot (#47). +package mcphost + +import ( + "context" + "errors" + "fmt" + "net" + "net/http" + "sync" + "time" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/settings" +) + +// ServerName and ServerVersion identify this server to clients. +const ( + ServerName = "linetta" + ServerVersion = "0.1.0" +) + +// shutdownGrace bounds how long Stop waits for in-flight tool calls. +const shutdownGrace = 3 * time.Second + +// ErrPortInUse means the configured port is taken. The writer must pick +// another one — the host never silently falls back to a different port, +// because every saved client config points at the configured one. +var ErrPortInUse = errors.New("mcphost: port already in use") + +// ErrConsentRequired means MCP access has not been accepted yet. +var ErrConsentRequired = errors.New("mcphost: MCP consent is required before starting the server") + +// Deps are the collaborators the host needs. Tools are registered separately +// (see tools_read.go) so this file stays about lifecycle and auth. +type Deps struct { + Settings *settings.Store + // Tools registers the tool set for the current mode on a fresh server. + Tools func(s *mcp.Server, mode string) + // Home is $LINETTA_HOME, where the discovery file lives. + Home string +} + +// Host owns the listener, the MCP server, and the discovery file. +type Host struct { + deps Deps + + mu sync.Mutex + httpSrv *http.Server + port int + token string + running bool +} + +// New returns a Host. Nothing binds until Start is called. +func New(deps Deps) *Host { return &Host{deps: deps} } + +// Status reports whether the server is listening and on which port. +type Status struct { + Running bool `json:"running"` + Mode string `json:"mode"` + Port int `json:"port,omitempty"` + ProjectID string `json:"project_id,omitempty"` + TokenSet bool `json:"token_set"` +} + +// Status returns the current listener state. +func (h *Host) Status() Status { + h.mu.Lock() + defer h.mu.Unlock() + st := Status{ + Running: h.running, + Mode: h.deps.Settings.MCPMode(), + ProjectID: h.deps.Settings.MCPProjectID(), + TokenSet: h.deps.Settings.MCPToken() != "", + } + if h.running { + st.Port = h.port + } + return st +} + +// Start binds the loopback listener and writes the discovery file. It is a +// no-op when the mode is off or the server is already running. +func (h *Host) Start(ctx context.Context) error { + mode := h.deps.Settings.MCPMode() + if mode == settings.MCPModeOff { + return nil + } + if !h.deps.Settings.HasMCPConsent() { + return ErrConsentRequired + } + + h.mu.Lock() + defer h.mu.Unlock() + if h.running { + return nil + } + + token, err := h.deps.Settings.EnsureMCPToken() + if err != nil { + return err + } + port := h.deps.Settings.MCPPort() + + ln, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + if isAddrInUse(err) { + return fmt.Errorf("%w: %d", ErrPortInUse, port) + } + return fmt.Errorf("mcphost: listen on 127.0.0.1:%d: %w", port, err) + } + + handler := mcp.NewStreamableHTTPHandler(func(*http.Request) *mcp.Server { + srv := mcp.NewServer(&mcp.Implementation{ + Name: ServerName, + Title: "Linetta", + Version: ServerVersion, + }, nil) + if h.deps.Tools != nil { + h.deps.Tools(srv, mode) + } + return srv + }, nil) + + mux := http.NewServeMux() + mux.Handle("/mcp", authMiddleware(token, handler)) + + srv := &http.Server{Handler: mux, ReadHeaderTimeout: 10 * time.Second} + h.httpSrv = srv + h.port = port + h.token = token + h.running = true + + go func() { + if err := srv.Serve(ln); err != nil && !errors.Is(err, http.ErrServerClosed) { + logf("serve: %v", err) + } + }() + + if err := writeDiscoveryFile(h.deps.Home, port, token); err != nil { + logf("discovery file: %v", err) + } + return nil +} + +// Stop shuts the listener down and removes the discovery file. Safe to call +// when not running. +func (h *Host) Stop() error { + h.mu.Lock() + srv := h.httpSrv + wasRunning := h.running + h.httpSrv = nil + h.running = false + h.port = 0 + h.token = "" + h.mu.Unlock() + + // Only a host that actually served may retract the discovery file. An + // engine that never started MCP — mode off, or a second instance — would + // otherwise erase a live server's endpoint on its way out, leaving the + // bridge with nothing to read while the server is still up. + if wasRunning { + removeDiscoveryFile(h.deps.Home) + } + if srv == nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), shutdownGrace) + defer cancel() + return srv.Shutdown(ctx) +} + +// Restart applies changed settings (mode, port, token) by cycling the listener. +func (h *Host) Restart(ctx context.Context) error { + if err := h.Stop(); err != nil { + return err + } + return h.Start(ctx) +} diff --git a/engine/internal/mcphost/host_test.go b/engine/internal/mcphost/host_test.go new file mode 100644 index 00000000..db02811b --- /dev/null +++ b/engine/internal/mcphost/host_test.go @@ -0,0 +1,327 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "errors" + "fmt" + "io/fs" + "net" + "net/http" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/devlikebear/linetta/engine/internal/settings" +) + +func newHost(t *testing.T, mode string, consent bool) (*Host, *settings.Store, string) { + t.Helper() + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + st, err := settings.NewWithSecretStore(settings.NewMemorySecretStore()) + if err != nil { + t.Fatalf("settings: %v", err) + } + patch := settings.Patch{MCPMode: &mode, MCPPort: freePort(t)} + if consent { + version := settings.MCPConsentVersion + at := int64(1) + patch.MCPConsentVersion = &version + patch.MCPConsentedAt = &at + } + if _, err := st.Set(context.Background(), patch); err != nil { + t.Fatalf("settings.Set: %v", err) + } + h := New(Deps{Settings: st, Home: home}) + t.Cleanup(func() { _ = h.Stop() }) + return h, st, home +} + +// freePort grabs a port the OS just handed out, then releases it, so parallel +// test runs do not collide on the fixed default. +func freePort(t *testing.T) *int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("probe port: %v", err) + } + port := ln.Addr().(*net.TCPAddr).Port + _ = ln.Close() + return &port +} + +func endpoint(h *Host) string { + return fmt.Sprintf("http://127.0.0.1:%d/mcp", h.Status().Port) +} + +// A POST that should reach the handler; the body is a valid initialize call so +// only auth decides the outcome. +func post(t *testing.T, url string, headers map[string]string) *http.Response { + t.Helper() + body := strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"initialize",` + + `"params":{"protocolVersion":"2025-06-18","capabilities":{},` + + `"clientInfo":{"name":"test","version":"1"}}}`) + req, err := http.NewRequest(http.MethodPost, url, body) + if err != nil { + t.Fatalf("new request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json, text/event-stream") + for k, v := range headers { + req.Header.Set(k, v) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("do request: %v", err) + } + t.Cleanup(func() { _ = resp.Body.Close() }) + return resp +} + +// Mode off must leave the machine untouched: nothing binds, nothing is written. +func TestStartIsNoopWhenModeOff(t *testing.T) { + h, _, home := newHost(t, settings.MCPModeOff, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + if h.Status().Running { + t.Fatal("mode off must not bind a listener") + } + if _, err := os.Stat(filepath.Join(home, DiscoveryFileName)); !errors.Is(err, fs.ErrNotExist) { + t.Fatal("mode off must not write a discovery file") + } +} + +// Enabling without consent must fail loudly rather than quietly serving. +func TestStartRequiresConsent(t *testing.T) { + h, _, _ := newHost(t, settings.MCPModeReadOnly, false) + err := h.Start(context.Background()) + if !errors.Is(err, ErrConsentRequired) { + t.Fatalf("Start without consent = %v, want ErrConsentRequired", err) + } + if h.Status().Running { + t.Fatal("server must not run without consent") + } +} + +func TestStartWritesDiscoveryFileAndStopRemovesIt(t *testing.T) { + h, st, home := newHost(t, settings.MCPModeReadOnly, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + status := h.Status() + if !status.Running || status.Port == 0 { + t.Fatalf("status = %+v, want a running server with a port", status) + } + + path := filepath.Join(home, DiscoveryFileName) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat discovery file: %v", err) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("discovery file mode = %o, want 600", perm) + } + } + d, err := ReadDiscoveryFile(home) + if err != nil { + t.Fatalf("ReadDiscoveryFile: %v", err) + } + if d.Port != status.Port || d.Token != st.MCPToken() || d.PID != os.Getpid() { + t.Fatalf("discovery = %+v, want port %d and the live token/pid", d, status.Port) + } + + if err := h.Stop(); err != nil { + t.Fatalf("Stop: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) { + t.Error("Stop must remove the discovery file so a stale endpoint is never advertised") + } + if h.Status().Running { + t.Error("status must report stopped after Stop") + } +} + +// The port is the writer's setting: a busy one is an error they can see and +// act on, never a silent bind somewhere else that breaks saved configs. +func TestStartReportsPortInUse(t *testing.T) { + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + st, err := settings.NewWithSecretStore(settings.NewMemorySecretStore()) + if err != nil { + t.Fatalf("settings: %v", err) + } + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("occupy port: %v", err) + } + defer ln.Close() + busy := ln.Addr().(*net.TCPAddr).Port + + mode := settings.MCPModeReadOnly + version := settings.MCPConsentVersion + at := int64(1) + if _, err := st.Set(context.Background(), settings.Patch{ + MCPMode: &mode, MCPPort: &busy, MCPConsentVersion: &version, MCPConsentedAt: &at, + }); err != nil { + t.Fatalf("settings.Set: %v", err) + } + + h := New(Deps{Settings: st, Home: home}) + t.Cleanup(func() { _ = h.Stop() }) + err = h.Start(context.Background()) + if !errors.Is(err, ErrPortInUse) { + t.Fatalf("Start on a busy port = %v, want ErrPortInUse", err) + } + if h.Status().Running { + t.Fatal("a failed bind must not leave the host marked running") + } +} + +func TestAuthRejectsMissingAndWrongToken(t *testing.T) { + h, _, _ := newHost(t, settings.MCPModeReadOnly, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + url := endpoint(h) + + if resp := post(t, url, nil); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("no token: status = %d, want 401", resp.StatusCode) + } + if resp := post(t, url, map[string]string{"Authorization": "Bearer nope"}); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("wrong token: status = %d, want 401", resp.StatusCode) + } + if resp := post(t, url, map[string]string{"Authorization": "nope"}); resp.StatusCode != http.StatusUnauthorized { + t.Errorf("malformed scheme: status = %d, want 401", resp.StatusCode) + } +} + +// A web page on any site can POST to 127.0.0.1; a non-loopback Origin is the +// DNS-rebinding signature the MCP spec tells servers to reject. +func TestAuthRejectsForeignOrigin(t *testing.T) { + h, st, _ := newHost(t, settings.MCPModeReadOnly, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + auth := "Bearer " + st.MCPToken() + resp := post(t, endpoint(h), map[string]string{ + "Authorization": auth, + "Origin": "https://evil.test", + }) + if resp.StatusCode != http.StatusForbidden { + t.Fatalf("foreign origin: status = %d, want 403 even with a valid token", resp.StatusCode) + } +} + +func TestAuthAllowsLoopbackOriginWithToken(t *testing.T) { + h, st, _ := newHost(t, settings.MCPModeReadOnly, true) + if err := h.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + auth := "Bearer " + st.MCPToken() + for _, origin := range []string{"", "http://localhost:3000", "http://127.0.0.1:5173"} { + headers := map[string]string{"Authorization": auth} + if origin != "" { + headers["Origin"] = origin + } + resp := post(t, endpoint(h), headers) + // 200 proves the request reached the MCP handler and initialize + // succeeded, not merely that auth declined to reject it. + if resp.StatusCode != http.StatusOK { + t.Errorf("origin %q: status = %d, want 200 from the MCP handler", origin, resp.StatusCode) + } + } +} + +func TestUnitOriginAndHostChecks(t *testing.T) { + for _, tc := range []struct { + origin string + want bool + }{ + {"", true}, + {"http://localhost", true}, + {"http://127.0.0.1:7391", true}, + {"http://[::1]:7391", true}, + {"https://evil.test", false}, + {"http://127.0.0.1.evil.test", false}, + {"not a url at all ::::", false}, + } { + if got := originAllowed(tc.origin); got != tc.want { + t.Errorf("originAllowed(%q) = %v, want %v", tc.origin, got, tc.want) + } + } + for _, tc := range []struct { + host string + want bool + }{ + {"127.0.0.1:7391", true}, + {"localhost:7391", true}, + {"[::1]:7391", true}, + {"linetta.evil.test", false}, + {"", false}, + } { + if got := hostAllowed(tc.host); got != tc.want { + t.Errorf("hostAllowed(%q) = %v, want %v", tc.host, got, tc.want) + } + } +} + +// Regression: Stop removed the discovery file unconditionally, so an engine +// that never served MCP — mode off, or a second instance sharing the home — +// erased a live server's endpoint on its way out. The server kept serving +// while the bridge had nothing left to read. +func TestStopKeepsAnotherHostsDiscoveryFile(t *testing.T) { + live, _, home := newHost(t, settings.MCPModeReadOnly, true) + if err := live.Start(context.Background()); err != nil { + t.Fatalf("Start: %v", err) + } + path := filepath.Join(home, DiscoveryFileName) + if _, err := os.Stat(path); err != nil { + t.Fatalf("live server should have written a discovery file: %v", err) + } + + // A second host over the same home that never starts (mode off). + idle := New(Deps{Settings: idleSettings(t, home), Home: home}) + if err := idle.Start(context.Background()); err != nil { + t.Fatalf("idle Start: %v", err) + } + if err := idle.Stop(); err != nil { + t.Fatalf("idle Stop: %v", err) + } + + if _, err := os.Stat(path); err != nil { + t.Fatal("an idle host's shutdown must not remove the live server's discovery file") + } + if !live.Status().Running { + t.Fatal("the live server should still be serving") + } + + // The owner still retracts its own file. + if err := live.Stop(); err != nil { + t.Fatalf("live Stop: %v", err) + } + if _, err := os.Stat(path); !errors.Is(err, fs.ErrNotExist) { + t.Fatal("the owning host must remove its discovery file on shutdown") + } +} + +// idleSettings returns a store over the same home with MCP off, standing in +// for an engine instance that shares the data directory but never serves. +func idleSettings(t *testing.T, home string) *settings.Store { + t.Helper() + t.Setenv("LINETTA_HOME", home) + s, err := settings.NewWithSecretStore(settings.NewMemorySecretStore()) + if err != nil { + t.Fatalf("settings: %v", err) + } + off := settings.MCPModeOff + if _, err := s.Set(context.Background(), settings.Patch{MCPMode: &off}); err != nil { + t.Fatalf("settings.Set(off): %v", err) + } + return s +} diff --git a/engine/internal/mcphost/tools.go b/engine/internal/mcphost/tools.go new file mode 100644 index 00000000..e675ffa8 --- /dev/null +++ b/engine/internal/mcphost/tools.go @@ -0,0 +1,167 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "fmt" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/entity" + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/manuscript" + "github.com/devlikebear/linetta/engine/internal/mention" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/plot" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/settings" + "github.com/devlikebear/linetta/engine/internal/storycontext" +) + +// ToolDeps carries everything the tool layer reads from. Every field is a repo +// the UI already uses, so an agent sees exactly what the writer sees. +type ToolDeps struct { + Projects *project.Repo + Nodes *node.Repo + Entities *entity.Repo + Mentions *mention.Repo + Facts *fact.Repo + Plot *plot.Builder + Manuscript *manuscript.Searcher + Context *storycontext.ContextBuilder + Settings *settings.Store + Activity *ActivityRepo +} + +// Register installs the tool set for a mode. Read tools are always present; +// write tools (Phase 3) are registered only for settings.MCPModeFull, so +// read_only does not merely refuse writes — the tools are absent from +// tools/list and cannot be called at all. +// +// The mode is captured when the listener starts. Changing it goes through +// Host.Restart (see mcpController.Enable), which builds a fresh server, so a +// running server never serves a stale tool set. +func (d ToolDeps) Register(s *mcp.Server, mode string) { + d.registerReadTools(s) + _ = mode // write tools land in Phase 3 +} + +// scopedInput is implemented by tool inputs that name a work and/or a target, +// so the activity log can record what was touched without every tool repeating it. +type scopedInput interface { + scope() (projectID, targetID string) +} + +// record wraps a typed tool handler so every call — success or failure — lands +// in the activity log the writer can inspect. Wrapping at registration time +// means no tool can forget to report itself. +func record[In, Out any](d ToolDeps, tool string, h mcp.ToolHandlerFor[In, Out]) mcp.ToolHandlerFor[In, Out] { + return func(ctx context.Context, req *mcp.CallToolRequest, in In) (*mcp.CallToolResult, Out, error) { + res, out, err := h(ctx, req, in) + + projectID, targetID := "", "" + if s, ok := any(in).(scopedInput); ok { + projectID, targetID = s.scope() + } + ok := err == nil && (res == nil || !res.IsError) + detail := "" + if err != nil { + detail = err.Error() + } else if res != nil && res.IsError { + detail = firstText(res) + } + d.recordActivity(ctx, tool, projectID, targetID, ok, detail) + return res, out, err + } +} + +func (d ToolDeps) recordActivity(ctx context.Context, tool, projectID, targetID string, ok bool, detail string) { + if d.Activity == nil { + return + } + if err := d.Activity.Record(ctx, ActivityEntry{ + Tool: tool, + ProjectID: projectID, + TargetID: targetID, + OK: ok, + Detail: detail, + }); err != nil { + logf("activity log: %v", err) + } +} + +func firstText(res *mcp.CallToolResult) string { + for _, c := range res.Content { + if tc, ok := c.(*mcp.TextContent); ok { + return tc.Text + } + } + return "" +} + +// toolErr returns a tool-level error result. Agents recover from these; a Go +// error would surface as a transport failure they cannot act on. +func toolErr(format string, args ...any) *mcp.CallToolResult { + return &mcp.CallToolResult{ + IsError: true, + Content: []mcp.Content{&mcp.TextContent{Text: fmt.Sprintf(format, args...)}}, + } +} + +// requireProject resolves the work a call targets and enforces the optional +// single-work restriction. Every tool funnels through here so the restriction +// cannot be bypassed by a tool that forgot to check. +func (d ToolDeps) requireProject(ctx context.Context, projectID string) (project.Project, *mcp.CallToolResult) { + projectID = strings.TrimSpace(projectID) + restricted := "" + if d.Settings != nil { + restricted = strings.TrimSpace(d.Settings.MCPProjectID()) + } + if restricted != "" { + if projectID == "" { + projectID = restricted + } else if projectID != restricted { + return project.Project{}, toolErr( + "this Linetta server is restricted to a single work; work %q is not available", projectID) + } + } + if projectID == "" { + return project.Project{}, toolErr("project_id is required; call linetta_list_works first") + } + p, err := d.Projects.Get(ctx, projectID) + if err != nil { + return project.Project{}, toolErr("work %q not found", projectID) + } + return p, nil +} + +// requireNode resolves a node and verifies it belongs to an allowed work, so a +// node id from another work cannot be read through a restricted server. +func (d ToolDeps) requireNode(ctx context.Context, nodeID string) (node.Node, *mcp.CallToolResult) { + nodeID = strings.TrimSpace(nodeID) + if nodeID == "" { + return node.Node{}, toolErr("node_id is required; call linetta_get_outline to find one") + } + n, err := d.Nodes.Get(ctx, nodeID) + if err != nil { + return node.Node{}, toolErr("scene or outline node %q not found", nodeID) + } + if _, errResult := d.requireProject(ctx, n.ProjectID); errResult != nil { + return node.Node{}, errResult + } + return n, nil +} + +// allowedProjectID returns the restriction, or "" when every work is reachable. +func (d ToolDeps) allowedProjectID() string { + if d.Settings == nil { + return "" + } + return strings.TrimSpace(d.Settings.MCPProjectID()) +} + +func entityKindFilter(kind string) string { + return strings.ToLower(strings.TrimSpace(kind)) +} diff --git a/engine/internal/mcphost/tools_read.go b/engine/internal/mcphost/tools_read.go new file mode 100644 index 00000000..fb258e4c --- /dev/null +++ b/engine/internal/mcphost/tools_read.go @@ -0,0 +1,622 @@ +//go:build !mobile + +package mcphost + +import ( + "context" + "sort" + "strings" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/devlikebear/linetta/engine/internal/fact" + "github.com/devlikebear/linetta/engine/internal/node" + "github.com/devlikebear/linetta/engine/internal/plot" + "github.com/devlikebear/linetta/engine/internal/project" + "github.com/devlikebear/linetta/engine/internal/storycontext" +) + +// ReadToolNames lists the read tools, in registration order. Tests assert +// tools/list against this so the surface cannot drift silently. +var ReadToolNames = []string{ + "linetta_list_works", + "linetta_get_outline", + "linetta_get_story_context", + "linetta_read_scene", + "linetta_search_manuscript", + "linetta_list_characters", + "linetta_where_does_appear", + "linetta_get_plot", + "linetta_get_fact_cards", +} + +const defaultSearchLimit = 20 + +// ---------- linetta_list_works ---------- + +type listWorksInput struct { + IncludeArchived bool `json:"include_archived,omitempty" jsonschema:"include archived works as well as active ones"` +} + +func (listWorksInput) scope() (string, string) { return "", "" } + +type workSummary struct { + ProjectID string `json:"project_id"` + Title string `json:"title"` + Status string `json:"status"` + Synopsis string `json:"synopsis,omitempty"` + Genres []string `json:"genres,omitempty"` + SceneCount int `json:"scene_count"` +} + +type listWorksOutput struct { + Works []workSummary `json:"works"` +} + +// ---------- linetta_get_outline ---------- + +type getOutlineInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work, from linetta_list_works"` +} + +func (in getOutlineInput) scope() (string, string) { return in.ProjectID, "" } + +type outlineRow struct { + NodeID string `json:"node_id"` + ParentID string `json:"parent_id,omitempty"` + Depth int `json:"depth"` + Kind string `json:"kind"` + Label string `json:"label"` + Title string `json:"title,omitempty"` + Status string `json:"status"` + WordCount int `json:"word_count"` + HasFreshSummary bool `json:"has_fresh_summary"` +} + +type getOutlineOutput struct { + ProjectID string `json:"project_id"` + Title string `json:"title"` + Outline []outlineRow `json:"outline"` +} + +// ---------- linetta_get_story_context ---------- + +type getStoryContextInput struct { + NodeID string `json:"node_id" jsonschema:"id of the scene to build the brief for"` + // Section toggles map onto the writer's own context checklist. Omit them + // to get everything. + IncludeFacts *bool `json:"include_facts,omitempty"` + IncludeMemories *bool `json:"include_memories,omitempty"` + IncludeReferences *bool `json:"include_references,omitempty"` + IncludePlot *bool `json:"include_plot,omitempty"` +} + +func (in getStoryContextInput) scope() (string, string) { return "", in.NodeID } + +type getStoryContextOutput struct { + ProjectID string `json:"project_id"` + NodeID string `json:"node_id"` + SceneLabel string `json:"scene_label"` + Brief string `json:"brief"` + IncludedSections []string `json:"included_sections"` + EmptySections []string `json:"empty_sections"` +} + +// ---------- linetta_read_scene ---------- + +type readSceneInput struct { + NodeID string `json:"node_id" jsonschema:"id of the scene to read"` +} + +func (in readSceneInput) scope() (string, string) { return "", in.NodeID } + +type readSceneOutput struct { + NodeID string `json:"node_id"` + ProjectID string `json:"project_id"` + Label string `json:"label"` + Title string `json:"title,omitempty"` + Status string `json:"status"` + WordCount int `json:"word_count"` + ContentVersion int `json:"content_version"` + Body string `json:"body"` + Summary string `json:"summary,omitempty"` + SummaryIsStale bool `json:"summary_is_stale"` +} + +// ---------- linetta_search_manuscript ---------- + +type searchManuscriptInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work to search"` + Query string `json:"query" jsonschema:"words or phrase to find in the manuscript"` + Limit int `json:"limit,omitempty"` +} + +func (in searchManuscriptInput) scope() (string, string) { return in.ProjectID, "" } + +type searchHit struct { + NodeID string `json:"node_id"` + Label string `json:"label"` + Snippet string `json:"snippet"` +} + +type searchManuscriptOutput struct { + Hits []searchHit `json:"hits"` +} + +// ---------- linetta_list_characters ---------- + +type listCharactersInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work"` + Kind string `json:"kind,omitempty" jsonschema:"filter by character, place, item, or concept; omit for all"` +} + +func (in listCharactersInput) scope() (string, string) { return in.ProjectID, "" } + +type entityRow struct { + EntityID string `json:"entity_id"` + Kind string `json:"kind"` + Name string `json:"name"` + Role string `json:"role,omitempty"` + Summary string `json:"summary,omitempty"` + Attributes map[string]string `json:"attributes,omitempty"` +} + +type listCharactersOutput struct { + Entities []entityRow `json:"entities"` +} + +// ---------- linetta_where_does_appear ---------- + +type whereAppearsInput struct { + EntityID string `json:"entity_id" jsonschema:"id of the character, place, item, or concept"` +} + +func (in whereAppearsInput) scope() (string, string) { return "", in.EntityID } + +type appearanceRow struct { + NodeID string `json:"node_id"` + Label string `json:"label"` + Status string `json:"status"` +} + +type whereAppearsOutput struct { + EntityID string `json:"entity_id"` + Scenes []appearanceRow `json:"scenes"` +} + +// ---------- linetta_get_plot ---------- + +type getPlotInput struct { + NodeID string `json:"node_id" jsonschema:"a scene in the work; the plot spine is built around it"` +} + +func (in getPlotInput) scope() (string, string) { return "", in.NodeID } + +type getPlotOutput struct { + NodeID string `json:"node_id"` + Spine plot.Spine `json:"spine"` +} + +// ---------- linetta_get_fact_cards ---------- + +type getFactCardsInput struct { + ProjectID string `json:"project_id" jsonschema:"id of the work"` + NodeID string `json:"node_id,omitempty" jsonschema:"restrict to cards attached to this scene"` + Limit int `json:"limit,omitempty"` +} + +func (in getFactCardsInput) scope() (string, string) { return in.ProjectID, in.NodeID } + +type factRow struct { + FactID string `json:"fact_id"` + Status string `json:"status"` + Claim string `json:"claim"` + Result string `json:"result,omitempty"` + Category string `json:"category,omitempty"` + Sources []string `json:"sources,omitempty"` +} + +type getFactCardsOutput struct { + Cards []factRow `json:"cards"` +} + +// registerReadTools installs every read tool, each wrapped so the call lands +// in the activity log. +func (d ToolDeps) registerReadTools(s *mcp.Server) { + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_list_works", + Description: "List the writer's works (novels) with their ids, titles, and scene counts. " + + "Start here to find the project_id other tools need.", + }, record(d, "linetta_list_works", d.listWorks)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_get_outline", + Description: "Return the work's outline tree: parts, chapters, and scenes with their node ids, " + + "status, and word counts. Use it to locate the scene you need to read or write.", + }, record(d, "linetta_get_outline", d.getOutline)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_get_story_context", + Description: "Build the curated brief for one scene: outline, chapter summaries, the previous " + + "scene's summary, character and relationship briefs, plot beats, fact cards, memories, and the " + + "writer's style and POV targets. Call this before drafting or revising so the text stays " + + "consistent with the rest of the work. Empty summary sections mean nobody has summarized those " + + "scenes yet.", + }, record(d, "linetta_get_story_context", d.getStoryContext)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_read_scene", + Description: "Read one scene's text as plain prose, with its content_version. Any later write to " + + "this scene must pass the content_version you got here, so the writer's own edits are never " + + "silently overwritten.", + }, record(d, "linetta_read_scene", d.readScene)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_search_manuscript", + Description: "Full-text search across the work's manuscript. Returns matching scenes with snippets.", + }, record(d, "linetta_search_manuscript", d.searchManuscript)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_list_characters", + Description: "List the work's story elements — characters by default, or places, items, and " + + "concepts via the kind filter — with their roles, summaries, and attributes.", + }, record(d, "linetta_list_characters", d.listCharacters)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_where_does_appear", + Description: "List the scenes where one character, place, item, or concept is mentioned.", + }, record(d, "linetta_where_does_appear", d.whereAppears)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_get_plot", + Description: "Return the plot spine around a scene: storylines and their beats, in order.", + }, record(d, "linetta_get_plot", d.getPlot)) + + mcp.AddTool(s, &mcp.Tool{ + Name: "linetta_get_fact_cards", + Description: "Return the work's Fact Book cards: source-backed research notes with their " + + "verification status. Use them for real-world details instead of inventing facts.", + }, record(d, "linetta_get_fact_cards", d.getFactCards)) +} + +func (d ToolDeps) listWorks(ctx context.Context, _ *mcp.CallToolRequest, in listWorksInput) (*mcp.CallToolResult, listWorksOutput, error) { + projects, err := d.Projects.List(ctx, project.ListFilter{IncludeArchived: in.IncludeArchived}) + if err != nil { + return toolErr("could not list works: %v", err), listWorksOutput{}, nil + } + restricted := d.allowedProjectID() + out := listWorksOutput{Works: []workSummary{}} + for _, p := range projects { + if restricted != "" && p.ID != restricted { + continue + } + scenes := 0 + if all, err := d.Nodes.ListByProject(ctx, p.ID); err == nil { + for _, n := range all { + if n.Kind == node.KindLeaf { + scenes++ + } + } + } + status := "active" + if p.ArchivedAt != nil { + status = "archived" + } + out.Works = append(out.Works, workSummary{ + ProjectID: p.ID, + Title: p.Title, + Status: status, + Synopsis: p.Synopsis, + Genres: p.Genres, + SceneCount: scenes, + }) + } + return nil, out, nil +} + +func (d ToolDeps) getOutline(ctx context.Context, _ *mcp.CallToolRequest, in getOutlineInput) (*mcp.CallToolResult, getOutlineOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, getOutlineOutput{}, nil + } + all, err := d.Nodes.ListByProject(ctx, p.ID) + if err != nil { + return toolErr("could not read the outline: %v", err), getOutlineOutput{}, nil + } + out := getOutlineOutput{ProjectID: p.ID, Title: p.Title, Outline: outlineRows(all)} + return nil, out, nil +} + +// outlineRows flattens the tree in document order with a depth column, which +// is what an agent needs to understand structure without reconstructing it. +func outlineRows(all []node.Node) []outlineRow { + children := map[string][]node.Node{} + for _, n := range all { + key := "" + if n.ParentID != nil { + key = *n.ParentID + } + children[key] = append(children[key], n) + } + for key := range children { + sort.SliceStable(children[key], func(i, j int) bool { + return children[key][i].Ordinal < children[key][j].Ordinal + }) + } + rows := []outlineRow{} + var walk func(parent string, depth int) + walk = func(parent string, depth int) { + for _, n := range children[parent] { + parentID := "" + if n.ParentID != nil { + parentID = *n.ParentID + } + rows = append(rows, outlineRow{ + NodeID: n.ID, + ParentID: parentID, + Depth: depth, + Kind: n.Kind, + Label: n.Label, + Title: n.Title, + Status: n.Status, + WordCount: n.WordCount, + HasFreshSummary: n.Summary != "" && n.SummaryForVersion == n.ContentVersion, + }) + walk(n.ID, depth+1) + } + } + walk("", 0) + return rows +} + +func (d ToolDeps) getStoryContext(ctx context.Context, _ *mcp.CallToolRequest, in getStoryContextInput) (*mcp.CallToolResult, getStoryContextOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, getStoryContextOutput{}, nil + } + if d.Context == nil { + return toolErr("story context is unavailable in this build"), getStoryContextOutput{}, nil + } + opts := storycontext.Options{ + Context: storycontext.ContextSelection{ + Facts: in.IncludeFacts, + Memories: in.IncludeMemories, + References: in.IncludeReferences, + Plot: in.IncludePlot, + }, + } + c, err := d.Context.BuildFull(ctx, n.ID, "", "", opts) + if err != nil { + return toolErr("could not build the story brief: %v", err), getStoryContextOutput{}, nil + } + // Only the user half of the render is the brief; the system half carries + // tone and instruction scaffolding meant for Linetta's own runner. + _, brief := storycontext.Render(c) + included, empty := sectionReport(c) + return nil, getStoryContextOutput{ + ProjectID: n.ProjectID, + NodeID: n.ID, + SceneLabel: c.SceneLabel, + Brief: brief, + IncludedSections: included, + EmptySections: empty, + }, nil +} + +// sectionReport tells the agent what the brief actually carries. An empty +// summary section is the signal to go write one with linetta_write_summary. +func sectionReport(c storycontext.Context) (included, empty []string) { + c = storycontext.ApplyContextSelection(c) + checks := []struct { + name string + present bool + }{ + {"current_scene", strings.TrimSpace(c.SceneText) != ""}, + {"overview", strings.TrimSpace(c.Outline) != ""}, + {"synopsis", strings.TrimSpace(c.Hierarchical.ProjectSynopsis) != "" || strings.TrimSpace(c.Project.Synopsis) != ""}, + {"nearby_scene_summaries", len(c.Hierarchical.NearbyLeafSummaries) > 0}, + {"related_scenes", len(c.RelatedScenes) > 0}, + {"entities", len(c.Entities) > 0}, + {"relationships", len(c.Relationships) > 0}, + {"plot", spineHasBeats(c.Plot)}, + {"notes", len(c.Notes) > 0}, + {"facts", len(c.Facts) > 0}, + {"memories", len(c.Memories) > 0}, + {"references", len(c.References) > 0}, + {"style_notes", strings.TrimSpace(c.StyleNotes) != ""}, + } + included, empty = []string{}, []string{} + for _, ch := range checks { + if ch.present { + included = append(included, ch.name) + } else { + empty = append(empty, ch.name) + } + } + return included, empty +} + +func (d ToolDeps) readScene(ctx context.Context, _ *mcp.CallToolRequest, in readSceneInput) (*mcp.CallToolResult, readSceneOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, readSceneOutput{}, nil + } + if n.Kind != node.KindLeaf { + return toolErr("node %q is a container (%s), not a scene; only scenes have body text", n.ID, n.Label), + readSceneOutput{}, nil + } + return nil, readSceneOutput{ + NodeID: n.ID, + ProjectID: n.ProjectID, + Label: n.Label, + Title: n.Title, + Status: n.Status, + WordCount: n.WordCount, + ContentVersion: n.ContentVersion, + // Trimmed at the tool boundary, not in PlainText: the brief's renderer + // depends on that function's exact output. An untouched empty scene + // otherwise arrives as "\n", which an agent can misread as content. + Body: strings.TrimSpace(storycontext.PlainText(n.ContentDoc)), + Summary: n.Summary, + SummaryIsStale: n.Summary == "" || n.SummaryForVersion != n.ContentVersion, + }, nil +} + +func (d ToolDeps) searchManuscript(ctx context.Context, _ *mcp.CallToolRequest, in searchManuscriptInput) (*mcp.CallToolResult, searchManuscriptOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, searchManuscriptOutput{}, nil + } + q := strings.TrimSpace(in.Query) + if q == "" { + return toolErr("query is required"), searchManuscriptOutput{}, nil + } + if d.Manuscript == nil { + return toolErr("manuscript search is unavailable in this build"), searchManuscriptOutput{}, nil + } + limit := in.Limit + if limit <= 0 || limit > 100 { + limit = defaultSearchLimit + } + hits, err := d.Manuscript.Query(ctx, p.ID, q, limit) + if err != nil { + return toolErr("search failed: %v", err), searchManuscriptOutput{}, nil + } + out := searchManuscriptOutput{Hits: []searchHit{}} + for _, h := range hits { + out.Hits = append(out.Hits, searchHit{NodeID: h.NodeID, Label: h.Breadcrumb, Snippet: h.Snippet}) + } + return nil, out, nil +} + +func (d ToolDeps) listCharacters(ctx context.Context, _ *mcp.CallToolRequest, in listCharactersInput) (*mcp.CallToolResult, listCharactersOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, listCharactersOutput{}, nil + } + all, err := d.Entities.ListByProject(ctx, p.ID) + if err != nil { + return toolErr("could not list story elements: %v", err), listCharactersOutput{}, nil + } + kind := entityKindFilter(in.Kind) + out := listCharactersOutput{Entities: []entityRow{}} + for _, e := range all { + if kind != "" && !strings.EqualFold(e.Kind, kind) { + continue + } + out.Entities = append(out.Entities, entityRow{ + EntityID: e.ID, + Kind: e.Kind, + Name: e.Name, + Role: e.Role, + Summary: e.Summary, + Attributes: e.Attributes, + }) + } + return nil, out, nil +} + +func (d ToolDeps) whereAppears(ctx context.Context, _ *mcp.CallToolRequest, in whereAppearsInput) (*mcp.CallToolResult, whereAppearsOutput, error) { + entityID := strings.TrimSpace(in.EntityID) + if entityID == "" { + return toolErr("entity_id is required; call linetta_list_characters first"), whereAppearsOutput{}, nil + } + ent, err := d.Entities.Get(ctx, entityID) + if err != nil { + return toolErr("story element %q not found", entityID), whereAppearsOutput{}, nil + } + if _, errResult := d.requireProject(ctx, ent.ProjectID); errResult != nil { + return errResult, whereAppearsOutput{}, nil + } + ids, _, err := d.Mentions.MentionedNodeIDs(ctx, entityID) + if err != nil { + return toolErr("could not read mentions: %v", err), whereAppearsOutput{}, nil + } + out := whereAppearsOutput{EntityID: entityID, Scenes: []appearanceRow{}} + if len(ids) == 0 { + return nil, out, nil + } + mentioned := make(map[string]bool, len(ids)) + for _, id := range ids { + mentioned[id] = true + } + all, err := d.Nodes.ListByProject(ctx, ent.ProjectID) + if err != nil { + return toolErr("could not read the outline: %v", err), whereAppearsOutput{}, nil + } + for _, row := range outlineRows(all) { + if !mentioned[row.NodeID] { + continue + } + out.Scenes = append(out.Scenes, appearanceRow{NodeID: row.NodeID, Label: row.Label, Status: row.Status}) + } + return nil, out, nil +} + +func (d ToolDeps) getPlot(ctx context.Context, _ *mcp.CallToolRequest, in getPlotInput) (*mcp.CallToolResult, getPlotOutput, error) { + n, errResult := d.requireNode(ctx, in.NodeID) + if errResult != nil { + return errResult, getPlotOutput{}, nil + } + if d.Plot == nil { + return toolErr("plot is unavailable in this build"), getPlotOutput{}, nil + } + spine, err := d.Plot.Build(ctx, n.ID) + if err != nil { + return toolErr("could not build the plot spine: %v", err), getPlotOutput{}, nil + } + return nil, getPlotOutput{NodeID: n.ID, Spine: spine}, nil +} + +func (d ToolDeps) getFactCards(ctx context.Context, _ *mcp.CallToolRequest, in getFactCardsInput) (*mcp.CallToolResult, getFactCardsOutput, error) { + p, errResult := d.requireProject(ctx, in.ProjectID) + if errResult != nil { + return errResult, getFactCardsOutput{}, nil + } + if d.Facts == nil { + return toolErr("the Fact Book is unavailable in this build"), getFactCardsOutput{}, nil + } + filter := fact.ListFilter{ProjectID: p.ID, Limit: in.Limit} + if filter.Limit <= 0 || filter.Limit > 200 { + filter.Limit = 50 + } + if nodeID := strings.TrimSpace(in.NodeID); nodeID != "" { + if _, errResult := d.requireNode(ctx, nodeID); errResult != nil { + return errResult, getFactCardsOutput{}, nil + } + filter.NodeID = &nodeID + } + cards, err := d.Facts.List(ctx, filter) + if err != nil { + return toolErr("could not read the Fact Book: %v", err), getFactCardsOutput{}, nil + } + out := getFactCardsOutput{Cards: []factRow{}} + for _, c := range cards { + row := factRow{ + FactID: c.ID, + Status: c.Status, + Claim: c.Claim, + Result: c.Result, + Category: c.Category, + } + for _, src := range c.Sources { + if strings.TrimSpace(src.URL) != "" { + row.Sources = append(row.Sources, src.URL) + } + } + out.Cards = append(out.Cards, row) + } + return nil, out, nil +} + +// spineHasBeats mirrors the renderer's own emptiness check so the section +// report agrees with what the brief actually contains. +func spineHasBeats(s plot.Spine) bool { + if len(s.Current.Beats) > 0 { + return true + } + if s.Prev != nil && len(s.Prev.Beats) > 0 { + return true + } + return s.Next != nil && len(s.Next.Beats) > 0 +} diff --git a/engine/internal/rpc/handlers/diagnostics.go b/engine/internal/rpc/handlers/diagnostics.go index defd1ea2..c6fb8e01 100644 --- a/engine/internal/rpc/handlers/diagnostics.go +++ b/engine/internal/rpc/handlers/diagnostics.go @@ -16,6 +16,7 @@ import ( type Capabilities struct { UnavailableProviders []string GitSyncAvailable bool + MCPAvailable bool } type diagnosticsPayload struct { @@ -26,6 +27,7 @@ type diagnosticsPayload struct { MigrationCount int `json:"migration_count"` UnavailableProviders []string `json:"unavailable_providers,omitempty"` GitSyncAvailable bool `json:"git_sync_available"` + MCPAvailable bool `json:"mcp_available"` } type diagnosticsGetPayload struct { @@ -56,6 +58,7 @@ func DiagnosticsVersion(st *store.Store, home string, version string, caps Capab MigrationCount: int(count.Int64), UnavailableProviders: caps.UnavailableProviders, GitSyncAvailable: caps.GitSyncAvailable, + MCPAvailable: caps.MCPAvailable, } return json.Marshal(payload) } diff --git a/engine/internal/rpc/handlers/mcp.go b/engine/internal/rpc/handlers/mcp.go new file mode 100644 index 00000000..7050f80f --- /dev/null +++ b/engine/internal/rpc/handlers/mcp.go @@ -0,0 +1,119 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + + "github.com/devlikebear/linetta/engine/internal/rpc" +) + +// MCPController is the slice of the MCP host the RPC layer needs. Declared as +// an interface so this file compiles on every build tag — the host itself is +// //go:build !mobile. +type MCPController interface { + Status() (json.RawMessage, error) + Enable(ctx context.Context) error + Disable(ctx context.Context) error + RegenerateToken(ctx context.Context) (json.RawMessage, error) + Activity(ctx context.Context, limit int) (json.RawMessage, error) +} + +// ErrMCPPortInUse lets the host report a taken port without the RPC layer +// importing mcphost. The renderer turns the reason code into a localized +// "port is in use, pick another" message. +var ErrMCPPortInUse = errors.New("mcp port in use") + +// ErrMCPConsentRequired means MCP access has not been accepted yet. +var ErrMCPConsentRequired = errors.New("mcp consent required") + +func mcpError(err error) error { + switch { + case errors.Is(err, ErrMCPPortInUse): + return &rpc.MethodError{ + Code: rpc.CodeInvalidParams, + Message: err.Error(), + Data: rpc.ReasonData("mcp_port_in_use"), + } + case errors.Is(err, ErrMCPConsentRequired): + return &rpc.MethodError{ + Code: rpc.CodeInvalidParams, + Message: err.Error(), + Data: rpc.ReasonData("mcp_consent_required"), + } + default: + return &rpc.MethodError{Code: rpc.CodeInternalError, Message: err.Error()} + } +} + +// MCPStatus returns a handler for mcp.status. +func MCPStatus(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + out, err := ctrl.Status() + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} + +// MCPEnable returns a handler for mcp.enable. +func MCPEnable(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + if err := ctrl.Enable(ctx); err != nil { + return nil, mcpError(err) + } + out, err := ctrl.Status() + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} + +// MCPDisable returns a handler for mcp.disable. This is the kill switch: it +// drops the listener immediately. +func MCPDisable(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + if err := ctrl.Disable(ctx); err != nil { + return nil, mcpError(err) + } + out, err := ctrl.Status() + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} + +// MCPRegenerateToken returns a handler for mcp.regenerate_token. The new token +// is returned once so the settings pane can render a fresh client snippet; +// settings.get never exposes it again. +func MCPRegenerateToken(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, _ json.RawMessage) (json.RawMessage, error) { + out, err := ctrl.RegenerateToken(ctx) + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} + +type mcpActivityParams struct { + Limit int `json:"limit,omitempty"` +} + +// MCPActivity returns a handler for mcp.activity: what external agents did. +func MCPActivity(ctrl MCPController) rpc.Handler { + return func(ctx context.Context, params json.RawMessage) (json.RawMessage, error) { + var p mcpActivityParams + if len(params) > 0 { + _ = json.Unmarshal(params, &p) + } + out, err := ctrl.Activity(ctx, p.Limit) + if err != nil { + return nil, mcpError(err) + } + return out, nil + } +} diff --git a/engine/internal/settings/mcp.go b/engine/internal/settings/mcp.go new file mode 100644 index 00000000..e7bb5711 --- /dev/null +++ b/engine/internal/settings/mcp.go @@ -0,0 +1,150 @@ +package settings + +import ( + "crypto/rand" + "encoding/base64" + "fmt" + "os" + "path/filepath" + "strings" +) + +// MCP access modes. off is the default: no listener binds until the writer +// turns it on. read_only registers only the read tools, so a misbehaving agent +// cannot reach a write tool at all — the guarantee is "not registered", not +// "registered and refused". +const ( + MCPModeOff = "off" + MCPModeReadOnly = "read_only" + MCPModeFull = "full" +) + +// DefaultMCPPort is fixed rather than ephemeral: a client config is written +// once and reused for months, and Claude Code has no client-side way to absorb +// a moving URL. A busy port surfaces as a visible error instead of a silent +// fallback. +const DefaultMCPPort = 7391 + +// MCPConsentVersion is the current MCP data-sharing consent revision. Separate +// from the AI provider consent: that one covers text Linetta sends to a +// provider it configured, this one covers a third-party client Linetta does +// not control. +const MCPConsentVersion = 1 + +// ValidMCPModes returns the accepted mcp_mode values. +func ValidMCPModes() []string { + return []string{MCPModeOff, MCPModeReadOnly, MCPModeFull} +} + +// MCPMode returns the configured access mode, normalized. +func (s *Store) MCPMode() string { + s.mu.RLock() + defer s.mu.RUnlock() + mode := s.cfg.MCPMode + for _, valid := range ValidMCPModes() { + if mode == valid { + return mode + } + } + return MCPModeOff +} + +// MCPPort returns the configured loopback port, normalized. +func (s *Store) MCPPort() int { + s.mu.RLock() + defer s.mu.RUnlock() + if s.cfg.MCPPort < 1024 || s.cfg.MCPPort > 65535 { + return DefaultMCPPort + } + return s.cfg.MCPPort +} + +// MCPProjectID returns the work the server is restricted to, or "" for all. +func (s *Store) MCPProjectID() string { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.MCPProjectID +} + +// HasMCPConsent reports whether the writer accepted the current MCP consent +// revision. The host refuses to start without it. +func (s *Store) HasMCPConsent() bool { + s.mu.RLock() + defer s.mu.RUnlock() + return s.cfg.MCPConsentVersion >= MCPConsentVersion +} + +// mcpTokenFileName holds the bearer token on platforms with no secure secret +// backend (Linux, as of today — see secrets_unsupported.go). +// +// Plaintext at 0600 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 credentials for third-party accounts, and quietly starting to +// store them in plaintext is not a change a writer opted into. +const mcpTokenFileName = "mcp-token" + +func (s *Store) mcpTokenPath() string { + return filepath.Join(s.dir, mcpTokenFileName) +} + +// MCPToken returns the bearer token, or "" when none has been generated. +func (s *Store) MCPToken() string { + if secret, ok, err := s.secrets.Get(mcpTokenSecretName); err == nil && ok { + return secret + } + raw, err := os.ReadFile(s.mcpTokenPath()) + if err != nil { + return "" + } + return strings.TrimSpace(string(raw)) +} + +// MCPTokenExists reports whether a token has been minted, WITHOUT reading its +// value. settings.get must never read secret values — on macOS that can prompt +// the Keychain, and the redacted view only needs presence. +func (s *Store) MCPTokenExists() bool { + if ok, err := s.secrets.Exists(mcpTokenSecretName); err == nil && ok { + return true + } + _, err := os.Stat(s.mcpTokenPath()) + return err == nil +} + +// EnsureMCPToken returns the existing token, generating one on first use so +// enabling MCP never leaves the server unauthenticated. +func (s *Store) EnsureMCPToken() (string, error) { + if token := s.MCPToken(); token != "" { + return token, nil + } + return s.RegenerateMCPToken() +} + +// RegenerateMCPToken issues a fresh token, invalidating every client config +// that carried the old one. +func (s *Store) RegenerateMCPToken() (string, error) { + buf := make([]byte, 32) + if _, err := rand.Read(buf); err != nil { + return "", fmt.Errorf("generate mcp token: %w", err) + } + token := base64.RawURLEncoding.EncodeToString(buf) + if err := s.secrets.Set(mcpTokenSecretName, token); err != nil { + // No secure backend on this platform. Fall back to a 0600 file rather + // than leaving MCP unusable — Linux is a shipping platform, and a + // server that cannot mint a token cannot start at all. + if writeErr := os.WriteFile(s.mcpTokenPath(), []byte(token), 0o600); writeErr != nil { + return "", fmt.Errorf("store mcp token: %w", err) + } + } + return token, nil +} + +// DeleteMCPToken removes the token entirely, from both possible locations. +func (s *Store) DeleteMCPToken() error { + err := s.secrets.Delete(mcpTokenSecretName) + if rmErr := os.Remove(s.mcpTokenPath()); rmErr != nil && !os.IsNotExist(rmErr) && err == nil { + err = rmErr + } + return err +} diff --git a/engine/internal/settings/mcp_test.go b/engine/internal/settings/mcp_test.go new file mode 100644 index 00000000..9728fb27 --- /dev/null +++ b/engine/internal/settings/mcp_test.go @@ -0,0 +1,263 @@ +package settings + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func configJSON(t *testing.T, c Config) string { + t.Helper() + raw, err := json.Marshal(c) + if err != nil { + t.Fatalf("marshal config: %v", err) + } + return string(raw) +} + +func newMCPStore(t *testing.T) *Store { + t.Helper() + t.Setenv("LINETTA_HOME", t.TempDir()) + s, err := NewWithSecretStore(NewMemorySecretStore()) + if err != nil { + t.Fatalf("NewWithSecretStore: %v", err) + } + return s +} + +// MCP must be inert until the writer turns it on. +func TestMCPDefaultsAreOff(t *testing.T) { + s := newMCPStore(t) + if got := s.MCPMode(); got != MCPModeOff { + t.Errorf("default mode = %q, want %q", got, MCPModeOff) + } + if got := s.MCPPort(); got != DefaultMCPPort { + t.Errorf("default port = %d, want %d", got, DefaultMCPPort) + } + if s.HasMCPConsent() { + t.Error("consent must not be granted by default") + } + if s.MCPToken() != "" { + t.Error("no token should exist before MCP is enabled") + } +} + +func TestMCPModeRoundTripsAndRejectsUnknown(t *testing.T) { + s := newMCPStore(t) + ctx := context.Background() + for _, mode := range ValidMCPModes() { + if _, err := s.Set(ctx, Patch{MCPMode: &mode}); err != nil { + t.Fatalf("Set(%q): %v", mode, err) + } + if got := s.MCPMode(); got != mode { + t.Errorf("mode = %q, want %q", got, mode) + } + } + bogus := "wide_open" + if _, err := s.Set(ctx, Patch{MCPMode: &bogus}); err == nil { + t.Fatal("an unknown mode must be rejected, not silently accepted") + } +} + +// A corrupt or hand-edited value must degrade to off, never to an open server. +func TestUnknownModeOnDiskFallsBackToOff(t *testing.T) { + c := normalizeMCPPreferences(Config{MCPMode: "full-access-please", MCPPort: DefaultMCPPort}) + if c.MCPMode != MCPModeOff { + t.Fatalf("mode = %q, want %q", c.MCPMode, MCPModeOff) + } +} + +func TestMCPPortValidation(t *testing.T) { + s := newMCPStore(t) + ctx := context.Background() + ok := 8123 + if _, err := s.Set(ctx, Patch{MCPPort: &ok}); err != nil { + t.Fatalf("Set(port): %v", err) + } + if got := s.MCPPort(); got != ok { + t.Errorf("port = %d, want %d", got, ok) + } + for _, bad := range []int{0, 80, 70000} { + if _, err := s.Set(ctx, Patch{MCPPort: &bad}); err == nil { + t.Errorf("port %d should be rejected", bad) + } + } + if c := normalizeMCPPreferences(Config{MCPMode: MCPModeOff, MCPPort: 42}); c.MCPPort != DefaultMCPPort { + t.Errorf("out-of-range disk value = %d, want default %d", c.MCPPort, DefaultMCPPort) + } +} + +// The token follows the api_key convention: stored in the secret store, never +// returned by settings.get, exposed only as a presence flag. +func TestMCPTokenIsRedactedAndPresenceOnly(t *testing.T) { + s := newMCPStore(t) + token, err := s.EnsureMCPToken() + if err != nil { + t.Fatalf("EnsureMCPToken: %v", err) + } + if len(token) < 32 { + t.Fatalf("token looks too short: %q", token) + } + if again, _ := s.EnsureMCPToken(); again != token { + t.Error("EnsureMCPToken must reuse the existing token") + } + + got, err := s.Get(context.Background()) + if err != nil { + t.Fatalf("Get: %v", err) + } + if !got.MCPTokenSet { + t.Error("mcp_token_set should be true once a token exists") + } + blob := configJSON(t, got) + if strings.Contains(blob, token) { + t.Fatal("settings.get leaked the raw MCP token") + } + + rotated, err := s.RegenerateMCPToken() + if err != nil { + t.Fatalf("RegenerateMCPToken: %v", err) + } + if rotated == token { + t.Error("regenerating must issue a different token") + } + if err := s.DeleteMCPToken(); err != nil { + t.Fatalf("DeleteMCPToken: %v", err) + } + if s.MCPToken() != "" { + t.Error("token should be gone after delete") + } +} + +// The disk file must never carry the presence flag (it is derived state). +func TestMCPTokenFlagNotPersisted(t *testing.T) { + c := sanitizeConfigForDisk(Config{MCPTokenSet: true}) + if c.MCPTokenSet { + t.Fatal("mcp_token_set must be cleared before writing settings.json") + } +} + +func TestMCPConsentGate(t *testing.T) { + s := newMCPStore(t) + ctx := context.Background() + if s.HasMCPConsent() { + t.Fatal("consent must start ungranted") + } + version := MCPConsentVersion + at := int64(1_700_000_000_000) + if _, err := s.Set(ctx, Patch{MCPConsentVersion: &version, MCPConsentedAt: &at}); err != nil { + t.Fatalf("Set(consent): %v", err) + } + if !s.HasMCPConsent() { + t.Error("consent should be granted after accepting the current revision") + } +} + +func TestMCPProjectRestriction(t *testing.T) { + s := newMCPStore(t) + if s.MCPProjectID() != "" { + t.Fatal("no restriction by default") + } + id := "proj-1" + if _, err := s.Set(context.Background(), Patch{MCPProjectID: &id}); err != nil { + t.Fatalf("Set(project): %v", err) + } + if got := s.MCPProjectID(); got != id { + t.Errorf("project = %q, want %q", got, id) + } +} + +// Regression: settings.Set returned the new mode in its response while +// persist() silently dropped it, because persist copies an explicit field +// list. MCP would come back off after every app restart, and no in-memory +// assertion could catch it — the check has to survive a reload from disk. +func TestMCPSettingsSurviveReload(t *testing.T) { + t.Setenv("LINETTA_HOME", t.TempDir()) + secrets := NewMemorySecretStore() + s, err := NewWithSecretStore(secrets) + if err != nil { + t.Fatalf("NewWithSecretStore: %v", err) + } + mode := MCPModeReadOnly + port := 8321 + projectID := "work-1" + version := MCPConsentVersion + at := int64(1_700_000_000_000) + if _, err := s.Set(context.Background(), Patch{ + MCPMode: &mode, + MCPPort: &port, + MCPProjectID: &projectID, + MCPConsentVersion: &version, + MCPConsentedAt: &at, + }); err != nil { + t.Fatalf("Set: %v", err) + } + + reloaded, err := NewWithSecretStore(secrets) + if err != nil { + t.Fatalf("reload: %v", err) + } + if got := reloaded.MCPMode(); got != mode { + t.Errorf("after reload mode = %q, want %q", got, mode) + } + if got := reloaded.MCPPort(); got != port { + t.Errorf("after reload port = %d, want %d", got, port) + } + if got := reloaded.MCPProjectID(); got != projectID { + t.Errorf("after reload project = %q, want %q", got, projectID) + } + if !reloaded.HasMCPConsent() { + t.Error("after reload consent was lost") + } +} + +// Regression: Linux has no secure secret backend, so EnsureMCPToken failed and +// MCP could not start at all. CI caught it; a Windows-only run never would. +// The fallback keeps the server usable on that platform. +func TestMCPTokenFallsBackToAFileWithoutASecureStore(t *testing.T) { + home := t.TempDir() + t.Setenv("LINETTA_HOME", home) + s, err := NewWithSecretStore(unsupportedSecretStore{}) + if err != nil { + t.Fatalf("NewWithSecretStore: %v", err) + } + + token, err := s.EnsureMCPToken() + if err != nil { + t.Fatalf("EnsureMCPToken without a secure store: %v", err) + } + if token == "" { + t.Fatal("a token must be minted even without a secure backend") + } + if got := s.MCPToken(); got != token { + t.Fatalf("MCPToken() = %q, want the token just minted", got) + } + if again, _ := s.EnsureMCPToken(); again != token { + t.Error("the fallback token must be reused, not reminted on every call") + } + + path := filepath.Join(home, mcpTokenFileName) + info, err := os.Stat(path) + if err != nil { + t.Fatalf("stat token file: %v", err) + } + if runtime.GOOS != "windows" { + if perm := info.Mode().Perm(); perm != 0o600 { + t.Errorf("token file mode = %o, want 600", perm) + } + } + + if err := s.DeleteMCPToken(); err != nil { + t.Fatalf("DeleteMCPToken: %v", err) + } + if s.MCPToken() != "" { + t.Error("the fallback token should be gone after delete") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Error("the token file should be removed too") + } +} diff --git a/engine/internal/settings/secrets.go b/engine/internal/settings/secrets.go index a7171c13..6ffce7f8 100644 --- a/engine/internal/settings/secrets.go +++ b/engine/internal/settings/secrets.go @@ -7,6 +7,10 @@ import ( const webSearchAPIKeySecretName = "web_search.api_key" +// mcpTokenSecretName holds the bearer token external MCP clients present to +// the local server. Kept in the secret store, never in settings.json. +const mcpTokenSecretName = "mcp.token" + func providerAPIKeySecretName(provider string) string { return "provider." + provider + ".api_key" } diff --git a/engine/internal/settings/settings.go b/engine/internal/settings/settings.go index d92a26b6..ccdf6e5c 100644 --- a/engine/internal/settings/settings.go +++ b/engine/internal/settings/settings.go @@ -131,6 +131,12 @@ type Config struct { WebSearchProvider string `json:"web_search_provider"` WebSearchAPIKey string `json:"web_search_api_key,omitempty"` // write-only in settings.set; redacted from settings.get and disk WebSearchAPIKeySet bool `json:"web_search_api_key_set,omitempty"` // read-only presence flag for settings.get + MCPMode string `json:"mcp_mode"` // off | read_only | full; off means no listener binds + MCPPort int `json:"mcp_port"` // fixed so saved client configs survive restarts + MCPProjectID string `json:"mcp_project_id"` // empty means every work is reachable + MCPConsentVersion int `json:"mcp_consent_version"` + MCPConsentedAt int64 `json:"mcp_consented_at"` + MCPTokenSet bool `json:"mcp_token_set,omitempty"` // read-only presence flag for settings.get } // Patch holds optional updates. Nil pointers mean "leave the field alone". @@ -156,6 +162,11 @@ type Patch struct { AIDataSharingConsentedAt *int64 `json:"ai_data_sharing_consented_at,omitempty"` WebSearchProvider *string `json:"web_search_provider,omitempty"` WebSearchAPIKey *string `json:"web_search_api_key,omitempty"` + MCPMode *string `json:"mcp_mode,omitempty"` + MCPPort *int `json:"mcp_port,omitempty"` + MCPProjectID *string `json:"mcp_project_id,omitempty"` + MCPConsentVersion *int `json:"mcp_consent_version,omitempty"` + MCPConsentedAt *int64 `json:"mcp_consented_at,omitempty"` } // Store reads and writes the settings file with internal locking. @@ -222,6 +233,8 @@ func defaults(home string) Config { BackupDir: filepath.Join(home, "backups"), OnboardingTourEnabled: true, WebSearchProvider: "brave", + MCPMode: MCPModeOff, + MCPPort: DefaultMCPPort, } } @@ -281,6 +294,20 @@ func (s *Store) load() error { if disk.WebSearchProvider != "" { s.cfg.WebSearchProvider = disk.WebSearchProvider } + // MCP settings written by a newer build must survive a reload. Blank or + // out-of-range values (including a file written by a build that predates + // these keys) keep the defaults, and normalizeMCPPreferences below is the + // final guard that an unrecognized mode never becomes an open server. + if disk.MCPMode != "" { + s.cfg.MCPMode = disk.MCPMode + } + if disk.MCPPort != 0 { + s.cfg.MCPPort = disk.MCPPort + } + s.cfg.MCPProjectID = disk.MCPProjectID + s.cfg.MCPConsentVersion = disk.MCPConsentVersion + s.cfg.MCPConsentedAt = disk.MCPConsentedAt + s.cfg = normalizeMCPPreferences(s.cfg) migratedProviderKeys, migratedWebKey, err := s.migrateLegacySecrets(&disk) if err != nil { s.mu.Unlock() @@ -514,6 +541,27 @@ func (s *Store) Set(ctx context.Context, p Patch) (Config, error) { } next.WebSearchAPIKey = "" } + if p.MCPMode != nil { + if !slices.Contains(ValidMCPModes(), *p.MCPMode) { + return Config{}, fmt.Errorf("settings: unknown mcp_mode %q", *p.MCPMode) + } + next.MCPMode = *p.MCPMode + } + if p.MCPPort != nil { + if *p.MCPPort < 1024 || *p.MCPPort > 65535 { + return Config{}, fmt.Errorf("settings: mcp_port %d out of range (1024-65535)", *p.MCPPort) + } + next.MCPPort = *p.MCPPort + } + if p.MCPProjectID != nil { + next.MCPProjectID = *p.MCPProjectID + } + if p.MCPConsentVersion != nil { + next.MCPConsentVersion = *p.MCPConsentVersion + } + if p.MCPConsentedAt != nil { + next.MCPConsentedAt = *p.MCPConsentedAt + } if next.WebSearchProvider == "" { next.WebSearchProvider = "brave" } @@ -566,6 +614,11 @@ func (s *Store) persist(next Config) error { AIDataSharingConsentVersion: next.AIDataSharingConsentVersion, AIDataSharingConsentedAt: next.AIDataSharingConsentedAt, WebSearchProvider: next.WebSearchProvider, + MCPMode: next.MCPMode, + MCPPort: next.MCPPort, + MCPProjectID: next.MCPProjectID, + MCPConsentVersion: next.MCPConsentVersion, + MCPConsentedAt: next.MCPConsentedAt, } body, err := json.MarshalIndent(persistable, "", " ") if err != nil { @@ -686,6 +739,20 @@ func normalizeEditorPreferences(c Config) Config { if !slices.Contains(validCopyProfiles(), c.CopyProfile) { c.CopyProfile = "plain" } + return normalizeMCPPreferences(c) +} + +// normalizeMCPPreferences keeps MCP settings safe by construction: an +// unrecognized mode falls back to off (never to an open server), and an +// out-of-range port falls back to the default so a bad value cannot make the +// server unreachable in a way the writer cannot see. +func normalizeMCPPreferences(c Config) Config { + if !slices.Contains(ValidMCPModes(), c.MCPMode) { + c.MCPMode = MCPModeOff + } + if c.MCPPort < 1024 || c.MCPPort > 65535 { + c.MCPPort = DefaultMCPPort + } return c } @@ -706,6 +773,9 @@ func (s *Store) redactedSettingsView(c Config) Config { if err == nil { c.WebSearchAPIKeySet = webKeySet } + // Presence only — never the value: settings.get must not read secrets, and + // the check has to see the 0600 file fallback too. + c.MCPTokenSet = s.MCPTokenExists() return c } @@ -724,6 +794,7 @@ func sanitizeConfigForMemory(c Config) Config { func sanitizeConfigForDisk(c Config) Config { c = sanitizeConfigForMemory(c) c.WebSearchAPIKeySet = false + c.MCPTokenSet = false providers := map[string]ProviderConfig{} for id, cfg := range c.Providers { cfg.APIKeySet = false diff --git a/engine/internal/store/migrations/0016_mcp_activity.sql b/engine/internal/store/migrations/0016_mcp_activity.sql new file mode 100644 index 00000000..219d6279 --- /dev/null +++ b/engine/internal/store/migrations/0016_mcp_activity.sql @@ -0,0 +1,18 @@ +-- Audit trail for tools called by external MCP clients. This is the writer's +-- answer to "what did the agent do while I was asleep": every tool call, read +-- or write, success or failure, lands here and is shown in Settings. +-- +-- project_id is intentionally NOT a foreign key: the log must survive the work +-- it refers to, so deleting a project never erases the record of what was done +-- to it. +CREATE TABLE mcp_activity ( + id TEXT PRIMARY KEY, + at INTEGER NOT NULL, + tool TEXT NOT NULL, + project_id TEXT NOT NULL DEFAULT '', + target_id TEXT NOT NULL DEFAULT '', + ok INTEGER NOT NULL DEFAULT 1, + detail TEXT NOT NULL DEFAULT '' +); + +CREATE INDEX idx_mcp_activity_at ON mcp_activity(at DESC); diff --git a/engine/internal/storycontext/builder.go b/engine/internal/storycontext/builder.go index f8509121..8c2655a9 100644 --- a/engine/internal/storycontext/builder.go +++ b/engine/internal/storycontext/builder.go @@ -628,6 +628,11 @@ func (b *ContextBuilder) findPreviousLeaf(ctx context.Context, cur node.Node) (* // docToPlainText walks a Tiptap doc and concatenates text content. Mentions are // rendered as `@label`. Block boundaries become newlines. +// PlainText renders a stored Tiptap document as plain text, the same way the +// story brief does. Exported so MCP tools return prose rather than editor JSON +// without duplicating the walker. +func PlainText(rawDoc *string) string { return docToPlainText(rawDoc) } + func docToPlainText(rawDoc *string) string { if rawDoc == nil || *rawDoc == "" { return ""