Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
39 changes: 31 additions & 8 deletions docs/contributing/architecture/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,8 @@ instead.
Signed-in users with an unverified email can request a fresh link with
`POST /account/resend-verification.json`
(`packages/worker/src/app/handlers/account-resend-verification.ts`), surfaced as
a "Resend verification email" button on `/account`. The endpoint reuses
a "Resend verification email" button on `/pending-verification`, `/account`,
`/onboarding`, and `/oauth/authorize`. The endpoint reuses
`createEmailVerification` (invalidating older tokens) and is rate-limited per
user (3 requests per 15 minutes).

Expand All @@ -145,12 +146,29 @@ null until `GET /verify-email?token=...` succeeds. Seeded and test fixture
accounts are created verified. Unverified accounts can sign in and see their
status on `/account`.

Unverified accounts can still use browser sessions and complete OAuth flows
(authorize + token exchange keep working so clients can finish login), but
assistant features are blocked until the email is verified:

- **MCP**: `handleMcpRequest` in `packages/worker/src/mcp-auth.ts` is the single
chokepoint for `/mcp`. After token validation it checks
Unverified accounts can still use browser sessions (sign in, manage account,
resend verification), but they must verify before MCP OAuth authorization or
assistant features:

- **Signup**: password signup keeps the authenticated session and lands on
`/pending-verification` (preserving a safe `redirectTo` such as an OAuth
authorize URL). Users can resend the verification email and continue once the
link succeeds; continue returns to `redirectTo` when present, otherwise
`/onboarding`.
- **Onboarding** (`/onboarding`): verified users only. Unverified HTML requests
redirect to `/pending-verification`. Loader/API data still exposes
`emailVerified` and withholds MCP URL/setup until verified as defense in
depth. `needsOnboarding` means incomplete overall setup (`!emailVerified` or
no MCP grant). Account keeps an inline verification card for resend/status;
home and account banners do not show the connect-agent callout while
unverified.
- **MCP OAuth authorize**: `/oauth/authorize` rejects approval before creating a
grant/token when the account email is unverified
(`403 email_verification_required`). The authorize UI keeps inline
verification/resend controls and the original OAuth query so verification in
another tab can resume without restarting the host connection.
- **MCP requests**: `handleMcpRequest` in `packages/worker/src/mcp-auth.ts` is
the single chokepoint for `/mcp`. After token validation it checks
`users.email_verified_at` (via `isAccountEmailVerified`) and rejects
unverified — or unidentifiable — accounts with a
`403 email_verification_required` JSON response pointing at `/account`. The
Expand Down Expand Up @@ -345,7 +363,12 @@ routed from `packages/worker/src/index.ts`.
- Supported scopes: `profile`, `email`
- On `/oauth/authorize`, unauthenticated users can log in inline or via top-nav
auth links; those links preserve the full authorize URL in `redirectTo` so
successful login/signup returns to the original OAuth request
successful login returns to the original OAuth request. Password signup lands
on `/pending-verification` with that safe `redirectTo` preserved for
continue-after-verify. The authorize tab itself should stay open when the
email link is opened elsewhere, so the original OAuth query remains resumable.
- Approval is rejected before `completeAuthorization` when the account email is
unverified, so no grant/token is created until verification succeeds.

`/mcp` is protected by `packages/worker/src/mcp-auth.ts`:

Expand Down
8 changes: 7 additions & 1 deletion docs/contributing/architecture/mcp-client-servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ callback replay finds no matching state.
`kody.mcp["<server-name>"].<tool>(input)` and are never exposed as flat
`kody.*` functions. Search capability detail returns the exact accessor.
- Tool calls flow worker → hub DO `callTool` → `MCPClientManager.callTool` →
remote server. Structured content is returned directly when present.
remote server. At the synthesized capability boundary, results are wrapped
with explicit `__mcpContent` / companion markers when protocol content must
reach the upstream client (especially non-text blocks such as images).
Structured content remains available for code; `isError` is preserved. See
[Raw MCP content blocks](../../use/raw-content-blocks.md).
- Malformed third-party content blocks are rejected with a source-specific
error. Image/audio URL-only payloads are not fetched.

## Management surfaces

Expand Down
15 changes: 10 additions & 5 deletions docs/contributing/architecture/primitives.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,13 @@ primitives:
Signed kody_session cookie auth for browser users; production signup
invite-gated with a Kit-backed public waiting list on /signup
(non-production signup open), account email verification surfaced in
session state, social login (GitHub/Google/X OAuth connections in
oauth_connections, mockable via MOCK_ client ids on non-production
runtimes), and opt-in second factors — TOTP two-factor (verifications
table, pending kody_verify cookie gates login until /verify) and passkeys
(WebAuthn sign-in as an alternative first factor that still honors 2FA).
session state and a dedicated `/pending-verification` experience after
password signup, MCP OAuth authorize gated until verified, social login
(GitHub/Google/X OAuth connections in oauth_connections, mockable via
MOCK_ client ids on non-production runtimes), and opt-in second factors —
TOTP two-factor (verifications table, pending kody_verify cookie gates
login until /verify) and passkeys (WebAuthn sign-in as an alternative
first factor that still honors 2FA).
code:
- packages/worker/src/app/auth-session.ts
- packages/worker/src/app/handlers/auth.ts
Expand All @@ -107,9 +109,12 @@ primitives:
- packages/worker/src/app/oauth-login-errors.ts
- packages/worker/migrations/0056-oauth-connections.sql
- packages/worker/src/app/handlers/verify-email.ts
- packages/worker/src/app/handlers/pending-verification.ts
- packages/worker/src/app/handlers/admin-invites.ts
- packages/worker/src/app/invites.ts
- packages/worker/src/app/email-verification.ts
- packages/worker/src/app/onboarding-data.ts
- packages/worker/src/oauth-handlers.ts
- packages/worker/src/app/two-factor.ts
- packages/worker/src/app/verify-session.ts
- packages/worker/src/app/passkeys.ts
Expand Down
5 changes: 4 additions & 1 deletion docs/contributing/architecture/remote-connectors.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,10 @@ The Worker sends MCP-style requests over the WebSocket wrapped in

- **`tools/call`** — Params: `{ name: string, arguments?: object }`. Return a
normal MCP **`CallToolResult`**-compatible payload (content, structured
content, `isError`, etc.).
content, `isError`, etc.). When execute returns that capability result
directly, protocol-valid non-text content blocks (images, audio, resources)
pass through to the upstream MCP client; see
[Raw MCP content blocks](../../use/raw-content-blocks.md).

If the Worker forwards **`notifications/tools/list_changed`**, the connector
should re-list tools when it supports dynamic registration.
Expand Down
8 changes: 6 additions & 2 deletions docs/use/connect-your-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ agent that supports MCP — not from a separate Kody chat app.
3. Complete the OAuth flow when the host opens it. Sign in to Kody if needed,
then approve access.

MCP stays unavailable until your account email is verified. If authorize fails
for that reason, verify from your account page and try again.
Your account email must be verified before authorize can finish or MCP can run.
If authorize asks you to verify, keep that tab open, finish verification (from
the email link or `/pending-verification`), then continue. You do not need to
restart the host connection. Unverified visits to Get started (`/onboarding`)
redirect to `/pending-verification`; after verification, onboarding shows the
MCP URL and setup prompt.

## Ask your agent to help set up

Expand Down
8 changes: 8 additions & 0 deletions docs/use/execute.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,11 @@ Readable non-secret configuration uses **`kody.value_get`** and
By default, **`execute`** returns text output. To return non-text MCP content
blocks such as images, return an object with a **`__mcpContent`** array instead;
see [Raw MCP content blocks](./raw-content-blocks.md).

The same passthrough applies when execute returns a result from a user-added MCP
server or remote connector that already includes protocol image (or other
non-text) content blocks.

**`responseLimit`** caps ordinary JSON/text output (~100 KB by default).
Protocol `__mcpContent` blocks use a separate ~512 KB content cap so valid
images larger than 100 KB are not collapsed into truncated JSON.
30 changes: 30 additions & 0 deletions docs/use/raw-content-blocks.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,33 @@ base64 string inside text.

Use this only when the return value is genuinely a non-text content block. For
normal structured data, return plain values and let `execute` serialize them.

## Size limits

`execute` has two separate caps:

- **`responseLimit`** (~100 KB by default) applies to ordinary JSON/text return
values (and to structured companion data alongside protocol content).
- **Protocol content** from `__mcpContent` (and from downstream MCP / remote
connector tools that pass through images, audio, or resources) uses a separate
~512 KB serialized-content cap. Base64 expands binary by about 4/3, so a ~133
KB WebP is roughly ~177 KB of JSON and fits; oversized protocol content fails
explicitly instead of being truncated into unusable JSON text.

## Downstream MCP servers and remote connectors

When execute code calls a user-added MCP server (`kody.mcp[...]`) or remote
connector (`kody.remote[...]`) and returns that capability result directly, Kody
preserves protocol-valid MCP content blocks from the downstream tool — including
`image`, `audio`, `resource`, and `resource_link` — and passes them through to
the upstream MCP client.

If the downstream tool returns both `structuredContent` and non-text `content`,
structured data stays available for code (and in execute’s
`structuredContent.result`) while the content blocks still pass through.

Image and audio blocks must already be protocol-valid
`{ type: 'image' | 'audio', data, mimeType }` with base64 `data`. Kody does not
fetch image URLs from third-party payloads (SSRF / host-approval risk).
Malformed blocks fail with a source-specific error naming the MCP server,
connector tool, or execute `__mcpContent` return.
9 changes: 5 additions & 4 deletions docs/use/troubleshooting.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,11 @@

## MCP requests fail with `email_verification_required`

Kody requires a verified account email before any MCP access. Open the
verification link sent to the account email at signup, or sign in to the app and
use **Resend verification email** on the `/account` page, then reconnect the MCP
client.
Kody requires a verified account email before any MCP access, including OAuth
authorization. Open the verification link sent at signup, or sign in and use
**Resend verification email** on `/pending-verification`, `/account`, or the
authorize page. Keep an open `/oauth/authorize` tab so you can continue the same
OAuth request after verifying in another tab, then reconnect or approve again.

## Search returns no good matches

Expand Down
33 changes: 31 additions & 2 deletions e2e/invite-signup-verification.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,25 +93,54 @@ test('admin invite signup and email verification happy path', async ({
await page.getByLabel('Password').fill(invitedPassword)
await page.getByLabel('Invite code').fill(inviteCode)
await page.getByRole('button', { name: 'Create account' }).click()
await expect(page).toHaveURL(/\/account$/)
await expect(page).toHaveURL(/\/pending-verification$/)
await expect(
page.getByRole('heading', { name: 'Check your email' }),
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Verify your email' }),
).toBeVisible()
await expect(
page.getByRole('heading', { name: 'Add Kody as an MCP server' }),
).toHaveCount(0)
await expect(page.getByText(/\/mcp/)).toHaveCount(0)

await page.goto('/onboarding')
await expect(page).toHaveURL(/\/pending-verification$/)
await expect(
page.getByRole('heading', { name: 'Add Kody as an MCP server' }),
).toHaveCount(0)

await page.getByRole('button', { name: 'Resend verification email' }).click()
const oauthResume = '/oauth/authorize?client_id=e2e-demo&response_type=code'
await expect(async () => {
await setEmailVerificationTokenInE2eDatabase({
email: invitedEmail,
token: verificationToken,
})
await page.goto(
`/verify-email?token=${encodeURIComponent(verificationToken)}`,
`/verify-email?token=${encodeURIComponent(verificationToken)}&redirectTo=${encodeURIComponent(oauthResume)}`,
)
await expect(
page.getByRole('heading', { name: 'Email verified' }),
).toBeVisible({ timeout: 1_000 })
}).toPass({ timeout: 15_000 })

await expect(page.getByText(/MCP access is now available/i)).toBeVisible()
const continueAuthorization = page.getByRole('link', {
name: 'Continue authorization',
})
await expect(continueAuthorization).toHaveAttribute('href', oauthResume)
await continueAuthorization.click()
await expect(page).toHaveURL(/\/oauth\/authorize/)

await page.goto('/onboarding')
await expect(page).toHaveURL(/\/onboarding$/)
await expect(
page.getByRole('heading', { name: 'Add Kody as an MCP server' }),
).toBeVisible()
await expect(page.getByText(/\/mcp/)).toBeVisible()

await page.goto('/account')
await expect(
page.getByText(`Email: ${invitedEmail} (verified)`),
Expand Down
92 changes: 25 additions & 67 deletions packages/worker/client/routes/account.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx'
import { consumeStaleNavigationData } from '#client/navigation-data.ts'
import { colors, spacing, typography } from '#client/styles/tokens.ts'
import {
cardTitleCss,
descriptionCss,
fieldCss,
fieldLabelCss,
Expand All @@ -35,9 +34,12 @@ import {
AccountManagementPanel,
AccountManagementShell,
AccountPageHeader,
noticeCardCss,
} from '#client/routes/account-management-components.tsx'
import { renderOnboardingBanner } from '#client/routes/onboarding-banner.tsx'
import {
renderEmailVerificationPrompt,
requestResendVerification,
} from '#client/routes/email-verification-prompt.tsx'
import {
fetchOnboardingPayload,
type OnboardingPayload,
Expand Down Expand Up @@ -69,7 +71,6 @@ type AccountConnectionsPayload = {
availableProviders: Array<AuthProviderInfo>
}

const resendVerificationApiPath = '/account/resend-verification.json'
const emailChangeApiPath = '/account/email-change.json'
const connectionsApiPath = '/account/connections.json'

Expand Down Expand Up @@ -312,34 +313,16 @@ export function AccountRoute(handle: Handle) {
handle.update()

try {
const response = await fetch(resendVerificationApiPath, {
method: 'POST',
headers: { Accept: 'application/json' },
credentials: 'include',
})
if (response.status === 401) {
const result = await requestResendVerification()
if (!result.ok && result.unauthorized) {
window.location.assign('/login')
return
}
const payload = await readJson<{
ok?: boolean
message?: string
error?: string
}>(response)
if (!response.ok || !payload?.ok) {
throw new Error(
payload?.error || 'Unable to send the verification email.',
)
}
resendMessage =
payload.message ?? 'Verification email sent. Check your inbox.'
resendTone = 'info'
} catch (error) {
resendMessage =
error instanceof Error
? error.message
: 'Unable to send the verification email.'
resendTone = result.ok ? 'info' : 'error'
resendMessage = result.message
} catch {
resendTone = 'error'
resendMessage = 'Unable to send the verification email.'
} finally {
resendStatus = 'idle'
handle.update()
Expand Down Expand Up @@ -559,46 +542,21 @@ export function AccountRoute(handle: Handle) {

{status === 'ready' ? (
<>
{needsOnboarding ? renderOnboardingBanner() : null}
{!emailVerified ? (
<section
aria-label="Email verification status"
mix={css(noticeCardCss)}
>
<h2 mix={css(cardTitleCss)}>Verify your email</h2>
<p mix={css(descriptionCss)}>
Check your inbox for the verification link. MCP access and
email features stay disabled until this account email is
verified.
</p>
<div>
<button
type="button"
disabled={resendStatus === 'sending'}
mix={[
css(primaryButtonCss),
on('click', handleResendVerification),
]}
>
{resendStatus === 'sending'
? 'Sending...'
: 'Resend verification email'}
</button>
</div>
{resendMessage ? (
<p
role="status"
mix={css({
color:
resendTone === 'error' ? colors.error : colors.text,
margin: 0,
})}
>
{resendMessage}
</p>
) : null}
</section>
) : null}
{!emailVerified
? renderEmailVerificationPrompt({
description:
'Check your inbox for the verification link. MCP access and email features stay locked until this account email is verified.',
resendStatus,
resendMessage,
resendTone,
onResend: () => {
void handleResendVerification()
},
secondaryHref: '/pending-verification',
secondaryLabel: 'Verification page',
})
: null}
{emailVerified && needsOnboarding ? renderOnboardingBanner() : null}
<AccountManagementPanel
title="Profile"
description="Your username is unique and visible anywhere Kody needs a display name. Your email stays on the account for login."
Expand Down
Loading
Loading