agents 0.0.0-7bd597a → 0.0.0-7f84d28

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/src/index.ts CHANGED
@@ -13,6 +13,20 @@ import { nanoid } from "nanoid";
13
13
 
14
14
  import { AsyncLocalStorage } from "node:async_hooks";
15
15
  import { MCPClientManager } from "./mcp/client";
16
+ import {
17
+ DurableObjectOAuthClientProvider,
18
+ type AgentsOAuthProvider,
19
+ } from "./mcp/do-oauth-client-provider";
20
+ import type {
21
+ Tool,
22
+ Resource,
23
+ Prompt,
24
+ } from "@modelcontextprotocol/sdk/types.js";
25
+
26
+ import type { Client } from "@modelcontextprotocol/sdk/client/index.js";
27
+ import type { SSEClientTransportOptions } from "@modelcontextprotocol/sdk/client/sse.js";
28
+
29
+ import { camelCaseToKebabCase } from "./client";
16
30
 
17
31
  export type { Connection, WSMessage, ConnectionContext } from "partyserver";
18
32
 
@@ -159,6 +173,43 @@ function getNextCronTime(cron: string) {
159
173
  return interval.getNextDate();
160
174
  }
161
175
 
176
+ /**
177
+ * MCP Server state update message from server -> Client
178
+ */
179
+ export type MCPServerMessage = {
180
+ type: "cf_agent_mcp_servers";
181
+ mcp: MCPServersState;
182
+ };
183
+
184
+ export type MCPServersState = {
185
+ servers: {
186
+ [id: string]: MCPServer;
187
+ };
188
+ tools: Tool[];
189
+ prompts: Prompt[];
190
+ resources: Resource[];
191
+ };
192
+
193
+ export type MCPServer = {
194
+ name: string;
195
+ server_url: string;
196
+ auth_url: string | null;
197
+ state: "authenticating" | "connecting" | "ready" | "discovering" | "failed";
198
+ };
199
+
200
+ /**
201
+ * MCP Server data stored in DO SQL for resuming MCP Server connections
202
+ */
203
+ type MCPServerRow = {
204
+ id: string;
205
+ name: string;
206
+ server_url: string;
207
+ client_id: string | null;
208
+ auth_url: string | null;
209
+ callback_url: string;
210
+ server_options: string;
211
+ };
212
+
162
213
  const STATE_ROW_ID = "cf_state_row_id";
163
214
  const STATE_WAS_CHANGED = "cf_state_was_changed";
164
215
 
@@ -200,12 +251,12 @@ export function getCurrentAgent<
200
251
  * @template State State type to store within the Agent
201
252
  */
202
253
  export class Agent<Env, State = unknown> extends Server<Env> {
203
- #state = DEFAULT_STATE as State;
254
+ private _state = DEFAULT_STATE as State;
204
255
 
205
- #ParentClass: typeof Agent<Env, State> =
256
+ private _ParentClass: typeof Agent<Env, State> =
206
257
  Object.getPrototypeOf(this).constructor;
207
258
 
208
- mcp: MCPClientManager = new MCPClientManager(this.#ParentClass.name, "0.0.1");
259
+ mcp: MCPClientManager = new MCPClientManager(this._ParentClass.name, "0.0.1");
209
260
 
210
261
  /**
211
262
  * Initial state for the Agent
@@ -217,9 +268,9 @@ export class Agent<Env, State = unknown> extends Server<Env> {
217
268
  * Current state of the Agent
218
269
  */
219
270
  get state(): State {
220
- if (this.#state !== DEFAULT_STATE) {
271
+ if (this._state !== DEFAULT_STATE) {
221
272
  // state was previously set, and populated internal state
222
- return this.#state;
273
+ return this._state;
223
274
  }
224
275
  // looks like this is the first time the state is being accessed
225
276
  // check if the state was set in a previous life
@@ -239,8 +290,8 @@ export class Agent<Env, State = unknown> extends Server<Env> {
239
290
  ) {
240
291
  const state = result[0]?.state as string; // could be null?
241
292
 
242
- this.#state = JSON.parse(state);
243
- return this.#state;
293
+ this._state = JSON.parse(state);
294
+ return this._state;
244
295
  }
245
296
 
246
297
  // ok, this is the first time the state is being accessed
@@ -301,7 +352,7 @@ export class Agent<Env, State = unknown> extends Server<Env> {
301
352
  `;
302
353
 
303
354
  void this.ctx.blockConcurrencyWhile(async () => {
304
- return this.#tryCatch(async () => {
355
+ return this._tryCatch(async () => {
305
356
  // Create alarms table if it doesn't exist
306
357
  this.sql`
307
358
  CREATE TABLE IF NOT EXISTS cf_agents_schedules (
@@ -321,13 +372,53 @@ export class Agent<Env, State = unknown> extends Server<Env> {
321
372
  });
322
373
  });
323
374
 
375
+ this.sql`
376
+ CREATE TABLE IF NOT EXISTS cf_agents_mcp_servers (
377
+ id TEXT PRIMARY KEY NOT NULL,
378
+ name TEXT NOT NULL,
379
+ server_url TEXT NOT NULL,
380
+ callback_url TEXT NOT NULL,
381
+ client_id TEXT,
382
+ auth_url TEXT,
383
+ server_options TEXT
384
+ )
385
+ `;
386
+
387
+ const _onRequest = this.onRequest.bind(this);
388
+ this.onRequest = (request: Request) => {
389
+ return agentContext.run(
390
+ { agent: this, connection: undefined, request },
391
+ async () => {
392
+ if (this.mcp.isCallbackRequest(request)) {
393
+ await this.mcp.handleCallbackRequest(request);
394
+
395
+ // after the MCP connection handshake, we can send updated mcp state
396
+ this.broadcast(
397
+ JSON.stringify({
398
+ type: "cf_agent_mcp_servers",
399
+ mcp: this._getMcpServerStateInternal(),
400
+ })
401
+ );
402
+
403
+ // We probably should let the user configure this response/redirect, but this is fine for now.
404
+ return new Response("<script>window.close();</script>", {
405
+ status: 200,
406
+ headers: { "content-type": "text/html" },
407
+ });
408
+ }
409
+
410
+ return this._tryCatch(() => _onRequest(request));
411
+ }
412
+ );
413
+ };
414
+
324
415
  const _onMessage = this.onMessage.bind(this);
325
416
  this.onMessage = async (connection: Connection, message: WSMessage) => {
326
417
  return agentContext.run(
327
418
  { agent: this, connection, request: undefined },
328
419
  async () => {
329
420
  if (typeof message !== "string") {
330
- return this.#tryCatch(() => _onMessage(connection, message));
421
+ return this._tryCatch(() => _onMessage(connection, message));
331
422
  }
332
423
 
333
424
  let parsed: unknown;
@@ -335,11 +426,11 @@ export class Agent<Env, State = unknown> extends Server<Env> {
335
426
  parsed = JSON.parse(message);
336
427
  } catch (e) {
337
428
  // silently fail and let the onMessage handler handle it
338
- return this.#tryCatch(() => _onMessage(connection, message));
429
+ return this._tryCatch(() => _onMessage(connection, message));
339
430
  }
340
431
 
341
432
  if (isStateUpdateMessage(parsed)) {
342
- this.#setStateInternal(parsed.state as State, connection);
433
+ this._setStateInternal(parsed.state as State, connection);
343
434
  return;
344
435
  }
345
436
 
@@ -353,7 +444,7 @@ export class Agent<Env, State = unknown> extends Server<Env> {
353
444
  throw new Error(`Method ${method} does not exist`);
354
445
  }
355
446
 
356
- if (!this.#isCallable(method)) {
447
+ if (!this._isCallable(method)) {
357
448
  throw new Error(`Method ${method} is not callable`);
358
449
  }
359
450
 
@@ -392,7 +483,7 @@ export class Agent<Env, State = unknown> extends Server<Env> {
392
483
  return;
393
484
  }
394
485
 
395
- return this.#tryCatch(() => _onMessage(connection, message));
486
+ return this._tryCatch(() => _onMessage(connection, message));
396
487
  }
397
488
  );
398
489
  };
@@ -413,15 +504,65 @@ export class Agent<Env, State = unknown> extends Server<Env> {
413
504
  })
414
505
  );
415
506
  }
416
- return this.#tryCatch(() => _onConnect(connection, ctx));
507
+
508
+ connection.send(
509
+ JSON.stringify({
510
+ type: "cf_agent_mcp_servers",
511
+ mcp: this._getMcpServerStateInternal(),
512
+ })
513
+ );
514
+
515
+ return this._tryCatch(() => _onConnect(connection, ctx));
417
516
  }, 20);
418
517
  }
419
518
  );
420
519
  };
520
+
521
+ const _onStart = this.onStart.bind(this);
522
+ this.onStart = async () => {
523
+ return agentContext.run(
524
+ { agent: this, connection: undefined, request: undefined },
525
+ async () => {
526
+ const servers = this.sql<MCPServerRow>`
527
+ SELECT id, name, server_url, client_id, auth_url, callback_url, server_options FROM cf_agents_mcp_servers;
528
+ `;
529
+
530
+ // from DO storage, reconnect to all servers using our saved auth information
531
+ await Promise.allSettled(
532
+ servers.map((server) => {
533
+ return this._connectToMcpServerInternal(
534
+ server.name,
535
+ server.server_url,
536
+ server.callback_url,
537
+ server.server_options
538
+ ? JSON.parse(server.server_options)
539
+ : undefined,
540
+ {
541
+ id: server.id,
542
+ oauthClientId: server.client_id ?? undefined,
543
+ }
544
+ );
545
+ })
546
+ );
547
+
548
+ this.broadcast(
549
+ JSON.stringify({
550
+ type: "cf_agent_mcp_servers",
551
+ mcp: this._getMcpServerStateInternal(),
552
+ })
553
+ );
554
+
555
+ await this._tryCatch(() => _onStart());
556
+ }
557
+ );
558
+ };
421
559
  }
422
560
 
423
- #setStateInternal(state: State, source: Connection | "server" = "server") {
424
- this.#state = state;
561
+ private _setStateInternal(
562
+ state: State,
563
+ source: Connection | "server" = "server"
564
+ ) {
565
+ this._state = state;
425
566
  this.sql`
426
567
  INSERT OR REPLACE INTO cf_agents_state (id, state)
427
568
  VALUES (${STATE_ROW_ID}, ${JSON.stringify(state)})
@@ -437,7 +578,7 @@ export class Agent<Env, State = unknown> extends Server<Env> {
437
578
  }),
438
579
  source !== "server" ? [source.id] : []
439
580
  );
440
- return this.#tryCatch(() => {
581
+ return this._tryCatch(() => {
441
582
  const { connection, request } = agentContext.getStore() || {};
442
583
  return agentContext.run(
443
584
  { agent: this, connection, request },
@@ -453,7 +594,7 @@ export class Agent<Env, State = unknown> extends Server<Env> {
453
594
  * @param state New state to set
454
595
  */
455
596
  setState(state: State) {
456
- this.#setStateInternal(state, "server");
597
+ this._setStateInternal(state, "server");
457
598
  }
458
599
 
459
600
  /**
@@ -478,7 +619,7 @@ export class Agent<Env, State = unknown> extends Server<Env> {
478
619
  );
479
620
  }
480
621
 
481
- async #tryCatch<T>(fn: () => T | Promise<T>) {
622
+ private async _tryCatch<T>(fn: () => T | Promise<T>) {
482
623
  try {
483
624
  return await fn();
484
625
  } catch (e) {
@@ -552,7 +693,7 @@ export class Agent<Env, State = unknown> extends Server<Env> {
552
693
  )}, 'scheduled', ${timestamp})
553
694
  `;
554
695
 
555
- await this.#scheduleNextAlarm();
696
+ await this._scheduleNextAlarm();
556
697
 
557
698
  return {
558
699
  id,
@@ -573,7 +714,7 @@ export class Agent<Env, State = unknown> extends Server<Env> {
573
714
  )}, 'delayed', ${when}, ${timestamp})
574
715
  `;
575
716
 
576
- await this.#scheduleNextAlarm();
717
+ await this._scheduleNextAlarm();
577
718
 
578
719
  return {
579
720
  id,
@@ -595,7 +736,7 @@ export class Agent<Env, State = unknown> extends Server<Env> {
595
736
  )}, 'cron', ${when}, ${timestamp})
596
737
  `;
597
738
 
598
- await this.#scheduleNextAlarm();
739
+ await this._scheduleNextAlarm();
599
740
 
600
741
  return {
601
742
  id,
@@ -682,11 +823,11 @@ export class Agent<Env, State = unknown> extends Server<Env> {
682
823
  async cancelSchedule(id: string): Promise<boolean> {
683
824
  this.sql`DELETE FROM cf_agents_schedules WHERE id = ${id}`;
684
825
 
685
- await this.#scheduleNextAlarm();
826
+ await this._scheduleNextAlarm();
686
827
  return true;
687
828
  }
688
829
 
689
- async #scheduleNextAlarm() {
830
+ private async _scheduleNextAlarm() {
690
831
  // Find the next schedule that needs to be executed
691
832
  const result = this.sql`
692
833
  SELECT time FROM cf_agents_schedules
@@ -703,10 +844,14 @@ export class Agent<Env, State = unknown> extends Server<Env> {
703
844
  }
704
845
 
705
846
  /**
706
- * Method called when an alarm fires
707
- * Executes any scheduled tasks that are due
847
+ * Method called when an alarm fires.
848
+ * Executes any scheduled tasks that are due.
849
+ *
850
+ * @remarks
851
+ * To schedule a task, please use the `this.schedule` method instead.
852
+ * See {@link https://developers.cloudflare.com/agents/api-reference/schedule-tasks/}
708
853
  */
709
- async alarm() {
854
+ public readonly alarm = async () => {
710
855
  const now = Math.floor(Date.now() / 1000);
711
856
 
712
857
  // Get all schedules that should be executed now
@@ -752,8 +897,8 @@ export class Agent<Env, State = unknown> extends Server<Env> {
752
897
  }
753
898
 
754
899
  // Schedule the next alarm
755
- await this.#scheduleNextAlarm();
756
- }
900
+ await this._scheduleNextAlarm();
901
+ };
757
902
 
758
903
  /**
759
904
  * Destroy the Agent, removing all state and scheduled tasks
@@ -762,20 +907,176 @@ export class Agent<Env, State = unknown> extends Server<Env> {
762
907
  // drop all tables
763
908
  this.sql`DROP TABLE IF EXISTS cf_agents_state`;
764
909
  this.sql`DROP TABLE IF EXISTS cf_agents_schedules`;
910
+ this.sql`DROP TABLE IF EXISTS cf_agents_mcp_servers`;
765
911
 
766
912
  // delete all alarms
767
913
  await this.ctx.storage.deleteAlarm();
768
914
  await this.ctx.storage.deleteAll();
769
915
  }
770
916
 
771
- /**
772
- * Get all methods marked as callable on this Agent
773
- * @returns A map of method names to their metadata
774
- */
775
- #isCallable(method: string): boolean {
917
+ private _isCallable(method: string): boolean {
776
918
  // biome-ignore lint/complexity/noBannedTypes: <explanation>
777
919
  return callableMetadata.has(this[method as keyof this] as Function);
778
920
  }
921
+
922
+ /**
923
+ * Connect to a new MCP Server
924
+ *
925
+ * @param url MCP Server SSE URL
926
+ * @param callbackHost Base host for the agent, used for the redirect URI.
927
+ * @param agentsPrefix agents routing prefix if not using `agents`
928
+ * @param options MCP client and transport (header) options
929
+ * @returns authUrl
930
+ */
931
+ async addMcpServer(
932
+ serverName: string,
933
+ url: string,
934
+ callbackHost: string,
935
+ agentsPrefix = "agents",
936
+ options?: {
937
+ client?: ConstructorParameters<typeof Client>[1];
938
+ transport?: {
939
+ headers: HeadersInit;
940
+ };
941
+ }
942
+ ): Promise<{ id: string; authUrl: string | undefined }> {
943
+ const callbackUrl = `${callbackHost}/${agentsPrefix}/${camelCaseToKebabCase(this._ParentClass.name)}/${this.name}/callback`;
944
+
945
+ const result = await this._connectToMcpServerInternal(
946
+ serverName,
947
+ url,
948
+ callbackUrl,
949
+ options
950
+ );
951
+
952
+ this.broadcast(
953
+ JSON.stringify({
954
+ type: "cf_agent_mcp_servers",
955
+ mcp: this._getMcpServerStateInternal(),
956
+ })
957
+ );
958
+
959
+ return result;
960
+ }
961
+
962
+ async _connectToMcpServerInternal(
963
+ serverName: string,
964
+ url: string,
965
+ callbackUrl: string,
966
+ // it's important that any options here are serializable because we put them into our sqlite DB for reconnection purposes
967
+ options?: {
968
+ client?: ConstructorParameters<typeof Client>[1];
969
+ /**
970
+ * We don't expose the normal set of transport options because:
971
+ * 1) we can't serialize things like the auth provider or a fetch function into the DB for reconnection purposes
972
+ * 2) We probably want these options to be agnostic to the transport type (SSE vs Streamable)
973
+ *
974
+ * This has the limitation that you can't override fetch, but I think headers should handle nearly all cases needed (i.e. non-standard bearer auth).
975
+ */
976
+ transport?: {
977
+ headers?: HeadersInit;
978
+ };
979
+ },
980
+ reconnect?: {
981
+ id: string;
982
+ oauthClientId?: string;
983
+ }
984
+ ): Promise<{ id: string; authUrl: string | undefined }> {
985
+ const authProvider = new DurableObjectOAuthClientProvider(
986
+ this.ctx.storage,
987
+ this.name,
988
+ callbackUrl
989
+ );
990
+
991
+ if (reconnect) {
992
+ authProvider.serverId = reconnect.id;
993
+ if (reconnect.oauthClientId) {
994
+ authProvider.clientId = reconnect.oauthClientId;
995
+ }
996
+ }
997
+
998
+ // allows passing through transport headers if necessary
999
+ // this handles some non-standard bearer auth setups (i.e. MCP server behind CF access instead of OAuth)
1000
+ let headerTransportOpts: SSEClientTransportOptions = {};
1001
+ if (options?.transport?.headers) {
1002
+ headerTransportOpts = {
1003
+ eventSourceInit: {
1004
+ fetch: (url, init) =>
1005
+ fetch(url, {
1006
+ ...init,
1007
+ headers: options?.transport?.headers,
1008
+ }),
1009
+ },
1010
+ requestInit: {
1011
+ headers: options?.transport?.headers,
1012
+ },
1013
+ };
1014
+ }
1015
+
1016
+ const { id, authUrl, clientId } = await this.mcp.connect(url, {
1017
+ reconnect,
1018
+ transport: {
1019
+ ...headerTransportOpts,
1020
+ authProvider,
1021
+ },
1022
+ client: options?.client,
1023
+ });
1024
+
1025
+ this.sql`
1026
+ INSERT OR REPLACE INTO cf_agents_mcp_servers (id, name, server_url, client_id, auth_url, callback_url, server_options)
1027
+ VALUES (
1028
+ ${id},
1029
+ ${serverName},
1030
+ ${url},
1031
+ ${clientId ?? null},
1032
+ ${authUrl ?? null},
1033
+ ${callbackUrl},
1034
+ ${options ? JSON.stringify(options) : null}
1035
+ );
1036
+ `;
1037
+
1038
+ return {
1039
+ id,
1040
+ authUrl,
1041
+ };
1042
+ }
1043
+
1044
+ async removeMcpServer(id: string) {
1045
+ this.mcp.closeConnection(id);
1046
+ this.sql`
1047
+ DELETE FROM cf_agents_mcp_servers WHERE id = ${id};
1048
+ `;
1049
+ this.broadcast(
1050
+ JSON.stringify({
1051
+ type: "cf_agent_mcp_servers",
1052
+ mcp: this._getMcpServerStateInternal(),
1053
+ })
1054
+ );
1055
+ }
1056
+
1057
+ private _getMcpServerStateInternal(): MCPServersState {
1058
+ const mcpState: MCPServersState = {
1059
+ servers: {},
1060
+ tools: this.mcp.listTools(),
1061
+ prompts: this.mcp.listPrompts(),
1062
+ resources: this.mcp.listResources(),
1063
+ };
1064
+
1065
+ const servers = this.sql<MCPServerRow>`
1066
+ SELECT id, name, server_url, client_id, auth_url, callback_url, server_options FROM cf_agents_mcp_servers;
1067
+ `;
1068
+
1069
+ for (const server of servers) {
1070
+ mcpState.servers[server.id] = {
1071
+ name: server.name,
1072
+ server_url: server.server_url,
1073
+ auth_url: server.auth_url,
1074
+ state: this.mcp.mcpConnections[server.id].connectionState,
1075
+ };
1076
+ }
1077
+
1078
+ return mcpState;
1079
+ }
779
1080
  }
780
1081
 
781
1082
  /**
@@ -894,13 +1195,13 @@ export async function getAgentByName<Env, T extends Agent<Env>>(
894
1195
  * A wrapper for streaming responses in callable methods
895
1196
  */
896
1197
  export class StreamingResponse {
897
- #connection: Connection;
898
- #id: string;
899
- #closed = false;
1198
+ private _connection: Connection;
1199
+ private _id: string;
1200
+ private _closed = false;
900
1201
 
901
1202
  constructor(connection: Connection, id: string) {
902
- this.#connection = connection;
903
- this.#id = id;
1203
+ this._connection = connection;
1204
+ this._id = id;
904
1205
  }
905
1206
 
906
1207
  /**
@@ -908,17 +1209,17 @@ export class StreamingResponse {
908
1209
  * @param chunk The data to send
909
1210
  */
910
1211
  send(chunk: unknown) {
911
- if (this.#closed) {
1212
+ if (this._closed) {
912
1213
  throw new Error("StreamingResponse is already closed");
913
1214
  }
914
1215
  const response: RPCResponse = {
915
1216
  type: "rpc",
916
- id: this.#id,
1217
+ id: this._id,
917
1218
  success: true,
918
1219
  result: chunk,
919
1220
  done: false,
920
1221
  };
921
- this.#connection.send(JSON.stringify(response));
1222
+ this._connection.send(JSON.stringify(response));
922
1223
  }
923
1224
 
924
1225
  /**
@@ -926,17 +1227,17 @@ export class StreamingResponse {
926
1227
  * @param finalChunk Optional final chunk of data to send
927
1228
  */
928
1229
  end(finalChunk?: unknown) {
929
- if (this.#closed) {
1230
+ if (this._closed) {
930
1231
  throw new Error("StreamingResponse is already closed");
931
1232
  }
932
- this.#closed = true;
1233
+ this._closed = true;
933
1234
  const response: RPCResponse = {
934
1235
  type: "rpc",
935
- id: this.#id,
1236
+ id: this._id,
936
1237
  success: true,
937
1238
  result: finalChunk,
938
1239
  done: true,
939
1240
  };
940
- this.#connection.send(JSON.stringify(response));
1241
+ this._connection.send(JSON.stringify(response));
941
1242
  }
942
1243
  }