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
63 changes: 63 additions & 0 deletions apps/web/src/api/mcpKeys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { authFetch } from './client'

/**
* Connect keys — the credential a reader pastes into Claude or ChatGPT so it can reach their books.
*
* The remote MCP endpoint is stateless and reads a bearer per request, so before this the only thing
* it could take was a 60-minute access token minted for the local transport. A connector configured
* with one stopped working inside the hour.
*/

export interface McpKey {
id: string
name: string
/** The clear-text head of the key. Enough to match a row against a config file, useless alone. */
prefix: string
createdAt: string
/**
* Null until the key has authenticated a request; written at most hourly, so it means "recently"
* rather than "exactly then". This is the field that answers "is the connector I just set up
* actually talking to us".
*/
lastUsedAt: string | null
revokedAt: string | null
}

/** The only response that ever carries the key itself. It is unrecoverable afterwards. */
export interface CreatedMcpKey extends Omit<McpKey, 'lastUsedAt' | 'revokedAt'> {
key: string
}

export async function listMcpKeys(): Promise<McpKey[]> {
const res = await authFetch<{ items: McpKey[] }>('/me/mcp/keys')
return res.items
}

export async function createMcpKey(name: string): Promise<CreatedMcpKey> {
return authFetch<CreatedMcpKey>('/me/mcp/keys', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
}

export async function revokeMcpKey(id: string): Promise<void> {
await authFetch<void>(`/me/mcp/keys/${id}`, { method: 'DELETE' })
}

/**
* A connector config with the key already in it, ready to paste. Two shapes because the clients
* differ: Claude Desktop takes a JSON file, ChatGPT and claude.ai take a URL plus a header in their
* own connector form.
*/
export function claudeDesktopConfig(key: string): string {
return `{
"mcpServers": {
"textstack": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://textstack.app/mcp",
"--header", "Authorization: Bearer ${key}"]
}
}
}`
}
172 changes: 172 additions & 0 deletions apps/web/src/components/mcp/ConnectAssistant.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import { useCallback, useEffect, useState } from 'react'
import { useAuth } from '../../context/AuthContext'
import { useTranslation } from '../../hooks/useTranslation'
import {
listMcpKeys,
createMcpKey,
revokeMcpKey,
claudeDesktopConfig,
type McpKey,
type CreatedMcpKey,
} from '../../api/mcpKeys'

/**
* Create and manage the connect keys that let an outside assistant reach the reader's books.
*
* <p>This is the whole onboarding. Before it, connecting Claude meant installing a .NET CLI, running
* a device flow in a terminal, copying a JWT out of a cache file — and repeating within the hour,
* because the only credential the remote endpoint accepted expired in sixty minutes. Nobody away
* from a terminal could do it and nobody at all could do it from a phone, which is the honest
* explanation for zero conclusions ever coming back.</p>
*
* <p>The key is shown exactly once. The server keeps a SHA-256 and nothing else, so "copy it now" is
* a statement of fact rather than a nudge — hence the persistent panel rather than a toast.</p>
*/
export function ConnectAssistant() {
const { t } = useTranslation()
const { isAuthenticated, openAuthModal } = useAuth()

const [keys, setKeys] = useState<McpKey[]>([])
const [loading, setLoading] = useState(false)
const [creating, setCreating] = useState(false)
const [created, setCreated] = useState<CreatedMcpKey | null>(null)
const [error, setError] = useState<string | null>(null)
const [copied, setCopied] = useState<'key' | 'config' | null>(null)

// Deliberately does NOT clear `error` on success. The mount refresh and a user action overlap:
// a create that failed at 200ms would have its message wiped by a list that succeeded at 300ms,
// leaving a button that visibly did nothing. Each action clears the error it is about to replace.
const refresh = useCallback(async () => {
setLoading(true)
try {
setKeys(await listMcpKeys())
} catch {
setError(t('mcp.connect.loadFailed'))
} finally {
setLoading(false)
}
}, [t])

useEffect(() => {
if (isAuthenticated) void refresh()
}, [isAuthenticated, refresh])

const create = async () => {
setCreating(true)
setError(null)
try {
const key = await createMcpKey(defaultKeyName())
setCreated(key)
await refresh()
} catch (e) {
setError(e instanceof Error ? e.message : t('mcp.connect.createFailed'))
} finally {
setCreating(false)
}
}

const revoke = async (id: string) => {
setError(null)
try {
await revokeMcpKey(id)
// A revoked key stops authenticating immediately; if the one on screen was just revoked, the
// panel must go with it rather than keep offering a dead string to copy.
if (created && created.id === id) setCreated(null)
await refresh()
} catch {
setError(t('mcp.connect.revokeFailed'))
}
}

const copy = async (text: string, which: 'key' | 'config') => {
try {
await navigator.clipboard.writeText(text)
setCopied(which)
setTimeout(() => setCopied(null), 2000)
} catch {
/* clipboard unavailable — the value is selectable on screen either way */
}
}

if (!isAuthenticated) {
return (
<section className="mcp-section mcp-connect">
<h2 className="mcp-section__heading">{t('mcp.connect.heading')}</h2>
<p className="mcp-section__lead">{t('mcp.connect.signInLead')}</p>
<button type="button" className="mcp-connect__btn" onClick={openAuthModal}>
{t('mcp.connect.signInCta')}
</button>
</section>
)
}

const live = keys.filter(k => !k.revokedAt)

return (
<section className="mcp-section mcp-connect">
<h2 className="mcp-section__heading">{t('mcp.connect.heading')}</h2>
<p className="mcp-section__lead">{t('mcp.connect.lead')}</p>

{error && <p className="mcp-connect__error" role="alert">{error}</p>}

{created && (
<div className="mcp-connect__fresh">
<p className="mcp-connect__once">{t('mcp.connect.shownOnce')}</p>

<div className="mcp-connect__keyrow">
<code className="mcp-connect__key">{created.key}</code>
<button type="button" className="mcp-connect__copy" onClick={() => copy(created.key, 'key')}>
{copied === 'key' ? t('mcp.copied') : t('mcp.copy')}
</button>
</div>

<p className="mcp-connect__configlabel">{t('mcp.connect.configLabel')}</p>
<div className="mcp-connect__keyrow">
<pre className="mcp-connect__config"><code>{claudeDesktopConfig(created.key)}</code></pre>
<button
type="button"
className="mcp-connect__copy"
onClick={() => copy(claudeDesktopConfig(created.key), 'config')}
>
{copied === 'config' ? t('mcp.copied') : t('mcp.copy')}
</button>
</div>
</div>
)}

<button type="button" className="mcp-connect__btn" onClick={create} disabled={creating}>
{creating ? t('mcp.connect.creating') : t('mcp.connect.createCta')}
</button>

{loading && live.length === 0 ? null : live.length === 0 ? (
<p className="mcp-connect__empty">{t('mcp.connect.empty')}</p>
) : (
<ul className="mcp-connect__list">
{live.map(k => (
<li key={k.id} className="mcp-connect__item">
<div>
<span className="mcp-connect__name">{k.name}</span>
<code className="mcp-connect__prefix">{k.prefix}…</code>
<span className="mcp-connect__used">
{k.lastUsedAt ? t('mcp.connect.usedRecently') : t('mcp.connect.neverUsed')}
</span>
</div>
<button type="button" className="mcp-connect__revoke" onClick={() => revoke(k.id)}>
{t('mcp.connect.revoke')}
</button>
</li>
))}
</ul>
)}
</section>
)
}

/**
* A label the reader can tell apart later without being asked to invent one now. Asking for a name
* before the key exists puts a form between them and the thing they came for; the list is editable
* only by revoking, so a date is the honest default.
*/
function defaultKeyName(): string {
return `Assistant · ${new Date().toISOString().slice(0, 10)}`
}
135 changes: 135 additions & 0 deletions apps/web/src/components/mcp/__tests__/ConnectAssistant.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { render, screen, cleanup, waitFor, fireEvent } from '@testing-library/react'

vi.mock('../../../hooks/useTranslation', () => ({
useTranslation: () => ({ t: (k: string) => k }),
}))

// vi.mock is hoisted above every top-level binding, so the fakes have to be created inside
// vi.hoisted rather than as plain consts — otherwise the factory runs before they exist.
const { auth, api } = vi.hoisted(() => ({
auth: { isAuthenticated: true, openAuthModal: () => {} },
api: { listMcpKeys: vi.fn(), createMcpKey: vi.fn(), revokeMcpKey: vi.fn() },
}))

vi.mock('../../../context/AuthContext', () => ({ useAuth: () => auth }))
vi.mock('../../../api/mcpKeys', async () => {
const actual = await vi.importActual<typeof import('../../../api/mcpKeys')>('../../../api/mcpKeys')
return { ...actual, ...api }
})

import { ConnectAssistant } from '../ConnectAssistant'

const KEY = 'tsk_abcdefghijklmnopqrstuvwxyz0123456789ABCDE'

const listed = (over: Partial<{ id: string; lastUsedAt: string | null; revokedAt: string | null }> = {}) => ({
id: 'k1',
name: 'Assistant · 2026-09-10',
prefix: 'tsk_abcdef',
createdAt: '2026-09-10T00:00:00Z',
lastUsedAt: null,
revokedAt: null,
...over,
})

beforeEach(() => {
auth.isAuthenticated = true
api.listMcpKeys.mockReset().mockResolvedValue([])
api.createMcpKey.mockReset()
api.revokeMcpKey.mockReset().mockResolvedValue(undefined)
})
afterEach(() => cleanup())

describe('ConnectAssistant', () => {
it('asks a signed-out reader to sign in and never calls the API', async () => {
auth.isAuthenticated = false
render(<ConnectAssistant />)

expect(screen.getByText('mcp.connect.signInCta')).toBeTruthy()
// A key is per-account; listing before there is an account is a guaranteed 401.
expect(api.listMcpKeys).not.toHaveBeenCalled()
})

it('shows the secret exactly once, and only after it is created', async () => {
api.createMcpKey.mockResolvedValue({
id: 'k1', name: 'n', key: KEY, prefix: 'tsk_abcdef', createdAt: '2026-09-10T00:00:00Z',
})
api.listMcpKeys.mockResolvedValue([listed()])

render(<ConnectAssistant />)
// Nothing secret on screen before the reader asks for it.
await waitFor(() => expect(api.listMcpKeys).toHaveBeenCalled())
expect(screen.queryByText(KEY)).toBeNull()

fireEvent.click(screen.getByText('mcp.connect.createCta'))

await waitFor(() => expect(screen.getByText(KEY)).toBeTruthy())
// The server keeps only a hash, so the warning is a statement of fact and must be present.
expect(screen.getByText('mcp.connect.shownOnce')).toBeTruthy()
})

it('puts the key into a ready-to-paste connector config', async () => {
api.createMcpKey.mockResolvedValue({
id: 'k1', name: 'n', key: KEY, prefix: 'tsk_abcdef', createdAt: '2026-09-10T00:00:00Z',
})
api.listMcpKeys.mockResolvedValue([listed()])

render(<ConnectAssistant />)
fireEvent.click(screen.getByText('mcp.connect.createCta'))

// The whole point: copy from here, paste into the client. A config the reader has to hand-edit
// to insert the key is the terminal step this feature exists to remove.
const config = await screen.findByText(/mcpServers/)
expect(config.textContent).toContain(KEY)
expect(config.textContent).toContain('https://textstack.app/mcp')
})

it('stops showing a secret the moment its key is revoked', async () => {
api.createMcpKey.mockResolvedValue({
id: 'k1', name: 'n', key: KEY, prefix: 'tsk_abcdef', createdAt: '2026-09-10T00:00:00Z',
})
api.listMcpKeys.mockResolvedValue([listed()])

render(<ConnectAssistant />)
fireEvent.click(screen.getByText('mcp.connect.createCta'))
await waitFor(() => expect(screen.getByText(KEY)).toBeTruthy())

api.listMcpKeys.mockResolvedValue([])
fireEvent.click(screen.getByText('mcp.connect.revoke'))

// Leaving a revoked key on screen invites pasting a string that authenticates nothing.
await waitFor(() => expect(screen.queryByText(KEY)).toBeNull())
expect(api.revokeMcpKey).toHaveBeenCalledWith('k1')
})

it('hides revoked keys from the list but keeps live ones', async () => {
api.listMcpKeys.mockResolvedValue([
listed({ id: 'live' }),
listed({ id: 'dead', revokedAt: '2026-09-10T01:00:00Z' }),
])

render(<ConnectAssistant />)
await waitFor(() => expect(screen.getAllByText('mcp.connect.revoke').length).toBe(1))
})

it('says whether a key has ever been used — the only signal that a connector works', async () => {
api.listMcpKeys.mockResolvedValue([listed({ lastUsedAt: null })])
render(<ConnectAssistant />)
await waitFor(() => expect(screen.getByText('mcp.connect.neverUsed')).toBeTruthy())

cleanup()
api.listMcpKeys.mockResolvedValue([listed({ lastUsedAt: '2026-09-10T02:00:00Z' })])
render(<ConnectAssistant />)
await waitFor(() => expect(screen.getByText('mcp.connect.usedRecently')).toBeTruthy())
})

it('surfaces a failed create instead of silently doing nothing', async () => {
api.listMcpKeys.mockResolvedValue([])
api.createMcpKey.mockRejectedValue(new Error('boom'))

render(<ConnectAssistant />)
fireEvent.click(screen.getByText('mcp.connect.createCta'))

await waitFor(() => expect(screen.getByRole('alert').textContent).toBe('boom'))
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -1149,5 +1149,20 @@
"vocabulary.weeklyBudget.label": "This week",
"vocabulary.weeklyBudget.progress": "{used} / {budget}",
"words.tabs.highlights": "Highlights",
"words.tabs.vocabulary": "Vocabulary"
"words.tabs.vocabulary": "Vocabulary",
"mcp.connect.heading": "Connect your assistant",
"mcp.connect.lead": "Create a key, paste it into Claude or ChatGPT once, and it can read the books you upload and write conclusions back. The key does not expire; revoke it here whenever you like.",
"mcp.connect.signInLead": "Sign in to create a key. It is tied to your account — that is what lets an assistant reach your library and nobody else's.",
"mcp.connect.signInCta": "Sign in",
"mcp.connect.createCta": "Create a key",
"mcp.connect.creating": "Creating…",
"mcp.connect.shownOnce": "Copy this now — it is shown once and cannot be recovered. We store only a hash of it.",
"mcp.connect.configLabel": "Claude Desktop — paste into claude_desktop_config.json",
"mcp.connect.empty": "No keys yet.",
"mcp.connect.revoke": "Revoke",
"mcp.connect.usedRecently": "used recently",
"mcp.connect.neverUsed": "never used",
"mcp.connect.loadFailed": "Could not load your keys.",
"mcp.connect.createFailed": "Could not create a key.",
"mcp.connect.revokeFailed": "Could not revoke that key."
}
Loading
Loading