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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ OPENAI_API_KEY=
# python3 -c "import secrets; print(secrets.token_urlsafe(32))"
ASAREE_ENCRYPTION_KEY=VSrs26eW-By3hinbUIc8wTIdL8AP8VTm9_as2tqkgXs

# Exact URLs registered during MCP OAuth. Set both to the externally reachable
# origins in deployed environments; providers reject redirect URI mismatches.
ASAREE_MCP_OAUTH_CALLBACK_URL=http://localhost:8000/api/mcp-servers/oauth/callback
ASAREE_FRONTEND_URL=http://localhost:5173
# Optional HTTPS Client ID Metadata Document. Leave blank to use dynamic
# client registration with providers that support it.
# ASAREE_MCP_OAUTH_CLIENT_METADATA_URL=https://example.org/asaree/oauth-client.json

# Where the workspace MCP server (asaree.mcp_servers.workspace_server) stores
# per-cell versioned workspaces. MUST be an absolute path, not the relative
# default (asaree_workspace_core.workspace.WORKSPACE_ROOT falls back to
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ docker compose up -d # picks up the changed .env
```

**5. Run an experiment.** Follow the worked myocardial-infarction use case in
[`publications/bioinformatics/README.md`](publications/bioinformatics/README.md)
[`publications/BDM/README.md`](publications/BDM/README.md)
— five agents in series building a classifier on a public dataset. It picks up
exactly where this step leaves off.

Expand Down Expand Up @@ -201,4 +201,4 @@ You're now at true zero. To get back to a working state:
2. Re-add the LLM credential, then re-run a use case notebook's early setup
cells (experiment, dataset, agent creation) — or, for the public
myocardial-infarction use case, follow its walkthrough:
[`publications/bioinformatics/README.md`](publications/bioinformatics/README.md).
[`publications/BDM/README.md`](publications/BDM/README.md).
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { RegisterPage } from '@/pages/RegisterPage'
import { ProfilePage } from '@/pages/ProfilePage'
import { ExperimentsPage } from '@/pages/ExperimentsPage'
import { ProtocolCanvasPage } from '@/pages/ProtocolCanvasPage'
import { McpOAuthCallbackPage } from '@/pages/McpOAuthCallbackPage'

export function App() {
return (
Expand All @@ -16,6 +17,7 @@ export function App() {
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/mcp/oauth/callback" element={<McpOAuthCallbackPage />} />
<Route element={<ProtectedRoute />}>
<Route path="/profile" element={<ProfilePage />} />
<Route path="/experiments" element={<ExperimentsPage />} />
Expand Down
11 changes: 9 additions & 2 deletions frontend/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ import type {
Trial,
} from '@/types/experiments'
import type { LLMConnectionCheck, LLMProvider, LLMSetting, LLMSettingModelsResponse } from '@/types/llmSettings'
import type { McpServer } from '@/types/mcpServers'
import type { McpOAuthAuthorization, McpServer } from '@/types/mcpServers'
import type { OkfBundle, OkfDocument } from '@/types/okf'
import type { CellRunBatch, PromptPreview, Protocol, ProtocolGraph, ProtocolRevision, ProtocolRun, TestRun } from '@/types/protocols'
import type { Run, RunStep } from '@/types/runs'
Expand Down Expand Up @@ -397,14 +397,21 @@ export const mcpServersApi = {
// existing scope (system servers like asaree-workspace aren't listed here
// either; not something the MCP Tool node picker widens).
list: () => request<McpServer[]>('/mcp-servers'),
get: (id: string) => request<McpServer>(`/mcp-servers/${id}`),
// Registers a connection the user typed in themselves -- backs the MCP
// Client Tool node (ConnectMcpServerDialog). The response already carries
// the discovered tools: core connects and lists them synchronously during
// registration, so a 201 whose `status` is 'error' means "row saved, server
// unreachable", not a failure to save. 409 on a duplicate `name`, 422 when
// the stdio allowlist or the SSRF guard rejects it.
create: (data: { name: string; transport: string; command?: string | null; url?: string | null; headers?: Record<string, string> | null }) =>
create: (data: { name: string; transport: string; command?: string | null; url?: string | null; headers?: Record<string, string> | null; server_env?: Record<string, string> | null }) =>
request<McpServer>('/mcp-servers', { method: 'POST', body: data }),
update: (id: string, data: { headers?: Record<string, string> | null; server_env?: Record<string, string> | null }) =>
request<McpServer>(`/mcp-servers/${id}`, { method: 'PATCH', body: data }),
beginOAuth: (id: string, scope?: string | null) =>
request<McpOAuthAuthorization>(`/mcp-servers/${id}/oauth/start`, { method: 'POST', body: { scope: scope || null } }),
clearCredentials: (id: string, revoke = false) =>
request<McpServer>(`/mcp-servers/${id}/credentials/clear`, { method: 'POST', body: { revoke } }),
// Re-dials and re-discovers tools. The repair path for a server registered
// while it happened to be down.
reconnect: (id: string) => request<McpServer>(`/mcp-servers/${id}/reconnect`, { method: 'POST' }),
Expand Down
26 changes: 15 additions & 11 deletions frontend/src/components/AppVersionBadge.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import { versionApi } from '@/api/client'
* tag `v0.3.0a` comes back as `0.3.0a0`. Dropping that padding shows the tag
* that was actually cut rather than a number nobody typed.
*
* `0.2.1.dev50+g8366ca1` -> `v0.2.1-dev50`: between tags hatch-vcs guesses the
* next patch release and counts the commits since. Kept (rather than rounded to
* the last real release) because "which build am I looking at" is the whole
* point of the badge, and a dev build is exactly the case where the tag alone
* would be a lie. The `+g<sha>` local part is dropped from the badge and kept
* in its tooltip -- it's the part you only want when you're already suspicious.
* `0.2.0.post1.dev50+g8366ca1` -> `v0.2.0+50`: between tags hatch-vcs (with
* pyproject's `no-guess-dev` scheme) keeps the last real tag and counts the
* commits since. The count is kept (rather than rounded to the tag) because
* "which build am I looking at" is the whole point of the badge, and a dev
* build is exactly the case where the tag alone would be a lie. The `+g<sha>`
* local part is dropped from the badge and kept in its tooltip -- it's the part
* you only want when you're already suspicious.
*/
function formatVersion(raw: string): string {
const [publicPart] = raw.split('+')
return `v${publicPart.replace(/\.dev/, '-dev').replace(/(a|b|rc)0$/, '$1')}`
return `v${publicPart.replace(/\.post\d+\.dev(\d+)$/, '+$1').replace(/(a|b|rc)0(?=$|\+)/, '$1')}`
}

/** The running build's version, pinned to the very top-right of the viewport.
Expand All @@ -36,12 +37,15 @@ export function AppVersionBadge() {
const { data } = useQuery({
queryKey: ['app-version'],
queryFn: versionApi.get,
// A build can't change under a running tab, so this is fetched once per
// session and never revalidated. Failure is silent: a missing badge is a
// better outcome than an error toast over the login form.
// A build can't change under a running tab, so once fetched this is never
// revalidated. Failure is silent (a missing badge beats an error toast over
// the login form) but not final: a tab opened while the backend is still
// starting -- e.g. right after `docker compose up` -- would otherwise keep
// that one refused request, and no badge, until a manual reload. Retry
// indefinitely on react-query's default backoff (capped at 30s).
staleTime: Infinity,
gcTime: Infinity,
retry: false,
retry: true,
})

if (!data?.version) return null
Expand Down
182 changes: 172 additions & 10 deletions frontend/src/components/protocol/ConnectMcpServerDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import { useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { Plug, Terminal } from 'lucide-react'
import { KeyRound, Plug, ShieldCheck, Terminal } from 'lucide-react'
import { ApiError, mcpServersApi } from '@/api/client'
import { Button } from '@/components/ui/button'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { HUD_ACCENT_RING_CLASSNAME } from '@/lib/utils'
import { authorizeMcpServer } from '@/lib/mcpOAuth'
import { credentialsAreValid, credentialsRecord, emptyCredentialEntry, type CredentialEntry } from '@/lib/mcpCredentials'
import { McpCredentialFields } from './McpCredentialFields'
import type { McpServer } from '@/types/mcpServers'

// The two transports an MCP client can speak here. Core's MCPTransport also
Expand Down Expand Up @@ -37,6 +40,15 @@ const TRANSPORTS = [
// than discovered by submitting a command and reading a 422 back.
const ALLOWED_EXECUTABLES = 'python, python3, python3.11, python3.12, uv, node, npx, npm'

type AuthMode = 'none' | 'bearer' | 'headers' | 'oauth' | 'env'

const HTTP_AUTH_MODES: { value: AuthMode; label: string; hint: string }[] = [
{ value: 'none', label: 'None', hint: 'Connect without credentials.' },
{ value: 'bearer', label: 'Bearer token', hint: 'Send an Authorization: Bearer header.' },
{ value: 'headers', label: 'Headers', hint: 'Send one or more API-key or custom authentication headers.' },
{ value: 'oauth', label: 'OAuth', hint: 'Authorize through the server’s standards-based browser flow.' },
]

// Registers a brand-new MCP server connection the user types in, then hands it
// back so the caller can place an MCP Client Tool node bound to it.
//
Expand All @@ -59,11 +71,24 @@ export function ConnectMcpServerDialog({
const [transport, setTransport] = useState('stdio')
const [command, setCommand] = useState('')
const [url, setUrl] = useState('')
const [authMode, setAuthMode] = useState<AuthMode>('none')
const [bearerToken, setBearerToken] = useState('')
const [credentials, setCredentials] = useState<CredentialEntry[]>([emptyCredentialEntry()])
const [oauthScope, setOauthScope] = useState('')
const [oauthServer, setOauthServer] = useState<McpServer | null>(null)
const [oauthPending, setOauthPending] = useState(false)
const [oauthError, setOauthError] = useState<string | null>(null)
const [oauthDetached, setOauthDetached] = useState(false)
const oauthAbortRef = useRef<AbortController | null>(null)
useEffect(() => () => oauthAbortRef.current?.abort(), [])
const queryClient = useQueryClient()

const isStdio = transport === 'stdio'
const endpoint = isStdio ? command.trim() : url.trim()
const canSubmit = name.trim().length > 0 && endpoint.length > 0
const authIsValid = authMode === 'none' || authMode === 'oauth'
|| (authMode === 'bearer' && bearerToken.length > 0)
|| ((authMode === 'headers' || authMode === 'env') && credentialsAreValid(credentials))
const canSubmit = name.trim().length > 0 && endpoint.length > 0 && authIsValid

const connectMutation = useMutation({
mutationFn: () =>
Expand All @@ -72,23 +97,67 @@ export function ConnectMcpServerDialog({
transport,
command: isStdio ? command.trim() : null,
url: isStdio ? null : url.trim(),
headers: !isStdio && authMode === 'bearer'
? { Authorization: `Bearer ${bearerToken}` }
: !isStdio && authMode === 'headers' ? credentialsRecord(credentials) : null,
server_env: isStdio && authMode === 'env' ? credentialsRecord(credentials) : null,
}),
onSuccess: (server) => {
queryClient.invalidateQueries({ queryKey: ['mcp-servers'] })
if (authMode === 'oauth') {
setOauthServer(server)
return
}
onConnected?.(server)
reset()
onOpenChange(false)
},
})

function reset() {
oauthAbortRef.current?.abort()
setName('')
setTransport('stdio')
setCommand('')
setUrl('')
setAuthMode('none')
setBearerToken('')
setCredentials([emptyCredentialEntry()])
setOauthScope('')
setOauthServer(null)
setOauthPending(false)
setOauthError(null)
connectMutation.reset()
}

async function authorizeOAuth() {
if (!oauthServer || oauthPending) return
setOauthPending(true)
setOauthError(null)
setOauthDetached(false)
const controller = new AbortController()
oauthAbortRef.current = controller
try {
await authorizeMcpServer(oauthServer.id, oauthScope.trim() || undefined, {
signal: controller.signal,
onDetached: () => setOauthDetached(true),
})
const connected = await mcpServersApi.get(oauthServer.id)
queryClient.invalidateQueries({ queryKey: ['mcp-servers'] })
onConnected?.(connected)
reset()
onOpenChange(false)
} catch (error) {
if (!controller.signal.aborted) {
setOauthError(error instanceof Error ? error.message : 'MCP authorization failed.')
}
} finally {
if (oauthAbortRef.current === controller) oauthAbortRef.current = null
setOauthDetached(false)
setOauthPending(false)
}
}

const errorMessage = !connectMutation.isError
? null
: connectMutation.error instanceof ApiError && typeof connectMutation.error.detail === 'string'
Expand Down Expand Up @@ -119,6 +188,7 @@ export function ConnectMcpServerDialog({
if (canSubmit && !connectMutation.isPending) connectMutation.mutate()
}}
>
{!oauthServer && <>
<div className="space-y-1.5">
<Label htmlFor="mcp-name">Name</Label>
<Input
Expand Down Expand Up @@ -149,8 +219,12 @@ export function ConnectMcpServerDialog({
<button
key={option.value}
type="button"
onClick={() => setTransport(option.value)}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm transition-colors ${
onClick={() => {
setTransport(option.value)
setAuthMode('none')
setCredentials([emptyCredentialEntry()])
}}
className={`flex cursor-pointer items-center gap-2 rounded-lg border px-3 py-2 text-left text-sm shadow-[0_0_16px_-6px_var(--primary)] transition-colors ${
active ? 'border-primary bg-primary/10 ring-1 ring-primary/40' : 'bg-background hover:bg-muted'
}`}
>
Expand All @@ -162,8 +236,9 @@ export function ConnectMcpServerDialog({
</div>
<p className="text-xs text-muted-foreground">{TRANSPORTS.find((t) => t.value === transport)?.hint}</p>
</div>
</>}

{isStdio ? (
{!oauthServer && (isStdio ? (
<div className="space-y-1.5">
<Label htmlFor="mcp-command">Command</Label>
<Input
Expand All @@ -190,15 +265,102 @@ export function ConnectMcpServerDialog({
/>
<p className="text-xs text-muted-foreground">The server&rsquo;s streamable HTTP endpoint.</p>
</div>
))}

{!oauthServer && (
<div className="space-y-2">
<Label>Authentication</Label>
<div className="grid grid-cols-2 gap-2">
{(isStdio
? [
{ value: 'none' as const, label: 'None', hint: 'Launch without additional credentials.' },
{ value: 'env' as const, label: 'Environment', hint: 'Pass encrypted environment credentials to the subprocess.' },
]
: HTTP_AUTH_MODES
).map((option) => (
<button
key={option.value}
type="button"
onClick={() => setAuthMode(option.value)}
className={`cursor-pointer rounded-lg border px-3 py-2 text-left text-sm shadow-[0_0_16px_-6px_var(--primary)] transition-colors ${
authMode === option.value ? 'border-primary bg-primary/10 ring-1 ring-primary/40' : 'bg-background hover:bg-muted'
}`}
>
<span className="font-medium">{option.label}</span>
</button>
))}
</div>
<p className="text-xs text-muted-foreground">
{(isStdio
? authMode === 'env' ? 'Secrets are encrypted at rest and passed only to this subprocess.' : 'Launch without additional credentials.'
: HTTP_AUTH_MODES.find((mode) => mode.value === authMode)?.hint)}
</p>
{authMode === 'bearer' && (
<Input
aria-label="Bearer token"
type="password"
autoComplete="off"
className="font-mono"
placeholder="Bearer token"
value={bearerToken}
onChange={(event) => setBearerToken(event.target.value)}
/>
)}
{(authMode === 'headers' || authMode === 'env') && (
<McpCredentialFields
entries={credentials}
onChange={setCredentials}
namePlaceholder={authMode === 'env' ? 'API_TOKEN' : 'X-API-Key'}
/>
)}
{authMode === 'oauth' && (
<div className="space-y-1.5">
<Input
aria-label="OAuth scopes"
className="font-mono"
placeholder="Optional scopes"
value={oauthScope}
onChange={(event) => setOauthScope(event.target.value)}
/>
<p className="text-xs text-muted-foreground">Leave blank to use the scopes advertised by the server.</p>
</div>
)}
</div>
)}

{oauthServer && (
<div className="space-y-3 rounded-lg border p-4">
<div className="flex items-start gap-2">
<ShieldCheck className="mt-0.5 size-4 text-primary" />
<div>
<p className="text-sm font-medium">Authorize {oauthServer.name}</p>
<p className="text-xs text-muted-foreground">The server is saved. Finish authorization in the provider window.</p>
</div>
</div>
<Button type="button" className="w-full" disabled={oauthPending} onClick={authorizeOAuth}>
<KeyRound className="size-4" />
{oauthPending ? 'Waiting for authorization…' : 'Open authorization'}
</Button>
{oauthPending && oauthDetached && (
<div className="flex items-center justify-between gap-3">
<p className="text-xs text-muted-foreground">
If you closed the authorization window, cancel and try again.
</p>
<Button type="button" variant="outline" size="sm" onClick={() => oauthAbortRef.current?.abort()}>
Cancel
</Button>
</div>
)}
</div>
)}

{errorMessage && <p className="text-sm text-destructive">{errorMessage}</p>}
{(errorMessage || oauthError) && <p className="text-sm text-destructive">{oauthError ?? errorMessage}</p>}

<DialogFooter>
{!oauthServer && <DialogFooter>
<Button type="submit" disabled={!canSubmit || connectMutation.isPending}>
{connectMutation.isPending ? 'Connecting…' : 'Connect'}
{connectMutation.isPending ? 'Connecting…' : authMode === 'oauth' ? 'Continue' : 'Connect'}
</Button>
</DialogFooter>
</DialogFooter>}
</form>
</DialogContent>
</Dialog>
Expand Down
Loading
Loading