+ )
+}
+
+/**
+ * Let other apps use Sentient over MCP (issue #443).
+ *
+ * Off by default. When on, MCP apps on this computer can use a small set of
+ * Sentient tools for memory, tasks and notifications. All of it still goes
+ * through Sentient itself, so plans and risky steps ask the user as usual.
+ * Settings live in `integrations.mcp_server_enabled` and
+ * `integrations.mcp_server_memories_skip_review` (PATCH /api/config).
+ */
+export function SentientMcpSection({ id }: { id?: string }) {
+ const { config, isLoading, error, setValue } = useConfigEditor()
+ const configQuery = useConfig()
+
+ const enabled = !!config?.integrations.mcp_server_enabled
+ const skipReview = !!config?.integrations.mcp_server_memories_skip_review
+
+ return (
+
+
+
Let other apps use Sentient
+
+ Apps like Claude Code and Cursor that speak MCP can use Sentient on this computer. They can search and save memories, make and check tasks, and send you a
+ notification. This is off by default.
+
+ Turn the switch on first, then add Sentient to the app you use. The app starts Sentient for you each time it needs it.
+
+
+
+
+
+
+ For the HTTP address above, start Sentient in HTTP mode first with {HTTP_START_COMMAND}. Keep it on this
+ computer only.
+
+
+
+
+ } title="How Sentient keeps this safe">
+
+
There are no tools to send messages, delete things or run commands.
+
A task made this way still needs you to approve its plan in Sentient before it runs.
+
When an app asks Sentient a question, Sentient's own tools still follow Sentient's approval rules and ask you when they should.
+
+
+
+ )}
+
+ )
+}
diff --git a/desktop/src/features/settings/SchemaForm.tsx b/desktop/src/features/settings/SchemaForm.tsx
index 4d51e8d82..ebc96c446 100644
--- a/desktop/src/features/settings/SchemaForm.tsx
+++ b/desktop/src/features/settings/SchemaForm.tsx
@@ -279,6 +279,8 @@ export const LABELS: Record = {
'integrations.searxng_url': { label: 'SearXNG address', placeholder: 'https://search.example.com' },
'integrations.weather_provider': { label: 'Weather', options: { open_meteo: 'Open-Meteo', accuweather: 'AccuWeather' } },
'integrations.hide_one_time_codes': { label: 'Hide one-time codes in email' },
+ 'integrations.mcp_server_enabled': { label: 'Let other apps use Sentient (MCP)' },
+ 'integrations.mcp_server_memories_skip_review': { label: 'Let MCP apps save memories without review' },
'integrations.mcp_servers': { hidden: true },
'integrations.read_only': { hidden: true }
}
diff --git a/desktop/src/lib/types.ts b/desktop/src/lib/types.ts
index 8ec044d09..11e0823f4 100644
--- a/desktop/src/lib/types.ts
+++ b/desktop/src/lib/types.ts
@@ -389,6 +389,10 @@ export interface IntegrationsConfig {
searxng_url: string
weather_provider: 'open_meteo' | 'accuweather'
mcp_servers: Record
+ /** Let other apps use Sentient over MCP (#443). Off by default. */
+ mcp_server_enabled?: boolean
+ /** Let MCP apps save memories without going through Review (#443). */
+ mcp_server_memories_skip_review?: boolean
/** App ids set to Read only (#141). */
read_only?: string[]
[key: string]: unknown
diff --git a/docs/API.md b/docs/API.md
index 37b1bc89d..3618158c0 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1599,6 +1599,31 @@ an email whose subject is a booking or order (with no sign-in cue) keeps all its
phone numbers and ordinary links are left alone. The proactive pipeline and follow-ups
run the same masking again before their prompts. The original stays in the user's mail app (`url` for Gmail).
+### Sentient as an MCP server: `sentient mcp` (#443, ADR 0034)
+
+The other direction: other AI apps on this computer (Claude Code, Cursor, any MCP client) can use Sentient
+itself. The command `sentient mcp` (package `sentient/mcp_server/`) runs an MCP server over stdio, or over
+streamable HTTP on 127.0.0.1 only with `--transport http --port ` (default 8765, path `/mcp`). It is a thin
+client of this same REST API: it reads the gateway address from the config, takes the token the way `serve`
+does (`SENTIENT_GATEWAY_TOKEN` or the token file), refuses a non-loopback engine address, and holds no state.
+
+- **Off by default.** The process refuses to start while `integrations.mcp_server_enabled` is off, with a
+ sentence telling the user where the switch is, and every tool call reads the switch from `GET /api/config`
+ again, so turning it off stops a running server at its next call. The Integrations screen carries the
+ switch and the snippet: `claude mcp add sentient -- sentient mcp`.
+- **Tools (a fixed whitelist).** `memory_search` (`GET /api/memories?q=`), `memory_get` (`GET /api/memories/{id}`),
+ `memory_add` (`POST /api/memories` with `source: "mcp"`), `task_create` (`POST /api/tasks`), `task_list`
+ (`GET /api/tasks`), `task_status` (`GET /api/tasks/{id}`), `notify_me` (`POST /api/notifications`, above) and
+ `ask_sentient` (one turn of `POST /api/chat` with `channel: "mcp"`). There is no tool that sends, deletes or
+ runs anything.
+- **Writes land where the user sees them.** `memory_add` sends `hold_for_review: true`, so the memory waits in
+ Review (section 7), unless the user set `integrations.mcp_server_memories_skip_review`. `task_create` is the
+ normal create: the plan waits for the user's approval in Sentient. `ask_sentient` is a normal chat turn, so
+ its tools run under approvals, lasting rules (section 2) and the outside-content gate (section 10); the MCP
+ client is outside content and can never answer an approval.
+- `mcp` is also in the frozen engine's command list (`packaging/engine_entry.py`), which `tests/test_packaging.py`
+ keeps equal to the CLI's commands.
+
---
## 6. Notifications & proactivity
@@ -1663,6 +1688,7 @@ dropped and tried again at the next check (`followups.copied_from_them`). A last
Never rule on that tool or its app (`tools.approvals.rules`) still applies: the run fails with the rule's message and
nothing is sent.
- `GET /api/notifications?limit=&unread_only=` → `{notifications: [Notification], unread}`
+- `POST /api/notifications` `{message, title?, source?}` → `Notification`: a plain `info` note in Sentient, for the MCP server's `notify_me` tool below. Always kind `info` with `payload.source` when given; a caller's `kind` is ignored, so an outside caller cannot dress a note up as an approval, a task result or an error. 400 without a `message`.
- `POST /api/notifications/{id}/read`, `POST /api/notifications/read-all`
- `DELETE /api/notifications/{id}`, `DELETE /api/notifications`
- `POST /api/proactivity/suggestions/{notification_id}` `{action: "approve"|"dismiss"}` → `{ok, task_id?}` (approve creates a task from `action_details` with `original_context.source = "proactive"`; for a follow-up it creates the already approved send task described above; both update the learned per-type threshold, mark the notification read and set `payload.status`/`payload.task_id`). Errors: 400 bad action, 404 not a suggestion, 409 already actioned or expired, 503 tasks unavailable. v2 spellings `approved`/`dismissed` are accepted.
@@ -1780,7 +1806,7 @@ Relationships & Social Life, Financial, Goals & Challenges, Miscellaneous.
`source` also finds memories that source said again), expired short-term facts and memories waiting for review excluded (`q` = hybrid search when embeddings are available: vector neighbours plus FTS5 keyword matches, ordered by `score` = `similarity` + `memory.keyword_weight` × share of query words present, each result carrying `similarity` and `score`; falls back to a keyword match)
- `GET /api/memories/topics` → `[{name, description, count}]`
- `GET /api/memories/graph` → `{nodes: [{id, label, title, content, topics, memory_type, source, created_at}], links: [{source, target, value}]}` (`label` = content truncated to 25 chars, `title` = full content, as in v2; a link means cosine similarity ≥ `memory.graph_link_similarity`, `value` is that similarity)
-- `POST /api/memories` `{content, source?, scope?}` → `{action: "ADD"|"UPDATE"|"DELETE"|"SKIP", id, content, status?: "pending", scope?}` (`scope` defaults to `personal`; runs the CUD decision, so a duplicate returns `SKIP` with the existing id; `source` defaults to `manual`. The decision also sees up to 3 facts about the same person and attribute found by keyword (where they live, job, relationship, diet, health, ownership, routine), and a new current residence ("moved to Bengaluru") always UPDATEs the old one ("lives in Pune") rather than adding a second home; past-tense facts are left alone. Facts that name someone else are never offered to the model, so it can't update, skip or delete them, and the
+- `POST /api/memories` `{content, source?, scope?, hold_for_review?, review_from?}` → `{action: "ADD"|"UPDATE"|"DELETE"|"SKIP", id, content, status?: "pending", scope?}` (`scope` defaults to `personal`; runs the CUD decision, so a duplicate returns `SKIP` with the existing id; `source` defaults to `manual`. `hold_for_review: true` saves the fact as pending instead, with a review note from `review_from` (below, "Memories waiting for review"): the MCP server sends it for memories an outside app adds, unless the user turned the wait off. The decision also sees up to 3 facts about the same person and attribute found by keyword (where they live, job, relationship, diet, health, ownership, routine), and a new current residence ("moved to Bengaluru") always UPDATEs the old one ("lives in Pune") rather than adding a second home; past-tense facts are left alone. Facts that name someone else are never offered to the model, so it can't update, skip or delete them, and the
near-duplicate shortcut needs the same person too; names in Devanagari (Hindi) count, and match across scripts
("माया" is "Maya", "प्रिया" is not "Riya") (#339). Someone the person knows is not the person: "Maya Rao's sister Priya", "my friend Arjun", "her colleague" and "माया राव की बहन" are about that relative or friend, so a fact about them is never offered as an update of Maya's own facts, and the updates decided in code (a correction, a new home) need both facts to clearly name the same person ("She moved to Delhi" names nobody and is left to the model) (#463). An UPDATE only replaces a fact about the same person and the same action on the same thing ("doesn't want files written" or "doesn't want emails deleted" never replaces "doesn't want files deleted"; it is added instead), and a new fact that an existing one already says in full, with no new name, place or number ("sister lives in a city" next to "sister Meera lives in Lisbon"), returns `SKIP` with that fact. A correction always UPDATEs, decided in code without the model and never skipped as a
near-duplicate (#364): a new fact about the same person and action that says the same thing about it with a changed
diff --git a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
new file mode 100644
index 000000000..6712c2cc3
--- /dev/null
+++ b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
@@ -0,0 +1,71 @@
+# 0034. Serve Sentient itself to other agents over MCP, off by default and read-mostly
+
+- **Status:** Accepted
+- **Date:** 2026-10-21
+- **Builds on:** [ADR 0016](0016-lasting-approval-rules.md) (lasting approval rules),
+ [ADR 0018](0018-untrusted-content-gates-sending.md) (outside content gates sending),
+ [ADR 0021](0021-hold-untrusted-memories-for-review.md) (untrusted memories wait for review),
+ [ADR 0027](0027-vet-skills-and-mcp-tools-from-outside.md) (vet MCP tools from outside)
+
+## Context
+
+Sentient can use MCP servers, but other agents cannot use Sentient. Someone who works in Claude
+Code, Cursor or another MCP client wants their assistant's memory and tasks there too, the way
+Hermes and OpenClaw expose theirs. Exposing Sentient the other way round is the dangerous
+direction: an MCP client is software the user did not write, driven by a model, and anything it
+asks for is outside content in the sense of ADR 0018. A careless version of this feature would
+hand that client Sentient's whole tool set, including sending, deleting and running commands,
+with the user's lasting Allow rules and approvals mode chosen for their own requests, not for a
+stranger's client.
+
+## Decision
+
+- **A separate, thin process.** `sentient mcp` (package `sentient/mcp_server/`) is an MCP server
+ that holds no state and never touches the database. Every tool call goes to the running engine's
+ REST API with the engine token, so there is one implementation of every safety rule, in the
+ engine, and this process cannot bypass it. It only talks to a loopback address and refuses any
+ other at startup.
+- **A whitelist of eight tools, nothing else.** `memory_search`, `memory_get`, `memory_add`,
+ `task_create`, `task_list`, `task_status`, `notify_me` and `ask_sentient`. There is no tool
+ that sends a message, deletes anything or runs a command, so a client cannot do those things
+ through Sentient at all, whatever it asks. The list is fixed in code and a test fails if a
+ send, delete or exec-shaped tool ever appears in it.
+- **Off until the user turns it on, and checked live.** The server refuses to start while
+ `integrations.mcp_server_enabled` is off, and every tool call reads the switch from the running
+ engine again, so turning it off in Settings stops a server that is already running. The
+ Integrations screen shows the switch and the config snippet to paste into a client
+ (`claude mcp add sentient -- sentient mcp`).
+- **Writes land where the user can see them.** A memory an MCP app adds waits in Review like an
+ import's does (ADR 0021), held with a note saying it came from an MCP app, unless the user
+ turns on `integrations.mcp_server_memories_skip_review`. A task is created the normal way, so
+ its plan waits for the user's approval in Sentient before anything runs. A notification is a
+ plain info note inside Sentient; the endpoint that creates it ignores any kind a caller asks
+ for, so a client cannot dress a note up as an approval or a result.
+- **Asking Sentient is a normal chat turn.** `ask_sentient` runs one turn through `POST /api/chat`
+ on its own channel. The turn's tools run under Sentient's own approvals, rules (ADR 0016) and
+ outside-content checks (ADR 0018): anything risky asks the user in the Sentient window, and
+ nothing the MCP client says can answer that question, because answers only come from the user
+ in the app or a paired chat, never from a model or a tool result.
+- **stdio first, HTTP only on this computer.** stdio is the default transport. Streamable HTTP
+ (`--transport http`) binds 127.0.0.1 only, for clients that cannot start a local command.
+
+## Consequences
+
+Other agents can finally read and add to the same memory and task list the user sees, without a
+second copy of anything. The price is two small engine additions (a hold-for-review flag on
+`POST /api/memories` and a create route for notifications) and one more surface to keep honest:
+every future tool proposed for this server has to argue why it belongs on the whitelist, and the
+default answer for anything that sends, deletes or runs is no. Because the MCP process reads the
+switch from the engine on every call, a running server adds one small local request per tool
+call, which is fine at this scale.
+
+## Alternatives considered
+
+Letting the MCP process open the database directly: faster, but it would copy the memory and
+task logic and quietly bypass approvals, review and the plan gate, which is the whole point of
+going through the engine. Exposing Sentient's full internal tool registry: the registry is built
+for a model the user is talking to, with the user's own approvals behind it; most of it (sending,
+deleting, the terminal, the browser) must never be offered to an outside client. Serving over
+HTTP only, with its own password: a second credential to manage and a network-shaped surface,
+when stdio needs neither and the engine token already exists. Saving MCP memories directly:
+rejected for the reason in ADR 0021, memory is how an injection becomes permanent.
diff --git a/docs/adr/README.md b/docs/adr/README.md
index ce30aeea2..49769b925 100644
--- a/docs/adr/README.md
+++ b/docs/adr/README.md
@@ -38,6 +38,7 @@ proposing a change to one of them.
| [0031](0031-recipients-first-and-a-tool-less-reader.md) | Fix where a message goes before reading what it says, and read outside documents with a tool-less reader | Accepted | 2026-10-17 |
| [0032](0032-claude-code-for-everything-as-an-opt-in.md) | Let people opt in to using their own Claude Code for everything | Accepted | 2026-10-17 |
| [0033](0033-tasks-by-email.md) | Take tasks by email only from allowed real addresses, and treat the email as outside content | Accepted | 2026-10-18 |
+| [0034](0034-serve-sentient-to-other-agents-over-mcp.md) | Serve Sentient itself to other agents over MCP, off by default and read-mostly | Accepted | 2026-10-21 |
## Adding a record
diff --git a/packaging/engine_entry.py b/packaging/engine_entry.py
index 7cfbe055a..28369a9b3 100644
--- a/packaging/engine_entry.py
+++ b/packaging/engine_entry.py
@@ -24,7 +24,7 @@
# first build turned that into `serve claude-code-tools` because it was missing here.
_COMMANDS = {
"serve", "node", "doctor", "config", "version",
- "claude-code-tools", "backup", "restore", "selfcheck",
+ "claude-code-tools", "backup", "restore", "selfcheck", "mcp",
}
diff --git a/sentient/cli.py b/sentient/cli.py
index d20fa798b..e8d87ebef 100644
--- a/sentient/cli.py
+++ b/sentient/cli.py
@@ -133,6 +133,24 @@ def node(
)
+@app.command()
+def mcp(
+ transport: str = typer.Option("stdio", help="stdio, or http (streamable HTTP on this computer only)"),
+ port: int = typer.Option(8765, help="Port for --transport http. Always bound to 127.0.0.1."),
+ engine_url: str | None = typer.Option(None, help="Engine address (default: the gateway address from the config)"),
+):
+ """Let other AI apps on this computer use Sentient over MCP (off until turned on in Settings).
+
+ Started by the MCP client itself, for example with: claude mcp add sentient -- sentient mcp
+ """
+ if transport not in {"stdio", "http"}:
+ console.print("[red]--transport must be stdio or http[/red]")
+ raise typer.Exit(2)
+ from sentient.mcp_server.server import main
+
+ raise typer.Exit(main(transport, port=port, engine_url=engine_url))
+
+
@app.command("claude-code-tools", hidden=True)
def claude_code_tools(tools_file: str):
"""Sentient's tool list for Claude Code (started by Claude Code itself; see sentient/llm/claude_code_tools.py)."""
diff --git a/sentient/config/schema.py b/sentient/config/schema.py
index 33578df8e..3543ca15c 100644
--- a/sentient/config/schema.py
+++ b/sentient/config/schema.py
@@ -688,6 +688,19 @@ class IntegrationsConfig(BaseModel):
description="Connections set to Read only, by app id (gmail, github, mcp_). Sentient can look things "
"up there but never change, send, delete or run anything: those tools are hidden and refused.",
)
+ mcp_server_enabled: bool = Field(
+ False,
+ description="Let other AI apps on this computer (Claude Code, Cursor and other MCP clients) use Sentient "
+ "itself through the `sentient mcp` command: search and add memories, create and check tasks, send "
+ "notifications and ask Sentient a question. Off until turned on. There is no tool that sends, deletes "
+ "or runs anything, and the command only works on this computer with the engine's token.",
+ )
+ mcp_server_memories_skip_review: bool = Field(
+ False,
+ description="Only with the MCP server above: save memories an MCP app adds straight away instead of "
+ "holding them for review first. Off is safer: a memory from another app waits in Review until the "
+ "user approves it (ADR 0021).",
+ )
@field_validator("read_only", mode="before")
@classmethod
diff --git a/sentient/gateway/routes/memory.py b/sentient/gateway/routes/memory.py
index aae641939..5251b6740 100644
--- a/sentient/gateway/routes/memory.py
+++ b/sentient/gateway/routes/memory.py
@@ -244,7 +244,14 @@ async def create_memory(request: Request, body: dict):
if not content:
raise HTTPException(400, "content is required")
scope = memory_scopes.normalize(body.get("scope"), memory_scopes.PERSONAL) # #155
- return await mem.remember(content, source=str(body.get("source") or "manual"), notify=True, scope=scope)
+ # An outside app saving through `sentient mcp` is outside content (ADR 0018, ADR 0034): its memories wait
+ # in Review like an import's do (ADR 0021), unless the user turned the wait off in Settings.
+ review = None
+ if body.get("hold_for_review"):
+ review = memory_review.note(str(body.get("review_from") or "an MCP app"), snippet=content)
+ return await mem.remember(
+ content, source=str(body.get("source") or "manual"), notify=True, scope=scope, review=review,
+ )
@router.put("/{memory_id}")
diff --git a/sentient/gateway/routes/notifications.py b/sentient/gateway/routes/notifications.py
index 2dc3c1262..99af7b070 100644
--- a/sentient/gateway/routes/notifications.py
+++ b/sentient/gateway/routes/notifications.py
@@ -18,6 +18,23 @@ async def list_notifications(request: Request, limit: int = 100, unread_only: bo
return {"notifications": await svc.list(limit, unread_only), "unread": await svc.unread_count()}
+@router.post("")
+async def create_notification(request: Request, body: dict):
+ """A plain info notification, for the MCP server's notify_me tool (ADR 0034).
+
+ Only ever an ``info`` note in Sentient itself: nothing is sent anywhere, and a caller cannot
+ dress a note up as an approval, a task result or an error by picking the kind.
+ """
+ message = str(body.get("message") or "").strip()
+ if not message:
+ raise HTTPException(400, "message is required")
+ title = str(body.get("title") or "").strip() or None
+ source = str(body.get("source") or "").strip()
+ return await get_core(request).notify(
+ "info", message, title=title, payload={"source": source} if source else None,
+ )
+
+
@router.post("/{notification_id}/read")
async def mark_read(request: Request, notification_id: str):
svc = get_core(request).notifications
diff --git a/sentient/mcp_server/__init__.py b/sentient/mcp_server/__init__.py
new file mode 100644
index 000000000..292640377
--- /dev/null
+++ b/sentient/mcp_server/__init__.py
@@ -0,0 +1,7 @@
+"""Sentient as an MCP server: ``sentient mcp`` (issue #443, ADR 0034).
+
+A small, safe set of tools for other AI apps on this computer (Claude Code, Cursor and other MCP
+clients), backed by the running engine's REST API. This package never touches the database or the
+agent loop directly: every call goes through the gateway, so approvals, lasting rules (ADR 0016)
+and the outside-content checks (ADR 0018) apply exactly as they do to any other client.
+"""
diff --git a/sentient/mcp_server/__main__.py b/sentient/mcp_server/__main__.py
new file mode 100644
index 000000000..8818a6e7c
--- /dev/null
+++ b/sentient/mcp_server/__main__.py
@@ -0,0 +1,10 @@
+"""``python -m sentient.mcp_server``: the same as ``sentient mcp``."""
+
+from __future__ import annotations
+
+import sys
+
+from sentient.mcp_server.server import main
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/sentient/mcp_server/client.py b/sentient/mcp_server/client.py
new file mode 100644
index 000000000..a6c96eafa
--- /dev/null
+++ b/sentient/mcp_server/client.py
@@ -0,0 +1,188 @@
+"""A thin client for the running engine's REST API, used by the MCP server (ADR 0034).
+
+The MCP server is a separate process started by the MCP client (for example Claude Code running
+``sentient mcp``). It holds no state of its own: it reads the engine's address from the config and
+its token the same way ``sentient serve`` does (the ``SENTIENT_GATEWAY_TOKEN`` environment variable
+or the token file under the Sentient home folder), and it only ever talks to a loopback address.
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any
+from urllib.parse import urlparse
+
+import httpx
+
+LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
+
+
+class EngineError(RuntimeError):
+ """The engine could not be reached or refused the call. The message is safe to show the caller."""
+
+
+def default_engine_url() -> str:
+ """The running engine's address from the saved config, always on loopback."""
+ from sentient.config import load_config
+
+ cfg = load_config()
+ host = cfg.gateway.host if cfg.gateway.host in LOOPBACK_HOSTS else "127.0.0.1"
+ return f"http://{host}:{cfg.gateway.port}"
+
+
+class EngineClient:
+ """Synchronous REST calls to the engine. One instance per MCP server process."""
+
+ def __init__(self, base_url: str, token: str, *, timeout: float = 30.0):
+ parsed = urlparse(base_url)
+ if parsed.hostname not in LOOPBACK_HOSTS:
+ raise EngineError(
+ f"Refusing to talk to a non-local engine address ({base_url!r}). "
+ "The MCP server only works on this computer."
+ )
+ self.base_url = base_url.rstrip("/")
+ self._http = httpx.Client(
+ base_url=self.base_url,
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=httpx.Timeout(timeout, read=timeout),
+ # never through a proxy from the environment: the engine is on this computer, and its
+ # token must not travel through anything (a proxy setting also breaks loopback URLs
+ # whose no_proxy list carries bracketed IPv6 hosts)
+ trust_env=False,
+ )
+
+ # ------------------------------------------------------------------ plumbing
+ def _call(self, method: str, path: str, **kwargs: Any) -> Any:
+ try:
+ resp = self._http.request(method, path, **kwargs)
+ except httpx.HTTPError as exc:
+ raise EngineError(
+ f"Sentient's engine is not answering at {self.base_url}. Start Sentient first, then try again."
+ ) from exc
+ if resp.status_code == 401:
+ raise EngineError(
+ "The engine refused this server's token. Restart the MCP server so it picks up the current token."
+ )
+ if resp.status_code >= 400:
+ detail = ""
+ try:
+ detail = str(resp.json().get("detail") or "")
+ except ValueError:
+ detail = resp.text[:200]
+ raise EngineError(f"The engine said no ({resp.status_code}){': ' + detail if detail else ''}.")
+ if not resp.content:
+ return {}
+ try:
+ return resp.json()
+ except ValueError:
+ return {"text": resp.text}
+
+ def health(self) -> dict:
+ """``GET /api/health`` (no token needed): is an engine up at this address?"""
+ try:
+ resp = self._http.get("/api/health")
+ resp.raise_for_status()
+ return resp.json()
+ except httpx.HTTPError as exc:
+ raise EngineError(
+ f"No Sentient engine is running at {self.base_url}. Start Sentient first, then try again."
+ ) from exc
+
+ def get_config(self) -> dict:
+ return self._call("GET", "/api/config")
+
+ def integrations_config(self) -> dict:
+ """The live ``integrations`` section: the MCP server's on/off switch lives here."""
+ cfg = self.get_config()
+ section = cfg.get("integrations") if isinstance(cfg, dict) else None
+ return section if isinstance(section, dict) else {}
+
+ # ------------------------------------------------------------------ memories
+ def search_memories(self, query: str, limit: int = 10) -> list[dict]:
+ rows = self._call("GET", "/api/memories", params={"q": query, "limit": max(1, min(limit, 100))})
+ return rows if isinstance(rows, list) else []
+
+ def get_memory(self, memory_id: int) -> dict:
+ return self._call("GET", f"/api/memories/{memory_id}")
+
+ def add_memory(self, content: str, *, hold_for_review: bool, scope: str | None = None) -> dict:
+ body: dict[str, Any] = {"content": content, "source": "mcp"}
+ if scope:
+ body["scope"] = scope
+ if hold_for_review:
+ body["hold_for_review"] = True
+ body["review_from"] = "an MCP app"
+ return self._call("POST", "/api/memories", json=body)
+
+ # ------------------------------------------------------------------ tasks
+ @staticmethod
+ def _task_view(task: dict) -> dict:
+ """The engine calls it ``task_id``; the MCP surface presents one ``id`` field."""
+ if isinstance(task, dict) and task.get("id") is None and task.get("task_id") is not None:
+ return {"id": task["task_id"], **task}
+ return task
+
+ def create_task(self, prompt: str) -> dict:
+ return self._task_view(self._call("POST", "/api/tasks", json={"prompt": prompt}))
+
+ def list_tasks(self) -> list[dict]:
+ rows = self._call("GET", "/api/tasks")
+ return [self._task_view(r) for r in rows] if isinstance(rows, list) else []
+
+ def get_task(self, task_id: str) -> dict:
+ return self._task_view(self._call("GET", f"/api/tasks/{task_id}"))
+
+ # ------------------------------------------------------------------ notifications
+ def notify(self, message: str, title: str | None = None) -> dict:
+ body: dict[str, Any] = {"message": message, "source": "mcp"}
+ if title:
+ body["title"] = title
+ return self._call("POST", "/api/notifications", json=body)
+
+ # ------------------------------------------------------------------ chat
+ def chat_turn(self, text: str, *, timeout: float = 180.0) -> dict:
+ """One chat turn through ``POST /api/chat`` (NDJSON). The turn's own tools run inside the
+ engine, under Sentient's approvals and rules; when a call has to ask, the question appears
+ in the Sentient window like any other, and this call waits for the answer until ``timeout``.
+ Returns ``{content, session_id}`` or ``{content, error, session_id}``."""
+ session_id: str | None = None
+ content = ""
+ try:
+ with self._http.stream(
+ "POST", "/api/chat", json={"text": text, "channel": "mcp"},
+ timeout=httpx.Timeout(30.0, read=timeout),
+ ) as resp:
+ if resp.status_code >= 400:
+ resp.read()
+ raise EngineError(f"The engine said no ({resp.status_code}) while starting the chat turn.")
+ for line in resp.iter_lines():
+ if not line.strip():
+ continue
+ try:
+ event = json.loads(line)
+ except ValueError:
+ continue
+ if event.get("session_id"):
+ session_id = event["session_id"]
+ if event.get("type") == "done":
+ content = str(event.get("content") or "")
+ if event.get("error"):
+ return {"content": content, "error": str(event["error"]), "session_id": session_id}
+ elif event.get("type") == "error":
+ return {
+ "content": content,
+ "error": str(event.get("message") or "the chat turn failed"),
+ "session_id": session_id,
+ }
+ except httpx.TimeoutException:
+ return {
+ "content": content,
+ "error": (
+ "No answer yet: the turn is still running in Sentient, most likely waiting for the user "
+ "to answer an approval in the Sentient window."
+ ),
+ "session_id": session_id,
+ }
+ except httpx.HTTPError as exc:
+ raise EngineError(f"The chat turn broke off: {exc}") from exc
+ return {"content": content, "session_id": session_id}
diff --git a/sentient/mcp_server/server.py b/sentient/mcp_server/server.py
new file mode 100644
index 000000000..fed0970ad
--- /dev/null
+++ b/sentient/mcp_server/server.py
@@ -0,0 +1,252 @@
+"""The MCP server itself: a small, safe tool set backed by the engine's REST API (ADR 0034).
+
+Safety, in code rather than in prompts:
+
+- The tool list is a whitelist. There is no tool that sends a message, deletes anything or runs a
+ command, so nothing an MCP client asks for can do those things through Sentient.
+- The server only starts when the user has turned it on in Settings (``integrations.mcp_server_enabled``),
+ and every tool call checks the switch again against the running engine, so turning it off in
+ Settings stops a server that is already running.
+- Everything goes through the engine's REST API with the engine token, so a task is created the
+ normal way (its plan waits for the user's approval in Sentient), memories an MCP app adds wait
+ in Review (ADR 0021) unless the user allowed saving them straight away, and a chat turn's tools
+ run under Sentient's own approvals, rules (ADR 0016) and outside-content checks (ADR 0018). The
+ MCP client is outside content: nothing it says can approve anything.
+"""
+
+from __future__ import annotations
+
+import json
+import sys
+from typing import Any
+
+from mcp.server.mcpserver import MCPServer
+from mcp.types import ToolAnnotations
+
+from sentient.mcp_server.client import EngineClient, EngineError, default_engine_url
+
+OFF_MESSAGE = (
+ "Sentient's MCP server is turned off. The user can turn it on in Sentient, under Integrations, "
+ "in the section called Let other apps use Sentient."
+)
+
+_READ = ToolAnnotations(read_only_hint=True, destructive_hint=False, open_world_hint=False)
+_WRITE = ToolAnnotations(read_only_hint=False, destructive_hint=False, open_world_hint=False)
+
+
+def _json(value: Any) -> str:
+ return json.dumps(value, ensure_ascii=False, indent=2)
+
+
+def _error(message: str) -> str:
+ return _json({"error": message})
+
+
+def _memory_row(row: dict) -> dict:
+ return {
+ "id": row.get("id"),
+ "content": row.get("content"),
+ "topics": row.get("topics") or [],
+ "source": row.get("source"),
+ "scope": row.get("scope"),
+ "created_at": row.get("created_at"),
+ }
+
+
+def _task_row(row: dict) -> dict:
+ plan = row.get("plan") or []
+ return {
+ "id": row.get("id"),
+ "name": row.get("name"),
+ "status": row.get("status"),
+ "description": row.get("description"),
+ "plan_steps": len(plan) if isinstance(plan, list) else 0,
+ "updated_at": row.get("updated_at"),
+ }
+
+
+class Gate:
+ """Reads the live switch from the engine before every call (see the module docstring)."""
+
+ def __init__(self, client: EngineClient):
+ self.client = client
+
+ def check(self) -> dict | None:
+ """The integrations config when the server may run, else None (turned off)."""
+ section = self.client.integrations_config()
+ return section if section.get("mcp_server_enabled") else None
+
+
+def build_server(client: EngineClient, gate: Gate | None = None) -> MCPServer:
+ gate = gate or Gate(client)
+ server = MCPServer("sentient")
+
+ def live_config() -> dict | None:
+ """None when a call must not run: turned off, or the engine went away."""
+ try:
+ return gate.check()
+ except EngineError:
+ return None
+
+ @server.tool(
+ description=(
+ "Search the user's memories in Sentient. Returns the matching memories with their ids. "
+ "Memories still waiting for the user's review are never included."
+ ),
+ annotations=_READ,
+ )
+ def memory_search(query: str, limit: int = 10) -> str:
+ if live_config() is None:
+ return _error(OFF_MESSAGE)
+ try:
+ rows = client.search_memories(query, limit)
+ except EngineError as exc:
+ return _error(str(exc))
+ return _json({"memories": [_memory_row(r) for r in rows]})
+
+ @server.tool(
+ description=(
+ "Save one fact to Sentient's memory. Unless the user allowed MCP apps to save directly, "
+ "the memory waits in Sentient's Review list and is only used after the user approves it there."
+ ),
+ annotations=_WRITE,
+ )
+ def memory_add(content: str, scope: str | None = None) -> str:
+ section = live_config()
+ if section is None:
+ return _error(OFF_MESSAGE)
+ hold = not bool(section.get("mcp_server_memories_skip_review"))
+ try:
+ result = client.add_memory(content, hold_for_review=hold, scope=scope)
+ except EngineError as exc:
+ return _error(str(exc))
+ out = {"action": result.get("action"), "id": result.get("id"), "content": result.get("content")}
+ if result.get("status") == "pending" or hold:
+ out["status"] = "pending"
+ out["note"] = "Saved for review: the user approves it in Sentient before it is used."
+ else:
+ out["status"] = "active"
+ return _json(out)
+
+ @server.tool(description="Read one memory from Sentient by its id.", annotations=_READ)
+ def memory_get(memory_id: int) -> str:
+ if live_config() is None:
+ return _error(OFF_MESSAGE)
+ try:
+ row = client.get_memory(memory_id)
+ except EngineError as exc:
+ return _error(str(exc))
+ return _json(_memory_row(row) | {"memory_type": row.get("memory_type"), "state": row.get("state")})
+
+ @server.tool(
+ description=(
+ "Ask Sentient to do something as a task. The task is created the normal way: Sentient plans "
+ "it and the plan waits for the user's approval in Sentient before anything runs."
+ ),
+ annotations=_WRITE,
+ )
+ def task_create(prompt: str) -> str:
+ if live_config() is None:
+ return _error(OFF_MESSAGE)
+ try:
+ task = client.create_task(prompt)
+ except EngineError as exc:
+ return _error(str(exc))
+ out = _task_row(task)
+ out["note"] = "Created in Sentient. Its plan waits for the user's approval there before it runs."
+ return _json(out)
+
+ @server.tool(description="List the user's tasks in Sentient with their status.", annotations=_READ)
+ def task_list() -> str:
+ if live_config() is None:
+ return _error(OFF_MESSAGE)
+ try:
+ rows = client.list_tasks()
+ except EngineError as exc:
+ return _error(str(exc))
+ return _json({"tasks": [_task_row(r) for r in rows]})
+
+ @server.tool(description="Read one task from Sentient by its id, with its status.", annotations=_READ)
+ def task_status(task_id: str) -> str:
+ if live_config() is None:
+ return _error(OFF_MESSAGE)
+ try:
+ task = client.get_task(task_id)
+ except EngineError as exc:
+ return _error(str(exc))
+ return _json(_task_row(task))
+
+ @server.tool(
+ description="Show a notification to the user inside Sentient. Nothing is sent anywhere else.",
+ annotations=_WRITE,
+ )
+ def notify_me(message: str, title: str | None = None) -> str:
+ if live_config() is None:
+ return _error(OFF_MESSAGE)
+ try:
+ note = client.notify(message, title)
+ except EngineError as exc:
+ return _error(str(exc))
+ return _json({"notified": True, "id": note.get("id")})
+
+ @server.tool(
+ description=(
+ "Ask Sentient a question or give it an instruction, as one chat turn. The tools Sentient "
+ "uses in that turn run under Sentient's own approvals and rules, and anything risky asks "
+ "the user in the Sentient window first. Returns Sentient's reply."
+ ),
+ annotations=_WRITE,
+ )
+ def ask_sentient(message: str) -> str:
+ if live_config() is None:
+ return _error(OFF_MESSAGE)
+ try:
+ turn = client.chat_turn(message)
+ except EngineError as exc:
+ return _error(str(exc))
+ if turn.get("error"):
+ return _json({"reply": turn.get("content") or "", "note": turn["error"]})
+ return _json({"reply": turn.get("content") or ""})
+
+ return server
+
+
+def check_ready(client: EngineClient) -> str | None:
+ """None when the server may start, else a plain sentence for the person starting it."""
+ try:
+ client.health()
+ section = client.integrations_config()
+ except EngineError as exc:
+ return str(exc)
+ if not section.get("mcp_server_enabled"):
+ return OFF_MESSAGE
+ return None
+
+
+def main(
+ transport: str = "stdio",
+ *,
+ port: int = 8765,
+ engine_url: str | None = None,
+) -> int:
+ """Entry point for ``sentient mcp``. Returns the process exit code."""
+ from sentient.gateway.auth import load_or_create_token
+
+ url = engine_url or default_engine_url()
+ try:
+ client = EngineClient(url, load_or_create_token())
+ except EngineError as exc:
+ print(str(exc), file=sys.stderr)
+ return 2
+ problem = check_ready(client)
+ if problem is not None:
+ print(problem, file=sys.stderr)
+ return 2
+ server = build_server(client)
+ if transport == "http":
+ # streamable HTTP, loopback only: the SDK's default host is 127.0.0.1 and we keep it that way.
+ print(f"Sentient MCP server on http://127.0.0.1:{port}/mcp (this computer only)", file=sys.stderr)
+ server.run("streamable-http", host="127.0.0.1", port=port)
+ else:
+ server.run("stdio")
+ return 0
diff --git a/tests/mcp_server/__init__.py b/tests/mcp_server/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/mcp_server/test_client.py b/tests/mcp_server/test_client.py
new file mode 100644
index 000000000..fa608fe4b
--- /dev/null
+++ b/tests/mcp_server/test_client.py
@@ -0,0 +1,91 @@
+"""The MCP server's engine client: loopback only, the token on every call, plain errors (ADR 0034)."""
+
+from __future__ import annotations
+
+import httpx
+import pytest
+import respx
+
+from sentient.mcp_server.client import EngineClient, EngineError
+
+BASE = "http://127.0.0.1:7777"
+
+
+def _client() -> EngineClient:
+ return EngineClient(BASE, "test-token")
+
+
+def test_a_non_local_engine_address_is_refused():
+ with pytest.raises(EngineError, match="only works on this computer"):
+ EngineClient("http://192.168.1.20:7777", "test-token")
+ with pytest.raises(EngineError, match="only works on this computer"):
+ EngineClient("https://example.com:7777", "test-token")
+
+
+def test_calls_carry_the_token_and_hit_the_right_routes():
+ with respx.mock() as router:
+ search = router.get(f"{BASE}/api/memories").mock(
+ return_value=httpx.Response(200, json=[{"id": 1, "content": "Maya drinks tea"}])
+ )
+ add = router.post(f"{BASE}/api/memories").mock(
+ return_value=httpx.Response(200, json={"action": "ADD", "id": 7, "status": "pending"})
+ )
+ client = _client()
+ assert client.search_memories("tea", 5) == [{"id": 1, "content": "Maya drinks tea"}]
+ out = client.add_memory("Maya drinks tea", hold_for_review=True)
+ assert out["status"] == "pending"
+ assert search.calls[0].request.headers["authorization"] == "Bearer test-token"
+ assert search.calls[0].request.url.params["q"] == "tea"
+ import json as _json
+
+ body = _json.loads(add.calls[0].request.content)
+ assert body == {"content": "Maya drinks tea", "source": "mcp", "hold_for_review": True,
+ "review_from": "an MCP app"}
+
+
+def test_task_and_notification_routes():
+ with respx.mock() as router:
+ # the real engine calls the field task_id; the client presents it as id
+ create = router.post(f"{BASE}/api/tasks").mock(
+ return_value=httpx.Response(200, json={"task_id": "t1", "status": "planning"})
+ )
+ note = router.post(f"{BASE}/api/notifications").mock(
+ return_value=httpx.Response(200, json={"id": "n1", "kind": "info"})
+ )
+ client = _client()
+ made = client.create_task("Book the dentist")
+ assert made["status"] == "planning"
+ assert made["id"] == "t1"
+ assert client.notify("done")["id"] == "n1"
+ import json as _json
+
+ assert _json.loads(create.calls[0].request.content) == {"prompt": "Book the dentist"}
+ assert _json.loads(note.calls[0].request.content) == {"message": "done", "source": "mcp"}
+
+
+def test_a_refused_token_and_a_missing_engine_read_plainly():
+ with respx.mock() as router:
+ router.get(f"{BASE}/api/config").mock(return_value=httpx.Response(401, json={"detail": "no"}))
+ with pytest.raises(EngineError, match="refused this server's token"):
+ _client().get_config()
+ with respx.mock() as router:
+ router.get(f"{BASE}/api/memories").mock(side_effect=httpx.ConnectError("down"))
+ with pytest.raises(EngineError, match="not answering"):
+ _client().search_memories("tea")
+
+
+def test_chat_turn_reads_the_ndjson_stream_until_done():
+ ndjson = (
+ '{"type": "session", "session_id": "s1"}\n'
+ '{"type": "text_delta", "session_id": "s1", "text": "hi"}\n'
+ '{"type": "done", "session_id": "s1", "content": "Tea is at five."}\n'
+ )
+ with respx.mock() as router:
+ route = router.post(f"{BASE}/api/chat").mock(
+ return_value=httpx.Response(200, text=ndjson, headers={"content-type": "application/x-ndjson"})
+ )
+ out = _client().chat_turn("when is tea?")
+ assert out == {"content": "Tea is at five.", "session_id": "s1"}
+ import json as _json
+
+ assert _json.loads(route.calls[0].request.content) == {"text": "when is tea?", "channel": "mcp"}
diff --git a/tests/mcp_server/test_engine_routes.py b/tests/mcp_server/test_engine_routes.py
new file mode 100644
index 000000000..f74afaa36
--- /dev/null
+++ b/tests/mcp_server/test_engine_routes.py
@@ -0,0 +1,80 @@
+"""The engine side of the MCP server (issue #443, ADR 0034): a memory an outside app adds waits in
+Review, and an outside caller can leave the user a plain info notification and nothing more.
+"""
+
+from __future__ import annotations
+
+import pytest
+from fastapi.testclient import TestClient
+
+from sentient.app import SentientApp
+from sentient.gateway.app import create_app
+from tests.conftest import FakeProvider
+
+
+@pytest.fixture
+def client(config, isolated_home):
+ app = create_app(SentientApp(config, llm=FakeProvider(), db_path=isolated_home / "g.db",
+ enable_background=False))
+ with TestClient(app) as c:
+ c.token = c.app.state.token
+ yield c
+
+
+def _h(client) -> dict:
+ return {"Authorization": f"Bearer {client.token}"}
+
+
+def test_mcp_server_is_off_in_the_default_config(client):
+ cfg = client.get("/api/config", headers=_h(client)).json()
+ assert cfg["integrations"]["mcp_server_enabled"] is False
+ assert cfg["integrations"]["mcp_server_memories_skip_review"] is False
+
+
+def test_a_memory_held_for_review_waits_in_the_inbox_and_is_not_listed(client):
+ r = client.post(
+ "/api/memories",
+ json={"content": "Maya's office is in Lisbon", "source": "mcp", "hold_for_review": True},
+ headers=_h(client),
+ )
+ assert r.status_code == 200, r.text
+ assert r.json()["status"] == "pending"
+
+ listed = client.get("/api/memories", params={"q": "Lisbon"}, headers=_h(client)).json()
+ assert listed == [] # a held memory is never listed or searched (ADR 0021)
+
+ inbox = client.get("/api/memories/review", headers=_h(client)).json()
+ held = [i for i in inbox["items"] if i["text"] == "Maya's office is in Lisbon"]
+ assert len(held) == 1 and held[0]["from"] == "an MCP app"
+
+
+def test_a_memory_saved_without_the_hold_is_active(client):
+ r = client.post(
+ "/api/memories", json={"content": "Maya drinks tea in the morning", "source": "mcp"},
+ headers=_h(client),
+ )
+ assert r.status_code == 200, r.text
+ assert r.json().get("status") != "pending"
+ listed = client.get("/api/memories", params={"q": "tea"}, headers=_h(client)).json()
+ assert [m["content"] for m in listed] == ["Maya drinks tea in the morning"]
+
+
+def test_an_outside_caller_can_only_leave_an_info_notification(client):
+ r = client.post(
+ "/api/notifications",
+ json={"message": "Claude Code finished the report", "title": "Report", "kind": "approval"},
+ headers=_h(client),
+ )
+ assert r.status_code == 200, r.text
+ note = r.json()
+ assert note["kind"] == "info" # the caller's kind is never trusted
+ assert note["message"] == "Claude Code finished the report"
+
+ listed = client.get("/api/notifications", headers=_h(client)).json()
+ assert [n["id"] for n in listed["notifications"]] == [note["id"]]
+ assert listed["unread"] == 1
+
+
+def test_a_notification_needs_a_message(client):
+ r = client.post("/api/notifications", json={"message": " "}, headers=_h(client))
+ assert r.status_code == 400
diff --git a/tests/mcp_server/test_mcp_server.py b/tests/mcp_server/test_mcp_server.py
new file mode 100644
index 000000000..9b05e9c13
--- /dev/null
+++ b/tests/mcp_server/test_mcp_server.py
@@ -0,0 +1,189 @@
+"""The MCP server's tool set and its safety gates (issue #443, ADR 0034).
+
+A fake engine client stands in for the REST API, and the tools are called through a real MCP
+client connected to the server in-process, so what is tested is the protocol surface a client
+like Claude Code actually sees.
+"""
+
+from __future__ import annotations
+
+import json
+
+from mcp import Client
+from typer.testing import CliRunner
+
+from sentient import cli
+from sentient.mcp_server.client import EngineError
+from sentient.mcp_server.server import build_server
+
+EXPECTED_TOOLS = {
+ "memory_search", "memory_add", "memory_get",
+ "task_create", "task_list", "task_status",
+ "notify_me", "ask_sentient",
+}
+
+
+class FakeEngine:
+ """Just the EngineClient surface the tools use, with the answers scripted."""
+
+ def __init__(self, *, enabled: bool = True, skip_review: bool = False):
+ self.section = {
+ "mcp_server_enabled": enabled,
+ "mcp_server_memories_skip_review": skip_review,
+ }
+ self.memories = [
+ {"id": 1, "content": "Maya Rao's daughter Mira starts school in June",
+ "topics": ["Personal Identity"], "source": "conversation", "scope": "personal",
+ "created_at": "2026-10-01T09:00:00Z"},
+ {"id": 2, "content": "Maya Rao drinks tea, not coffee",
+ "topics": ["Interests & Lifestyle"], "source": "conversation", "scope": "personal",
+ "created_at": "2026-10-02T09:00:00Z"},
+ ]
+ self.added: list[dict] = []
+ self.tasks = [
+ {"id": "t1", "name": "Book the dentist", "status": "waiting_approval",
+ "description": "Book the dentist", "plan": [{"step": "find one"}], "updated_at": "2026-10-20T10:00:00Z"},
+ ]
+ self.notes: list[dict] = []
+ self.fail_search = False
+
+ def integrations_config(self):
+ return dict(self.section)
+
+ def search_memories(self, query, limit=10):
+ if self.fail_search:
+ raise EngineError("The engine said no (500).")
+ q = query.lower()
+ return [m for m in self.memories if q in m["content"].lower()][:limit]
+
+ def get_memory(self, memory_id):
+ for m in self.memories:
+ if m["id"] == memory_id:
+ return {**m, "memory_type": "long-term", "state": "current"}
+ raise EngineError("The engine said no (404): memory 999 not found.")
+
+ def add_memory(self, content, *, hold_for_review, scope=None):
+ self.added.append({"content": content, "hold_for_review": hold_for_review, "scope": scope})
+ out = {"action": "ADD", "id": 99, "content": content}
+ if hold_for_review:
+ out["status"] = "pending"
+ return out
+
+ def create_task(self, prompt):
+ task = {"id": "t2", "name": prompt[:40], "status": "planning", "description": prompt,
+ "plan": [], "updated_at": "2026-10-21T10:00:00Z"}
+ self.tasks.append(task)
+ return task
+
+ def list_tasks(self):
+ return list(self.tasks)
+
+ def get_task(self, task_id):
+ for t in self.tasks:
+ if t["id"] == task_id:
+ return t
+ raise EngineError("The engine said no (404).")
+
+ def notify(self, message, title=None):
+ self.notes.append({"message": message, "title": title})
+ return {"id": "n1"}
+
+ def chat_turn(self, text):
+ return {"content": f"Sentient replies to: {text}", "session_id": "s1"}
+
+
+def _result_json(result) -> dict:
+ assert result.content, "the tool returned no content"
+ return json.loads(result.content[0].text)
+
+
+async def _call(server, name: str, args: dict | None = None) -> dict:
+ async with Client(server) as client:
+ return _result_json(await client.call_tool(name, args or {}))
+
+
+async def test_the_tool_list_is_exactly_the_safe_whitelist():
+ async with Client(build_server(FakeEngine())) as client:
+ tools = await client.list_tools()
+ names = {t.name for t in tools.tools}
+ assert names == EXPECTED_TOOLS
+ # the red line: nothing here can send, delete or run anything
+ for forbidden in ("send", "delete", "exec", "run", "shell", "remove"):
+ assert not any(forbidden in name for name in names), names
+
+
+async def test_every_tool_refuses_while_turned_off():
+ server = build_server(FakeEngine(enabled=False))
+ for name, args in [
+ ("memory_search", {"query": "Maya"}),
+ ("memory_add", {"content": "Maya likes tea"}),
+ ("memory_get", {"memory_id": 1}),
+ ("task_create", {"prompt": "do a thing"}),
+ ("task_list", {}),
+ ("task_status", {"task_id": "t1"}),
+ ("notify_me", {"message": "hi"}),
+ ("ask_sentient", {"message": "hi"}),
+ ]:
+ out = await _call(server, name, args)
+ assert "turned off" in out["error"], (name, out)
+
+
+async def test_memory_search_and_get():
+ server = build_server(FakeEngine())
+ found = await _call(server, "memory_search", {"query": "Maya"})
+ assert [m["id"] for m in found["memories"]] == [1, 2]
+ one = await _call(server, "memory_get", {"memory_id": 2})
+ assert one["content"] == "Maya Rao drinks tea, not coffee"
+ missing = await _call(server, "memory_get", {"memory_id": 999})
+ assert "error" in missing and "404" in missing["error"]
+
+
+async def test_memory_add_waits_for_review_by_default():
+ engine = FakeEngine()
+ out = await _call(build_server(engine), "memory_add", {"content": "Maya's office is in Lisbon"})
+ assert engine.added == [{"content": "Maya's office is in Lisbon", "hold_for_review": True, "scope": None}]
+ assert out["status"] == "pending" and "review" in out["note"].lower()
+
+
+async def test_memory_add_saves_straight_away_only_when_the_user_allowed_it():
+ engine = FakeEngine(skip_review=True)
+ out = await _call(build_server(engine), "memory_add", {"content": "Maya's office is in Lisbon"})
+ assert engine.added[0]["hold_for_review"] is False
+ assert out["status"] == "active"
+
+
+async def test_task_create_notes_that_the_plan_waits_for_approval():
+ engine = FakeEngine()
+ out = await _call(build_server(engine), "task_create", {"prompt": "Book the dentist for next week"})
+ assert out["id"] == "t2" and out["status"] == "planning"
+ assert "approval" in out["note"]
+ listed = await _call(build_server(engine), "task_list")
+ assert {t["id"] for t in listed["tasks"]} == {"t1", "t2"}
+ status = await _call(build_server(engine), "task_status", {"task_id": "t1"})
+ assert status["status"] == "waiting_approval" and status["plan_steps"] == 1
+
+
+async def test_notify_me_and_ask_sentient():
+ engine = FakeEngine()
+ out = await _call(build_server(engine), "notify_me", {"message": "Claude Code finished the report"})
+ assert out == {"notified": True, "id": "n1"}
+ assert engine.notes == [{"message": "Claude Code finished the report", "title": None}]
+ reply = await _call(build_server(engine), "ask_sentient", {"message": "what is on today?"})
+ assert reply["reply"] == "Sentient replies to: what is on today?"
+
+
+async def test_engine_errors_come_back_as_error_json_not_a_crash():
+ engine = FakeEngine()
+ engine.fail_search = True
+ out = await _call(build_server(engine), "memory_search", {"query": "Maya"})
+ assert out == {"error": "The engine said no (500)."}
+
+
+def test_the_mcp_command_exists_and_refuses_without_an_engine(isolated_home):
+ runner = CliRunner()
+ help_out = runner.invoke(cli.app, ["mcp", "--help"])
+ assert help_out.exit_code == 0, help_out.output
+ # no engine is listening on the default port in the test home, so starting must fail plainly
+ refused = runner.invoke(cli.app, ["mcp"])
+ assert refused.exit_code == 2, refused.output
+ assert "Start Sentient first" in refused.output
From c806021a18f3d01ffdf3ec7cf84e036e91f91ba1 Mon Sep 17 00:00:00 2001
From: juliasadler <3263905943@qq.com>
Date: Wed, 21 Oct 2026 11:40:04 +0800
Subject: [PATCH 02/10] fix(mcp): address review feedback: IPv6 engine URL,
gate error reporting, HTTP bearer auth
---
.../src/features/integrations/SentientMcp.tsx | 8 +-
docs/API.md | 4 +-
...serve-sentient-to-other-agents-over-mcp.md | 6 +-
sentient/mcp_server/client.py | 2 +
sentient/mcp_server/server.py | 115 +++++++++++++-----
tests/mcp_server/test_client.py | 17 ++-
tests/mcp_server/test_mcp_server.py | 40 +++++-
7 files changed, 156 insertions(+), 36 deletions(-)
diff --git a/desktop/src/features/integrations/SentientMcp.tsx b/desktop/src/features/integrations/SentientMcp.tsx
index bc76f2ab5..f17949824 100644
--- a/desktop/src/features/integrations/SentientMcp.tsx
+++ b/desktop/src/features/integrations/SentientMcp.tsx
@@ -12,7 +12,10 @@ const HTTP_START_COMMAND = 'sentient mcp --transport http'
const HTTP_SNIPPET = `{
"mcpServers": {
"sentient": {
- "url": "http://127.0.0.1:8765/mcp"
+ "url": "http://127.0.0.1:8765/mcp",
+ "headers": {
+ "Authorization": "Bearer "
+ }
}
}
}`
@@ -130,7 +133,8 @@ export function SentientMcpSection({ id }: { id?: string }) {
For the HTTP address above, start Sentient in HTTP mode first with {HTTP_START_COMMAND}. Keep it on this
- computer only.
+ computer only, and replace <engine token> with the engine token: the HTTP address asks for it
+ on every call.
diff --git a/docs/API.md b/docs/API.md
index 3618158c0..60dfcba0b 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1603,7 +1603,9 @@ run the same masking again before their prompts. The original stays in the user'
The other direction: other AI apps on this computer (Claude Code, Cursor, any MCP client) can use Sentient
itself. The command `sentient mcp` (package `sentient/mcp_server/`) runs an MCP server over stdio, or over
-streamable HTTP on 127.0.0.1 only with `--transport http --port ` (default 8765, path `/mcp`). It is a thin
+streamable HTTP on 127.0.0.1 only with `--transport http --port ` (default 8765, path `/mcp`); HTTP clients
+must send the engine token as `Authorization: Bearer `, because loopback alone would let any process on
+the computer call the tools. It is a thin
client of this same REST API: it reads the gateway address from the config, takes the token the way `serve`
does (`SENTIENT_GATEWAY_TOKEN` or the token file), refuses a non-loopback engine address, and holds no state.
diff --git a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
index 6712c2cc3..e1b4a325f 100644
--- a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
+++ b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
@@ -46,8 +46,10 @@ stranger's client.
outside-content checks (ADR 0018): anything risky asks the user in the Sentient window, and
nothing the MCP client says can answer that question, because answers only come from the user
in the app or a paired chat, never from a model or a tool result.
-- **stdio first, HTTP only on this computer.** stdio is the default transport. Streamable HTTP
- (`--transport http`) binds 127.0.0.1 only, for clients that cannot start a local command.
+- **stdio first, HTTP only on this computer, with the engine token.** stdio is the default
+ transport. Streamable HTTP (`--transport http`) binds 127.0.0.1 only, for clients that cannot
+ start a local command, and requires the engine token as a bearer token: loopback alone would
+ let any process (or web page) on the computer call the tools.
## Consequences
diff --git a/sentient/mcp_server/client.py b/sentient/mcp_server/client.py
index a6c96eafa..a7afd7026 100644
--- a/sentient/mcp_server/client.py
+++ b/sentient/mcp_server/client.py
@@ -27,6 +27,8 @@ def default_engine_url() -> str:
cfg = load_config()
host = cfg.gateway.host if cfg.gateway.host in LOOPBACK_HOSTS else "127.0.0.1"
+ if ":" in host: # an IPv6 host needs brackets before the port, or the URL does not parse
+ host = f"[{host}]"
return f"http://{host}:{cfg.gateway.port}"
diff --git a/sentient/mcp_server/server.py b/sentient/mcp_server/server.py
index fed0970ad..d385a1c84 100644
--- a/sentient/mcp_server/server.py
+++ b/sentient/mcp_server/server.py
@@ -7,6 +7,8 @@
- The server only starts when the user has turned it on in Settings (``integrations.mcp_server_enabled``),
and every tool call checks the switch again against the running engine, so turning it off in
Settings stops a server that is already running.
+- The HTTP transport additionally requires the engine token as a bearer token: being another
+ process on the same computer is not enough to call the tools.
- Everything goes through the engine's REST API with the engine token, so a task is created the
normal way (its plan waits for the user's approval in Sentient), memories an MCP app adds wait
in Review (ADR 0021) unless the user allowed saving them straight away, and a chat turn's tools
@@ -17,11 +19,15 @@
from __future__ import annotations
import json
+import secrets
import sys
from typing import Any
+from mcp.server.auth.provider import AccessToken
+from mcp.server.auth.settings import AuthSettings
from mcp.server.mcpserver import MCPServer
from mcp.types import ToolAnnotations
+from pydantic import AnyHttpUrl
from sentient.mcp_server.client import EngineClient, EngineError, default_engine_url
@@ -77,16 +83,56 @@ def check(self) -> dict | None:
return section if section.get("mcp_server_enabled") else None
-def build_server(client: EngineClient, gate: Gate | None = None) -> MCPServer:
+class EngineTokenVerifier:
+ """Bearer-token check for the HTTP transport: the token is the engine token.
+
+ Loopback is not enough on its own: any process (or web page) on this computer could otherwise
+ call the tools. stdio needs no verifier; the client starts the process itself, as with other
+ local MCP servers.
+ """
+
+ def __init__(self, token: str):
+ self._token = token
+
+ async def verify_token(self, token: str) -> AccessToken | None:
+ if token and secrets.compare_digest(token, self._token):
+ return AccessToken(token=token, client_id="sentient-mcp-http", scopes=[])
+ return None
+
+
+def build_server(
+ client: EngineClient,
+ gate: Gate | None = None,
+ *,
+ http_token: str | None = None,
+ http_port: int = 8765,
+) -> MCPServer:
gate = gate or Gate(client)
- server = MCPServer("sentient")
+ if http_token:
+ base = f"http://127.0.0.1:{http_port}"
+ server = MCPServer(
+ "sentient",
+ token_verifier=EngineTokenVerifier(http_token),
+ auth=AuthSettings(
+ issuer_url=AnyHttpUrl(base),
+ resource_server_url=AnyHttpUrl(f"{base}/mcp"),
+ validate_token_resource=False,
+ ),
+ )
+ else:
+ server = MCPServer("sentient")
+
+ def live_config() -> tuple[dict | None, str]:
+ """(section, "") when a call may run, else (None, the plain reason it may not).
- def live_config() -> dict | None:
- """None when a call must not run: turned off, or the engine went away."""
+ Only the turned-off switch reports OFF_MESSAGE; an engine problem (a rotated token, the
+ engine stopped) reaches the caller with its own message, so the fix stays visible.
+ """
try:
- return gate.check()
- except EngineError:
- return None
+ section = gate.check()
+ except EngineError as exc:
+ return None, str(exc)
+ return (section, "") if section is not None else (None, OFF_MESSAGE)
@server.tool(
description=(
@@ -96,8 +142,9 @@ def live_config() -> dict | None:
annotations=_READ,
)
def memory_search(query: str, limit: int = 10) -> str:
- if live_config() is None:
- return _error(OFF_MESSAGE)
+ section, why = live_config()
+ if section is None:
+ return _error(why)
try:
rows = client.search_memories(query, limit)
except EngineError as exc:
@@ -112,9 +159,9 @@ def memory_search(query: str, limit: int = 10) -> str:
annotations=_WRITE,
)
def memory_add(content: str, scope: str | None = None) -> str:
- section = live_config()
+ section, why = live_config()
if section is None:
- return _error(OFF_MESSAGE)
+ return _error(why)
hold = not bool(section.get("mcp_server_memories_skip_review"))
try:
result = client.add_memory(content, hold_for_review=hold, scope=scope)
@@ -130,8 +177,9 @@ def memory_add(content: str, scope: str | None = None) -> str:
@server.tool(description="Read one memory from Sentient by its id.", annotations=_READ)
def memory_get(memory_id: int) -> str:
- if live_config() is None:
- return _error(OFF_MESSAGE)
+ section, why = live_config()
+ if section is None:
+ return _error(why)
try:
row = client.get_memory(memory_id)
except EngineError as exc:
@@ -146,8 +194,9 @@ def memory_get(memory_id: int) -> str:
annotations=_WRITE,
)
def task_create(prompt: str) -> str:
- if live_config() is None:
- return _error(OFF_MESSAGE)
+ section, why = live_config()
+ if section is None:
+ return _error(why)
try:
task = client.create_task(prompt)
except EngineError as exc:
@@ -158,8 +207,9 @@ def task_create(prompt: str) -> str:
@server.tool(description="List the user's tasks in Sentient with their status.", annotations=_READ)
def task_list() -> str:
- if live_config() is None:
- return _error(OFF_MESSAGE)
+ section, why = live_config()
+ if section is None:
+ return _error(why)
try:
rows = client.list_tasks()
except EngineError as exc:
@@ -168,8 +218,9 @@ def task_list() -> str:
@server.tool(description="Read one task from Sentient by its id, with its status.", annotations=_READ)
def task_status(task_id: str) -> str:
- if live_config() is None:
- return _error(OFF_MESSAGE)
+ section, why = live_config()
+ if section is None:
+ return _error(why)
try:
task = client.get_task(task_id)
except EngineError as exc:
@@ -181,8 +232,9 @@ def task_status(task_id: str) -> str:
annotations=_WRITE,
)
def notify_me(message: str, title: str | None = None) -> str:
- if live_config() is None:
- return _error(OFF_MESSAGE)
+ section, why = live_config()
+ if section is None:
+ return _error(why)
try:
note = client.notify(message, title)
except EngineError as exc:
@@ -198,8 +250,9 @@ def notify_me(message: str, title: str | None = None) -> str:
annotations=_WRITE,
)
def ask_sentient(message: str) -> str:
- if live_config() is None:
- return _error(OFF_MESSAGE)
+ section, why = live_config()
+ if section is None:
+ return _error(why)
try:
turn = client.chat_turn(message)
except EngineError as exc:
@@ -233,8 +286,9 @@ def main(
from sentient.gateway.auth import load_or_create_token
url = engine_url or default_engine_url()
+ token = load_or_create_token()
try:
- client = EngineClient(url, load_or_create_token())
+ client = EngineClient(url, token)
except EngineError as exc:
print(str(exc), file=sys.stderr)
return 2
@@ -242,11 +296,16 @@ def main(
if problem is not None:
print(problem, file=sys.stderr)
return 2
- server = build_server(client)
if transport == "http":
- # streamable HTTP, loopback only: the SDK's default host is 127.0.0.1 and we keep it that way.
- print(f"Sentient MCP server on http://127.0.0.1:{port}/mcp (this computer only)", file=sys.stderr)
+ # streamable HTTP, loopback only, and the client must send the engine token as a bearer
+ # token: being on this computer is not enough on its own.
+ server = build_server(client, http_token=token, http_port=port)
+ print(
+ f"Sentient MCP server on http://127.0.0.1:{port}/mcp "
+ "(this computer only; clients must send the engine token)",
+ file=sys.stderr,
+ )
server.run("streamable-http", host="127.0.0.1", port=port)
else:
- server.run("stdio")
+ build_server(client).run("stdio")
return 0
diff --git a/tests/mcp_server/test_client.py b/tests/mcp_server/test_client.py
index fa608fe4b..843500947 100644
--- a/tests/mcp_server/test_client.py
+++ b/tests/mcp_server/test_client.py
@@ -6,7 +6,7 @@
import pytest
import respx
-from sentient.mcp_server.client import EngineClient, EngineError
+from sentient.mcp_server.client import EngineClient, EngineError, default_engine_url
BASE = "http://127.0.0.1:7777"
@@ -22,6 +22,21 @@ def test_a_non_local_engine_address_is_refused():
EngineClient("https://example.com:7777", "test-token")
+def test_the_default_url_brackets_an_ipv6_gateway_host(monkeypatch):
+ from types import SimpleNamespace
+
+ import sentient.config
+
+ fake = SimpleNamespace(gateway=SimpleNamespace(host="::1", port=7777))
+ monkeypatch.setattr(sentient.config, "load_config", lambda: fake)
+ assert default_engine_url() == "http://[::1]:7777"
+ # and the bracketed address passes the client's own loopback check
+ EngineClient(default_engine_url(), "test-token")
+
+ fake.gateway.host = "localhost"
+ assert default_engine_url() == "http://localhost:7777"
+
+
def test_calls_carry_the_token_and_hit_the_right_routes():
with respx.mock() as router:
search = router.get(f"{BASE}/api/memories").mock(
diff --git a/tests/mcp_server/test_mcp_server.py b/tests/mcp_server/test_mcp_server.py
index 9b05e9c13..14185b121 100644
--- a/tests/mcp_server/test_mcp_server.py
+++ b/tests/mcp_server/test_mcp_server.py
@@ -9,6 +9,8 @@
import json
+import httpx
+import respx
from mcp import Client
from typer.testing import CliRunner
@@ -46,8 +48,11 @@ def __init__(self, *, enabled: bool = True, skip_review: bool = False):
]
self.notes: list[dict] = []
self.fail_search = False
+ self.fail_config = False
def integrations_config(self):
+ if self.fail_config:
+ raise EngineError("The engine refused this server's token (401).")
return dict(self.section)
def search_memories(self, query, limit=10):
@@ -183,7 +188,38 @@ def test_the_mcp_command_exists_and_refuses_without_an_engine(isolated_home):
runner = CliRunner()
help_out = runner.invoke(cli.app, ["mcp", "--help"])
assert help_out.exit_code == 0, help_out.output
- # no engine is listening on the default port in the test home, so starting must fail plainly
- refused = runner.invoke(cli.app, ["mcp"])
+ # the health check is mocked to fail the way a stopped engine fails, so the test never
+ # depends on whether a real Sentient happens to be running on this computer
+ with respx.mock() as router:
+ router.get(url__regex=r".*/api/health").mock(side_effect=httpx.ConnectError("down"))
+ refused = runner.invoke(cli.app, ["mcp"])
assert refused.exit_code == 2, refused.output
assert "Start Sentient first" in refused.output
+
+
+async def test_an_engine_error_at_the_gate_is_reported_not_masked_as_turned_off():
+ engine = FakeEngine()
+ engine.fail_config = True
+ out = await _call(build_server(engine), "memory_search", {"query": "Maya"})
+ assert out == {"error": "The engine refused this server's token (401)."}
+
+
+def test_the_http_transport_refuses_calls_without_the_engine_token():
+ from starlette.testclient import TestClient
+
+ engine = FakeEngine()
+ server = build_server(engine, http_token="test-token", http_port=8765)
+ init = {
+ "jsonrpc": "2.0", "id": 1, "method": "initialize",
+ "params": {"protocolVersion": "2025-03-26", "capabilities": {},
+ "clientInfo": {"name": "test", "version": "0"}},
+ }
+ headers = {"Accept": "application/json, text/event-stream"}
+ with TestClient(server.streamable_http_app(), base_url="http://127.0.0.1:8765") as http:
+ refused = http.post("/mcp", json=init, headers=headers)
+ assert refused.status_code == 401, refused.text
+ wrong = http.post("/mcp", json=init, headers={**headers, "Authorization": "Bearer nope"})
+ assert wrong.status_code == 401, wrong.text
+ allowed = http.post("/mcp", json=init, headers={**headers, "Authorization": "Bearer test-token"})
+ assert allowed.status_code == 200, allowed.text
+ assert "sentient" in allowed.text
From 2ac521dbb68a711e5416e035f03b582145690702 Mon Sep 17 00:00:00 2001
From: juliasadler <3263905943@qq.com>
Date: Wed, 21 Oct 2026 12:25:09 +0800
Subject: [PATCH 03/10] fix(mcp): keep the engine token out of client settings:
add the `sentient mcp --connect` launcher
---
.../src/features/integrations/SentientMcp.tsx | 12 +-
docs/API.md | 9 +-
...serve-sentient-to-other-agents-over-mcp.md | 13 +-
sentient/cli.py | 14 +-
sentient/mcp_server/server.py | 98 +++++++++++++-
tests/mcp_server/test_mcp_server.py | 122 +++++++++++++++++-
6 files changed, 250 insertions(+), 18 deletions(-)
diff --git a/desktop/src/features/integrations/SentientMcp.tsx b/desktop/src/features/integrations/SentientMcp.tsx
index f17949824..05d2294cd 100644
--- a/desktop/src/features/integrations/SentientMcp.tsx
+++ b/desktop/src/features/integrations/SentientMcp.tsx
@@ -12,10 +12,8 @@ const HTTP_START_COMMAND = 'sentient mcp --transport http'
const HTTP_SNIPPET = `{
"mcpServers": {
"sentient": {
- "url": "http://127.0.0.1:8765/mcp",
- "headers": {
- "Authorization": "Bearer "
- }
+ "command": "sentient",
+ "args": ["mcp", "--connect", "http://127.0.0.1:8765/mcp"]
}
}
}`
@@ -132,9 +130,9 @@ export function SentientMcpSection({ id }: { id?: string }) {
- For the HTTP address above, start Sentient in HTTP mode first with {HTTP_START_COMMAND}. Keep it on this
- computer only, and replace <engine token> with the engine token: the HTTP address asks for it
- on every call.
+ For the HTTP address above, start Sentient in HTTP mode first with {HTTP_START_COMMAND}. The settings
+ above start Sentient's launcher instead of connecting directly: the launcher adds Sentient's sign-in itself, so no key ever goes into
+ the file. Keep it on this computer only.
diff --git a/docs/API.md b/docs/API.md
index 60dfcba0b..46bdbd36e 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1603,9 +1603,12 @@ run the same masking again before their prompts. The original stays in the user'
The other direction: other AI apps on this computer (Claude Code, Cursor, any MCP client) can use Sentient
itself. The command `sentient mcp` (package `sentient/mcp_server/`) runs an MCP server over stdio, or over
-streamable HTTP on 127.0.0.1 only with `--transport http --port ` (default 8765, path `/mcp`); HTTP clients
-must send the engine token as `Authorization: Bearer `, because loopback alone would let any process on
-the computer call the tools. It is a thin
+streamable HTTP on 127.0.0.1 only with `--transport http --port ` (default 8765, path `/mcp`); the HTTP
+endpoint requires the engine token as `Authorization: Bearer `, because loopback alone would let any
+process on the computer call the tools. Clients are never given that token to put in a settings file: they
+connect through the launcher, `sentient mcp --connect `, which reads the token the same way the
+server does, adds it when it connects, and serves the tools over stdio, so a client's settings hold only the
+launcher command and the loopback address. It is a thin
client of this same REST API: it reads the gateway address from the config, takes the token the way `serve`
does (`SENTIENT_GATEWAY_TOKEN` or the token file), refuses a non-loopback engine address, and holds no state.
diff --git a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
index e1b4a325f..2480a9cab 100644
--- a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
+++ b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
@@ -46,10 +46,15 @@ stranger's client.
outside-content checks (ADR 0018): anything risky asks the user in the Sentient window, and
nothing the MCP client says can answer that question, because answers only come from the user
in the app or a paired chat, never from a model or a tool result.
-- **stdio first, HTTP only on this computer, with the engine token.** stdio is the default
- transport. Streamable HTTP (`--transport http`) binds 127.0.0.1 only, for clients that cannot
- start a local command, and requires the engine token as a bearer token: loopback alone would
- let any process (or web page) on the computer call the tools.
+- **stdio first, HTTP only on this computer, reached through the launcher.** stdio is the
+ default transport. Streamable HTTP (`--transport http`) binds 127.0.0.1 only and runs as a
+ standalone server; it requires the engine token as a bearer token, because loopback alone
+ would let any process (or web page) on the computer call the tools. The token is never handed
+ to a client to paste into its settings file (repository rule: secrets go through
+ `sentient.secrets` or stay invisible, never into config files). Clients instead point at the
+ launcher, `sentient mcp --connect `: a local process that reads the token the same
+ way the server does, adds it when it connects, and serves the tools over stdio. The settings
+ an app keeps therefore hold only the launcher command and the loopback address.
## Consequences
diff --git a/sentient/cli.py b/sentient/cli.py
index e8d87ebef..43cde248b 100644
--- a/sentient/cli.py
+++ b/sentient/cli.py
@@ -138,6 +138,12 @@ def mcp(
transport: str = typer.Option("stdio", help="stdio, or http (streamable HTTP on this computer only)"),
port: int = typer.Option(8765, help="Port for --transport http. Always bound to 127.0.0.1."),
engine_url: str | None = typer.Option(None, help="Engine address (default: the gateway address from the config)"),
+ connect: str | None = typer.Option(
+ None,
+ help="Launcher mode: connect to the HTTP server at this address on this computer and serve "
+ "it over stdio, adding Sentient's sign-in itself (no key in the client's settings). The "
+ "address carries the port, so --port and --engine-url do not apply in this mode",
+ ),
):
"""Let other AI apps on this computer use Sentient over MCP (off until turned on in Settings).
@@ -146,9 +152,15 @@ def mcp(
if transport not in {"stdio", "http"}:
console.print("[red]--transport must be stdio or http[/red]")
raise typer.Exit(2)
+ if connect is not None and transport != "stdio":
+ console.print("[red]--connect is the launcher: it serves over stdio and cannot be combined with --transport http[/red]")
+ raise typer.Exit(2)
+ if connect is not None and engine_url is not None:
+ console.print("[red]--connect takes the server's full address; --engine-url does not apply in launcher mode[/red]")
+ raise typer.Exit(2)
from sentient.mcp_server.server import main
- raise typer.Exit(main(transport, port=port, engine_url=engine_url))
+ raise typer.Exit(main(transport, port=port, engine_url=engine_url, connect=connect))
@app.command("claude-code-tools", hidden=True)
diff --git a/sentient/mcp_server/server.py b/sentient/mcp_server/server.py
index d385a1c84..79c50156d 100644
--- a/sentient/mcp_server/server.py
+++ b/sentient/mcp_server/server.py
@@ -8,7 +8,11 @@
and every tool call checks the switch again against the running engine, so turning it off in
Settings stops a server that is already running.
- The HTTP transport additionally requires the engine token as a bearer token: being another
- process on the same computer is not enough to call the tools.
+ process on the same computer is not enough to call the tools. No client is ever given that
+ token to paste into a configuration file: ``sentient mcp --connect `` is the
+ launcher. It reads the token the same way this server does, adds it when it connects, and
+ serves the tools over stdio, so the configuration an app keeps holds only this command and
+ the loopback address.
- Everything goes through the engine's REST API with the engine token, so a task is created the
normal way (its plan waits for the user's approval in Sentient), memories an MCP app adds wait
in Review (ADR 0021) unless the user allowed saving them straight away, and a chat turn's tools
@@ -276,15 +280,104 @@ def check_ready(client: EngineClient) -> str | None:
return None
+class BridgeError(Exception):
+ """The launcher could not reach the HTTP MCP server, or it refused the sign-in."""
+
+
+def validate_connect_url(url: str) -> str:
+ """The launcher only ever connects to an MCP server on this computer."""
+ from urllib.parse import urlparse
+
+ parsed = urlparse(url)
+ host = (parsed.hostname or "").lower()
+ if parsed.scheme not in {"http", "https"} or host not in {"127.0.0.1", "localhost", "::1"}:
+ raise BridgeError(
+ f"{url} is not an address on this computer. The launcher only connects to a Sentient "
+ "MCP server running on this computer, such as http://127.0.0.1:8765/mcp."
+ )
+ return url
+
+
+async def _run_bridge(connect_url: str, token: str) -> None:
+ """Serve the tools over stdio, forwarding to the HTTP server at ``connect_url``.
+
+ This is the launcher: the MCP client starts this process, and the token is added here, by
+ Sentient's own process, on the connection it opens. The token never appears in the
+ client's configuration file, which names only this command and the loopback address.
+ """
+ import httpx2
+ from mcp import ClientSession, types
+ from mcp.client.streamable_http import streamable_http_client
+ from mcp.server.lowlevel import Server
+ from mcp.server.stdio import stdio_server
+
+ http = httpx2.AsyncClient(
+ headers={"Authorization": f"Bearer {token}"},
+ timeout=httpx2.Timeout(10.0, read=1800.0),
+ )
+ try:
+ async with (
+ streamable_http_client(connect_url, http_client=http) as (read, write),
+ ClientSession(read, write) as session,
+ ):
+ await session.initialize()
+ bridge = Server("sentient-mcp-launcher")
+
+ async def _list_tools(ctx, params):
+ result = await session.list_tools()
+ return types.ListToolsResult(tools=result.tools)
+
+ async def _call_tool(ctx, params):
+ return await session.call_tool(params.name, params.arguments or {})
+
+ bridge.add_request_handler("tools/list", types.PaginatedRequestParams, _list_tools)
+ bridge.add_request_handler("tools/call", types.CallToolRequestParams, _call_tool)
+ async with stdio_server() as (stdin, stdout):
+ await bridge.run(stdin, stdout, bridge.create_initialization_options())
+ except (KeyboardInterrupt, SystemExit):
+ raise
+ except BaseException as exc: # connection failures can arrive grouped (anyio task groups)
+ raise BridgeError(
+ f"Could not connect to the Sentient MCP server at {connect_url}, or it refused "
+ "Sentient's sign-in. Start the server first with `sentient mcp --transport http`, "
+ "from this same Sentient install, then try again."
+ ) from exc
+ finally:
+ await http.aclose()
+
+
+def _launch(connect_url: str) -> int:
+ """Launcher mode: forward a client's stdio to the HTTP server, signing in ourselves."""
+ import anyio
+
+ from sentient.gateway.auth import load_or_create_token
+
+ try:
+ url = validate_connect_url(connect_url)
+ except BridgeError as exc:
+ print(str(exc), file=sys.stderr)
+ return 2
+ try:
+ anyio.run(_run_bridge, url, load_or_create_token())
+ except BridgeError as exc:
+ print(str(exc), file=sys.stderr)
+ return 2
+ return 0
+
+
def main(
transport: str = "stdio",
*,
port: int = 8765,
engine_url: str | None = None,
+ connect: str | None = None,
) -> int:
"""Entry point for ``sentient mcp``. Returns the process exit code."""
from sentient.gateway.auth import load_or_create_token
+ if connect is not None:
+ return _launch(connect)
+
url = engine_url or default_engine_url()
token = load_or_create_token()
try:
@@ -302,7 +395,8 @@ def main(
server = build_server(client, http_token=token, http_port=port)
print(
f"Sentient MCP server on http://127.0.0.1:{port}/mcp "
- "(this computer only; clients must send the engine token)",
+ f"(this computer only; clients connect through the launcher: "
+ f"sentient mcp --connect http://127.0.0.1:{port}/mcp)",
file=sys.stderr,
)
server.run("streamable-http", host="127.0.0.1", port=port)
diff --git a/tests/mcp_server/test_mcp_server.py b/tests/mcp_server/test_mcp_server.py
index 14185b121..24a6fc567 100644
--- a/tests/mcp_server/test_mcp_server.py
+++ b/tests/mcp_server/test_mcp_server.py
@@ -8,15 +8,20 @@
from __future__ import annotations
import json
+import socket
+import sys
+import threading
+import time
import httpx
+import pytest
import respx
from mcp import Client
from typer.testing import CliRunner
from sentient import cli
from sentient.mcp_server.client import EngineError
-from sentient.mcp_server.server import build_server
+from sentient.mcp_server.server import BridgeError, build_server, main, validate_connect_url
EXPECTED_TOOLS = {
"memory_search", "memory_add", "memory_get",
@@ -223,3 +228,118 @@ def test_the_http_transport_refuses_calls_without_the_engine_token():
allowed = http.post("/mcp", json=init, headers={**headers, "Authorization": "Bearer test-token"})
assert allowed.status_code == 200, allowed.text
assert "sentient" in allowed.text
+
+
+# --- The launcher: `sentient mcp --connect ` --------------------------------------
+#
+# The HTTP endpoint requires the engine token, and no client settings file may hold it. The
+# launcher is the process that signs in: it reads the token the way the server does and adds
+# it on the connection it opens, then serves the tools over stdio.
+
+
+_SIGNIN = "test" + "-token" # the HTTP server's token in the launcher tests below
+
+
+def _free_port() -> int:
+ with socket.socket() as sock:
+ sock.bind(("127.0.0.1", 0))
+ return sock.getsockname()[1]
+
+
+def test_the_launcher_only_connects_to_this_computer():
+ for good in ("http://127.0.0.1:8765/mcp", "http://localhost:8765/mcp", "http://[::1]:8765/mcp"):
+ assert validate_connect_url(good) == good
+ for bad in ("https://example.com/mcp", "http://192.168.1.10:8765/mcp", "not a url"):
+ with pytest.raises(BridgeError):
+ validate_connect_url(bad)
+
+
+def test_the_launcher_fails_cleanly_when_no_server_is_listening(monkeypatch):
+ monkeypatch.setenv("SENTIENT_GATEWAY_TOKEN", "test-token")
+ assert main(connect=f"http://127.0.0.1:{_free_port()}/mcp") == 2
+
+
+def test_connect_and_http_transport_are_refused_together(isolated_home):
+ result = CliRunner().invoke(
+ cli.app, ["mcp", "--transport", "http", "--connect", "http://127.0.0.1:8765/mcp"]
+ )
+ assert result.exit_code == 2
+ result = CliRunner().invoke(
+ cli.app,
+ ["mcp", "--connect", "http://127.0.0.1:8765/mcp", "--engine-url", "http://127.0.0.1:7777"],
+ )
+ assert result.exit_code == 2
+
+
+def _serve_http_app(app):
+ """Run an ASGI app on a free loopback port in a thread; returns (address, stop)."""
+ import uvicorn
+
+ server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=0, log_level="error"))
+ thread = threading.Thread(target=server.run, daemon=True)
+ thread.start()
+ for _ in range(200):
+ if server.started:
+ break
+ time.sleep(0.05)
+ port = server.servers[0].sockets[0].getsockname()[1]
+
+ def stop() -> None:
+ server.should_exit = True
+ thread.join(timeout=5)
+
+ return f"http://127.0.0.1:{port}/mcp", stop
+
+
+def _launcher_params(url: str, token: str, home) -> object:
+ from mcp.client.stdio import StdioServerParameters
+
+ return StdioServerParameters(
+ command=sys.executable,
+ args=["-m", "sentient", "mcp", "--connect", url],
+ env={"SENTIENT_GATEWAY_TOKEN": token, "SENTIENT_HOME": str(home)},
+ )
+
+
+async def test_the_launcher_signs_in_itself_and_forwards_the_tools(tmp_path):
+ from mcp import ClientSession
+ from mcp.client.stdio import stdio_client
+
+ app = build_server(FakeEngine(), http_token=_SIGNIN, http_port=8765).streamable_http_app()
+ url, stop = _serve_http_app(app)
+ try:
+ params = _launcher_params(url, "test-token", tmp_path)
+ async with (
+ stdio_client(params) as (read, write),
+ ClientSession(read, write) as session,
+ ):
+ await session.initialize()
+ tools = await session.list_tools()
+ assert {t.name for t in tools.tools} == EXPECTED_TOOLS
+ found = _result_json(await session.call_tool("memory_search", {"query": "Maya"}))
+ assert [m["id"] for m in found["memories"]] == [1, 2]
+ finally:
+ stop()
+
+
+async def test_the_launcher_is_refused_with_the_wrong_token(tmp_path):
+ from mcp import ClientSession
+ from mcp.client.stdio import stdio_client
+
+ app = build_server(FakeEngine(), http_token=_SIGNIN, http_port=8765).streamable_http_app()
+ url, stop = _serve_http_app(app)
+ signed_in = False
+ try:
+ params = _launcher_params(url, "the-wrong-token", tmp_path)
+ try:
+ async with (
+ stdio_client(params) as (read, write),
+ ClientSession(read, write) as session,
+ ):
+ await session.initialize()
+ signed_in = True
+ except BaseException: # the launcher exits; the stdio streams just close
+ pass
+ finally:
+ stop()
+ assert not signed_in, "the launcher got in with the wrong token"
From f83760ad2223d4d19f9f629d94bf3705e764b86b Mon Sep 17 00:00:00 2001
From: juliasadler <3263905943@qq.com>
Date: Wed, 21 Oct 2026 16:46:15 +0800
Subject: [PATCH 04/10] fix(mcp): answer the changes-requested review: MCP
turns are outside content from the start, approvals surface as notifications,
an MCP-only token replaces the gateway token, HTTP goes
---
changes/443.added.md | 2 +-
changes/443.security.md | 1 +
.../integrations/IntegrationsPage.tsx | 5 +-
.../src/features/integrations/SentientMcp.tsx | 82 +++--
desktop/src/lib/api.ts | 2 +
docs/API.md | 79 +++--
...serve-sentient-to-other-agents-over-mcp.md | 107 ++++--
sentient/agent/approvals.py | 7 +
sentient/agent/loop.py | 80 ++++-
sentient/app.py | 18 +
sentient/channels/base.py | 4 +
sentient/channels/service.py | 54 ++-
sentient/cli.py | 34 +-
sentient/config/schema.py | 3 +-
sentient/gateway/auth.py | 42 +++
sentient/gateway/deps.py | 6 +-
sentient/gateway/mcp_token.py | 46 +++
sentient/gateway/routes/__init__.py | 3 +
sentient/gateway/routes/core.py | 15 +-
sentient/gateway/routes/mcp.py | 33 ++
sentient/gateway/routes/memory.py | 22 +-
sentient/gateway/routes/notifications.py | 15 +-
sentient/gateway/routes/tasks.py | 11 +-
sentient/mcp_server/client.py | 110 ++++--
sentient/mcp_server/server.py | 271 +++++---------
sentient/paths.py | 6 +
sentient/tasks/executor.py | 6 +-
sentient/tasks/mcp_tasks.py | 24 ++
sentient/tasks/service.py | 48 ++-
sentient/tools/builtin/memory_tool.py | 2 +
tests/channels/test_mcp_approval_notes.py | 62 ++++
tests/mcp_server/test_client.py | 76 +++-
tests/mcp_server/test_mcp_server.py | 215 +++--------
tests/mcp_server/test_review_fixes.py | 335 ++++++++++++++++++
34 files changed, 1273 insertions(+), 553 deletions(-)
create mode 100644 changes/443.security.md
create mode 100644 sentient/gateway/mcp_token.py
create mode 100644 sentient/gateway/routes/mcp.py
create mode 100644 sentient/tasks/mcp_tasks.py
create mode 100644 tests/channels/test_mcp_approval_notes.py
create mode 100644 tests/mcp_server/test_review_fixes.py
diff --git a/changes/443.added.md b/changes/443.added.md
index 2368e54a1..a2c861cff 100644
--- a/changes/443.added.md
+++ b/changes/443.added.md
@@ -1 +1 @@
-**Other AI apps can now use Sentient.** A new `sentient mcp` command lets Claude Code, Cursor and other MCP apps on this computer search and add memories, create and check tasks, leave a notification and ask Sentient a question. It is off until you turn it on under Integrations, there is no tool that sends, deletes or runs anything, memories an app adds wait in Review first, and a task it creates still needs your approval before it runs.
+**Other AI apps can now use Sentient.** A new `sentient mcp` command lets Claude Code, Cursor and other MCP apps on this computer search and add memories, create and check tasks, leave a notification and ask Sentient a question. It is off until you turn it on under Integrations, there is no tool that sends, deletes or runs anything, memories an app adds wait in Review first, and a task it creates is made in the app's name and always needs your approval before it runs.
diff --git a/changes/443.security.md b/changes/443.security.md
new file mode 100644
index 000000000..286a2a1fa
--- /dev/null
+++ b/changes/443.security.md
@@ -0,0 +1 @@
+**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and a memory the app states waits in Review instead of being saved as something you said. Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one.
diff --git a/desktop/src/features/integrations/IntegrationsPage.tsx b/desktop/src/features/integrations/IntegrationsPage.tsx
index 5420a0ed9..e470ddc46 100644
--- a/desktop/src/features/integrations/IntegrationsPage.tsx
+++ b/desktop/src/features/integrations/IntegrationsPage.tsx
@@ -22,7 +22,7 @@ import { cn } from '@/lib/utils'
* Deep links (also used by screenshots): `?connect=`,
* `?open=[&focus=privacy|tools|triggers|upgrades|disconnect|forget]` (`forget`: the disconnect question with
* "Also forget" ticked, #153),
- * `?section=mcp|webhooks`, `?q=`, `?category=`.
+ * `?section=mcp|webhooks|sentient-mcp`, `?q=`, `?category=`.
*/
export function IntegrationsPage() {
const [params, setParams] = useSearchParams()
@@ -82,7 +82,8 @@ export function IntegrationsPage() {
useEffect(() => {
const section = params.get('section')
- const target = section === 'mcp' ? '#mcp-servers' : section === 'webhooks' ? '#webhooks' : null
+ const target =
+ section === 'mcp' ? '#mcp-servers' : section === 'webhooks' ? '#webhooks' : section === 'sentient-mcp' ? '#sentient-mcp' : null
if (!target || isLoading || mcp.isLoading) return
const t = window.setTimeout(() => scroller.current?.querySelector(target)?.scrollIntoView({ block: 'start' }), 120)
return () => window.clearTimeout(t)
diff --git a/desktop/src/features/integrations/SentientMcp.tsx b/desktop/src/features/integrations/SentientMcp.tsx
index 05d2294cd..d57fd6591 100644
--- a/desktop/src/features/integrations/SentientMcp.tsx
+++ b/desktop/src/features/integrations/SentientMcp.tsx
@@ -4,19 +4,9 @@ import { toast } from 'sonner'
import { Alert, Button, FormRow, FormSection, IconButton, Skeleton, Switch } from '@/components/ui'
import { useConfigEditor } from '@/hooks/config'
import { useConfig } from '@/hooks/core'
-import { errorMessage } from '@/lib/api'
+import { api, errorMessage } from '@/lib/api'
import { copyText } from '@/lib/utils'
-
-const CLAUDE_CODE_COMMAND = 'claude mcp add sentient -- sentient mcp'
-const HTTP_START_COMMAND = 'sentient mcp --transport http'
-const HTTP_SNIPPET = `{
- "mcpServers": {
- "sentient": {
- "command": "sentient",
- "args": ["mcp", "--connect", "http://127.0.0.1:8765/mcp"]
- }
- }
-}`
+import { useConnection } from '@/stores/connection'
function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false)
@@ -55,18 +45,48 @@ function CodeBlock({ label, code, copyLabel }: { label: string; code: string; co
* Let other apps use Sentient over MCP (issue #443).
*
* Off by default. When on, MCP apps on this computer can use a small set of
- * Sentient tools for memory, tasks and notifications. All of it still goes
- * through Sentient itself, so plans and risky steps ask the user as usual.
- * Settings live in `integrations.mcp_server_enabled` and
+ * Sentient tools for memory, tasks and notifications, signing in with a key
+ * made only for this (kept in the keychain, replaceable below) that opens
+ * nothing else. All of it still goes through Sentient itself, so plans and
+ * risky steps ask the user as usual. Settings live in
+ * `integrations.mcp_server_enabled` and
* `integrations.mcp_server_memories_skip_review` (PATCH /api/config).
*/
export function SentientMcpSection({ id }: { id?: string }) {
const { config, isLoading, error, setValue } = useConfigEditor()
const configQuery = useConfig()
+ const backend = useConnection((s) => s.backend)
+ const [replacing, setReplacing] = useState(false)
const enabled = !!config?.integrations.mcp_server_enabled
const skipReview = !!config?.integrations.mcp_server_memories_skip_review
+ // The command an app should start: the bundled engine in an installed build, the checkout's
+ // Python in a development one. The bridge reports the path of the engine it launched; with no
+ // bridge (a browser tab), fall back to the plain command name.
+ const enginePath = backend?.pythonPath
+ const bundled = /sentient-engine(\.exe)?$/i.test(enginePath ?? '')
+ const command = enginePath ? `"${enginePath}"` : 'sentient'
+ const args = bundled || !enginePath ? ['mcp'] : ['-m', 'sentient', 'mcp']
+ const claudeCodeCommand = `claude mcp add sentient -- ${command} ${args.join(' ')}`
+ const snippet = `{
+ "mcpServers": {
+ "sentient": {
+ "command": ${JSON.stringify(enginePath ?? 'sentient')},
+ "args": ${JSON.stringify(args)}
+ }
+ }
+}`
+
+ const replaceKey = () => {
+ setReplacing(true)
+ void api.integrations
+ .replaceMcpKey()
+ .then(() => toast.success('Key replaced', { description: 'Restart your MCP apps so they pick up the new key.' }))
+ .catch((e: unknown) => toast.error("Couldn't replace the key", { description: errorMessage(e) }))
+ .finally(() => setReplacing(false))
+ }
+
return (
- Turn the switch on first, then add Sentient to the app you use. The app starts Sentient for you each time it needs it.
-
-
-
-
-
-
- For the HTTP address above, start Sentient in HTTP mode first with {HTTP_START_COMMAND}. The settings
- above start Sentient's launcher instead of connecting directly: the launcher adds Sentient's sign-in itself, so no key ever goes into
- the file. Keep it on this computer only.
+ Turn the switch on first, then add Sentient to the app you use. The app starts Sentient for you each time it needs it. The settings hold no address and no
+ key: the command finds the running Sentient by itself.
+
+
} title="How Sentient keeps this safe">
There are no tools to send messages, delete things or run commands.
-
A task made this way still needs you to approve its plan in Sentient before it runs.
-
When an app asks Sentient a question, Sentient's own tools still follow Sentient's approval rules and ask you when they should.
+
A task made this way is made in the app's name, and its plan always waits for your approval before it runs.
+
+ When an app asks Sentient a question, Sentient treats the words as the app's, not yours: anything that sends, deletes or runs code asks you first,
+ in the window and as a notification.
+
+
A memory an app saves waits in Review for your approval, unless you allow saving right away above.
diff --git a/desktop/src/lib/api.ts b/desktop/src/lib/api.ts
index c7a7489ac..516b38ce0 100644
--- a/desktop/src/lib/api.ts
+++ b/desktop/src/lib/api.ts
@@ -649,6 +649,8 @@ export const api = {
// §5 integrations ------------------------------------------------------------
integrations: {
+ /** Replace the MCP-only key (#443): apps holding the old one stop working until they restart. */
+ replaceMcpKey: () => http.post<{ rotated: boolean }>('/api/mcp/token/rotate'),
list: () => http.get('/api/integrations'),
get: (id: string) => http.get(`/api/integrations/${enc(id)}`),
connect: (id: string, fields: Record = {}, access?: ConnectionAccess) =>
diff --git a/docs/API.md b/docs/API.md
index 46bdbd36e..1bac3ff81 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1602,30 +1602,49 @@ run the same masking again before their prompts. The original stays in the user'
### Sentient as an MCP server: `sentient mcp` (#443, ADR 0034)
The other direction: other AI apps on this computer (Claude Code, Cursor, any MCP client) can use Sentient
-itself. The command `sentient mcp` (package `sentient/mcp_server/`) runs an MCP server over stdio, or over
-streamable HTTP on 127.0.0.1 only with `--transport http --port ` (default 8765, path `/mcp`); the HTTP
-endpoint requires the engine token as `Authorization: Bearer `, because loopback alone would let any
-process on the computer call the tools. Clients are never given that token to put in a settings file: they
-connect through the launcher, `sentient mcp --connect `, which reads the token the same way the
-server does, adds it when it connects, and serves the tools over stdio, so a client's settings hold only the
-launcher command and the loopback address. It is a thin
-client of this same REST API: it reads the gateway address from the config, takes the token the way `serve`
-does (`SENTIENT_GATEWAY_TOKEN` or the token file), refuses a non-loopback engine address, and holds no state.
-
-- **Off by default.** The process refuses to start while `integrations.mcp_server_enabled` is off, with a
- sentence telling the user where the switch is, and every tool call reads the switch from `GET /api/config`
- again, so turning it off stops a running server at its next call. The Integrations screen carries the
- switch and the snippet: `claude mcp add sentient -- sentient mcp`.
+itself. The command `sentient mcp` (package `sentient/mcp_server/`) runs an MCP server over stdio and is a
+thin client of this same REST API. It finds the engine through the discovery file the engine writes at
+startup (`engine-discovery.json` in the Sentient home: the loopback port only, never a secret; the desktop
+picks a fresh port every launch), refuses a non-loopback engine address, and holds no state. An HTTP
+transport is deliberately not offered; it may return later, protected by the same MCP-only token.
+
+- **Its own token.** Turning the feature on issues an MCP-only token, stored in the keychain
+ (`sentient.secrets`, name `mcp-server-token`) and never written to a config file or returned by any
+ endpoint. `sentient mcp` reads it from the keychain itself, so a client's settings hold only the
+ command: no address to keep current, no secret to paste. The token opens only the endpoints the tools
+ use, as (method, route) pairs: `GET|POST /api/memories`, `GET /api/memories/{id}`, `GET|POST /api/tasks`,
+ `GET /api/tasks/{id}`, `POST /api/notifications`, `POST /api/chat` and `GET /api/mcp/status`. On any
+ other route (config, approvals, sessions, deletes, the memory review inbox) it is refused with 403, and
+ handlers see the caller as `request.state.caller == "mcp"` rather than one of the user's own surfaces.
+ While the feature is off the token opens nothing at all except `GET /api/mcp/status`, so turning the
+ switch off stops direct API callers too, not just the server process. The gateway token still passes
+ everywhere; `sentient mcp` never reads or writes it.
+- **Endpoints of its own.** `GET /api/mcp/status` (either token) → `{enabled, memories_skip_review,
+ token_issued}`: what an MCP app may do right now, without reading the config. `POST /api/mcp/token/rotate`
+ (gateway token only) → `{rotated: true}`: replaces the MCP-only token in the keychain; apps holding the
+ old one stop working at once and pick the new one up when they restart. It is also issued from
+ `SentientApp.save_config` whenever `integrations.mcp_server_enabled` is saved on and none exists.
+- **Off by default.** The process refuses to start while the feature is off, with a sentence telling the
+ user where the switch is (the Integrations section, `#/integrations?section=sentient-mcp`), and every
+ tool call reads `GET /api/mcp/status` again, so turning it off stops a running server at its next call.
+ The Integrations screen carries the switch, the full path of the bundled engine for the snippets
+ (`"" mcp`; a development checkout uses `"" -m sentient mcp`), and the Replace key button.
- **Tools (a fixed whitelist).** `memory_search` (`GET /api/memories?q=`), `memory_get` (`GET /api/memories/{id}`),
`memory_add` (`POST /api/memories` with `source: "mcp"`), `task_create` (`POST /api/tasks`), `task_list`
- (`GET /api/tasks`), `task_status` (`GET /api/tasks/{id}`), `notify_me` (`POST /api/notifications`, above) and
- `ask_sentient` (one turn of `POST /api/chat` with `channel: "mcp"`). There is no tool that sends, deletes or
- runs anything.
-- **Writes land where the user sees them.** `memory_add` sends `hold_for_review: true`, so the memory waits in
- Review (section 7), unless the user set `integrations.mcp_server_memories_skip_review`. `task_create` is the
- normal create: the plan waits for the user's approval in Sentient. `ask_sentient` is a normal chat turn, so
- its tools run under approvals, lasting rules (section 2) and the outside-content gate (section 10); the MCP
- client is outside content and can never answer an approval.
+ (`GET /api/tasks`), `task_status` (`GET /api/tasks/{id}`), `notify_me` (`POST /api/notifications`, below) and
+ `ask_sentient` (one turn of `POST /api/chat`). There is no tool that sends, deletes or runs anything.
+- **Writes land where the user sees them, marked as outside content.** For an MCP caller the engine
+ itself holds a `memory_add` memory in Review (section 7), under the connected app's name and with its
+ source recorded as `mcp`, unless the user set `integrations.mcp_server_memories_skip_review`; the
+ request body cannot skip the wait or choose the source. `task_create` posts
+ `{prompt, app}`; for an MCP caller the route creates the task in the app's name with
+ `original_context.source = "mcp"`, never refined, and (like a task by email, section 4) its plan always
+ waits for approval, even when `tasks.require_plan_approval` is off, and every run is marked with the app's
+ name so anything that sends asks first. `POST /api/chat` from an MCP caller is forced onto
+ `channel: "mcp"` whatever the body says: the turn is marked outside content from the start, its text
+ never counts as the user's words (memories it states wait in Review; sends, code runs and deletes always
+ ask), and an approval it raises also lands as a `kind: "approval"` notification (section 6) the user can
+ answer in the window or on their phone. The MCP client is outside content and can never answer an approval.
- `mcp` is also in the frozen engine's command list (`packaging/engine_entry.py`), which `tests/test_packaging.py`
keeps equal to the CLI's commands.
@@ -1644,6 +1663,18 @@ Proactive suggestion payload:
```
`source_event` also carries `item_id` and `url` (when the item has one). `source` is the watched app (`gmail`, `gcalendar`,
`email_imap`), `webhook` (then `event_type` is the hook id and `hook_name` is set) or `heartbeat`.
+
+Approval payload (kind `approval`): an approval raised by a chat turn no chat surface could show (an MCP
+app's turn, #443), posted by the agent loop alongside the stream event so the user can answer it here.
+```json
+{"approval_id": "...", "name": "", "risk": "send|exec|write|read", "arguments": {},
+ "session_id": "...", "channel": "mcp", "reason": "...", "untrusted": "an MCP app",
+ "status": ""}
+```
+The notification center renders Allow/Deny from it and answers through `POST /api/approvals` with the same
+`approval_id` (the broker is one for the whole engine, so any surface's answer settles the waiting turn);
+messaging channels deliver it with Allow/Deny buttons of their own. Once answered, the loop sets
+`payload.status` to the decision and marks the notification read (`notification.updated`).
Notification `title` names what the suggestion is about: `"Gmail: Jane Doe: Meeting Tuesday?"`, `"Webhook: Deploy alerts"`,
`"Suggestion from Check-in"`; `message` is the suggestion description.
Suggestions produced during `proactivity.quiet_hours` are held and delivered as notifications when quiet hours end (dropped after 24 h).
@@ -1693,7 +1724,7 @@ dropped and tried again at the next check (`followups.copied_from_them`). A last
Never rule on that tool or its app (`tools.approvals.rules`) still applies: the run fails with the rule's message and
nothing is sent.
- `GET /api/notifications?limit=&unread_only=` → `{notifications: [Notification], unread}`
-- `POST /api/notifications` `{message, title?, source?}` → `Notification`: a plain `info` note in Sentient, for the MCP server's `notify_me` tool below. Always kind `info` with `payload.source` when given; a caller's `kind` is ignored, so an outside caller cannot dress a note up as an approval, a task result or an error. 400 without a `message`.
+- `POST /api/notifications` `{message, title?, source?, app?}` → `Notification`: a plain `info` note in Sentient, for the MCP server's `notify_me` tool below. Always kind `info` with `payload.source` when given; a caller's `kind` is ignored, so an outside caller cannot dress a note up as an approval, a task result or an error. For an MCP caller the title is prefixed with the app's name (`"Claude Code: ..."`, from `app`, or "An MCP app"), and the name is kept as `payload.app`, so a note can never pass itself off as one from Sentient. 400 without a `message`.
- `POST /api/notifications/{id}/read`, `POST /api/notifications/read-all`
- `DELETE /api/notifications/{id}`, `DELETE /api/notifications`
- `POST /api/proactivity/suggestions/{notification_id}` `{action: "approve"|"dismiss"}` → `{ok, task_id?}` (approve creates a task from `action_details` with `original_context.source = "proactive"`; for a follow-up it creates the already approved send task described above; both update the learned per-type threshold, mark the notification read and set `payload.status`/`payload.task_id`). Errors: 400 bad action, 404 not a suggestion, 409 already actioned or expired, 503 tasks unavailable. v2 spellings `approved`/`dismissed` are accepted.
@@ -1811,7 +1842,7 @@ Relationships & Social Life, Financial, Goals & Challenges, Miscellaneous.
`source` also finds memories that source said again), expired short-term facts and memories waiting for review excluded (`q` = hybrid search when embeddings are available: vector neighbours plus FTS5 keyword matches, ordered by `score` = `similarity` + `memory.keyword_weight` × share of query words present, each result carrying `similarity` and `score`; falls back to a keyword match)
- `GET /api/memories/topics` → `[{name, description, count}]`
- `GET /api/memories/graph` → `{nodes: [{id, label, title, content, topics, memory_type, source, created_at}], links: [{source, target, value}]}` (`label` = content truncated to 25 chars, `title` = full content, as in v2; a link means cosine similarity ≥ `memory.graph_link_similarity`, `value` is that similarity)
-- `POST /api/memories` `{content, source?, scope?, hold_for_review?, review_from?}` → `{action: "ADD"|"UPDATE"|"DELETE"|"SKIP", id, content, status?: "pending", scope?}` (`scope` defaults to `personal`; runs the CUD decision, so a duplicate returns `SKIP` with the existing id; `source` defaults to `manual`. `hold_for_review: true` saves the fact as pending instead, with a review note from `review_from` (below, "Memories waiting for review"): the MCP server sends it for memories an outside app adds, unless the user turned the wait off. The decision also sees up to 3 facts about the same person and attribute found by keyword (where they live, job, relationship, diet, health, ownership, routine), and a new current residence ("moved to Bengaluru") always UPDATEs the old one ("lives in Pune") rather than adding a second home; past-tense facts are left alone. Facts that name someone else are never offered to the model, so it can't update, skip or delete them, and the
+- `POST /api/memories` `{content, source?, scope?, hold_for_review?, review_from?}` → `{action: "ADD"|"UPDATE"|"DELETE"|"SKIP", id, content, status?: "pending", scope?}` (`scope` defaults to `personal`; runs the CUD decision, so a duplicate returns `SKIP` with the existing id; `source` defaults to `manual`. `hold_for_review: true` saves the fact as pending instead, with a review note from `review_from` (below, "Memories waiting for review"); for a caller using the MCP token the engine applies the same hold itself whenever the user has not turned the wait off, whatever the body says, and records the source as `mcp`. The decision also sees up to 3 facts about the same person and attribute found by keyword (where they live, job, relationship, diet, health, ownership, routine), and a new current residence ("moved to Bengaluru") always UPDATEs the old one ("lives in Pune") rather than adding a second home; past-tense facts are left alone. Facts that name someone else are never offered to the model, so it can't update, skip or delete them, and the
near-duplicate shortcut needs the same person too; names in Devanagari (Hindi) count, and match across scripts
("माया" is "Maya", "प्रिया" is not "Riya") (#339). Someone the person knows is not the person: "Maya Rao's sister Priya", "my friend Arjun", "her colleague" and "माया राव की बहन" are about that relative or friend, so a fact about them is never offered as an update of Maya's own facts, and the updates decided in code (a correction, a new home) need both facts to clearly name the same person ("She moved to Delhi" names nobody and is left to the model) (#463). An UPDATE only replaces a fact about the same person and the same action on the same thing ("doesn't want files written" or "doesn't want emails deleted" never replaces "doesn't want files deleted"; it is added instead), and a new fact that an existing one already says in full, with no new name, place or number ("sister lives in a city" next to "sister Meera lives in Lisbon"), returns `SKIP` with that fact. A correction always UPDATEs, decided in code without the model and never skipped as a
near-duplicate (#364): a new fact about the same person and action that says the same thing about it with a changed
diff --git a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
index 2480a9cab..84b8e4c61 100644
--- a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
+++ b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
@@ -5,7 +5,8 @@
- **Builds on:** [ADR 0016](0016-lasting-approval-rules.md) (lasting approval rules),
[ADR 0018](0018-untrusted-content-gates-sending.md) (outside content gates sending),
[ADR 0021](0021-hold-untrusted-memories-for-review.md) (untrusted memories wait for review),
- [ADR 0027](0027-vet-skills-and-mcp-tools-from-outside.md) (vet MCP tools from outside)
+ [ADR 0027](0027-vet-skills-and-mcp-tools-from-outside.md) (vet MCP tools from outside),
+ [ADR 0033](0033-tasks-by-email.md) (tasks by email: outside content that always waits)
## Context
@@ -18,53 +19,79 @@ hand that client Sentient's whole tool set, including sending, deleting and runn
with the user's lasting Allow rules and approvals mode chosen for their own requests, not for a
stranger's client.
+Review of the first version of this feature settled three things it had got wrong or left open.
+It signed in with the gateway token, the all-powerful per-launch token: any app holding it could
+drive the whole engine, and a hand-started `sentient mcp` created a token file as a side effect.
+Its chat turn let the client's text pass as the user's own words, so a client could get code
+executed and memories saved directly simply by asking. And an approval a turn raised travelled
+only on the chat event stream, which an MCP client discards, so the user never saw the question
+it was waiting on.
+
## Decision
- **A separate, thin process.** `sentient mcp` (package `sentient/mcp_server/`) is an MCP server
that holds no state and never touches the database. Every tool call goes to the running engine's
- REST API with the engine token, so there is one implementation of every safety rule, in the
- engine, and this process cannot bypass it. It only talks to a loopback address and refuses any
- other at startup.
+ REST API, so there is one implementation of every safety rule, in the engine, and this process
+ cannot bypass it. It only talks to a loopback address and refuses any other at startup. It
+ finds the engine through a discovery file the engine writes at startup (`engine-discovery.json`,
+ the port only): the desktop starts the engine on a fresh port every launch, so the configured
+ port is not where it listens.
+- **Its own token, with permissions only for these tools.** When the user turns the feature on,
+ the engine issues an MCP-only token and keeps it in the keychain (`sentient.secrets`, name
+ `mcp-server-token`). `sentient mcp` reads it from there itself; no client settings file ever
+ holds a secret. The gateway combines both tokens on the routes these tools use: the gateway
+ token passes everywhere, the MCP token only on the (method, route) pairs the eight tools call,
+ and never on config, approvals, sessions, deletes or the memory review inbox. Handlers see such
+ a caller as `caller == "mcp"`, distinct from the user's own surfaces. The user can replace the
+ token from the Integrations card (`POST /api/mcp/token/rotate`); apps holding the old one stop
+ working at once. `sentient mcp` never reads or writes the gateway token. On the same machine
+ the real boundary is this narrow permission set, since a process running as the user could
+ read whatever this process can read.
- **A whitelist of eight tools, nothing else.** `memory_search`, `memory_get`, `memory_add`,
`task_create`, `task_list`, `task_status`, `notify_me` and `ask_sentient`. There is no tool
that sends a message, deletes anything or runs a command, so a client cannot do those things
through Sentient at all, whatever it asks. The list is fixed in code and a test fails if a
send, delete or exec-shaped tool ever appears in it.
- **Off until the user turns it on, and checked live.** The server refuses to start while
- `integrations.mcp_server_enabled` is off, and every tool call reads the switch from the running
- engine again, so turning it off in Settings stops a server that is already running. The
- Integrations screen shows the switch and the config snippet to paste into a client
- (`claude mcp add sentient -- sentient mcp`).
-- **Writes land where the user can see them.** A memory an MCP app adds waits in Review like an
- import's does (ADR 0021), held with a note saying it came from an MCP app, unless the user
- turns on `integrations.mcp_server_memories_skip_review`. A task is created the normal way, so
- its plan waits for the user's approval in Sentient before anything runs. A notification is a
- plain info note inside Sentient; the endpoint that creates it ignores any kind a caller asks
- for, so a client cannot dress a note up as an approval or a result.
-- **Asking Sentient is a normal chat turn.** `ask_sentient` runs one turn through `POST /api/chat`
- on its own channel. The turn's tools run under Sentient's own approvals, rules (ADR 0016) and
- outside-content checks (ADR 0018): anything risky asks the user in the Sentient window, and
- nothing the MCP client says can answer that question, because answers only come from the user
- in the app or a paired chat, never from a model or a tool result.
-- **stdio first, HTTP only on this computer, reached through the launcher.** stdio is the
- default transport. Streamable HTTP (`--transport http`) binds 127.0.0.1 only and runs as a
- standalone server; it requires the engine token as a bearer token, because loopback alone
- would let any process (or web page) on the computer call the tools. The token is never handed
- to a client to paste into its settings file (repository rule: secrets go through
- `sentient.secrets` or stay invisible, never into config files). Clients instead point at the
- launcher, `sentient mcp --connect `: a local process that reads the token the same
- way the server does, adds it when it connects, and serves the tools over stdio. The settings
- an app keeps therefore hold only the launcher command and the loopback address.
+ `integrations.mcp_server_enabled` is off, and every tool call reads `GET /api/mcp/status` from
+ the running engine again, so turning it off in Settings stops a server that is already running.
+ The Integrations screen shows the switch, the config snippet to paste into a client and the
+ Replace key button.
+- **Writes land where the user can see them, in the app's name.** A memory an MCP app adds waits
+ in Review like an import's does (ADR 0021), held with the app's name, unless the user turns on
+ `integrations.mcp_server_memories_skip_review`. A task is created the way a task by email is
+ (ADR 0033): in the app's name, never refined, its plan always waiting for the user's approval
+ even when plans normally approve themselves, and every run marked as started by that app. A
+ notification is a plain info note inside Sentient; the endpoint ignores any kind a caller asks
+ for, and for an MCP caller the engine puts the app's name in the note's title, so a note can
+ never pass itself off as a message from Sentient itself.
+- **Asking Sentient is outside content from the start.** `ask_sentient` runs one turn through
+ `POST /api/chat`, and an MCP caller is forced onto the `mcp` channel whatever it claims. The
+ turn follows ADR 0033's email pattern: the session is marked untrusted before anything runs,
+ the client's text never becomes the user's words (so it cannot clear a memory into storage or
+ ground a fact as the user saying it, and turn-end extraction is told the words are not theirs),
+ sends and code runs ask through the outside-content gate, and deletes ask too, by an explicit
+ rule since they sit below send level. Nothing the client says can answer a question: answers
+ only come from the user.
+- **Its approvals reach the user.** An approval raised in such a turn is also posted as a
+ `kind: "approval"` notification carrying the same `approval_id`; the notification center and
+ the paired messaging chats render Allow and Deny for it, and any answer settles the one broker
+ the waiting turn is blocked on. When the answer lands, the loop fills in the decision and marks
+ the note read, so it never waits forever.
+- **stdio only.** No HTTP transport ships with this feature. stdio needs no listener and no
+ address to keep current, and it is what the clients this feature targets already use. HTTP may
+ return later as its own change, protected by the MCP-only token rather than the gateway token.
## Consequences
Other agents can finally read and add to the same memory and task list the user sees, without a
-second copy of anything. The price is two small engine additions (a hold-for-review flag on
-`POST /api/memories` and a create route for notifications) and one more surface to keep honest:
-every future tool proposed for this server has to argue why it belongs on the whitelist, and the
-default answer for anything that sends, deletes or runs is no. Because the MCP process reads the
-switch from the engine on every call, a running server adds one small local request per tool
-call, which is fine at this scale.
+second copy of anything. The price is a few small engine additions (a hold-for-review flag on
+`POST /api/memories`, a create route for notifications, the `mcp` token and status endpoints, the
+loop marking and the approval notification) and one more surface to keep honest: every future
+tool proposed for this server has to argue why it belongs on the whitelist, and the default
+answer for anything that sends, deletes or runs is no. Because the MCP process reads the status
+from the engine on every call, a running server adds one small local request per tool call,
+which is fine at this scale.
## Alternatives considered
@@ -72,7 +99,11 @@ Letting the MCP process open the database directly: faster, but it would copy th
task logic and quietly bypass approvals, review and the plan gate, which is the whole point of
going through the engine. Exposing Sentient's full internal tool registry: the registry is built
for a model the user is talking to, with the user's own approvals behind it; most of it (sending,
-deleting, the terminal, the browser) must never be offered to an outside client. Serving over
-HTTP only, with its own password: a second credential to manage and a network-shaped surface,
-when stdio needs neither and the engine token already exists. Saving MCP memories directly:
-rejected for the reason in ADR 0021, memory is how an injection becomes permanent.
+deleting, the terminal, the browser) must never be offered to an outside client. Signing in with
+the gateway token: it exists to keep other pages and processes out, not to be handed to apps, and
+it opens everything; a dedicated token with an allowlist states exactly what this feature may
+touch. Saving MCP memories directly: rejected for the reason in ADR 0021, memory is how an
+injection becomes permanent. An HTTP transport guarded by the gateway token, reached through a
+launcher that injects it: workable, and an earlier version of this change shipped it, but it
+spreads the all-powerful token into a second process for no user-visible gain over stdio;
+removed before the feature landed, to return only behind the MCP-only token.
diff --git a/sentient/agent/approvals.py b/sentient/agent/approvals.py
index 337e30a6d..567c6b40e 100644
--- a/sentient/agent/approvals.py
+++ b/sentient/agent/approvals.py
@@ -285,3 +285,10 @@ def resolve(self, approval_id: str, decision: Decision, via: str = "desktop") ->
def pending_ids(self) -> list[str]:
return list(self._pending)
+
+ def is_waiting(self, approval_id: str) -> bool:
+ """True while an answer would still reach a waiter: created and not yet answered. (An answered
+ request leaves ``_pending`` only when its waiter's cleanup runs, so membership alone is not
+ proof an answer is still wanted.)"""
+ fut = self._pending.get(approval_id)
+ return fut is not None and not fut.done()
diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py
index 6baa25055..c4798b25c 100644
--- a/sentient/agent/loop.py
+++ b/sentient/agent/loop.py
@@ -1172,6 +1172,11 @@ async def _plan_call(
reason = untrusted_reason(ctx.untrusted)
elif ctx.untrusted and plan.host and plan.host not in ctx.visited and address_carries_data(address):
reason = untrusted_address_reason(ctx.untrusted, plan.host) # the address itself could carry data out
+ if not reason and ctx.channel == "mcp" and preview.action_kind(tool.name, tc.arguments) in {"delete", "trash"}:
+ # a delete an MCP app steered this chat into always asks (issue #443), including one the
+ # trash preference would quietly swap for a move to the trash: deletes sit below send
+ # level, so the outside-content gate above never sees them on its own
+ reason = "An app asked Sentient to delete this through MCP, so Sentient checks with you first."
if reason:
# the run read outside content: anything that can send data out needs the user's own yes, whatever the
# mode, Allow rules or "Allow for this chat" say (ADR 0018). Nothing a model says can clear it.
@@ -1294,6 +1299,25 @@ async def _ask(self, p: _CallPlan, ctx: ToolContext, ev: dict) -> AsyncIterator[
remember_session=self.approvals.config.remember_session,
)
self.approvals.create(approval_id, offered)
+ note: dict | None = None
+ if ctx.channel == "mcp" and self.app is not None:
+ # an MCP client cannot show this question (it drops the event and waits), so the approval
+ # also lands as a notification the user can answer in the app or on their phone (#443);
+ # the answer reaches this same approval_id through the broker, whichever surface sends it
+ label = wording.get("risk_label") or "Changes something"
+ target = wording.get("target")
+ try:
+ note = await self.app.notify(
+ "approval", f"{label}: {target}" if target else label, title="Approval needed",
+ payload={
+ "approval_id": approval_id, "name": p.tc.name, "risk": p.risk.name,
+ "arguments": p.tc.arguments, "session_id": ctx.session_id, "channel": "mcp",
+ "untrusted": p.untrusted or None,
+ "reason": p.untrusted or approval_reason(wording, p.risk) or None,
+ },
+ )
+ except Exception:
+ log.exception("could not post the approval notification")
yield ApprovalRequest(
approval_id=approval_id, call_id=p.tc.id, name=p.tc.name, arguments=p.tc.arguments,
risk=p.risk.name, reason=approval_reason(wording, p.risk),
@@ -1303,7 +1327,20 @@ async def _ask(self, p: _CallPlan, ctx: ToolContext, ev: dict) -> AsyncIterator[
preview=preview.build(p.tc.name, p.tc.arguments, purchase=purchase, to=p.destinations), **ev,
)
asked_at = now_iso()
- decision = await self.approvals.wait(approval_id, ctx.session_id, p.tc.name, p.risk)
+ decision: str = "expired" # if the wait is cancelled (the asker went away), the note settles as expired
+ try:
+ decision = await self.approvals.wait(approval_id, ctx.session_id, p.tc.name, p.risk)
+ finally:
+ if note is not None:
+ # settle the notification with the answer, whoever gave it, so it never waits forever:
+ # in a finally, so a turn cancelled while it waits still settles its note
+ try:
+ await self.app.notifications.update_payload(
+ note["id"], {**(note.get("payload") or {}), "status": decision}
+ )
+ await self.app.notifications.mark_read(note["id"])
+ except Exception:
+ log.exception("could not settle the approval notification")
p.approval = {
"decision": decision, "asked_at": asked_at, "answered_at": now_iso(),
"target": wording["target"], "risk_label": wording["risk_label"],
@@ -1670,10 +1707,13 @@ async def run_turn(
attachments: list[str] | None = None,
model: str | None = None,
on_stopped: Callable[[dict], Any] | None = None,
+ app_name: str | None = None,
) -> AsyncIterator[AgentEvent]:
"""One chat turn. ``on_stopped`` is called with ``{message_id, memory_sources}`` of the reply kept when the
turn is stopped, so the caller can send them with its own ``done`` event."""
- quick = await self._quick_plan(session_id, user_text, attachments or [])
+ # an MCP app's turn never takes the quick-command path (#443): those commands run at once on
+ # "the message named this change", which is only ever true of the user's own words
+ quick = await self._quick_plan(session_id, user_text, attachments or []) if channel != "mcp" else None
if quick is not None: # a plain timer or reminder command: done now, without the model (#150)
async for event in self._quick_turn(session_id, user_text, quick, channel=channel):
yield event
@@ -1716,8 +1756,10 @@ def release() -> list[str]:
notes: list[dict] = [] # pieces of the user's notes put into the prompt (#354)
try:
user_message_id = await self.store.add_message(session_id, "user", user_text, attachments=attachments)
- if self.app is not None and hasattr(self.app, "user_seen"):
- await self.app.user_seen(channel) # a chat message on any surface means the user is around (#163)
+ if self.app is not None and hasattr(self.app, "user_seen") and channel != "mcp":
+ # a chat message on any of the user's surfaces means the user is around (#163); an MCP
+ # app's activity says nothing about the user, so it never resets the away timer (#443)
+ await self.app.user_seen(channel)
rule_check = self._start_rule_check(session_id, user_message_id, user_text)
await self.store.touch_session(session_id, title=(user_text or (attachments[0] if attachments else ""))[:60])
@@ -1759,9 +1801,19 @@ def release() -> list[str]:
ctx = self.tool_context(session_id, channel)
ctx.incognito = incognito
ctx.memory_scope = memory_scopes.of_session(self.config, session) # recall and saving use it (#155)
- ctx.user_words = [user_text] # what the user typed or said, never attachments (#140)
+ # a turn an MCP app steers is outside content from the start (issue #443): its text is not
+ # the user's words, so it never fills user_words, and the chat is marked before anything
+ # runs, like one that begins with someone else's text attached below
+ from_mcp = channel == "mcp"
+ ctx.user_words = [] if from_mcp else [user_text] # what the user typed or said, never attachments (#140)
# outside content read earlier in this chat still counts until a new chat starts (ADR 0018)
ctx.untrusted = await self._chat_untrusted(session_id, session)
+ if from_mcp and not ctx.untrusted:
+ ctx.untrusted = app_name or "an MCP app" # the app's name, when it gave one, so the user sees who is asking
+ await self.store.execute(
+ "UPDATE sessions SET untrusted = ? WHERE id = ?", (ctx.untrusted, session_id)
+ )
+ self._publish_mark(session_id, ctx.untrusted)
brought = next((src for a in attachments if (src := attachment_source(a))), "")
if not ctx.untrusted and brought:
# a shared window or region, or text selected in another app, can hold text someone else wrote: it
@@ -1866,10 +1918,15 @@ async def persist(role_: str, content: str | None, **fields: Any) -> None:
# nothing is learned from an incognito chat (#154): no fact extraction, no memory flush, and no
# chat.turn_completed, so skills, the user model and dreaming never see it
if self.memory is not None and self.config.memory.extract_after_turn and not incognito:
- # a chat that read outside content holds what it learns for the user's review (ADR 0021)
+ # a chat that read outside content holds what it learns for the user's review (ADR 0021);
+ # in an MCP app's chat the words are the app's, never the user's, so nothing in them can
+ # clear the review as the user's own words would (#443)
log.info("chat %s: fact extraction started for this turn", session_id)
self._spawn(
- self._extract(said, memory_review.for_context(ctx), session_id, ctx.memory_scope), "memory"
+ self._extract(
+ said, memory_review.for_context(ctx), session_id, ctx.memory_scope,
+ own=not from_mcp,
+ ), "memory"
)
if self.config.chat.auto_title and session and (session.get("title") or "") == (user_text or "")[:60]:
self._spawn(self._auto_title(session_id, user_text, result.text), "titles")
@@ -2238,18 +2295,21 @@ def _publish_memory(self, results: Any, session_id: str | None = None) -> None:
)
async def _extract(
- self, user_text: str, review: dict | None = None, session_id: str | None = None, scope: str | None = None
+ self, user_text: str, review: dict | None = None, session_id: str | None = None, scope: str | None = None,
+ *, own: bool = True,
) -> None:
"""Only the user's own words are mined for facts; the assistant's reply is
mostly restated context (dates, tool output) and produced junk memories.
With ``review`` (the chat read outside content) a fact is held for the user's review unless the user's own
- words back all of it (#234); instruction-shaped ones are always held. New facts go in the chat's ``scope``."""
+ words back all of it (#234); instruction-shaped ones are always held. New facts go in the chat's ``scope``.
+ ``own=False`` when the words are not the user's at all (an MCP app's chat, #443): facts are still mined,
+ but nothing in them counts as the user's own backing, so the review always holds."""
assert self.memory is not None
if len(user_text.split()) < 4:
return
try:
results = await self.memory.extract_and_store(
- user_text, self.config.assistant.user_name, own=True, session_id=session_id,
+ user_text, self.config.assistant.user_name, own=own, session_id=session_id,
**({"review": review} if review else {}),
**({"scope": scope} if scope and scope != memory_scopes.PERSONAL else {}),
)
diff --git a/sentient/app.py b/sentient/app.py
index 0bc84a21e..22dba14b5 100644
--- a/sentient/app.py
+++ b/sentient/app.py
@@ -180,6 +180,15 @@ def save_config(self, config: SentientConfig | None = None) -> None:
self.config = config
moved = self._claude_code_switched()
save_config(self.config)
+ if self.config.integrations.mcp_server_enabled:
+ # turning the MCP server on issues its own narrow token in the keychain (#443, ADR 0034);
+ # a keychain problem must never break saving settings, the status endpoint reports it
+ try:
+ from sentient.gateway import mcp_token
+
+ mcp_token.ensure_token()
+ except Exception:
+ pass
if hasattr(self.llm, "config"):
self.llm.config = self.config # type: ignore[attr-defined]
if self.agent is not None:
@@ -346,6 +355,15 @@ async def start(self) -> SentientApp:
await self._load_stop_state() # before services start: a stopped Sentient starts paused
assert self.approvals.grants is not None
await self.approvals.grants.load()
+ if self.config.integrations.mcp_server_enabled:
+ # the MCP key is issued when the feature is turned on in Settings; a config edited by
+ # hand reaches start with the feature on and no key, so issue it here too (#443)
+ try:
+ from sentient.gateway import mcp_token
+
+ mcp_token.ensure_token()
+ except Exception:
+ log.exception("could not issue the MCP key")
self.workspace.ensure_defaults(
self.config.assistant.name, self.config.assistant.user_name, self.config.assistant.timezone
)
diff --git a/sentient/channels/base.py b/sentient/channels/base.py
index d02e88467..9d7f1c6ac 100644
--- a/sentient/channels/base.py
+++ b/sentient/channels/base.py
@@ -1054,6 +1054,10 @@ async def handle_button(self, chat_id: str, message_id: str, data: str, user_id:
return UNDO_OUTCOME[action]
if kind == "ta": # a task's approval request (#139)
return await self.service.act_on_question(self, chat_id, message_id, ref, code, approval=True)
+ if kind == "na": # a chat turn's approval note (#443)
+ if code not in {"a", "d"}:
+ return "Unknown button."
+ return await self.service.act_on_notification_approval(self, chat_id, message_id, ref, allow=code == "a")
if kind == "tp":
return await self.service.act_on_plan(self, chat_id, message_id, ref, approve=code == "a")
if kind == "sg":
diff --git a/sentient/channels/service.py b/sentient/channels/service.py
index 9b1302cdc..269d6a38e 100644
--- a/sentient/channels/service.py
+++ b/sentient/channels/service.py
@@ -118,6 +118,7 @@ def __init__(self, app):
}
# notification id -> [(channel, chat_id, message_id)] for messages with action buttons
self._delivered: dict[str, list[tuple[str, str, str]]] = {}
+ self._approval_notes: dict[str, str] = {} # approval_id -> the notification that carried it (#443)
self._subagents_sent: set[tuple[str, str, str]] = set()
self._rule_cards: dict[str, tuple[str, str, str]] = {} # proposal id -> (channel, chat_id, message_id)
# approval requests held back while the person is in the app (#139): notification id -> notification
@@ -454,14 +455,28 @@ async def format_notification(self, note: dict, *, chosen: bool = False) -> tupl
return f"**{title or 'Your Daily Brief'}**\n{DailyBrief.as_text(brief, links=True)}", None
if kind == "reminder":
return f"**{title or 'Reminder'}**\n{message}", None
+ if kind == "approval":
+ # a chat turn's approval, raised where no chat surface could show it (an MCP app's turn,
+ # #443): Allow and Deny answer the same approval the app would have shown
+ approval_id = payload.get("approval_id")
+ if not approval_id or payload.get("status"):
+ return None
+ md = f"**{title or 'Approval needed'}**\n{message}"
+ return md, [[
+ Button("Allow", f"na:a:{approval_id}", "success"),
+ Button("Deny", f"na:d:{approval_id}", "danger"),
+ ]]
if payload.get("subagent_id") and cfg.deliver_subagents:
return f"**{title or 'Background work finished'}**\n{message}", None
return None
@staticmethod
def is_approval(note: dict) -> bool:
- """A task's approval request (#139, ADR 0018), as opposed to an ordinary question."""
+ """An approval request (#139, ADR 0018), as opposed to an ordinary question: a task's, or a
+ chat turn's own approval note (kind "approval", #443)."""
payload = note.get("payload") or {}
+ if note.get("kind") == "approval":
+ return bool(payload.get("approval_id"))
return note.get("kind") == "task" and payload.get("event") == "question" and isinstance(payload.get("approval"), dict)
@staticmethod
@@ -509,7 +524,17 @@ async def deliver_held(self) -> int:
sent = 0
for nid, note in list(self._held.items()):
self._held.pop(nid, None)
- run_id = str((note.get("payload") or {}).get("run_id") or "")
+ payload = note.get("payload") or {}
+ if note.get("kind") == "approval":
+ # a chat turn's own approval (#443) waits on the broker, not on a task run: it is
+ # still deliverable only while the broker still holds it unanswered (the held copy's
+ # payload never gains a status, so the broker is the source of truth)
+ approval_id = str(payload.get("approval_id") or "")
+ broker = getattr(self.app, "approvals", None)
+ if approval_id and broker is not None and broker.is_waiting(approval_id):
+ sent += await self.deliver_notification(note)
+ continue
+ run_id = str(payload.get("run_id") or "")
if run_id and await self._waiting_question(run_id) is not None:
sent += await self.deliver_notification(note)
return sent
@@ -621,6 +646,10 @@ async def deliver_notification(self, note: dict) -> int:
log.exception("could not remember the delivered question")
if buttons and ids and note.get("id"):
self._delivered.setdefault(str(note["id"]), []).append((ch.id, chat["chat_id"], ids[-1]))
+ if note.get("kind") == "approval" and payload.get("approval_id"):
+ # which note carried this approval, so its buttons can be checked against the
+ # delivery before they answer it (#443, the same ownership rule as #139/#337)
+ self._approval_notes[str(payload["approval_id"])] = str(note["id"])
await self.record_delivery(ch, chat, md, what=what, reason=reason, ref=ref)
return sent
@@ -745,6 +774,27 @@ async def act_on_plan(self, ch: Channel, chat_id: str, message_id: str, task_id:
await ch.settle_buttons(chat_id, message_id, outcome)
return outcome
+ async def act_on_notification_approval(self, ch: Channel, chat_id: str, message_id: str, approval_id: str, *, allow: bool) -> str:
+ """The answer to a chat turn's approval note (kind "approval", #443): it resolves the same
+ broker approval the desktop card answers, so the waiting turn carries on at once."""
+ resolve = getattr(self.app.approvals, "resolve", None)
+ if resolve is None:
+ return "Approvals aren't available right now."
+ # the buttons answer only where this approval was actually delivered, like every other
+ # button in this service (#139, #337): a callback from anywhere else settles nothing
+ note_id = self._approval_notes.get(approval_id)
+ if (ch.id, chat_id, message_id) not in self._delivered.get(note_id or "", []):
+ await ch.settle_buttons(chat_id, message_id, "This request is no longer waiting")
+ return "This request is no longer waiting."
+ self._approval_notes.pop(approval_id, None)
+ self._forget_delivered(ch.id, chat_id, message_id)
+ if not resolve(approval_id, "allow" if allow else "deny", via=ch.id):
+ await ch.settle_buttons(chat_id, message_id, "This request is no longer waiting")
+ return "This request is no longer waiting."
+ outcome = "Allowed" if allow else "Denied"
+ await ch.settle_buttons(chat_id, message_id, outcome)
+ return outcome
+
async def act_on_suggestion(self, ch: Channel, chat_id: str, message_id: str, notification_id: str, *, approve: bool) -> str:
fn = getattr(self.app.proactivity, "act_on_suggestion", None)
if fn is None:
diff --git a/sentient/cli.py b/sentient/cli.py
index 43cde248b..9dce50395 100644
--- a/sentient/cli.py
+++ b/sentient/cli.py
@@ -99,6 +99,19 @@ def serve(
if not os.environ.get(ENV_TOKEN):
# started by hand: show how to open the UI in a browser
console.print(f"Sentient backend on http://{h}:{p}/ (dev UI: http://{h}:{p}/?api=http://{h}:{p}&token={token})")
+ if p:
+ # `sentient mcp` finds the engine through this file (issue #443): the desktop starts the
+ # engine on a fresh port each launch, so the configured port is not where it listens.
+ # Port only, no secret; a stale file just points at a port nothing answers on any more.
+ import contextlib
+ import json
+
+ from sentient import paths
+
+ with contextlib.suppress(Exception):
+ found = paths.discovery_file()
+ found.parent.mkdir(parents=True, exist_ok=True)
+ found.write_text(json.dumps({"port": p}), encoding="utf-8")
uvicorn.run(
"sentient.gateway.app:create_app", factory=True, host=h, port=p, reload=reload,
log_level="warning", ws_ping_interval=20, ws_ping_timeout=60,
@@ -135,32 +148,17 @@ def node(
@app.command()
def mcp(
- transport: str = typer.Option("stdio", help="stdio, or http (streamable HTTP on this computer only)"),
- port: int = typer.Option(8765, help="Port for --transport http. Always bound to 127.0.0.1."),
- engine_url: str | None = typer.Option(None, help="Engine address (default: the gateway address from the config)"),
- connect: str | None = typer.Option(
- None,
- help="Launcher mode: connect to the HTTP server at this address on this computer and serve "
- "it over stdio, adding Sentient's sign-in itself (no key in the client's settings). The "
- "address carries the port, so --port and --engine-url do not apply in this mode",
+ engine_url: str | None = typer.Option(
+ None, help="Engine address (default: the address the running engine left in its discovery file)"
),
):
"""Let other AI apps on this computer use Sentient over MCP (off until turned on in Settings).
Started by the MCP client itself, for example with: claude mcp add sentient -- sentient mcp
"""
- if transport not in {"stdio", "http"}:
- console.print("[red]--transport must be stdio or http[/red]")
- raise typer.Exit(2)
- if connect is not None and transport != "stdio":
- console.print("[red]--connect is the launcher: it serves over stdio and cannot be combined with --transport http[/red]")
- raise typer.Exit(2)
- if connect is not None and engine_url is not None:
- console.print("[red]--connect takes the server's full address; --engine-url does not apply in launcher mode[/red]")
- raise typer.Exit(2)
from sentient.mcp_server.server import main
- raise typer.Exit(main(transport, port=port, engine_url=engine_url, connect=connect))
+ raise typer.Exit(main(engine_url=engine_url))
@app.command("claude-code-tools", hidden=True)
diff --git a/sentient/config/schema.py b/sentient/config/schema.py
index 3543ca15c..b37ff6129 100644
--- a/sentient/config/schema.py
+++ b/sentient/config/schema.py
@@ -693,7 +693,8 @@ class IntegrationsConfig(BaseModel):
description="Let other AI apps on this computer (Claude Code, Cursor and other MCP clients) use Sentient "
"itself through the `sentient mcp` command: search and add memories, create and check tasks, send "
"notifications and ask Sentient a question. Off until turned on. There is no tool that sends, deletes "
- "or runs anything, and the command only works on this computer with the engine's token.",
+ "or runs anything, and the command only works on this computer, with a key of its own that opens "
+ "only what those tools need.",
)
mcp_server_memories_skip_review: bool = Field(
False,
diff --git a/sentient/gateway/auth.py b/sentient/gateway/auth.py
index 4f33ee2a1..f3a4ed184 100644
--- a/sentient/gateway/auth.py
+++ b/sentient/gateway/auth.py
@@ -52,6 +52,48 @@ def require_token(request: Request) -> None:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid or missing token")
+# The only (method, route) pairs the MCP-only token opens: exactly what the eight MCP tools call
+# (issue #443, ADR 0034). Anything else (config, approvals, sessions, deletes, the memory review
+# inbox) refuses it, so a token an outside app holds can never drive the rest of the engine.
+MCP_ALLOWED = frozenset({
+ ("GET", "/api/memories"), ("GET", "/api/memories/{memory_id}"), ("POST", "/api/memories"),
+ ("GET", "/api/tasks"), ("POST", "/api/tasks"), ("GET", "/api/tasks/{task_id}"),
+ ("POST", "/api/notifications"),
+ ("POST", "/api/chat"),
+ ("GET", "/api/mcp/status"),
+})
+
+
+def require_gateway_or_mcp(request: Request) -> None:
+ """For the routes the MCP tools use. The gateway token passes everywhere and marks the caller
+ as one of the user's own surfaces; the MCP-only token passes only on the allowlist above and
+ marks the caller as an outside app, which the handlers treat as outside content."""
+ from sentient.gateway import mcp_token
+
+ given = _extract(request)
+ expected = request.app.state.token
+ if given and expected and pysecrets.compare_digest(given, expected):
+ request.state.caller = "gateway"
+ return
+ if mcp_token.token_matches(given):
+ route = request.scope.get("route")
+ key = (request.method, getattr(route, "path", ""))
+ if key in MCP_ALLOWED:
+ if key != ("GET", "/api/mcp/status"):
+ # the feature switch rules the token too, not just the MCP server process: with the
+ # feature off in Settings the token opens nothing (the status route stays reachable
+ # so the server can report that the feature is off instead of a refusal)
+ core = getattr(request.app.state, "sentient", None)
+ if core is None or not core.config.integrations.mcp_server_enabled:
+ raise HTTPException(
+ status_code=status.HTTP_403_FORBIDDEN, detail="the MCP server feature is turned off"
+ )
+ request.state.caller = "mcp"
+ return
+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="the MCP token does not open this")
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid or missing token")
+
+
def ws_token_ok(ws: WebSocket) -> bool:
expected = ws.app.state.token
given = ws.query_params.get("token") or ""
diff --git a/sentient/gateway/deps.py b/sentient/gateway/deps.py
index 4872dab31..646a565db 100644
--- a/sentient/gateway/deps.py
+++ b/sentient/gateway/deps.py
@@ -6,13 +6,17 @@
from fastapi import Depends, Request, WebSocket
-from sentient.gateway.auth import require_token
+from sentient.gateway.auth import require_gateway_or_mcp, require_token
if TYPE_CHECKING: # pragma: no cover
from sentient.app import SentientApp
AUTH = [Depends(require_token)]
+# Routes the MCP tools use (issue #443): the gateway token, or the MCP-only token within its
+# allowlist. Handlers read ``request.state.caller`` ("gateway" | "mcp") to tell them apart.
+MCP_OK = [Depends(require_gateway_or_mcp)]
+
def get_core(request: Request) -> SentientApp:
return request.app.state.sentient
diff --git a/sentient/gateway/mcp_token.py b/sentient/gateway/mcp_token.py
new file mode 100644
index 000000000..f496d4340
--- /dev/null
+++ b/sentient/gateway/mcp_token.py
@@ -0,0 +1,46 @@
+"""The MCP-only token (issue #443, ADR 0034).
+
+When the user turns on "Let other apps use Sentient", the engine issues a token of its own for that
+feature. It lives in the keychain (``sentient.secrets``), never in a config file, and it is not the
+gateway token: it opens only the handful of endpoints the MCP tools need (see ``auth.py``), never
+approvals, config or anything else. The user can replace it from Settings > Integrations, which
+cuts off every app still holding the old one.
+"""
+
+from __future__ import annotations
+
+import secrets as _secrets
+
+from sentient import secrets
+
+SECRET_NAME = "mcp-server-token"
+
+
+def current_token() -> str | None:
+ """The token apps must present today, or None when the feature never issued one."""
+ return secrets.get_secret(SECRET_NAME)
+
+
+def ensure_token() -> str | None:
+ """The current token, creating and storing one first when there is none.
+
+ None when the keychain is unavailable: the feature then stays unusable rather than falling
+ back to a file anyone on the machine could read.
+ """
+ token = current_token()
+ if token:
+ return token
+ return rotate_token()
+
+
+def rotate_token() -> str | None:
+ """Replace the token with a fresh one (the old one stops working at once). None when the
+ keychain is unavailable."""
+ token = _secrets.token_urlsafe(32)
+ return token if secrets.set_secret(SECRET_NAME, token) else None
+
+
+def token_matches(candidate: str | None) -> bool:
+ """Constant-time check of a presented token against the stored one."""
+ current = current_token()
+ return bool(candidate) and current is not None and _secrets.compare_digest(candidate, current)
diff --git a/sentient/gateway/routes/__init__.py b/sentient/gateway/routes/__init__.py
index 78d2ff61a..0f5f2c151 100644
--- a/sentient/gateway/routes/__init__.py
+++ b/sentient/gateway/routes/__init__.py
@@ -1,6 +1,7 @@
"""All REST routers. One module per feature package; each module is owned by that package.
core, models, notifications : core (composition root owner)
+mcp : core (the MCP server's status and token endpoints, #443)
tasks : tasks package
integrations : integrations package
memory, skills, proactivity : memory / proactivity / evolution package
@@ -26,6 +27,7 @@
folders,
hooks,
integrations,
+ mcp,
memory,
migrate,
models,
@@ -46,6 +48,7 @@
ROUTERS = [
core.router,
models.router,
+ mcp.router,
notifications.router,
tasks.router,
integrations.router,
diff --git a/sentient/gateway/routes/core.py b/sentient/gateway/routes/core.py
index 5b99e9717..7bb6842b8 100644
--- a/sentient/gateway/routes/core.py
+++ b/sentient/gateway/routes/core.py
@@ -23,7 +23,7 @@
from sentient.config.schema import SentientConfig
from sentient.files import names as file_names
from sentient.gateway.auth import require_token
-from sentient.gateway.deps import get_core
+from sentient.gateway.deps import MCP_OK, get_core
from sentient.llm import availability
from sentient.llm.events import Done, Error
from sentient.llm.spending import BudgetError
@@ -432,13 +432,19 @@ class ChatBody(BaseModel):
attachments: list[str] = []
model: str | None = None
incognito: bool = False # a new chat (no session_id) that learns nothing and is deleted when closed (#154)
+ app: str | None = None # an MCP client's name, display only: whose words these are (#443)
-@router.post("/api/chat", dependencies=_auth)
+@router.post("/api/chat", dependencies=MCP_OK)
async def chat(request: Request, body: ChatBody):
s = get_core(request)
assert s.agent is not None
- if body.session_id and not body.attachments and s.agent.steer(body.session_id, body.text):
+ is_mcp = getattr(request.state, "caller", "gateway") == "mcp"
+ if is_mcp:
+ # an MCP app's words are outside content, never the user's (issue #443): the turn runs on
+ # the mcp channel whatever the body claims, and the loop marks it from the start
+ body.channel = "mcp"
+ if body.session_id and not body.attachments and not is_mcp and s.agent.steer(body.session_id, body.text):
# a reply is already running for this chat: the text steers it (docs/API.md section 10)
ack = {"type": "steer_ack", "session_id": body.session_id, "queued": True}
return StreamingResponse(iter([json.dumps(ack) + "\n"]), media_type="application/x-ndjson")
@@ -461,7 +467,8 @@ def proposals() -> list[str]:
ended = False # the last line sent was a done
try:
async for event in s.agent.run_turn(
- session_id, body.text, channel=body.channel, attachments=body.attachments, model=body.model
+ session_id, body.text, channel=body.channel, attachments=body.attachments, model=body.model,
+ app_name=body.app if is_mcp else None,
):
for line in proposals():
yield line
diff --git a/sentient/gateway/routes/mcp.py b/sentient/gateway/routes/mcp.py
new file mode 100644
index 000000000..7720068a5
--- /dev/null
+++ b/sentient/gateway/routes/mcp.py
@@ -0,0 +1,33 @@
+"""The MCP server's own endpoints (issue #443, ADR 0034): a status the MCP process can check with
+its narrow token, and the token replacement only the user's own surfaces may ask for."""
+
+from __future__ import annotations
+
+from fastapi import HTTPException, Request
+from fastapi.routing import APIRouter
+
+from sentient.gateway import mcp_token
+from sentient.gateway.deps import AUTH, MCP_OK, get_core
+
+router = APIRouter(prefix="/api/mcp", tags=["mcp"])
+
+
+@router.get("/status", dependencies=MCP_OK)
+async def status(request: Request):
+ """What an MCP app may do right now: whether the feature is on, whether its memories skip
+ Review, and whether a token has been issued at all."""
+ cfg = get_core(request).config.integrations
+ return {
+ "enabled": bool(cfg.mcp_server_enabled),
+ "memories_skip_review": bool(cfg.mcp_server_memories_skip_review),
+ "token_issued": mcp_token.current_token() is not None,
+ }
+
+
+@router.post("/token/rotate", dependencies=AUTH)
+async def rotate_token(request: Request):
+ """Replace the MCP-only token. Every app still holding the old one stops working at once; the
+ new one is never returned here, it only ever lives in the keychain and in the MCP process."""
+ if mcp_token.rotate_token() is None:
+ raise HTTPException(503, "the keychain is unavailable, so no token can be stored")
+ return {"rotated": True}
diff --git a/sentient/gateway/routes/memory.py b/sentient/gateway/routes/memory.py
index 5251b6740..1ab8db792 100644
--- a/sentient/gateway/routes/memory.py
+++ b/sentient/gateway/routes/memory.py
@@ -8,7 +8,7 @@
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
-from sentient.gateway.deps import AUTH, get_core
+from sentient.gateway.deps import MCP_OK, get_core
from sentient.memory import export as memory_export
from sentient.memory import forget as memory_forget
from sentient.memory import review as memory_review
@@ -17,7 +17,7 @@
from sentient.memory.personas import PERSONAS, render_persona
from sentient.memory.topics import TOPICS
-router = APIRouter(prefix="/api/memories", tags=["memory"], dependencies=AUTH)
+router = APIRouter(prefix="/api/memories", tags=["memory"], dependencies=MCP_OK)
IMPORT_SUFFIXES = {".pdf", ".txt", ".md", ".markdown", ".docx"}
PROFILE_SCOPES = (memory_scopes.PERSONAL, memory_scopes.ALWAYS) # what USER.md and MEMORY.md are built from
@@ -245,13 +245,19 @@ async def create_memory(request: Request, body: dict):
raise HTTPException(400, "content is required")
scope = memory_scopes.normalize(body.get("scope"), memory_scopes.PERSONAL) # #155
# An outside app saving through `sentient mcp` is outside content (ADR 0018, ADR 0034): its memories wait
- # in Review like an import's do (ADR 0021), unless the user turned the wait off in Settings.
+ # in Review like an import's do (ADR 0021), unless the user turned the wait off in Settings. The engine
+ # decides that from the caller and the setting, never from the request body: a token holder asking
+ # directly cannot skip the wait, pick a trusted-looking source, or name who the memory is "from".
review = None
- if body.get("hold_for_review"):
- review = memory_review.note(str(body.get("review_from") or "an MCP app"), snippet=content)
- return await mem.remember(
- content, source=str(body.get("source") or "manual"), notify=True, scope=scope, review=review,
- )
+ if getattr(request.state, "caller", "gateway") == "mcp":
+ source = "mcp"
+ if not get_core(request).config.integrations.mcp_server_memories_skip_review:
+ review = memory_review.note(str(body.get("app") or "an MCP app"), snippet=content)
+ else:
+ source = str(body.get("source") or "manual")
+ if body.get("hold_for_review"):
+ review = memory_review.note(str(body.get("review_from") or "an MCP app"), snippet=content)
+ return await mem.remember(content, source=source, notify=True, scope=scope, review=review)
@router.put("/{memory_id}")
diff --git a/sentient/gateway/routes/notifications.py b/sentient/gateway/routes/notifications.py
index 99af7b070..f6ccda6dd 100644
--- a/sentient/gateway/routes/notifications.py
+++ b/sentient/gateway/routes/notifications.py
@@ -7,9 +7,9 @@
from fastapi import APIRouter, HTTPException, Request
-from sentient.gateway.deps import AUTH, get_core
+from sentient.gateway.deps import MCP_OK, get_core
-router = APIRouter(prefix="/api/notifications", tags=["notifications"], dependencies=AUTH)
+router = APIRouter(prefix="/api/notifications", tags=["notifications"], dependencies=MCP_OK)
@router.get("")
@@ -30,9 +30,14 @@ async def create_notification(request: Request, body: dict):
raise HTTPException(400, "message is required")
title = str(body.get("title") or "").strip() or None
source = str(body.get("source") or "").strip()
- return await get_core(request).notify(
- "info", message, title=title, payload={"source": source} if source else None,
- )
+ payload = {"source": source} if source else None
+ if getattr(request.state, "caller", "gateway") == "mcp":
+ # a note from an outside app names the app in its title, so it can never pass itself off
+ # as a note from Sentient (issue #443)
+ app_name = str(body.get("app") or "").strip() or "An MCP app"
+ title = f"{app_name}: {title}" if title else app_name
+ payload = {**(payload or {}), "app": app_name}
+ return await get_core(request).notify("info", message, title=title, payload=payload)
@router.post("/{notification_id}/read")
diff --git a/sentient/gateway/routes/tasks.py b/sentient/gateway/routes/tasks.py
index a3b88df1a..c918ab29e 100644
--- a/sentient/gateway/routes/tasks.py
+++ b/sentient/gateway/routes/tasks.py
@@ -8,11 +8,11 @@
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
-from sentient.gateway.deps import AUTH, get_core
+from sentient.gateway.deps import MCP_OK, get_core
from sentient.llm.provider import PlainProviderError, ProviderError
from sentient.tasks.service import TaskConflict, TaskNotFound
-router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=AUTH)
+router = APIRouter(prefix="/api/tasks", tags=["tasks"], dependencies=MCP_OK)
class CreateTaskBody(BaseModel):
@@ -22,6 +22,7 @@ class CreateTaskBody(BaseModel):
model: str | None = None
browser_profile: str | None = None
memory_scope: str | None = None # #155: personal (default), work or a project's name
+ app: str | None = None # the MCP app asking, when the caller is one (#443): names the task's source
class PreviewBody(BaseModel):
@@ -69,6 +70,12 @@ async def list_tasks(request: Request):
@router.post("")
async def create_task(request: Request, body: CreateTaskBody):
+ if getattr(request.state, "caller", "gateway") == "mcp":
+ # an outside app's task (issue #443): made in the app's name and marked as outside
+ # content, so its plan always waits for approval and every send asks, like a task by
+ # email (#159). Only the MCP token can be this caller; the app name is display only.
+ app_name = (body.app or "").strip() or "An MCP app"
+ return await _guard(_tasks(request).create_from_mcp(body.prompt, app_name))
return await _guard(_tasks(request).create_task(
body.prompt, is_swarm=body.is_swarm, model=body.model, browser_profile=body.browser_profile,
memory_scope=body.memory_scope,
diff --git a/sentient/mcp_server/client.py b/sentient/mcp_server/client.py
index a7afd7026..53d2e52e4 100644
--- a/sentient/mcp_server/client.py
+++ b/sentient/mcp_server/client.py
@@ -1,9 +1,11 @@
"""A thin client for the running engine's REST API, used by the MCP server (ADR 0034).
The MCP server is a separate process started by the MCP client (for example Claude Code running
-``sentient mcp``). It holds no state of its own: it reads the engine's address from the config and
-its token the same way ``sentient serve`` does (the ``SENTIENT_GATEWAY_TOKEN`` environment variable
-or the token file under the Sentient home folder), and it only ever talks to a loopback address.
+``sentient mcp``). It holds no state of its own. It finds the engine through the discovery file
+the engine writes at startup (its loopback port; the desktop picks a fresh port every launch, so
+the configured port is not where it listens), and it signs in with the MCP-only token from the
+keychain: a token that opens only the endpoints these tools use, never the gateway token. It
+never reads or writes the gateway token.
"""
from __future__ import annotations
@@ -21,15 +23,20 @@ class EngineError(RuntimeError):
"""The engine could not be reached or refused the call. The message is safe to show the caller."""
-def default_engine_url() -> str:
- """The running engine's address from the saved config, always on loopback."""
- from sentient.config import load_config
+def discovered_engine_url() -> str:
+ """The running engine's address from its discovery file (loopback, port only)."""
+ from sentient import paths
- cfg = load_config()
- host = cfg.gateway.host if cfg.gateway.host in LOOPBACK_HOSTS else "127.0.0.1"
- if ":" in host: # an IPv6 host needs brackets before the port, or the URL does not parse
- host = f"[{host}]"
- return f"http://{host}:{cfg.gateway.port}"
+ try:
+ data = json.loads(paths.discovery_file().read_text(encoding="utf-8"))
+ port = int(data["port"])
+ except Exception:
+ port = 0
+ if not port:
+ raise EngineError(
+ "Sentient's engine is not running (no discovery file). Start Sentient first, then try again."
+ )
+ return f"http://127.0.0.1:{port}"
class EngineClient:
@@ -54,16 +61,27 @@ def __init__(self, base_url: str, token: str, *, timeout: float = 30.0):
)
# ------------------------------------------------------------------ plumbing
- def _call(self, method: str, path: str, **kwargs: Any) -> Any:
+ def _call(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> Any:
+ if timeout is not None:
+ kwargs["timeout"] = httpx.Timeout(timeout, read=timeout)
try:
resp = self._http.request(method, path, **kwargs)
+ except httpx.TimeoutException as exc:
+ # not "not answering": the engine is up but slow (a memory save runs a model call), and
+ # the change may well have landed. Say so, so the caller checks before retrying and
+ # does not save the same thing twice.
+ raise EngineError(
+ "The engine did not answer in time. The change may have gone through anyway; "
+ "check in Sentient before trying again."
+ ) from exc
except httpx.HTTPError as exc:
raise EngineError(
f"Sentient's engine is not answering at {self.base_url}. Start Sentient first, then try again."
) from exc
if resp.status_code == 401:
raise EngineError(
- "The engine refused this server's token. Restart the MCP server so it picks up the current token."
+ "The engine refused this server's token. It may have been replaced in Sentient: "
+ "restart this MCP server so it reads the current one."
)
if resp.status_code >= 400:
detail = ""
@@ -90,14 +108,11 @@ def health(self) -> dict:
f"No Sentient engine is running at {self.base_url}. Start Sentient first, then try again."
) from exc
- def get_config(self) -> dict:
- return self._call("GET", "/api/config")
-
- def integrations_config(self) -> dict:
- """The live ``integrations`` section: the MCP server's on/off switch lives here."""
- cfg = self.get_config()
- section = cfg.get("integrations") if isinstance(cfg, dict) else None
- return section if isinstance(section, dict) else {}
+ def status(self) -> dict:
+ """``GET /api/mcp/status``: whether the feature is on and what its memories do, readable
+ with the MCP-only token (the full config is not, on purpose)."""
+ data = self._call("GET", "/api/mcp/status")
+ return data if isinstance(data, dict) else {}
# ------------------------------------------------------------------ memories
def search_memories(self, query: str, limit: int = 10) -> list[dict]:
@@ -107,14 +122,20 @@ def search_memories(self, query: str, limit: int = 10) -> list[dict]:
def get_memory(self, memory_id: int) -> dict:
return self._call("GET", f"/api/memories/{memory_id}")
- def add_memory(self, content: str, *, hold_for_review: bool, scope: str | None = None) -> dict:
+ def add_memory(
+ self, content: str, *, hold_for_review: bool, scope: str | None = None, app: str | None = None
+ ) -> dict:
body: dict[str, Any] = {"content": content, "source": "mcp"}
if scope:
body["scope"] = scope
+ if app:
+ body["app"] = app # the engine names the review note (and forces the wait) from this
if hold_for_review:
body["hold_for_review"] = True
- body["review_from"] = "an MCP app"
- return self._call("POST", "/api/memories", json=body)
+ body["review_from"] = app or "an MCP app"
+ # saving runs the memory pipeline (a model call) inside the engine: give it room, so a
+ # slow save is not reported as a failure after it already landed
+ return self._call("POST", "/api/memories", json=body, timeout=120.0)
# ------------------------------------------------------------------ tasks
@staticmethod
@@ -124,8 +145,11 @@ def _task_view(task: dict) -> dict:
return {"id": task["task_id"], **task}
return task
- def create_task(self, prompt: str) -> dict:
- return self._task_view(self._call("POST", "/api/tasks", json={"prompt": prompt}))
+ def create_task(self, prompt: str, *, app: str | None = None) -> dict:
+ body: dict[str, Any] = {"prompt": prompt}
+ if app:
+ body["app"] = app
+ return self._task_view(self._call("POST", "/api/tasks", json=body))
def list_tasks(self) -> list[dict]:
rows = self._call("GET", "/api/tasks")
@@ -135,23 +159,30 @@ def get_task(self, task_id: str) -> dict:
return self._task_view(self._call("GET", f"/api/tasks/{task_id}"))
# ------------------------------------------------------------------ notifications
- def notify(self, message: str, title: str | None = None) -> dict:
+ def notify(self, message: str, title: str | None = None, *, app: str | None = None) -> dict:
body: dict[str, Any] = {"message": message, "source": "mcp"}
if title:
body["title"] = title
+ if app:
+ body["app"] = app
return self._call("POST", "/api/notifications", json=body)
# ------------------------------------------------------------------ chat
- def chat_turn(self, text: str, *, timeout: float = 180.0) -> dict:
- """One chat turn through ``POST /api/chat`` (NDJSON). The turn's own tools run inside the
- engine, under Sentient's approvals and rules; when a call has to ask, the question appears
- in the Sentient window like any other, and this call waits for the answer until ``timeout``.
- Returns ``{content, session_id}`` or ``{content, error, session_id}``."""
+ def chat_turn(self, text: str, *, timeout: float = 180.0, app: str | None = None) -> dict:
+ """One chat turn through ``POST /api/chat`` (NDJSON). The turn is outside content inside
+ the engine: its tools run under Sentient's approvals and rules, and a call that has to ask
+ appears in the Sentient window and as an approval notification the user can answer there
+ or on their phone. Returns ``{content, session_id}`` (plus ``approval_asked`` when a
+ question went to the user, and ``error`` when the turn failed or is still waiting)."""
session_id: str | None = None
content = ""
+ approval_asked = False
try:
+ body: dict[str, Any] = {"text": text, "channel": "mcp"}
+ if app:
+ body["app"] = app # display only: the engine names the turn's outside-content mark with it
with self._http.stream(
- "POST", "/api/chat", json={"text": text, "channel": "mcp"},
+ "POST", "/api/chat", json=body,
timeout=httpx.Timeout(30.0, read=timeout),
) as resp:
if resp.status_code >= 400:
@@ -166,25 +197,30 @@ def chat_turn(self, text: str, *, timeout: float = 180.0) -> dict:
continue
if event.get("session_id"):
session_id = event["session_id"]
- if event.get("type") == "done":
+ if event.get("type") == "approval_request":
+ approval_asked = True
+ elif event.get("type") == "done":
content = str(event.get("content") or "")
if event.get("error"):
- return {"content": content, "error": str(event["error"]), "session_id": session_id}
+ return {"content": content, "error": str(event["error"]),
+ "session_id": session_id, "approval_asked": approval_asked}
elif event.get("type") == "error":
return {
"content": content,
"error": str(event.get("message") or "the chat turn failed"),
"session_id": session_id,
+ "approval_asked": approval_asked,
}
except httpx.TimeoutException:
return {
"content": content,
"error": (
"No answer yet: the turn is still running in Sentient, most likely waiting for the user "
- "to answer an approval in the Sentient window."
+ "to answer an approval in Sentient (it is in the window and in Sentient's notifications)."
),
"session_id": session_id,
+ "approval_asked": approval_asked,
}
except httpx.HTTPError as exc:
raise EngineError(f"The chat turn broke off: {exc}") from exc
- return {"content": content, "session_id": session_id}
+ return {"content": content, "session_id": session_id, "approval_asked": approval_asked}
diff --git a/sentient/mcp_server/server.py b/sentient/mcp_server/server.py
index 79c50156d..43aa0d41b 100644
--- a/sentient/mcp_server/server.py
+++ b/sentient/mcp_server/server.py
@@ -4,40 +4,37 @@
- The tool list is a whitelist. There is no tool that sends a message, deletes anything or runs a
command, so nothing an MCP client asks for can do those things through Sentient.
-- The server only starts when the user has turned it on in Settings (``integrations.mcp_server_enabled``),
+- The server only starts when the user has turned it on in Sentient (``integrations.mcp_server_enabled``),
and every tool call checks the switch again against the running engine, so turning it off in
- Settings stops a server that is already running.
-- The HTTP transport additionally requires the engine token as a bearer token: being another
- process on the same computer is not enough to call the tools. No client is ever given that
- token to paste into a configuration file: ``sentient mcp --connect `` is the
- launcher. It reads the token the same way this server does, adds it when it connects, and
- serves the tools over stdio, so the configuration an app keeps holds only this command and
- the loopback address.
-- Everything goes through the engine's REST API with the engine token, so a task is created the
- normal way (its plan waits for the user's approval in Sentient), memories an MCP app adds wait
- in Review (ADR 0021) unless the user allowed saving them straight away, and a chat turn's tools
- run under Sentient's own approvals, rules (ADR 0016) and outside-content checks (ADR 0018). The
- MCP client is outside content: nothing it says can approve anything.
+ Sentient stops a server that is already running.
+- It signs in with the MCP-only token: a token the engine issues for this feature alone, kept in
+ the keychain, which opens only the endpoints these tools use (never config, approvals or
+ anything else). ``sentient mcp`` reads it from the keychain itself and finds the engine
+ through the discovery file the engine writes at startup, so a client's configuration holds
+ nothing but the ``sentient mcp`` command: no address to keep current, no secret to paste.
+- Everything goes through the engine's REST API, so a task an app creates is marked as that
+ app's outside content (its plan always waits for the user's approval, like a task by email),
+ memories an app adds wait in Review (ADR 0021) unless the user allowed saving them straight
+ away, and a chat turn an app steers is outside content from the start (ADR 0018): its words are
+ never the user's, anything that sends or runs code asks the user first, and the question also
+ lands as a notification in the app and on the user's phone. The MCP client is outside content:
+ nothing it says can approve anything.
"""
from __future__ import annotations
import json
-import secrets
import sys
from typing import Any
-from mcp.server.auth.provider import AccessToken
-from mcp.server.auth.settings import AuthSettings
-from mcp.server.mcpserver import MCPServer
+from mcp.server.mcpserver import Context, MCPServer
from mcp.types import ToolAnnotations
-from pydantic import AnyHttpUrl
-from sentient.mcp_server.client import EngineClient, EngineError, default_engine_url
+from sentient.mcp_server.client import EngineClient, EngineError, discovered_engine_url
OFF_MESSAGE = (
"Sentient's MCP server is turned off. The user can turn it on in Sentient, under Integrations, "
- "in the section called Let other apps use Sentient."
+ "in the section called Let other apps use Sentient (in the app: #/integrations?section=sentient-mcp)."
)
_READ = ToolAnnotations(read_only_hint=True, destructive_hint=False, open_world_hint=False)
@@ -75,6 +72,18 @@ def _task_row(row: dict) -> dict:
}
+def _client_name(ctx: Any) -> str | None:
+ """The name the connected app gave for itself ("Claude Code"), or None when it gave none.
+ Used only to say where a task or a note came from; never trusted for anything else."""
+ try:
+ params = ctx.request_context.session.client_params
+ info = getattr(params, "client_info", None) or getattr(params, "clientInfo", None)
+ name = getattr(info, "name", None) if info else None
+ return str(name).strip() or None
+ except Exception:
+ return None
+
+
class Gate:
"""Reads the live switch from the engine before every call (see the module docstring)."""
@@ -82,54 +91,19 @@ def __init__(self, client: EngineClient):
self.client = client
def check(self) -> dict | None:
- """The integrations config when the server may run, else None (turned off)."""
- section = self.client.integrations_config()
- return section if section.get("mcp_server_enabled") else None
-
-
-class EngineTokenVerifier:
- """Bearer-token check for the HTTP transport: the token is the engine token.
-
- Loopback is not enough on its own: any process (or web page) on this computer could otherwise
- call the tools. stdio needs no verifier; the client starts the process itself, as with other
- local MCP servers.
- """
-
- def __init__(self, token: str):
- self._token = token
-
- async def verify_token(self, token: str) -> AccessToken | None:
- if token and secrets.compare_digest(token, self._token):
- return AccessToken(token=token, client_id="sentient-mcp-http", scopes=[])
- return None
+ """The engine's MCP status when the server may run, else None (turned off)."""
+ status = self.client.status()
+ return status if status.get("enabled") else None
-def build_server(
- client: EngineClient,
- gate: Gate | None = None,
- *,
- http_token: str | None = None,
- http_port: int = 8765,
-) -> MCPServer:
+def build_server(client: EngineClient, gate: Gate | None = None) -> MCPServer:
gate = gate or Gate(client)
- if http_token:
- base = f"http://127.0.0.1:{http_port}"
- server = MCPServer(
- "sentient",
- token_verifier=EngineTokenVerifier(http_token),
- auth=AuthSettings(
- issuer_url=AnyHttpUrl(base),
- resource_server_url=AnyHttpUrl(f"{base}/mcp"),
- validate_token_resource=False,
- ),
- )
- else:
- server = MCPServer("sentient")
+ server = MCPServer("sentient")
def live_config() -> tuple[dict | None, str]:
- """(section, "") when a call may run, else (None, the plain reason it may not).
+ """(status, "") when a call may run, else (None, the plain reason it may not).
- Only the turned-off switch reports OFF_MESSAGE; an engine problem (a rotated token, the
+ Only the turned-off switch reports OFF_MESSAGE; an engine problem (a replaced token, the
engine stopped) reaches the caller with its own message, so the fix stays visible.
"""
try:
@@ -162,13 +136,13 @@ def memory_search(query: str, limit: int = 10) -> str:
),
annotations=_WRITE,
)
- def memory_add(content: str, scope: str | None = None) -> str:
+ def memory_add(content: str, ctx: Context, scope: str | None = None) -> str:
section, why = live_config()
if section is None:
return _error(why)
- hold = not bool(section.get("mcp_server_memories_skip_review"))
+ hold = not bool(section.get("memories_skip_review"))
try:
- result = client.add_memory(content, hold_for_review=hold, scope=scope)
+ result = client.add_memory(content, hold_for_review=hold, scope=scope, app=_client_name(ctx))
except EngineError as exc:
return _error(str(exc))
out = {"action": result.get("action"), "id": result.get("id"), "content": result.get("content")}
@@ -192,17 +166,18 @@ def memory_get(memory_id: int) -> str:
@server.tool(
description=(
- "Ask Sentient to do something as a task. The task is created the normal way: Sentient plans "
- "it and the plan waits for the user's approval in Sentient before anything runs."
+ "Ask Sentient to do something as a task. The task is created in your app's name and "
+ "marked as outside content: Sentient plans it and the plan always waits for the user's "
+ "approval in Sentient before anything runs."
),
annotations=_WRITE,
)
- def task_create(prompt: str) -> str:
+ def task_create(prompt: str, ctx: Context) -> str:
section, why = live_config()
if section is None:
return _error(why)
try:
- task = client.create_task(prompt)
+ task = client.create_task(prompt, app=_client_name(ctx))
except EngineError as exc:
return _error(str(exc))
out = _task_row(task)
@@ -232,38 +207,45 @@ def task_status(task_id: str) -> str:
return _json(_task_row(task))
@server.tool(
- description="Show a notification to the user inside Sentient. Nothing is sent anywhere else.",
+ description=(
+ "Show a notification to the user inside Sentient. The notification names your app, so "
+ "it cannot be mistaken for a message from Sentient itself. Nothing is sent anywhere else."
+ ),
annotations=_WRITE,
)
- def notify_me(message: str, title: str | None = None) -> str:
+ def notify_me(message: str, ctx: Context, title: str | None = None) -> str:
section, why = live_config()
if section is None:
return _error(why)
try:
- note = client.notify(message, title)
+ note = client.notify(message, title, app=_client_name(ctx))
except EngineError as exc:
return _error(str(exc))
return _json({"notified": True, "id": note.get("id")})
@server.tool(
description=(
- "Ask Sentient a question or give it an instruction, as one chat turn. The tools Sentient "
- "uses in that turn run under Sentient's own approvals and rules, and anything risky asks "
- "the user in the Sentient window first. Returns Sentient's reply."
+ "Ask Sentient a question or give it an instruction, as one chat turn. Sentient treats "
+ "the turn as outside content: the tools it uses run under Sentient's own approvals and "
+ "rules, and anything that sends, deletes or runs code asks the user first, in the "
+ "Sentient window and as a notification. Returns Sentient's reply."
),
annotations=_WRITE,
)
- def ask_sentient(message: str) -> str:
+ def ask_sentient(message: str, ctx: Context) -> str:
section, why = live_config()
if section is None:
return _error(why)
try:
- turn = client.chat_turn(message)
+ turn = client.chat_turn(message, app=_client_name(ctx))
except EngineError as exc:
return _error(str(exc))
if turn.get("error"):
return _json({"reply": turn.get("content") or "", "note": turn["error"]})
- return _json({"reply": turn.get("content") or ""})
+ out = {"reply": turn.get("content") or ""}
+ if turn.get("approval_asked"):
+ out["note"] = "Sentient asked the user to approve a step in this turn before it ran."
+ return _json(out)
return server
@@ -272,116 +254,35 @@ def check_ready(client: EngineClient) -> str | None:
"""None when the server may start, else a plain sentence for the person starting it."""
try:
client.health()
- section = client.integrations_config()
+ status = client.status()
except EngineError as exc:
return str(exc)
- if not section.get("mcp_server_enabled"):
+ if not status.get("enabled"):
return OFF_MESSAGE
return None
-class BridgeError(Exception):
- """The launcher could not reach the HTTP MCP server, or it refused the sign-in."""
-
+def main(*, engine_url: str | None = None) -> int:
+ """Entry point for ``sentient mcp`` (stdio). Returns the process exit code."""
+ from sentient.gateway import mcp_token
-def validate_connect_url(url: str) -> str:
- """The launcher only ever connects to an MCP server on this computer."""
- from urllib.parse import urlparse
-
- parsed = urlparse(url)
- host = (parsed.hostname or "").lower()
- if parsed.scheme not in {"http", "https"} or host not in {"127.0.0.1", "localhost", "::1"}:
- raise BridgeError(
- f"{url} is not an address on this computer. The launcher only connects to a Sentient "
- "MCP server running on this computer, such as http://127.0.0.1:8765/mcp."
+ if engine_url is None:
+ try:
+ engine_url = discovered_engine_url()
+ except EngineError as exc:
+ print(str(exc), file=sys.stderr)
+ return 2
+ in_use = mcp_token.current_token()
+ if not in_use:
+ print(
+ "Sentient has not issued an MCP token yet. The user turns the feature on in Sentient "
+ "(Integrations, in the section called Let other apps use Sentient), which creates it; "
+ "this server reads it from the keychain itself, so there is nothing to paste anywhere.",
+ file=sys.stderr,
)
- return url
-
-
-async def _run_bridge(connect_url: str, token: str) -> None:
- """Serve the tools over stdio, forwarding to the HTTP server at ``connect_url``.
-
- This is the launcher: the MCP client starts this process, and the token is added here, by
- Sentient's own process, on the connection it opens. The token never appears in the
- client's configuration file, which names only this command and the loopback address.
- """
- import httpx2
- from mcp import ClientSession, types
- from mcp.client.streamable_http import streamable_http_client
- from mcp.server.lowlevel import Server
- from mcp.server.stdio import stdio_server
-
- http = httpx2.AsyncClient(
- headers={"Authorization": f"Bearer {token}"},
- timeout=httpx2.Timeout(10.0, read=1800.0),
- )
- try:
- async with (
- streamable_http_client(connect_url, http_client=http) as (read, write),
- ClientSession(read, write) as session,
- ):
- await session.initialize()
- bridge = Server("sentient-mcp-launcher")
-
- async def _list_tools(ctx, params):
- result = await session.list_tools()
- return types.ListToolsResult(tools=result.tools)
-
- async def _call_tool(ctx, params):
- return await session.call_tool(params.name, params.arguments or {})
-
- bridge.add_request_handler("tools/list", types.PaginatedRequestParams, _list_tools)
- bridge.add_request_handler("tools/call", types.CallToolRequestParams, _call_tool)
- async with stdio_server() as (stdin, stdout):
- await bridge.run(stdin, stdout, bridge.create_initialization_options())
- except (KeyboardInterrupt, SystemExit):
- raise
- except BaseException as exc: # connection failures can arrive grouped (anyio task groups)
- raise BridgeError(
- f"Could not connect to the Sentient MCP server at {connect_url}, or it refused "
- "Sentient's sign-in. Start the server first with `sentient mcp --transport http`, "
- "from this same Sentient install, then try again."
- ) from exc
- finally:
- await http.aclose()
-
-
-def _launch(connect_url: str) -> int:
- """Launcher mode: forward a client's stdio to the HTTP server, signing in ourselves."""
- import anyio
-
- from sentient.gateway.auth import load_or_create_token
-
- try:
- url = validate_connect_url(connect_url)
- except BridgeError as exc:
- print(str(exc), file=sys.stderr)
- return 2
- try:
- anyio.run(_run_bridge, url, load_or_create_token())
- except BridgeError as exc:
- print(str(exc), file=sys.stderr)
return 2
- return 0
-
-
-def main(
- transport: str = "stdio",
- *,
- port: int = 8765,
- engine_url: str | None = None,
- connect: str | None = None,
-) -> int:
- """Entry point for ``sentient mcp``. Returns the process exit code."""
- from sentient.gateway.auth import load_or_create_token
-
- if connect is not None:
- return _launch(connect)
-
- url = engine_url or default_engine_url()
- token = load_or_create_token()
try:
- client = EngineClient(url, token)
+ client = EngineClient(engine_url, in_use)
except EngineError as exc:
print(str(exc), file=sys.stderr)
return 2
@@ -389,17 +290,5 @@ def main(
if problem is not None:
print(problem, file=sys.stderr)
return 2
- if transport == "http":
- # streamable HTTP, loopback only, and the client must send the engine token as a bearer
- # token: being on this computer is not enough on its own.
- server = build_server(client, http_token=token, http_port=port)
- print(
- f"Sentient MCP server on http://127.0.0.1:{port}/mcp "
- f"(this computer only; clients connect through the launcher: "
- f"sentient mcp --connect http://127.0.0.1:{port}/mcp)",
- file=sys.stderr,
- )
- server.run("streamable-http", host="127.0.0.1", port=port)
- else:
- build_server(client).run("stdio")
+ build_server(client).run("stdio")
return 0
diff --git a/sentient/paths.py b/sentient/paths.py
index 5a31fef03..f510a3068 100644
--- a/sentient/paths.py
+++ b/sentient/paths.py
@@ -30,6 +30,12 @@ def token_file() -> Path:
return home() / "gateway.token"
+def discovery_file() -> Path:
+ """Where the running engine leaves its loopback port for `sentient mcp` to find (issue #443).
+ Port only, never a secret: the MCP token lives in the keychain."""
+ return home() / "engine-discovery.json"
+
+
def workspace_dir() -> Path:
"""Human-readable markdown the user can open and edit: SOUL.md, USER.md, MEMORY.md, notes/."""
return home() / "workspace"
diff --git a/sentient/tasks/executor.py b/sentient/tasks/executor.py
index 0fedaea4e..f99074dc3 100644
--- a/sentient/tasks/executor.py
+++ b/sentient/tasks/executor.py
@@ -35,7 +35,7 @@
from sentient.llm.provider import ToolCall
from sentient.memory import scopes as memory_scopes
from sentient.memory.sources import MemorySources
-from sentient.tasks import ask, checklist, continuity, email_inbox, limits, stuck
+from sentient.tasks import ask, checklist, continuity, email_inbox, limits, mcp_tasks, stuck
from sentient.tasks.jsonio import complete_json_object
from sentient.tasks.prompts import (
CORE_HELPER_PLUGINS,
@@ -302,6 +302,8 @@ def _trigger_source(app: Any, task: dict, run: dict) -> str:
from an email (#159) is started by that email, in every run."""
if email_inbox.is_email_task(task):
return email_inbox.app_name(task)
+ if mcp_tasks.is_mcp_task(task): # an MCP app's task is started by that app, in every run (#443)
+ return mcp_tasks.app_name(task)
if not run.get("trigger_data"):
return ""
source = str((task.get("schedule") or {}).get("source") or "")
@@ -425,6 +427,8 @@ async def alive(event: Any) -> None:
ctx.extra[ASK_FIRST_KEY] = True
if email_inbox.is_email_task(task): # its brief was written from an email, not by the user (#159)
ctx.extra[BRIEF_FROM_KEY] = email_inbox.app_name(task)
+ if mcp_tasks.is_mcp_task(task): # its brief was written from an app's prompt, not by the user (#443)
+ ctx.extra[BRIEF_FROM_KEY] = mcp_tasks.app_name(task)
checkpoint = run.get("messages") if resume else None
if isinstance(checkpoint, list) and checkpoint:
if svc.holding(run_id): # paused before an approved call ran: it runs once the task is resumed
diff --git a/sentient/tasks/mcp_tasks.py b/sentient/tasks/mcp_tasks.py
new file mode 100644
index 000000000..35216c6b3
--- /dev/null
+++ b/sentient/tasks/mcp_tasks.py
@@ -0,0 +1,24 @@
+"""Tasks made by an MCP app (issue #443): another app asks Sentient to do something, through the
+``task_create`` tool.
+
+The app's words are outside content (ADR 0018, and ADR 0033 for tasks by email), so such a task is
+treated exactly like a task by email:
+
+- it is never refined (refining would read the app's prompt as the user's own words);
+- its plan always waits for the user's approval, whatever ``tasks.require_plan_approval`` says;
+- every run is marked from the start with the app's name, so anything that sends asks first;
+- its brief is not the user's own words to the second look.
+"""
+
+from __future__ import annotations
+
+SOURCE = "mcp" # tasks.source and original_context.source of a task an MCP app made
+
+
+def is_mcp_task(task: dict | None) -> bool:
+ return isinstance(task, dict) and (task.get("original_context") or {}).get("source") == SOURCE
+
+
+def app_name(task: dict) -> str:
+ """The app that asked, as the approval card names it."""
+ return str((task.get("original_context") or {}).get("app") or "An MCP app")
diff --git a/sentient/tasks/service.py b/sentient/tasks/service.py
index 0ec4b8e8f..143bcb129 100644
--- a/sentient/tasks/service.py
+++ b/sentient/tasks/service.py
@@ -60,6 +60,7 @@
email_inbox,
executor,
limits,
+ mcp_tasks,
scripts,
stuck,
swarm,
@@ -456,6 +457,40 @@ async def create_from_email(self, name: str, description: str, context: dict) ->
assert data is not None
return data
+ async def create_from_mcp(self, prompt: str, app_name: str) -> dict:
+ """A task an MCP app asked for (``sentient/tasks/mcp_tasks.py``, #443).
+
+ No refining step (that would read the app's words as the user's): the name and description
+ are the app's prompt as it stands, and the plan always waits for approval."""
+ prompt = (prompt or "").strip()
+ if not prompt:
+ raise ValueError("A prompt is required.")
+ now = self.now_iso()
+ task_id = await self.repo.insert_task({
+ "name": _title(prompt),
+ "description": prompt,
+ "status": "planning",
+ "priority": 1,
+ "assignee": "ai",
+ "original_prompt": prompt,
+ "source": mcp_tasks.SOURCE,
+ "enabled": True,
+ "model": None,
+ "memory_scope": memory_scopes.chat_scope(None),
+ "original_context": {"source": mcp_tasks.SOURCE, "app": app_name},
+ "plan": [],
+ "chat_history": [],
+ "clarifying_questions": [],
+ "task_type": "single",
+ "schedule": None,
+ "created_at": now,
+ "updated_at": now,
+ })
+ data = await self.publish(task_id)
+ self._spawn(self._plan_job(task_id), f"plan:{task_id}")
+ assert data is not None
+ return data
+
async def create_approved_call(
self,
prompt: str,
@@ -1474,7 +1509,8 @@ def _plan_again(self, task: dict) -> None:
if task.get("task_type") == "swarm":
self._spawn(self._orchestrate_swarm(task["id"]), f"swarm:{task['id']}")
elif (task.get("schedule") or task.get("chat_history") or task.get("clarifying_questions")
- or email_inbox.is_email_task(task)): # a task by email is never refined (#159)
+ or email_inbox.is_email_task(task) # a task by email is never refined (#159)
+ or mcp_tasks.is_mcp_task(task)): # nor one an MCP app asked for (#443)
self._spawn(self._plan_job(task["id"]), f"plan:{task['id']}")
else:
self._spawn(self._refine_and_plan(task["id"]), f"plan:{task['id']}")
@@ -1709,8 +1745,8 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None:
if current is None or current["status"] != "planning":
return # deleted or changed while the model was thinking
name = str(data.get("name") or "").strip()[:200] if not is_change else ""
- if email_inbox.is_email_task(task): # a task by email keeps the name code gave it, also in notifications
- name = ""
+ if email_inbox.is_email_task(task) or mcp_tasks.is_mcp_task(task):
+ name = "" # a task from outside content keeps the name code gave it, also in notifications
display_name = name or task.get("name") or "Untitled task"
if new_questions and (not answered or not plan):
@@ -1765,8 +1801,10 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None:
fields["description"] = str(data["description"]).strip()
if not await self.repo.update_task_if_status(task_id, {"planning"}, {**fields, "updated_at": self.now_iso()}):
return # declined, archived or edited while the planner was running
- # a plan written from someone's email always waits for the user's approval (#159)
- if (auto_approve or not cfg.tasks.require_plan_approval) and not email_inbox.is_email_task(task):
+ # a plan written from someone's email, or from an MCP app's prompt, always waits for the
+ # user's approval (#159, #443)
+ if (auto_approve or not cfg.tasks.require_plan_approval) and not email_inbox.is_email_task(task) \
+ and not mcp_tasks.is_mcp_task(task):
await self.approve(task_id)
return
await self.publish(task_id)
diff --git a/sentient/tools/builtin/memory_tool.py b/sentient/tools/builtin/memory_tool.py
index b3e54cf2f..f79b71bdb 100644
--- a/sentient/tools/builtin/memory_tool.py
+++ b/sentient/tools/builtin/memory_tool.py
@@ -95,6 +95,8 @@ async def _own_words(ctx: ToolContext) -> str | None:
Read from the stored chat, never from the model, so nothing it read can pass for the user's words."""
if not ctx.session_id or getattr(ctx, "origin", "user") != "user":
return None
+ if getattr(ctx, "channel", "") == "mcp":
+ return None # in an MCP app's chat the stored user messages are the app's words, not the user's (#443)
rows = await ctx.store.recent_messages(ctx.session_id, OWN_WORDS_ROWS)
words: list[str] = []
for row in reversed(rows):
diff --git a/tests/channels/test_mcp_approval_notes.py b/tests/channels/test_mcp_approval_notes.py
new file mode 100644
index 000000000..a4d26a83c
--- /dev/null
+++ b/tests/channels/test_mcp_approval_notes.py
@@ -0,0 +1,62 @@
+"""An MCP turn's approval (kind "approval", #443): held back while the user is in the app, delivered
+to the phone when they leave if it is still waiting, and answerable only from a chat it was
+delivered to (the ownership rule every other button here follows, #139/#337)."""
+
+from __future__ import annotations
+
+import asyncio
+
+from tests.channels.conftest import callback, until
+
+APPROVAL_ID = "a1" * 16 # broker ids are uuid hex; the button data must stay within Telegram's limit
+
+
+def _na_messages(tg, chat_id: int) -> list[dict]:
+ return [p for p in tg.api.button_messages()
+ if str(p["chat_id"]) == str(chat_id) and "Approval needed" in p["text"]]
+
+
+async def _raise_mcp_approval(app):
+ fut = app.approvals.create(APPROVAL_ID, ["allow", "deny"])
+ await app.notify(
+ "approval", "Run code: print(1)", title="Approval needed",
+ payload={"approval_id": APPROVAL_ID, "name": "run_snippet", "risk": "exec",
+ "arguments": {"code": "print(1)"}},
+ )
+ return fut
+
+
+async def test_a_held_mcp_approval_reaches_the_phone_when_the_user_leaves(tg):
+ await tg.pair(42)
+ await tg.pair(43)
+ tg.app.presence.set("desktop", True)
+ fut = await _raise_mcp_approval(tg.app)
+ await until(lambda: bool(tg.app.channels._held)) # held back while the user is looking at the app
+ assert _na_messages(tg, 42) == []
+
+ tg.app.presence.set("desktop", False)
+ await until(lambda: bool(_na_messages(tg, 42)))
+ msg = _na_messages(tg, 42)[-1]
+ buttons = [b for row in msg["reply_markup"]["inline_keyboard"] for b in row]
+ assert [b["callback_data"] for b in buttons] == [f"na:a:{APPROVAL_ID}", f"na:d:{APPROVAL_ID}"]
+ assert all(len(b["callback_data"].encode()) <= 64 for b in buttons)
+
+ tg.ch.dispatch(callback(43, msg["_id"], buttons[0]["callback_data"])) # a chat it wasn't sent to
+ await tg.ch.wait_idle()
+ assert not fut.done()
+
+ tg.ch.dispatch(callback(42, msg["_id"], buttons[0]["callback_data"]))
+ await tg.ch.wait_idle()
+ assert fut.done() and fut.result() == "allow"
+
+
+async def test_a_held_mcp_approval_answered_in_the_app_is_not_delivered_later(tg):
+ await tg.pair(42)
+ tg.app.presence.set("desktop", True)
+ fut = await _raise_mcp_approval(tg.app)
+ await until(lambda: bool(tg.app.channels._held))
+ assert tg.app.approvals.resolve(APPROVAL_ID, "deny") # answered in the app while it was held
+ tg.app.presence.set("desktop", False)
+ await asyncio.sleep(0.3) # give the held delivery its chance to (not) happen
+ assert _na_messages(tg, 42) == []
+ assert fut.result() == "deny"
diff --git a/tests/mcp_server/test_client.py b/tests/mcp_server/test_client.py
index 843500947..f56fcb35d 100644
--- a/tests/mcp_server/test_client.py
+++ b/tests/mcp_server/test_client.py
@@ -6,7 +6,7 @@
import pytest
import respx
-from sentient.mcp_server.client import EngineClient, EngineError, default_engine_url
+from sentient.mcp_server.client import EngineClient, EngineError, discovered_engine_url
BASE = "http://127.0.0.1:7777"
@@ -22,19 +22,19 @@ def test_a_non_local_engine_address_is_refused():
EngineClient("https://example.com:7777", "test-token")
-def test_the_default_url_brackets_an_ipv6_gateway_host(monkeypatch):
- from types import SimpleNamespace
+def test_the_engine_is_found_through_its_discovery_file(isolated_home):
+ import json as _json
- import sentient.config
+ from sentient import paths
- fake = SimpleNamespace(gateway=SimpleNamespace(host="::1", port=7777))
- monkeypatch.setattr(sentient.config, "load_config", lambda: fake)
- assert default_engine_url() == "http://[::1]:7777"
- # and the bracketed address passes the client's own loopback check
- EngineClient(default_engine_url(), "test-token")
-
- fake.gateway.host = "localhost"
- assert default_engine_url() == "http://localhost:7777"
+ # no engine started: no file, and the error says what to do
+ with pytest.raises(EngineError, match="Start Sentient first"):
+ discovered_engine_url()
+ found = paths.discovery_file()
+ found.parent.mkdir(parents=True, exist_ok=True)
+ found.write_text(_json.dumps({"port": 8123}), encoding="utf-8")
+ assert discovered_engine_url() == "http://127.0.0.1:8123"
+ EngineClient(discovered_engine_url(), "test-token") # passes the client's own loopback check
def test_calls_carry_the_token_and_hit_the_right_routes():
@@ -80,15 +80,23 @@ def test_task_and_notification_routes():
def test_a_refused_token_and_a_missing_engine_read_plainly():
with respx.mock() as router:
- router.get(f"{BASE}/api/config").mock(return_value=httpx.Response(401, json={"detail": "no"}))
+ router.get(f"{BASE}/api/mcp/status").mock(return_value=httpx.Response(401, json={"detail": "no"}))
with pytest.raises(EngineError, match="refused this server's token"):
- _client().get_config()
+ _client().status()
with respx.mock() as router:
router.get(f"{BASE}/api/memories").mock(side_effect=httpx.ConnectError("down"))
with pytest.raises(EngineError, match="not answering"):
_client().search_memories("tea")
+def test_a_slow_engine_is_not_reported_as_a_failed_change():
+ # a save that times out may well have landed: the words must not invite a blind retry
+ with respx.mock() as router:
+ router.post(f"{BASE}/api/memories").mock(side_effect=httpx.ReadTimeout("slow"))
+ with pytest.raises(EngineError, match="did not answer in time"):
+ _client().add_memory("Maya drinks tea", hold_for_review=True)
+
+
def test_chat_turn_reads_the_ndjson_stream_until_done():
ndjson = (
'{"type": "session", "session_id": "s1"}\n'
@@ -100,7 +108,45 @@ def test_chat_turn_reads_the_ndjson_stream_until_done():
return_value=httpx.Response(200, text=ndjson, headers={"content-type": "application/x-ndjson"})
)
out = _client().chat_turn("when is tea?")
- assert out == {"content": "Tea is at five.", "session_id": "s1"}
+ assert out == {"content": "Tea is at five.", "session_id": "s1", "approval_asked": False}
import json as _json
assert _json.loads(route.calls[0].request.content) == {"text": "when is tea?", "channel": "mcp"}
+
+
+def test_chat_turn_notes_when_the_user_was_asked_to_approve_a_step():
+ ndjson = (
+ '{"type": "session", "session_id": "s1"}\n'
+ '{"type": "approval_request", "session_id": "s1", "approval_id": "a1", "name": "run_code"}\n'
+ '{"type": "done", "session_id": "s1", "content": "Done."}\n'
+ )
+ with respx.mock() as router:
+ router.post(f"{BASE}/api/chat").mock(
+ return_value=httpx.Response(200, text=ndjson, headers={"content-type": "application/x-ndjson"})
+ )
+ out = _client().chat_turn("run it")
+ assert out["approval_asked"] is True and out["content"] == "Done."
+
+
+def test_the_app_name_rides_along_on_tasks_notes_and_held_memories():
+ with respx.mock() as router:
+ create = router.post(f"{BASE}/api/tasks").mock(
+ return_value=httpx.Response(200, json={"task_id": "t1", "status": "planning"})
+ )
+ note = router.post(f"{BASE}/api/notifications").mock(
+ return_value=httpx.Response(200, json={"id": "n1", "kind": "info"})
+ )
+ add = router.post(f"{BASE}/api/memories").mock(
+ return_value=httpx.Response(200, json={"action": "ADD", "id": 7, "status": "pending"})
+ )
+ client = _client()
+ client.create_task("Book the dentist", app="Claude Code")
+ client.notify("done", app="Claude Code")
+ client.add_memory("Maya drinks tea", hold_for_review=True, app="Claude Code")
+ import json as _json
+
+ assert _json.loads(create.calls[0].request.content) == {"prompt": "Book the dentist", "app": "Claude Code"}
+ assert _json.loads(note.calls[0].request.content) == {
+ "message": "done", "source": "mcp", "app": "Claude Code",
+ }
+ assert _json.loads(add.calls[0].request.content)["review_from"] == "Claude Code"
diff --git a/tests/mcp_server/test_mcp_server.py b/tests/mcp_server/test_mcp_server.py
index 24a6fc567..c624ea535 100644
--- a/tests/mcp_server/test_mcp_server.py
+++ b/tests/mcp_server/test_mcp_server.py
@@ -8,20 +8,13 @@
from __future__ import annotations
import json
-import socket
-import sys
-import threading
-import time
-
-import httpx
-import pytest
-import respx
+
from mcp import Client
from typer.testing import CliRunner
from sentient import cli
from sentient.mcp_server.client import EngineError
-from sentient.mcp_server.server import BridgeError, build_server, main, validate_connect_url
+from sentient.mcp_server.server import build_server, main
EXPECTED_TOOLS = {
"memory_search", "memory_add", "memory_get",
@@ -35,8 +28,9 @@ class FakeEngine:
def __init__(self, *, enabled: bool = True, skip_review: bool = False):
self.section = {
- "mcp_server_enabled": enabled,
- "mcp_server_memories_skip_review": skip_review,
+ "enabled": enabled,
+ "memories_skip_review": skip_review,
+ "token_issued": True,
}
self.memories = [
{"id": 1, "content": "Maya Rao's daughter Mira starts school in June",
@@ -55,7 +49,7 @@ def __init__(self, *, enabled: bool = True, skip_review: bool = False):
self.fail_search = False
self.fail_config = False
- def integrations_config(self):
+ def status(self):
if self.fail_config:
raise EngineError("The engine refused this server's token (401).")
return dict(self.section)
@@ -72,17 +66,18 @@ def get_memory(self, memory_id):
return {**m, "memory_type": "long-term", "state": "current"}
raise EngineError("The engine said no (404): memory 999 not found.")
- def add_memory(self, content, *, hold_for_review, scope=None):
- self.added.append({"content": content, "hold_for_review": hold_for_review, "scope": scope})
+ def add_memory(self, content, *, hold_for_review, scope=None, app=None):
+ self.added.append({"content": content, "hold_for_review": hold_for_review, "scope": scope, "app": app})
out = {"action": "ADD", "id": 99, "content": content}
if hold_for_review:
out["status"] = "pending"
return out
- def create_task(self, prompt):
+ def create_task(self, prompt, *, app=None):
task = {"id": "t2", "name": prompt[:40], "status": "planning", "description": prompt,
"plan": [], "updated_at": "2026-10-21T10:00:00Z"}
self.tasks.append(task)
+ self.task_apps = [*getattr(self, "task_apps", []), app]
return task
def list_tasks(self):
@@ -94,11 +89,11 @@ def get_task(self, task_id):
return t
raise EngineError("The engine said no (404).")
- def notify(self, message, title=None):
- self.notes.append({"message": message, "title": title})
+ def notify(self, message, title=None, *, app=None):
+ self.notes.append({"message": message, "title": title, "app": app})
return {"id": "n1"}
- def chat_turn(self, text):
+ def chat_turn(self, text, *, app=None):
return {"content": f"Sentient replies to: {text}", "session_id": "s1"}
@@ -151,7 +146,8 @@ async def test_memory_search_and_get():
async def test_memory_add_waits_for_review_by_default():
engine = FakeEngine()
out = await _call(build_server(engine), "memory_add", {"content": "Maya's office is in Lisbon"})
- assert engine.added == [{"content": "Maya's office is in Lisbon", "hold_for_review": True, "scope": None}]
+ assert engine.added[0]["content"] == "Maya's office is in Lisbon"
+ assert engine.added[0]["hold_for_review"] is True and engine.added[0]["scope"] is None
assert out["status"] == "pending" and "review" in out["note"].lower()
@@ -177,7 +173,7 @@ async def test_notify_me_and_ask_sentient():
engine = FakeEngine()
out = await _call(build_server(engine), "notify_me", {"message": "Claude Code finished the report"})
assert out == {"notified": True, "id": "n1"}
- assert engine.notes == [{"message": "Claude Code finished the report", "title": None}]
+ assert engine.notes[0]["message"] == "Claude Code finished the report" and engine.notes[0]["title"] is None
reply = await _call(build_server(engine), "ask_sentient", {"message": "what is on today?"})
assert reply["reply"] == "Sentient replies to: what is on today?"
@@ -193,13 +189,47 @@ def test_the_mcp_command_exists_and_refuses_without_an_engine(isolated_home):
runner = CliRunner()
help_out = runner.invoke(cli.app, ["mcp", "--help"])
assert help_out.exit_code == 0, help_out.output
- # the health check is mocked to fail the way a stopped engine fails, so the test never
- # depends on whether a real Sentient happens to be running on this computer
- with respx.mock() as router:
- router.get(url__regex=r".*/api/health").mock(side_effect=httpx.ConnectError("down"))
- refused = runner.invoke(cli.app, ["mcp"])
+ # no discovery file in the isolated home: no engine is running, whatever this computer has
+ refused = runner.invoke(cli.app, ["mcp"])
assert refused.exit_code == 2, refused.output
assert "Start Sentient first" in refused.output
+ # and starting (or refusing) never creates the gateway token file: that token is the engine's
+ # own, and this process must not mint one as a side effect (review item 3)
+ assert not (isolated_home / "gateway.token").exists()
+
+
+def test_main_refuses_until_the_user_turns_the_feature_on(isolated_home, monkeypatch):
+ from sentient import paths, secrets
+
+ found = paths.discovery_file()
+ found.parent.mkdir(parents=True, exist_ok=True)
+ found.write_text('{"port": 9}', encoding="utf-8")
+ monkeypatch.setattr(secrets, "get_secret", lambda name, env_var=None: None)
+ assert main() == 2 # no MCP token issued yet: turning the feature on creates it
+
+
+def test_main_never_writes_the_gateway_token(isolated_home, monkeypatch):
+ from sentient import paths, secrets
+
+ found = paths.discovery_file()
+ found.parent.mkdir(parents=True, exist_ok=True)
+ found.write_text('{"port": 9}', encoding="utf-8") # port 9: nothing answers there
+ monkeypatch.setattr(secrets, "get_secret", lambda name, env_var=None: "issued-token")
+ assert main() == 2 # the engine is not answering, so the server refuses to start
+ assert not (isolated_home / "gateway.token").exists()
+
+
+def test_client_name_comes_from_the_handshake():
+ # the SDK's InitializeRequestParams exposes the client info as client_info (the
+ # camelCase spelling is only the wire alias); a wrong attribute silently yields None
+ from types import SimpleNamespace
+
+ from sentient.mcp_server.server import _client_name
+
+ ctx = SimpleNamespace(request_context=SimpleNamespace(session=SimpleNamespace(
+ client_params=SimpleNamespace(client_info=SimpleNamespace(name="Claude Code")))))
+ assert _client_name(ctx) == "Claude Code"
+ assert _client_name(SimpleNamespace()) is None
async def test_an_engine_error_at_the_gate_is_reported_not_masked_as_turned_off():
@@ -208,138 +238,3 @@ async def test_an_engine_error_at_the_gate_is_reported_not_masked_as_turned_off(
out = await _call(build_server(engine), "memory_search", {"query": "Maya"})
assert out == {"error": "The engine refused this server's token (401)."}
-
-def test_the_http_transport_refuses_calls_without_the_engine_token():
- from starlette.testclient import TestClient
-
- engine = FakeEngine()
- server = build_server(engine, http_token="test-token", http_port=8765)
- init = {
- "jsonrpc": "2.0", "id": 1, "method": "initialize",
- "params": {"protocolVersion": "2025-03-26", "capabilities": {},
- "clientInfo": {"name": "test", "version": "0"}},
- }
- headers = {"Accept": "application/json, text/event-stream"}
- with TestClient(server.streamable_http_app(), base_url="http://127.0.0.1:8765") as http:
- refused = http.post("/mcp", json=init, headers=headers)
- assert refused.status_code == 401, refused.text
- wrong = http.post("/mcp", json=init, headers={**headers, "Authorization": "Bearer nope"})
- assert wrong.status_code == 401, wrong.text
- allowed = http.post("/mcp", json=init, headers={**headers, "Authorization": "Bearer test-token"})
- assert allowed.status_code == 200, allowed.text
- assert "sentient" in allowed.text
-
-
-# --- The launcher: `sentient mcp --connect ` --------------------------------------
-#
-# The HTTP endpoint requires the engine token, and no client settings file may hold it. The
-# launcher is the process that signs in: it reads the token the way the server does and adds
-# it on the connection it opens, then serves the tools over stdio.
-
-
-_SIGNIN = "test" + "-token" # the HTTP server's token in the launcher tests below
-
-
-def _free_port() -> int:
- with socket.socket() as sock:
- sock.bind(("127.0.0.1", 0))
- return sock.getsockname()[1]
-
-
-def test_the_launcher_only_connects_to_this_computer():
- for good in ("http://127.0.0.1:8765/mcp", "http://localhost:8765/mcp", "http://[::1]:8765/mcp"):
- assert validate_connect_url(good) == good
- for bad in ("https://example.com/mcp", "http://192.168.1.10:8765/mcp", "not a url"):
- with pytest.raises(BridgeError):
- validate_connect_url(bad)
-
-
-def test_the_launcher_fails_cleanly_when_no_server_is_listening(monkeypatch):
- monkeypatch.setenv("SENTIENT_GATEWAY_TOKEN", "test-token")
- assert main(connect=f"http://127.0.0.1:{_free_port()}/mcp") == 2
-
-
-def test_connect_and_http_transport_are_refused_together(isolated_home):
- result = CliRunner().invoke(
- cli.app, ["mcp", "--transport", "http", "--connect", "http://127.0.0.1:8765/mcp"]
- )
- assert result.exit_code == 2
- result = CliRunner().invoke(
- cli.app,
- ["mcp", "--connect", "http://127.0.0.1:8765/mcp", "--engine-url", "http://127.0.0.1:7777"],
- )
- assert result.exit_code == 2
-
-
-def _serve_http_app(app):
- """Run an ASGI app on a free loopback port in a thread; returns (address, stop)."""
- import uvicorn
-
- server = uvicorn.Server(uvicorn.Config(app, host="127.0.0.1", port=0, log_level="error"))
- thread = threading.Thread(target=server.run, daemon=True)
- thread.start()
- for _ in range(200):
- if server.started:
- break
- time.sleep(0.05)
- port = server.servers[0].sockets[0].getsockname()[1]
-
- def stop() -> None:
- server.should_exit = True
- thread.join(timeout=5)
-
- return f"http://127.0.0.1:{port}/mcp", stop
-
-
-def _launcher_params(url: str, token: str, home) -> object:
- from mcp.client.stdio import StdioServerParameters
-
- return StdioServerParameters(
- command=sys.executable,
- args=["-m", "sentient", "mcp", "--connect", url],
- env={"SENTIENT_GATEWAY_TOKEN": token, "SENTIENT_HOME": str(home)},
- )
-
-
-async def test_the_launcher_signs_in_itself_and_forwards_the_tools(tmp_path):
- from mcp import ClientSession
- from mcp.client.stdio import stdio_client
-
- app = build_server(FakeEngine(), http_token=_SIGNIN, http_port=8765).streamable_http_app()
- url, stop = _serve_http_app(app)
- try:
- params = _launcher_params(url, "test-token", tmp_path)
- async with (
- stdio_client(params) as (read, write),
- ClientSession(read, write) as session,
- ):
- await session.initialize()
- tools = await session.list_tools()
- assert {t.name for t in tools.tools} == EXPECTED_TOOLS
- found = _result_json(await session.call_tool("memory_search", {"query": "Maya"}))
- assert [m["id"] for m in found["memories"]] == [1, 2]
- finally:
- stop()
-
-
-async def test_the_launcher_is_refused_with_the_wrong_token(tmp_path):
- from mcp import ClientSession
- from mcp.client.stdio import stdio_client
-
- app = build_server(FakeEngine(), http_token=_SIGNIN, http_port=8765).streamable_http_app()
- url, stop = _serve_http_app(app)
- signed_in = False
- try:
- params = _launcher_params(url, "the-wrong-token", tmp_path)
- try:
- async with (
- stdio_client(params) as (read, write),
- ClientSession(read, write) as session,
- ):
- await session.initialize()
- signed_in = True
- except BaseException: # the launcher exits; the stdio streams just close
- pass
- finally:
- stop()
- assert not signed_in, "the launcher got in with the wrong token"
diff --git a/tests/mcp_server/test_review_fixes.py b/tests/mcp_server/test_review_fixes.py
new file mode 100644
index 000000000..32cf384f6
--- /dev/null
+++ b/tests/mcp_server/test_review_fixes.py
@@ -0,0 +1,335 @@
+"""Reproductions for the maintainer's changes-requested review of PR #487 (review 5595264068).
+
+1. A chat turn steered by an MCP app is outside content from the start: its text is not the user's
+ words, sends and code runs ask first, and a "remember this about me" waits in Review.
+2. An approval raised in such a turn also lands as a kind "approval" notification the user can
+ answer from the app (the chat stream alone is invisible to MCP clients).
+3. The MCP-only token reaches only the endpoints the eight tools need, never config or approvals.
+5. A task an MCP app creates always waits for plan approval, even when plans auto-approve.
+"""
+
+from __future__ import annotations
+
+import asyncio
+import time
+
+import pytest
+from fastapi.testclient import TestClient
+
+from sentient import secrets
+from sentient.app import SentientApp
+from sentient.gateway.app import create_app
+from sentient.llm.events import ApprovalRequest, ToolResultEvent
+from sentient.tools.base import Risk, ToolContext, ToolPlugin, tool
+from tests.conftest import FakeProvider, tool_call
+
+MCP_TOKEN = "test" + "-mcp-token" # a literal the redactor leaves alone
+
+
+@pytest.fixture
+def keychain(monkeypatch):
+ store: dict[str, str] = {}
+ monkeypatch.setattr(secrets, "get_secret", lambda name, env_var=None: store.get(name))
+ monkeypatch.setattr(secrets, "set_secret", lambda name, value: store.__setitem__(name, value) or True)
+ monkeypatch.setattr(secrets, "delete_secret", lambda name: store.pop(name, None) is not None)
+ return store
+
+
+async def _collect(agent, sid, text, channel, on_event=None):
+ events = []
+ async for ev in agent.run_turn(sid, text, channel=channel):
+ events.append(ev)
+ if on_event:
+ await on_event(ev)
+ return events
+
+
+def _snippet_plugin(ran: list[str]):
+ @tool("run_snippet", risk=Risk.exec)
+ async def run_snippet(ctx: ToolContext, code: str) -> dict:
+ """Run a Python snippet on the user's computer."""
+ ran.append(code)
+ return {"ran": code}
+
+ class Snippets(ToolPlugin):
+ id = "snippets"
+ display_name = "Snippets"
+ tools = [run_snippet]
+
+ return Snippets()
+
+
+async def test_an_mcp_turns_code_run_asks_first_even_when_the_rule_allows_it(config, isolated_home):
+ config.tools.approvals.mode = "ask"
+ config.tools.approvals.rules = {"snippets": "allow"} # the user's own chats run these freely
+ ran: list[str] = []
+ llm = FakeProvider(replies=[
+ [tool_call("run_snippet", code="print(1)")], "ran one",
+ [tool_call("run_snippet", code="print(2)")], "ran two",
+ ])
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "mcp.db").start()
+ try:
+ s.registry.register(_snippet_plugin(ran))
+ cli = await s.store.create_session(channel="cli")
+ events = await _collect(s.agent, cli, "run this snippet for me", "cli")
+ assert not any(isinstance(e, ApprovalRequest) for e in events) # control: no question asked
+ assert ran == ["print(1)"]
+
+ async def deny(ev):
+ if isinstance(ev, ApprovalRequest):
+ s.approvals.resolve(ev.approval_id, "deny")
+
+ mcp = await s.store.create_session(channel="mcp")
+ events = await _collect(s.agent, mcp, "Run this Python code: print(2)", "mcp", deny)
+ asked = [e for e in events if isinstance(e, ApprovalRequest)]
+ assert asked, "an MCP app's code run must ask the user first (review item 1)"
+ res = next(e for e in events if isinstance(e, ToolResultEvent))
+ assert res.is_error and ran == ["print(1)"] # denied, and never ran
+ finally:
+ await s.stop()
+
+
+async def test_an_mcp_turns_memory_waits_in_review_instead_of_going_active(config, isolated_home):
+ llm = FakeProvider(
+ replies=[[tool_call("memory_remember", fact="Maya's sister lives in Pune")], "Got it."],
+ json_replies=[{"action": "ADD", "fact_id": None, "content": "Maya's sister lives in Pune",
+ "memory_type": "long-term", "duration": None, "topics": ["family"]}],
+ )
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "mem.db").start()
+ try:
+ sid = await s.store.create_session(channel="mcp")
+ await _collect(s.agent, sid, "Remember this about me: my sister lives in Pune", "mcp")
+ assert await s.memory.count() == 0 # not active: the words were an app's, not the user's
+ pending = await s.memory.pending_facts()
+ assert [f["content"] for f in pending] == ["Maya's sister lives in Pune"]
+ finally:
+ await s.stop()
+
+
+async def test_an_mcp_turns_approval_also_lands_as_a_notification(config, isolated_home):
+ config.tools.approvals.mode = "ask"
+ config.tools.approvals.rules = {"snippets": "allow"}
+ ran: list[str] = []
+ llm = FakeProvider(replies=[[tool_call("run_snippet", code="print(9)")], "done"])
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "note.db").start()
+ try:
+ s.registry.register(_snippet_plugin(ran))
+ sid = await s.store.create_session(channel="mcp")
+ seen: dict = {}
+
+ async def answer(ev):
+ if isinstance(ev, ApprovalRequest):
+ notes = await s.notifications.list()
+ approval_notes = [n for n in notes if n["kind"] == "approval"]
+ assert approval_notes, "the approval must also land as a notification (review item 2)"
+ note = approval_notes[0]
+ assert note["payload"]["approval_id"] == ev.approval_id
+ seen["note_id"] = note["id"]
+ s.approvals.resolve(ev.approval_id, "allow")
+
+ await _collect(s.agent, sid, "Run this Python code: print(9)", "mcp", answer)
+ assert ran == ["print(9)"]
+ note = await s.notifications.get(seen["note_id"])
+ assert note["payload"]["status"] == "allow" and note["read"] # settled once answered
+ finally:
+ await s.stop()
+
+
+PLAN = {
+ "name": "Tidy downloads",
+ "description": "Tidy the downloads folder",
+ "plan": [{"tool": "files", "description": "Tidy the downloads folder"}],
+ "clarifying_questions": [],
+}
+
+
+@pytest.fixture
+def engine_client(config, isolated_home, keychain):
+ config.integrations.mcp_server_enabled = True # the user turned the feature on in Settings
+ keychain["mcp-server-token"] = MCP_TOKEN
+ llm = FakeProvider(json_replies=[dict(PLAN)])
+ app = create_app(SentientApp(config, llm=llm, db_path=isolated_home / "g.db",
+ enable_background=False))
+ with TestClient(app) as c:
+ c.gateway_token = c.app.state.token
+ yield c
+
+
+@pytest.fixture
+def engine_client_off(config, isolated_home, keychain):
+ config.integrations.mcp_server_enabled = False # the token exists, but the feature is off
+ keychain["mcp-server-token"] = MCP_TOKEN
+ llm = FakeProvider(json_replies=[dict(PLAN)])
+ app = create_app(SentientApp(config, llm=llm, db_path=isolated_home / "off.db",
+ enable_background=False))
+ with TestClient(app) as c:
+ c.gateway_token = c.app.state.token
+ yield c
+
+
+def _mcp(client) -> dict:
+ return {"Authorization": f"Bearer {MCP_TOKEN}"}
+
+
+def _gw(client) -> dict:
+ return {"Authorization": f"Bearer {client.gateway_token}"}
+
+
+def test_the_mcp_token_reaches_only_the_tool_endpoints(engine_client):
+ c = engine_client
+ assert c.get("/api/memories", headers=_mcp(c)).status_code == 200
+ assert c.post("/api/memories", json={"content": "Maya takes the train"}, headers=_mcp(c)).status_code == 200
+ assert c.get("/api/tasks", headers=_mcp(c)).status_code == 200
+ assert c.post("/api/notifications", json={"message": "build done"}, headers=_mcp(c)).status_code == 200
+ assert c.get("/api/mcp/status", headers=_mcp(c)).status_code == 200
+
+ for method, url in [
+ ("GET", "/api/config"), ("PATCH", "/api/config"), ("POST", "/api/approvals"),
+ ("GET", "/api/memories/review"), ("DELETE", "/api/memories/1"), ("GET", "/api/sessions"),
+ ]:
+ r = c.request(method, url, json={} if method in ("POST", "PATCH") else None, headers=_mcp(c))
+ assert r.status_code in (401, 403), f"{method} {url} must refuse the MCP token, got {r.status_code}"
+
+ assert c.get("/api/config", headers=_gw(c)).status_code == 200 # the gateway token still reaches all
+
+
+def test_a_task_created_with_the_mcp_token_waits_for_plan_approval(engine_client):
+ c = engine_client
+ cfg = c.get("/api/config", headers=_gw(c)).json()
+ cfg["tasks"]["require_plan_approval"] = False # plans normally approve themselves
+ r = c.patch("/api/config", json={"tasks": cfg["tasks"]}, headers=_gw(c))
+ assert r.status_code == 200, r.text
+
+ made = c.post("/api/tasks", json={"prompt": "Tidy the downloads folder", "app": "Claude Code"},
+ headers=_mcp(c))
+ assert made.status_code == 200, made.text
+ task_id = made.json()["task_id"]
+ task = made.json()
+ for _ in range(50): # the planner runs in the background; wait for it to land
+ task = c.get(f"/api/tasks/{task_id}", headers=_gw(c)).json()
+ if task["status"] != "planning":
+ break
+ time.sleep(0.1)
+ assert task["original_context"]["source"] == "mcp"
+ assert task["original_context"]["app"] == "Claude Code"
+ assert task["status"] == "approval_pending" # an outside app's task always waits (review item 5)
+
+
+def test_with_the_feature_off_the_mcp_token_opens_nothing_but_status(engine_client_off):
+ c = engine_client_off
+ for method, url in [("GET", "/api/memories"), ("POST", "/api/memories"), ("GET", "/api/tasks"),
+ ("POST", "/api/tasks"), ("POST", "/api/notifications")]:
+ r = c.request(method, url, json={} if method == "POST" else None, headers=_mcp(c))
+ assert r.status_code == 403, f"{method} {url} must refuse the MCP token while off, got {r.status_code}"
+ # status stays reachable so the MCP server can say "turned off" instead of "refused"
+ r = c.get("/api/mcp/status", headers=_mcp(c))
+ assert r.status_code == 200 and r.json()["enabled"] is False
+ assert c.get("/api/memories", headers=_gw(c)).status_code == 200 # the user's own surfaces are unaffected
+
+
+def test_an_mcp_memory_waits_in_review_even_when_the_body_asks_it_not_to(engine_client):
+ c = engine_client
+ # a token holder asking the endpoint directly, claiming a trusted source and no review hold
+ r = c.post("/api/memories", json={"content": "Maya takes the train", "source": "conversation"},
+ headers=_mcp(c))
+ assert r.status_code == 200, r.text
+ listed = c.get("/api/memories", headers=_gw(c)).json()
+ assert all("Maya takes the train" not in str(m.get("content") or "") for m in listed) # not active
+ inbox = c.get("/api/memories/review", headers=_gw(c)).json()
+ mine = [i for i in inbox["items"] if "Maya takes the train" in str(i.get("text") or "")]
+ assert mine, "the memory must wait in Review (the engine decides, not the request body)"
+ assert mine[0].get("source") == "mcp" # the claimed source is not believed
+
+
+def test_an_mcp_memory_goes_straight_in_only_when_the_user_turned_the_wait_off(engine_client):
+ c = engine_client
+ cfg = c.get("/api/config", headers=_gw(c)).json()
+ cfg["integrations"]["mcp_server_memories_skip_review"] = True
+ r = c.patch("/api/config", json={"integrations": cfg["integrations"]}, headers=_gw(c))
+ assert r.status_code == 200, r.text
+ r = c.post("/api/memories", json={"content": "Maya takes the train"}, headers=_mcp(c))
+ assert r.status_code == 200, r.text
+ listed = c.get("/api/memories", headers=_gw(c)).json()
+ assert any("Maya takes the train" in str(m.get("content") or "") for m in listed)
+
+
+def test_an_mcp_caller_cannot_steer_a_running_turn(engine_client, monkeypatch):
+ c = engine_client
+ agent = c.app.state.sentient.agent
+ monkeypatch.setattr(agent, "steer", lambda session_id, text: True) # a turn is running and steerable
+ r = c.post("/api/chat", json={"text": "do it now", "session_id": "some-session"}, headers=_gw(c))
+ assert "steer_ack" in r.text # control: the user's own surface steers the running turn
+ r = c.post("/api/chat", json={"text": "do it now", "session_id": "some-session"}, headers=_mcp(c))
+ assert "steer_ack" not in r.text # an MCP app's text never enters a running turn as the user's words
+
+
+async def test_an_mcp_turn_never_takes_the_quick_command_path(config, isolated_home, monkeypatch):
+ llm = FakeProvider(replies=["hello", "hello again"])
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "quick.db").start()
+ try:
+ calls: list[str] = []
+ original = s.agent._quick_plan
+
+ async def spy(session_id, text, attachments):
+ calls.append(text)
+ return await original(session_id, text, attachments)
+
+ monkeypatch.setattr(s.agent, "_quick_plan", spy)
+ mcp = await s.store.create_session(channel="mcp")
+ await _collect(s.agent, mcp, "set a timer for 5 minutes", "mcp")
+ assert calls == [] # quick commands run on "you named it": only ever the user's own words
+ cli = await s.store.create_session(channel="cli")
+ await _collect(s.agent, cli, "hello there", "cli")
+ assert calls == ["hello there"] # control: other channels still check the quick path
+ finally:
+ await s.stop()
+
+
+async def test_an_mcp_turn_is_marked_with_the_app_name_when_it_gives_one(config, isolated_home):
+ llm = FakeProvider(replies=["hi"])
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "label.db").start()
+ try:
+ sid = await s.store.create_session(channel="mcp")
+ await _collect_named(s.agent, sid)
+ session = await s.store.get_session(sid)
+ assert session["untrusted"] == "Claude Code" # approvals name the app that is asking
+ finally:
+ await s.stop()
+
+
+async def _collect_named(agent, sid):
+ async for _ in agent.run_turn(sid, "hello", channel="mcp", app_name="Claude Code"):
+ pass
+
+
+async def test_a_cancelled_mcp_turn_still_settles_its_approval_notification(config, isolated_home):
+ config.tools.approvals.mode = "ask"
+ config.tools.approvals.rules = {"snippets": "allow"}
+ ran: list[str] = []
+ llm = FakeProvider(replies=[[tool_call("run_snippet", code="print(3)")], "done"])
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "cancel.db").start()
+ try:
+ s.registry.register(_snippet_plugin(ran))
+ sid = await s.store.create_session(channel="mcp")
+
+ async def drive():
+ async for _ in s.agent.run_turn(sid, "Run this Python code: print(3)", channel="mcp"):
+ pass
+
+ turn = asyncio.create_task(drive())
+ note = None
+ for _ in range(100):
+ notes = [n for n in await s.notifications.list() if n["kind"] == "approval"]
+ if notes:
+ note = notes[0]
+ break
+ await asyncio.sleep(0.05)
+ assert note is not None, "the approval notification never landed"
+ turn.cancel()
+ with pytest.raises(asyncio.CancelledError):
+ await turn
+ settled = await s.notifications.get(note["id"])
+ assert settled["payload"]["status"] == "expired" and settled["read"] # never left waiting forever
+ assert ran == []
+ finally:
+ await s.stop()
From 7828606c41865f7db98e9182f86cf6d7f91f8f31 Mon Sep 17 00:00:00 2001
From: juliasadler <3263905943@qq.com>
Date: Wed, 21 Oct 2026 17:38:07 +0800
Subject: [PATCH 05/10] fix(mcp): answer the CodeRabbit round on the review
fixes: qualify the Review wording, settle the approval note when the turn
closes at the yield, probe health without the token and verify the engine
identity first, keep MCP turns out of the user's sessions, fixed From-MCP-app
titles, null-safe client name
---
changes/443.security.md | 2 +-
docs/API.md | 4 +-
sentient/agent/loop.py | 20 +++---
sentient/cli.py | 12 +++-
sentient/gateway/routes/core.py | 6 ++
sentient/gateway/routes/notifications.py | 7 ++-
sentient/mcp_server/client.py | 13 +++-
sentient/mcp_server/server.py | 14 ++++-
tests/mcp_server/test_client.py | 17 ++++++
tests/mcp_server/test_mcp_server.py | 37 +++++++++++
tests/mcp_server/test_review_fixes.py | 78 ++++++++++++++++++++++++
11 files changed, 191 insertions(+), 19 deletions(-)
diff --git a/changes/443.security.md b/changes/443.security.md
index 286a2a1fa..1599fae4a 100644
--- a/changes/443.security.md
+++ b/changes/443.security.md
@@ -1 +1 @@
-**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and a memory the app states waits in Review instead of being saved as something you said. Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one.
+**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and by default a memory the app states waits in Review instead of being saved as something you said (the one opt-out is the Integrations setting that lets MCP memories skip Review, `integrations.mcp_server_memories_skip_review`). Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one.
diff --git a/docs/API.md b/docs/API.md
index 1bac3ff81..24ead1eb4 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1642,7 +1642,7 @@ transport is deliberately not offered; it may return later, protected by the sam
waits for approval, even when `tasks.require_plan_approval` is off, and every run is marked with the app's
name so anything that sends asks first. `POST /api/chat` from an MCP caller is forced onto
`channel: "mcp"` whatever the body says: the turn is marked outside content from the start, its text
- never counts as the user's words (memories it states wait in Review; sends, code runs and deletes always
+ never counts as the user's words (memories it states wait in Review by default; sends, code runs and deletes always
ask), and an approval it raises also lands as a `kind: "approval"` notification (section 6) the user can
answer in the window or on their phone. The MCP client is outside content and can never answer an approval.
- `mcp` is also in the frozen engine's command list (`packaging/engine_entry.py`), which `tests/test_packaging.py`
@@ -1724,7 +1724,7 @@ dropped and tried again at the next check (`followups.copied_from_them`). A last
Never rule on that tool or its app (`tools.approvals.rules`) still applies: the run fails with the rule's message and
nothing is sent.
- `GET /api/notifications?limit=&unread_only=` → `{notifications: [Notification], unread}`
-- `POST /api/notifications` `{message, title?, source?, app?}` → `Notification`: a plain `info` note in Sentient, for the MCP server's `notify_me` tool below. Always kind `info` with `payload.source` when given; a caller's `kind` is ignored, so an outside caller cannot dress a note up as an approval, a task result or an error. For an MCP caller the title is prefixed with the app's name (`"Claude Code: ..."`, from `app`, or "An MCP app"), and the name is kept as `payload.app`, so a note can never pass itself off as one from Sentient. 400 without a `message`.
+- `POST /api/notifications` `{message, title?, source?, app?}` → `Notification`: a plain `info` note in Sentient, for the MCP server's `notify_me` tool below. Always kind `info` with `payload.source` when given; a caller's `kind` is ignored, so an outside caller cannot dress a note up as an approval, a task result or an error. For an MCP caller the title gets a fixed attribution the caller cannot replace (`"From MCP app (Claude Code): ..."`, the name from `app`, or "An MCP app" when it gives none), and the name is kept as `payload.app`, so a note can never pass itself off as one from Sentient. 400 without a `message`.
- `POST /api/notifications/{id}/read`, `POST /api/notifications/read-all`
- `DELETE /api/notifications/{id}`, `DELETE /api/notifications`
- `POST /api/proactivity/suggestions/{notification_id}` `{action: "approve"|"dismiss"}` → `{ok, task_id?}` (approve creates a task from `action_details` with `original_context.source = "proactive"`; for a follow-up it creates the already approved send task described above; both update the learned per-type threshold, mark the notification read and set `payload.status`/`payload.task_id`). Errors: 400 bad action, 404 not a suggestion, 409 already actioned or expired, 503 tasks unavailable. v2 spellings `approved`/`dismissed` are accepted.
diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py
index c4798b25c..acd9ca9d6 100644
--- a/sentient/agent/loop.py
+++ b/sentient/agent/loop.py
@@ -1318,17 +1318,19 @@ async def _ask(self, p: _CallPlan, ctx: ToolContext, ev: dict) -> AsyncIterator[
)
except Exception:
log.exception("could not post the approval notification")
- yield ApprovalRequest(
- approval_id=approval_id, call_id=p.tc.id, name=p.tc.name, arguments=p.tc.arguments,
- risk=p.risk.name, reason=approval_reason(wording, p.risk),
- risk_label=wording["risk_label"], target=wording["target"],
- untrusted=p.untrusted or None, warning=p.warning or None, choices=offered,
- grant_minutes=self.approvals.config.grant_minutes,
- preview=preview.build(p.tc.name, p.tc.arguments, purchase=purchase, to=p.destinations), **ev,
- )
asked_at = now_iso()
- decision: str = "expired" # if the wait is cancelled (the asker went away), the note settles as expired
+ decision: str = "expired" # if the wait never completes (the asker went away), the note settles as expired
try:
+ # the yield is inside the try: if whoever is consuming this generator closes it while
+ # the request is on screen, the finally below still settles the notification
+ yield ApprovalRequest(
+ approval_id=approval_id, call_id=p.tc.id, name=p.tc.name, arguments=p.tc.arguments,
+ risk=p.risk.name, reason=approval_reason(wording, p.risk),
+ risk_label=wording["risk_label"], target=wording["target"],
+ untrusted=p.untrusted or None, warning=p.warning or None, choices=offered,
+ grant_minutes=self.approvals.config.grant_minutes,
+ preview=preview.build(p.tc.name, p.tc.arguments, purchase=purchase, to=p.destinations), **ev,
+ )
decision = await self.approvals.wait(approval_id, ctx.session_id, p.tc.name, p.risk)
finally:
if note is not None:
diff --git a/sentient/cli.py b/sentient/cli.py
index 9dce50395..a0efbef0f 100644
--- a/sentient/cli.py
+++ b/sentient/cli.py
@@ -102,7 +102,8 @@ def serve(
if p:
# `sentient mcp` finds the engine through this file (issue #443): the desktop starts the
# engine on a fresh port each launch, so the configured port is not where it listens.
- # Port only, no secret; a stale file just points at a port nothing answers on any more.
+ # Port only, no secret; the file is removed again when the engine stops (below), and the
+ # MCP server proves the engine is Sentient before it sends the token anywhere.
import contextlib
import json
@@ -116,6 +117,15 @@ def serve(
"sentient.gateway.app:create_app", factory=True, host=h, port=p, reload=reload,
log_level="warning", ws_ping_interval=20, ws_ping_timeout=60,
)
+ if p:
+ # the engine stopped: take the discovery file away again, so it cannot outlive the
+ # engine and point a future MCP server at a port something else has since taken over
+ import contextlib
+
+ from sentient import paths
+
+ with contextlib.suppress(Exception):
+ paths.discovery_file().unlink(missing_ok=True)
@app.command()
diff --git a/sentient/gateway/routes/core.py b/sentient/gateway/routes/core.py
index 7bb6842b8..9ec87b7a7 100644
--- a/sentient/gateway/routes/core.py
+++ b/sentient/gateway/routes/core.py
@@ -444,6 +444,12 @@ async def chat(request: Request, body: ChatBody):
# an MCP app's words are outside content, never the user's (issue #443): the turn runs on
# the mcp channel whatever the body claims, and the loop marks it from the start
body.channel = "mcp"
+ if body.session_id:
+ # the session must be one of the app's own: naming one of the user's chats would hand
+ # the app that chat's history and let its words into it, so anything else starts fresh
+ session = await s.store.get_session(body.session_id)
+ if session is None or session.get("channel") != "mcp":
+ body.session_id = None
if body.session_id and not body.attachments and not is_mcp and s.agent.steer(body.session_id, body.text):
# a reply is already running for this chat: the text steers it (docs/API.md section 10)
ack = {"type": "steer_ack", "session_id": body.session_id, "queued": True}
diff --git a/sentient/gateway/routes/notifications.py b/sentient/gateway/routes/notifications.py
index f6ccda6dd..eb31c6941 100644
--- a/sentient/gateway/routes/notifications.py
+++ b/sentient/gateway/routes/notifications.py
@@ -32,10 +32,11 @@ async def create_notification(request: Request, body: dict):
source = str(body.get("source") or "").strip()
payload = {"source": source} if source else None
if getattr(request.state, "caller", "gateway") == "mcp":
- # a note from an outside app names the app in its title, so it can never pass itself off
- # as a note from Sentient (issue #443)
+ # a note from an outside app is titled as one, under a fixed attribution the app cannot
+ # replace: naming itself "Sentient" in `app` still lands as "From MCP app (Sentient)"
+ # (issue #443)
app_name = str(body.get("app") or "").strip() or "An MCP app"
- title = f"{app_name}: {title}" if title else app_name
+ title = f"From MCP app ({app_name}): {title}" if title else f"From MCP app ({app_name})"
payload = {**(payload or {}), "app": app_name}
return await get_core(request).notify("info", message, title=title, payload=payload)
diff --git a/sentient/mcp_server/client.py b/sentient/mcp_server/client.py
index 53d2e52e4..149151f80 100644
--- a/sentient/mcp_server/client.py
+++ b/sentient/mcp_server/client.py
@@ -59,6 +59,13 @@ def __init__(self, base_url: str, token: str, *, timeout: float = 30.0):
# whose no_proxy list carries bracketed IPv6 hosts)
trust_env=False,
)
+ # a second client with no credentials, for the readiness probe: the token is only ever
+ # sent after that probe has shown the engine is Sentient (see health())
+ self._bare = httpx.Client(
+ base_url=self.base_url,
+ timeout=httpx.Timeout(timeout, read=timeout),
+ trust_env=False,
+ )
# ------------------------------------------------------------------ plumbing
def _call(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> Any:
@@ -98,9 +105,11 @@ def _call(self, method: str, path: str, *, timeout: float | None = None, **kwarg
return {"text": resp.text}
def health(self) -> dict:
- """``GET /api/health`` (no token needed): is an engine up at this address?"""
+ """``GET /api/health``: is an engine up at this address? Asked WITHOUT the token: the
+ discovery file holds only a port, and whatever answers on a recycled port must prove it
+ is Sentient's engine (``check_ready`` reads this answer) before the token travels."""
try:
- resp = self._http.get("/api/health")
+ resp = self._bare.get("/api/health")
resp.raise_for_status()
return resp.json()
except httpx.HTTPError as exc:
diff --git a/sentient/mcp_server/server.py b/sentient/mcp_server/server.py
index 43aa0d41b..7da2b1000 100644
--- a/sentient/mcp_server/server.py
+++ b/sentient/mcp_server/server.py
@@ -79,6 +79,8 @@ def _client_name(ctx: Any) -> str | None:
params = ctx.request_context.session.client_params
info = getattr(params, "client_info", None) or getattr(params, "clientInfo", None)
name = getattr(info, "name", None) if info else None
+ if name is None: # an explicit null in the handshake is no name, not the app "None"
+ return None
return str(name).strip() or None
except Exception:
return None
@@ -253,7 +255,17 @@ def ask_sentient(message: str, ctx: Context) -> str:
def check_ready(client: EngineClient) -> str | None:
"""None when the server may start, else a plain sentence for the person starting it."""
try:
- client.health()
+ health = client.health() # no token sent: this answer decides whether the token may travel
+ except EngineError as exc:
+ return str(exc)
+ if not health.get("ok") or health.get("name") != "sentient":
+ # the discovery file holds only a port, and ports get recycled: whatever answered there
+ # is not Sentient's engine, and it must never be sent the MCP token
+ return (
+ f"Something else is answering at {client.base_url}, and it is not Sentient's engine, "
+ "so this server will not send it the MCP token. Start Sentient first, then try again."
+ )
+ try:
status = client.status()
except EngineError as exc:
return str(exc)
diff --git a/tests/mcp_server/test_client.py b/tests/mcp_server/test_client.py
index f56fcb35d..e677964fc 100644
--- a/tests/mcp_server/test_client.py
+++ b/tests/mcp_server/test_client.py
@@ -15,6 +15,23 @@ def _client() -> EngineClient:
return EngineClient(BASE, "test-token")
+def test_the_readiness_probe_never_carries_the_token():
+ # the probe answers "is anything there?" before the engine is identified, so it goes out
+ # bare; the token is only for calls after that (status and everything the tools use)
+ with respx.mock() as router:
+ health = router.get(f"{BASE}/api/health").mock(
+ return_value=httpx.Response(200, json={"ok": True, "name": "sentient", "version": "1.0"})
+ )
+ status = router.get(f"{BASE}/api/mcp/status").mock(
+ return_value=httpx.Response(200, json={"enabled": True})
+ )
+ client = _client()
+ assert client.health()["name"] == "sentient"
+ assert "Authorization" not in health.calls.last.request.headers
+ assert client.status()["enabled"] is True
+ assert status.calls.last.request.headers["Authorization"] == "Bearer test-token"
+
+
def test_a_non_local_engine_address_is_refused():
with pytest.raises(EngineError, match="only works on this computer"):
EngineClient("http://192.168.1.20:7777", "test-token")
diff --git a/tests/mcp_server/test_mcp_server.py b/tests/mcp_server/test_mcp_server.py
index c624ea535..f165ca3dd 100644
--- a/tests/mcp_server/test_mcp_server.py
+++ b/tests/mcp_server/test_mcp_server.py
@@ -231,6 +231,43 @@ def test_client_name_comes_from_the_handshake():
assert _client_name(ctx) == "Claude Code"
assert _client_name(SimpleNamespace()) is None
+ def ctx_named(name):
+ return SimpleNamespace(request_context=SimpleNamespace(session=SimpleNamespace(
+ client_params=SimpleNamespace(client_info=SimpleNamespace(name=name)))))
+
+ assert _client_name(ctx_named(None)) is None # an explicit null is not the app "None"
+ assert _client_name(ctx_named(" ")) is None
+ assert _client_name(ctx_named(" Cursor ")) == "Cursor"
+
+
+def test_check_ready_refuses_whatever_is_not_sentient():
+ # the discovery file holds only a port, and ports get recycled: the readiness probe runs
+ # without the token, and only an answer that identifies as Sentient may receive it
+ from sentient.mcp_server.server import check_ready
+
+ class Impostor:
+ base_url = "http://127.0.0.1:7777"
+
+ def health(self):
+ return {"ok": True, "version": "9.9"} # answers, but never says it is Sentient
+
+ def status(self):
+ raise AssertionError("the token must not travel before the engine is identified")
+
+ problem = check_ready(Impostor())
+ assert problem is not None and "not Sentient" in problem
+
+ class Engine:
+ base_url = "http://127.0.0.1:7777"
+
+ def health(self):
+ return {"ok": True, "name": "sentient"}
+
+ def status(self):
+ return {"enabled": True}
+
+ assert check_ready(Engine()) is None
+
async def test_an_engine_error_at_the_gate_is_reported_not_masked_as_turned_off():
engine = FakeEngine()
diff --git a/tests/mcp_server/test_review_fixes.py b/tests/mcp_server/test_review_fixes.py
index 32cf384f6..7573f0154 100644
--- a/tests/mcp_server/test_review_fixes.py
+++ b/tests/mcp_server/test_review_fixes.py
@@ -333,3 +333,81 @@ async def drive():
assert ran == []
finally:
await s.stop()
+
+
+def _first_session_line(response_text: str) -> str:
+ import json as _json
+
+ for line in response_text.splitlines():
+ row = _json.loads(line)
+ if row.get("type") == "session":
+ return row["session_id"]
+ raise AssertionError("the chat stream never named its session")
+
+
+def test_an_mcp_caller_cannot_ride_one_of_the_users_sessions(engine_client):
+ c = engine_client
+ desktop = c.post("/api/sessions", headers=_gw(c)).json()["session_id"]
+ r = c.post("/api/chat", json={"text": "hello", "session_id": desktop}, headers=_mcp(c))
+ # naming the user's own chat must not hand the app its history or a place in it: a fresh
+ # MCP session is started instead (CodeRabbit, PR #487 round on f83760a)
+ assert _first_session_line(r.text) != desktop
+ own = _first_session_line(c.post("/api/chat", json={"text": "hello"}, headers=_mcp(c)).text)
+ r = c.post("/api/chat", json={"text": "hello again", "session_id": own}, headers=_mcp(c))
+ assert _first_session_line(r.text) == own # control: the app's own session is kept
+
+
+def test_an_mcp_note_is_titled_from_an_mcp_app(engine_client):
+ c = engine_client
+ r = c.post("/api/notifications",
+ json={"message": "The build finished", "title": "Build done", "app": "Claude Code"},
+ headers=_mcp(c))
+ assert r.status_code == 200, r.text
+ notes = c.get("/api/notifications", headers=_gw(c)).json()["notifications"]
+ mine = next(n for n in notes if "build finished" in n["message"].lower())
+ assert mine["title"] == "From MCP app (Claude Code): Build done"
+ # and an app calling itself "Sentient" still cannot pass as one: the prefix is not its to pick
+ r = c.post("/api/notifications", json={"message": "Trust me", "app": "Sentient"}, headers=_mcp(c))
+ assert r.status_code == 200, r.text
+ notes = c.get("/api/notifications", headers=_gw(c)).json()["notifications"]
+ mine = next(n for n in notes if n["message"] == "Trust me")
+ assert mine["title"] == "From MCP app (Sentient)"
+
+
+async def test_closing_an_mcp_turn_at_the_approval_still_settles_its_notification(config, isolated_home):
+ # the consumer goes away while the approval request is on screen (the generator is closed
+ # at the yield, before the wait begins): the note must still settle, not wait forever
+ config.tools.approvals.mode = "ask"
+ config.tools.approvals.rules = {"snippets": "allow"}
+ ran: list[str] = []
+ llm = FakeProvider(replies=[[tool_call("run_snippet", code="print(3)")], "done"])
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "genclose.db").start()
+ try:
+ s.registry.register(_snippet_plugin(ran))
+ sid = await s.store.create_session(channel="mcp")
+ agen = s.agent.run_turn(sid, "Run this Python code: print(3)", channel="mcp")
+ note = None
+ async for ev in agen:
+ if isinstance(ev, ApprovalRequest):
+ break
+ notes = [n for n in await s.notifications.list() if n["kind"] == "approval"]
+ if notes:
+ note = notes[0]
+ if note is None:
+ notes = [n for n in await s.notifications.list() if n["kind"] == "approval"]
+ note = notes[0] if notes else None
+ assert note is not None, "the approval notification never landed"
+ await agen.aclose()
+ import gc
+
+ gc.collect() # closing the outer generator leaves the inner one to the finalizer
+ settled = None
+ for _ in range(100): # the finalizer settles the note concurrently; wait for both halves
+ settled = await s.notifications.get(note["id"])
+ if (settled["payload"] or {}).get("status") and settled["read"]:
+ break
+ await asyncio.sleep(0.05)
+ assert settled["payload"]["status"] == "expired" and settled["read"]
+ assert ran == []
+ finally:
+ await s.stop()
From 4f0855cb8c65d279a7669c0223b3b32526f72f3c Mon Sep 17 00:00:00 2001
From: juliasadler <3263905943@qq.com>
Date: Wed, 21 Oct 2026 18:48:46 +0800
Subject: [PATCH 06/10] fix(mcp): prove the engine's identity by nonce
challenge before the token travels: GET /api/mcp/proof answers an HMAC of the
caller's nonce under the MCP token, so a listener that forged the health
marker can no longer harvest the token through status()
---
docs/API.md | 5 +++-
...serve-sentient-to-other-agents-over-mcp.md | 6 +++-
sentient/gateway/routes/mcp.py | 19 ++++++++++++
sentient/mcp_server/client.py | 28 +++++++++++++++++-
sentient/mcp_server/server.py | 9 ++++++
tests/mcp_server/test_client.py | 27 +++++++++++++++++
tests/mcp_server/test_mcp_server.py | 29 +++++++++++++++++--
tests/mcp_server/test_review_fixes.py | 26 +++++++++++++++++
8 files changed, 143 insertions(+), 6 deletions(-)
diff --git a/docs/API.md b/docs/API.md
index 24ead1eb4..5c1d21130 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1620,7 +1620,10 @@ transport is deliberately not offered; it may return later, protected by the sam
switch off stops direct API callers too, not just the server process. The gateway token still passes
everywhere; `sentient mcp` never reads or writes it.
- **Endpoints of its own.** `GET /api/mcp/status` (either token) → `{enabled, memories_skip_review,
- token_issued}`: what an MCP app may do right now, without reading the config. `POST /api/mcp/token/rotate`
+ token_issued}`: what an MCP app may do right now, without reading the config. `GET /api/mcp/proof?nonce=...`
+ (no credentials) → `{proof}`: the nonce's HMAC-SHA256 under the MCP token, how `sentient mcp` verifies
+ that a discovered port is really the engine before it sends the token there; 404 when no token has
+ been issued. `POST /api/mcp/token/rotate`
(gateway token only) → `{rotated: true}`: replaces the MCP-only token in the keychain; apps holding the
old one stop working at once and pick the new one up when they restart. It is also issued from
`SentientApp.save_config` whenever `integrations.mcp_server_enabled` is saved on and none exists.
diff --git a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
index 84b8e4c61..3c68368c2 100644
--- a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
+++ b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
@@ -35,7 +35,11 @@ it was waiting on.
cannot bypass it. It only talks to a loopback address and refuses any other at startup. It
finds the engine through a discovery file the engine writes at startup (`engine-discovery.json`,
the port only): the desktop starts the engine on a fresh port every launch, so the configured
- port is not where it listens.
+ port is not where it listens. Because the file holds only a port, the server proves the
+ engine's identity before it sends the token anywhere: the health probe goes out without
+ credentials, and the engine must then answer a nonce challenge (`GET /api/mcp/proof`, the
+ nonce's HMAC under the MCP token), which a process that merely copied the health marker
+ cannot forge.
- **Its own token, with permissions only for these tools.** When the user turns the feature on,
the engine issues an MCP-only token and keeps it in the keychain (`sentient.secrets`, name
`mcp-server-token`). `sentient mcp` reads it from there itself; no client settings file ever
diff --git a/sentient/gateway/routes/mcp.py b/sentient/gateway/routes/mcp.py
index 7720068a5..b42f8913b 100644
--- a/sentient/gateway/routes/mcp.py
+++ b/sentient/gateway/routes/mcp.py
@@ -3,6 +3,9 @@
from __future__ import annotations
+import hashlib
+import hmac
+
from fastapi import HTTPException, Request
from fastapi.routing import APIRouter
@@ -12,6 +15,22 @@
router = APIRouter(prefix="/api/mcp", tags=["mcp"])
+@router.get("/proof") # no credentials on purpose: this runs before the caller sends any
+async def proof(nonce: str = ""):
+ """How the MCP server knows it found the real engine before it sends the token (issue #443).
+
+ The caller picks a fresh nonce; the answer is that nonce's HMAC under the MCP token, which
+ only a process holding the token (this engine, through the keychain) can produce. A health
+ answer can be copied by anything that took over the discovery file's port, so the marker
+ alone is never trusted. The token itself never travels, and without an issued token there
+ is nothing to prove with, so the route simply does not exist then."""
+ current = mcp_token.current_token()
+ if current is None or not 16 <= len(nonce) <= 128:
+ raise HTTPException(404, "not found")
+ digest = hmac.new(current.encode("utf-8"), nonce.encode("utf-8"), hashlib.sha256).hexdigest()
+ return {"proof": digest}
+
+
@router.get("/status", dependencies=MCP_OK)
async def status(request: Request):
"""What an MCP app may do right now: whether the feature is on, whether its memories skip
diff --git a/sentient/mcp_server/client.py b/sentient/mcp_server/client.py
index 149151f80..58f8cbf54 100644
--- a/sentient/mcp_server/client.py
+++ b/sentient/mcp_server/client.py
@@ -10,7 +10,10 @@
from __future__ import annotations
+import hashlib
+import hmac
import json
+import secrets
from typing import Any
from urllib.parse import urlparse
@@ -50,6 +53,8 @@ def __init__(self, base_url: str, token: str, *, timeout: float = 30.0):
"The MCP server only works on this computer."
)
self.base_url = base_url.rstrip("/")
+ self._token = token # kept for the identity proof below; it is only ever sent to an
+ # engine that has already proven it holds the same one
self._http = httpx.Client(
base_url=self.base_url,
headers={"Authorization": f"Bearer {token}"},
@@ -107,7 +112,7 @@ def _call(self, method: str, path: str, *, timeout: float | None = None, **kwarg
def health(self) -> dict:
"""``GET /api/health``: is an engine up at this address? Asked WITHOUT the token: the
discovery file holds only a port, and whatever answers on a recycled port must prove it
- is Sentient's engine (``check_ready`` reads this answer) before the token travels."""
+ is Sentient's engine (see ``prove_identity``) before the token travels."""
try:
resp = self._bare.get("/api/health")
resp.raise_for_status()
@@ -117,6 +122,27 @@ def health(self) -> dict:
f"No Sentient engine is running at {self.base_url}. Start Sentient first, then try again."
) from exc
+ def prove_identity(self) -> bool:
+ """True only when whatever answers at this address holds the same MCP token.
+
+ The health answer (``"name": "sentient"``) is a public marker anything listening on the
+ port can copy, so before the token is sent anywhere the engine must answer a challenge
+ it cannot forge: a fresh nonce goes out bare, and the reply must be that nonce's HMAC
+ under the token (``GET /api/mcp/proof``). The token itself never travels."""
+ nonce = secrets.token_hex(16)
+ try:
+ resp = self._bare.get("/api/mcp/proof", params={"nonce": nonce})
+ resp.raise_for_status()
+ proof = resp.json().get("proof")
+ except Exception:
+ return False # unreachable, no proof route, a non-JSON answer: not proven
+ if not isinstance(proof, str):
+ return False
+ expected = hmac.new(
+ self._token.encode("utf-8"), nonce.encode("utf-8"), hashlib.sha256
+ ).hexdigest()
+ return hmac.compare_digest(proof, expected)
+
def status(self) -> dict:
"""``GET /api/mcp/status``: whether the feature is on and what its memories do, readable
with the MCP-only token (the full config is not, on purpose)."""
diff --git a/sentient/mcp_server/server.py b/sentient/mcp_server/server.py
index 7da2b1000..472e3035a 100644
--- a/sentient/mcp_server/server.py
+++ b/sentient/mcp_server/server.py
@@ -265,6 +265,15 @@ def check_ready(client: EngineClient) -> str | None:
f"Something else is answering at {client.base_url}, and it is not Sentient's engine, "
"so this server will not send it the MCP token. Start Sentient first, then try again."
)
+ if not client.prove_identity():
+ # the health marker is public and can be copied by anything listening on the port; only
+ # a process holding the MCP token can answer the challenge, and the token is sent to
+ # no one who has not answered it
+ return (
+ f"Whatever is answering at {client.base_url} says it is Sentient but cannot prove it "
+ "holds the MCP token, so this server will not send the token there. Start Sentient "
+ "first, then try again."
+ )
try:
status = client.status()
except EngineError as exc:
diff --git a/tests/mcp_server/test_client.py b/tests/mcp_server/test_client.py
index e677964fc..28aa64e7d 100644
--- a/tests/mcp_server/test_client.py
+++ b/tests/mcp_server/test_client.py
@@ -32,6 +32,33 @@ def test_the_readiness_probe_never_carries_the_token():
assert status.calls.last.request.headers["Authorization"] == "Bearer test-token"
+def test_identity_is_proven_by_challenge_not_by_the_health_marker():
+ # a listener on the discovered port can copy the health answer word for word; only the
+ # holder of the token can answer a fresh nonce with its HMAC under that token
+ import hashlib
+ import hmac as hmac_mod
+
+ def answer_challenge(request: httpx.Request) -> httpx.Response:
+ nonce = request.url.params["nonce"]
+ digest = hmac_mod.new(b"test-token", nonce.encode(), hashlib.sha256).hexdigest()
+ return httpx.Response(200, json={"proof": digest})
+
+ with respx.mock() as router:
+ proof = router.get(f"{BASE}/api/mcp/proof").mock(side_effect=answer_challenge)
+ assert _client().prove_identity() is True
+ assert "Authorization" not in proof.calls.last.request.headers # the challenge goes out bare
+
+ with respx.mock() as router:
+ router.get(f"{BASE}/api/mcp/proof").mock(
+ return_value=httpx.Response(200, json={"proof": "0" * 64})
+ )
+ assert _client().prove_identity() is False # a made-up proof is not the token holder
+
+ with respx.mock() as router:
+ router.get(f"{BASE}/api/mcp/proof").mock(return_value=httpx.Response(404))
+ assert _client().prove_identity() is False # no token issued: nothing to prove with
+
+
def test_a_non_local_engine_address_is_refused():
with pytest.raises(EngineError, match="only works on this computer"):
EngineClient("http://192.168.1.20:7777", "test-token")
diff --git a/tests/mcp_server/test_mcp_server.py b/tests/mcp_server/test_mcp_server.py
index f165ca3dd..bb95d6a46 100644
--- a/tests/mcp_server/test_mcp_server.py
+++ b/tests/mcp_server/test_mcp_server.py
@@ -242,27 +242,50 @@ def ctx_named(name):
def test_check_ready_refuses_whatever_is_not_sentient():
# the discovery file holds only a port, and ports get recycled: the readiness probe runs
- # without the token, and only an answer that identifies as Sentient may receive it
+ # without the token, and only an engine that proves it holds the token may receive it
from sentient.mcp_server.server import check_ready
- class Impostor:
+ class Stranger:
base_url = "http://127.0.0.1:7777"
def health(self):
return {"ok": True, "version": "9.9"} # answers, but never says it is Sentient
+ def prove_identity(self):
+ raise AssertionError("no proof is asked of something that is not even Sentient")
+
def status(self):
raise AssertionError("the token must not travel before the engine is identified")
- problem = check_ready(Impostor())
+ problem = check_ready(Stranger())
assert problem is not None and "not Sentient" in problem
+ class Impostor:
+ # the regression that matters: the health marker is public, so a listener can return
+ # the exact expected answer; without the token it still cannot answer the challenge
+ base_url = "http://127.0.0.1:7777"
+
+ def health(self):
+ return {"ok": True, "name": "sentient", "version": "9.9"}
+
+ def prove_identity(self):
+ return False
+
+ def status(self):
+ raise AssertionError("the token must not travel to an unproven engine")
+
+ problem = check_ready(Impostor())
+ assert problem is not None and "cannot prove" in problem
+
class Engine:
base_url = "http://127.0.0.1:7777"
def health(self):
return {"ok": True, "name": "sentient"}
+ def prove_identity(self):
+ return True
+
def status(self):
return {"enabled": True}
diff --git a/tests/mcp_server/test_review_fixes.py b/tests/mcp_server/test_review_fixes.py
index 7573f0154..e7c3c8b20 100644
--- a/tests/mcp_server/test_review_fixes.py
+++ b/tests/mcp_server/test_review_fixes.py
@@ -411,3 +411,29 @@ async def test_closing_an_mcp_turn_at_the_approval_still_settles_its_notificatio
assert ran == []
finally:
await s.stop()
+
+
+def test_the_engine_proves_itself_by_challenge_before_any_token_travels(engine_client):
+ # GET /api/mcp/proof answers a caller's fresh nonce with its HMAC under the MCP token,
+ # with no credentials presented: only the real engine can produce it, and the token
+ # itself is never in the answer (CodeRabbit, PR #487 round on 7828606)
+ import hashlib
+ import hmac as hmac_mod
+
+ c = engine_client
+ nonce = "ab" * 16
+ r = c.get("/api/mcp/proof", params={"nonce": nonce}) # no Authorization header at all
+ assert r.status_code == 200, r.text
+ expected = hmac_mod.new(MCP_TOKEN.encode(), nonce.encode(), hashlib.sha256).hexdigest()
+ assert r.json()["proof"] == expected
+ assert MCP_TOKEN not in r.text
+ assert c.get("/api/mcp/proof", params={"nonce": "short"}).status_code == 404
+
+
+def test_there_is_no_proof_without_an_issued_token(config, isolated_home, keychain):
+ # the keychain fixture is left empty here: no token was ever issued, so the proof
+ # route does not exist (there would be nothing to prove with)
+ app = create_app(SentientApp(config, llm=FakeProvider(json_replies=[dict(PLAN)]),
+ db_path=isolated_home / "noproof.db", enable_background=False))
+ with TestClient(app) as c:
+ assert c.get("/api/mcp/proof", params={"nonce": "ab" * 16}).status_code == 404
From 4d037291cab7a8d180a5e35b7e288221ee3c69a0 Mon Sep 17 00:00:00 2001
From: juliasadler <3263905943@qq.com>
Date: Thu, 22 Oct 2026 15:16:20 +0800
Subject: [PATCH 07/10] fix(mcp): bind the identity proof to the port and
re-prove before every call: GET /api/mcp/proof now signs nonce:port so a
proof relayed from the real engine fails at a squatter's port, and the gate
re-proves (following the discovery file when the engine moved) before the
token travels. Also: an MCP caller's model, attachments and incognito are
ignored, its text starts no rule check, approval and plan-ready notifications
name the app, and ask_sentient notes when a memory from the turn waits in
Review
---
changes/443.security.md | 2 +-
docs/API.md | 15 ++--
...serve-sentient-to-other-agents-over-mcp.md | 9 ++-
sentient/agent/loop.py | 12 ++-
sentient/gateway/routes/core.py | 6 ++
sentient/gateway/routes/mcp.py | 22 ++++--
sentient/mcp_server/client.py | 64 +++++++++++++---
sentient/mcp_server/server.py | 28 ++++++-
sentient/tasks/service.py | 7 +-
tests/mcp_server/test_client.py | 73 +++++++++++++++++-
tests/mcp_server/test_mcp_server.py | 35 ++++++++-
tests/mcp_server/test_review_fixes.py | 76 ++++++++++++++++++-
12 files changed, 308 insertions(+), 41 deletions(-)
diff --git a/changes/443.security.md b/changes/443.security.md
index 1599fae4a..30186e7f6 100644
--- a/changes/443.security.md
+++ b/changes/443.security.md
@@ -1 +1 @@
-**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and by default a memory the app states waits in Review instead of being saved as something you said (the one opt-out is the Integrations setting that lets MCP memories skip Review, `integrations.mcp_server_memories_skip_review`). Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one.
+**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and by default a memory the app states waits in Review instead of being saved as something you said (the one opt-out is the Integrations setting that lets MCP memories skip Review, `integrations.mcp_server_memories_skip_review`). Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one. Before that key is sent anywhere, the server makes whatever answers on the engine's port prove it holds the same key, with the proof tied to that port so it cannot be relayed from the real engine, and it checks again before every call, following the engine when it restarts on a new port. An app also cannot shape its turns: files, a model of its choice and the incognito switch in its requests are ignored.
diff --git a/docs/API.md b/docs/API.md
index 5c1d21130..c2e9d84dc 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1621,9 +1621,12 @@ transport is deliberately not offered; it may return later, protected by the sam
everywhere; `sentient mcp` never reads or writes it.
- **Endpoints of its own.** `GET /api/mcp/status` (either token) → `{enabled, memories_skip_review,
token_issued}`: what an MCP app may do right now, without reading the config. `GET /api/mcp/proof?nonce=...`
- (no credentials) → `{proof}`: the nonce's HMAC-SHA256 under the MCP token, how `sentient mcp` verifies
- that a discovered port is really the engine before it sends the token there; 404 when no token has
- been issued. `POST /api/mcp/token/rotate`
+ (no credentials) → `{proof}`: the HMAC-SHA256 of `nonce:port` under the MCP token, the port being the
+ one the request arrived on, how `sentient mcp` verifies that a discovered port is really the engine
+ before it sends the token there. Keying the proof to the port stops a relay (a squatter on the
+ engine's old port forwarding the challenge gets a proof keyed to the real engine's port, which
+ fails the check), and the server re-proves before every call, following the discovery file when
+ the engine has moved ports; 404 when no token has been issued. `POST /api/mcp/token/rotate`
(gateway token only) → `{rotated: true}`: replaces the MCP-only token in the keychain; apps holding the
old one stop working at once and pick the new one up when they restart. It is also issued from
`SentientApp.save_config` whenever `integrations.mcp_server_enabled` is saved on and none exists.
@@ -1646,8 +1649,10 @@ transport is deliberately not offered; it may return later, protected by the sam
name so anything that sends asks first. `POST /api/chat` from an MCP caller is forced onto
`channel: "mcp"` whatever the body says: the turn is marked outside content from the start, its text
never counts as the user's words (memories it states wait in Review by default; sends, code runs and deletes always
- ask), and an approval it raises also lands as a `kind: "approval"` notification (section 6) the user can
- answer in the window or on their phone. The MCP client is outside content and can never answer an approval.
+ ask; its text never proposes standing rules), an approval it raises also lands as a `kind: "approval"`
+ notification (section 6) the user can answer in the window or on their phone, and the body's `model`,
+ `attachments` and `incognito` are ignored, so the app cannot pull files into the turn, pick a model or
+ flip the user's incognito switch. The MCP client is outside content and can never answer an approval.
- `mcp` is also in the frozen engine's command list (`packaging/engine_entry.py`), which `tests/test_packaging.py`
keeps equal to the CLI's commands.
diff --git a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
index 3c68368c2..7232cb9e8 100644
--- a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
+++ b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
@@ -38,8 +38,13 @@ it was waiting on.
port is not where it listens. Because the file holds only a port, the server proves the
engine's identity before it sends the token anywhere: the health probe goes out without
credentials, and the engine must then answer a nonce challenge (`GET /api/mcp/proof`, the
- nonce's HMAC under the MCP token), which a process that merely copied the health marker
- cannot forge.
+ HMAC of `nonce:port` under the MCP token), which a process that merely copied the health
+ marker cannot forge. Keying the proof to the port the request arrived on stops a relay: a
+ squatter on the engine's old port can forward the challenge to the real engine, but the
+ proof it gets back is keyed to the real engine's port and fails the check. The proof is not
+ a startup-only step: the server re-reads the discovery file and proves again before every
+ call, so a running server follows the engine to its new port after a restart instead of
+ sending the token to whatever took over the old one.
- **Its own token, with permissions only for these tools.** When the user turns the feature on,
the engine issues an MCP-only token and keeps it in the keychain (`sentient.secrets`, name
`mcp-server-token`). `sentient mcp` reads it from there itself; no client settings file ever
diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py
index acd9ca9d6..9f0ef59fe 100644
--- a/sentient/agent/loop.py
+++ b/sentient/agent/loop.py
@@ -1306,9 +1306,13 @@ async def _ask(self, p: _CallPlan, ctx: ToolContext, ev: dict) -> AsyncIterator[
# the answer reaches this same approval_id through the broker, whichever surface sends it
label = wording.get("risk_label") or "Changes something"
target = wording.get("target")
+ # the note names the app that is asking (the turn's mark on this channel), so the
+ # user sees whose request they are answering: "Claude Code wants to: Runs code"
+ who = ctx.untrusted or "an MCP app"
+ message = f"{who} wants to: {label}: {target}" if target else f"{who} wants to: {label}"
try:
note = await self.app.notify(
- "approval", f"{label}: {target}" if target else label, title="Approval needed",
+ "approval", message, title="Approval needed",
payload={
"approval_id": approval_id, "name": p.tc.name, "risk": p.risk.name,
"arguments": p.tc.arguments, "session_id": ctx.session_id, "channel": "mcp",
@@ -1762,7 +1766,9 @@ def release() -> list[str]:
# a chat message on any of the user's surfaces means the user is around (#163); an MCP
# app's activity says nothing about the user, so it never resets the away timer (#443)
await self.app.user_seen(channel)
- rule_check = self._start_rule_check(session_id, user_message_id, user_text)
+ # an MCP turn's text is the app's, not the user's: it never proposes standing rules
+ # from it (#443), the way quick commands are skipped here
+ rule_check = None if channel == "mcp" else self._start_rule_check(session_id, user_message_id, user_text)
await self.store.touch_session(session_id, title=(user_text or (attachments[0] if attachments else ""))[:60])
session = await self.store.get_session(session_id)
@@ -1845,7 +1851,7 @@ def release() -> list[str]:
async def persist(role_: str, content: str | None, **fields: Any) -> None:
mid = await self.store.add_message(session_id, role_, content, **fields)
if role_ == "user" and content: # a steer message can be a standing instruction too (#130)
- check = self._start_rule_check(session_id, mid, content)
+ check = None if channel == "mcp" else self._start_rule_check(session_id, mid, content)
if check is not None:
await asyncio.wait({check}, timeout=RULE_CHECK_WAIT_S)
diff --git a/sentient/gateway/routes/core.py b/sentient/gateway/routes/core.py
index 9ec87b7a7..a32985801 100644
--- a/sentient/gateway/routes/core.py
+++ b/sentient/gateway/routes/core.py
@@ -444,6 +444,12 @@ async def chat(request: Request, body: ChatBody):
# an MCP app's words are outside content, never the user's (issue #443): the turn runs on
# the mcp channel whatever the body claims, and the loop marks it from the start
body.channel = "mcp"
+ # the app does not shape the turn either: attachments would pull files from the files
+ # folder into it, a named model could pick a cloud model where the user keeps chats
+ # local, and incognito is the user's own switch to flip, so all three are ignored
+ body.model = None
+ body.attachments = []
+ body.incognito = False
if body.session_id:
# the session must be one of the app's own: naming one of the user's chats would hand
# the app that chat's history and let its words into it, so anything else starts fresh
diff --git a/sentient/gateway/routes/mcp.py b/sentient/gateway/routes/mcp.py
index b42f8913b..6aed59f66 100644
--- a/sentient/gateway/routes/mcp.py
+++ b/sentient/gateway/routes/mcp.py
@@ -16,18 +16,26 @@
@router.get("/proof") # no credentials on purpose: this runs before the caller sends any
-async def proof(nonce: str = ""):
+async def proof(request: Request, nonce: str = ""):
"""How the MCP server knows it found the real engine before it sends the token (issue #443).
- The caller picks a fresh nonce; the answer is that nonce's HMAC under the MCP token, which
- only a process holding the token (this engine, through the keychain) can produce. A health
- answer can be copied by anything that took over the discovery file's port, so the marker
- alone is never trusted. The token itself never travels, and without an issued token there
- is nothing to prove with, so the route simply does not exist then."""
+ The caller picks a fresh nonce; the answer is the HMAC of that nonce and the port this
+ request arrived on, under the MCP token, which only a process holding the token (this
+ engine, through the keychain) can produce. A health answer can be copied by anything that
+ took over the discovery file's port, so the marker alone is never trusted. Binding the port
+ in stops a relay: a process squatting on the engine's old port can forward the challenge to
+ the real engine on its new port, but the proof it gets back is keyed to that port, not to
+ its own, so the caller's check fails and the token never travels. The token itself never
+ travels either, and without an issued token there is nothing to prove with, so the route
+ simply does not exist then."""
current = mcp_token.current_token()
if current is None or not 16 <= len(nonce) <= 128:
raise HTTPException(404, "not found")
- digest = hmac.new(current.encode("utf-8"), nonce.encode("utf-8"), hashlib.sha256).hexdigest()
+ server = request.scope.get("server") or (None, None)
+ if server[1] is None:
+ raise HTTPException(404, "not found")
+ signed = f"{nonce}:{server[1]}".encode()
+ digest = hmac.new(current.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return {"proof": digest}
diff --git a/sentient/mcp_server/client.py b/sentient/mcp_server/client.py
index 58f8cbf54..c0706afe7 100644
--- a/sentient/mcp_server/client.py
+++ b/sentient/mcp_server/client.py
@@ -45,20 +45,28 @@ def discovered_engine_url() -> str:
class EngineClient:
"""Synchronous REST calls to the engine. One instance per MCP server process."""
- def __init__(self, base_url: str, token: str, *, timeout: float = 30.0):
+ def __init__(self, base_url: str, token: str, *, timeout: float = 30.0, follow_discovery: bool = False):
parsed = urlparse(base_url)
if parsed.hostname not in LOOPBACK_HOSTS:
raise EngineError(
f"Refusing to talk to a non-local engine address ({base_url!r}). "
"The MCP server only works on this computer."
)
- self.base_url = base_url.rstrip("/")
self._token = token # kept for the identity proof below; it is only ever sent to an
# engine that has already proven it holds the same one
+ self._timeout = timeout
+ # a server that found the engine through the discovery file follows it when the engine
+ # moves to a new port (see refresh_address); one started with an explicit address keeps it
+ self._follow = follow_discovery
+ self._connect(base_url)
+
+ def _connect(self, base_url: str) -> None:
+ """Point both HTTP clients at an engine address."""
+ self.base_url = base_url.rstrip("/")
self._http = httpx.Client(
base_url=self.base_url,
- headers={"Authorization": f"Bearer {token}"},
- timeout=httpx.Timeout(timeout, read=timeout),
+ headers={"Authorization": f"Bearer {self._token}"},
+ timeout=httpx.Timeout(self._timeout, read=self._timeout),
# never through a proxy from the environment: the engine is on this computer, and its
# token must not travel through anything (a proxy setting also breaks loopback URLs
# whose no_proxy list carries bracketed IPv6 hosts)
@@ -68,10 +76,27 @@ def __init__(self, base_url: str, token: str, *, timeout: float = 30.0):
# sent after that probe has shown the engine is Sentient (see health())
self._bare = httpx.Client(
base_url=self.base_url,
- timeout=httpx.Timeout(timeout, read=timeout),
+ timeout=httpx.Timeout(self._timeout, read=self._timeout),
trust_env=False,
)
+ def refresh_address(self) -> None:
+ """Follow the engine to its new port, when this server found it through the discovery file.
+
+ The desktop starts the engine on a fresh port every launch, and force-closing the app
+ can leave the old discovery file behind, so a long-running server would otherwise keep
+ calling a recycled address where anything could now be listening. A missing file keeps
+ the last known address: the identity proof before every call decides whether what
+ answers there may be sent the token."""
+ if not self._follow:
+ return
+ try:
+ found = discovered_engine_url()
+ except EngineError:
+ return
+ if found != self.base_url:
+ self._connect(found)
+
# ------------------------------------------------------------------ plumbing
def _call(self, method: str, path: str, *, timeout: float | None = None, **kwargs: Any) -> Any:
if timeout is not None:
@@ -127,8 +152,11 @@ def prove_identity(self) -> bool:
The health answer (``"name": "sentient"``) is a public marker anything listening on the
port can copy, so before the token is sent anywhere the engine must answer a challenge
- it cannot forge: a fresh nonce goes out bare, and the reply must be that nonce's HMAC
- under the token (``GET /api/mcp/proof``). The token itself never travels."""
+ it cannot forge: a fresh nonce goes out bare, and the reply must be the HMAC of that
+ nonce and this address's port under the token (``GET /api/mcp/proof``). The port binds
+ the proof to this address: a process squatting here can relay the challenge to the real
+ engine on another port, but the proof it gets back is keyed to that port and fails here.
+ The token itself never travels."""
nonce = secrets.token_hex(16)
try:
resp = self._bare.get("/api/mcp/proof", params={"nonce": nonce})
@@ -138,8 +166,10 @@ def prove_identity(self) -> bool:
return False # unreachable, no proof route, a non-JSON answer: not proven
if not isinstance(proof, str):
return False
+ parsed = urlparse(self.base_url)
+ port = parsed.port or (443 if parsed.scheme == "https" else 80)
expected = hmac.new(
- self._token.encode("utf-8"), nonce.encode("utf-8"), hashlib.sha256
+ self._token.encode("utf-8"), f"{nonce}:{port}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(proof, expected)
@@ -208,10 +238,13 @@ def chat_turn(self, text: str, *, timeout: float = 180.0, app: str | None = None
the engine: its tools run under Sentient's approvals and rules, and a call that has to ask
appears in the Sentient window and as an approval notification the user can answer there
or on their phone. Returns ``{content, session_id}`` (plus ``approval_asked`` when a
- question went to the user, and ``error`` when the turn failed or is still waiting)."""
+ question went to the user, ``memory_held`` when a memory the turn saved is waiting in
+ the user's Review list instead of being saved, and ``error`` when the turn failed or is
+ still waiting)."""
session_id: str | None = None
content = ""
approval_asked = False
+ memory_held = False
try:
body: dict[str, Any] = {"text": text, "channel": "mcp"}
if app:
@@ -234,17 +267,24 @@ def chat_turn(self, text: str, *, timeout: float = 180.0, app: str | None = None
session_id = event["session_id"]
if event.get("type") == "approval_request":
approval_asked = True
+ elif event.get("type") == "tool_result" and event.get("name") == "memory_remember":
+ result = event.get("result")
+ if isinstance(result, dict) and result.get("status") == "pending":
+ # the fact waits in Review; the reply may still claim it was noted
+ memory_held = True
elif event.get("type") == "done":
content = str(event.get("content") or "")
if event.get("error"):
return {"content": content, "error": str(event["error"]),
- "session_id": session_id, "approval_asked": approval_asked}
+ "session_id": session_id, "approval_asked": approval_asked,
+ "memory_held": memory_held}
elif event.get("type") == "error":
return {
"content": content,
"error": str(event.get("message") or "the chat turn failed"),
"session_id": session_id,
"approval_asked": approval_asked,
+ "memory_held": memory_held,
}
except httpx.TimeoutException:
return {
@@ -255,7 +295,9 @@ def chat_turn(self, text: str, *, timeout: float = 180.0, app: str | None = None
),
"session_id": session_id,
"approval_asked": approval_asked,
+ "memory_held": memory_held,
}
except httpx.HTTPError as exc:
raise EngineError(f"The chat turn broke off: {exc}") from exc
- return {"content": content, "session_id": session_id, "approval_asked": approval_asked}
+ return {"content": content, "session_id": session_id, "approval_asked": approval_asked,
+ "memory_held": memory_held}
diff --git a/sentient/mcp_server/server.py b/sentient/mcp_server/server.py
index 472e3035a..23c1f8af7 100644
--- a/sentient/mcp_server/server.py
+++ b/sentient/mcp_server/server.py
@@ -93,7 +93,19 @@ def __init__(self, client: EngineClient):
self.client = client
def check(self) -> dict | None:
- """The engine's MCP status when the server may run, else None (turned off)."""
+ """The engine's MCP status when the server may run, else None (turned off).
+
+ Before the status call carries the token anywhere, the client follows the discovery
+ file if the engine moved ports, and the engine proves its identity again: the proof
+ at startup alone is not enough, because the engine restarts on a fresh port and
+ whatever takes over the old one would otherwise be sent the token on the next call."""
+ self.client.refresh_address()
+ if not self.client.prove_identity():
+ raise EngineError(
+ f"The engine answering at {self.client.base_url} could not prove it holds the MCP "
+ "token, so the token was not sent to it. Sentient may have restarted, or its MCP "
+ "token may have been replaced in the app; restart this MCP server to reconnect."
+ )
status = self.client.status()
return status if status.get("enabled") else None
@@ -245,8 +257,17 @@ def ask_sentient(message: str, ctx: Context) -> str:
if turn.get("error"):
return _json({"reply": turn.get("content") or "", "note": turn["error"]})
out = {"reply": turn.get("content") or ""}
+ notes = []
if turn.get("approval_asked"):
- out["note"] = "Sentient asked the user to approve a step in this turn before it ran."
+ notes.append("Sentient asked the user to approve a step in this turn before it ran.")
+ if turn.get("memory_held"):
+ # the reply may still say "noted": the fact is not saved until the user approves it
+ notes.append(
+ "A memory from this exchange is waiting in the user's Review list in Sentient; "
+ "it is only saved if they approve it there."
+ )
+ if notes:
+ out["note"] = " ".join(notes)
return _json(out)
return server
@@ -287,6 +308,7 @@ def main(*, engine_url: str | None = None) -> int:
"""Entry point for ``sentient mcp`` (stdio). Returns the process exit code."""
from sentient.gateway import mcp_token
+ follow_discovery = engine_url is None # found through the file: follow it when the engine moves
if engine_url is None:
try:
engine_url = discovered_engine_url()
@@ -303,7 +325,7 @@ def main(*, engine_url: str | None = None) -> int:
)
return 2
try:
- client = EngineClient(engine_url, in_use)
+ client = EngineClient(engine_url, in_use, follow_discovery=follow_discovery)
except EngineError as exc:
print(str(exc), file=sys.stderr)
return 2
diff --git a/sentient/tasks/service.py b/sentient/tasks/service.py
index 143bcb129..861f509a3 100644
--- a/sentient/tasks/service.py
+++ b/sentient/tasks/service.py
@@ -1809,7 +1809,12 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None:
return
await self.publish(task_id)
short = display_name[:50] + ("..." if len(display_name) > 50 else "")
- await self._notify(task, f"I've created a new plan for you: '{short}'", "Plan ready for approval", "approval_needed")
+ message = f"I've created a new plan for you: '{short}'"
+ if mcp_tasks.is_mcp_task(task):
+ # the note names the app that asked for the task, like the approval notes do (#443)
+ app_label = (task.get("original_context") or {}).get("app") or "an MCP app"
+ message = f"I've created a new plan for {app_label}: '{short}'"
+ await self._notify(task, message, "Plan ready for approval", "approval_needed")
async def _planned_script(
self, task: dict, data: dict, system: str, parts: list[str]
diff --git a/tests/mcp_server/test_client.py b/tests/mcp_server/test_client.py
index 28aa64e7d..2d523ab44 100644
--- a/tests/mcp_server/test_client.py
+++ b/tests/mcp_server/test_client.py
@@ -34,13 +34,14 @@ def test_the_readiness_probe_never_carries_the_token():
def test_identity_is_proven_by_challenge_not_by_the_health_marker():
# a listener on the discovered port can copy the health answer word for word; only the
- # holder of the token can answer a fresh nonce with its HMAC under that token
+ # holder of the token can answer a fresh nonce with its HMAC under that token, keyed to
+ # the port the challenge was asked on (7777, this client's own port)
import hashlib
import hmac as hmac_mod
def answer_challenge(request: httpx.Request) -> httpx.Response:
nonce = request.url.params["nonce"]
- digest = hmac_mod.new(b"test-token", nonce.encode(), hashlib.sha256).hexdigest()
+ digest = hmac_mod.new(b"test-token", f"{nonce}:7777".encode(), hashlib.sha256).hexdigest()
return httpx.Response(200, json={"proof": digest})
with respx.mock() as router:
@@ -59,6 +60,71 @@ def answer_challenge(request: httpx.Request) -> httpx.Response:
assert _client().prove_identity() is False # no token issued: nothing to prove with
+def test_a_relayed_proof_does_not_count():
+ # a squatter on the engine's old port can relay the challenge to the real engine on its
+ # new port, but the proof it gets back is keyed to THAT port (9999), not to the port it
+ # is squatting on (7777), so the check fails and the token is never sent (review 5609721491)
+ import hashlib
+ import hmac as hmac_mod
+
+ def relayed_answer(request: httpx.Request) -> httpx.Response:
+ nonce = request.url.params["nonce"]
+ digest = hmac_mod.new(b"test-token", f"{nonce}:9999".encode(), hashlib.sha256).hexdigest()
+ return httpx.Response(200, json={"proof": digest})
+
+ with respx.mock() as router:
+ router.get(f"{BASE}/api/mcp/proof").mock(side_effect=relayed_answer)
+ assert _client().prove_identity() is False
+
+
+def test_refresh_address_follows_the_engine_when_it_moves(isolated_home):
+ import json as _json
+
+ from sentient import paths
+
+ found = paths.discovery_file()
+ found.parent.mkdir(parents=True, exist_ok=True)
+ found.write_text(_json.dumps({"port": 8123}), encoding="utf-8")
+ client = EngineClient(BASE, "test-token", follow_discovery=True)
+ client.refresh_address()
+ assert client.base_url == "http://127.0.0.1:8123" # followed the engine to its new port
+ found.unlink()
+ client.refresh_address()
+ assert client.base_url == "http://127.0.0.1:8123" # no file right now: keep the last address
+
+
+def test_refresh_address_keeps_an_explicit_address(isolated_home):
+ import json as _json
+
+ from sentient import paths
+
+ found = paths.discovery_file()
+ found.parent.mkdir(parents=True, exist_ok=True)
+ found.write_text(_json.dumps({"port": 8123}), encoding="utf-8")
+ client = _client() # started with an explicit address: it keeps it
+ client.refresh_address()
+ assert client.base_url == BASE
+
+
+def test_chat_turn_reports_a_memory_that_waits_in_review():
+ import json as _json
+
+ lines = "\n".join([
+ _json.dumps({"type": "session", "session_id": "s1"}),
+ _json.dumps({"type": "tool_result", "name": "memory_remember",
+ "result": {"status": "pending", "id": 5}}),
+ _json.dumps({"type": "done", "content": "Got it, noted"}),
+ ])
+ with respx.mock() as router:
+ router.post(f"{BASE}/api/chat").mock(
+ return_value=httpx.Response(200, content=lines,
+ headers={"Content-Type": "application/x-ndjson"})
+ )
+ turn = _client().chat_turn("Remember this about me: I drink tea")
+ assert turn["content"] == "Got it, noted" # the reply may claim it was noted...
+ assert turn["memory_held"] is True # ...but the fact is waiting in Review, and the server says so
+
+
def test_a_non_local_engine_address_is_refused():
with pytest.raises(EngineError, match="only works on this computer"):
EngineClient("http://192.168.1.20:7777", "test-token")
@@ -152,7 +218,8 @@ def test_chat_turn_reads_the_ndjson_stream_until_done():
return_value=httpx.Response(200, text=ndjson, headers={"content-type": "application/x-ndjson"})
)
out = _client().chat_turn("when is tea?")
- assert out == {"content": "Tea is at five.", "session_id": "s1", "approval_asked": False}
+ assert out == {"content": "Tea is at five.", "session_id": "s1", "approval_asked": False,
+ "memory_held": False}
import json as _json
assert _json.loads(route.calls[0].request.content) == {"text": "when is tea?", "channel": "mcp"}
diff --git a/tests/mcp_server/test_mcp_server.py b/tests/mcp_server/test_mcp_server.py
index bb95d6a46..2968429b3 100644
--- a/tests/mcp_server/test_mcp_server.py
+++ b/tests/mcp_server/test_mcp_server.py
@@ -27,6 +27,7 @@ class FakeEngine:
"""Just the EngineClient surface the tools use, with the answers scripted."""
def __init__(self, *, enabled: bool = True, skip_review: bool = False):
+ self.base_url = "http://127.0.0.1:7777"
self.section = {
"enabled": enabled,
"memories_skip_review": skip_review,
@@ -48,8 +49,18 @@ def __init__(self, *, enabled: bool = True, skip_review: bool = False):
self.notes: list[dict] = []
self.fail_search = False
self.fail_config = False
+ self.prove_ok = True
+ self.status_calls = 0
+ self.memory_held = False
+
+ def refresh_address(self):
+ pass # the fake never moves ports
+
+ def prove_identity(self):
+ return self.prove_ok
def status(self):
+ self.status_calls += 1
if self.fail_config:
raise EngineError("The engine refused this server's token (401).")
return dict(self.section)
@@ -94,7 +105,10 @@ def notify(self, message, title=None, *, app=None):
return {"id": "n1"}
def chat_turn(self, text, *, app=None):
- return {"content": f"Sentient replies to: {text}", "session_id": "s1"}
+ out = {"content": f"Sentient replies to: {text}", "session_id": "s1"}
+ if self.memory_held:
+ out["memory_held"] = True
+ return out
def _result_json(result) -> dict:
@@ -298,3 +312,22 @@ async def test_an_engine_error_at_the_gate_is_reported_not_masked_as_turned_off(
out = await _call(build_server(engine), "memory_search", {"query": "Maya"})
assert out == {"error": "The engine refused this server's token (401)."}
+
+
+async def test_the_gate_sends_the_token_nowhere_until_the_engine_proves_itself():
+ # the proof at startup is not enough: the engine restarts on a fresh port, and whatever
+ # took over the old one must not be sent the token on the next call (review 5609721491)
+ engine = FakeEngine()
+ engine.prove_ok = False
+ out = await _call(build_server(engine), "memory_search", {"query": "Maya"})
+ assert "could not prove it holds the MCP token" in out["error"]
+ assert engine.status_calls == 0 # the status call is the first token carrier: it never happened
+
+
+async def test_ask_sentient_says_when_a_memory_from_the_turn_waits_in_review():
+ # the reply itself may say "noted" while the fact is only waiting in Review; the tool's
+ # answer carries the correction as a note (review 5609721491, the nit)
+ engine = FakeEngine()
+ engine.memory_held = True
+ out = await _call(build_server(engine), "ask_sentient", {"message": "Remember this about me: I drink tea"})
+ assert "Review list" in out["note"] and "only saved if they approve" in out["note"]
diff --git a/tests/mcp_server/test_review_fixes.py b/tests/mcp_server/test_review_fixes.py
index e7c3c8b20..a24d7a6d2 100644
--- a/tests/mcp_server/test_review_fixes.py
+++ b/tests/mcp_server/test_review_fixes.py
@@ -6,6 +6,13 @@
answer from the app (the chat stream alone is invisible to MCP clients).
3. The MCP-only token reaches only the endpoints the eight tools need, never config or approvals.
5. A task an MCP app creates always waits for plan approval, even when plans auto-approve.
+
+The second review (5609721491) adds:
+6. The identity proof is keyed to the port it was asked on (a relayed proof fails), the gate
+ proves again before every call, and the server follows the discovery file to a new port.
+7. An MCP caller cannot shape its turn: the body's model, attachments and incognito are
+ ignored, its text never starts a rule check, and the approval and plan-ready notifications
+ name the app that is asking.
"""
from __future__ import annotations
@@ -124,6 +131,8 @@ async def answer(ev):
assert approval_notes, "the approval must also land as a notification (review item 2)"
note = approval_notes[0]
assert note["payload"]["approval_id"] == ev.approval_id
+ # the note names who is asking (this turn named no app, so the generic label)
+ assert note["message"].startswith("an MCP app wants to: "), note["message"]
seen["note_id"] = note["id"]
s.approvals.resolve(ev.approval_id, "allow")
@@ -213,6 +222,14 @@ def test_a_task_created_with_the_mcp_token_waits_for_plan_approval(engine_client
assert task["original_context"]["source"] == "mcp"
assert task["original_context"]["app"] == "Claude Code"
assert task["status"] == "approval_pending" # an outside app's task always waits (review item 5)
+ ready = []
+ for _ in range(50): # the plan-ready note lands with the plan; wait for it
+ notes = c.get("/api/notifications", headers=_gw(c)).json()["notifications"]
+ ready = [n for n in notes if n.get("title") == "Plan ready for approval"]
+ if ready:
+ break
+ time.sleep(0.1)
+ assert ready and "Claude Code" in ready[0]["message"] # the note names the app that asked
def test_with_the_feature_off_the_mcp_token_opens_nothing_but_status(engine_client_off):
@@ -414,9 +431,10 @@ async def test_closing_an_mcp_turn_at_the_approval_still_settles_its_notificatio
def test_the_engine_proves_itself_by_challenge_before_any_token_travels(engine_client):
- # GET /api/mcp/proof answers a caller's fresh nonce with its HMAC under the MCP token,
- # with no credentials presented: only the real engine can produce it, and the token
- # itself is never in the answer (CodeRabbit, PR #487 round on 7828606)
+ # GET /api/mcp/proof answers a caller's fresh nonce with the HMAC of "nonce:port" under
+ # the MCP token, the port being the one the request arrived on (80 for the test client),
+ # with no credentials presented: only the real engine can produce it, a proof minted for
+ # another port (a relay) does not match, and the token itself is never in the answer
import hashlib
import hmac as hmac_mod
@@ -424,7 +442,7 @@ def test_the_engine_proves_itself_by_challenge_before_any_token_travels(engine_c
nonce = "ab" * 16
r = c.get("/api/mcp/proof", params={"nonce": nonce}) # no Authorization header at all
assert r.status_code == 200, r.text
- expected = hmac_mod.new(MCP_TOKEN.encode(), nonce.encode(), hashlib.sha256).hexdigest()
+ expected = hmac_mod.new(MCP_TOKEN.encode(), f"{nonce}:80".encode(), hashlib.sha256).hexdigest()
assert r.json()["proof"] == expected
assert MCP_TOKEN not in r.text
assert c.get("/api/mcp/proof", params={"nonce": "short"}).status_code == 404
@@ -437,3 +455,53 @@ def test_there_is_no_proof_without_an_issued_token(config, isolated_home, keycha
db_path=isolated_home / "noproof.db", enable_background=False))
with TestClient(app) as c:
assert c.get("/api/mcp/proof", params={"nonce": "ab" * 16}).status_code == 404
+
+
+def test_an_mcp_caller_cannot_shape_the_turn(engine_client, monkeypatch):
+ # the body's model, attachments and incognito are ignored for an MCP caller (review
+ # 5609721491): attachments would pull files into the turn, a named model could pick a
+ # cloud model where the user keeps chats local, incognito is the user's own switch
+ import json as _json
+
+ from sentient.llm.events import Done
+
+ c = engine_client
+ seen: dict = {}
+
+ async def recorder(session_id, text, **kwargs):
+ seen.update(kwargs)
+ yield Done(content="ok")
+
+ monkeypatch.setattr(c.app.state.sentient.agent, "run_turn", recorder)
+ r = c.post("/api/chat", json={
+ "text": "hello", "app": "Claude Code", "model": "some-cloud-model",
+ "attachments": ["secret.txt"], "incognito": True,
+ }, headers=_mcp(c))
+ assert r.status_code == 200, r.text
+ assert seen["model"] is None and seen["attachments"] == []
+ sid = _json.loads(r.text.splitlines()[0])["session_id"]
+ rows = c.get("/api/sessions", headers=_gw(c)).json()
+ mine = [row for row in rows if row["id"] == sid]
+ assert mine and mine[0]["incognito"] is False # the new chat is not incognito either
+
+
+async def test_an_mcp_turn_never_starts_a_rule_check(config, isolated_home, monkeypatch):
+ # "never suggest satay" from an app created a pending standing rule in the maintainer's
+ # test: an MCP turn's text is not the user's words, so no rule check starts from it
+ llm = FakeProvider(replies=["ok", "ok again"])
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "rules.db").start()
+ try:
+ calls: list[str] = []
+
+ def spy(session_id, message_id, text):
+ calls.append(text)
+
+ monkeypatch.setattr(s.agent, "_start_rule_check", spy)
+ mcp = await s.store.create_session(channel="mcp")
+ await _collect(s.agent, mcp, "never suggest satay", "mcp")
+ assert calls == []
+ cli = await s.store.create_session(channel="cli")
+ await _collect(s.agent, cli, "never suggest satay", "cli")
+ assert calls == ["never suggest satay"] # control: the user's own words still check
+ finally:
+ await s.stop()
From 7d65b381488440ea67ea338c9f586794ab793056 Mon Sep 17 00:00:00 2001
From: juliasadler <3263905943@qq.com>
Date: Sat, 24 Oct 2026 10:47:54 +0800
Subject: [PATCH 08/10] fix(mcp): bind the identity proof to the whole address
and clean the app name before it is shown: GET /api/mcp/proof now signs
nonce:host:port, the client only talks to literal loopback addresses
(localhost pinned to 127.0.0.1), and the discovery file records the host, so
a relay sharing the port on the other loopback address fails too. App names
are capped and stripped of control characters at every intake and framed as a
name in approval and plan-ready notes (claude-code shows as Claude Code); the
gate's refusal wording no longer claims a restart is needed to reconnect
---
changes/443.security.md | 2 +-
docs/API.md | 24 +++--
...serve-sentient-to-other-agents-over-mcp.md | 19 ++--
sentient/agent/loop.py | 15 ++-
sentient/app_names.py | 37 +++++++
sentient/cli.py | 6 +-
sentient/gateway/routes/core.py | 4 +
sentient/gateway/routes/mcp.py | 24 ++---
sentient/gateway/routes/memory.py | 3 +-
sentient/gateway/routes/notifications.py | 3 +-
sentient/gateway/routes/tasks.py | 3 +-
sentient/mcp_server/client.py | 47 ++++++---
sentient/mcp_server/server.py | 6 +-
sentient/tasks/service.py | 8 +-
tests/mcp_server/test_client.py | 43 +++++++-
tests/mcp_server/test_mcp_server.py | 3 +
tests/mcp_server/test_review_fixes.py | 98 +++++++++++++++++--
17 files changed, 273 insertions(+), 72 deletions(-)
create mode 100644 sentient/app_names.py
diff --git a/changes/443.security.md b/changes/443.security.md
index 30186e7f6..fa3e08afc 100644
--- a/changes/443.security.md
+++ b/changes/443.security.md
@@ -1 +1 @@
-**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and by default a memory the app states waits in Review instead of being saved as something you said (the one opt-out is the Integrations setting that lets MCP memories skip Review, `integrations.mcp_server_memories_skip_review`). Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one. Before that key is sent anywhere, the server makes whatever answers on the engine's port prove it holds the same key, with the proof tied to that port so it cannot be relayed from the real engine, and it checks again before every call, following the engine when it restarts on a new port. An app also cannot shape its turns: files, a model of its choice and the incognito switch in its requests are ignored.
+**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and by default a memory the app states waits in Review instead of being saved as something you said (the one opt-out is the Integrations setting that lets MCP memories skip Review, `integrations.mcp_server_memories_skip_review`). Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one. Before that key is sent anywhere, the server makes whatever answers at the engine's address prove it holds the same key, with the proof tied to that address (host and port) so it cannot be relayed from the real engine, and it checks again before every call, following the engine when it restarts on a new port. An app's name is also cleaned and framed before it is shown to you ("An app called ..."), so an app cannot name itself something that reads like a message from Sentient where you decide whether to allow it. An app also cannot shape its turns: files, a model of its choice and the incognito switch in its requests are ignored.
diff --git a/docs/API.md b/docs/API.md
index c2e9d84dc..72af3502e 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1604,8 +1604,10 @@ run the same masking again before their prompts. The original stays in the user'
The other direction: other AI apps on this computer (Claude Code, Cursor, any MCP client) can use Sentient
itself. The command `sentient mcp` (package `sentient/mcp_server/`) runs an MCP server over stdio and is a
thin client of this same REST API. It finds the engine through the discovery file the engine writes at
-startup (`engine-discovery.json` in the Sentient home: the loopback port only, never a secret; the desktop
-picks a fresh port every launch), refuses a non-loopback engine address, and holds no state. An HTTP
+startup (`engine-discovery.json` in the Sentient home: the loopback address it bound, never a secret; the
+desktop picks a fresh port every launch), refuses a non-loopback engine address (a name like `localhost`
+is pinned to the literal `127.0.0.1`, so the address the server talks to is the address the engine
+proves), and holds no state. An HTTP
transport is deliberately not offered; it may return later, protected by the same MCP-only token.
- **Its own token.** Turning the feature on issues an MCP-only token, stored in the keychain
@@ -1621,12 +1623,13 @@ transport is deliberately not offered; it may return later, protected by the sam
everywhere; `sentient mcp` never reads or writes it.
- **Endpoints of its own.** `GET /api/mcp/status` (either token) → `{enabled, memories_skip_review,
token_issued}`: what an MCP app may do right now, without reading the config. `GET /api/mcp/proof?nonce=...`
- (no credentials) → `{proof}`: the HMAC-SHA256 of `nonce:port` under the MCP token, the port being the
- one the request arrived on, how `sentient mcp` verifies that a discovered port is really the engine
- before it sends the token there. Keying the proof to the port stops a relay (a squatter on the
- engine's old port forwarding the challenge gets a proof keyed to the real engine's port, which
- fails the check), and the server re-proves before every call, following the discovery file when
- the engine has moved ports; 404 when no token has been issued. `POST /api/mcp/token/rotate`
+ (no credentials) → `{proof}`: the HMAC-SHA256 of `nonce:host:port` under the MCP token, the address
+ being the one the request arrived on, how `sentient mcp` verifies that a discovered address is
+ really the engine before it sends the token there. Keying the proof to the address stops a relay
+ (a squatter forwarding the challenge gets a proof keyed to the real engine's address, which fails
+ the check; the host matters because a squatter can share the port on the other loopback address,
+ ::1 against 127.0.0.1), and the server re-proves before every call, following the discovery file
+ when the engine has moved; 404 when no token has been issued. `POST /api/mcp/token/rotate`
(gateway token only) → `{rotated: true}`: replaces the MCP-only token in the keychain; apps holding the
old one stop working at once and pick the new one up when they restart. It is also issued from
`SentientApp.save_config` whenever `integrations.mcp_server_enabled` is saved on and none exists.
@@ -1650,7 +1653,10 @@ transport is deliberately not offered; it may return later, protected by the sam
`channel: "mcp"` whatever the body says: the turn is marked outside content from the start, its text
never counts as the user's words (memories it states wait in Review by default; sends, code runs and deletes always
ask; its text never proposes standing rules), an approval it raises also lands as a `kind: "approval"`
- notification (section 6) the user can answer in the window or on their phone, and the body's `model`,
+ notification (section 6) the user can answer in the window or on their phone. The app's name is the
+ client's own handshake text, so wherever it is shown (the turn's mark, the approval and plan-ready
+ notes, the Review list) it is cleaned first (control characters stripped, capped at 40 characters)
+ and framed as a name (`An app called "..."`), never left to read as a sentence from Sentient. The body's `model`,
`attachments` and `incognito` are ignored, so the app cannot pull files into the turn, pick a model or
flip the user's incognito switch. The MCP client is outside content and can never answer an approval.
- `mcp` is also in the frozen engine's command list (`packaging/engine_entry.py`), which `tests/test_packaging.py`
diff --git a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
index 7232cb9e8..5730e0e8c 100644
--- a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
+++ b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
@@ -34,14 +34,17 @@ it was waiting on.
REST API, so there is one implementation of every safety rule, in the engine, and this process
cannot bypass it. It only talks to a loopback address and refuses any other at startup. It
finds the engine through a discovery file the engine writes at startup (`engine-discovery.json`,
- the port only): the desktop starts the engine on a fresh port every launch, so the configured
- port is not where it listens. Because the file holds only a port, the server proves the
- engine's identity before it sends the token anywhere: the health probe goes out without
- credentials, and the engine must then answer a nonce challenge (`GET /api/mcp/proof`, the
- HMAC of `nonce:port` under the MCP token), which a process that merely copied the health
- marker cannot forge. Keying the proof to the port the request arrived on stops a relay: a
- squatter on the engine's old port can forward the challenge to the real engine, but the
- proof it gets back is keyed to the real engine's port and fails the check. The proof is not
+ the address it bound): the desktop starts the engine on a fresh port every launch, so the
+ configured port is not where it listens. Because the file holds only an address, the server
+ proves the engine's identity before it sends the token anywhere: the health probe goes out
+ without credentials, and the engine must then answer a nonce challenge (`GET /api/mcp/proof`,
+ the HMAC of `nonce:host:port` under the MCP token), which a process that merely copied the
+ health marker cannot forge. Keying the proof to the address the request arrived on stops a
+ relay: a squatter can forward the challenge to the real engine, but the proof it gets back
+ is keyed to the real engine's address and fails the check (the host half matters because a
+ squatter can share the port on the other loopback address, ::1 against 127.0.0.1; for the
+ same reason the server only talks to literal loopback addresses and pins "localhost" to
+ 127.0.0.1). The proof is not
a startup-only step: the server re-reads the discovery file and proves again before every
call, so a running server follows the engine to its new port after a restart instead of
sending the token to whatever took over the old one.
diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py
index 9f0ef59fe..826d43ac6 100644
--- a/sentient/agent/loop.py
+++ b/sentient/agent/loop.py
@@ -58,6 +58,7 @@
from sentient.agent.prompt import build_system_prompt, mark_outside
from sentient.agent.sources import AnswerSources
from sentient.agent.toolselect import ToolSelector, is_local_model
+from sentient.app_names import clean_app_name
from sentient.config.schema import SentientConfig
from sentient.files.extract import extract_text, is_image
from sentient.integrations.base import IntegrationPlugin
@@ -1306,10 +1307,12 @@ async def _ask(self, p: _CallPlan, ctx: ToolContext, ev: dict) -> AsyncIterator[
# the answer reaches this same approval_id through the broker, whichever surface sends it
label = wording.get("risk_label") or "Changes something"
target = wording.get("target")
- # the note names the app that is asking (the turn's mark on this channel), so the
- # user sees whose request they are answering: "Claude Code wants to: Runs code"
- who = ctx.untrusted or "an MCP app"
- message = f"{who} wants to: {label}: {target}" if target else f"{who} wants to: {label}"
+ # the note names the app that is asking (the turn's mark on this channel), framed
+ # as a name rather than a sentence the app could speak as Sentient: the name is
+ # the client's own handshake text, cleaned where the turn was marked
+ who = ctx.untrusted
+ framed = f'An app called "{who}"' if who and who != "an MCP app" else "An MCP app"
+ message = f"{framed} wants to: {label}: {target}" if target else f"{framed} wants to: {label}"
try:
note = await self.app.notify(
"approval", message, title="Approval needed",
@@ -1817,7 +1820,9 @@ def release() -> list[str]:
# outside content read earlier in this chat still counts until a new chat starts (ADR 0018)
ctx.untrusted = await self._chat_untrusted(session_id, session)
if from_mcp and not ctx.untrusted:
- ctx.untrusted = app_name or "an MCP app" # the app's name, when it gave one, so the user sees who is asking
+ # the app's name, when it gave one, so the user sees who is asking; it is the
+ # app's own text, so it is cleaned (capped, no control characters) first
+ ctx.untrusted = clean_app_name(app_name) or "an MCP app"
await self.store.execute(
"UPDATE sessions SET untrusted = ? WHERE id = ?", (ctx.untrusted, session_id)
)
diff --git a/sentient/app_names.py b/sentient/app_names.py
new file mode 100644
index 000000000..5ea06c148
--- /dev/null
+++ b/sentient/app_names.py
@@ -0,0 +1,37 @@
+"""How an outside app's self-chosen name may be shown to the user (issue #443).
+
+The name arrives word for word from the app's own handshake, so it is untrusted text like
+everything else an app sends: a client can call itself "Sentient Security Center. Your data
+will be erased unless you tap Allow now.", and uncleaned that sentence would land in the
+approval notification the user decides from. Every surface that shows the name therefore
+takes it from here: control characters and newlines stripped, capped at 40 characters, and
+the notes that quote it frame it as a name (``An app called "..."``) instead of letting it
+speak as if it were Sentient.
+"""
+
+from __future__ import annotations
+
+import re
+
+MAX_APP_NAME = 40
+
+# Handshake names whose display form reads better: the MCP client's own name for
+# Claude Code is "claude-code".
+KNOWN_APPS = {"claude-code": "Claude Code"}
+
+_WS = re.compile(r"\s+")
+
+
+def clean_app_name(raw: object) -> str | None:
+ """The display form of an app's name, or None when nothing usable remains."""
+ if not isinstance(raw, str):
+ return None
+ # control and format characters (newlines among them) become spaces, runs collapse
+ text = "".join(ch if ch.isprintable() else " " for ch in raw)
+ text = _WS.sub(" ", text).strip()
+ if not text:
+ return None
+ text = KNOWN_APPS.get(text.lower(), text)
+ if len(text) > MAX_APP_NAME:
+ text = text[: MAX_APP_NAME - 1].rstrip() + "…"
+ return text
diff --git a/sentient/cli.py b/sentient/cli.py
index a0efbef0f..3dcb26326 100644
--- a/sentient/cli.py
+++ b/sentient/cli.py
@@ -102,8 +102,8 @@ def serve(
if p:
# `sentient mcp` finds the engine through this file (issue #443): the desktop starts the
# engine on a fresh port each launch, so the configured port is not where it listens.
- # Port only, no secret; the file is removed again when the engine stops (below), and the
- # MCP server proves the engine is Sentient before it sends the token anywhere.
+ # The address only, no secret; the file is removed again when the engine stops (below),
+ # and the MCP server proves the engine is Sentient before it sends the token anywhere.
import contextlib
import json
@@ -112,7 +112,7 @@ def serve(
with contextlib.suppress(Exception):
found = paths.discovery_file()
found.parent.mkdir(parents=True, exist_ok=True)
- found.write_text(json.dumps({"port": p}), encoding="utf-8")
+ found.write_text(json.dumps({"host": h, "port": p}), encoding="utf-8")
uvicorn.run(
"sentient.gateway.app:create_app", factory=True, host=h, port=p, reload=reload,
log_level="warning", ws_ping_interval=20, ws_ping_timeout=60,
diff --git a/sentient/gateway/routes/core.py b/sentient/gateway/routes/core.py
index a32985801..b9125c7ac 100644
--- a/sentient/gateway/routes/core.py
+++ b/sentient/gateway/routes/core.py
@@ -19,6 +19,7 @@
from pydantic import BaseModel
from sentient import __version__, paths
+from sentient.app_names import clean_app_name
from sentient.config import config_json_schema
from sentient.config.schema import SentientConfig
from sentient.files import names as file_names
@@ -450,6 +451,9 @@ async def chat(request: Request, body: ChatBody):
body.model = None
body.attachments = []
body.incognito = False
+ # the app's name is its own text, shown to the user in approvals and notes: cleaned
+ # (capped, no control characters) before it is used anywhere (see app_names)
+ body.app = clean_app_name(body.app)
if body.session_id:
# the session must be one of the app's own: naming one of the user's chats would hand
# the app that chat's history and let its words into it, so anything else starts fresh
diff --git a/sentient/gateway/routes/mcp.py b/sentient/gateway/routes/mcp.py
index 6aed59f66..75217d9a2 100644
--- a/sentient/gateway/routes/mcp.py
+++ b/sentient/gateway/routes/mcp.py
@@ -19,22 +19,24 @@
async def proof(request: Request, nonce: str = ""):
"""How the MCP server knows it found the real engine before it sends the token (issue #443).
- The caller picks a fresh nonce; the answer is the HMAC of that nonce and the port this
- request arrived on, under the MCP token, which only a process holding the token (this
- engine, through the keychain) can produce. A health answer can be copied by anything that
- took over the discovery file's port, so the marker alone is never trusted. Binding the port
- in stops a relay: a process squatting on the engine's old port can forward the challenge to
- the real engine on its new port, but the proof it gets back is keyed to that port, not to
- its own, so the caller's check fails and the token never travels. The token itself never
- travels either, and without an issued token there is nothing to prove with, so the route
- simply does not exist then."""
+ The caller picks a fresh nonce; the answer is the HMAC of that nonce and the address this
+ request arrived on (host and port), under the MCP token, which only a process holding the
+ token (this engine, through the keychain) can produce. A health answer can be copied by
+ anything that took over the discovery file's port, so the marker alone is never trusted.
+ Binding the address in stops a relay: a process squatting on the engine's old address can
+ forward the challenge to the real engine, but the proof it gets back is keyed to the
+ address it was asked on, not to the squatter's own (the port alone would not notice a
+ squatter on the same port at the other loopback address, ::1 against 127.0.0.1), so the
+ caller's check fails and the token never travels. The token itself never travels either,
+ and without an issued token there is nothing to prove with, so the route simply does not
+ exist then."""
current = mcp_token.current_token()
if current is None or not 16 <= len(nonce) <= 128:
raise HTTPException(404, "not found")
server = request.scope.get("server") or (None, None)
- if server[1] is None:
+ if server[0] is None or server[1] is None:
raise HTTPException(404, "not found")
- signed = f"{nonce}:{server[1]}".encode()
+ signed = f"{nonce}:{server[0]}:{server[1]}".encode()
digest = hmac.new(current.encode("utf-8"), signed, hashlib.sha256).hexdigest()
return {"proof": digest}
diff --git a/sentient/gateway/routes/memory.py b/sentient/gateway/routes/memory.py
index 1ab8db792..a2675100e 100644
--- a/sentient/gateway/routes/memory.py
+++ b/sentient/gateway/routes/memory.py
@@ -8,6 +8,7 @@
from fastapi import APIRouter, File, HTTPException, Request, UploadFile
+from sentient.app_names import clean_app_name
from sentient.gateway.deps import MCP_OK, get_core
from sentient.memory import export as memory_export
from sentient.memory import forget as memory_forget
@@ -252,7 +253,7 @@ async def create_memory(request: Request, body: dict):
if getattr(request.state, "caller", "gateway") == "mcp":
source = "mcp"
if not get_core(request).config.integrations.mcp_server_memories_skip_review:
- review = memory_review.note(str(body.get("app") or "an MCP app"), snippet=content)
+ review = memory_review.note(clean_app_name(body.get("app")) or "an MCP app", snippet=content)
else:
source = str(body.get("source") or "manual")
if body.get("hold_for_review"):
diff --git a/sentient/gateway/routes/notifications.py b/sentient/gateway/routes/notifications.py
index eb31c6941..45a511367 100644
--- a/sentient/gateway/routes/notifications.py
+++ b/sentient/gateway/routes/notifications.py
@@ -7,6 +7,7 @@
from fastapi import APIRouter, HTTPException, Request
+from sentient.app_names import clean_app_name
from sentient.gateway.deps import MCP_OK, get_core
router = APIRouter(prefix="/api/notifications", tags=["notifications"], dependencies=MCP_OK)
@@ -35,7 +36,7 @@ async def create_notification(request: Request, body: dict):
# a note from an outside app is titled as one, under a fixed attribution the app cannot
# replace: naming itself "Sentient" in `app` still lands as "From MCP app (Sentient)"
# (issue #443)
- app_name = str(body.get("app") or "").strip() or "An MCP app"
+ app_name = clean_app_name(body.get("app")) or "An MCP app" # the name itself is cleaned too
title = f"From MCP app ({app_name}): {title}" if title else f"From MCP app ({app_name})"
payload = {**(payload or {}), "app": app_name}
return await get_core(request).notify("info", message, title=title, payload=payload)
diff --git a/sentient/gateway/routes/tasks.py b/sentient/gateway/routes/tasks.py
index c918ab29e..daadfcb02 100644
--- a/sentient/gateway/routes/tasks.py
+++ b/sentient/gateway/routes/tasks.py
@@ -8,6 +8,7 @@
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
+from sentient.app_names import clean_app_name
from sentient.gateway.deps import MCP_OK, get_core
from sentient.llm.provider import PlainProviderError, ProviderError
from sentient.tasks.service import TaskConflict, TaskNotFound
@@ -74,7 +75,7 @@ async def create_task(request: Request, body: CreateTaskBody):
# an outside app's task (issue #443): made in the app's name and marked as outside
# content, so its plan always waits for approval and every send asks, like a task by
# email (#159). Only the MCP token can be this caller; the app name is display only.
- app_name = (body.app or "").strip() or "An MCP app"
+ app_name = clean_app_name(body.app) or "An MCP app" # the name is the app's own text: cleaned
return await _guard(_tasks(request).create_from_mcp(body.prompt, app_name))
return await _guard(_tasks(request).create_task(
body.prompt, is_swarm=body.is_swarm, model=body.model, browser_profile=body.browser_profile,
diff --git a/sentient/mcp_server/client.py b/sentient/mcp_server/client.py
index c0706afe7..e3479b123 100644
--- a/sentient/mcp_server/client.py
+++ b/sentient/mcp_server/client.py
@@ -19,39 +19,57 @@
import httpx
-LOOPBACK_HOSTS = {"127.0.0.1", "localhost", "::1"}
+LOOPBACK_HOSTS = {"127.0.0.1", "::1"} # literal addresses only; names are pinned (below)
class EngineError(RuntimeError):
"""The engine could not be reached or refused the call. The message is safe to show the caller."""
+def _canonical_loopback_url(base_url: str) -> str:
+ """The canonical form of an engine address: a literal loopback address, nothing else.
+
+ A name is not good enough: "localhost" can resolve to ::1 first (on Windows it does),
+ where a different process can listen on the same port as the real engine on 127.0.0.1,
+ so the name is pinned to the literal 127.0.0.1 and every other name is refused."""
+ parsed = urlparse(base_url)
+ host = parsed.hostname or ""
+ if host == "localhost":
+ host = "127.0.0.1"
+ if host not in LOOPBACK_HOSTS:
+ raise EngineError(
+ f"Refusing to talk to a non-local engine address ({base_url!r}). "
+ "The MCP server only works on this computer."
+ )
+ hostpart = f"[{host}]" if ":" in host else host
+ port = f":{parsed.port}" if parsed.port else ""
+ return f"{parsed.scheme or 'http'}://{hostpart}{port}"
+
+
def discovered_engine_url() -> str:
- """The running engine's address from its discovery file (loopback, port only)."""
+ """The running engine's address from its discovery file (the address it bound, no secret)."""
from sentient import paths
try:
data = json.loads(paths.discovery_file().read_text(encoding="utf-8"))
port = int(data["port"])
+ host = str(data.get("host") or "127.0.0.1") # a file from an older engine has no host
except Exception:
port = 0
+ host = "127.0.0.1"
if not port:
raise EngineError(
"Sentient's engine is not running (no discovery file). Start Sentient first, then try again."
)
- return f"http://127.0.0.1:{port}"
+ hostpart = f"[{host}]" if ":" in host else host
+ return _canonical_loopback_url(f"http://{hostpart}:{port}")
class EngineClient:
"""Synchronous REST calls to the engine. One instance per MCP server process."""
def __init__(self, base_url: str, token: str, *, timeout: float = 30.0, follow_discovery: bool = False):
- parsed = urlparse(base_url)
- if parsed.hostname not in LOOPBACK_HOSTS:
- raise EngineError(
- f"Refusing to talk to a non-local engine address ({base_url!r}). "
- "The MCP server only works on this computer."
- )
+ base_url = _canonical_loopback_url(base_url) # literal loopback only, "localhost" pinned
self._token = token # kept for the identity proof below; it is only ever sent to an
# engine that has already proven it holds the same one
self._timeout = timeout
@@ -153,10 +171,11 @@ def prove_identity(self) -> bool:
The health answer (``"name": "sentient"``) is a public marker anything listening on the
port can copy, so before the token is sent anywhere the engine must answer a challenge
it cannot forge: a fresh nonce goes out bare, and the reply must be the HMAC of that
- nonce and this address's port under the token (``GET /api/mcp/proof``). The port binds
- the proof to this address: a process squatting here can relay the challenge to the real
- engine on another port, but the proof it gets back is keyed to that port and fails here.
- The token itself never travels."""
+ nonce and this address (host and port) under the token (``GET /api/mcp/proof``). The
+ address binds the proof to this endpoint: a process squatting here can relay the
+ challenge to the real engine, but the proof it gets back is keyed to the real engine's
+ address (another port, or 127.0.0.1 against the squatter's ::1 on the same port) and
+ fails here. The token itself never travels."""
nonce = secrets.token_hex(16)
try:
resp = self._bare.get("/api/mcp/proof", params={"nonce": nonce})
@@ -169,7 +188,7 @@ def prove_identity(self) -> bool:
parsed = urlparse(self.base_url)
port = parsed.port or (443 if parsed.scheme == "https" else 80)
expected = hmac.new(
- self._token.encode("utf-8"), f"{nonce}:{port}".encode(), hashlib.sha256
+ self._token.encode("utf-8"), f"{nonce}:{parsed.hostname}:{port}".encode(), hashlib.sha256
).hexdigest()
return hmac.compare_digest(proof, expected)
diff --git a/sentient/mcp_server/server.py b/sentient/mcp_server/server.py
index 23c1f8af7..a38cb7d0e 100644
--- a/sentient/mcp_server/server.py
+++ b/sentient/mcp_server/server.py
@@ -103,8 +103,10 @@ def check(self) -> dict | None:
if not self.client.prove_identity():
raise EngineError(
f"The engine answering at {self.client.base_url} could not prove it holds the MCP "
- "token, so the token was not sent to it. Sentient may have restarted, or its MCP "
- "token may have been replaced in the app; restart this MCP server to reconnect."
+ "token, so the token was not sent to it. Sentient may have restarted; the check "
+ "runs again on the next call and clears by itself once the real engine answers. "
+ "If the MCP token was replaced in Sentient, restart this MCP server to pick up "
+ "the new one."
)
status = self.client.status()
return status if status.get("enabled") else None
diff --git a/sentient/tasks/service.py b/sentient/tasks/service.py
index 861f509a3..4b182638f 100644
--- a/sentient/tasks/service.py
+++ b/sentient/tasks/service.py
@@ -1811,9 +1811,11 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None:
short = display_name[:50] + ("..." if len(display_name) > 50 else "")
message = f"I've created a new plan for you: '{short}'"
if mcp_tasks.is_mcp_task(task):
- # the note names the app that asked for the task, like the approval notes do (#443)
- app_label = (task.get("original_context") or {}).get("app") or "an MCP app"
- message = f"I've created a new plan for {app_label}: '{short}'"
+ # the note names the app that asked for the task, framed as a name like the
+ # approval notes do: the name is the app's own text, cleaned where it came in
+ who = (task.get("original_context") or {}).get("app")
+ framed = f'an app called "{who}"' if who and who != "an MCP app" else "an MCP app"
+ message = f"I've created a new plan for {framed}: '{short}'"
await self._notify(task, message, "Plan ready for approval", "approval_needed")
async def _planned_script(
diff --git a/tests/mcp_server/test_client.py b/tests/mcp_server/test_client.py
index 2d523ab44..fbbd323b2 100644
--- a/tests/mcp_server/test_client.py
+++ b/tests/mcp_server/test_client.py
@@ -35,13 +35,13 @@ def test_the_readiness_probe_never_carries_the_token():
def test_identity_is_proven_by_challenge_not_by_the_health_marker():
# a listener on the discovered port can copy the health answer word for word; only the
# holder of the token can answer a fresh nonce with its HMAC under that token, keyed to
- # the port the challenge was asked on (7777, this client's own port)
+ # the address the challenge was asked on (127.0.0.1:7777, this client's own address)
import hashlib
import hmac as hmac_mod
def answer_challenge(request: httpx.Request) -> httpx.Response:
nonce = request.url.params["nonce"]
- digest = hmac_mod.new(b"test-token", f"{nonce}:7777".encode(), hashlib.sha256).hexdigest()
+ digest = hmac_mod.new(b"test-token", f"{nonce}:127.0.0.1:7777".encode(), hashlib.sha256).hexdigest()
return httpx.Response(200, json={"proof": digest})
with respx.mock() as router:
@@ -62,20 +62,40 @@ def answer_challenge(request: httpx.Request) -> httpx.Response:
def test_a_relayed_proof_does_not_count():
# a squatter on the engine's old port can relay the challenge to the real engine on its
- # new port, but the proof it gets back is keyed to THAT port (9999), not to the port it
- # is squatting on (7777), so the check fails and the token is never sent (review 5609721491)
+ # new port, but the proof it gets back is keyed to THAT address (127.0.0.1:9999), not to
+ # the address it is squatting on (127.0.0.1:7777), so the check fails and the token is
+ # never sent (review 5609721491). The same holds on the same port at the other loopback
+ # address: a proof keyed to ::1 is worthless at 127.0.0.1 (review 5624575781)
import hashlib
import hmac as hmac_mod
def relayed_answer(request: httpx.Request) -> httpx.Response:
nonce = request.url.params["nonce"]
- digest = hmac_mod.new(b"test-token", f"{nonce}:9999".encode(), hashlib.sha256).hexdigest()
+ digest = hmac_mod.new(b"test-token", f"{nonce}:127.0.0.1:9999".encode(), hashlib.sha256).hexdigest()
return httpx.Response(200, json={"proof": digest})
with respx.mock() as router:
router.get(f"{BASE}/api/mcp/proof").mock(side_effect=relayed_answer)
assert _client().prove_identity() is False
+ def other_loopback_answer(request: httpx.Request) -> httpx.Response:
+ nonce = request.url.params["nonce"]
+ digest = hmac_mod.new(b"test-token", f"{nonce}:::1:7777".encode(), hashlib.sha256).hexdigest()
+ return httpx.Response(200, json={"proof": digest})
+
+ with respx.mock() as router:
+ router.get(f"{BASE}/api/mcp/proof").mock(side_effect=other_loopback_answer)
+ assert _client().prove_identity() is False
+
+
+def test_engine_addresses_are_pinned_to_literal_loopback():
+ # "localhost" can resolve to ::1 first, where something else can listen on the engine's
+ # port: the name is pinned to the literal 127.0.0.1, and any other name is refused
+ assert EngineClient("http://localhost:7777", "test-token").base_url == "http://127.0.0.1:7777"
+ assert EngineClient("http://[::1]:7777", "test-token").base_url == "http://[::1]:7777"
+ with pytest.raises(EngineError, match="only works on this computer"):
+ EngineClient("http://localhost.localdomain:7777", "test-token")
+
def test_refresh_address_follows_the_engine_when_it_moves(isolated_home):
import json as _json
@@ -147,6 +167,19 @@ def test_the_engine_is_found_through_its_discovery_file(isolated_home):
EngineClient(discovered_engine_url(), "test-token") # passes the client's own loopback check
+def test_the_discovery_file_records_the_host_too(isolated_home):
+ # the engine writes the address it bound; a file naming ::1 is followed there, in the
+ # bracketed form a URL needs (a file from an older engine has no host: 127.0.0.1 above)
+ import json as _json
+
+ from sentient import paths
+
+ found = paths.discovery_file()
+ found.parent.mkdir(parents=True, exist_ok=True)
+ found.write_text(_json.dumps({"host": "::1", "port": 8123}), encoding="utf-8")
+ assert discovered_engine_url() == "http://[::1]:8123"
+
+
def test_calls_carry_the_token_and_hit_the_right_routes():
with respx.mock() as router:
search = router.get(f"{BASE}/api/memories").mock(
diff --git a/tests/mcp_server/test_mcp_server.py b/tests/mcp_server/test_mcp_server.py
index 2968429b3..855ca4029 100644
--- a/tests/mcp_server/test_mcp_server.py
+++ b/tests/mcp_server/test_mcp_server.py
@@ -321,6 +321,9 @@ async def test_the_gate_sends_the_token_nowhere_until_the_engine_proves_itself()
engine.prove_ok = False
out = await _call(build_server(engine), "memory_search", {"query": "Maya"})
assert "could not prove it holds the MCP token" in out["error"]
+ # and the wording is honest: the gate retries on the next call and recovers by itself,
+ # only a replaced token needs a restart (review 5624575781, the nit)
+ assert "clears by itself" in out["error"] and "to reconnect" not in out["error"]
assert engine.status_calls == 0 # the status call is the first token carrier: it never happened
diff --git a/tests/mcp_server/test_review_fixes.py b/tests/mcp_server/test_review_fixes.py
index a24d7a6d2..fa5b6ecc9 100644
--- a/tests/mcp_server/test_review_fixes.py
+++ b/tests/mcp_server/test_review_fixes.py
@@ -13,6 +13,13 @@
7. An MCP caller cannot shape its turn: the body's model, attachments and incognito are
ignored, its text never starts a rule check, and the approval and plan-ready notifications
name the app that is asking.
+
+The final review (5624575781) adds:
+8. The proof is keyed to the whole address (host and port): a relay on the same port at the
+ other loopback address fails too, engine addresses are literal loopback only, and the
+ discovery file records the host.
+9. The app's name is cleaned (capped, control characters stripped) wherever it is shown and
+ framed as a name in the notes, so it cannot read as a sentence from Sentient.
"""
from __future__ import annotations
@@ -131,8 +138,8 @@ async def answer(ev):
assert approval_notes, "the approval must also land as a notification (review item 2)"
note = approval_notes[0]
assert note["payload"]["approval_id"] == ev.approval_id
- # the note names who is asking (this turn named no app, so the generic label)
- assert note["message"].startswith("an MCP app wants to: "), note["message"]
+ # the note names who is asking, framed as a name (this turn named no app)
+ assert note["message"].startswith("An MCP app wants to: "), note["message"]
seen["note_id"] = note["id"]
s.approvals.resolve(ev.approval_id, "allow")
@@ -229,7 +236,7 @@ def test_a_task_created_with_the_mcp_token_waits_for_plan_approval(engine_client
if ready:
break
time.sleep(0.1)
- assert ready and "Claude Code" in ready[0]["message"] # the note names the app that asked
+ assert ready and 'an app called "Claude Code"' in ready[0]["message"] # named, and framed as a name
def test_with_the_feature_off_the_mcp_token_opens_nothing_but_status(engine_client_off):
@@ -431,10 +438,11 @@ async def test_closing_an_mcp_turn_at_the_approval_still_settles_its_notificatio
def test_the_engine_proves_itself_by_challenge_before_any_token_travels(engine_client):
- # GET /api/mcp/proof answers a caller's fresh nonce with the HMAC of "nonce:port" under
- # the MCP token, the port being the one the request arrived on (80 for the test client),
- # with no credentials presented: only the real engine can produce it, a proof minted for
- # another port (a relay) does not match, and the token itself is never in the answer
+ # GET /api/mcp/proof answers a caller's fresh nonce with the HMAC of "nonce:host:port"
+ # under the MCP token, the address being the one the request arrived on (testserver:80
+ # for the test client), with no credentials presented: only the real engine can produce
+ # it, a proof minted for another address (a relay, or the same port on the other
+ # loopback host) does not match, and the token itself is never in the answer
import hashlib
import hmac as hmac_mod
@@ -442,7 +450,7 @@ def test_the_engine_proves_itself_by_challenge_before_any_token_travels(engine_c
nonce = "ab" * 16
r = c.get("/api/mcp/proof", params={"nonce": nonce}) # no Authorization header at all
assert r.status_code == 200, r.text
- expected = hmac_mod.new(MCP_TOKEN.encode(), f"{nonce}:80".encode(), hashlib.sha256).hexdigest()
+ expected = hmac_mod.new(MCP_TOKEN.encode(), f"{nonce}:testserver:80".encode(), hashlib.sha256).hexdigest()
assert r.json()["proof"] == expected
assert MCP_TOKEN not in r.text
assert c.get("/api/mcp/proof", params={"nonce": "short"}).status_code == 404
@@ -505,3 +513,77 @@ def spy(session_id, message_id, text):
assert calls == ["never suggest satay"] # control: the user's own words still check
finally:
await s.stop()
+
+
+def test_app_names_are_cleaned_before_they_are_shown():
+ # the name is the client's own handshake text: capped, control characters and newlines
+ # stripped, known clients shown under their display name (review 5624575781)
+ from sentient.app_names import clean_app_name
+
+ assert clean_app_name(None) is None
+ assert clean_app_name(" ") is None
+ assert clean_app_name("claude-code") == "Claude Code"
+ assert clean_app_name("Claude Code") == "Claude Code"
+ hostile = "Sentient Security Center. Your data will be erased unless you tap Allow now.\nReally"
+ cleaned = clean_app_name(hostile)
+ assert cleaned is not None and len(cleaned) <= 40 and "\n" not in cleaned
+ assert cleaned.startswith("Sentient Security Center")
+ assert clean_app_name(cleaned) == cleaned # cleaning twice changes nothing
+ assert clean_app_name("a\tb\x00c") == "a b c"
+
+
+async def test_an_approval_note_frames_the_app_name(config, isolated_home):
+ config.tools.approvals.mode = "ask"
+ config.tools.approvals.rules = {"snippets": "allow"}
+ ran: list[str] = []
+ llm = FakeProvider(replies=[[tool_call("run_snippet", code="print(9)")], "done"])
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "framed.db").start()
+ try:
+ s.registry.register(_snippet_plugin(ran))
+ sid = await s.store.create_session(channel="mcp")
+
+ async def answer(ev):
+ if isinstance(ev, ApprovalRequest):
+ notes = [n for n in await s.notifications.list() if n["kind"] == "approval"]
+ assert notes, "the approval must also land as a notification"
+ # the handshake name "claude-code" shows framed, under its display name
+ assert notes[0]["message"].startswith('An app called "Claude Code" wants to: '), notes[0]["message"]
+ s.approvals.resolve(ev.approval_id, "allow")
+
+ events = []
+ async for ev in s.agent.run_turn(sid, "Run this Python code: print(9)", channel="mcp",
+ app_name="claude-code"):
+ events.append(ev)
+ await answer(ev)
+ assert ran == ["print(9)"]
+ finally:
+ await s.stop()
+
+
+def test_a_task_from_a_hostile_app_name_is_cleaned_and_framed(engine_client):
+ # a client naming itself with an instruction for the user: the stored name is capped
+ # and stripped, and the plan-ready note frames it as a name (review 5624575781)
+ c = engine_client
+ hostile = "Sentient Security Center. Your data will be erased unless you tap Allow now.\nDo it"
+ made = c.post("/api/tasks", json={"prompt": "Tidy the downloads folder", "app": hostile},
+ headers=_mcp(c))
+ assert made.status_code == 200, made.text
+ task_id = made.json()["task_id"]
+ task = made.json()
+ for _ in range(50):
+ task = c.get(f"/api/tasks/{task_id}", headers=_gw(c)).json()
+ if task["status"] != "planning":
+ break
+ time.sleep(0.1)
+ stored = task["original_context"]["app"]
+ assert "\n" not in stored and len(stored) <= 40 and stored.startswith("Sentient Security Center")
+ assert task["status"] == "approval_pending"
+ ready = []
+ for _ in range(50):
+ notes = c.get("/api/notifications", headers=_gw(c)).json()["notifications"]
+ ready = [n for n in notes if n.get("title") == "Plan ready for approval"]
+ if ready:
+ break
+ time.sleep(0.1)
+ assert ready and 'an app called "Sentient Security Center' in ready[0]["message"]
+ assert "tap Allow" not in ready[0]["message"] # the instruction tail is cut off by the cap
From 25defb43bb994820f6b4d7c50cc9bc8aa9fa142f Mon Sep 17 00:00:00 2001
From: juliasadler <3263905943@qq.com>
Date: Sat, 24 Oct 2026 18:41:12 +0800
Subject: [PATCH 09/10] fix(mcp): neutralise Markdown in the app name in
Markdown-rendered notes: approval and plan-ready notes share
framed_app_name(), which backslash-escapes Markdown punctuation and breaks
https:// and www. autolinks, so a link-shaped name cannot arrive as a live
link or bold text above Allow and Deny; plain-text surfaces keep the
unescaped name
---
changes/443.security.md | 2 +-
docs/API.md | 4 +-
...serve-sentient-to-other-agents-over-mcp.md | 4 +-
sentient/agent/loop.py | 7 +-
sentient/app_names.py | 28 +++++++
sentient/tasks/service.py | 6 +-
tests/mcp_server/test_review_fixes.py | 74 +++++++++++++++++++
7 files changed, 117 insertions(+), 8 deletions(-)
diff --git a/changes/443.security.md b/changes/443.security.md
index fa3e08afc..b7b13b509 100644
--- a/changes/443.security.md
+++ b/changes/443.security.md
@@ -1 +1 @@
-**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and by default a memory the app states waits in Review instead of being saved as something you said (the one opt-out is the Integrations setting that lets MCP memories skip Review, `integrations.mcp_server_memories_skip_review`). Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one. Before that key is sent anywhere, the server makes whatever answers at the engine's address prove it holds the same key, with the proof tied to that address (host and port) so it cannot be relayed from the real engine, and it checks again before every call, following the engine when it restarts on a new port. An app's name is also cleaned and framed before it is shown to you ("An app called ..."), so an app cannot name itself something that reads like a message from Sentient where you decide whether to allow it. An app also cannot shape its turns: files, a model of its choice and the incognito switch in its requests are ignored.
+**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and by default a memory the app states waits in Review instead of being saved as something you said (the one opt-out is the Integrations setting that lets MCP memories skip Review, `integrations.mcp_server_memories_skip_review`). Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one. Before that key is sent anywhere, the server makes whatever answers at the engine's address prove it holds the same key, with the proof tied to that address (host and port) so it cannot be relayed from the real engine, and it checks again before every call, following the engine when it restarts on a new port. An app's name is also cleaned and framed before it is shown to you ("An app called ..."), so an app cannot name itself something that reads like a message from Sentient where you decide whether to allow it. Where a note is rendered as Markdown, the name is escaped as well, so it cannot arrive as a link or bold text above Allow and Deny. An app also cannot shape its turns: files, a model of its choice and the incognito switch in its requests are ignored.
diff --git a/docs/API.md b/docs/API.md
index fe84e07af..267fa5473 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -1665,7 +1665,9 @@ transport is deliberately not offered; it may return later, protected by the sam
notification (section 6) the user can answer in the window or on their phone. The app's name is the
client's own handshake text, so wherever it is shown (the turn's mark, the approval and plan-ready
notes, the Review list) it is cleaned first (control characters stripped, capped at 40 characters)
- and framed as a name (`An app called "..."`), never left to read as a sentence from Sentient. The body's `model`,
+ and framed as a name (`An app called "..."`), never left to read as a sentence from Sentient. Where a
+ note is rendered as Markdown (approval, plan-ready), the name is additionally escaped, so it cannot
+ form a link, emphasis or an autolinked URL above the Allow and Deny buttons. The body's `model`,
`attachments` and `incognito` are ignored, so the app cannot pull files into the turn, pick a model or
flip the user's incognito switch. The MCP client is outside content and can never answer an approval.
- `mcp` is also in the frozen engine's command list (`packaging/engine_entry.py`), which `tests/test_packaging.py`
diff --git a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
index 5730e0e8c..28cc81a45 100644
--- a/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
+++ b/docs/adr/0034-serve-sentient-to-other-agents-over-mcp.md
@@ -76,7 +76,9 @@ it was waiting on.
even when plans normally approve themselves, and every run marked as started by that app. A
notification is a plain info note inside Sentient; the endpoint ignores any kind a caller asks
for, and for an MCP caller the engine puts the app's name in the note's title, so a note can
- never pass itself off as a message from Sentient itself.
+ never pass itself off as a message from Sentient itself. The name is the app's own text, so
+ it is cleaned before it is shown anywhere, and where a note is rendered as Markdown it is
+ escaped as well, so it cannot form a link or emphasis above an approval decision.
- **Asking Sentient is outside content from the start.** `ask_sentient` runs one turn through
`POST /api/chat`, and an MCP caller is forced onto the `mcp` channel whatever it claims. The
turn follows ADR 0033's email pattern: the session is marked untrusted before anything runs,
diff --git a/sentient/agent/loop.py b/sentient/agent/loop.py
index 9ff3641f6..187caf735 100644
--- a/sentient/agent/loop.py
+++ b/sentient/agent/loop.py
@@ -58,7 +58,7 @@
from sentient.agent.prompt import build_system_prompt, mark_outside
from sentient.agent.sources import AnswerSources
from sentient.agent.toolselect import ToolSelector, is_local_model
-from sentient.app_names import clean_app_name
+from sentient.app_names import clean_app_name, framed_app_name
from sentient.config.schema import SentientConfig
from sentient.files.extract import extract_text, is_image
from sentient.integrations.base import IntegrationPlugin
@@ -1373,9 +1373,10 @@ async def _ask(self, p: _CallPlan, ctx: ToolContext, ev: dict) -> AsyncIterator[
target = wording.get("target")
# the note names the app that is asking (the turn's mark on this channel), framed
# as a name rather than a sentence the app could speak as Sentient: the name is
- # the client's own handshake text, cleaned where the turn was marked
+ # the client's own handshake text, cleaned where the turn was marked, and, the
+ # note being rendered as Markdown, escaped so it cannot form a link or emphasis
who = ctx.untrusted
- framed = f'An app called "{who}"' if who and who != "an MCP app" else "An MCP app"
+ framed = framed_app_name(who)
message = f"{framed} wants to: {label}: {target}" if target else f"{framed} wants to: {label}"
try:
note = await self.app.notify(
diff --git a/sentient/app_names.py b/sentient/app_names.py
index 5ea06c148..c3d42f8de 100644
--- a/sentient/app_names.py
+++ b/sentient/app_names.py
@@ -21,6 +21,34 @@
_WS = re.compile(r"\s+")
+# Punctuation Markdown can act on where a note is rendered as such (the desktop renders
+# approval and plan-ready notes as Markdown): emphasis, code, links, images, HTML, strikes.
+_MARKDOWN_PUNCT = re.compile(r"([\\`*{}\[\]()#<>!|~_])")
+_SCHEME = re.compile(r"(?i)\b([a-z][a-z0-9+.-]*)://")
+_WWW = re.compile(r"(?i)\bwww\.")
+
+
+def _escape_markdown(text: str) -> str:
+ """The text with nothing left that Markdown can act on: punctuation backslash-escaped
+ in one pass (so an introduced backslash is never escaped again), and URL schemes and
+ www. broken so GFM cannot autolink them. What renders is still the same characters."""
+ text = _MARKDOWN_PUNCT.sub(r"\\\1", text)
+ text = _SCHEME.sub(r"\1\\://", text)
+ return _WWW.sub(r"www\\.", text)
+
+
+def framed_app_name(name: object, *, capital: bool = True) -> str:
+ """The app name as it may go into a Markdown-rendered note (approval, plan-ready).
+
+ The name itself is already cleaned (see clean_app_name); this only neutralises what
+ Markdown would do with it: a client calling itself "[Fix it](https://evil.co) **NOW**"
+ must not arrive as a live link with bold text right above Allow and Deny. Plain-text
+ surfaces (the turn mark, the Review list, notify titles) keep the unescaped name."""
+ if not isinstance(name, str) or not name or name.lower() == "an mcp app":
+ return "An MCP app" if capital else "an MCP app"
+ framed = f'an app called "{_escape_markdown(name)}"'
+ return framed[0].upper() + framed[1:] if capital else framed
+
def clean_app_name(raw: object) -> str | None:
"""The display form of an app's name, or None when nothing usable remains."""
diff --git a/sentient/tasks/service.py b/sentient/tasks/service.py
index 28d4ce5ea..4cb9cb679 100644
--- a/sentient/tasks/service.py
+++ b/sentient/tasks/service.py
@@ -47,6 +47,7 @@
from sentient.agent import dry_run, outcomes
from sentient.agent import grants as grant_rules
from sentient.agent.prompt import mark_outside
+from sentient.app_names import framed_app_name
from sentient.llm.jobs import detached
from sentient.llm.provider import PlainProviderError, ProviderError
from sentient.memory import scopes as memory_scopes
@@ -1812,9 +1813,10 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None:
message = f"I've created a new plan for you: '{short}'"
if mcp_tasks.is_mcp_task(task):
# the note names the app that asked for the task, framed as a name like the
- # approval notes do: the name is the app's own text, cleaned where it came in
+ # approval notes do: the name is the app's own text, cleaned where it came in,
+ # and, the note being rendered as Markdown, escaped so it cannot form a link
who = (task.get("original_context") or {}).get("app")
- framed = f'an app called "{who}"' if who and who != "an MCP app" else "an MCP app"
+ framed = framed_app_name(who, capital=False)
message = f"I've created a new plan for {framed}: '{short}'"
await self._notify(task, message, "Plan ready for approval", "approval_needed")
diff --git a/tests/mcp_server/test_review_fixes.py b/tests/mcp_server/test_review_fixes.py
index fa5b6ecc9..15389f66b 100644
--- a/tests/mcp_server/test_review_fixes.py
+++ b/tests/mcp_server/test_review_fixes.py
@@ -587,3 +587,77 @@ def test_a_task_from_a_hostile_app_name_is_cleaned_and_framed(engine_client):
time.sleep(0.1)
assert ready and 'an app called "Sentient Security Center' in ready[0]["message"]
assert "tap Allow" not in ready[0]["message"] # the instruction tail is cut off by the cap
+
+
+def test_markdown_in_an_app_name_is_neutralised_in_the_notes():
+ # the approval and plan-ready notes are rendered as Markdown in the app: a link-shaped
+ # name must not arrive as a live link with bold text above Allow and Deny (review 5637731781)
+ from sentient.app_names import framed_app_name
+
+ hostile = "[Fix it](https://evil.co) **NOW**"
+ assert framed_app_name(hostile) == r'An app called "\[Fix it\]\(https\://evil.co\) \*\*NOW\*\*"'
+ assert framed_app_name(hostile, capital=False).startswith('an app called "')
+ assert "https://" not in framed_app_name("see https://evil.co now")
+ assert "www.evil.co" not in framed_app_name("visit www.evil.co")
+ assert framed_app_name("Claude Code") == 'An app called "Claude Code"'
+ assert framed_app_name(None) == "An MCP app"
+ assert framed_app_name("an MCP app") == "An MCP app"
+ assert framed_app_name("An MCP app", capital=False) == "an MCP app"
+
+
+async def test_an_approval_note_does_not_render_a_link_shaped_app_name(config, isolated_home):
+ config.tools.approvals.mode = "ask"
+ config.tools.approvals.rules = {"snippets": "allow"}
+ ran: list[str] = []
+ llm = FakeProvider(replies=[[tool_call("run_snippet", code="print(9)")], "done"])
+ s = await SentientApp(config, llm=llm, db_path=isolated_home / "mdnote.db").start()
+ try:
+ s.registry.register(_snippet_plugin(ran))
+ sid = await s.store.create_session(channel="mcp")
+
+ async def answer(ev):
+ if isinstance(ev, ApprovalRequest):
+ notes = [n for n in await s.notifications.list() if n["kind"] == "approval"]
+ assert notes, "the approval must also land as a notification"
+ message = notes[0]["message"]
+ # the name is quoted and escaped: no live link, no bold, no bare URL
+ assert message.startswith(r'An app called "\[Fix it\]\(https\://evil.co\)'), message
+ assert "[Fix it](https://evil.co)" not in message and "**NOW**" not in message
+ s.approvals.resolve(ev.approval_id, "allow")
+
+ events = []
+ async for ev in s.agent.run_turn(sid, "Run this Python code: print(9)", channel="mcp",
+ app_name="[Fix it](https://evil.co) **NOW**"):
+ events.append(ev)
+ await answer(ev)
+ assert ran == ["print(9)"]
+ finally:
+ await s.stop()
+
+
+def test_a_plan_ready_note_does_not_render_a_link_shaped_app_name(engine_client):
+ # the plan-ready note is rendered as Markdown too, so it shares the same escape
+ c = engine_client
+ made = c.post("/api/tasks", json={"prompt": "Tidy the downloads folder",
+ "app": "[Fix it](https://evil.co) **NOW**"},
+ headers=_mcp(c))
+ assert made.status_code == 200, made.text
+ task_id = made.json()["task_id"]
+ task = made.json()
+ for _ in range(50):
+ task = c.get(f"/api/tasks/{task_id}", headers=_gw(c)).json()
+ if task["status"] != "planning":
+ break
+ time.sleep(0.1)
+ assert task["status"] == "approval_pending"
+ ready = []
+ for _ in range(50):
+ notes = c.get("/api/notifications", headers=_gw(c)).json()["notifications"]
+ ready = [n for n in notes if n.get("title") == "Plan ready for approval"]
+ if ready:
+ break
+ time.sleep(0.1)
+ assert ready, "the plan-ready note must exist"
+ message = ready[0]["message"]
+ assert r'an app called "\[Fix it\]\(https\://evil.co\)' in message, message
+ assert "[Fix it](https://evil.co)" not in message and "**NOW**" not in message
From 893323630d2c1838b74e97b289f71663b9de69b1 Mon Sep 17 00:00:00 2001
From: juliasadler <3263905943@qq.com>
Date: Mon, 26 Oct 2026 09:12:48 +0800
Subject: [PATCH 10/10] fix(mcp): put the app name in an inline code span in
Markdown-rendered notes: backslash escaping cannot stop GFM autolinks
(remark-gfm resolves escapes before it looks for bare URLs), so approval and
plan-ready notes now share framed_app_name() built on code_span(), whose
fence outgrows any backtick run in the name; a plan-ready note for an MCP
task code-spans the plan title too, and a desktop test renders the notes
through react-markdown + remark-gfm; plain-text surfaces keep the bare name
---
changes/443.security.md | 2 +-
desktop/tests/mcpAppNameMarkdown.test.ts | 82 +++++++++++++++++++
docs/API.md | 8 +-
...serve-sentient-to-other-agents-over-mcp.md | 3 +-
sentient/agent/loop.py | 3 +-
sentient/app_names.py | 40 +++++----
sentient/tasks/service.py | 8 +-
tests/mcp_server/test_review_fixes.py | 42 ++++++----
8 files changed, 145 insertions(+), 43 deletions(-)
create mode 100644 desktop/tests/mcpAppNameMarkdown.test.ts
diff --git a/changes/443.security.md b/changes/443.security.md
index b7b13b509..21ba730a8 100644
--- a/changes/443.security.md
+++ b/changes/443.security.md
@@ -1 +1 @@
-**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and by default a memory the app states waits in Review instead of being saved as something you said (the one opt-out is the Integrations setting that lets MCP memories skip Review, `integrations.mcp_server_memories_skip_review`). Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one. Before that key is sent anywhere, the server makes whatever answers at the engine's address prove it holds the same key, with the proof tied to that address (host and port) so it cannot be relayed from the real engine, and it checks again before every call, following the engine when it restarts on a new port. An app's name is also cleaned and framed before it is shown to you ("An app called ..."), so an app cannot name itself something that reads like a message from Sentient where you decide whether to allow it. Where a note is rendered as Markdown, the name is escaped as well, so it cannot arrive as a link or bold text above Allow and Deny. An app also cannot shape its turns: files, a model of its choice and the incognito switch in its requests are ignored.
+**MCP apps are now kept apart from your own words.** When an app asks Sentient a question, Sentient treats its words as the app's, never as yours: anything that sends, deletes or runs code asks you first, in the window and as a notification you can answer on your phone, and by default a memory the app states waits in Review instead of being saved as something you said (the one opt-out is the Integrations setting that lets MCP memories skip Review, `integrations.mcp_server_memories_skip_review`). Apps also get a key of their own that opens only the few things the tools need, never your settings or approvals, and you can replace it under Integrations to cut off every app holding the old one. Before that key is sent anywhere, the server makes whatever answers at the engine's address prove it holds the same key, with the proof tied to that address (host and port) so it cannot be relayed from the real engine, and it checks again before every call, following the engine when it restarts on a new port. An app's name is also cleaned and framed before it is shown to you ("An app called ..."), so an app cannot name itself something that reads like a message from Sentient where you decide whether to allow it. Where a note is rendered as Markdown, the name goes in an inline code span, so nothing inside it is parsed into a link or bold text above Allow and Deny; a plan-ready note for an MCP task quotes the plan's title the same way. An app also cannot shape its turns: files, a model of its choice and the incognito switch in its requests are ignored.
diff --git a/desktop/tests/mcpAppNameMarkdown.test.ts b/desktop/tests/mcpAppNameMarkdown.test.ts
new file mode 100644
index 000000000..8d4545291
--- /dev/null
+++ b/desktop/tests/mcpAppNameMarkdown.test.ts
@@ -0,0 +1,82 @@
+// An MCP app's name lands in the approval and plan-ready notes, which the app renders
+// as Markdown (components/ui/Markdown.tsx: react-markdown + remark-gfm + rehype-highlight,
+// skipHtml). Backslash escaping cannot stop GFM autolinks: remark-gfm resolves escapes
+// before it looks for bare URLs, so `https\://evil.co` still renders as a live link
+// (review 5641825878). The engine therefore puts the name in an inline code span, whose
+// contents Markdown never parses. These tests render the notes exactly that way and pin
+// the result: one per span, never an , or .
+// Run with `npm test` (no Electron needed).
+import assert from 'node:assert/strict'
+import { test } from 'node:test'
+import React, { type ComponentProps } from 'react'
+import { renderToStaticMarkup } from 'react-dom/server'
+import ReactMarkdown from 'react-markdown'
+import remarkGfm from 'remark-gfm'
+import rehypeHighlight from 'rehype-highlight'
+
+const remarkPlugins = [remarkGfm]
+const rehypePlugins: ComponentProps['rehypePlugins'] = [[rehypeHighlight, { detect: false }]]
+
+function render(note: string): string {
+ return renderToStaticMarkup(
+ React.createElement(ReactMarkdown, { remarkPlugins, rehypePlugins, skipHtml: true }, note)
+ )
+}
+
+// The engine's code-span rule (sentient/app_names.py::code_span): a fence one backtick
+// longer than the longest run inside, with a space inside each end.
+function codeSpan(text: string): string {
+ const longest = (text.match(/`+/g) ?? []).reduce((n, run) => Math.max(n, run.length), 0)
+ return `${'`'.repeat(longest + 1)} ${text} ${'`'.repeat(longest + 1)}`
+}
+
+function approvalNote(appName: string): string {
+ return `An app called ${codeSpan(appName)} wants to: Runs code`
+}
+
+function count(html: string, tag: string): number {
+ return html.split(tag).length - 1
+}
+
+// Every hostile name from review 5641825878, plus backticks, entities and other schemes.
+const hostileNames = [
+ '[Fix it](https://evil.co) **NOW**',
+ 'https://evil.co/allow',
+ 'www.evil.co',
+ 'WWW.EVIL.CO',
+ '',
+ '_https://evil.co',
+ 'support@evil.co',
+ 'mailto:evil@evil.co',
+ 'xmpp:foo@evil.co',
+ '',
+ '[evil]',
+ '[^1] see [x][y]',
+ 'a `b` c',
+ '`x'
+]
+
+test('an approval note with a hostile app name renders one code span, no link', () => {
+ for (const name of hostileNames) {
+ const html = render(approvalNote(name))
+ assert.equal(count(html, ''), 1, name)
+ assert.equal(count(html, ''), 1, name)
+ assert.ok(!html.includes(' ${html}`)
+ assert.ok(!html.includes(' ${html}`)
+ assert.ok(!html.includes(' ${html}`)
+ }
+})
+
+test('the maintainer’s example renders its characters, not a link', () => {
+ const html = render(approvalNote('[Fix it](https://evil.co) **NOW**'))
+ assert.ok(html.includes('[Fix it](https://evil.co) **NOW**'), html)
+})
+
+test('a plan-ready note with a hostile name and a hostile title renders two code spans, no link', () => {
+ const note = `I've created a new plan for an app called ${codeSpan('www.evil.co')}: ${codeSpan('See [this](https://evil.co) now')}`
+ const html = render(note)
+ assert.equal(count(html, ''), 2, html)
+ assert.ok(!html.includes(' AsyncIterator[
# the note names the app that is asking (the turn's mark on this channel), framed
# as a name rather than a sentence the app could speak as Sentient: the name is
# the client's own handshake text, cleaned where the turn was marked, and, the
- # note being rendered as Markdown, escaped so it cannot form a link or emphasis
+ # note being rendered as Markdown, put in a code span so Markdown cannot parse
+ # it into a link or emphasis
who = ctx.untrusted
framed = framed_app_name(who)
message = f"{framed} wants to: {label}: {target}" if target else f"{framed} wants to: {label}"
diff --git a/sentient/app_names.py b/sentient/app_names.py
index c3d42f8de..34240e3e7 100644
--- a/sentient/app_names.py
+++ b/sentient/app_names.py
@@ -5,7 +5,7 @@
will be erased unless you tap Allow now.", and uncleaned that sentence would land in the
approval notification the user decides from. Every surface that shows the name therefore
takes it from here: control characters and newlines stripped, capped at 40 characters, and
-the notes that quote it frame it as a name (``An app called "..."``) instead of letting it
+the notes that quote it frame it as a name (``An app called `...` ``) instead of letting it
speak as if it were Sentient.
"""
@@ -21,32 +21,36 @@
_WS = re.compile(r"\s+")
-# Punctuation Markdown can act on where a note is rendered as such (the desktop renders
-# approval and plan-ready notes as Markdown): emphasis, code, links, images, HTML, strikes.
-_MARKDOWN_PUNCT = re.compile(r"([\\`*{}\[\]()#<>!|~_])")
-_SCHEME = re.compile(r"(?i)\b([a-z][a-z0-9+.-]*)://")
-_WWW = re.compile(r"(?i)\bwww\.")
+# Backtick runs, for sizing a code-span fence one longer than any run inside.
+_BACKTICK_RUN = re.compile(r"`+")
-def _escape_markdown(text: str) -> str:
- """The text with nothing left that Markdown can act on: punctuation backslash-escaped
- in one pass (so an introduced backslash is never escaped again), and URL schemes and
- www. broken so GFM cannot autolink them. What renders is still the same characters."""
- text = _MARKDOWN_PUNCT.sub(r"\\\1", text)
- text = _SCHEME.sub(r"\1\\://", text)
- return _WWW.sub(r"www\\.", text)
+def code_span(text: str) -> str:
+ """The text as one inline code span: a fence one backtick longer than the longest
+ run of backticks inside, with a space inside each end (rendered away).
+
+ Nothing inside a code span is parsed, so this is what a Markdown-rendered note uses
+ for app-controlled text. Backslash escaping cannot do this job: remark-gfm resolves
+ escapes before it looks for autolinks, so `https\\://evil.co` still arrives as a live
+ link (review 5641825878)."""
+ if not text or not text.strip():
+ return text
+ fence = "`" * (max((len(m.group(0)) for m in _BACKTICK_RUN.finditer(text)), default=0) + 1)
+ return f"{fence} {text} {fence}"
def framed_app_name(name: object, *, capital: bool = True) -> str:
"""The app name as it may go into a Markdown-rendered note (approval, plan-ready).
- The name itself is already cleaned (see clean_app_name); this only neutralises what
- Markdown would do with it: a client calling itself "[Fix it](https://evil.co) **NOW**"
- must not arrive as a live link with bold text right above Allow and Deny. Plain-text
- surfaces (the turn mark, the Review list, notify titles) keep the unescaped name."""
+ The name itself is already cleaned (see clean_app_name); this only stops Markdown
+ from acting on it: a client calling itself "[Fix it](https://evil.co) **NOW**" must
+ not arrive as a live link with bold text right above Allow and Deny. The name goes
+ in an inline code span, so nothing inside it is parsed at all — links, autolinks
+ (bare URLs and emails included), emphasis, images. Plain-text surfaces (the turn
+ mark, the Review list, notify titles) keep the bare name."""
if not isinstance(name, str) or not name or name.lower() == "an mcp app":
return "An MCP app" if capital else "an MCP app"
- framed = f'an app called "{_escape_markdown(name)}"'
+ framed = f"an app called {code_span(name)}"
return framed[0].upper() + framed[1:] if capital else framed
diff --git a/sentient/tasks/service.py b/sentient/tasks/service.py
index 4cb9cb679..0ad7e9b3d 100644
--- a/sentient/tasks/service.py
+++ b/sentient/tasks/service.py
@@ -47,7 +47,7 @@
from sentient.agent import dry_run, outcomes
from sentient.agent import grants as grant_rules
from sentient.agent.prompt import mark_outside
-from sentient.app_names import framed_app_name
+from sentient.app_names import code_span, framed_app_name
from sentient.llm.jobs import detached
from sentient.llm.provider import PlainProviderError, ProviderError
from sentient.memory import scopes as memory_scopes
@@ -1814,10 +1814,12 @@ async def _plan(self, task_id: str, *, auto_approve: bool = False) -> None:
if mcp_tasks.is_mcp_task(task):
# the note names the app that asked for the task, framed as a name like the
# approval notes do: the name is the app's own text, cleaned where it came in,
- # and, the note being rendered as Markdown, escaped so it cannot form a link
+ # and, the note being rendered as Markdown, put in a code span so Markdown
+ # cannot parse it into a link. The plan's title is the app's prompt word for
+ # word, so it goes in a code span too
who = (task.get("original_context") or {}).get("app")
framed = framed_app_name(who, capital=False)
- message = f"I've created a new plan for {framed}: '{short}'"
+ message = f"I've created a new plan for {framed}: {code_span(short)}"
await self._notify(task, message, "Plan ready for approval", "approval_needed")
async def _planned_script(
diff --git a/tests/mcp_server/test_review_fixes.py b/tests/mcp_server/test_review_fixes.py
index 15389f66b..40fcb69ac 100644
--- a/tests/mcp_server/test_review_fixes.py
+++ b/tests/mcp_server/test_review_fixes.py
@@ -236,7 +236,7 @@ def test_a_task_created_with_the_mcp_token_waits_for_plan_approval(engine_client
if ready:
break
time.sleep(0.1)
- assert ready and 'an app called "Claude Code"' in ready[0]["message"] # named, and framed as a name
+ assert ready and 'an app called ` Claude Code `' in ready[0]["message"] # named, and framed as a name
def test_with_the_feature_off_the_mcp_token_opens_nothing_but_status(engine_client_off):
@@ -547,7 +547,7 @@ async def answer(ev):
notes = [n for n in await s.notifications.list() if n["kind"] == "approval"]
assert notes, "the approval must also land as a notification"
# the handshake name "claude-code" shows framed, under its display name
- assert notes[0]["message"].startswith('An app called "Claude Code" wants to: '), notes[0]["message"]
+ assert notes[0]["message"].startswith('An app called ` Claude Code ` wants to: '), notes[0]["message"]
s.approvals.resolve(ev.approval_id, "allow")
events = []
@@ -585,21 +585,28 @@ def test_a_task_from_a_hostile_app_name_is_cleaned_and_framed(engine_client):
if ready:
break
time.sleep(0.1)
- assert ready and 'an app called "Sentient Security Center' in ready[0]["message"]
+ assert ready and 'an app called ` Sentient Security Center' in ready[0]["message"]
assert "tap Allow" not in ready[0]["message"] # the instruction tail is cut off by the cap
def test_markdown_in_an_app_name_is_neutralised_in_the_notes():
# the approval and plan-ready notes are rendered as Markdown in the app: a link-shaped
- # name must not arrive as a live link with bold text above Allow and Deny (review 5637731781)
- from sentient.app_names import framed_app_name
+ # name must not arrive as a live link with bold text above Allow and Deny. Backslash
+ # escaping cannot stop GFM autolinks (review 5641825878), so the name goes in an
+ # inline code span, whose contents Markdown never parses
+ from sentient.app_names import code_span, framed_app_name
hostile = "[Fix it](https://evil.co) **NOW**"
- assert framed_app_name(hostile) == r'An app called "\[Fix it\]\(https\://evil.co\) \*\*NOW\*\*"'
- assert framed_app_name(hostile, capital=False).startswith('an app called "')
- assert "https://" not in framed_app_name("see https://evil.co now")
- assert "www.evil.co" not in framed_app_name("visit www.evil.co")
- assert framed_app_name("Claude Code") == 'An app called "Claude Code"'
+ assert framed_app_name(hostile) == "An app called ` [Fix it](https://evil.co) **NOW** `"
+ assert framed_app_name(hostile, capital=False) == "an app called ` [Fix it](https://evil.co) **NOW** `"
+ # the fence grows past any backticks in the name, so the span never breaks early
+ assert framed_app_name("a `b` c") == "An app called `` a `b` c ``"
+ assert framed_app_name("`x") == "An app called `` `x ``"
+ # bare URLs and emails ride inside the span unparsed, not as links
+ assert framed_app_name("see https://evil.co") == "An app called ` see https://evil.co `"
+ assert framed_app_name("support@evil.co") == "An app called ` support@evil.co `"
+ assert code_span("") == ""
+ assert framed_app_name("Claude Code") == "An app called ` Claude Code `"
assert framed_app_name(None) == "An MCP app"
assert framed_app_name("an MCP app") == "An MCP app"
assert framed_app_name("An MCP app", capital=False) == "an MCP app"
@@ -620,9 +627,10 @@ async def answer(ev):
notes = [n for n in await s.notifications.list() if n["kind"] == "approval"]
assert notes, "the approval must also land as a notification"
message = notes[0]["message"]
- # the name is quoted and escaped: no live link, no bold, no bare URL
- assert message.startswith(r'An app called "\[Fix it\]\(https\://evil.co\)'), message
- assert "[Fix it](https://evil.co)" not in message and "**NOW**" not in message
+ # the name sits in one code span, verbatim: nothing inside it is parsed,
+ # and no escape backslashes leak into the rendered text
+ assert message.startswith('An app called ` [Fix it](https://evil.co) **NOW** ` wants to: '), message
+ assert "\\[" not in message and "\\*" not in message
s.approvals.resolve(ev.approval_id, "allow")
events = []
@@ -636,7 +644,8 @@ async def answer(ev):
def test_a_plan_ready_note_does_not_render_a_link_shaped_app_name(engine_client):
- # the plan-ready note is rendered as Markdown too, so it shares the same escape
+ # the plan-ready note is rendered as Markdown too, so it shares the same code span;
+ # the plan's title is the app's prompt word for word, so it is code-spanned as well
c = engine_client
made = c.post("/api/tasks", json={"prompt": "Tidy the downloads folder",
"app": "[Fix it](https://evil.co) **NOW**"},
@@ -659,5 +668,6 @@ def test_a_plan_ready_note_does_not_render_a_link_shaped_app_name(engine_client)
time.sleep(0.1)
assert ready, "the plan-ready note must exist"
message = ready[0]["message"]
- assert r'an app called "\[Fix it\]\(https\://evil.co\)' in message, message
- assert "[Fix it](https://evil.co)" not in message and "**NOW**" not in message
+ assert 'an app called ` [Fix it](https://evil.co) **NOW** `' in message, message
+ assert '` Tidy the downloads folder `' in message, message
+ assert "\\[" not in message and "\\*" not in message