@vercel/connect 2.4.0 → 2.5.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 +23 -0
- package/dist/ai-sdk/connect-events-adapter.d.ts +77 -0
- package/dist/ai-sdk/connect-events-adapter.js +87 -0
- package/dist/ai-sdk/index.d.ts +1 -0
- package/dist/ai-sdk/index.js +1 -0
- package/dist/eve/connection-authorization.d.ts +20 -2
- package/dist/eve/connection-authorization.js +45 -2
- package/dist/eve/index.d.ts +1 -1
- package/dist/internal/event-request.d.ts +10 -0
- package/dist/internal/event-request.js +31 -0
- package/dist/internal/event-token-params.d.ts +3 -0
- package/dist/internal/event-token-params.js +94 -0
- package/dist/internal/json.d.ts +5 -0
- package/dist/internal/json.js +1 -0
- package/dist/mcp/events.d.ts +98 -0
- package/dist/mcp/events.js +65 -0
- package/dist/mcp/index.d.ts +2 -0
- package/dist/mcp/index.js +2 -0
- package/dist/mcp/verify-event.d.ts +67 -0
- package/dist/mcp/verify-event.js +247 -0
- package/package.json +5 -4
package/README.md
CHANGED
|
@@ -243,3 +243,26 @@ const providers = [connect({ connector: 'linear' })];
|
|
|
243
243
|
```
|
|
244
244
|
|
|
245
245
|
See the source under `src/` for the full API (additional helpers like `revokeToken`, `getTokenResponse`, `startAuthorization`, `experimental_startInstallation`, typed error classes, and per-adapter options).
|
|
246
|
+
|
|
247
|
+
Experimental [MCP event subscription management](./docs/mcp-events.md) is available through `@vercel/connect/mcp`.
|
|
248
|
+
|
|
249
|
+
### Experimental eve event integration
|
|
250
|
+
|
|
251
|
+
`connect()` also exposes a runtime-only `vercelConnect.experimental_events`
|
|
252
|
+
bridge for eve's event-enabled connections. eve can create a managed AI SDK
|
|
253
|
+
adapter using the current authenticated principal and its framework-owned
|
|
254
|
+
receiver path. It reuses the provider's `createSubject` mapping and
|
|
255
|
+
`tokenParams`, including scopes, installation, audience, and resources. Creating
|
|
256
|
+
the provider or adapter performs no network requests or subscriptions.
|
|
257
|
+
|
|
258
|
+
The bridge also verifies Connect deliveries with the existing MCP event verifier.
|
|
259
|
+
eve must then authorize each delivery against its saved session/subscription
|
|
260
|
+
binding before invoking an authored callback. The bridge does not implement
|
|
261
|
+
eve's inbox, session dispatch, or persistence. Those require the matching eve
|
|
262
|
+
integration release.
|
|
263
|
+
|
|
264
|
+
Event management uses deployment OIDC and the default Connect region, independently
|
|
265
|
+
of the provider's `connectOptions.vercelToken` and `connectOptions.region`
|
|
266
|
+
credential options. Event request failures use the provider's usual error
|
|
267
|
+
translation. Compiler metadata must omit the live bridge and continue serializing
|
|
268
|
+
only `connector` and `requirement`.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { type McpSubscribeInput, type McpSubscription } from '../mcp/events.js';
|
|
2
|
+
import type { JsonObject } from '../internal/json.js';
|
|
3
|
+
import type { EventRequestOptions } from '../internal/event-request.js';
|
|
4
|
+
import type { ConnectTokenParams } from '../token.js';
|
|
5
|
+
/** Trusted server-side scope. Create a separate adapter for each account. */
|
|
6
|
+
export interface Experimental_ConnectEventsAdapterOptions {
|
|
7
|
+
connector: string;
|
|
8
|
+
/** Use the same resolved parameters as connectAuthProvider. */
|
|
9
|
+
tokenParams: ConnectTokenParams;
|
|
10
|
+
destination: {
|
|
11
|
+
path: string;
|
|
12
|
+
};
|
|
13
|
+
/** Overrides the client transport URL. Connect must authorize the endpoint. */
|
|
14
|
+
mcpUrl?: string;
|
|
15
|
+
}
|
|
16
|
+
export type Experimental_ConnectEventsSubscribeInput = Pick<McpSubscribeInput, 'name' | 'idempotencyKey'> & {
|
|
17
|
+
arguments: JsonObject;
|
|
18
|
+
context?: JsonObject;
|
|
19
|
+
options?: Experimental_ConnectEventsRequestOptions;
|
|
20
|
+
} & ({
|
|
21
|
+
ttlMs: number;
|
|
22
|
+
expiresAt?: never;
|
|
23
|
+
} | {
|
|
24
|
+
expiresAt: string | null;
|
|
25
|
+
ttlMs?: never;
|
|
26
|
+
});
|
|
27
|
+
/** A Connect watch, not an upstream MCP subscription grant. */
|
|
28
|
+
export type Experimental_ConnectEventsSubscription = Pick<McpSubscription, 'id' | 'name' | 'status' | 'expiresAt' | 'cleanupStatus'> & {
|
|
29
|
+
arguments: JsonObject;
|
|
30
|
+
};
|
|
31
|
+
export interface Experimental_ConnectEventsListInput {
|
|
32
|
+
cursor?: string;
|
|
33
|
+
limit?: number;
|
|
34
|
+
status?: McpSubscription['status'];
|
|
35
|
+
options?: Experimental_ConnectEventsRequestOptions;
|
|
36
|
+
}
|
|
37
|
+
export type Experimental_ConnectEventsRequestOptions = EventRequestOptions;
|
|
38
|
+
/**
|
|
39
|
+
* Structurally compatible with AI SDK's experimental managed events adapter.
|
|
40
|
+
* Requires the transport-context contract from vercel/ai#22325. No AI SDK
|
|
41
|
+
* dependency is required to bind operations directly with createAdapter().
|
|
42
|
+
* Listing is scoped to the bound account and workload project/environment,
|
|
43
|
+
* not to an individual conversation or context.
|
|
44
|
+
*/
|
|
45
|
+
export interface Experimental_ConnectEventsAdapter {
|
|
46
|
+
createAdapter(context: {
|
|
47
|
+
transport: {
|
|
48
|
+
type: 'http' | 'sse';
|
|
49
|
+
url: string;
|
|
50
|
+
} | {
|
|
51
|
+
type: 'custom';
|
|
52
|
+
};
|
|
53
|
+
}): Experimental_ConnectEventOperations;
|
|
54
|
+
}
|
|
55
|
+
/** Subscription operations bound to a single account, destination and endpoint. */
|
|
56
|
+
export interface Experimental_ConnectEventOperations {
|
|
57
|
+
subscribe(input: Experimental_ConnectEventsSubscribeInput): Promise<Experimental_ConnectEventsSubscription>;
|
|
58
|
+
getSubscription(input: {
|
|
59
|
+
id: string;
|
|
60
|
+
options?: Experimental_ConnectEventsRequestOptions;
|
|
61
|
+
}): Promise<Experimental_ConnectEventsSubscription>;
|
|
62
|
+
listSubscriptions(input?: Experimental_ConnectEventsListInput): Promise<{
|
|
63
|
+
subscriptions: Experimental_ConnectEventsSubscription[];
|
|
64
|
+
nextCursor?: string;
|
|
65
|
+
}>;
|
|
66
|
+
unsubscribe(input: {
|
|
67
|
+
id: string;
|
|
68
|
+
options?: Experimental_ConnectEventsRequestOptions;
|
|
69
|
+
}): Promise<Experimental_ConnectEventsSubscription>;
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Configure Connect-managed subscriptions without allocating a webhook or
|
|
73
|
+
* retaining a workload token. Connect owns upstream verification and subscription management;
|
|
74
|
+
* the application owns its receiver, authorization bindings and durable inbox.
|
|
75
|
+
* Connect manages provider renewal separately from the application's deadline.
|
|
76
|
+
*/
|
|
77
|
+
export declare function experimental_connectEventsAdapter({ connector, tokenParams: configuredTokenParams, destination: { path }, mcpUrl: configuredMcpUrl, }: Experimental_ConnectEventsAdapterOptions): Experimental_ConnectEventsAdapter;
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { getSubscription, listSubscriptions, subscribe, unsubscribe, } from '../mcp/events.js';
|
|
2
|
+
import { snapshotEventTokenParams } from '../internal/event-token-params.js';
|
|
3
|
+
/**
|
|
4
|
+
* Configure Connect-managed subscriptions without allocating a webhook or
|
|
5
|
+
* retaining a workload token. Connect owns upstream verification and subscription management;
|
|
6
|
+
* the application owns its receiver, authorization bindings and durable inbox.
|
|
7
|
+
* Connect manages provider renewal separately from the application's deadline.
|
|
8
|
+
*/
|
|
9
|
+
export function experimental_connectEventsAdapter({ connector, tokenParams: configuredTokenParams, destination: { path }, mcpUrl: configuredMcpUrl, }) {
|
|
10
|
+
if (typeof connector !== 'string' ||
|
|
11
|
+
!connector ||
|
|
12
|
+
typeof path !== 'string' ||
|
|
13
|
+
!path) {
|
|
14
|
+
throw new TypeError('A connector and destination path are required.');
|
|
15
|
+
}
|
|
16
|
+
const tokenParams = snapshotEventTokenParams(configuredTokenParams);
|
|
17
|
+
return {
|
|
18
|
+
createAdapter({ transport }) {
|
|
19
|
+
const mcpUrl = configuredMcpUrl ??
|
|
20
|
+
(transport.type === 'custom' ? undefined : transport.url);
|
|
21
|
+
return {
|
|
22
|
+
async subscribe(input) {
|
|
23
|
+
// Select fields explicitly so extra runtime properties cannot override
|
|
24
|
+
// the bound scope or turn provider arguments into delivery configuration.
|
|
25
|
+
return toSubscription(await subscribe(connector, {
|
|
26
|
+
tokenParams,
|
|
27
|
+
mcpUrl,
|
|
28
|
+
name: input.name,
|
|
29
|
+
arguments: input.arguments,
|
|
30
|
+
destination: { path },
|
|
31
|
+
context: input.context,
|
|
32
|
+
idempotencyKey: input.idempotencyKey,
|
|
33
|
+
...monitoringLifetime(input),
|
|
34
|
+
}, requestOptions(input.options)));
|
|
35
|
+
},
|
|
36
|
+
async getSubscription({ id, options }) {
|
|
37
|
+
return toSubscription(await getSubscription(connector, { id, expectedSubject: tokenParams.subject }, requestOptions(options)));
|
|
38
|
+
},
|
|
39
|
+
async listSubscriptions({ cursor, limit, status, options } = {}) {
|
|
40
|
+
const page = await listSubscriptions(connector, { tokenParams, cursor, limit, status }, requestOptions(options));
|
|
41
|
+
return {
|
|
42
|
+
subscriptions: page.subscriptions.map(toSubscription),
|
|
43
|
+
...(page.pagination.next === null
|
|
44
|
+
? {}
|
|
45
|
+
: { nextCursor: page.pagination.next }),
|
|
46
|
+
};
|
|
47
|
+
},
|
|
48
|
+
async unsubscribe({ id, options }) {
|
|
49
|
+
return toSubscription(await unsubscribe(connector, { id, expectedSubject: tokenParams.subject }, requestOptions(options)));
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
function requestOptions(options) {
|
|
56
|
+
// AI SDK inputs cannot override the workload identity or API region.
|
|
57
|
+
return options === undefined
|
|
58
|
+
? undefined
|
|
59
|
+
: {
|
|
60
|
+
signal: options.signal,
|
|
61
|
+
timeout: options.timeout,
|
|
62
|
+
maxTotalTimeout: options.maxTotalTimeout,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
function monitoringLifetime(input) {
|
|
66
|
+
if (input.ttlMs !== undefined && input.expiresAt === undefined) {
|
|
67
|
+
return { ttlMs: input.ttlMs };
|
|
68
|
+
}
|
|
69
|
+
if (input.ttlMs === undefined && input.expiresAt !== undefined) {
|
|
70
|
+
return { expiresAt: input.expiresAt };
|
|
71
|
+
}
|
|
72
|
+
throw new TypeError('Provide exactly one of ttlMs or expiresAt.');
|
|
73
|
+
}
|
|
74
|
+
function toSubscription(subscription) {
|
|
75
|
+
return {
|
|
76
|
+
id: subscription.id,
|
|
77
|
+
name: subscription.name,
|
|
78
|
+
// The HTTP response is parsed JSON. Keep the narrower JSON type in this
|
|
79
|
+
// adapter without changing the base API's Record<string, unknown> contract.
|
|
80
|
+
arguments: subscription.arguments,
|
|
81
|
+
status: subscription.status,
|
|
82
|
+
expiresAt: subscription.expiresAt,
|
|
83
|
+
...(subscription.cleanupStatus === undefined
|
|
84
|
+
? {}
|
|
85
|
+
: { cleanupStatus: subscription.cleanupStatus }),
|
|
86
|
+
};
|
|
87
|
+
}
|
package/dist/ai-sdk/index.d.ts
CHANGED
|
@@ -52,3 +52,4 @@
|
|
|
52
52
|
*/
|
|
53
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';
|
|
54
54
|
export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, type ConnectErrorOptions, type ConnectTokenExchangeSubject, type ConnectTokenParams, type ConnectTokenSubject, type ConnectVendorErrorPayload, } from '../token.js';
|
|
55
|
+
export { experimental_connectEventsAdapter, type Experimental_ConnectEventsAdapter, type Experimental_ConnectEventOperations, type Experimental_ConnectEventsAdapterOptions, type Experimental_ConnectEventsListInput, type Experimental_ConnectEventsRequestOptions, type Experimental_ConnectEventsSubscribeInput, type Experimental_ConnectEventsSubscription, } from './connect-events-adapter.js';
|
package/dist/ai-sdk/index.js
CHANGED
|
@@ -52,3 +52,4 @@
|
|
|
52
52
|
*/
|
|
53
53
|
export { CONNECT_DISCOVERY_STATE, CONNECT_MANAGED_REDIRECT_URL, connectAuthProvider, ConsentRequiredError, getConsentChallenge, isConsentRequiredError, } from '../mcp/connect-auth-provider.js';
|
|
54
54
|
export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, } from '../token.js';
|
|
55
|
+
export { experimental_connectEventsAdapter, } from './connect-events-adapter.js';
|
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
* project, but the rest of `@vercel/connect` works without it.
|
|
30
30
|
*/
|
|
31
31
|
import { type ConnectionPrincipal, type InteractiveAuthorizationDefinition, type NonInteractiveAuthorizationDefinition } from 'eve/connections';
|
|
32
|
+
import { type Experimental_ConnectEventOperations } from '../ai-sdk/connect-events-adapter.js';
|
|
33
|
+
import { type McpEventDelivery } from '../mcp/verify-event.js';
|
|
32
34
|
import type { ConnectOptions, ConnectTokenParams, ConnectTokenSubject } from '../token.js';
|
|
33
35
|
/**
|
|
34
36
|
* Authorization phase passed to {@link EveAuthorizationOptions.onError}
|
|
@@ -186,6 +188,19 @@ export interface EveAuthorizationOptions {
|
|
|
186
188
|
}
|
|
187
189
|
/** Input accepted by {@link connect}. */
|
|
188
190
|
export type EveAuthorizationInput = string | EveAuthorizationOptions;
|
|
191
|
+
/** Runtime bridge consumed only by event-enabled eve connections. */
|
|
192
|
+
export interface Experimental_EveEventsBackend {
|
|
193
|
+
createAdapter(input: {
|
|
194
|
+
principal: ConnectionPrincipal;
|
|
195
|
+
connection: EveConnectionAuthorizationContext;
|
|
196
|
+
destination: {
|
|
197
|
+
path: string;
|
|
198
|
+
};
|
|
199
|
+
}): Promise<Experimental_ConnectEventOperations>;
|
|
200
|
+
verify(request: Request, options: {
|
|
201
|
+
path: string;
|
|
202
|
+
}): Promise<McpEventDelivery>;
|
|
203
|
+
}
|
|
189
204
|
/**
|
|
190
205
|
* Structurally-readable marker exposed on every {@link connect} return
|
|
191
206
|
* value so downstream tooling can detect Vercel Connect-backed
|
|
@@ -204,13 +219,16 @@ export type EveAuthorizationInput = string | EveAuthorizationOptions;
|
|
|
204
219
|
* Eve's build tooling. It remains optional so existing connector-only metadata
|
|
205
220
|
* assignments stay source-compatible.
|
|
206
221
|
*
|
|
207
|
-
* The
|
|
208
|
-
*
|
|
222
|
+
* The connector and requirement are serializable metadata. The optional
|
|
223
|
+
* events bridge is a live runtime capability and must not be serialized.
|
|
224
|
+
* Token-fetching identity continues to be driven by the
|
|
209
225
|
* `getToken` / `startAuthorization` / `completeAuthorization`
|
|
210
226
|
* callbacks. When auto-provisioning is enabled, the same connector value is
|
|
211
227
|
* also sent as the managed OAuth UID.
|
|
212
228
|
*/
|
|
213
229
|
export interface VercelConnectMetadata {
|
|
230
|
+
/** Live capability; compilers must omit this from serializable metadata. */
|
|
231
|
+
readonly experimental_events?: Experimental_EveEventsBackend;
|
|
214
232
|
/** Raw connector UID or service-connector key passed to the helper. */
|
|
215
233
|
readonly connector: string;
|
|
216
234
|
/** Complete credential requirement consumed by eve build tooling. */
|
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
*/
|
|
31
31
|
import { ConnectionAuthorizationFailedError, ConnectionAuthorizationRequiredError, } from 'eve/connections';
|
|
32
32
|
import { startAuthorization } from '../authorization.js';
|
|
33
|
+
import { experimental_connectEventsAdapter, } from '../ai-sdk/connect-events-adapter.js';
|
|
34
|
+
import { verifyMcpEvent } from '../mcp/verify-event.js';
|
|
33
35
|
import { ConnectError, ConnectorInstallationRequiredError, deleteTokenCacheEntry, getTokenResponse, NoValidTokenError, revokeToken, UserAuthorizationRequiredError, } from '../token.js';
|
|
34
36
|
import { provisionEveOAuthConnector } from './provision-oauth-connector.js';
|
|
35
37
|
export function connect(input) {
|
|
@@ -44,11 +46,23 @@ export function connect(input) {
|
|
|
44
46
|
const vercelConnect = requirement === undefined
|
|
45
47
|
? { connector: options.connector }
|
|
46
48
|
: { connector: options.connector, requirement };
|
|
49
|
+
const eventMetadata = {
|
|
50
|
+
...vercelConnect,
|
|
51
|
+
experimental_events: buildEventsBackend(options),
|
|
52
|
+
};
|
|
47
53
|
const evict = makeEvict(options);
|
|
48
54
|
if (options.principalType === 'app') {
|
|
49
|
-
return {
|
|
55
|
+
return {
|
|
56
|
+
...buildNonInteractiveDefinition(options),
|
|
57
|
+
vercelConnect: eventMetadata,
|
|
58
|
+
evict,
|
|
59
|
+
};
|
|
50
60
|
}
|
|
51
|
-
return {
|
|
61
|
+
return {
|
|
62
|
+
...buildInteractiveDefinition(options),
|
|
63
|
+
vercelConnect: eventMetadata,
|
|
64
|
+
evict,
|
|
65
|
+
};
|
|
52
66
|
}
|
|
53
67
|
/**
|
|
54
68
|
* Builds the {@link EveConnectAuthorizationDefinition.evict} callback for
|
|
@@ -83,6 +97,35 @@ function makeEvict(options) {
|
|
|
83
97
|
deleteTokenCacheEntry(options.connector, params);
|
|
84
98
|
};
|
|
85
99
|
}
|
|
100
|
+
function buildEventsBackend(options) {
|
|
101
|
+
return {
|
|
102
|
+
verify: verifyMcpEvent,
|
|
103
|
+
async createAdapter({ principal, connection, destination }) {
|
|
104
|
+
// Apply the same error translation to subject resolution and requests.
|
|
105
|
+
async function call(operation) {
|
|
106
|
+
try {
|
|
107
|
+
return await operation();
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
throw translate(error, 'getToken', options);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const adapter = await call(async () => experimental_connectEventsAdapter({
|
|
114
|
+
connector: options.connector,
|
|
115
|
+
tokenParams: await buildTokenParams(options, principal, connection),
|
|
116
|
+
destination,
|
|
117
|
+
}).createAdapter({
|
|
118
|
+
transport: { type: 'http', url: connection.url },
|
|
119
|
+
}));
|
|
120
|
+
return {
|
|
121
|
+
subscribe: input => call(() => adapter.subscribe(input)),
|
|
122
|
+
getSubscription: input => call(() => adapter.getSubscription(input)),
|
|
123
|
+
listSubscriptions: input => call(() => adapter.listSubscriptions(input)),
|
|
124
|
+
unsubscribe: input => call(() => adapter.unsubscribe(input)),
|
|
125
|
+
};
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
}
|
|
86
129
|
function normalizeAuthorizationOptions(input) {
|
|
87
130
|
if (typeof input === 'string') {
|
|
88
131
|
return { connector: input };
|
package/dist/eve/index.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* this barrel re-exports the public API so consumers import
|
|
7
7
|
* everything from `@vercel/connect/eve`.
|
|
8
8
|
*/
|
|
9
|
-
export { connect, type EveAuthorizationInput, type EveAuthorizationOptions, type EveConnectAuthorizationDefinition, type EveConnectionAuthorizationContext, type ConnectAuthorizationPhase, type VercelConnectMetadata, } from './connection-authorization.js';
|
|
9
|
+
export { connect, type EveAuthorizationInput, type EveAuthorizationOptions, type EveConnectAuthorizationDefinition, type EveConnectionAuthorizationContext, type ConnectAuthorizationPhase, type VercelConnectMetadata, type Experimental_EveEventsBackend, } from './connection-authorization.js';
|
|
10
10
|
export { CONNECT_OAUTH_ISSUER, connectOAuth, type ConnectOAuthAudienceEnvironment, type ConnectOAuthEnvironment, type ConnectOAuthOptions, } from './connect-oauth.js';
|
|
11
11
|
export { connectDiscordCredentials, type ConnectDiscordCredentialsParams, } from './discord-credentials.js';
|
|
12
12
|
export { connectGitHubCredentials, type ConnectGitHubCredentialsParams, } from './github-credentials.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export interface EventRequestOptions {
|
|
2
|
+
/** Cancels this request, not a subscription already accepted by Connect. */
|
|
3
|
+
signal?: AbortSignal;
|
|
4
|
+
/** Request deadline in milliseconds, including credential resolution. */
|
|
5
|
+
timeout?: number;
|
|
6
|
+
/** Overall deadline in milliseconds. The earlier supplied deadline wins. */
|
|
7
|
+
maxTotalTimeout?: number;
|
|
8
|
+
}
|
|
9
|
+
/** Bound credential resolution, fetch and response parsing with one deadline. */
|
|
10
|
+
export declare function eventRequest<T>(options: EventRequestOptions | undefined, run: (signal: AbortSignal | undefined) => Promise<T>): Promise<T>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Bound credential resolution, fetch and response parsing with one deadline. */
|
|
2
|
+
export async function eventRequest(options, run) {
|
|
3
|
+
const { signal: externalSignal, timeout, maxTotalTimeout } = options ?? {};
|
|
4
|
+
externalSignal?.throwIfAborted();
|
|
5
|
+
const deadlines = [timeout, maxTotalTimeout].filter((value) => value !== undefined);
|
|
6
|
+
if (deadlines.some(value => !Number.isInteger(value) || value <= 0 || value > 2_147_483_647)) {
|
|
7
|
+
throw new TypeError('Request timeouts must be positive 32-bit integers.');
|
|
8
|
+
}
|
|
9
|
+
const controller = deadlines.length ? new AbortController() : undefined;
|
|
10
|
+
const signal = controller?.signal ?? externalSignal;
|
|
11
|
+
const forwardAbort = () => controller?.abort(externalSignal?.reason);
|
|
12
|
+
externalSignal?.addEventListener('abort', forwardAbort, { once: true });
|
|
13
|
+
const timer = controller
|
|
14
|
+
? setTimeout(() => controller.abort(new DOMException('Request timed out.', 'TimeoutError')), Math.min(...deadlines))
|
|
15
|
+
: undefined;
|
|
16
|
+
let onAbort;
|
|
17
|
+
try {
|
|
18
|
+
const aborted = new Promise((_, reject) => {
|
|
19
|
+
onAbort = () => reject(signal?.reason);
|
|
20
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
21
|
+
});
|
|
22
|
+
return await Promise.race([run(signal), aborted]);
|
|
23
|
+
}
|
|
24
|
+
finally {
|
|
25
|
+
if (timer !== undefined)
|
|
26
|
+
clearTimeout(timer);
|
|
27
|
+
externalSignal?.removeEventListener('abort', forwardAbort);
|
|
28
|
+
if (onAbort)
|
|
29
|
+
signal?.removeEventListener('abort', onAbort);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/** Copy only credential fields; extra properties cannot become routing input. */
|
|
2
|
+
export function snapshotEventTokenParams(params) {
|
|
3
|
+
if (!params || typeof params !== 'object')
|
|
4
|
+
throw invalid();
|
|
5
|
+
const subject = snapshotSubject(params.subject);
|
|
6
|
+
for (const field of ['scopes', 'audience', 'resources']) {
|
|
7
|
+
const value = params[field];
|
|
8
|
+
if (value !== undefined &&
|
|
9
|
+
(!Array.isArray(value) || !value.every(nonEmptyString))) {
|
|
10
|
+
throw invalid();
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
if (params.installationId !== undefined &&
|
|
14
|
+
!nonEmptyString(params.installationId))
|
|
15
|
+
throw invalid();
|
|
16
|
+
if (params.validityBufferMs !== undefined &&
|
|
17
|
+
(!Number.isSafeInteger(params.validityBufferMs) ||
|
|
18
|
+
params.validityBufferMs < 0))
|
|
19
|
+
throw invalid();
|
|
20
|
+
if (params.authorizationDetails !== undefined &&
|
|
21
|
+
(!Array.isArray(params.authorizationDetails) ||
|
|
22
|
+
!params.authorizationDetails.every(detail => object(detail) && nonEmptyString(detail.type))))
|
|
23
|
+
throw invalid();
|
|
24
|
+
// Snapshot nested arrays/details before any asynchronous credential lookup.
|
|
25
|
+
// Reject non-JSON values instead of silently changing credential constraints.
|
|
26
|
+
return JSON.parse(JSON.stringify({
|
|
27
|
+
subject,
|
|
28
|
+
installationId: params.installationId,
|
|
29
|
+
scopes: params.scopes,
|
|
30
|
+
audience: params.audience,
|
|
31
|
+
resources: params.resources,
|
|
32
|
+
authorizationDetails: params.authorizationDetails,
|
|
33
|
+
validityBufferMs: params.validityBufferMs,
|
|
34
|
+
}, (_key, value) => {
|
|
35
|
+
if (typeof value === 'function' ||
|
|
36
|
+
typeof value === 'symbol' ||
|
|
37
|
+
typeof value === 'bigint' ||
|
|
38
|
+
(typeof value === 'number' && !Number.isFinite(value)))
|
|
39
|
+
throw invalid();
|
|
40
|
+
return value;
|
|
41
|
+
}));
|
|
42
|
+
}
|
|
43
|
+
function snapshotSubject(subject) {
|
|
44
|
+
if (!subject || typeof subject !== 'object')
|
|
45
|
+
throw invalid();
|
|
46
|
+
switch (subject.type) {
|
|
47
|
+
case 'app':
|
|
48
|
+
return { type: 'app' };
|
|
49
|
+
case 'user':
|
|
50
|
+
if (!nonEmptyString(subject.id) ||
|
|
51
|
+
!optionalString(subject.issuer) ||
|
|
52
|
+
!optionalString(subject.experimental_idp))
|
|
53
|
+
throw invalid();
|
|
54
|
+
return {
|
|
55
|
+
type: 'user',
|
|
56
|
+
id: subject.id,
|
|
57
|
+
issuer: subject.issuer,
|
|
58
|
+
experimental_idp: subject.experimental_idp,
|
|
59
|
+
};
|
|
60
|
+
case 'jwt-bearer':
|
|
61
|
+
if (!nonEmptyString(subject.sub) ||
|
|
62
|
+
!optionalString(subject.iss) ||
|
|
63
|
+
!optionalString(subject.aud) ||
|
|
64
|
+
(subject.additionalClaims !== undefined &&
|
|
65
|
+
!object(subject.additionalClaims)))
|
|
66
|
+
throw invalid();
|
|
67
|
+
return {
|
|
68
|
+
type: 'jwt-bearer',
|
|
69
|
+
sub: subject.sub,
|
|
70
|
+
iss: subject.iss,
|
|
71
|
+
aud: subject.aud,
|
|
72
|
+
additionalClaims: subject.additionalClaims,
|
|
73
|
+
};
|
|
74
|
+
case 'token':
|
|
75
|
+
case undefined:
|
|
76
|
+
if (!nonEmptyString(subject.token))
|
|
77
|
+
throw invalid();
|
|
78
|
+
return { type: subject.type, token: subject.token };
|
|
79
|
+
default:
|
|
80
|
+
throw invalid();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function object(value) {
|
|
84
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
85
|
+
}
|
|
86
|
+
function nonEmptyString(value) {
|
|
87
|
+
return typeof value === 'string' && value.length > 0;
|
|
88
|
+
}
|
|
89
|
+
function optionalString(value) {
|
|
90
|
+
return value === undefined || nonEmptyString(value);
|
|
91
|
+
}
|
|
92
|
+
function invalid() {
|
|
93
|
+
return new TypeError('Valid resolved ConnectTokenParams are required.');
|
|
94
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type EventRequestOptions } from '../internal/event-request.js';
|
|
2
|
+
import { type ConnectOptions, type ConnectTokenParams } from '../token.js';
|
|
3
|
+
export type McpEventSubject = Extract<ConnectTokenParams['subject'], {
|
|
4
|
+
type: 'app' | 'user';
|
|
5
|
+
}>;
|
|
6
|
+
export interface McpEventDescriptor {
|
|
7
|
+
name: string;
|
|
8
|
+
description?: string;
|
|
9
|
+
delivery: ('webhook' | 'poll' | 'push')[];
|
|
10
|
+
inputSchema: Record<string, unknown>;
|
|
11
|
+
payloadSchema: Record<string, unknown>;
|
|
12
|
+
}
|
|
13
|
+
export type McpSubscriptionStatus = 'pending' | 'active' | 'needs_auth' | 'stopped' | 'expired' | 'failed';
|
|
14
|
+
export interface McpSubscription {
|
|
15
|
+
id: string;
|
|
16
|
+
connectorId: string;
|
|
17
|
+
projectId: string;
|
|
18
|
+
environment: string;
|
|
19
|
+
subject: McpEventSubject;
|
|
20
|
+
name: string;
|
|
21
|
+
arguments: Record<string, unknown>;
|
|
22
|
+
destination: {
|
|
23
|
+
path: string;
|
|
24
|
+
};
|
|
25
|
+
context: Record<string, unknown>;
|
|
26
|
+
status: McpSubscriptionStatus;
|
|
27
|
+
/** ISO deadline, or null for an explicitly indefinite watch. */
|
|
28
|
+
expiresAt: string | null;
|
|
29
|
+
createdAt: string;
|
|
30
|
+
updatedAt: string;
|
|
31
|
+
cleanupStatus?: 'pending' | 'complete' | 'exhausted';
|
|
32
|
+
stopReason?: string;
|
|
33
|
+
lastError?: string;
|
|
34
|
+
}
|
|
35
|
+
export type McpSubscribeInput = McpEventSourceInput & {
|
|
36
|
+
name: string;
|
|
37
|
+
arguments: Record<string, unknown>;
|
|
38
|
+
destination: {
|
|
39
|
+
path: string;
|
|
40
|
+
};
|
|
41
|
+
context?: Record<string, unknown>;
|
|
42
|
+
idempotencyKey: string;
|
|
43
|
+
} & ({
|
|
44
|
+
ttlMs: number;
|
|
45
|
+
expiresAt?: never;
|
|
46
|
+
} | {
|
|
47
|
+
expiresAt: string | null;
|
|
48
|
+
ttlMs?: never;
|
|
49
|
+
});
|
|
50
|
+
/** Upstream credential selection, separate from event and pagination fields. */
|
|
51
|
+
export interface McpEventScope {
|
|
52
|
+
/** Derive from authenticated application state, never model output. */
|
|
53
|
+
tokenParams: ConnectTokenParams;
|
|
54
|
+
}
|
|
55
|
+
/** MCP endpoint selection for operations that contact the upstream server. */
|
|
56
|
+
export interface McpEventSourceInput extends McpEventScope {
|
|
57
|
+
/** Defaults to the connector endpoint. Connect must authorize this URL. */
|
|
58
|
+
mcpUrl?: string;
|
|
59
|
+
}
|
|
60
|
+
/** Lookup uses the globally unique ID; ownership is checked independently. */
|
|
61
|
+
export interface McpSubscriptionLookupInput {
|
|
62
|
+
id: string;
|
|
63
|
+
/**
|
|
64
|
+
* Optional assertion against the stored creation subject, not a request for
|
|
65
|
+
* upstream credentials. Account-bound callers should always supply it.
|
|
66
|
+
*/
|
|
67
|
+
expectedSubject?: ConnectTokenParams['subject'];
|
|
68
|
+
}
|
|
69
|
+
export type McpEventOptions = Pick<ConnectOptions, 'vercelToken' | 'region'> & EventRequestOptions;
|
|
70
|
+
export declare function discoverEvents(connector: string, params: McpEventSourceInput, options?: McpEventOptions): Promise<{
|
|
71
|
+
status: "supported" | "unsupported" | "unavailable" | "needs_auth";
|
|
72
|
+
}>;
|
|
73
|
+
/** Optional account-scoped catalog discovery; does not poll event occurrences. */
|
|
74
|
+
export declare function listEventTypes(connector: string, params: McpEventSourceInput & {
|
|
75
|
+
cursor?: string;
|
|
76
|
+
}, options?: McpEventOptions): Promise<{
|
|
77
|
+
events: McpEventDescriptor[];
|
|
78
|
+
nextCursor?: string;
|
|
79
|
+
}>;
|
|
80
|
+
export declare function subscribe(connector: string, params: McpSubscribeInput, options?: McpEventOptions): Promise<McpSubscription>;
|
|
81
|
+
export declare function listSubscriptions(connector: string, params: McpEventScope & {
|
|
82
|
+
cursor?: string;
|
|
83
|
+
status?: McpSubscriptionStatus;
|
|
84
|
+
limit?: number;
|
|
85
|
+
}, options?: McpEventOptions): Promise<{
|
|
86
|
+
subscriptions: McpSubscription[];
|
|
87
|
+
pagination: {
|
|
88
|
+
next: string | null;
|
|
89
|
+
};
|
|
90
|
+
}>;
|
|
91
|
+
export declare function getSubscription(connector: string, params: McpSubscriptionLookupInput, options?: McpEventOptions): Promise<McpSubscription>;
|
|
92
|
+
export declare function unsubscribe(connector: string, params: McpSubscriptionLookupInput, options?: McpEventOptions): Promise<McpSubscription>;
|
|
93
|
+
/** Stops only this connector/subject/project/environment scope. */
|
|
94
|
+
export declare function unsubscribeAll(connector: string, params: McpEventScope & {
|
|
95
|
+
idempotencyKey: string;
|
|
96
|
+
}, options?: McpEventOptions): Promise<{
|
|
97
|
+
accepted: true;
|
|
98
|
+
}>;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { getVercelOidcToken } from '@vercel/oidc';
|
|
2
|
+
import { eventRequest, } from '../internal/event-request.js';
|
|
3
|
+
import { resolveBaseUrl } from '../internal/base-url.js';
|
|
4
|
+
import { createConnectErrorFromResponse, } from '../token.js';
|
|
5
|
+
export function discoverEvents(connector, params, options) {
|
|
6
|
+
return request(connector, 'events/discover', params, options);
|
|
7
|
+
}
|
|
8
|
+
/** Optional account-scoped catalog discovery; does not poll event occurrences. */
|
|
9
|
+
export function listEventTypes(connector, params, options) {
|
|
10
|
+
const url = new URL('/v1/connect/mcp-events/event-types', resolveBaseUrl(options));
|
|
11
|
+
url.searchParams.set('connector', connector);
|
|
12
|
+
url.searchParams.set('tokenRequest', JSON.stringify(params.tokenParams));
|
|
13
|
+
if (params.cursor !== undefined)
|
|
14
|
+
url.searchParams.set('cursor', params.cursor);
|
|
15
|
+
if (params.mcpUrl !== undefined)
|
|
16
|
+
url.searchParams.set('sourceUrl', params.mcpUrl);
|
|
17
|
+
return send(url, { method: 'GET' }, options);
|
|
18
|
+
}
|
|
19
|
+
export function subscribe(connector, params, options) {
|
|
20
|
+
return request(connector, 'subscriptions', params, options);
|
|
21
|
+
}
|
|
22
|
+
export function listSubscriptions(connector, params, options) {
|
|
23
|
+
return request(connector, 'subscriptions/actions/list', params, options);
|
|
24
|
+
}
|
|
25
|
+
export function getSubscription(connector, params, options) {
|
|
26
|
+
return request(connector, 'subscriptions/actions/get', params, options);
|
|
27
|
+
}
|
|
28
|
+
export function unsubscribe(connector, params, options) {
|
|
29
|
+
return request(connector, 'subscriptions/actions/unsubscribe', params, options);
|
|
30
|
+
}
|
|
31
|
+
/** Stops only this connector/subject/project/environment scope. */
|
|
32
|
+
export function unsubscribeAll(connector, params, options) {
|
|
33
|
+
return request(connector, 'subscriptions/actions/unsubscribe-all', params, options);
|
|
34
|
+
}
|
|
35
|
+
async function request(connector, operation, params, options) {
|
|
36
|
+
// Serialize before awaiting auth so caller mutation cannot change the subject.
|
|
37
|
+
const { mcpUrl, ...fields } = params;
|
|
38
|
+
const body = JSON.stringify({
|
|
39
|
+
...fields,
|
|
40
|
+
source: { type: 'mcp', url: mcpUrl },
|
|
41
|
+
});
|
|
42
|
+
const url = new URL(`/v1/connect/connectors/${encodeURIComponent(connector)}/${operation}`, resolveBaseUrl(options));
|
|
43
|
+
return send(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body }, options);
|
|
44
|
+
}
|
|
45
|
+
function send(url, init, options) {
|
|
46
|
+
const { vercelToken } = options ?? {};
|
|
47
|
+
return eventRequest(options, async (signal) => {
|
|
48
|
+
signal?.throwIfAborted();
|
|
49
|
+
const token = vercelToken ?? (await getVercelOidcToken());
|
|
50
|
+
signal?.throwIfAborted();
|
|
51
|
+
const response = await fetch(url.toString(), {
|
|
52
|
+
...init,
|
|
53
|
+
redirect: 'error',
|
|
54
|
+
headers: {
|
|
55
|
+
Accept: 'application/json',
|
|
56
|
+
...init.headers,
|
|
57
|
+
Authorization: `Bearer ${token}`,
|
|
58
|
+
},
|
|
59
|
+
...(signal === undefined ? {} : { signal }),
|
|
60
|
+
});
|
|
61
|
+
if (!response.ok)
|
|
62
|
+
throw await createConnectErrorFromResponse(response, 'Failed to manage event subscriptions');
|
|
63
|
+
return (await response.json());
|
|
64
|
+
});
|
|
65
|
+
}
|
package/dist/mcp/index.d.ts
CHANGED
|
@@ -34,3 +34,5 @@
|
|
|
34
34
|
*/
|
|
35
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';
|
|
36
36
|
export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, type ConnectErrorOptions, type ConnectTokenExchangeSubject, type ConnectTokenParams, type ConnectTokenSubject, type ConnectVendorErrorPayload, } from '../token.js';
|
|
37
|
+
export { discoverEvents as experimental_discoverEvents, listEventTypes as experimental_listEventTypes, subscribe as experimental_subscribe, listSubscriptions as experimental_listSubscriptions, getSubscription as experimental_getSubscription, unsubscribe as experimental_unsubscribe, unsubscribeAll as experimental_unsubscribeAll, type McpEventScope as Experimental_McpEventScope, type McpEventSourceInput as Experimental_McpEventSourceInput, type McpEventOptions as Experimental_McpEventOptions, type McpEventDescriptor as Experimental_McpEventDescriptor, type McpEventSubject as Experimental_McpEventSubject, type McpSubscribeInput as Experimental_McpSubscribeInput, type McpSubscription as Experimental_McpSubscription, type McpSubscriptionStatus as Experimental_McpSubscriptionStatus, type McpSubscriptionLookupInput as Experimental_McpSubscriptionLookupInput, } from './events.js';
|
|
38
|
+
export { verifyMcpEvent as experimental_verifyMcpEvent, McpEventVerificationError as Experimental_McpEventVerificationError, type McpEventDelivery as Experimental_McpEventDelivery, type VerifyMcpEventOptions as Experimental_VerifyMcpEventOptions, } from './verify-event.js';
|
package/dist/mcp/index.js
CHANGED
|
@@ -34,3 +34,5 @@
|
|
|
34
34
|
*/
|
|
35
35
|
export { CONNECT_DISCOVERY_STATE, CONNECT_MANAGED_REDIRECT_URL, connectAuthProvider, ConsentRequiredError, getConsentChallenge, isConsentRequiredError, } from './connect-auth-provider.js';
|
|
36
36
|
export { ConnectError, ConnectorInstallationRequiredError, NoValidTokenError, UserAuthorizationRequiredError, } from '../token.js';
|
|
37
|
+
export { discoverEvents as experimental_discoverEvents, listEventTypes as experimental_listEventTypes, subscribe as experimental_subscribe, listSubscriptions as experimental_listSubscriptions, getSubscription as experimental_getSubscription, unsubscribe as experimental_unsubscribe, unsubscribeAll as experimental_unsubscribeAll, } from './events.js';
|
|
38
|
+
export { verifyMcpEvent as experimental_verifyMcpEvent, McpEventVerificationError as Experimental_McpEventVerificationError, } from './verify-event.js';
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
interface McpDeliveryEnvelope {
|
|
2
|
+
version: 1;
|
|
3
|
+
deliveryId: string;
|
|
4
|
+
subscriptionId: string;
|
|
5
|
+
source: {
|
|
6
|
+
type: 'mcp';
|
|
7
|
+
connectorId: string;
|
|
8
|
+
};
|
|
9
|
+
context: Record<string, unknown>;
|
|
10
|
+
}
|
|
11
|
+
export type McpEventDelivery = McpDeliveryEnvelope & ({
|
|
12
|
+
event: {
|
|
13
|
+
eventId: string;
|
|
14
|
+
name: string;
|
|
15
|
+
timestamp: string;
|
|
16
|
+
data: Record<string, unknown>;
|
|
17
|
+
cursor: string | null;
|
|
18
|
+
_meta?: Record<string, unknown>;
|
|
19
|
+
};
|
|
20
|
+
} | {
|
|
21
|
+
control: {
|
|
22
|
+
type: 'gap';
|
|
23
|
+
cursor: string | null;
|
|
24
|
+
truncated?: true;
|
|
25
|
+
};
|
|
26
|
+
} | {
|
|
27
|
+
control: {
|
|
28
|
+
type: 'terminated';
|
|
29
|
+
error: {
|
|
30
|
+
code: number;
|
|
31
|
+
message: string;
|
|
32
|
+
data?: unknown;
|
|
33
|
+
};
|
|
34
|
+
};
|
|
35
|
+
});
|
|
36
|
+
export interface VerifyMcpEventOptions {
|
|
37
|
+
/** Registered destination path from trusted server configuration. */
|
|
38
|
+
path: string;
|
|
39
|
+
/** Optional additional owner restriction. Defaults to VERCEL_ORG_ID when set. */
|
|
40
|
+
ownerId?: string;
|
|
41
|
+
/** Existing Vercel OIDC audience, normally https://vercel.com/<team-slug>. */
|
|
42
|
+
audience?: string;
|
|
43
|
+
/** Match the project's issuer mode: global or https://oidc.vercel.com/<team-slug>. */
|
|
44
|
+
issuer?: string;
|
|
45
|
+
/** Defaults to VERCEL_PROJECT_ID. Wildcards are not supported. */
|
|
46
|
+
projectId?: string;
|
|
47
|
+
/** Defaults to VERCEL_TARGET_ENV, then VERCEL_ENV. */
|
|
48
|
+
environment?: string;
|
|
49
|
+
/** Required for a custom environment; use its stable env_ ID. */
|
|
50
|
+
customEnvironmentId?: string;
|
|
51
|
+
/** Required for preview; defaults to VERCEL_GIT_COMMIT_REF. */
|
|
52
|
+
branch?: string;
|
|
53
|
+
}
|
|
54
|
+
export declare class McpEventVerificationError extends Error {
|
|
55
|
+
readonly code: 'invalid_delivery' | 'configuration_error' | 'verification_unavailable';
|
|
56
|
+
constructor(code: McpEventVerificationError['code']);
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Authenticate Connect's delivery token and validate the event envelope.
|
|
60
|
+
* Consumes the body. Does not acknowledge, deduplicate, or start application work.
|
|
61
|
+
* Persist acceptance by deliveryId before returning success to Connect.
|
|
62
|
+
*
|
|
63
|
+
* Generic Request URLs and forwarded headers cannot establish a trusted public
|
|
64
|
+
* route. Supply the registered path; project and environment default to env vars.
|
|
65
|
+
*/
|
|
66
|
+
export declare function verifyMcpEvent(request: Request, options: VerifyMcpEventOptions): Promise<McpEventDelivery>;
|
|
67
|
+
export {};
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import { verifyVercelOidcToken } from '@vercel/oidc';
|
|
2
|
+
import { readNonEmptyEnv } from '../internal/env.js';
|
|
3
|
+
export class McpEventVerificationError extends Error {
|
|
4
|
+
code;
|
|
5
|
+
constructor(code) {
|
|
6
|
+
super(code === 'configuration_error'
|
|
7
|
+
? 'Trusted MCP event receiver configuration is missing or invalid.'
|
|
8
|
+
: code === 'verification_unavailable'
|
|
9
|
+
? 'MCP event verification is temporarily unavailable.'
|
|
10
|
+
: 'Invalid MCP event delivery.');
|
|
11
|
+
this.name = 'McpEventVerificationError';
|
|
12
|
+
this.code = code;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Authenticate Connect's delivery token and validate the event envelope.
|
|
17
|
+
* Consumes the body. Does not acknowledge, deduplicate, or start application work.
|
|
18
|
+
* Persist acceptance by deliveryId before returning success to Connect.
|
|
19
|
+
*
|
|
20
|
+
* Generic Request URLs and forwarded headers cannot establish a trusted public
|
|
21
|
+
* route. Supply the registered path; project and environment default to env vars.
|
|
22
|
+
*/
|
|
23
|
+
export async function verifyMcpEvent(request, options) {
|
|
24
|
+
const config = receiverConfig(options);
|
|
25
|
+
const token = request.headers
|
|
26
|
+
.get('authorization')
|
|
27
|
+
?.match(/^Bearer\s+(.+)$/i)?.[1];
|
|
28
|
+
if (!token ||
|
|
29
|
+
request.method !== 'POST' ||
|
|
30
|
+
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(request.headers.get('content-type') ?? '') ||
|
|
31
|
+
(request.headers.has('content-encoding') &&
|
|
32
|
+
request.headers.get('content-encoding') !== 'identity'))
|
|
33
|
+
throw invalid();
|
|
34
|
+
let verified;
|
|
35
|
+
try {
|
|
36
|
+
verified = await verifyVercelOidcToken(token, {
|
|
37
|
+
issuer: config.issuer,
|
|
38
|
+
audience: config.audience,
|
|
39
|
+
ownerId: config.ownerId,
|
|
40
|
+
projectId: config.projectId,
|
|
41
|
+
environment: config.environment,
|
|
42
|
+
algorithms: ['RS256'],
|
|
43
|
+
typ: 'JWT',
|
|
44
|
+
requiredClaims: [
|
|
45
|
+
'iss',
|
|
46
|
+
'aud',
|
|
47
|
+
'iat',
|
|
48
|
+
'exp',
|
|
49
|
+
'owner_id',
|
|
50
|
+
'project_id',
|
|
51
|
+
'environment',
|
|
52
|
+
],
|
|
53
|
+
maxTokenAge: 300,
|
|
54
|
+
clockTolerance: 60,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
const code = isObject(error) ? error.code : undefined;
|
|
59
|
+
if (typeof code === 'string' &&
|
|
60
|
+
(/^(ERR_JWT_|ERR_JWS_)/.test(code) ||
|
|
61
|
+
[
|
|
62
|
+
'ERR_JWKS_NO_MATCHING_KEY',
|
|
63
|
+
'ERR_JOSE_ALG_NOT_ALLOWED',
|
|
64
|
+
'ERR_JOSE_NOT_SUPPORTED',
|
|
65
|
+
'ERR_VERCEL_OIDC_TOKEN_VALIDATION',
|
|
66
|
+
].includes(code)))
|
|
67
|
+
throw invalid();
|
|
68
|
+
throw new McpEventVerificationError('verification_unavailable');
|
|
69
|
+
}
|
|
70
|
+
// The reserved purpose distinguishes Connect deliveries from ordinary project
|
|
71
|
+
// tokens. Trust the envelope received over HTTPS; no body signature is used.
|
|
72
|
+
verifyDeliveryIdentity(verified, config);
|
|
73
|
+
const bytes = await readBody(request);
|
|
74
|
+
let delivery;
|
|
75
|
+
try {
|
|
76
|
+
delivery = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(bytes));
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
throw invalid();
|
|
80
|
+
}
|
|
81
|
+
if (!isDelivery(delivery))
|
|
82
|
+
throw invalid();
|
|
83
|
+
return delivery;
|
|
84
|
+
}
|
|
85
|
+
// Signature, algorithm, issuer, audience, identity, and token age are checked
|
|
86
|
+
// by the OIDC verifier above. These checks restrict the delivery's purpose and
|
|
87
|
+
// destination; they do not bind the token to an individual envelope.
|
|
88
|
+
function verifyDeliveryIdentity(verified, config) {
|
|
89
|
+
const { payload, protectedHeader } = verified;
|
|
90
|
+
const claims = payload.connect;
|
|
91
|
+
if (!isString(protectedHeader.kid) ||
|
|
92
|
+
protectedHeader.jku !== undefined ||
|
|
93
|
+
protectedHeader.x5u !== undefined ||
|
|
94
|
+
!Number.isInteger(payload.iat) ||
|
|
95
|
+
!Number.isInteger(payload.exp) ||
|
|
96
|
+
payload.exp <= payload.iat ||
|
|
97
|
+
payload.exp - payload.iat > 300 ||
|
|
98
|
+
payload.custom_environment_id !== config.customEnvironmentId ||
|
|
99
|
+
!isObject(claims) ||
|
|
100
|
+
claims.purpose !== 'mcp_event_delivery' ||
|
|
101
|
+
claims.version !== 1 ||
|
|
102
|
+
claims.destinationPath !== config.path ||
|
|
103
|
+
claims.previewBranch !== config.previewBranch)
|
|
104
|
+
throw invalid();
|
|
105
|
+
}
|
|
106
|
+
function receiverConfig(options) {
|
|
107
|
+
const projectId = options?.projectId ?? readNonEmptyEnv('VERCEL_PROJECT_ID');
|
|
108
|
+
const environment = options?.environment ??
|
|
109
|
+
readNonEmptyEnv('VERCEL_TARGET_ENV') ??
|
|
110
|
+
readNonEmptyEnv('VERCEL_ENV');
|
|
111
|
+
const previewBranch = environment === 'preview'
|
|
112
|
+
? (options?.branch ?? readNonEmptyEnv('VERCEL_GIT_COMMIT_REF'))
|
|
113
|
+
: undefined;
|
|
114
|
+
const ownerId = options?.ownerId ?? readNonEmptyEnv('VERCEL_ORG_ID');
|
|
115
|
+
if (!options ||
|
|
116
|
+
!isString(projectId) ||
|
|
117
|
+
projectId === '*' ||
|
|
118
|
+
!isString(environment) ||
|
|
119
|
+
environment === '*' ||
|
|
120
|
+
environment === 'development' ||
|
|
121
|
+
(ownerId !== undefined && (!isString(ownerId) || ownerId === '*')) ||
|
|
122
|
+
(options.audience !== undefined &&
|
|
123
|
+
(!isString(options.audience) || options.audience === '*')) ||
|
|
124
|
+
(options.issuer !== undefined &&
|
|
125
|
+
!/^https:\/\/oidc\.vercel\.com(?:\/[A-Za-z0-9_-]+)?$/.test(options.issuer)) ||
|
|
126
|
+
(environment === 'preview' &&
|
|
127
|
+
(!isString(previewBranch) || previewBranch === '*')) ||
|
|
128
|
+
(environment !== 'preview' && options.branch !== undefined) ||
|
|
129
|
+
(!['production', 'preview'].includes(environment) &&
|
|
130
|
+
!/^env_[A-Za-z0-9]+$/.test(options.customEnvironmentId ?? '')) ||
|
|
131
|
+
(['production', 'preview'].includes(environment) &&
|
|
132
|
+
options.customEnvironmentId !== undefined))
|
|
133
|
+
throw new McpEventVerificationError('configuration_error');
|
|
134
|
+
try {
|
|
135
|
+
const url = new URL(options.path, 'https://receiver.invalid');
|
|
136
|
+
if (!options.path.startsWith('/') ||
|
|
137
|
+
options.path.startsWith('//') ||
|
|
138
|
+
url.origin !== 'https://receiver.invalid' ||
|
|
139
|
+
url.pathname !== options.path ||
|
|
140
|
+
url.search ||
|
|
141
|
+
url.hash)
|
|
142
|
+
throw new Error();
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
throw new McpEventVerificationError('configuration_error');
|
|
146
|
+
}
|
|
147
|
+
return { ...options, ownerId, projectId, environment, previewBranch };
|
|
148
|
+
}
|
|
149
|
+
async function readBody(request) {
|
|
150
|
+
const reader = request.body?.getReader();
|
|
151
|
+
if (!reader)
|
|
152
|
+
throw invalid();
|
|
153
|
+
const chunks = [];
|
|
154
|
+
let size = 0;
|
|
155
|
+
try {
|
|
156
|
+
for (;;) {
|
|
157
|
+
const { value, done } = await reader.read();
|
|
158
|
+
if (done)
|
|
159
|
+
break;
|
|
160
|
+
size += value.byteLength;
|
|
161
|
+
if (size > 300 * 1024) {
|
|
162
|
+
await reader.cancel();
|
|
163
|
+
throw invalid();
|
|
164
|
+
}
|
|
165
|
+
chunks.push(value);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
reader.releaseLock();
|
|
170
|
+
}
|
|
171
|
+
const body = new Uint8Array(size);
|
|
172
|
+
let offset = 0;
|
|
173
|
+
for (const chunk of chunks) {
|
|
174
|
+
body.set(chunk, offset);
|
|
175
|
+
offset += chunk.byteLength;
|
|
176
|
+
}
|
|
177
|
+
return body;
|
|
178
|
+
}
|
|
179
|
+
function isObject(value) {
|
|
180
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
181
|
+
}
|
|
182
|
+
function isString(value) {
|
|
183
|
+
return typeof value === 'string' && value.length > 0;
|
|
184
|
+
}
|
|
185
|
+
function hasOnly(value, keys) {
|
|
186
|
+
return Object.keys(value).every(key => keys.includes(key));
|
|
187
|
+
}
|
|
188
|
+
function isDelivery(value) {
|
|
189
|
+
if (!isObject(value) ||
|
|
190
|
+
value.version !== 1 ||
|
|
191
|
+
!hasOnly(value, [
|
|
192
|
+
'version',
|
|
193
|
+
'deliveryId',
|
|
194
|
+
'subscriptionId',
|
|
195
|
+
'source',
|
|
196
|
+
'event',
|
|
197
|
+
'control',
|
|
198
|
+
'context',
|
|
199
|
+
]) ||
|
|
200
|
+
!isString(value.deliveryId) ||
|
|
201
|
+
!isString(value.subscriptionId) ||
|
|
202
|
+
!isObject(value.context))
|
|
203
|
+
return false;
|
|
204
|
+
const { source, event, control } = value;
|
|
205
|
+
if (!(isObject(source) &&
|
|
206
|
+
hasOnly(source, ['type', 'connectorId']) &&
|
|
207
|
+
source.type === 'mcp' &&
|
|
208
|
+
isString(source.connectorId)))
|
|
209
|
+
return false;
|
|
210
|
+
if ('control' in value) {
|
|
211
|
+
if ('event' in value || !isObject(control))
|
|
212
|
+
return false;
|
|
213
|
+
if (control.type === 'gap')
|
|
214
|
+
return (hasOnly(control, ['type', 'cursor', 'truncated']) &&
|
|
215
|
+
isCursor(control.cursor) &&
|
|
216
|
+
(control.truncated === undefined || control.truncated === true));
|
|
217
|
+
if (control.type === 'terminated')
|
|
218
|
+
return (hasOnly(control, ['type', 'error']) &&
|
|
219
|
+
isObject(control.error) &&
|
|
220
|
+
hasOnly(control.error, ['code', 'message', 'data']) &&
|
|
221
|
+
Number.isSafeInteger(control.error.code) &&
|
|
222
|
+
typeof control.error.message === 'string');
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
return (isObject(event) &&
|
|
226
|
+
hasOnly(event, [
|
|
227
|
+
'eventId',
|
|
228
|
+
'name',
|
|
229
|
+
'timestamp',
|
|
230
|
+
'data',
|
|
231
|
+
'cursor',
|
|
232
|
+
'_meta',
|
|
233
|
+
]) &&
|
|
234
|
+
isString(event.eventId) &&
|
|
235
|
+
isString(event.name) &&
|
|
236
|
+
isString(event.timestamp) &&
|
|
237
|
+
Number.isFinite(Date.parse(event.timestamp)) &&
|
|
238
|
+
isObject(event.data) &&
|
|
239
|
+
isCursor(event.cursor) &&
|
|
240
|
+
(event._meta === undefined || isObject(event._meta)));
|
|
241
|
+
}
|
|
242
|
+
function isCursor(value) {
|
|
243
|
+
return value === null || typeof value === 'string';
|
|
244
|
+
}
|
|
245
|
+
function invalid() {
|
|
246
|
+
return new McpEventVerificationError('invalid_delivery');
|
|
247
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vercel/connect",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.5.0",
|
|
4
4
|
"license": "Apache-2.0",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"repository": {
|
|
@@ -50,11 +50,11 @@
|
|
|
50
50
|
"dist"
|
|
51
51
|
],
|
|
52
52
|
"dependencies": {
|
|
53
|
-
"@vercel/oidc": "4.0.
|
|
53
|
+
"@vercel/oidc": "4.0.1"
|
|
54
54
|
},
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"@ai-sdk/mcp": "^1 || ^2",
|
|
57
|
-
"@auth/core": ">=0.
|
|
57
|
+
"@auth/core": ">=0.41.3",
|
|
58
58
|
"@chat-adapter/slack": "^4.0.0",
|
|
59
59
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
60
60
|
"ai": "^6 || ^7.0.0-beta.0",
|
|
@@ -86,13 +86,14 @@
|
|
|
86
86
|
},
|
|
87
87
|
"devDependencies": {
|
|
88
88
|
"@ai-sdk/mcp": "2.0.0-beta.37",
|
|
89
|
-
"@auth/core": "0.
|
|
89
|
+
"@auth/core": "0.41.3",
|
|
90
90
|
"@chat-adapter/slack": "4.31.0",
|
|
91
91
|
"@modelcontextprotocol/sdk": "1.29.0",
|
|
92
92
|
"@tanstack/ai-mcp": "0.3.10",
|
|
93
93
|
"ai": "7.0.26",
|
|
94
94
|
"better-auth": "1.5.5",
|
|
95
95
|
"eve": "0.39.1",
|
|
96
|
+
"jose5": "npm:jose@5.9.6",
|
|
96
97
|
"typescript": "^5"
|
|
97
98
|
},
|
|
98
99
|
"private": false,
|