@vercel/connect 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,12 +2,13 @@
2
2
 
3
3
  SDK for obtaining scoped tokens for third-party services on behalf of apps or users. Authenticates the calling Vercel project via [`@vercel/oidc`](https://www.npmjs.com/package/@vercel/oidc) and exchanges the OIDC token for a Vercel Connect-issued credential.
4
4
 
5
- Seven entrypoints, all ESM:
5
+ Eight entrypoints, all ESM:
6
6
 
7
7
  - `@vercel/connect` — core token / authorization SDK
8
8
  - `@vercel/connect/chat` — adapter helpers for the [Chat SDK](https://chat-sdk.dev) (`chat`): `connectSlackAdapter`, `connectDiscordAdapter`, `connectGitHubAdapter`, `connectLinearAdapter`, `connectNotionAdapter`, `connectTelegramAdapter`, `connectSendblueAdapter`, `connectTeamsAdapter` (no Chat SDK dependency — returns structural config)
9
9
  - `@vercel/connect/ai-sdk` — [Vercel AI SDK](https://ai-sdk.dev) glue: re-exports `connectAuthProvider` for MCP transports (optional peers: `ai`, `@ai-sdk/mcp`)
10
- - `@vercel/connect/mcp` — canonical MCP-spec `OAuthClientProvider` for any MCP client (optional peer: `@ai-sdk/mcp`)
10
+ - `@vercel/connect/mcp` — canonical MCP-spec `OAuthClientProvider` for any MCP client (no SDK dependency; returns a self-contained `ConnectOAuthClientProvider` assignable to `@ai-sdk/mcp` and `@modelcontextprotocol/sdk`)
11
+ - `@vercel/connect/tanstack-ai` — [TanStack AI](https://tanstack.com/ai) glue: `connectMCPTransport` builds a `@tanstack/ai-mcp` transport config with a Connect `authProvider` (eager consent on Streamable HTTP, spec 401 flow on SSE), plus `getConsentChallenge` (no TanStack, AI SDK, or MCP SDK dependency)
11
12
  - `@vercel/connect/eve` — adapter helpers for [Eve](https://github.com/vercel/eve) connections (optional peer: `eve`)
12
13
  - `@vercel/connect/betterauth` — [Better Auth](https://www.better-auth.com/) `genericOAuth` provider (optional peer: `better-auth`)
13
14
  - `@vercel/connect/authjs` — [Auth.js](https://authjs.dev/) `OAuth2Config` provider (optional peer: `@auth/core`)
@@ -121,6 +122,62 @@ SDK's `toolApproval` option or `wrapMcpTools` from `@ai-sdk/policy-opa`.
121
122
 
122
123
  Non-AI-SDK MCP clients (the official MCP TypeScript SDK, Mastra, etc.)
123
124
  can import the same `connectAuthProvider` from `@vercel/connect/mcp`.
125
+ TanStack AI has its own subpath, `@vercel/connect/tanstack-ai`, described
126
+ below.
127
+
128
+ ### TanStack AI + MCP
129
+
130
+ `@tanstack/ai-mcp`'s `createMCPClient` passes `transport.authProvider`
131
+ straight to the official `@modelcontextprotocol/sdk` transports.
132
+ `connectMCPTransport` builds that transport config with a Connect-backed
133
+ `authProvider` and picks the consent mode per transport: on Streamable HTTP
134
+ (`type: 'http'`) a missing grant fails eagerly inside `createMCPClient`, at
135
+ the route boundary, instead of mid-run inside a tool call where TanStack
136
+ would feed the error back to the model as text; on SSE it keeps the MCP-spec
137
+ 401 flow, because the SSE EventSource would turn an eager throw into a
138
+ reconnect loop. TanStack wraps connect failures in `MCPConnectionError`;
139
+ `getConsentChallenge` looks through the `cause` chain for you. The adapter
140
+ also supplies a fixed discovery state so the official SDK never fetches OAuth
141
+ metadata from URLs the MCP server names in `WWW-Authenticate`, and the
142
+ transport refuses HTTP redirects by default (`redirect: 'error'`, as in
143
+ `@ai-sdk/mcp`).
144
+
145
+ ```ts
146
+ import { chat, toServerSentEventsResponse } from '@tanstack/ai';
147
+ import { createMCPClient } from '@tanstack/ai-mcp';
148
+ import {
149
+ connectMCPTransport,
150
+ getConsentChallenge,
151
+ } from '@vercel/connect/tanstack-ai';
152
+
153
+ export async function POST(request: Request) {
154
+ try {
155
+ const linear = await createMCPClient({
156
+ transport: connectMCPTransport(
157
+ { type: 'http', url: 'https://mcp.linear.app/mcp' },
158
+ 'oauth/linear',
159
+ { subject: { type: 'user', id: userId } },
160
+ { redirectUrl: 'https://app.example.com/integrations' }
161
+ ),
162
+ });
163
+ const stream = chat({ adapter, messages, mcp: { clients: [linear] } });
164
+ return toServerSentEventsResponse(stream);
165
+ } catch (err) {
166
+ const challenge = getConsentChallenge(err);
167
+ if (challenge) return Response.redirect(challenge.url);
168
+ throw err;
169
+ }
170
+ }
171
+ ```
172
+
173
+ `createMCPClients` pools take the same per-entry transport config, so one
174
+ `connectMCPTransport` per connector drops in. `connectAuthProvider` is also
175
+ re-exported for hand-built transports; only pass `consent: 'eager'` with
176
+ `type: 'http'`. Tool-call approval remains independent of Connect: use
177
+ TanStack's `needsApproval` on tool definitions. See the
178
+ [TanStack AI integration guide](https://github.com/vercel/vercel/blob/main/packages/connect/docs/tanstack-ai-mcp-integration.md)
179
+ for the eager-consent rationale, the SSE caveat, pool caveats, and the
180
+ `redirectUrl` fix for official-SDK transports.
124
181
 
125
182
  ### Eve
126
183
 
@@ -11,10 +11,12 @@
11
11
  * the AI SDK's own `toolApproval` primitive (and `wrapMcpTools` in
12
12
  * `@ai-sdk/policy-opa`). See `docs/ai-sdk-mcp-integration.md`.
13
13
  *
14
- * Both `ai` and `@ai-sdk/mcp` are optional peer dependencies:
15
- * importing this entrypoint requires them to be installed in the
16
- * consumer project, but the rest of `@vercel/connect` works without
17
- * them.
14
+ * This entrypoint has no runtime or type import of `ai` or
15
+ * `@ai-sdk/mcp`; `connectAuthProvider` returns the self-contained
16
+ * `ConnectOAuthClientProvider`, which the test suite asserts is
17
+ * assignable to `@ai-sdk/mcp`'s `OAuthClientProvider`. Both packages
18
+ * are declared as optional peers only to signal the tested version
19
+ * ranges.
18
20
  *
19
21
  * ```ts
20
22
  * import { createMCPClient } from '@ai-sdk/mcp';
@@ -48,5 +50,5 @@
48
50
  * }
49
51
  * ```
50
52
  */
51
- export { connectAuthProvider, ConsentRequiredError, type ConnectAuthProviderOptions, type ConsentChallenge, } from '../mcp/connect-auth-provider.js';
53
+ export { CONNECT_DISCOVERY_STATE, CONNECT_MANAGED_REDIRECT_URL, connectAuthProvider, ConsentRequiredError, getConsentChallenge, isConsentRequiredError, type ConnectAuthProviderOptions, type ConnectConsentMode, type ConnectOAuthClientInformation, type ConnectOAuthClientMetadata, type ConnectOAuthClientProvider, type ConnectOAuthDiscoveryState, type ConnectOAuthTokens, type ConsentChallenge, } from '../mcp/connect-auth-provider.js';
52
54
  export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, type ConnectErrorOptions, type ConnectTokenExchangeSubject, type ConnectTokenParams, type ConnectTokenSubject, type ConnectVendorErrorPayload, } from '../token.js';
@@ -11,10 +11,12 @@
11
11
  * the AI SDK's own `toolApproval` primitive (and `wrapMcpTools` in
12
12
  * `@ai-sdk/policy-opa`). See `docs/ai-sdk-mcp-integration.md`.
13
13
  *
14
- * Both `ai` and `@ai-sdk/mcp` are optional peer dependencies:
15
- * importing this entrypoint requires them to be installed in the
16
- * consumer project, but the rest of `@vercel/connect` works without
17
- * them.
14
+ * This entrypoint has no runtime or type import of `ai` or
15
+ * `@ai-sdk/mcp`; `connectAuthProvider` returns the self-contained
16
+ * `ConnectOAuthClientProvider`, which the test suite asserts is
17
+ * assignable to `@ai-sdk/mcp`'s `OAuthClientProvider`. Both packages
18
+ * are declared as optional peers only to signal the tested version
19
+ * ranges.
18
20
  *
19
21
  * ```ts
20
22
  * import { createMCPClient } from '@ai-sdk/mcp';
@@ -48,5 +50,5 @@
48
50
  * }
49
51
  * ```
50
52
  */
51
- export { connectAuthProvider, ConsentRequiredError, } from '../mcp/connect-auth-provider.js';
53
+ export { CONNECT_DISCOVERY_STATE, CONNECT_MANAGED_REDIRECT_URL, connectAuthProvider, ConsentRequiredError, getConsentChallenge, isConsentRequiredError, } from '../mcp/connect-auth-provider.js';
52
54
  export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, } from '../token.js';
package/dist/index.d.ts CHANGED
@@ -2,4 +2,4 @@ export { deleteTokenCacheEntry, getToken, getTokenResponse, revokeToken, Connect
2
2
  export { startAuthorization, type ConnectAuthorizationOptions, type ConnectAuthorizationResponse, } from './authorization.js';
3
3
  export { experimental_startInstallation, type ConnectInstallationOptions, type ConnectInstallationParams, type ConnectInstallationResponse, } from './installation.js';
4
4
  export type { ConnectAuthorizationDetail } from './authorization-details.js';
5
- export { getConnectorMetadata, type ConnectorMetadata, } from './connector.js';
5
+ export { getConnectorMetadata, type ConnectorMetadata } from './connector.js';
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
1
  export { deleteTokenCacheEntry, getToken, getTokenResponse, revokeToken, ConnectError, NoValidTokenError, UserAuthorizationRequiredError, ConnectorInstallationRequiredError, } from './token.js';
2
2
  export { startAuthorization, } from './authorization.js';
3
3
  export { experimental_startInstallation, } from './installation.js';
4
- export { getConnectorMetadata, } from './connector.js';
4
+ export { getConnectorMetadata } from './connector.js';
@@ -1,5 +1,117 @@
1
- import type { OAuthClientProvider } from '@ai-sdk/mcp';
2
1
  import { type ConnectTokenParams } from '../token.js';
2
+ /**
3
+ * OAuth token set as consumed by MCP client transports (RFC 6749 §5.1).
4
+ * Structurally identical to `OAuthTokens` in both `@ai-sdk/mcp` and
5
+ * `@modelcontextprotocol/sdk`; declared here so this module has no
6
+ * type dependency on either package.
7
+ */
8
+ export interface ConnectOAuthTokens {
9
+ access_token: string;
10
+ token_type: string;
11
+ expires_in?: number;
12
+ scope?: string;
13
+ refresh_token?: string;
14
+ id_token?: string;
15
+ }
16
+ /**
17
+ * Minimal OAuth client metadata (RFC 7591 §2). Only consulted by MCP
18
+ * transports during dynamic client registration, which the Connect
19
+ * adapter never triggers.
20
+ */
21
+ export interface ConnectOAuthClientMetadata {
22
+ redirect_uris: string[];
23
+ }
24
+ /** OAuth client identity reported to MCP transports. */
25
+ export interface ConnectOAuthClientInformation {
26
+ client_id: string;
27
+ }
28
+ /**
29
+ * Structural mirror of the official SDK's `OAuthDiscoveryState`: the
30
+ * result of RFC 9728 protected-resource discovery plus authorization
31
+ * server metadata. {@link connectAuthProvider} returns a fixed state
32
+ * naming Connect as the authorization server so the SDK never performs
33
+ * network discovery; see {@link CONNECT_DISCOVERY_STATE}.
34
+ */
35
+ export interface ConnectOAuthDiscoveryState {
36
+ authorizationServerUrl: string;
37
+ authorizationServerMetadata: {
38
+ issuer: string;
39
+ authorization_endpoint: string;
40
+ token_endpoint: string;
41
+ response_types_supported: string[];
42
+ code_challenge_methods_supported: string[];
43
+ };
44
+ resourceMetadata: {
45
+ resource: string;
46
+ authorization_servers: string[];
47
+ };
48
+ }
49
+ /**
50
+ * The MCP-spec `OAuthClientProvider` members implemented by
51
+ * {@link connectAuthProvider}.
52
+ *
53
+ * This is the intersection of the required surface of
54
+ * `OAuthClientProvider` from `@ai-sdk/mcp` and from the official
55
+ * `@modelcontextprotocol/sdk` (`client/auth.js`), so a value of this
56
+ * type is assignable to either interface without a cast. The optional
57
+ * members those interfaces declare (`state`, `invalidateCredentials`,
58
+ * `addClientAuthentication`, `prepareTokenRequest`, discovery-state
59
+ * hooks, …) are deliberately omitted: Connect owns registration, PKCE,
60
+ * refresh, and storage server-side, and declaring them here would tie
61
+ * this type to one SDK's parameter types. The test suite asserts
62
+ * assignability against both SDKs.
63
+ *
64
+ * The exceptions are the three discovery hooks at the bottom. They
65
+ * are optional in the official SDK's interface and absent from
66
+ * `@ai-sdk/mcp`'s, and are implemented here so the official SDK's
67
+ * `auth()` never fetches discovery documents from URLs chosen by the
68
+ * MCP server's `WWW-Authenticate` header (a blind-SSRF vector when
69
+ * the configured server is compromised). Connect is the authorization
70
+ * server from this adapter's point of view, so there is nothing to
71
+ * discover.
72
+ */
73
+ export interface ConnectOAuthClientProvider {
74
+ tokens(): Promise<ConnectOAuthTokens | undefined>;
75
+ saveTokens(tokens: ConnectOAuthTokens): void;
76
+ redirectToAuthorization(authorizationUrl: URL): Promise<void>;
77
+ saveCodeVerifier(codeVerifier: string): void;
78
+ codeVerifier(): string;
79
+ get redirectUrl(): string;
80
+ get clientMetadata(): ConnectOAuthClientMetadata;
81
+ clientInformation(): ConnectOAuthClientInformation;
82
+ discoveryState(): ConnectOAuthDiscoveryState;
83
+ saveDiscoveryState(state: unknown): void;
84
+ validateResourceURL(serverUrl: string | URL, resource?: string): Promise<URL | undefined>;
85
+ }
86
+ /**
87
+ * How {@link connectAuthProvider} surfaces a missing Connect grant.
88
+ *
89
+ * - `'transport'` (default) — `tokens()` returns `undefined` and the
90
+ * MCP transport's own 401 → `auth()` orchestrator eventually calls
91
+ * `redirectToAuthorization`, where the Connect consent URL is
92
+ * raised. This is the MCP-spec contract and works with every
93
+ * transport in `@ai-sdk/mcp` and `@modelcontextprotocol/sdk`.
94
+ * - `'eager'` — `tokens()` itself raises the consent challenge, before
95
+ * any request reaches the MCP server. Connect authoritatively knows
96
+ * whether a grant exists, so this skips the transport's
97
+ * protected-resource discovery round-trips and surfaces the
98
+ * challenge on the very first request (the MCP `initialize`
99
+ * handshake) instead of mid-run inside a tool call.
100
+ *
101
+ * **Streamable HTTP only.** The official SDK's
102
+ * `StreamableHTTPClientTransport` (and `@ai-sdk/mcp`'s HTTP
103
+ * transport) call `tokens()` per request and let a throw propagate
104
+ * to the caller. The official `SSEClientTransport` instead calls
105
+ * `tokens()` inside the `fetch` it hands to its EventSource, which
106
+ * treats any throw as a retryable network error: the consent error
107
+ * is flattened to a cause-less `SseError` and the EventSource
108
+ * reconnects every few seconds, minting a new Connect authorization
109
+ * request each time, until the transport is closed. Never combine
110
+ * `'eager'` with an SSE transport; `connectMCPTransport` in
111
+ * `@vercel/connect/tanstack-ai` selects the mode per transport type
112
+ * for you.
113
+ */
114
+ export type ConnectConsentMode = 'transport' | 'eager';
3
115
  /** Options accepted by {@link connectAuthProvider}. */
4
116
  export interface ConnectAuthProviderOptions {
5
117
  /**
@@ -20,8 +132,16 @@ export interface ConnectAuthProviderOptions {
20
132
  * `startAuthorization` (as its `callbackUrl`) and also surfaced
21
133
  * through the MCP-spec `OAuthClientProvider.redirectUrl` getter.
22
134
  *
23
- * Defaults to an empty string — Connect then falls back to the
24
- * connector's server-side registered redirect.
135
+ * When omitted, Connect falls back to the connector's server-side
136
+ * registered redirect, and the `redirectUrl` getter reports
137
+ * {@link CONNECT_MANAGED_REDIRECT_URL} instead of an empty string.
138
+ * The official `@modelcontextprotocol/sdk` treats a falsy
139
+ * `redirectUrl` as a request for its non-interactive
140
+ * client-credentials flow, which would bypass Connect's consent
141
+ * page entirely; a non-empty placeholder keeps that transport on
142
+ * the interactive path. The placeholder is never used as a real
143
+ * redirect: `redirectToAuthorization` discards the SDK-built
144
+ * authorization URL in favour of Connect's own consent URL.
25
145
  */
26
146
  readonly redirectUrl?: string;
27
147
  /**
@@ -35,10 +155,29 @@ export interface ConnectAuthProviderOptions {
35
155
  * Called when Vercel Connect reports that the user has not yet
36
156
  * authorized the connector. The caller decides how to surface the
37
157
  * consent URL — `redirect()`, throw a typed error, emit a custom
38
- * UI chunk, etc. If omitted, `connectAuthProvider` throws
39
- * {@link ConsentRequiredError}.
158
+ * UI chunk, etc.
159
+ *
160
+ * In `'transport'` consent mode the callback replaces the default
161
+ * {@link ConsentRequiredError}: if it returns normally, this adapter
162
+ * throws nothing. The MCP transport still fails the request with
163
+ * its own generic `UnauthorizedError` (which carries no `cause`, so
164
+ * {@link getConsentChallenge} cannot recover the URL from it); a
165
+ * non-throwing callback is therefore only useful for side effects
166
+ * such as logging or emitting a UI chunk, or when the callback
167
+ * itself redirects by throwing (e.g. a framework `redirect()`).
168
+ *
169
+ * In `'eager'` mode the callback runs first and
170
+ * {@link ConsentRequiredError} is thrown regardless, because
171
+ * `tokens()` must abort the outgoing request; returning `undefined`
172
+ * there would let the transport proceed unauthenticated and raise
173
+ * the challenge a second time.
40
174
  */
41
175
  readonly onConsentRequired?: (challenge: ConsentChallenge) => void | Promise<void>;
176
+ /**
177
+ * Where the consent challenge is raised. See
178
+ * {@link ConnectConsentMode}. Defaults to `'transport'`.
179
+ */
180
+ readonly consent?: ConnectConsentMode;
42
181
  }
43
182
  /**
44
183
  * Returned to {@link ConnectAuthProviderOptions.onConsentRequired} (or
@@ -68,6 +207,11 @@ export interface ConsentChallenge {
68
207
  * the configured subject. Catch at the boundary
69
208
  * (`route handler` / `streamText` call site) and redirect to
70
209
  * `error.url`.
210
+ *
211
+ * MCP client libraries often wrap connection failures in their own
212
+ * error type (for example `@tanstack/ai-mcp`'s `MCPConnectionError`)
213
+ * with this error on `cause`. Use {@link getConsentChallenge} to find
214
+ * it anywhere in a `cause` chain.
71
215
  */
72
216
  export declare class ConsentRequiredError extends Error {
73
217
  readonly name = "ConsentRequiredError";
@@ -81,13 +225,79 @@ export declare class ConsentRequiredError extends Error {
81
225
  constructor(challenge: ConsentChallenge);
82
226
  }
83
227
  /**
84
- * Builds an MCP-spec {@link OAuthClientProvider} backed by Vercel
228
+ * Type guard for {@link ConsentRequiredError}. Only matches the error
229
+ * itself; use {@link getConsentChallenge} to look through wrapping
230
+ * errors' `cause` chains.
231
+ */
232
+ export declare function isConsentRequiredError(err: unknown): err is ConsentRequiredError;
233
+ /**
234
+ * Finds a Connect consent challenge in `err` or anywhere along its
235
+ * `cause` chain and returns it, or `undefined` when none is present.
236
+ *
237
+ * MCP clients typically raise the adapter's {@link ConsentRequiredError}
238
+ * from inside their connect / tool-call machinery and re-wrap it —
239
+ * `@tanstack/ai-mcp`'s `createMCPClient` throws `MCPConnectionError`
240
+ * with the original error on `cause`. This helper lets a route handler
241
+ * branch on consent without knowing the wrapping shape:
242
+ *
243
+ * ```ts
244
+ * } catch (err) {
245
+ * const challenge = getConsentChallenge(err);
246
+ * if (challenge) return Response.redirect(challenge.url);
247
+ * throw err;
248
+ * }
249
+ * ```
250
+ *
251
+ * Matching is by `instanceof` first, then structurally on
252
+ * `name === 'ConsentRequiredError'` so a duplicated copy of
253
+ * `@vercel/connect` in the dependency graph is still recognized.
254
+ */
255
+ export declare function getConsentChallenge(err: unknown): ConsentChallenge | undefined;
256
+ /**
257
+ * Value reported by `OAuthClientProvider.redirectUrl` when no
258
+ * {@link ConnectAuthProviderOptions.redirectUrl} is configured.
259
+ *
260
+ * Connect's hosted consent flow owns the real post-consent redirect
261
+ * (the connector's server-side registered redirect), so this URL is
262
+ * never followed. It exists only so the official
263
+ * `@modelcontextprotocol/sdk` `auth()` orchestrator — which treats a
264
+ * falsy `redirectUrl` as "run the non-interactive client-credentials
265
+ * flow" — stays on the interactive path and reaches
266
+ * `redirectToAuthorization`.
267
+ */
268
+ export declare const CONNECT_MANAGED_REDIRECT_URL = "https://connect.vercel.com/";
269
+ /**
270
+ * Discovery state returned by `OAuthClientProvider.discoveryState()`.
271
+ *
272
+ * The official `@modelcontextprotocol/sdk` `auth()` orchestrator runs
273
+ * RFC 9728 discovery on every 401: it fetches the URL named by the MCP
274
+ * server's `WWW-Authenticate: Bearer resource_metadata="…"` parameter,
275
+ * then the authorization-server metadata at whatever host that
276
+ * document names. Neither URL is validated, so a compromised MCP
277
+ * server can point them at private-network addresses (blind SSRF).
278
+ * When a provider returns a complete cached state the orchestrator
279
+ * skips every discovery fetch, so this adapter returns one that names
280
+ * Connect as the authorization server. None of these endpoints is
281
+ * ever called: `redirectToAuthorization` discards the authorization
282
+ * URL the SDK builds from them in favour of Connect's own consent URL,
283
+ * and the token endpoint is only used for refresh and code exchange,
284
+ * both of which Connect performs server-side.
285
+ */
286
+ export declare const CONNECT_DISCOVERY_STATE: ConnectOAuthDiscoveryState;
287
+ /**
288
+ * Builds an MCP-spec `OAuthClientProvider` backed by Vercel
85
289
  * Connect. The returned object delegates `tokens()` to
86
290
  * {@link getTokenResponse} and `redirectToAuthorization` to
87
291
  * {@link startAuthorization}. Connect owns client registration, PKCE,
88
292
  * state, and the callback handshake server-side, so most of the
89
293
  * `OAuthClientProvider` surface is intentionally a no-op.
90
294
  *
295
+ * The returned object satisfies both `@ai-sdk/mcp`'s and the official
296
+ * `@modelcontextprotocol/sdk`'s `OAuthClientProvider` interfaces, so
297
+ * it plugs into `createMCPClient` from either `@ai-sdk/mcp` or
298
+ * `@tanstack/ai-mcp`, and into a hand-built
299
+ * `StreamableHTTPClientTransport`.
300
+ *
91
301
  * @param connector Vercel Connect connector UID (e.g. `oauth/linear`)
92
302
  * or opaque connector id.
93
303
  * @param params Token request parameters — always specify a
@@ -96,4 +306,4 @@ export declare class ConsentRequiredError extends Error {
96
306
  * security semantics.
97
307
  * @param options Optional adapter behavior overrides.
98
308
  */
99
- export declare function connectAuthProvider(connector: string, params: ConnectTokenParams, options?: ConnectAuthProviderOptions): OAuthClientProvider;
309
+ export declare function connectAuthProvider(connector: string, params: ConnectTokenParams, options?: ConnectAuthProviderOptions): ConnectOAuthClientProvider;
@@ -1,11 +1,16 @@
1
1
  import { startAuthorization } from '../authorization.js';
2
- import { ConnectorInstallationRequiredError, UserAuthorizationRequiredError, getTokenResponse, } from '../token.js';
2
+ import { UserAuthorizationRequiredError, getTokenResponse, } from '../token.js';
3
3
  /**
4
4
  * Thrown by the {@link connectAuthProvider} default
5
5
  * `onConsentRequired` handler when the user has no Connect grant for
6
6
  * the configured subject. Catch at the boundary
7
7
  * (`route handler` / `streamText` call site) and redirect to
8
8
  * `error.url`.
9
+ *
10
+ * MCP client libraries often wrap connection failures in their own
11
+ * error type (for example `@tanstack/ai-mcp`'s `MCPConnectionError`)
12
+ * with this error on `cause`. Use {@link getConsentChallenge} to find
13
+ * it anywhere in a `cause` chain.
9
14
  */
10
15
  export class ConsentRequiredError extends Error {
11
16
  name = 'ConsentRequiredError';
@@ -27,17 +32,144 @@ export class ConsentRequiredError extends Error {
27
32
  this.expiresAt = challenge.expiresAt;
28
33
  }
29
34
  }
35
+ /**
36
+ * Type guard for {@link ConsentRequiredError}. Only matches the error
37
+ * itself; use {@link getConsentChallenge} to look through wrapping
38
+ * errors' `cause` chains.
39
+ */
40
+ export function isConsentRequiredError(err) {
41
+ return err instanceof ConsentRequiredError;
42
+ }
43
+ /** Upper bound on `cause` hops walked by {@link getConsentChallenge}. */
44
+ const MAX_CAUSE_DEPTH = 16;
45
+ /**
46
+ * Finds a Connect consent challenge in `err` or anywhere along its
47
+ * `cause` chain and returns it, or `undefined` when none is present.
48
+ *
49
+ * MCP clients typically raise the adapter's {@link ConsentRequiredError}
50
+ * from inside their connect / tool-call machinery and re-wrap it —
51
+ * `@tanstack/ai-mcp`'s `createMCPClient` throws `MCPConnectionError`
52
+ * with the original error on `cause`. This helper lets a route handler
53
+ * branch on consent without knowing the wrapping shape:
54
+ *
55
+ * ```ts
56
+ * } catch (err) {
57
+ * const challenge = getConsentChallenge(err);
58
+ * if (challenge) return Response.redirect(challenge.url);
59
+ * throw err;
60
+ * }
61
+ * ```
62
+ *
63
+ * Matching is by `instanceof` first, then structurally on
64
+ * `name === 'ConsentRequiredError'` so a duplicated copy of
65
+ * `@vercel/connect` in the dependency graph is still recognized.
66
+ */
67
+ export function getConsentChallenge(err) {
68
+ let current = err;
69
+ for (let depth = 0; depth < MAX_CAUSE_DEPTH; depth++) {
70
+ if (current === null || typeof current !== 'object') {
71
+ return undefined;
72
+ }
73
+ const challenge = toConsentChallenge(current);
74
+ if (challenge) {
75
+ return challenge;
76
+ }
77
+ current = current.cause;
78
+ }
79
+ return undefined;
80
+ }
81
+ function toConsentChallenge(candidate) {
82
+ if (candidate instanceof ConsentRequiredError) {
83
+ return {
84
+ connector: candidate.connector,
85
+ subject: candidate.subject,
86
+ url: candidate.url,
87
+ request: candidate.request,
88
+ verifier: candidate.verifier,
89
+ deviceCode: candidate.deviceCode,
90
+ expiresAt: candidate.expiresAt,
91
+ };
92
+ }
93
+ const value = candidate;
94
+ if (value.name === 'ConsentRequiredError' &&
95
+ typeof value.url === 'string' &&
96
+ typeof value.connector === 'string' &&
97
+ typeof value.request === 'string' &&
98
+ typeof value.verifier === 'string' &&
99
+ value.subject !== undefined) {
100
+ return {
101
+ connector: value.connector,
102
+ subject: value.subject,
103
+ url: value.url,
104
+ request: value.request,
105
+ verifier: value.verifier,
106
+ deviceCode: value.deviceCode,
107
+ expiresAt: value.expiresAt,
108
+ };
109
+ }
110
+ return undefined;
111
+ }
112
+ /**
113
+ * Value reported by `OAuthClientProvider.redirectUrl` when no
114
+ * {@link ConnectAuthProviderOptions.redirectUrl} is configured.
115
+ *
116
+ * Connect's hosted consent flow owns the real post-consent redirect
117
+ * (the connector's server-side registered redirect), so this URL is
118
+ * never followed. It exists only so the official
119
+ * `@modelcontextprotocol/sdk` `auth()` orchestrator — which treats a
120
+ * falsy `redirectUrl` as "run the non-interactive client-credentials
121
+ * flow" — stays on the interactive path and reaches
122
+ * `redirectToAuthorization`.
123
+ */
124
+ export const CONNECT_MANAGED_REDIRECT_URL = 'https://connect.vercel.com/';
125
+ /**
126
+ * Discovery state returned by `OAuthClientProvider.discoveryState()`.
127
+ *
128
+ * The official `@modelcontextprotocol/sdk` `auth()` orchestrator runs
129
+ * RFC 9728 discovery on every 401: it fetches the URL named by the MCP
130
+ * server's `WWW-Authenticate: Bearer resource_metadata="…"` parameter,
131
+ * then the authorization-server metadata at whatever host that
132
+ * document names. Neither URL is validated, so a compromised MCP
133
+ * server can point them at private-network addresses (blind SSRF).
134
+ * When a provider returns a complete cached state the orchestrator
135
+ * skips every discovery fetch, so this adapter returns one that names
136
+ * Connect as the authorization server. None of these endpoints is
137
+ * ever called: `redirectToAuthorization` discards the authorization
138
+ * URL the SDK builds from them in favour of Connect's own consent URL,
139
+ * and the token endpoint is only used for refresh and code exchange,
140
+ * both of which Connect performs server-side.
141
+ */
142
+ export const CONNECT_DISCOVERY_STATE = {
143
+ authorizationServerUrl: 'https://connect.vercel.com',
144
+ authorizationServerMetadata: {
145
+ issuer: 'https://connect.vercel.com',
146
+ authorization_endpoint: 'https://connect.vercel.com/',
147
+ token_endpoint: 'https://connect.vercel.com/',
148
+ response_types_supported: ['code'],
149
+ code_challenge_methods_supported: ['S256'],
150
+ },
151
+ resourceMetadata: {
152
+ resource: 'https://connect.vercel.com/',
153
+ authorization_servers: ['https://connect.vercel.com'],
154
+ },
155
+ };
30
156
  const EMPTY_CLIENT_METADATA = {
31
157
  redirect_uris: [],
32
158
  };
33
159
  /**
34
- * Builds an MCP-spec {@link OAuthClientProvider} backed by Vercel
160
+ * Builds an MCP-spec `OAuthClientProvider` backed by Vercel
35
161
  * Connect. The returned object delegates `tokens()` to
36
162
  * {@link getTokenResponse} and `redirectToAuthorization` to
37
163
  * {@link startAuthorization}. Connect owns client registration, PKCE,
38
164
  * state, and the callback handshake server-side, so most of the
39
165
  * `OAuthClientProvider` surface is intentionally a no-op.
40
166
  *
167
+ * The returned object satisfies both `@ai-sdk/mcp`'s and the official
168
+ * `@modelcontextprotocol/sdk`'s `OAuthClientProvider` interfaces, so
169
+ * it plugs into `createMCPClient` from either `@ai-sdk/mcp` or
170
+ * `@tanstack/ai-mcp`, and into a hand-built
171
+ * `StreamableHTTPClientTransport`.
172
+ *
41
173
  * @param connector Vercel Connect connector UID (e.g. `oauth/linear`)
42
174
  * or opaque connector id.
43
175
  * @param params Token request parameters — always specify a
@@ -47,7 +179,8 @@ const EMPTY_CLIENT_METADATA = {
47
179
  * @param options Optional adapter behavior overrides.
48
180
  */
49
181
  export function connectAuthProvider(connector, params, options) {
50
- const redirectUrl = options?.redirectUrl ?? '';
182
+ const configuredRedirectUrl = options?.redirectUrl;
183
+ const consentMode = options?.consent ?? 'transport';
51
184
  async function resolveVercelToken() {
52
185
  const { vercelToken } = options ?? {};
53
186
  if (typeof vercelToken === 'function') {
@@ -55,6 +188,27 @@ export function connectAuthProvider(connector, params, options) {
55
188
  }
56
189
  return vercelToken;
57
190
  }
191
+ /** Asks Connect for a consent URL for the configured subject. */
192
+ async function mintConsentChallenge() {
193
+ const vercelToken = await resolveVercelToken();
194
+ const response = await startAuthorization(connector, params, {
195
+ ...(vercelToken !== undefined && { vercelToken }),
196
+ // Forward the post-consent return URL so the user lands back
197
+ // in the app. Omitted => Connect uses the connector's
198
+ // registered redirect.
199
+ ...(configuredRedirectUrl && { callbackUrl: configuredRedirectUrl }),
200
+ ...(options?.deviceCode && { deviceCode: true }),
201
+ });
202
+ return {
203
+ connector,
204
+ subject: params.subject,
205
+ url: response.url,
206
+ request: response.request,
207
+ verifier: response.verifier,
208
+ deviceCode: response.deviceCode,
209
+ expiresAt: response.expiresAt,
210
+ };
211
+ }
58
212
  return {
59
213
  async tokens() {
60
214
  try {
@@ -68,42 +222,34 @@ export function connectAuthProvider(connector, params, options) {
68
222
  };
69
223
  }
70
224
  catch (err) {
71
- if (err instanceof UserAuthorizationRequiredError) {
225
+ if (!(err instanceof UserAuthorizationRequiredError)) {
226
+ // ConnectorInstallationRequiredError (a configuration
227
+ // problem, not a per-user consent issue) and everything
228
+ // else propagate as-is rather than masquerading as a
229
+ // consent challenge.
230
+ throw err;
231
+ }
232
+ if (consentMode === 'transport') {
72
233
  // Returning undefined lets the MCP transport's auth()
73
234
  // orchestrator trigger redirectToAuthorization below,
74
235
  // where we surface the Connect consent URL.
75
236
  return undefined;
76
237
  }
77
- if (err instanceof ConnectorInstallationRequiredError) {
78
- // Configuration problem, not a per-user issue. Surface
79
- // immediately rather than masking as a consent challenge.
80
- throw err;
81
- }
82
- throw err;
238
+ // Eager: surface Connect's consent URL right here, before
239
+ // the transport sends anything. Connect is authoritative
240
+ // about the missing grant, so there is nothing to gain from
241
+ // letting the server 401 first. The callback runs for side
242
+ // effects, then the request is aborted unconditionally.
243
+ const challenge = await mintConsentChallenge();
244
+ await options?.onConsentRequired?.(challenge);
245
+ throw new ConsentRequiredError(challenge);
83
246
  }
84
247
  },
85
248
  async redirectToAuthorization(_authorizationUrl) {
86
249
  // The URL argument is the MCP server's discovered authorization
87
250
  // endpoint. We ignore it — Connect has its own consent URL
88
251
  // backed by the connector's registered OAuth client.
89
- const vercelToken = await resolveVercelToken();
90
- const response = await startAuthorization(connector, params, {
91
- ...(vercelToken !== undefined && { vercelToken }),
92
- // Forward the post-consent return URL so the user lands back
93
- // in the app. Falsy (the default empty string) => Connect uses
94
- // the connector's registered redirect.
95
- ...(redirectUrl && { callbackUrl: redirectUrl }),
96
- ...(options?.deviceCode && { deviceCode: true }),
97
- });
98
- const challenge = {
99
- connector,
100
- subject: params.subject,
101
- url: response.url,
102
- request: response.request,
103
- verifier: response.verifier,
104
- deviceCode: response.deviceCode,
105
- expiresAt: response.expiresAt,
106
- };
252
+ const challenge = await mintConsentChallenge();
107
253
  if (options?.onConsentRequired) {
108
254
  await options.onConsentRequired(challenge);
109
255
  return;
@@ -126,10 +272,13 @@ export function connectAuthProvider(connector, params, options) {
126
272
  throw new Error('@vercel/connect: OAuthClientProvider.codeVerifier() is unsupported. Vercel Connect owns the PKCE flow server-side; this method should never be invoked by a correctly-configured MCP transport.');
127
273
  },
128
274
  get redirectUrl() {
129
- // Same value forwarded to Connect as the post-consent return
130
- // URL. Surfaced here to satisfy the OAuthClientProvider
131
- // contract; the standard flow that reads it is never triggered.
132
- return redirectUrl;
275
+ // The configured post-consent return URL, or a non-empty
276
+ // placeholder. Must never be falsy: the official MCP SDK's
277
+ // auth() reads `!provider.redirectUrl` as "non-interactive
278
+ // flow" and would try a client-credentials token request
279
+ // against the third-party authorization server instead of
280
+ // calling redirectToAuthorization.
281
+ return configuredRedirectUrl || CONNECT_MANAGED_REDIRECT_URL;
133
282
  },
134
283
  get clientMetadata() {
135
284
  // Returned only during dynamic client registration, which we
@@ -144,5 +293,21 @@ export function connectAuthProvider(connector, params, options) {
144
293
  // returning a synthetic value.
145
294
  return { client_id: connector };
146
295
  },
296
+ discoveryState() {
297
+ // Pre-empts RFC 9728 discovery so the official SDK never fetches
298
+ // URLs chosen by the MCP server's WWW-Authenticate header.
299
+ return CONNECT_DISCOVERY_STATE;
300
+ },
301
+ saveDiscoveryState(_state) {
302
+ // No-op: the state above is fixed and never needs persisting.
303
+ },
304
+ async validateResourceURL() {
305
+ // Bypasses the SDK's check that the synthetic resource metadata
306
+ // matches the MCP server URL (it would throw before reaching
307
+ // redirectToAuthorization). Returning undefined also omits the
308
+ // RFC 8707 `resource` parameter from the authorization URL the
309
+ // SDK builds, which is discarded anyway.
310
+ return undefined;
311
+ },
147
312
  };
148
313
  }
@@ -9,9 +9,12 @@
9
9
  * `@vercel/connect/ai-sdk` instead; that subpath re-exports from
10
10
  * here.
11
11
  *
12
- * `@ai-sdk/mcp` is an optional peer dependency: importing this
13
- * entrypoint requires it to be installed in the consumer project,
14
- * but the rest of `@vercel/connect` works without it.
12
+ * This entrypoint has no runtime or type import of `@ai-sdk/mcp` or
13
+ * `@modelcontextprotocol/sdk`; `connectAuthProvider` returns the
14
+ * self-contained `ConnectOAuthClientProvider`, which the test suite
15
+ * asserts is assignable to both SDKs' `OAuthClientProvider`. Both
16
+ * packages are declared as optional peers only to signal the tested
17
+ * version ranges.
15
18
  *
16
19
  * ```ts
17
20
  * import { createMCPClient } from '@ai-sdk/mcp';
@@ -29,5 +32,5 @@
29
32
  * });
30
33
  * ```
31
34
  */
32
- export { connectAuthProvider, ConsentRequiredError, type ConnectAuthProviderOptions, type ConsentChallenge, } from './connect-auth-provider.js';
35
+ export { CONNECT_DISCOVERY_STATE, CONNECT_MANAGED_REDIRECT_URL, connectAuthProvider, ConsentRequiredError, getConsentChallenge, isConsentRequiredError, type ConnectAuthProviderOptions, type ConnectConsentMode, type ConnectOAuthClientInformation, type ConnectOAuthClientMetadata, type ConnectOAuthClientProvider, type ConnectOAuthDiscoveryState, type ConnectOAuthTokens, type ConsentChallenge, } from './connect-auth-provider.js';
33
36
  export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, type ConnectErrorOptions, type ConnectTokenExchangeSubject, type ConnectTokenParams, type ConnectTokenSubject, type ConnectVendorErrorPayload, } from '../token.js';
package/dist/mcp/index.js CHANGED
@@ -9,9 +9,12 @@
9
9
  * `@vercel/connect/ai-sdk` instead; that subpath re-exports from
10
10
  * here.
11
11
  *
12
- * `@ai-sdk/mcp` is an optional peer dependency: importing this
13
- * entrypoint requires it to be installed in the consumer project,
14
- * but the rest of `@vercel/connect` works without it.
12
+ * This entrypoint has no runtime or type import of `@ai-sdk/mcp` or
13
+ * `@modelcontextprotocol/sdk`; `connectAuthProvider` returns the
14
+ * self-contained `ConnectOAuthClientProvider`, which the test suite
15
+ * asserts is assignable to both SDKs' `OAuthClientProvider`. Both
16
+ * packages are declared as optional peers only to signal the tested
17
+ * version ranges.
15
18
  *
16
19
  * ```ts
17
20
  * import { createMCPClient } from '@ai-sdk/mcp';
@@ -29,5 +32,5 @@
29
32
  * });
30
33
  * ```
31
34
  */
32
- export { connectAuthProvider, ConsentRequiredError, } from './connect-auth-provider.js';
35
+ export { CONNECT_DISCOVERY_STATE, CONNECT_MANAGED_REDIRECT_URL, connectAuthProvider, ConsentRequiredError, getConsentChallenge, isConsentRequiredError, } from './connect-auth-provider.js';
33
36
  export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, } from '../token.js';
@@ -0,0 +1,145 @@
1
+ /**
2
+ * Public surface of the `@vercel/connect/tanstack-ai` subpath.
3
+ *
4
+ * [TanStack AI](https://tanstack.com/ai)'s MCP client
5
+ * (`@tanstack/ai-mcp`) is built directly on the official
6
+ * `@modelcontextprotocol/sdk` transports and accepts any MCP-spec
7
+ * `OAuthClientProvider` on its `http` / `sse` transport config. This
8
+ * subpath adds one TanStack-shaped helper on top of the shared Connect
9
+ * adapter:
10
+ *
11
+ * - {@link connectMCPTransport} builds the whole
12
+ * `createMCPClient({ transport })` config and picks the consent mode
13
+ * per transport type: `'eager'` for Streamable HTTP, `'transport'`
14
+ * for SSE. Eager consent makes `tokens()` raise the Connect consent
15
+ * challenge before the first request (the MCP `initialize`
16
+ * handshake) leaves the process, so a missing grant always fails at
17
+ * the route boundary, never mid-run inside a tool call where
18
+ * TanStack's chat loop would swallow it into an
19
+ * `Error executing tool: …` result the model sees instead of the
20
+ * user. Eager mode is unsafe on SSE (see `ConnectConsentMode`), so
21
+ * SSE configs keep the MCP-spec 401 flow.
22
+ * - {@link getConsentChallenge} looks through `MCPConnectionError`
23
+ * (and any other wrapper) `cause` chains so the route handler can
24
+ * redirect without knowing the wrapping shape.
25
+ *
26
+ * `connectAuthProvider` is re-exported unchanged from
27
+ * `@vercel/connect/mcp` for callers who build the transport config
28
+ * themselves; pass `consent: 'eager'` explicitly only with
29
+ * `type: 'http'`.
30
+ *
31
+ * Nothing here imports `@tanstack/ai`, `@tanstack/ai-mcp`,
32
+ * `@ai-sdk/mcp`, or `@modelcontextprotocol/sdk`: the returned provider
33
+ * is the self-contained `ConnectOAuthClientProvider`, which the test
34
+ * suite asserts is assignable to both SDKs' `OAuthClientProvider`
35
+ * interfaces, and the transport config types below mirror
36
+ * `@tanstack/ai-mcp`'s structurally. See
37
+ * `docs/tanstack-ai-mcp-integration.md`.
38
+ *
39
+ * ```ts
40
+ * import { chat, toServerSentEventsResponse } from '@tanstack/ai';
41
+ * import { createMCPClient } from '@tanstack/ai-mcp';
42
+ * import {
43
+ * connectMCPTransport,
44
+ * getConsentChallenge,
45
+ * } from '@vercel/connect/tanstack-ai';
46
+ *
47
+ * export async function POST(request: Request) {
48
+ * try {
49
+ * const linear = await createMCPClient({
50
+ * transport: connectMCPTransport(
51
+ * { type: 'http', url: 'https://mcp.linear.app/mcp' },
52
+ * 'oauth/linear',
53
+ * { subject: { type: 'user', id: userId } },
54
+ * { redirectUrl: 'https://app.example.com/integrations' }
55
+ * ),
56
+ * });
57
+ * const stream = chat({ adapter, messages, mcp: { clients: [linear] } });
58
+ * return toServerSentEventsResponse(stream);
59
+ * } catch (err) {
60
+ * const challenge = getConsentChallenge(err);
61
+ * if (challenge) return Response.redirect(challenge.url);
62
+ * throw err;
63
+ * }
64
+ * }
65
+ * ```
66
+ */
67
+ import { type ConnectAuthProviderOptions, type ConnectOAuthClientProvider } from '../mcp/connect-auth-provider.js';
68
+ import type { ConnectTokenParams } from '../token.js';
69
+ /**
70
+ * The network transport configs accepted by `@tanstack/ai-mcp`'s
71
+ * `createMCPClient` / `createMCPClients`, mirrored structurally so this
72
+ * module needs no TanStack import. `type: 'http'` is Streamable HTTP
73
+ * (preferred, edge-safe); `type: 'sse'` is the legacy SSE transport.
74
+ */
75
+ export interface ConnectMCPTransportConfig {
76
+ readonly type: 'http' | 'sse';
77
+ readonly url: string;
78
+ /** Extra headers sent with every request (e.g. deployment-protection bypass). */
79
+ readonly headers?: Record<string, string>;
80
+ /** Bring-your-own `fetch`, forwarded to the SDK transport. */
81
+ readonly fetch?: typeof fetch;
82
+ }
83
+ /**
84
+ * {@link ConnectMCPTransportConfig} with the Connect provider attached
85
+ * and, unless `redirect: 'follow'` was requested, a `fetch` that refuses
86
+ * HTTP redirects.
87
+ */
88
+ export type ConnectMCPTransport<T extends ConnectMCPTransportConfig> = T & {
89
+ readonly fetch?: typeof fetch;
90
+ readonly authProvider: ConnectOAuthClientProvider;
91
+ };
92
+ /** Options accepted by {@link connectMCPTransport}. */
93
+ export interface ConnectMCPTransportOptions extends ConnectAuthProviderOptions {
94
+ /**
95
+ * How HTTP redirects from the MCP server are handled, for every
96
+ * request the transport makes (`initialize`, tool calls, the SSE
97
+ * stream, session teardown).
98
+ *
99
+ * - `'error'` (default): the request fails instead of following the
100
+ * redirect. The official SDK otherwise uses native fetch's
101
+ * `'follow'`, which lets a compromised MCP server bounce the
102
+ * application to private-network URLs (a blind GET; the Fetch
103
+ * spec strips `Authorization` on cross-origin redirects). This
104
+ * matches `@ai-sdk/mcp`, whose transport also defaults to
105
+ * `'error'`.
106
+ * - `'follow'`: native behavior, for an endpoint that legitimately
107
+ * redirects. `config.fetch` is then passed through untouched.
108
+ *
109
+ * The policy is applied by wrapping `config.fetch` (or the global
110
+ * `fetch`, resolved at call time), so a caller-supplied guarded
111
+ * fetch keeps its own protections. On `type: 'http'` the wrapper
112
+ * passes fetch's own `redirect: 'error'`, which rejects the request.
113
+ * On `type: 'sse'` it passes `redirect: 'manual'` instead: the
114
+ * official SDK's `SSEClientTransport` runs `fetch` inside an
115
+ * EventSource that treats any rejection as a retryable network
116
+ * error and reconnects every few seconds with no handle left for
117
+ * the caller to close, whereas a non-200 response is terminal. With
118
+ * `'manual'` the 3xx comes back as that terminal response, so the
119
+ * connection fails once and stays closed.
120
+ */
121
+ readonly redirect?: 'follow' | 'error';
122
+ }
123
+ /**
124
+ * Builds a `@tanstack/ai-mcp` transport config whose `authProvider` is
125
+ * backed by Vercel Connect, choosing the consent mode that is safe for
126
+ * the transport: `'eager'` for `type: 'http'` (consent surfaces inside
127
+ * `createMCPClient`, before any request reaches the server) and
128
+ * `'transport'` for `type: 'sse'` (the MCP-spec 401 flow; eager mode
129
+ * would be flattened into a retry loop by the SSE EventSource). An
130
+ * explicit `options.consent` overrides the choice — only do that with
131
+ * `type: 'http'`.
132
+ *
133
+ * `headers` on `config` are passed through untouched. `fetch` is
134
+ * wrapped to refuse HTTP redirects unless `options.redirect` is
135
+ * `'follow'`; see {@link ConnectMCPTransportOptions.redirect}.
136
+ *
137
+ * @param config TanStack transport config without `authProvider`.
138
+ * @param connector Vercel Connect connector UID (e.g. `oauth/linear`)
139
+ * or opaque connector id.
140
+ * @param params Token request parameters — always specify a `subject`.
141
+ * @param options Optional adapter and transport behavior overrides.
142
+ */
143
+ export declare function connectMCPTransport<T extends ConnectMCPTransportConfig>(config: T, connector: string, params: ConnectTokenParams, options?: ConnectMCPTransportOptions): ConnectMCPTransport<T>;
144
+ export { CONNECT_DISCOVERY_STATE, CONNECT_MANAGED_REDIRECT_URL, connectAuthProvider, ConsentRequiredError, getConsentChallenge, isConsentRequiredError, type ConnectAuthProviderOptions, type ConnectConsentMode, type ConnectOAuthClientInformation, type ConnectOAuthClientMetadata, type ConnectOAuthClientProvider, type ConnectOAuthDiscoveryState, type ConnectOAuthTokens, type ConsentChallenge, } from '../mcp/connect-auth-provider.js';
145
+ export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, type ConnectErrorOptions, type ConnectTokenExchangeSubject, type ConnectTokenParams, type ConnectTokenSubject, type ConnectVendorErrorPayload, } from '../token.js';
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Public surface of the `@vercel/connect/tanstack-ai` subpath.
3
+ *
4
+ * [TanStack AI](https://tanstack.com/ai)'s MCP client
5
+ * (`@tanstack/ai-mcp`) is built directly on the official
6
+ * `@modelcontextprotocol/sdk` transports and accepts any MCP-spec
7
+ * `OAuthClientProvider` on its `http` / `sse` transport config. This
8
+ * subpath adds one TanStack-shaped helper on top of the shared Connect
9
+ * adapter:
10
+ *
11
+ * - {@link connectMCPTransport} builds the whole
12
+ * `createMCPClient({ transport })` config and picks the consent mode
13
+ * per transport type: `'eager'` for Streamable HTTP, `'transport'`
14
+ * for SSE. Eager consent makes `tokens()` raise the Connect consent
15
+ * challenge before the first request (the MCP `initialize`
16
+ * handshake) leaves the process, so a missing grant always fails at
17
+ * the route boundary, never mid-run inside a tool call where
18
+ * TanStack's chat loop would swallow it into an
19
+ * `Error executing tool: …` result the model sees instead of the
20
+ * user. Eager mode is unsafe on SSE (see `ConnectConsentMode`), so
21
+ * SSE configs keep the MCP-spec 401 flow.
22
+ * - {@link getConsentChallenge} looks through `MCPConnectionError`
23
+ * (and any other wrapper) `cause` chains so the route handler can
24
+ * redirect without knowing the wrapping shape.
25
+ *
26
+ * `connectAuthProvider` is re-exported unchanged from
27
+ * `@vercel/connect/mcp` for callers who build the transport config
28
+ * themselves; pass `consent: 'eager'` explicitly only with
29
+ * `type: 'http'`.
30
+ *
31
+ * Nothing here imports `@tanstack/ai`, `@tanstack/ai-mcp`,
32
+ * `@ai-sdk/mcp`, or `@modelcontextprotocol/sdk`: the returned provider
33
+ * is the self-contained `ConnectOAuthClientProvider`, which the test
34
+ * suite asserts is assignable to both SDKs' `OAuthClientProvider`
35
+ * interfaces, and the transport config types below mirror
36
+ * `@tanstack/ai-mcp`'s structurally. See
37
+ * `docs/tanstack-ai-mcp-integration.md`.
38
+ *
39
+ * ```ts
40
+ * import { chat, toServerSentEventsResponse } from '@tanstack/ai';
41
+ * import { createMCPClient } from '@tanstack/ai-mcp';
42
+ * import {
43
+ * connectMCPTransport,
44
+ * getConsentChallenge,
45
+ * } from '@vercel/connect/tanstack-ai';
46
+ *
47
+ * export async function POST(request: Request) {
48
+ * try {
49
+ * const linear = await createMCPClient({
50
+ * transport: connectMCPTransport(
51
+ * { type: 'http', url: 'https://mcp.linear.app/mcp' },
52
+ * 'oauth/linear',
53
+ * { subject: { type: 'user', id: userId } },
54
+ * { redirectUrl: 'https://app.example.com/integrations' }
55
+ * ),
56
+ * });
57
+ * const stream = chat({ adapter, messages, mcp: { clients: [linear] } });
58
+ * return toServerSentEventsResponse(stream);
59
+ * } catch (err) {
60
+ * const challenge = getConsentChallenge(err);
61
+ * if (challenge) return Response.redirect(challenge.url);
62
+ * throw err;
63
+ * }
64
+ * }
65
+ * ```
66
+ */
67
+ import { connectAuthProvider, } from '../mcp/connect-auth-provider.js';
68
+ /**
69
+ * Builds a `@tanstack/ai-mcp` transport config whose `authProvider` is
70
+ * backed by Vercel Connect, choosing the consent mode that is safe for
71
+ * the transport: `'eager'` for `type: 'http'` (consent surfaces inside
72
+ * `createMCPClient`, before any request reaches the server) and
73
+ * `'transport'` for `type: 'sse'` (the MCP-spec 401 flow; eager mode
74
+ * would be flattened into a retry loop by the SSE EventSource). An
75
+ * explicit `options.consent` overrides the choice — only do that with
76
+ * `type: 'http'`.
77
+ *
78
+ * `headers` on `config` are passed through untouched. `fetch` is
79
+ * wrapped to refuse HTTP redirects unless `options.redirect` is
80
+ * `'follow'`; see {@link ConnectMCPTransportOptions.redirect}.
81
+ *
82
+ * @param config TanStack transport config without `authProvider`.
83
+ * @param connector Vercel Connect connector UID (e.g. `oauth/linear`)
84
+ * or opaque connector id.
85
+ * @param params Token request parameters — always specify a `subject`.
86
+ * @param options Optional adapter and transport behavior overrides.
87
+ */
88
+ export function connectMCPTransport(config, connector, params, options) {
89
+ const { redirect = 'error', ...providerOptions } = options ?? {};
90
+ const consent = providerOptions.consent ?? (config.type === 'http' ? 'eager' : 'transport');
91
+ return {
92
+ ...config,
93
+ ...(redirect === 'error' && {
94
+ fetch: withoutRedirects(config.fetch, config.type),
95
+ }),
96
+ authProvider: connectAuthProvider(connector, params, {
97
+ ...providerOptions,
98
+ consent,
99
+ }),
100
+ };
101
+ }
102
+ /**
103
+ * Wraps `fetchImpl` (or the global `fetch`, looked up per call so
104
+ * request-scoped fetch shims keep working) so no request follows a
105
+ * server-chosen `Location`.
106
+ *
107
+ * Streamable HTTP gets `redirect: 'error'`: fetch rejects the 3xx with
108
+ * a `TypeError` and the SDK surfaces a connection error. SSE gets
109
+ * `redirect: 'manual'`: fetch resolves with the 3xx itself (status
110
+ * `303`, or `0` on runtimes that return an opaque redirect), which the
111
+ * SDK's EventSource treats as a terminal non-200 response. A rejection
112
+ * there would instead be treated as a transient network error and
113
+ * retried on a timer that outlives the failed `createMCPClient` call.
114
+ */
115
+ function withoutRedirects(fetchImpl, transportType) {
116
+ const redirect = transportType === 'sse' ? 'manual' : 'error';
117
+ return (input, init) => (fetchImpl ?? fetch)(input, { ...init, redirect });
118
+ }
119
+ export { CONNECT_DISCOVERY_STATE, CONNECT_MANAGED_REDIRECT_URL, connectAuthProvider, ConsentRequiredError, getConsentChallenge, isConsentRequiredError, } from '../mcp/connect-auth-provider.js';
120
+ export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, } from '../token.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vercel/connect",
3
- "version": "2.2.0",
3
+ "version": "2.3.0",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "repository": {
@@ -36,6 +36,10 @@
36
36
  "./ai-sdk": {
37
37
  "types": "./dist/ai-sdk/index.d.ts",
38
38
  "default": "./dist/ai-sdk/index.js"
39
+ },
40
+ "./tanstack-ai": {
41
+ "types": "./dist/tanstack-ai/index.d.ts",
42
+ "default": "./dist/tanstack-ai/index.js"
39
43
  }
40
44
  },
41
45
  "files": [
@@ -48,6 +52,7 @@
48
52
  "@ai-sdk/mcp": "^1 || ^2",
49
53
  "@auth/core": ">=0.37.0",
50
54
  "@chat-adapter/slack": "^4.0.0",
55
+ "@modelcontextprotocol/sdk": "^1.29.0",
51
56
  "ai": "^6 || ^7.0.0-beta.0",
52
57
  "better-auth": ">=1.5.0",
53
58
  "eve": ">=0.13.7"
@@ -62,6 +67,9 @@
62
67
  "@chat-adapter/slack": {
63
68
  "optional": true
64
69
  },
70
+ "@modelcontextprotocol/sdk": {
71
+ "optional": true
72
+ },
65
73
  "ai": {
66
74
  "optional": true
67
75
  },
@@ -76,6 +84,8 @@
76
84
  "@ai-sdk/mcp": "2.0.0-beta.37",
77
85
  "@auth/core": "0.37.4",
78
86
  "@chat-adapter/slack": "4.31.0",
87
+ "@modelcontextprotocol/sdk": "1.29.0",
88
+ "@tanstack/ai-mcp": "0.3.10",
79
89
  "ai": "7.0.26",
80
90
  "better-auth": "1.5.5",
81
91
  "eve": "0.39.1",