diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index b116f8cc..ce9d4286 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -148,6 +148,22 @@ jobs: rm -f "${cert_path}" echo "api_key_path=${key_path}" >> "${GITHUB_OUTPUT}" + - name: Build MCP bridge + shell: bash + env: + # Signed with the same Developer ID as the app: notarytool scans every + # Mach-O in the bundle, and Go's ad-hoc signature without a hardened + # runtime fails notarization. + APPLE_SIGNING_IDENTITY: ${{ steps.apple_signing.outputs.configured == 'true' && secrets.APPLE_SIGNING_IDENTITY || '' }} + run: | + set -euo pipefail + bash scripts/build-mcp-bridge.sh + if [ "${RUNNER_OS}" = "macOS" ] && [ -n "${APPLE_SIGNING_IDENTITY}" ]; then + bridge=apps/desktop/src-tauri/resources/linetta-mcp + codesign --force --timestamp --options runtime --sign "${APPLE_SIGNING_IDENTITY}" "${bridge}" + codesign --verify --verbose=2 "${bridge}" + fi + - name: Build Tauri app shell: bash working-directory: apps/desktop @@ -212,6 +228,15 @@ jobs: run: | set -euo pipefail mkdir -p dist + # The bridge also ships standalone: Mac App Store builds do not bundle + # it, so those writers need a download. A missing file fails the job, + # which is the gate against releasing a bridge-less build. + bridge="../../../resources/linetta-mcp" + if [ "${{ matrix.artifact }}" = "windows" ]; then + cp "${bridge}.exe" "dist/linetta-mcp-windows.exe" + else + cp "${bridge}" "dist/linetta-mcp-${{ matrix.artifact }}" + fi case "${{ matrix.artifact }}" in macos) tar -czf dist/Linetta-macos.app.tar.gz -C macos Linetta.app diff --git a/.gitignore b/.gitignore index e65fbced..96f335f0 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,9 @@ apps/desktop/vite.config.js # Rust / Tauri apps/desktop/src-tauri/target/ apps/desktop/src-tauri/gen/ +# Built by scripts/build-mcp-bridge.sh; the directory's README.md is committed. +apps/desktop/src-tauri/resources/linetta-mcp +apps/desktop/src-tauri/resources/linetta-mcp.exe # Local app data (engine writes here in dev when LINETTA_HOME=./.linetta) /.linetta/ diff --git a/Makefile b/Makefile index a027b92a..e37e6be0 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help dev test test-go test-desktop test-tauri test-mobile-engine audit audit-go audit-desktop audit-rust validate-actions-runtime validate-distribution build-engine build-mobile-engine-ios build-mobile-engine-android mobile-ios-init mobile-android-init build-mobile-ios-sim smoke-mobile-ios-sim dev-mobile-ios build-mobile-android-debug build-mobile-android-release-smoke patch-mobile-android-signing build-desktop release-macos-local build-mas-local release-mas-local bump-version ci +.PHONY: help dev test test-go test-desktop test-tauri test-mobile-engine audit audit-go audit-desktop audit-rust validate-actions-runtime validate-distribution build-engine build-mcp-bridge build-mobile-engine-ios build-mobile-engine-android mobile-ios-init mobile-android-init build-mobile-ios-sim smoke-mobile-ios-sim dev-mobile-ios build-mobile-android-debug build-mobile-android-release-smoke patch-mobile-android-signing build-desktop release-macos-local build-mas-local release-mas-local bump-version ci help: ## Show available targets @awk 'BEGIN {FS = ":.*##"} /^[a-zA-Z0-9_-]+:.*##/ {printf "%-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST) @@ -8,7 +8,7 @@ dev: ## Start the desktop app in dev mode test: test-go test-desktop test-tauri ## Run all local verification -ci: validate-actions-runtime test ## Run the CI verification contract +ci: validate-actions-runtime validate-distribution test ## Run the CI verification contract audit: audit-go audit-desktop audit-rust ## Check reachable and locked dependency vulnerabilities @@ -48,6 +48,9 @@ validate-distribution: ## Validate release packaging metadata build-engine: ## Build the standalone JSONRPC debug engine bash scripts/build-engine.sh +build-mcp-bridge: ## Build the stdio MCP bridge Claude Desktop launches + bash scripts/build-mcp-bridge.sh + build-mobile-engine-ios: ## Build the embedded Go engine xcframework for iOS bash apps/desktop/src-tauri/scripts/build-engine-ios.sh @@ -80,7 +83,7 @@ build-mobile-android-release-smoke: ## Build signed Android release APK/AAB with patch-mobile-android-signing: ## Patch generated Android Gradle signing from keystore.properties bash scripts/patch-tauri-android-signing.sh -build-desktop: ## Build the desktop release binary for the current OS +build-desktop: build-mcp-bridge ## Build the desktop release binary for the current OS cd apps/desktop && pnpm tauri build --no-bundle release-macos-local: ## Build, sign, notarize, and staple the macOS app + dmg locally diff --git a/apps/desktop/src-tauri/entitlements/linetta-mas.entitlements b/apps/desktop/src-tauri/entitlements/linetta-mas.entitlements index c85cd2bc..d3f6ee86 100644 --- a/apps/desktop/src-tauri/entitlements/linetta-mas.entitlements +++ b/apps/desktop/src-tauri/entitlements/linetta-mas.entitlements @@ -6,6 +6,8 @@ com.apple.security.network.client + com.apple.security.network.server + com.apple.security.files.user-selected.read-write com.apple.security.files.bookmarks.app-scope diff --git a/apps/desktop/src-tauri/resources/README.md b/apps/desktop/src-tauri/resources/README.md new file mode 100644 index 00000000..774f71ac --- /dev/null +++ b/apps/desktop/src-tauri/resources/README.md @@ -0,0 +1,12 @@ +# Bundled resources + +Build outputs that ship inside the app bundle land here. They are produced by +the build, not committed: + +- `linetta-mcp` (`linetta-mcp.exe` on Windows) — the stdio MCP bridge that + Claude Desktop launches. Built by `make build-mcp-bridge`. + +This file is committed on purpose. `bundle.resources` globs this directory, and +Tauri treats a glob that matches nothing as a build error — so an empty +directory would break `cargo build` for anyone who has not built the bridge +yet. diff --git a/apps/desktop/src-tauri/src/lib.rs b/apps/desktop/src-tauri/src/lib.rs index f0f9691d..0905eba4 100644 --- a/apps/desktop/src-tauri/src/lib.rs +++ b/apps/desktop/src-tauri/src/lib.rs @@ -62,6 +62,11 @@ const RENDERER_ENGINE_METHODS: &[&str] = &[ "manuscript.replace_apply", "manuscript.replace_preview", "manuscript.search", + "mcp.activity", + "mcp.disable", + "mcp.enable", + "mcp.regenerate_token", + "mcp.status", "mentions.list_for_node", "nodes.convert_to_container", "nodes.create_child", @@ -175,6 +180,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ engine_ping, engine_call, + mcp_bridge_path, engine_status, open_recovery_folder, restore_latest_backup, @@ -204,6 +210,26 @@ struct EngineStatus { migration_count: Option, } +/// Absolute path to the bundled MCP bridge, or None when it is not present +/// (the Mac App Store build ships without it, and a dev build may not have run +/// the build script yet). The settings pane prints this into the writer's +/// client config, so it must be the real installed path, not a guess. +#[tauri::command] +fn mcp_bridge_path(app: tauri::AppHandle) -> Option { + let name = if cfg!(target_os = "windows") { + "linetta-mcp.exe" + } else { + "linetta-mcp" + }; + let candidate = app.path().resource_dir().ok()?.join("resources").join(name); + if candidate.is_file() { + return Some(candidate.to_string_lossy().into_owned()); + } + // Dev builds run from the crate directory rather than a bundle. + let dev = std::env::current_dir().ok()?.join("src-tauri/resources").join(name); + dev.is_file().then(|| dev.to_string_lossy().into_owned()) +} + #[tauri::command] async fn engine_ping(state: tauri::State<'_, EngineState>) -> Result { let engine = engine_handle(state.inner())?; diff --git a/apps/desktop/src-tauri/tauri.conf.json b/apps/desktop/src-tauri/tauri.conf.json index 8599659b..f594ef39 100644 --- a/apps/desktop/src-tauri/tauri.conf.json +++ b/apps/desktop/src-tauri/tauri.conf.json @@ -30,6 +30,9 @@ }, "bundle": { "active": true, + "resources": [ + "resources/*" + ], "targets": [ "app", "dmg" diff --git a/apps/desktop/src-tauri/tauri.mas.conf.json b/apps/desktop/src-tauri/tauri.mas.conf.json index eb739a06..9bb2cf27 100644 --- a/apps/desktop/src-tauri/tauri.mas.conf.json +++ b/apps/desktop/src-tauri/tauri.mas.conf.json @@ -2,6 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "bundle": { "category": "Productivity", + "resources": [], "macOS": { "entitlements": "entitlements/linetta-mas.entitlements", "minimumSystemVersion": "12.0" diff --git a/apps/desktop/src-tauri/tauri.windows.conf.json b/apps/desktop/src-tauri/tauri.windows.conf.json index a78a87bb..d61dbbcf 100644 --- a/apps/desktop/src-tauri/tauri.windows.conf.json +++ b/apps/desktop/src-tauri/tauri.windows.conf.json @@ -2,7 +2,8 @@ "$schema": "https://schema.tauri.app/config/2", "bundle": { "resources": { - "gen/windows/linetta_engine_ffi.dll": "linetta_engine_ffi.dll" + "gen/windows/linetta_engine_ffi.dll": "linetta_engine_ffi.dll", + "resources/*": "resources" } } } diff --git a/apps/desktop/src/components/McpIndicator.test.tsx b/apps/desktop/src/components/McpIndicator.test.tsx new file mode 100644 index 00000000..afdf9d77 --- /dev/null +++ b/apps/desktop/src/components/McpIndicator.test.tsx @@ -0,0 +1,81 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { act } from "react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const ev = vi.hoisted(() => ({ + listeners: new Map void>(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: (event: string, cb: (e: { payload: unknown }) => void) => { + ev.listeners.set(event, cb); + return Promise.resolve(() => ev.listeners.delete(event)); + }, +})); + +const rpc = vi.hoisted(() => ({ status: vi.fn() })); +vi.mock("../lib/rpc", () => ({ mcp: { status: rpc.status } })); +vi.mock("../lib/i18n", () => ({ useI18n: () => ({ t: (k: string) => k }) })); + +import { McpIndicator } from "./McpIndicator"; + +function renderIndicator() { + return render( + + + , + ); +} + +describe("McpIndicator", () => { + beforeEach(() => { + vi.clearAllMocks(); + ev.listeners.clear(); + }); + + it("stays out of the way when nothing is listening", async () => { + rpc.status.mockResolvedValue({ running: false, mode: "off", token_set: false }); + renderIndicator(); + await waitFor(() => expect(rpc.status).toHaveBeenCalled()); + expect(screen.queryByTestId("mcp-indicator")).toBeNull(); + }); + + it("shows while an agent can reach the work, and links to Settings", async () => { + rpc.status.mockResolvedValue({ running: true, mode: "full", port: 7391, token_set: true }); + renderIndicator(); + + const dot = await screen.findByTestId("mcp-indicator"); + // The label has to say what the agent can do, not just that something is on. + expect(dot.getAttribute("aria-label")).toBe("workspace.mcp.active.full"); + expect(dot.getAttribute("href")).toBe("/settings"); + }); + + it("distinguishes read-only from full access", async () => { + rpc.status.mockResolvedValue({ running: true, mode: "read_only", port: 7391, token_set: true }); + renderIndicator(); + const dot = await screen.findByTestId("mcp-indicator"); + expect(dot.getAttribute("aria-label")).toBe("workspace.mcp.active.readOnly"); + }); + + it("appears as soon as an agent change arrives, without waiting for a poll", async () => { + rpc.status.mockResolvedValue({ running: false, mode: "off", token_set: false }); + renderIndicator(); + await waitFor(() => expect(rpc.status).toHaveBeenCalled()); + expect(screen.queryByTestId("mcp-indicator")).toBeNull(); + + const cb = ev.listeners.get("mcp-changed"); + expect(cb).toBeTruthy(); + await act(async () => { + cb?.({ payload: { project_id: "p1", tool: "linetta_write_scene" } }); + }); + expect(screen.getByTestId("mcp-indicator")).toBeTruthy(); + }); + + it("stays hidden on a build with no MCP method at all", async () => { + rpc.status.mockRejectedValue(new Error("method not found: mcp.status")); + renderIndicator(); + await waitFor(() => expect(rpc.status).toHaveBeenCalled()); + expect(screen.queryByTestId("mcp-indicator")).toBeNull(); + }); +}); diff --git a/apps/desktop/src/components/McpIndicator.tsx b/apps/desktop/src/components/McpIndicator.tsx new file mode 100644 index 00000000..709de410 --- /dev/null +++ b/apps/desktop/src/components/McpIndicator.tsx @@ -0,0 +1,54 @@ +import { useCallback, useEffect, useState } from "react"; +import { Link } from "react-router-dom"; + +import { useEngineEvent } from "../hooks/useEngineEvent"; +import { useI18n } from "../lib/i18n"; +import { mcp } from "../lib/rpc"; + +/** A quiet marker that an external agent can reach this manuscript. + * + * The writer must never be surprised that something other than them can edit + * the work. It shows only while the server is actually listening, and clicking + * it goes to Settings, where the activity log and the kill switch are. + */ +export function McpIndicator() { + const { t } = useI18n(); + const [running, setRunning] = useState(false); + const [mode, setMode] = useState(""); + + const refresh = useCallback(async () => { + try { + const st = await mcp.status(); + setRunning(st.running); + setMode(st.mode); + } catch { + // A build without MCP has no such method; staying hidden is correct. + setRunning(false); + } + }, []); + + useEffect(() => { + void refresh(); + }, [refresh]); + + // An agent change is proof the server is live, so the dot appears without + // waiting for a poll. + useEngineEvent("mcp-changed", () => { + setRunning(true); + }); + + if (!running) return null; + + const label = mode === "full" ? t("workspace.mcp.active.full") : t("workspace.mcp.active.readOnly"); + return ( + + + + ); +} diff --git a/apps/desktop/src/components/settings/McpSection.test.tsx b/apps/desktop/src/components/settings/McpSection.test.tsx new file mode 100644 index 00000000..d07042e7 --- /dev/null +++ b/apps/desktop/src/components/settings/McpSection.test.tsx @@ -0,0 +1,190 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const rpc = vi.hoisted(() => ({ + status: vi.fn(), + enable: vi.fn(), + disable: vi.fn(), + regenerateToken: vi.fn(), + activity: vi.fn(), + settingsGet: vi.fn(), + settingsSet: vi.fn(), + bridgePath: vi.fn(), +})); + +vi.mock("../../lib/rpc", () => ({ + mcp: { + status: rpc.status, + enable: rpc.enable, + disable: rpc.disable, + regenerateToken: rpc.regenerateToken, + activity: rpc.activity, + }, + settings: { get: rpc.settingsGet, set: rpc.settingsSet }, + // The pane asks the shell for the installed bridge path when none is given. + mcpBridgePath: rpc.bridgePath, +})); + +vi.mock("../../lib/i18n", () => ({ + // The keys are the contract under test, not the prose, so echo them back. + useI18n: () => ({ + t: (key: string, vars?: Record) => + vars ? `${key}:${Object.values(vars).join(",")}` : key, + }), +})); + +import { McpSection } from "./McpSection"; + +const OFF = { running: false, mode: "off", token_set: false }; +const RUNNING = { running: true, mode: "full", port: 7391, token_set: true }; + +function settingsWith(overrides: Record = {}) { + return { mcp_mode: "read_only", mcp_port: 7391, mcp_project_id: "", ...overrides }; +} + +describe("McpSection", () => { + beforeEach(() => { + vi.clearAllMocks(); + rpc.status.mockResolvedValue(OFF); + rpc.settingsGet.mockResolvedValue(settingsWith()); + rpc.settingsSet.mockResolvedValue(settingsWith()); + rpc.activity.mockResolvedValue([]); + rpc.bridgePath.mockResolvedValue(null); + }); + + it("refuses to enable until the writer has explicitly consented", async () => { + render(); + const enable = await screen.findByTestId("mcp-enable"); + + // Consent is a decision, not something a toggle implies. + expect(enable).toBeDisabled(); + expect(rpc.enable).not.toHaveBeenCalled(); + + await userEvent.click(screen.getByTestId("mcp-consent")); + await waitFor(() => expect(rpc.settingsSet).toHaveBeenCalled()); + expect(rpc.settingsSet.mock.calls[0][0]).toMatchObject({ mcp_consent_version: 1 }); + await waitFor(() => expect(screen.getByTestId("mcp-enable")).toBeEnabled()); + }); + + it("starts enabled when consent was already given", async () => { + rpc.settingsGet.mockResolvedValue(settingsWith({ mcp_consent_version: 1 })); + render(); + await waitFor(() => expect(screen.getByTestId("mcp-enable")).toBeEnabled()); + }); + + it("puts the token in the run-it-yourself command and a header helper in .mcp.json", async () => { + rpc.settingsGet.mockResolvedValue(settingsWith({ mcp_consent_version: 1 })); + rpc.enable.mockResolvedValue({ token: "SECRET-TOKEN", status: RUNNING }); + render(); + + await userEvent.click(await screen.findByTestId("mcp-enable")); + await screen.findByTestId("mcp-snippets"); + + const command = screen.getByTestId("mcp-snippet-command").textContent ?? ""; + expect(command).toContain("claude mcp add --transport http linetta"); + expect(command).toContain("http://127.0.0.1:7391/mcp"); + expect(command).toContain("Bearer SECRET-TOKEN"); + + // .mcp.json is a file people commit, so the token must never land in it. + const projectConfig = screen.getByTestId("mcp-snippet-project").textContent ?? ""; + expect(projectConfig).not.toContain("SECRET-TOKEN"); + expect(projectConfig).toContain("headersHelper"); + expect(projectConfig).toContain("/opt/linetta/linetta-mcp --print-headers"); + + const desktop = screen.getByTestId("mcp-snippet-desktop").textContent ?? ""; + expect(desktop).not.toContain("SECRET-TOKEN"); + expect(desktop).toContain("/opt/linetta/linetta-mcp"); + }); + + it("enables with the chosen mode, port, and work restriction", async () => { + rpc.settingsGet.mockResolvedValue(settingsWith({ mcp_consent_version: 1 })); + rpc.enable.mockResolvedValue({ token: "t", status: RUNNING }); + render(); + + await userEvent.selectOptions(await screen.findByLabelText("settings.mcp.mode"), "full"); + await userEvent.clear(screen.getByLabelText("settings.mcp.projectLimit")); + await userEvent.type(screen.getByLabelText("settings.mcp.projectLimit"), "work-1"); + await userEvent.click(screen.getByTestId("mcp-enable")); + + await waitFor(() => expect(rpc.enable).toHaveBeenCalled()); + const calls = rpc.settingsSet.mock.calls; + const patch = calls[calls.length - 1]?.[0]; + expect(patch).toMatchObject({ mcp_mode: "full", mcp_project_id: "work-1" }); + }); + + it("kills the listener and drops the token from the pane", async () => { + rpc.status.mockResolvedValue(RUNNING); + rpc.settingsGet.mockResolvedValue(settingsWith({ mcp_consent_version: 1 })); + rpc.disable.mockResolvedValue(OFF); + render(); + + await userEvent.click(await screen.findByTestId("mcp-disable")); + + await waitFor(() => expect(rpc.disable).toHaveBeenCalledTimes(1)); + await waitFor(() => expect(screen.queryByTestId("mcp-snippets")).toBeNull()); + }); + + it("says how to recover the command when the token is not in hand", async () => { + rpc.status.mockResolvedValue(RUNNING); + rpc.settingsGet.mockResolvedValue(settingsWith({ mcp_consent_version: 1 })); + render(); + + // Reopening Settings on a running server: the token was minted earlier and + // settings.get redacts it, so the pane must not pretend to have it. + await screen.findByTestId("mcp-token-hidden"); + expect(screen.queryByTestId("mcp-snippet-command")).toBeNull(); + }); + + it("surfaces a refused enable instead of silently doing nothing", async () => { + rpc.settingsGet.mockResolvedValue(settingsWith({ mcp_consent_version: 1 })); + rpc.enable.mockRejectedValue(new Error("mcp port in use")); + render(); + + await userEvent.click(await screen.findByTestId("mcp-enable")); + const error = await screen.findByTestId("mcp-error"); + expect(error.textContent).toContain("port in use"); + }); + + it("says where to get the bridge on a build that ships without one", async () => { + rpc.status.mockResolvedValue(RUNNING); + rpc.settingsGet.mockResolvedValue(settingsWith({ mcp_consent_version: 1 })); + rpc.bridgePath.mockResolvedValue(null); + render(); + + // Mac App Store builds leave the bridge out, so the Desktop snippet points + // at a command the writer does not have yet. Saying so beats a silent fail. + await screen.findByTestId("mcp-bridge-missing"); + }); + + it("stays quiet about the bridge when the build actually bundles one", async () => { + rpc.status.mockResolvedValue(RUNNING); + rpc.settingsGet.mockResolvedValue(settingsWith({ mcp_consent_version: 1 })); + rpc.bridgePath.mockResolvedValue("/Applications/Linetta.app/resources/linetta-mcp"); + render(); + + const desktop = await screen.findByTestId("mcp-snippet-desktop"); + await waitFor(() => + expect(desktop.textContent).toContain("/Applications/Linetta.app/resources/linetta-mcp"), + ); + expect(screen.queryByTestId("mcp-bridge-missing")).toBeNull(); + }); + + it("names the taken port as something the writer can fix", async () => { + rpc.settingsGet.mockResolvedValue(settingsWith({ mcp_consent_version: 1 })); + // The engine's English sentence says nothing about what to do next; the + // reason code is what turns it into "pick another port". + const refused = Object.assign(new Error("mcp port in use: 7391"), { + data: { reason: "mcp_port_in_use" }, + }); + rpc.enable.mockRejectedValue(refused); + render(); + + await userEvent.click(await screen.findByTestId("mcp-enable")); + const error = await screen.findByTestId("mcp-error"); + expect(error.textContent).toBe("errors.mcpPortInUse"); + + // The port field stays editable so the fix is one keystroke away. + expect(screen.getByLabelText("settings.mcp.port")).not.toBeDisabled(); + }); +}); diff --git a/apps/desktop/src/components/settings/McpSection.tsx b/apps/desktop/src/components/settings/McpSection.tsx new file mode 100644 index 00000000..1790cefb --- /dev/null +++ b/apps/desktop/src/components/settings/McpSection.tsx @@ -0,0 +1,273 @@ +import { useCallback, useEffect, useState } from "react"; + +import { useI18n } from "../../lib/i18n"; +import { mcp, mcpBridgePath, settings as settingsApi } from "../../lib/rpc"; +import { rpcErrorMessage } from "../../lib/rpcMessage"; +import type { McpActivityEntry, McpStatus } from "../../lib/types"; + +/** Connect an external agent (MCP). + * + * The pane's job is to make one decision legible: an agent outside Linetta is + * about to be able to read — and, in full mode, rewrite — the manuscript. So + * consent is an explicit checkbox rather than something a toggle implies, and + * the token only ever reaches the copyable command, never the .mcp.json + * snippet, because that file is one people commit. + */ + +export type McpSectionProps = { + /** Absolute path to the bundled bridge. Resolved from the shell when not + * supplied; tests pass it directly. */ + bridgePath?: string; +}; + +type Saved = { + mode: string; + port: number; + projectId: string; + consented: boolean; +}; + +const MODES = ["read_only", "full"] as const; + +export function McpSection({ bridgePath }: McpSectionProps) { + const { t } = useI18n(); + // undefined until the shell answers, then a path or null. The difference + // matters: null means this build ships no bridge (Mac App Store), which the + // writer has to be told, and "not asked yet" must not look like that. + const [resolvedBridge, setResolvedBridge] = useState(bridgePath); + const [status, setStatus] = useState(null); + const [saved, setSaved] = useState({ mode: "read_only", port: 7391, projectId: "", consented: false }); + const [token, setToken] = useState(null); + const [activity, setActivity] = useState([]); + // The raw failure is kept and translated at render time: a reason code has + // no language of its own, and switching language should redraw the message + // rather than leave a stale sentence on screen. + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + + const refresh = useCallback(async () => { + const [st, cfg] = await Promise.all([mcp.status(), settingsApi.get()]); + setStatus(st); + setSaved({ + mode: cfg.mcp_mode && cfg.mcp_mode !== "off" ? cfg.mcp_mode : "read_only", + port: cfg.mcp_port || 7391, + projectId: cfg.mcp_project_id ?? "", + consented: (cfg.mcp_consent_version ?? 0) >= 1, + }); + if (st.running) { + setActivity(await mcp.activity(20)); + } + }, []); + + useEffect(() => { + void refresh().catch(setError); + }, [refresh]); + + useEffect(() => { + if (bridgePath) return; + void mcpBridgePath() + .then((path) => setResolvedBridge(path ?? null)) + .catch(() => setResolvedBridge(null)); + }, [bridgePath]); + + const guard = async (fn: () => Promise) => { + setBusy(true); + setError(null); + try { + await fn(); + } catch (e) { + setError(e); + } finally { + setBusy(false); + } + }; + + const saveConsent = (consented: boolean) => + guard(async () => { + await settingsApi.set({ + mcp_consent_version: consented ? 1 : 0, + mcp_consented_at: consented ? Date.now() : 0, + }); + setSaved((s) => ({ ...s, consented })); + }); + + const enable = () => + guard(async () => { + await settingsApi.set({ + mcp_mode: saved.mode, + mcp_port: saved.port, + mcp_project_id: saved.projectId, + }); + const res = await mcp.enable(); + setToken(res.token); + setStatus(res.status); + setActivity(await mcp.activity(20)); + }); + + const disable = () => + guard(async () => { + setStatus(await mcp.disable()); + setToken(null); + }); + + const regenerate = () => + guard(async () => { + const res = await mcp.regenerateToken(); + setToken(res.token); + setStatus(res.status); + }); + + const running = status?.running ?? false; + const port = status?.port || saved.port; + const endpoint = `http://127.0.0.1:${port}/mcp`; + + // The literal token goes only in the command the writer runs on their own + // machine. .mcp.json gets headersHelper instead — it is a file people commit. + const addCommand = token + ? `claude mcp add --transport http linetta ${endpoint} --header "Authorization: Bearer ${token}"` + : null; + const projectConfig = JSON.stringify( + { + mcpServers: { + linetta: { + type: "http", + url: endpoint, + headersHelper: `${resolvedBridge ?? "linetta-mcp"} --print-headers`, + }, + }, + }, + null, + 2, + ); + const desktopConfig = JSON.stringify( + { mcpServers: { linetta: { command: resolvedBridge ?? "linetta-mcp", args: [] } } }, + null, + 2, + ); + + return ( +
+

{t("settings.mcp.title")}

+

{t("settings.mcp.description")}

+ + + +
+ + +
+ +
+ + setSaved((s) => ({ ...s, port: Number(e.target.value) }))} + /> +
+ +
+ + setSaved((s) => ({ ...s, projectId: e.target.value }))} + /> +
+ + {!running && ( + + )} + {running && ( + <> +

{t("settings.mcp.running", { port: String(port) })}

+ + + + )} + + {error != null && ( +

+ {/* A taken port is the one failure a writer can fix from this pane, + so the engine's reason code becomes a sentence naming the knob. */} + {rpcErrorMessage(error, t)} +

+ )} + + {running && ( +
+

{t("settings.mcp.snippets.title")}

+ {addCommand ? ( + <> +

{t("settings.mcp.snippets.claudeCode")}

+
{addCommand}
+ + ) : ( +

+ {t("settings.mcp.snippets.tokenHidden")} +

+ )} + {resolvedBridge === null && ( +

+ {t("settings.mcp.snippets.bridgeMissing")} +

+ )} +

{t("settings.mcp.snippets.projectConfig")}

+
{projectConfig}
+

{t("settings.mcp.snippets.desktop")}

+
{desktopConfig}
+
+ )} + + {running && activity.length > 0 && ( +
+

{t("settings.mcp.activity.title")}

+
    + {activity.map((entry) => ( +
  • + {entry.ok ? "✓" : "✕"} {entry.tool} + {entry.detail ? ` — ${entry.detail}` : ""} +
  • + ))} +
+
+ )} +
+ ); +} diff --git a/apps/desktop/src/lib/i18n.tsx b/apps/desktop/src/lib/i18n.tsx index 2c057977..09161b01 100644 --- a/apps/desktop/src/lib/i18n.tsx +++ b/apps/desktop/src/lib/i18n.tsx @@ -848,6 +848,8 @@ const messages = { "settings.aiWizard.description": "API 키나 모델 이름을 몰라도 괜찮습니다. 아래에서 쓰고 싶은 AI를 고르면 Linetta가 필요한 단계만 보여줍니다.", "settings.aiWizard.current": "현재 선택", "settings.aiWizard.policy": "Claude와 Gemini 구독 로그인은 각 회사의 공식 제품 안에서 쓰는 흐름이라 Linetta에서는 제공하지 않습니다. ChatGPT 구독은 OpenAI Codex 공식 로그인만 지원하고, Claude/Gemini는 API 키로 연결합니다.", + "errors.mcpPortInUse": "그 포트는 이미 다른 프로그램이 쓰고 있습니다. 다른 포트 번호를 넣고 다시 켜 보세요.", + "errors.mcpConsentRequired": "외부 에이전트 접근에 먼저 동의해야 켤 수 있습니다.", "errors.aiDataSharingConsentRequired": "아직 AI 데이터 전송에 동의하지 않았습니다. 설정 아래쪽 'AI 데이터 전송 동의'에서 '동의하고 AI 사용'을 누르면 사용할 수 있습니다. 동의는 현재 선택된 공급자에만 적용되므로, 공급자를 바꾸셨다면 먼저 그 공급자를 선택한 뒤 동의하세요.", "settings.aiConsent.title": "AI 데이터 전송 동의", "settings.aiConsent.description": "AI 기능을 사용하면 프롬프트, 선택한 원고와 주변 문맥, 작품 설정·레퍼런스, 첨부 이미지가 현재 선택한 제3자 AI 공급자({provider})에게 전송됩니다. 개발자는 이 데이터를 받지 않으며, 공급자가 자체 정책에 따라 처리·보관할 수 있습니다.", @@ -1098,6 +1100,28 @@ const messages = { "settings.git.initHelp": "지정한 폴더가 아직 git 저장소가 아닐 때 아래 버튼으로 초기화할 수 있습니다. 초기화 후 GitHub remote를 추가하세요:", "settings.git.errorNoFolder": "git 폴더를 먼저 지정하세요", "settings.git.errorInitFailed": "초기화 실패: {error}", + "workspace.mcp.active.readOnly": "외부 에이전트가 이 작품을 읽을 수 있습니다 (읽기 전용) — 설정에서 확인", + "workspace.mcp.active.full": "외부 에이전트가 이 작품을 고칠 수 있습니다 — 설정에서 확인", + "settings.mcp.title": "외부 에이전트 연결 (MCP)", + "settings.mcp.description": "Claude Code나 Claude Desktop 같은 외부 AI 에이전트가 이 컴퓨터 안에서만 Linetta에 접속해 작품을 읽고, 전체 권한을 주면 원고를 쓸 수 있습니다. 켜기 전까지는 아무것도 열리지 않습니다.", + "settings.mcp.consent": "외부 에이전트가 내 원고와 설정 자료를 읽는 것에 동의합니다. 전체 권한에서는 원고를 고칠 수도 있으며, 모든 변경은 기록되고 되돌릴 수 있습니다.", + "settings.mcp.mode": "권한", + "settings.mcp.mode.read_only": "읽기 전용 — 에이전트가 원고를 바꿀 수 없습니다", + "settings.mcp.mode.full": "전체 — 에이전트가 원고와 설정을 고칠 수 있습니다", + "settings.mcp.port": "포트", + "settings.mcp.projectLimit": "작품 제한", + "settings.mcp.projectLimit.placeholder": "비워두면 모든 작품", + "settings.mcp.enable": "MCP 켜기", + "settings.mcp.disable": "MCP 끄기", + "settings.mcp.regenerate": "토큰 재발급", + "settings.mcp.running": "127.0.0.1:{port} 에서 실행 중", + "settings.mcp.snippets.title": "연결 설정", + "settings.mcp.snippets.claudeCode": "Claude Code — 터미널에서 이 한 줄을 실행하세요.", + "settings.mcp.snippets.projectConfig": "프로젝트 .mcp.json — 토큰 대신 헤더 헬퍼를 씁니다. 이 파일은 커밋해도 안전합니다.", + "settings.mcp.snippets.bridgeMissing": "이 빌드에는 브리지(linetta-mcp)가 들어 있지 않습니다. 위의 Claude Code 명령 한 줄은 브리지 없이 그대로 동작합니다. Claude Desktop을 쓰려면 GitHub 릴리스나 Homebrew에서 linetta-mcp를 받아 설치하고, 아래 경로를 설치한 위치로 바꾸세요.", + "settings.mcp.snippets.desktop": "Claude Desktop — claude_desktop_config.json 에 넣으세요.", + "settings.mcp.snippets.tokenHidden": "토큰은 켤 때 한 번만 보여집니다. 접속 명령이 필요하면 토큰을 재발급하세요.", + "settings.mcp.activity.title": "최근 활동", "settings.git.alreadyRepo": "이미 git 저장소입니다", }, en: { @@ -1936,6 +1960,8 @@ const messages = { "settings.aiWizard.description": "You do not need to know API keys or model names yet. Pick the AI you want to use and Linetta will show only the steps you need.", "settings.aiWizard.current": "Current selection", "settings.aiWizard.policy": "Claude and Gemini subscription logins are designed for each company's official products, so Linetta does not provide them. ChatGPT subscriptions are supported only through official OpenAI Codex login, while Claude/Gemini use API keys.", + "errors.mcpPortInUse": "That port is already taken by another program. Enter a different port number and turn it on again.", + "errors.mcpConsentRequired": "Accept external agent access before turning this on.", "errors.aiDataSharingConsentRequired": "You have not consented to AI data sharing yet. Open 'AI Data Sharing Consent' further down in Settings and choose 'Consent and enable AI'. Consent applies to the provider you have selected, so if you switched providers, select the one you want first and then consent.", "settings.aiConsent.title": "AI Data Sharing Consent", "settings.aiConsent.description": "When you use AI features, prompts, selected manuscript and surrounding context, story settings and references, and attached images are sent to the selected third-party AI provider ({provider}). The developer does not receive this data; the provider may process and retain it under its own policy.", @@ -2186,6 +2212,28 @@ const messages = { "settings.git.initHelp": "If the selected folder is not a git repository yet, use the button below to initialize it. After initialization, add a GitHub remote with:", "settings.git.errorNoFolder": "Choose a git folder first", "settings.git.errorInitFailed": "Initialization failed: {error}", + "workspace.mcp.active.readOnly": "An external agent can read this work (read only) — open Settings", + "workspace.mcp.active.full": "An external agent can change this work — open Settings", + "settings.mcp.title": "Connect an external agent (MCP)", + "settings.mcp.description": "An external AI agent such as Claude Code or Claude Desktop can reach Linetta from this computer only, read your work, and — with full access — write the manuscript. Nothing is open until you turn it on.", + "settings.mcp.consent": "I agree to let an external agent read my manuscript and story notes. With full access it can also change the manuscript; every change is recorded and can be undone.", + "settings.mcp.mode": "Access", + "settings.mcp.mode.read_only": "Read only — the agent cannot change the manuscript", + "settings.mcp.mode.full": "Full — the agent can write scenes and story notes", + "settings.mcp.port": "Port", + "settings.mcp.projectLimit": "Limit to one work", + "settings.mcp.projectLimit.placeholder": "Leave empty for every work", + "settings.mcp.enable": "Turn on MCP", + "settings.mcp.disable": "Turn off MCP", + "settings.mcp.regenerate": "Regenerate token", + "settings.mcp.running": "Serving on 127.0.0.1:{port}", + "settings.mcp.snippets.title": "Connection setup", + "settings.mcp.snippets.claudeCode": "Claude Code — run this one line in your terminal.", + "settings.mcp.snippets.projectConfig": "Project .mcp.json — uses a header helper instead of the token, so this file is safe to commit.", + "settings.mcp.snippets.bridgeMissing": "This build does not ship the bridge (linetta-mcp). The Claude Code command above works as-is without it. For Claude Desktop, install linetta-mcp from the GitHub release or Homebrew and replace the path below with where you put it.", + "settings.mcp.snippets.desktop": "Claude Desktop — add this to claude_desktop_config.json.", + "settings.mcp.snippets.tokenHidden": "The token is shown once, when you turn MCP on. Regenerate it if you need the connection command again.", + "settings.mcp.activity.title": "Recent activity", "settings.git.alreadyRepo": "Already a git repository", }, ja: { @@ -3024,6 +3072,8 @@ const messages = { "settings.aiWizard.description": "API キーやモデル名が分からなくても大丈夫です。使いたい AI を選ぶと、Linetta が必要な手順だけを表示します。", "settings.aiWizard.current": "現在の選択", "settings.aiWizard.policy": "Claude と Gemini のサブスクリプションログインは各社公式製品内の利用フローなので、Linetta では提供しません。ChatGPT サブスクリプションは OpenAI Codex の公式ログインのみ対応し、Claude/Gemini は API キーで接続します。", + "errors.mcpPortInUse": "そのポートは他のプログラムが使用中です。別のポート番号を入力してもう一度オンにしてください。", + "errors.mcpConsentRequired": "先に外部エージェントのアクセスに同意してください。", "errors.aiDataSharingConsentRequired": "まだ AI データ送信に同意していません。設定の下の「AIデータ送信への同意」で「同意してAIを使用」を押してください。同意は現在選択中のプロバイダーにのみ適用されるため、プロバイダーを変更した場合は先にそのプロバイダーを選択してから同意してください。", "settings.aiConsent.title": "AIデータ送信への同意", "settings.aiConsent.description": "AI機能を使用すると、プロンプト、選択した原稿と周辺コンテキスト、作品設定・参考資料、添付画像が、現在選択中の第三者AIプロバイダー({provider})へ送信されます。開発者はこのデータを受け取りませんが、プロバイダーが自社のポリシーに従って処理・保存する場合があります。", @@ -3274,6 +3324,28 @@ const messages = { "settings.git.initHelp": "指定したフォルダがまだ git リポジトリでない場合は、下のボタンで初期化できます。初期化後、GitHub remote を追加してください:", "settings.git.errorNoFolder": "先に git フォルダを指定してください", "settings.git.errorInitFailed": "初期化失敗: {error}", + "workspace.mcp.active.readOnly": "外部エージェントがこの作品を読めます(読み取り専用)— 設定を開く", + "workspace.mcp.active.full": "外部エージェントがこの作品を変更できます — 設定を開く", + "settings.mcp.title": "外部エージェント接続 (MCP)", + "settings.mcp.description": "Claude Code や Claude Desktop などの外部 AI エージェントが、このコンピュータ内からのみ Linetta に接続して作品を読み、フル権限では原稿を書けます。オンにするまで何も開きません。", + "settings.mcp.consent": "外部エージェントが原稿と設定資料を読むことに同意します。フル権限では原稿を変更でき、すべての変更は記録され、元に戻せます。", + "settings.mcp.mode": "権限", + "settings.mcp.mode.read_only": "読み取り専用 — エージェントは原稿を変更できません", + "settings.mcp.mode.full": "フル — エージェントが原稿と設定を変更できます", + "settings.mcp.port": "ポート", + "settings.mcp.projectLimit": "作品を限定", + "settings.mcp.projectLimit.placeholder": "空欄ですべての作品", + "settings.mcp.enable": "MCP をオンにする", + "settings.mcp.disable": "MCP をオフにする", + "settings.mcp.regenerate": "トークンを再発行", + "settings.mcp.running": "127.0.0.1:{port} で動作中", + "settings.mcp.snippets.title": "接続設定", + "settings.mcp.snippets.claudeCode": "Claude Code — ターミナルでこの一行を実行してください。", + "settings.mcp.snippets.projectConfig": "プロジェクト .mcp.json — トークンの代わりにヘッダーヘルパーを使うため、コミットしても安全です。", + "settings.mcp.snippets.bridgeMissing": "このビルドにはブリッジ (linetta-mcp) が同梱されていません。上の Claude Code のコマンドはブリッジなしでもそのまま動作します。Claude Desktop を使う場合は GitHub リリースまたは Homebrew から linetta-mcp を入手し、下のパスをインストール先に置き換えてください。", + "settings.mcp.snippets.desktop": "Claude Desktop — claude_desktop_config.json に追加してください。", + "settings.mcp.snippets.tokenHidden": "トークンはオンにしたときに一度だけ表示されます。接続コマンドが必要なら再発行してください。", + "settings.mcp.activity.title": "最近の操作", "settings.git.alreadyRepo": "すでに git リポジトリです", }, } as const; diff --git a/apps/desktop/src/lib/rpc.ts b/apps/desktop/src/lib/rpc.ts index 53cc73d2..aa71b37a 100644 --- a/apps/desktop/src/lib/rpc.ts +++ b/apps/desktop/src/lib/rpc.ts @@ -29,6 +29,9 @@ import type { FolderSyncResult, GitSyncInitResult, GitSyncResult, + McpActivityEntry, + McpStatus, + McpTokenResult, ImportMarkdownResult, ImportPreviewResult, ListProjectsParams, @@ -100,6 +103,14 @@ export async function restoreLatestBackup(): Promise<{ return invoke("restore_latest_backup"); } +/** Absolute path to the bundled MCP bridge, or null when this build ships + * without it (Mac App Store) or the dev build has not run the build script. + * The settings pane prints it into the writer's client config, so a guess + * would produce a snippet that silently fails. */ +export async function mcpBridgePath(): Promise { + return invoke("mcp_bridge_path"); +} + export async function openPath(path: string): Promise { return invoke("open_path", { path }); } @@ -309,6 +320,14 @@ export const imports = { }), }; +export const mcp = { + status: () => rpcCall("mcp.status"), + enable: () => rpcCall("mcp.enable"), + disable: () => rpcCall("mcp.disable"), + regenerateToken: () => rpcCall("mcp.regenerate_token"), + activity: (limit?: number) => rpcCall("mcp.activity", { limit }), +}; + export const gitSync = { run: () => rpcCall("git_sync.run"), init: () => rpcCall("git_sync.init"), diff --git a/apps/desktop/src/lib/rpcMessage.ts b/apps/desktop/src/lib/rpcMessage.ts index 1a2e9685..7380e8dc 100644 --- a/apps/desktop/src/lib/rpcMessage.ts +++ b/apps/desktop/src/lib/rpcMessage.ts @@ -15,6 +15,8 @@ type Translate = ReturnType["t"]; */ const REASON_MESSAGE_KEYS: Record = { ai_data_sharing_consent_required: "errors.aiDataSharingConsentRequired", + mcp_port_in_use: "errors.mcpPortInUse", + mcp_consent_required: "errors.mcpConsentRequired", }; // Matched on shape rather than `instanceof RpcError`: tests mock ../lib/rpc diff --git a/apps/desktop/src/lib/types.ts b/apps/desktop/src/lib/types.ts index e9b51628..6f7f9005 100644 --- a/apps/desktop/src/lib/types.ts +++ b/apps/desktop/src/lib/types.ts @@ -390,6 +390,7 @@ export interface DiagnosticsSnapshot { ops_status: OpsStatus[]; unavailable_providers?: string[]; git_sync_available?: boolean; + mcp_available?: boolean; } // Wire shape from ai.preview_context RPC. Mirrors engine PreviewCounts JSON. @@ -458,6 +459,34 @@ export interface AIContextPreview { budgetTokenEstimate: number; } +/** Live state of the local MCP server that external agents connect to. */ +export interface McpStatus { + running: boolean; + mode: string; + port?: number; + project_id?: string; + token_set: boolean; +} + +/** Enabling and regenerating both hand back the bearer token once: it is + * minted engine-side and settings.get redacts it forever after, so this is + * the only moment the pane can build a copyable client command. */ +export interface McpTokenResult { + token: string; + status: McpStatus; +} + +/** One recorded external tool call, shown in the activity list. */ +export interface McpActivityEntry { + id: string; + at: number; + tool: string; + project_id?: string; + target_id?: string; + ok: boolean; + detail?: string; +} + export interface GitSyncResult { skipped: boolean; files_written: number; @@ -586,6 +615,13 @@ export interface Settings { web_search_provider: WebSearchProvider; web_search_api_key: string; web_search_api_key_set?: boolean; + mcp_mode?: string; + mcp_port?: number; + mcp_project_id?: string; + mcp_consent_version?: number; + mcp_consented_at?: number; + /** Presence flag only — the token itself never reaches the renderer here. */ + mcp_token_set?: boolean; } export interface SettingsPatch { @@ -610,6 +646,11 @@ export interface SettingsPatch { ai_data_sharing_consented_at?: number; web_search_provider?: WebSearchProvider; web_search_api_key?: string; + mcp_mode?: string; + mcp_port?: number; + mcp_project_id?: string; + mcp_consent_version?: number; + mcp_consented_at?: number; } export interface SnapshotEntry { diff --git a/apps/desktop/src/routes/Settings.tsx b/apps/desktop/src/routes/Settings.tsx index 95c9cd23..59a71f93 100644 --- a/apps/desktop/src/routes/Settings.tsx +++ b/apps/desktop/src/routes/Settings.tsx @@ -15,6 +15,7 @@ import { openExternalUrl, } from "../lib/rpc"; import { rpcErrorMessage } from "../lib/rpcMessage"; +import { McpSection } from "../components/settings/McpSection"; import { APP_LANGUAGES, localeForLanguage, useI18n } from "../lib/i18n"; import { dispatchAppEvent } from "../lib/appEvents"; import { isWindows } from "../lib/platform"; @@ -89,6 +90,8 @@ export function Settings() { ); const [unavailableProviders, setUnavailableProviders] = useState([]); const [gitSyncAvailable, setGitSyncAvailable] = useState(true); + // Hidden entirely on builds without MCP (mobile), the same way git sync is. + const [mcpAvailable, setMcpAvailable] = useState(false); const providers = useMemo( () => buildProviders(t).filter((p) => !unavailableProviders.includes(p.id)), [t, unavailableProviders], @@ -164,6 +167,7 @@ export function Settings() { setOpsRows(rows); setUnavailableProviders(diag.unavailable_providers ?? []); setGitSyncAvailable(diag.git_sync_available ?? true); + setMcpAvailable(diag.mcp_available ?? false); syncOpenRouterDrafts(s); }) .catch((e) => { if (!cancelled) setError(String(e)); }); @@ -1065,6 +1069,8 @@ export function Settings() { + {mcpAvailable && } + {!gitSyncAvailable && (

{t("settings.git.title")}

diff --git a/apps/desktop/src/routes/Workspace.tsx b/apps/desktop/src/routes/Workspace.tsx index 08a65e5c..7ce0c31e 100644 --- a/apps/desktop/src/routes/Workspace.tsx +++ b/apps/desktop/src/routes/Workspace.tsx @@ -3,6 +3,7 @@ import type { MouseEvent as ReactMouseEvent } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Search, Command as CommandIcon, MessageCircle, Maximize2, ArrowLeft, BookOpen, Replace, Sparkles, Menu, Keyboard } from "lucide-react"; import { nodes, projects, snapshots, entities as entitiesApi, mentions as mentionsApi, threads as threadsApi, beats as beatsApi, settings as settingsApi, exportApi, notes as notesApi, gitSync, ai as aiApi, stats as statsApi, diagnostics as diagnosticsApi } from "../lib/rpc"; +import { McpIndicator } from "../components/McpIndicator"; import { NoteMarkerExtension } from "../components/editor/NoteMarkerExtension"; import { AITargetExtension } from "../components/editor/AITargetExtension"; import { NotePopover } from "../components/NotePopover"; @@ -1627,6 +1628,7 @@ export function Workspace() {
+