Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changes/443.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +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 is made in the app's name and always needs your approval before it runs.
1 change: 1 addition & 0 deletions changes/443.security.md
Original file line number Diff line number Diff line change
@@ -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 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.
11 changes: 9 additions & 2 deletions desktop/src/features/integrations/IntegrationsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { ConnectDialog } from '@/features/integrations/ConnectDialog'
import { IntegrationCard } from '@/features/integrations/IntegrationCard'
import { IntegrationDrawer } from '@/features/integrations/IntegrationDrawer'
import { McpServersSection } from '@/features/integrations/McpServers'
import { SentientMcpSection } from '@/features/integrations/SentientMcp'
import { CATEGORIES, sortIntegrations, type CategoryFilter } from '@/features/integrations/meta'
import { useIntegrations } from '@/hooks/integrations'
import { useMcpServers } from '@/hooks/integrations'
Expand All @@ -21,7 +22,7 @@ import { cn } from '@/lib/utils'
* Deep links (also used by screenshots): `?connect=<id>`,
* `?open=<id>[&focus=privacy|tools|triggers|upgrades|disconnect|forget]` (`forget`: the disconnect question with
* "Also forget" ticked, #153),
* `?section=mcp|webhooks`, `?q=<search>`, `?category=<id>`.
* `?section=mcp|webhooks|sentient-mcp`, `?q=<search>`, `?category=<id>`.
*/
export function IntegrationsPage() {
const [params, setParams] = useSearchParams()
Expand Down Expand Up @@ -81,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)
Expand Down Expand Up @@ -245,6 +247,11 @@ export function IntegrationsPage() {
<McpServersSection id="mcp-servers" />
</div>
)}
{showMcp && (
<div className="border-t border-border pt-8">
<SentientMcpSection id="sentient-mcp" />
</div>
)}
</div>
)}
</div>
Expand Down
177 changes: 177 additions & 0 deletions desktop/src/features/integrations/SentientMcp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { IconCheck, IconCopy, IconShieldCheck } from '@tabler/icons-react'
import { useState } from 'react'
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 { api, errorMessage } from '@/lib/api'
import { copyText } from '@/lib/utils'
import { useConnection } from '@/stores/connection'

function CopyButton({ text, label }: { text: string; label: string }) {
const [copied, setCopied] = useState(false)
return (
<IconButton
size="xs"
label={copied ? 'Copied' : label}
icon={copied ? <IconCheck size={13} className="text-success" /> : <IconCopy size={13} />}
onClick={() =>
void copyText(text).then((ok) => {
if (!ok) {
toast.error("Couldn't copy to the clipboard")
return
}
setCopied(true)
window.setTimeout(() => setCopied(false), 1400)
})
}
/>
)
}

function CodeBlock({ label, code, copyLabel }: { label: string; code: string; copyLabel: string }) {
return (
<div>
<div className="mb-1.5 flex items-center justify-between gap-3">
<span className="text-sm font-medium text-fg">{label}</span>
<CopyButton text={code} label={copyLabel} />
</div>
<pre className="selectable overflow-x-auto rounded-xl border border-border bg-sunken px-3.5 py-3 font-mono text-[12px] leading-relaxed text-fg">{code}</pre>
</div>
)
}

/**
* 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, 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 (
<section id={id} className="scroll-mt-6">
<div className="mb-3">
<h2 className="text-md font-semibold text-fg">Let other apps use Sentient</h2>
<p className="mt-0.5 max-w-2xl text-sm text-fg-subtle">
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.
</p>
</div>

{isLoading ? (
<Skeleton className="h-40 rounded-xl" />
) : error || !config ? (
<Alert
tone="danger"
title="Couldn't load settings"
action={
<Button size="sm" onClick={() => void configQuery.refetch()}>
Try again
</Button>
}
>
{error ? errorMessage(error) : 'Sentient did not return its settings. Try again.'}
</Alert>
) : (
<div className="space-y-5">
<FormSection>
<FormRow
label="Let other apps use Sentient"
description="When this is on, MCP apps on this computer can reach Sentient. It only works on this computer."
htmlFor="sentient-mcp-enabled"
>
<Switch
id="sentient-mcp-enabled"
checked={enabled}
onCheckedChange={(v) => setValue('integrations.mcp_server_enabled', v, { immediate: true })}
/>
</FormRow>
<FormRow
label="Let MCP apps save memories without review"
description="By default, a memory added by another app waits in Review for your approval. Turn this on only if you want those memories saved right away."
htmlFor="sentient-mcp-skip-review"
>
<Switch
id="sentient-mcp-skip-review"
checked={skipReview}
disabled={!enabled}
onCheckedChange={(v) => setValue('integrations.mcp_server_memories_skip_review', v, { immediate: true })}
/>
</FormRow>
<FormRow
label="App access key"
description="Apps prove themselves with a key made only for this, kept in your keychain. It opens only the few things the tools need, never your settings or approvals. Replacing it cuts off every app still holding the old key until the app restarts."
>
<Button size="sm" variant="secondary" loading={replacing} disabled={!enabled} onClick={replaceKey}>
Replace key
</Button>
</FormRow>
</FormSection>

<div className="space-y-4 rounded-xl border border-border bg-surface px-4 py-4">
<div>
<div className="text-sm font-semibold text-fg">Add Sentient to your app</div>
<p className="mt-0.5 text-xs leading-relaxed text-fg-subtle">
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.
</p>
</div>
<CodeBlock label="Claude Code" code={claudeCodeCommand} copyLabel="Copy Claude Code command" />
<CodeBlock label="Other apps" code={snippet} copyLabel="Copy settings" />
</div>

<Alert tone="info" icon={<IconShieldCheck />} title="How Sentient keeps this safe">
<ul className="list-disc space-y-1 pl-4">
<li>There are no tools to send messages, delete things or run commands.</li>
<li>A task made this way is made in the app&apos;s name, and its plan always waits for your approval before it runs.</li>
<li>
When an app asks Sentient a question, Sentient treats the words as the app&apos;s, not yours: anything that sends, deletes or runs code asks you first,
in the window and as a notification.
</li>
<li>A memory an app saves waits in Review for your approval, unless you allow saving right away above.</li>
</ul>
</Alert>
</div>
)}
</section>
)
}
2 changes: 2 additions & 0 deletions desktop/src/features/settings/SchemaForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,8 @@ export const LABELS: Record<string, FieldLabel> = {
'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 }
}
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -662,6 +662,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<Integration[]>('/api/integrations'),
get: (id: string) => http.get<Integration>(`/api/integrations/${enc(id)}`),
connect: (id: string, fields: Record<string, string> = {}, access?: ConnectionAccess) =>
Expand Down
4 changes: 4 additions & 0 deletions desktop/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,10 @@ export interface IntegrationsConfig {
searxng_url: string
weather_provider: 'open_meteo' | 'accuweather'
mcp_servers: Record<string, McpServerConfig>
/** 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
Expand Down
82 changes: 82 additions & 0 deletions desktop/tests/mcpAppNameMarkdown.test.ts
Original file line number Diff line number Diff line change
@@ -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 <code> per span, never an <a>, <strong> or <img>.
// 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<typeof ReactMarkdown>['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',
'![x](http://evil.co/p.png)',
'_https://evil.co',
'support@evil.co',
'mailto:evil@evil.co',
'xmpp:foo@evil.co',
'<https://evil.co>',
'&#91;evil&#93;',
'[^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, '<code>'), 1, name)
assert.equal(count(html, '</code>'), 1, name)
assert.ok(!html.includes('<a '), `${name} -> ${html}`)
assert.ok(!html.includes('<strong'), `${name} -> ${html}`)
assert.ok(!html.includes('<img'), `${name} -> ${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('<code>[Fix it](https://evil.co) **NOW**</code>'), 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, '<code>'), 2, html)
assert.ok(!html.includes('<a '), html)
assert.ok(!html.includes('<strong'), html)
assert.ok(!html.includes('<img'), html)
})
Loading
Loading