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
25 changes: 18 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ This is a Next.js 15 fullstack AI agent chat application using LangGraph.js with
first initializes the Postgres checkpointer, then assembles the model, tools, and middleware.
- **MCP Integration**: MCP servers are stored in Postgres; the agent sees their tools through the
disclosure middleware and connects on demand (see "Progressive disclosure" below).
`src/lib/agent/mcp.ts` now holds only the Connections page's tool-listing probe.
`src/lib/agent/mcp.ts` now holds only the Connections page's tool-listing probe, which opens
its session through the connector.
- **Persistent Memory**: Uses LangGraph's Postgres checkpointer for conversation history
- **Built-in finance tools** (always registered server-side in `index.ts`, provider-agnostic Zod):
- `src/lib/agent/tools/finance.ts` — `log_expense` (mutating), `query_transactions` (read-only,
Expand Down Expand Up @@ -354,17 +355,27 @@ Instructions loaded on demand, in the standard `SKILL.md` format — full detail
spawning a process), so nothing connects on page load and no tool count is stored — a stored count
goes stale while looking current. Failures are typed (`auth_required`, `invalid_config`, `timeout`,
`connect_failed`), never an empty list.
- `toServerConfig()` (`src/lib/mcp/serverConfig.ts`) turns a row into a client config for BOTH the
agent (`src/lib/mcp/connector.ts`) and the probe, so the page connects exactly the way the
agent will.
- Tool names are prefixed `server__tool`; server names are restricted to `[A-Za-z0-9_-]` in the form
- `toServerConfig()` (`src/lib/mcp/serverConfig.ts`) turns a row into a client config, and
`openSession()` (`src/lib/mcp/connector.ts`) connects it for BOTH the agent and the probe, so
the page connects exactly the way the agent will.
- **Adapter is `@langchain/mcp-adapters` 2.x on MCP SDK v2 (`@modelcontextprotocol/client`)**;
`@modelcontextprotocol/sdk` (v1) is gone, OAuth code included. Tool names are the adapter's
default `server_tool`, and the disclosure package builds the same names (`prefixedToolName()`),
so the connector renames nothing. The one default overridden is elicitation (`elicitation: false`
per server — its interrupt is one the approval flow cannot answer, so the thread would hang). v2 config is strict: unknown
keys throw at construction, and stdio `args` is required.
- Tool names are prefixed `server_tool`; server names are restricted to `[A-Za-z0-9_-]` in the form
because the prefix becomes part of a provider tool name.
- HTTP servers may require OAuth authentication - see [docs/OAUTH.md](docs/OAUTH.md)
- HTTP servers may require OAuth authentication - see [docs/OAUTH.md](docs/OAUTH.md). Saving an
HTTP server probes it and records its OAuth status before responding (the Connect button only
renders for `REQUIRED` / `EXPIRED` / `UNKNOWN`, so a NULL status hid sign-in entirely), and a
401 at connect time records `REQUIRED`/`EXPIRED` and returns `auth_required`. The check route
delegates to the SDK's `auth()` — never hardcode a scope in the client metadata.
- **Progressive disclosure** ([docs/MCP_DISCLOSURE.md](docs/MCP_DISCLOSURE.md), read it before
changing this code): the model sees a short list of capabilities plus `load_tools`, and a tool's
definition only once a search has loaded it; nothing connects until a tool is called. The logic
is the workspace package `packages/mcp-disclosure`, which must not import app code or the MCP
client library; `src/lib/mcp/connector.ts` is the only agent-path file using the adapters.
client library; `src/lib/mcp/connector.ts` is the only file using the adapters.

### Tool Approval Workflow

Expand Down
34 changes: 10 additions & 24 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -142,34 +142,20 @@ async function buildAgent(cfg?: AgentConfigOptions) {
- Model configuration (`createChatModel`) and system prompt
- Checkpointer integration for persistence

### 2. MCP Integration (`src/lib/agent/mcp.ts`)
### 2. MCP Integration (`src/lib/mcp/connector.ts`)

Manages dynamic tool loading from Model Context Protocol servers.

```typescript
export async function createMCPClient(): Promise<MultiServerMCPClient | null> {
const mcpServers = await getMCPServerConfigs(); // From database

if (Object.keys(mcpServers).length === 0) {
return null;
}

const client = new MultiServerMCPClient({
mcpServers: mcpServers,
throwOnLoadError: false,
prefixToolNameWithServerName: true, // Prevent conflicts
});

return client;
}
```
MCP servers are disclosed progressively (see [MCP_DISCLOSURE.md](MCP_DISCLOSURE.md)): the model
sees a capability list and `load_tools`, and a server connects only when one of its tools is
called. `openSession()` in `connector.ts` is the one place that uses `@langchain/mcp-adapters`
(2.x, on MCP SDK v2): it builds a single-server `MCPAdapter` from the stored row via
`toServerConfig()`.

**Key Features:**

- Database-driven MCP server configuration
- Support for stdio and HTTP transports
- Tool name prefixing for conflict prevention
- Graceful error handling for failed servers
- Tool names prefixed `server_tool` (the adapter default) for conflict prevention
- Typed connection failures (`auth_required`, `invalid_config`, `timeout`, `connect_failed`)

### 3. Streaming Service (`src/services/agentService.ts`)

Expand Down Expand Up @@ -443,7 +429,7 @@ client sends one decision per call id (`decisions={"<callId>":"allow"|"deny"}`),
### Server Configuration Flow

```
Database MCPServer → getMCPServerConfigs() → MultiServerMCPClient → Agent Tools
Database MCPServer → toServerConfig() → MCPAdapter (one per session) → Agent Tools
```

### Configuration Examples
Expand Down Expand Up @@ -477,7 +463,7 @@ Database MCPServer → getMCPServerConfigs() → MultiServerMCPClient → Agent
### Tool Loading Process

1. **Database Query**: Fetch enabled MCP servers
2. **Client Creation**: Initialize MultiServerMCPClient
2. **Client Creation**: Open a single-server `MCPAdapter` session on first tool call
3. **Tool Discovery**: Get available tools from each server
4. **Name Prefixing**: Add server name prefix to prevent conflicts
5. **Agent Binding**: Bind tools to language model
Expand Down
47 changes: 31 additions & 16 deletions docs/MCP_DISCLOSURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,29 @@ The logic lives in the workspace package [`packages/mcp-disclosure`](../packages
(`@agentailor/mcp-disclosure`). It imports nothing from the app and **nothing from the MCP client
library** — only MCP protocol shapes (`tools/list`) and LangChain tool types. The app supplies:

| App file | Role |
| -------------------------- | --------------------------------------------------------------------- |
| `src/lib/mcp/connector.ts` | The only agent-path file using `@langchain/mcp-adapters`: one session |
| `src/lib/mcp/pool.ts` | The process-wide pool singleton and the middleware's `resolveTool` |
| `src/lib/mcp/catalog.ts` | Catalog refresh and loading, through the connector |
| `src/lib/agent/index.ts` | Registers core tools only, plus the middleware |

Moving to another MCP client (the v2 adapters) means rewriting `connector.ts`; the catalog rows,
the pool and the middleware are unaffected. The Connections page's "list tools" probe
(`listServerTools`) still uses the adapter directly and is left for that migration.
| App file | Role |
| -------------------------- | ------------------------------------------------------------------ |
| `src/lib/mcp/connector.ts` | The only file using `@langchain/mcp-adapters`: one session |
| `src/lib/mcp/pool.ts` | The process-wide pool singleton and the middleware's `resolveTool` |
| `src/lib/mcp/catalog.ts` | Catalog refresh and loading, through the connector |
| `src/lib/agent/index.ts` | Registers core tools only, plus the middleware |

Moving to another MCP client means rewriting `connector.ts`; the catalog rows, the pool and the
middleware are unaffected. The Connections page's "list tools" probe (`listServerTools`) opens a
session through the connector too, so it connects exactly as the agent does.

The connector runs on `@langchain/mcp-adapters` 2.x (MCP TypeScript SDK v2,
`@modelcontextprotocol/client`), and the package is built to be a pass-through over it:

- **Tool names are the adapter's own.** v2 names tools `{server}_{tool}` by default, and
`prefixedToolName()` in `policy.ts` builds the same name for the catalog's stand-ins, the search
index and `activeTools`. The tool the model is shown and the tool that runs carry one name, so
the connector keys its tools by the adapter's name and `getTool` / `resolveTool` take the name
as the model called it. A server name may itself contain `_`; `createToolServerIndex` resolves
that by preferring the server whose catalog lists the remainder.
- **Elicitation is the one default overridden.** v2 raises a server's elicitation request as a LangGraph interrupt. The approval
flow only answers `humanInTheLoopMiddleware` interrupts, so such a pause would hang the thread.
`toServerConfig` sets `elicitation: false` on every server until it is supported (Deferred).

## Overview

Expand Down Expand Up @@ -71,7 +84,7 @@ sequenceDiagram
A->>M: load_tools("search pages")
M-->>A: matches loaded
Note over M: no connection yet
A->>M: notion__search(...)
A->>M: notion_search(...)
M->>P: resolveTool
P->>S: connect (first call)
M->>S: run tool
Expand All @@ -80,8 +93,10 @@ sequenceDiagram

## Rules for changing this code

- **Keep `connector.ts` the only agent-path adapter file.** The catalog stores protocol data
- **Keep `connector.ts` the only adapter file.** The catalog stores protocol data
(`tools/list`), never adapter-converted schemas.
- **Don't rename tools.** `prefixedToolName()` must match the adapter's default naming; if the
adapter ever changes it, change that one function, not the connector.
- **`load_tools` returns a plain string; `wrapToolCall` applies the state change.** The chat UI
streams a tool's raw return value, so a tool returning a `Command` showed up as a serialized
object. `planLoadTools` is the single source for both.
Expand Down Expand Up @@ -171,7 +186,7 @@ interrupt carrying the authorization URL, with a Connect button in the chat) is
No MCP tool is registered with `createAgent`. Instead:

1. **Stand-ins from the catalog.** `catalogTools()` builds one tool per cached entry
(`server__tool`, its description, and its `inputSchema` exactly as the server listed it). `wrapModelCall` adds the loaded stand-ins to `request.tools` —
(`server_tool`, its description, and its `inputSchema` exactly as the server listed it). `wrapModelCall` adds the loaded stand-ins to `request.tools` —
LangChain accepts tools added there as long as a `wrapToolCall` runs them. A stand-in throws if it is ever run directly.
2. **The real tool on call.** `wrapToolCall` asks `resolveTool(serverId, toolName)` for the real
tool and passes it on as `request.tool`, which the tool node runs; result formatting stays the
Expand Down Expand Up @@ -229,7 +244,7 @@ Code defaults in `packages/mcp-disclosure/src/types.ts`; the pool's idle timeout

## State (per thread, via the checkpointer)

`activeTools: string[]` holds `server__tool` names in load order, through an appending, de-duplicating
`activeTools: string[]` holds `server_tool` names in load order, through an appending, de-duplicating
reducer, so two `load_tools` calls in one step both apply. A tool whose server was disabled or
removed is left out when the list is built; no state change is needed.

Expand Down Expand Up @@ -299,6 +314,6 @@ model was offered (see [OBSERVABILITY.md](OBSERVABILITY.md)).
- Resuming a run after sign-in in place (an interrupt with the authorization URL), instead of
asking the user to reconnect on the Connections page and ask again.
- Provider-native tool search (`defer_loading`). The same tool could sit in front of it.
- Moving the Connections page probe (`listServerTools`) behind the connector — with the v2
adapter migration.
- MCP elicitation: answering a server's `mcp_elicitation` interrupt in the chat, beside the
approval card. Disabled per server until then.
- Background refresh jobs.
83 changes: 51 additions & 32 deletions docs/OAUTH.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,18 @@ Some MCP HTTP servers require OAuth 2.0/2.1 authentication. This document explai

## Overview

OAuth support is implemented using a **lazy detection** approach:

1. When a user adds an HTTP MCP server, OAuth status is initially "UNKNOWN"
2. The user clicks "Connect" to check if OAuth is required
1. When an HTTP server is **saved** (added, or its URL changed), the app probes it before
responding and records `REQUIRED`, `NOT_REQUIRED`, or `UNKNOWN` if it could not be reached.
This is what makes the Connect button appear: it renders for `REQUIRED`, `EXPIRED` and
`UNKNOWN` only, so a server left with no status never offered sign-in.
2. The user clicks "Connect" ("Check auth" for `UNKNOWN`)
3. If required, the user is redirected to the authorization server
4. Upon successful authorization, tokens are stored and the server becomes usable

A connection that later fails with a 401 (an `UnauthorizedError` or HTTP 401 anywhere in the
error's cause chain, `src/lib/mcp/authFailure.ts`) records `REQUIRED` — or `EXPIRED` for a server
that was `CONNECTED` — and reports `auth_required`, so the page offers Connect again.

## OAuth Flow

```
Expand Down Expand Up @@ -41,7 +46,7 @@ OAuth support is implemented using a **lazy detection** approach:

| Status | Description | UI Indicator |
| -------------- | ---------------------------------------- | ------------ |
| `UNKNOWN` | OAuth requirement not yet checked | None |
| `UNKNOWN` | Probe on save could not reach the server | "Check auth" |
| `NOT_REQUIRED` | Server does not require OAuth | None |
| `REQUIRED` | OAuth required, user needs to connect | Yellow badge |
| `CONNECTED` | Successfully authenticated | Green badge |
Expand All @@ -52,7 +57,7 @@ OAuth support is implemented using a **lazy detection** approach:
| File | Purpose |
| ------------------------------------------------ | ------------------------------------------- |
| `src/lib/mcp/oauth-detection.ts` | Detects OAuth requirements via 401 response |
| `src/lib/mcp/oauth-provider.ts` | Implements `OAuthClientProvider` interface |
| `src/lib/mcp/oauth-provider.ts` | Implements `OAuthClientProvider` (SDK v2) |
| `src/app/api/oauth/check/[serverId]/route.ts` | Initiates OAuth flow, returns auth URL |
| `src/app/api/oauth/callback/[serverId]/route.ts` | Handles OAuth callback, exchanges tokens |
| `src/components/OAuthStatusBadge.tsx` | Displays OAuth status in server list |
Expand All @@ -62,48 +67,62 @@ OAuth support is implemented using a **lazy detection** approach:

The `MCPServer` model includes these OAuth-related fields:

```prisma
model MCPServer {
// ... other fields
requiresAuth Boolean? @default(false)
authTokens Json? // { access_token, refresh_token, expires_at, ... }
clientInfo Json? // { client_id, client_secret, ... }
codeVerifier String? // PKCE code verifier for ongoing flow
oauthStatus String? @default("UNKNOWN")
}
```
Columns on `mcp_server` (`src/lib/database/schema.ts`):

| Column | Holds |
| --------------- | -------------------------------------------------------- |
| `requires_auth` | Set true once OAuth completes |
| `auth_tokens` | `{ access_token, refresh_token, expires_at, issuer, … }` |
| `client_info` | The dynamically registered client (`client_id`, …) |
| `code_verifier` | PKCE verifier for the flow in progress |
| `oauth_status` | One of the statuses above; NULL until first probed |

## How It Works

### 1. OAuth Detection

When the user clicks "Connect", the app makes a request to the MCP server. If the server responds with:

- **401 Unauthorized** + `WWW-Authenticate: Bearer` header → OAuth is required
- **200 OK** or other status → OAuth is not required
On save, and again when the user clicks "Connect", the app sends a GET to the MCP server
(5s timeout). If the server responds with:

### 2. Client Registration
- **401 Unauthorized** → OAuth is required; the `WWW-Authenticate` header's `resource_metadata`
and `scope` are kept for the next step
- **any other status** → OAuth is not required
- **no response** → `UNKNOWN`

If OAuth is required and no client is registered:
### 2. Registration and authorization (the SDK's `auth()`)

1. Discover protected resource metadata from the server
2. Discover authorization server metadata
3. Dynamically register a client (if supported)
4. Store `clientInfo` for future use
The check route calls `auth()` from `@modelcontextprotocol/client` rather than running the steps
itself, so the authorization request is built exactly the way the callback's token exchange
expects. It:

### 3. Authorization

1. Generate authorization URL with PKCE (`code_verifier` stored in database)
2. Redirect user to authorization server
3. User grants permission
1. Discovers the protected resource and authorization server metadata
2. Picks the **scope**: the challenge's `scope`, else the resource's `scopes_supported`, plus
`offline_access` when the server offers it. The client metadata carries no scope of its own —
a hardcoded `read write` made Keycloak's client-scope policy refuse registration (HTTP 403).
3. Dynamically registers a client if none is stored, and stores `clientInfo`
4. Uses a stored refresh token if one still works (the route then reports `CONNECTED`)
5. Otherwise builds the authorization URL with PKCE and the RFC 8707 `resource` parameter. The
provider's `redirectToAuthorization` only records that URL; the route returns it and the
browser goes there.

### 4. Token Exchange

1. Authorization server redirects to `/api/oauth/callback/[serverId]`
2. Exchange authorization code for tokens
2. Exchange the code for tokens with `transport.finishAuth(searchParams)` — all the callback
params, not just `code`, because the SDK checks the `iss` parameter (RFC 9207) when the
authorization server sends one
3. Store tokens in database
4. Update status to `CONNECTED`

### 5. Invalidation

When the SDK finds stored credentials rejected it calls `invalidateCredentials(scope)`. The
provider clears the matching columns (`clearOAuthCredentials` in the repository): dropping tokens
sets `EXPIRED`, dropping the client registration sets `REQUIRED`, so the agent stops connecting
and the Connections page asks for sign-in again. `discovery` is a no-op — nothing is cached.

All OAuth helpers and types come from `@modelcontextprotocol/client` (MCP SDK v2).

## Environment Variables

```env
Expand Down
2 changes: 1 addition & 1 deletion eval/cases/mcp.cases.mts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ export const cases: EvalCase[] = [
mcp: true,
graders: [
toolCalled("load_tools"),
toolNotCalled(`${MCP_FIXTURE.driveServer}__list_files`),
toolNotCalled(`${MCP_FIXTURE.driveServer}_list_files`),
statesAnyOf(["Connections"]),
],
runs: RUN_POLICY.single,
Expand Down
Loading
Loading