@ai-sdk/mcp 2.0.69 → 2.0.71

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.
@@ -0,0 +1,238 @@
1
+ import { safeParseJSON } from '@ai-sdk/provider-utils';
2
+ import { z } from 'zod/v4';
3
+ import { MCP_EVENT_CALLBACK_KEY } from './mcp-events';
4
+ import { verifyMCPEventSignature } from './mcp-event-signature';
5
+ import {
6
+ MCPEventSchema,
7
+ MCPEventControlSchema,
8
+ type MCPEvent,
9
+ type MCPEventControl,
10
+ type MCPEventDefinition,
11
+ type MCPEventStore,
12
+ type MCPEventSubscriptionInfo,
13
+ } from './mcp-event-types';
14
+
15
+ const MAX_BODY_BYTES = 256 * 1024;
16
+ const VerificationSchema = z.object({
17
+ type: z.literal('verification'),
18
+ challenge: z.string().min(1),
19
+ });
20
+
21
+ export type MCPEventWebhookOptions = {
22
+ store: MCPEventStore;
23
+ /**
24
+ * Optionally validate event data using an application schema or the server's
25
+ * payloadSchema. Throw to reject the payload with 400 before onEvent runs.
26
+ * The event envelope and signature are always checked.
27
+ */
28
+ validatePayload?: (args: {
29
+ definition: MCPEventDefinition;
30
+ data: MCPEvent['data'];
31
+ }) => void | PromiseLike<void>;
32
+ /**
33
+ * Resolve only after durable acceptance or successful processing. Deliveries
34
+ * may be duplicated or reordered; deduplicate by subscription ID + eventId.
35
+ * Throw to return 503 so the server can retry. The helper does not run agents.
36
+ */
37
+ onEvent: (args: {
38
+ subscription: MCPEventSubscriptionInfo;
39
+ event: MCPEvent;
40
+ }) => Promise<void>;
41
+ /**
42
+ * Handles a signed replay gap: some events are no longer available. Reconcile
43
+ * application state or notify the agent, then resolve after durable acceptance.
44
+ * The helper saves the fresh cursor and truncated flag; delivery continues.
45
+ * Throw to return 503 without changing saved state. Deduplicate by subscription
46
+ * ID + messageId.
47
+ */
48
+ onGap?: (args: {
49
+ subscription: MCPEventSubscriptionInfo;
50
+ messageId: string;
51
+ gap: Extract<MCPEventControl, { type: 'gap' }>;
52
+ }) => Promise<void>;
53
+ /**
54
+ * Handles signed termination: the server has ended the subscription. Inspect
55
+ * termination.error and notify the agent, then resolve after durable acceptance.
56
+ * The helper removes the saved subscription after this callback succeeds.
57
+ * Throw to return 503 without changing saved state. Deduplicate by subscription
58
+ * ID + messageId.
59
+ */
60
+ onTerminated?: (args: {
61
+ subscription: MCPEventSubscriptionInfo;
62
+ messageId: string;
63
+ termination: Extract<MCPEventControl, { type: 'terminated' }>;
64
+ }) => Promise<void>;
65
+ onError?: (error: unknown) => void;
66
+ };
67
+
68
+ /**
69
+ * Create a Web Request/Response handler for MCP webhook deliveries. Mount it
70
+ * at the callback URL before subscribing. Verification is enabled automatically
71
+ * using the pending delivery secret saved by the client's subscribe method.
72
+ */
73
+ export function createMCPEventWebhook({
74
+ store,
75
+ validatePayload,
76
+ onEvent,
77
+ onGap,
78
+ onTerminated,
79
+ onError,
80
+ }: MCPEventWebhookOptions): (request: Request) => Promise<Response> {
81
+ return async request => {
82
+ if (request.method !== 'POST') {
83
+ return new Response(null, { status: 405, headers: { Allow: 'POST' } });
84
+ }
85
+ if (
86
+ request.headers
87
+ .get('content-type')
88
+ ?.split(';')[0]
89
+ .trim()
90
+ .toLowerCase() !== 'application/json'
91
+ ) {
92
+ return new Response(null, { status: 415 });
93
+ }
94
+ if (Number(request.headers.get('content-length')) > MAX_BODY_BYTES) {
95
+ return new Response(null, { status: 413 });
96
+ }
97
+
98
+ try {
99
+ const key = new URL(request.url).searchParams.get(MCP_EVENT_CALLBACK_KEY);
100
+ const id = request.headers.get('X-MCP-Subscription-Id');
101
+ if (!key || !id) return new Response(null, { status: 401 });
102
+ const subscription = await store.get(key);
103
+ if (
104
+ !subscription ||
105
+ (subscription.id != null && subscription.id !== id)
106
+ ) {
107
+ return new Response(null, { status: 401 });
108
+ }
109
+
110
+ // Bound streamed bodies too; Content-Length is optional and untrusted.
111
+ const reader = request.body?.getReader();
112
+ if (!reader) return new Response(null, { status: 400 });
113
+ const chunks: Uint8Array[] = [];
114
+ let length = 0;
115
+ try {
116
+ while (true) {
117
+ const { done, value } = await reader.read();
118
+ if (done) break;
119
+ length += value.byteLength;
120
+ if (length > MAX_BODY_BYTES) {
121
+ await reader.cancel();
122
+ return new Response(null, { status: 413 });
123
+ }
124
+ chunks.push(value);
125
+ }
126
+ } finally {
127
+ reader.releaseLock();
128
+ }
129
+ const body = new Uint8Array(length);
130
+ let offset = 0;
131
+ for (const chunk of chunks) {
132
+ body.set(chunk, offset);
133
+ offset += chunk.length;
134
+ }
135
+
136
+ if (
137
+ !(await verifyMCPEventSignature({
138
+ headers: request.headers,
139
+ body,
140
+ secret: subscription.delivery.secret,
141
+ }))
142
+ ) {
143
+ return new Response(null, { status: 401 });
144
+ }
145
+ const parsed = await safeParseJSON({
146
+ text: new TextDecoder().decode(body),
147
+ });
148
+ if (!parsed.success) return new Response(null, { status: 400 });
149
+ const value = parsed.value;
150
+
151
+ let control: MCPEventControl | undefined;
152
+ if (value != null && typeof value === 'object' && 'type' in value) {
153
+ if (value.type === 'verification') {
154
+ const verification = VerificationSchema.safeParse(value);
155
+ if (!verification.success) return new Response(null, { status: 400 });
156
+ return Response.json({ challenge: verification.data.challenge });
157
+ }
158
+ const parsedControl = MCPEventControlSchema.safeParse(value);
159
+ if (!parsedControl.success) return new Response(null, { status: 400 });
160
+ control = parsedControl.data;
161
+ }
162
+
163
+ // Servers can POST before a subscribe or refresh response is persisted.
164
+ // Events and gaps must retry until the ID and renewed expiration are saved.
165
+ // A verified termination for a known ID can end a pending renewal too:
166
+ // access may have been revoked, so renewal can no longer succeed.
167
+ if (
168
+ subscription.id == null ||
169
+ (subscription.status !== 'active' && control?.type !== 'terminated')
170
+ )
171
+ return new Response(null, { status: 503 });
172
+ const subscriptionInfo: MCPEventSubscriptionInfo = {
173
+ id: subscription.id,
174
+ name: subscription.name,
175
+ arguments: subscription.arguments,
176
+ delivery: { mode: 'webhook', url: subscription.delivery.url },
177
+ refreshBefore: subscription.refreshBefore ?? null,
178
+ };
179
+ if (control) {
180
+ const messageId = request.headers.get('webhook-id')!;
181
+ if (control.type === 'gap') {
182
+ await onGap?.({
183
+ subscription: subscriptionInfo,
184
+ messageId,
185
+ gap: control,
186
+ });
187
+ await store.update(subscription.key, {
188
+ cursor: control.cursor,
189
+ truncated: true,
190
+ });
191
+ } else {
192
+ await onTerminated?.({
193
+ subscription: subscriptionInfo,
194
+ messageId,
195
+ termination: control,
196
+ });
197
+ await store.delete(subscription.key);
198
+ }
199
+ return new Response(null, { status: 204 });
200
+ }
201
+ if (
202
+ subscription.refreshBefore != null &&
203
+ Date.parse(subscription.refreshBefore) <= Date.now()
204
+ ) {
205
+ return new Response(null, { status: 410 });
206
+ }
207
+ const event = MCPEventSchema.safeParse(value);
208
+ if (
209
+ !event.success ||
210
+ event.data.name !== subscription.name ||
211
+ event.data.eventId !== request.headers.get('webhook-id')
212
+ ) {
213
+ return new Response(null, { status: 400 });
214
+ }
215
+ try {
216
+ await validatePayload?.({
217
+ definition: subscription.definition,
218
+ data: event.data.data,
219
+ });
220
+ } catch (error) {
221
+ onError?.(error);
222
+ return new Response(null, { status: 400 });
223
+ }
224
+
225
+ await onEvent({
226
+ subscription: subscriptionInfo,
227
+ event: event.data,
228
+ });
229
+ if (event.data.cursor !== undefined) {
230
+ await store.update(subscription.key, { cursor: event.data.cursor });
231
+ }
232
+ return new Response(null, { status: 204 });
233
+ } catch (error) {
234
+ onError?.(error);
235
+ return new Response(null, { status: 503 });
236
+ }
237
+ };
238
+ }
@@ -0,0 +1,82 @@
1
+ import type { JSONObject } from '@ai-sdk/provider';
2
+ import type { MCPEvents } from './mcp-event-types';
3
+ import type { RequestOptions } from './types';
4
+
5
+ /** A durable watch owned by the adapter's backend, not an upstream MCP lease. */
6
+ export type ManagedSubscription = {
7
+ /** Backend subscription ID; use it for managed lifecycle operations. */
8
+ id: string;
9
+ name: string;
10
+ arguments: JSONObject;
11
+ status:
12
+ | 'pending'
13
+ | 'active'
14
+ | 'needs_auth'
15
+ | 'stopped'
16
+ | 'expired'
17
+ | 'failed';
18
+ /** Application monitoring deadline. null means until explicitly stopped. */
19
+ expiresAt: string | null;
20
+ cleanupStatus?: 'pending' | 'complete' | 'exhausted';
21
+ };
22
+
23
+ export type ManagedSubscribeInput = {
24
+ name: string;
25
+ arguments: JSONObject;
26
+ /** Application metadata, not an authorization grant. */
27
+ context?: JSONObject;
28
+ /** Reuse the original key when recovering an uncertain create result. */
29
+ idempotencyKey: string;
30
+ options?: RequestOptions;
31
+ } & (
32
+ | { ttlMs: number; expiresAt?: never }
33
+ | { expiresAt: string | null; ttlMs?: never }
34
+ );
35
+
36
+ /**
37
+ * Bound to an authorized account and destination by the application. The backend
38
+ * owns callback provisioning, verification, persistence, renewal and cleanup.
39
+ * Implementations must honor request options and preserve actionable errors.
40
+ * Cancelling a request does not cancel an already accepted remote subscription.
41
+ */
42
+ export interface MCPEventsAdapter {
43
+ subscribe(input: ManagedSubscribeInput): Promise<ManagedSubscription>;
44
+
45
+ getSubscription(input: {
46
+ id: string;
47
+ options?: RequestOptions;
48
+ }): Promise<ManagedSubscription>;
49
+
50
+ listSubscriptions(input?: {
51
+ cursor?: string;
52
+ limit?: number;
53
+ status?: ManagedSubscription['status'];
54
+ options?: RequestOptions;
55
+ }): Promise<{
56
+ subscriptions: ManagedSubscription[];
57
+ nextCursor?: string;
58
+ }>;
59
+
60
+ unsubscribe(input: {
61
+ id: string;
62
+ options?: RequestOptions;
63
+ }): Promise<ManagedSubscription>;
64
+ }
65
+
66
+ /** Catalog discovery uses MCP; all subscription operations use the adapter. */
67
+ export interface ManagedMCPEvents extends MCPEventsAdapter {
68
+ list: MCPEvents['list'];
69
+ }
70
+
71
+ export function createManagedMCPEvents(
72
+ adapter: MCPEventsAdapter,
73
+ list: MCPEvents['list'],
74
+ ): ManagedMCPEvents {
75
+ return {
76
+ list,
77
+ subscribe: input => adapter.subscribe(input),
78
+ getSubscription: input => adapter.getSubscription(input),
79
+ listSubscriptions: input => adapter.listSubscriptions(input),
80
+ unsubscribe: input => adapter.unsubscribe(input),
81
+ };
82
+ }
@@ -0,0 +1,219 @@
1
+ import { MCPClientError } from '../error/mcp-client-error';
2
+ import {
3
+ createMCPEventSecret,
4
+ decodeMCPEventSecret,
5
+ } from './mcp-event-signature';
6
+ import {
7
+ ListEventsResultSchema,
8
+ SubscribeEventResultSchema,
9
+ type MCPEventStore,
10
+ type MCPEventsConfig,
11
+ type MCPEvents,
12
+ type MCPEventSubscription,
13
+ } from './mcp-event-types';
14
+ import { ResultSchema, type Request, type RequestOptions } from './types';
15
+ import type { z } from 'zod/v4';
16
+
17
+ export const MCP_EVENT_CALLBACK_KEY = 'mcp_event_subscription';
18
+
19
+ export function createMCPEvents({
20
+ request,
21
+ store,
22
+ validateArguments,
23
+ }: {
24
+ request: <T extends z.ZodType<object>>(args: {
25
+ request: Request;
26
+ resultSchema: T;
27
+ options?: RequestOptions;
28
+ }) => Promise<z.infer<T>>;
29
+ store?: MCPEventStore;
30
+ validateArguments?: MCPEventsConfig['validateArguments'];
31
+ }): MCPEvents {
32
+ function getStore(): MCPEventStore {
33
+ if (!store) {
34
+ throw new MCPClientError({
35
+ message:
36
+ 'Configure experimental_events.store to manage direct event subscriptions',
37
+ });
38
+ }
39
+ return store;
40
+ }
41
+
42
+ async function getSubscription({
43
+ id,
44
+ key,
45
+ }: Parameters<MCPEvents['refresh']>[0]): Promise<MCPEventSubscription> {
46
+ const storage = getStore();
47
+ const subscription =
48
+ id !== undefined ? await storage.getById(id) : await storage.get(key);
49
+ if (!subscription) {
50
+ throw new MCPClientError({
51
+ message: `Unknown MCP event subscription: ${id ?? key}`,
52
+ });
53
+ }
54
+ return subscription;
55
+ }
56
+
57
+ async function subscribe(
58
+ subscription: MCPEventSubscription,
59
+ options?: RequestOptions,
60
+ ) {
61
+ const result = await request({
62
+ request: {
63
+ method: 'events/subscribe',
64
+ params: {
65
+ name: subscription.name,
66
+ arguments: subscription.arguments,
67
+ delivery: subscription.delivery,
68
+ cursor: subscription.cursor,
69
+ ...(subscription.ttlMs === undefined
70
+ ? {}
71
+ : { ttlMs: subscription.ttlMs }),
72
+ ...(subscription.maxAgeMs === undefined
73
+ ? {}
74
+ : { maxAgeMs: subscription.maxAgeMs }),
75
+ },
76
+ },
77
+ resultSchema: SubscribeEventResultSchema,
78
+ options,
79
+ });
80
+ await getStore().update(subscription.key, {
81
+ id: result.id,
82
+ refreshBefore: result.refreshBefore,
83
+ cursor: result.cursor,
84
+ truncated: result.truncated,
85
+ status: 'active',
86
+ });
87
+ return result;
88
+ }
89
+
90
+ const events: MCPEvents = {
91
+ list({ params, options } = {}) {
92
+ return request({
93
+ request: { method: 'events/list', params },
94
+ resultSchema: ListEventsResultSchema,
95
+ options,
96
+ });
97
+ },
98
+
99
+ async subscribe({
100
+ name,
101
+ arguments: args = {},
102
+ delivery,
103
+ cursor = null,
104
+ ttlMs,
105
+ maxAgeMs,
106
+ options,
107
+ }) {
108
+ const storage = getStore();
109
+ const url = new URL(delivery.url);
110
+ const localHttp =
111
+ delivery.allowInsecureLocalhost === true &&
112
+ url.protocol === 'http:' &&
113
+ (url.hostname === '127.0.0.1' || url.hostname === '[::1]');
114
+ if (
115
+ (url.protocol !== 'https:' && !localHttp) ||
116
+ url.username ||
117
+ url.password ||
118
+ url.hash
119
+ ) {
120
+ throw new MCPClientError({
121
+ message:
122
+ 'MCP event callback URLs must use HTTPS without credentials or fragments',
123
+ });
124
+ }
125
+ if (delivery.mode !== 'webhook') {
126
+ throw new MCPClientError({
127
+ message: 'Only webhook event delivery is supported',
128
+ });
129
+ }
130
+ if (
131
+ (ttlMs != null && (!Number.isSafeInteger(ttlMs) || ttlMs <= 0)) ||
132
+ (maxAgeMs != null && (!Number.isSafeInteger(maxAgeMs) || maxAgeMs < 0))
133
+ ) {
134
+ throw new MCPClientError({
135
+ message: 'Invalid MCP event subscription lifetime or replay age',
136
+ });
137
+ }
138
+
139
+ let pageCursor: string | undefined;
140
+ const visited = new Set<string>();
141
+ let definition;
142
+ do {
143
+ const page = await events.list({
144
+ params: { cursor: pageCursor },
145
+ options,
146
+ });
147
+ definition = page.events.find(event => event.name === name);
148
+ if (definition) break;
149
+ pageCursor = page.nextCursor ?? undefined;
150
+ if (pageCursor != null) {
151
+ if (visited.has(pageCursor)) {
152
+ throw new MCPClientError({
153
+ message: 'Repeated MCP events pagination cursor',
154
+ });
155
+ }
156
+ visited.add(pageCursor);
157
+ }
158
+ } while (pageCursor != null);
159
+ if (!definition) {
160
+ throw new MCPClientError({ message: `Unknown MCP event: ${name}` });
161
+ }
162
+ if (!definition.delivery.includes('webhook')) {
163
+ throw new MCPClientError({
164
+ message: `MCP event ${name} does not support webhook delivery`,
165
+ });
166
+ }
167
+ await validateArguments?.({ definition, arguments: args });
168
+ const secret = delivery.secret ?? createMCPEventSecret();
169
+ decodeMCPEventSecret(secret);
170
+
171
+ // A routing key is available before the server-derived subscription ID.
172
+ // Persist it and the secret before the server attempts callback verification.
173
+ const key = crypto.randomUUID();
174
+ url.searchParams.set(MCP_EVENT_CALLBACK_KEY, key);
175
+ const subscription: MCPEventSubscription = {
176
+ key,
177
+ name,
178
+ arguments: structuredClone(args),
179
+ definition,
180
+ delivery: { mode: 'webhook', url: url.href, secret },
181
+ cursor,
182
+ truncated: false,
183
+ ttlMs,
184
+ maxAgeMs,
185
+ status: 'pending',
186
+ };
187
+ await storage.set(subscription);
188
+ // Retain pending state on errors: a timed-out request may have registered
189
+ // the callback remotely. Recover or unsubscribe using the stored key.
190
+ return subscribe(subscription, options);
191
+ },
192
+
193
+ async refresh(args) {
194
+ const subscription = await getSubscription(args);
195
+ // Deliveries must retry until the renewed expiration is persisted.
196
+ // Retain pending state on errors, as the server may have renewed already.
197
+ await getStore().update(subscription.key, { status: 'pending' });
198
+ return subscribe(subscription, args.options);
199
+ },
200
+
201
+ async unsubscribe(args) {
202
+ const subscription = await getSubscription(args);
203
+ await request({
204
+ request: {
205
+ method: 'events/unsubscribe',
206
+ params: {
207
+ name: subscription.name,
208
+ arguments: subscription.arguments,
209
+ delivery: { mode: 'webhook', url: subscription.delivery.url },
210
+ },
211
+ },
212
+ resultSchema: ResultSchema,
213
+ options: args.options,
214
+ });
215
+ await getStore().delete(subscription.key);
216
+ },
217
+ };
218
+ return events;
219
+ }
package/src/tool/oauth.ts CHANGED
@@ -658,8 +658,12 @@ export function buildDiscoveryUrls(
658
658
  expectedIssuer: pathIssuer,
659
659
  });
660
660
 
661
+ // Assign the path rather than resolving it as a URL reference: a pathname
662
+ // beginning with // would otherwise replace the authorization server host.
663
+ const oidcDiscoveryUrl = new URL(url.origin);
664
+ oidcDiscoveryUrl.pathname = `${pathname}/.well-known/openid-configuration`;
661
665
  urlsToTry.push({
662
- url: new URL(`${pathname}/.well-known/openid-configuration`, url.origin),
666
+ url: oidcDiscoveryUrl,
663
667
  type: 'oidc',
664
668
  expectedIssuer: pathIssuer,
665
669
  });
@@ -697,8 +701,17 @@ export async function discoverAuthorizationServerMetadata(
697
701
  ): Promise<AuthorizationServerMetadata | undefined> {
698
702
  const headers = { 'MCP-Protocol-Version': protocolVersion };
699
703
 
704
+ const authorizationServerOrigin = new URL(authorizationServerUrl).origin;
700
705
  const urlsToTry = buildDiscoveryUrls(authorizationServerUrl);
701
706
 
707
+ // Check all candidates before any request so discovery cannot bypass an
708
+ // application's authorization server validation by changing the origin.
709
+ if (urlsToTry.some(({ url }) => url.origin !== authorizationServerOrigin)) {
710
+ throw new MCPClientOAuthError({
711
+ message: 'OAuth discovery URL changed authorization-server origin',
712
+ });
713
+ }
714
+
702
715
  for (const { url: endpointUrl, type, expectedIssuer } of urlsToTry) {
703
716
  const response = await fetchWithCorsRetry(
704
717
  endpointUrl,
package/src/tool/types.ts CHANGED
@@ -116,6 +116,7 @@ const ElicitationCapabilitySchema = z
116
116
  .loose();
117
117
 
118
118
  const ServerCapabilitiesSchema = z.looseObject({
119
+ events: z.looseObject({ listChanged: z.boolean().nullish() }).nullish(),
119
120
  experimental: z.optional(z.object({}).loose()),
120
121
  logging: z.optional(z.object({}).loose()),
121
122
  completions: z.optional(z.object({}).loose()),