@ai-sdk/mcp 2.0.69 → 2.0.70
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/CHANGELOG.md +8 -0
- package/README.md +23 -0
- package/dist/index.d.ts +665 -348
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +368 -5
- package/dist/index.js.map +1 -1
- package/dist/mcp-stdio/index.js +1 -0
- package/dist/mcp-stdio/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +24 -0
- package/src/tool/mcp-client.ts +62 -4
- package/src/tool/mcp-event-signature.ts +79 -0
- package/src/tool/mcp-event-types.ts +151 -0
- package/src/tool/mcp-event-webhook.ts +238 -0
- package/src/tool/mcp-events-adapter.ts +82 -0
- package/src/tool/mcp-events.ts +219 -0
- package/src/tool/oauth.ts +14 -1
- package/src/tool/types.ts +1 -0
|
@@ -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:
|
|
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()),
|