History

Merge pull request #342 from RhysSullivan/rs/mcp-per-user-oauth

Rhys Sullivan committed 6 months ago

Commit a0ab5a72 parents 596d576 897cee416 files · +2501 −63Browse files

Unified · Split

16 files
  1. .oxlintrc.jsonc +1 −1
  2. apps/cloud/drizzle/0002_fat_white_tiger.sql +1 −0
  3. apps/cloud/drizzle/meta/0002_snapshot.json +1276 −0
  4. apps/cloud/drizzle/meta/_journal.json +7 −0
  5. apps/cloud/src/services/executor-schema.ts +2 −2
  6. apps/cloud/src/services/mcp-oauth.node.test.ts +429 −0
  7. apps/local/src/server/mcp-oauth.test.ts +458 −0
  8. packages/core/sdk/src/executor.test.ts +1 −2
  9. packages/plugins/mcp/src/api/group.ts +16 −0
  10. packages/plugins/mcp/src/api/handlers.ts +5 −0
  11. packages/plugins/mcp/src/react/AddMcpSource.tsx +40 −2
  12. packages/plugins/mcp/src/sdk/binding-store.ts +1 −1
  13. packages/plugins/mcp/src/sdk/invoke.ts +16 −3
  14. packages/plugins/mcp/src/sdk/oauth.ts +29 −2
  15. packages/plugins/mcp/src/sdk/plugin.ts +195 −50
  16. packages/plugins/mcp/src/sdk/types.ts +24 −0
.oxlintrc.jsonc+1−1
@@ -16,5 +16,5 @@ · 15 unmodified lines
1616 },
1717 ],
1818 },
19− "ignorePatterns": [".astro/", "**/routeTree.gen.ts"],
19+ "ignorePatterns": [".astro/", "**/routeTree.gen.ts", ".references/"],
2020 }
apps/cloud/drizzle/0002_fat_white_tiger.sql+1−0
@@ -0,0 +1 @@
1+ALTER TABLE "mcp_oauth_session" ALTER COLUMN "expires_at" SET DATA TYPE bigint;
apps/cloud/drizzle/meta/0002_snapshot.json+1276−0
@@ -0,0 +1,1276 @@
1+{
2+ "id": "f029f809-1bf3-46e0-a31b-82df7c9c7171",
3+ "prevId": "12ebb69b-fb44-4e61-80b9-a48b5471ef5a",
4+ "version": "7",
5+ "dialect": "postgresql",
6+ "tables": {
7+ "public.accounts": {
8+ "name": "accounts",
9+ "schema": "",
10+ "columns": {
11+ "id": {
12+ "name": "id",
13+ "type": "text",
14+ "primaryKey": true,
15+ "notNull": true
16+ },
17+ "created_at": {
18+ "name": "created_at",
19+ "type": "timestamp with time zone",
20+ "primaryKey": false,
21+ "notNull": true,
22+ "default": "now()"
23+ }
24+ },
25+ "indexes": {},
26+ "foreignKeys": {},
27+ "compositePrimaryKeys": {},
28+ "uniqueConstraints": {},
29+ "policies": {},
30+ "checkConstraints": {},
31+ "isRLSEnabled": false
32+ },
33+ "public.memberships": {
34+ "name": "memberships",
35+ "schema": "",
36+ "columns": {
37+ "account_id": {
38+ "name": "account_id",
39+ "type": "text",
40+ "primaryKey": false,
41+ "notNull": true
42+ },
43+ "organization_id": {
44+ "name": "organization_id",
45+ "type": "text",
46+ "primaryKey": false,
47+ "notNull": true
48+ },
49+ "created_at": {
50+ "name": "created_at",
51+ "type": "timestamp with time zone",
52+ "primaryKey": false,
53+ "notNull": true,
54+ "default": "now()"
55+ }
56+ },
57+ "indexes": {},
58+ "foreignKeys": {
59+ "memberships_account_id_accounts_id_fk": {
60+ "name": "memberships_account_id_accounts_id_fk",
61+ "tableFrom": "memberships",
62+ "tableTo": "accounts",
63+ "columnsFrom": [
64+ "account_id"
65+ ],
66+ "columnsTo": [
67+ "id"
68+ ],
69+ "onDelete": "cascade",
70+ "onUpdate": "no action"
71+ },
72+ "memberships_organization_id_organizations_id_fk": {
73+ "name": "memberships_organization_id_organizations_id_fk",
74+ "tableFrom": "memberships",
75+ "tableTo": "organizations",
76+ "columnsFrom": [
77+ "organization_id"
78+ ],
79+ "columnsTo": [
80+ "id"
81+ ],
82+ "onDelete": "cascade",
83+ "onUpdate": "no action"
84+ }
85+ },
86+ "compositePrimaryKeys": {
87+ "memberships_account_id_organization_id_pk": {
88+ "name": "memberships_account_id_organization_id_pk",
89+ "columns": [
90+ "account_id",
91+ "organization_id"
92+ ]
93+ }
94+ },
95+ "uniqueConstraints": {},
96+ "policies": {},
97+ "checkConstraints": {},
98+ "isRLSEnabled": false
99+ },
100+ "public.organizations": {
101+ "name": "organizations",
102+ "schema": "",
103+ "columns": {
104+ "id": {
105+ "name": "id",
106+ "type": "text",
107+ "primaryKey": true,
108+ "notNull": true
109+ },
110+ "name": {
111+ "name": "name",
112+ "type": "text",
113+ "primaryKey": false,
114+ "notNull": true
115+ },
116+ "created_at": {
117+ "name": "created_at",
118+ "type": "timestamp with time zone",
119+ "primaryKey": false,
120+ "notNull": true,
121+ "default": "now()"
122+ }
123+ },
124+ "indexes": {},
125+ "foreignKeys": {},
126+ "compositePrimaryKeys": {},
127+ "uniqueConstraints": {},
128+ "policies": {},
129+ "checkConstraints": {},
130+ "isRLSEnabled": false
131+ },
132+ "public.blob": {
133+ "name": "blob",
134+ "schema": "",
135+ "columns": {
136+ "namespace": {
137+ "name": "namespace",
138+ "type": "text",
139+ "primaryKey": false,
140+ "notNull": true
141+ },
142+ "key": {
143+ "name": "key",
144+ "type": "text",
145+ "primaryKey": false,
146+ "notNull": true
147+ },
148+ "value": {
149+ "name": "value",
150+ "type": "text",
151+ "primaryKey": false,
152+ "notNull": true
153+ }
154+ },
155+ "indexes": {},
156+ "foreignKeys": {},
157+ "compositePrimaryKeys": {
158+ "blob_namespace_key_pk": {
159+ "name": "blob_namespace_key_pk",
160+ "columns": [
161+ "namespace",
162+ "key"
163+ ]
164+ }
165+ },
166+ "uniqueConstraints": {},
167+ "policies": {},
168+ "checkConstraints": {},
169+ "isRLSEnabled": false
170+ },
171+ "public.definition": {
172+ "name": "definition",
173+ "schema": "",
174+ "columns": {
175+ "id": {
176+ "name": "id",
177+ "type": "text",
178+ "primaryKey": false,
179+ "notNull": true
180+ },
181+ "scope_id": {
182+ "name": "scope_id",
183+ "type": "text",
184+ "primaryKey": false,
185+ "notNull": true
186+ },
187+ "source_id": {
188+ "name": "source_id",
189+ "type": "text",
190+ "primaryKey": false,
191+ "notNull": true
192+ },
193+ "plugin_id": {
194+ "name": "plugin_id",
195+ "type": "text",
196+ "primaryKey": false,
197+ "notNull": true
198+ },
199+ "name": {
200+ "name": "name",
201+ "type": "text",
202+ "primaryKey": false,
203+ "notNull": true
204+ },
205+ "schema": {
206+ "name": "schema",
207+ "type": "jsonb",
208+ "primaryKey": false,
209+ "notNull": true
210+ },
211+ "created_at": {
212+ "name": "created_at",
213+ "type": "timestamp",
214+ "primaryKey": false,
215+ "notNull": true
216+ }
217+ },
218+ "indexes": {
219+ "definition_scope_id_idx": {
220+ "name": "definition_scope_id_idx",
221+ "columns": [
222+ {
223+ "expression": "scope_id",
224+ "isExpression": false,
225+ "asc": true,
226+ "nulls": "last"
227+ }
228+ ],
229+ "isUnique": false,
230+ "concurrently": false,
231+ "method": "btree",
232+ "with": {}
233+ },
234+ "definition_source_id_idx": {
235+ "name": "definition_source_id_idx",
236+ "columns": [
237+ {
238+ "expression": "source_id",
239+ "isExpression": false,
240+ "asc": true,
241+ "nulls": "last"
242+ }
243+ ],
244+ "isUnique": false,
245+ "concurrently": false,
246+ "method": "btree",
247+ "with": {}
248+ },
249+ "definition_plugin_id_idx": {
250+ "name": "definition_plugin_id_idx",
251+ "columns": [
252+ {
253+ "expression": "plugin_id",
254+ "isExpression": false,
255+ "asc": true,
256+ "nulls": "last"
257+ }
258+ ],
259+ "isUnique": false,
260+ "concurrently": false,
261+ "method": "btree",
262+ "with": {}
263+ }
264+ },
265+ "foreignKeys": {},
266+ "compositePrimaryKeys": {
267+ "definition_scope_id_id_pk": {
268+ "name": "definition_scope_id_id_pk",
269+ "columns": [
270+ "scope_id",
271+ "id"
272+ ]
273+ }
274+ },
275+ "uniqueConstraints": {},
276+ "policies": {},
277+ "checkConstraints": {},
278+ "isRLSEnabled": false
279+ },
280+ "public.graphql_operation": {
281+ "name": "graphql_operation",
282+ "schema": "",
283+ "columns": {
284+ "id": {
285+ "name": "id",
286+ "type": "text",
287+ "primaryKey": false,
288+ "notNull": true
289+ },
290+ "scope_id": {
291+ "name": "scope_id",
292+ "type": "text",
293+ "primaryKey": false,
294+ "notNull": true
295+ },
296+ "source_id": {
297+ "name": "source_id",
298+ "type": "text",
299+ "primaryKey": false,
300+ "notNull": true
301+ },
302+ "binding": {
303+ "name": "binding",
304+ "type": "jsonb",
305+ "primaryKey": false,
306+ "notNull": true
307+ }
308+ },
309+ "indexes": {
310+ "graphql_operation_scope_id_idx": {
311+ "name": "graphql_operation_scope_id_idx",
312+ "columns": [
313+ {
314+ "expression": "scope_id",
315+ "isExpression": false,
316+ "asc": true,
317+ "nulls": "last"
318+ }
319+ ],
320+ "isUnique": false,
321+ "concurrently": false,
322+ "method": "btree",
323+ "with": {}
324+ },
325+ "graphql_operation_source_id_idx": {
326+ "name": "graphql_operation_source_id_idx",
327+ "columns": [
328+ {
329+ "expression": "source_id",
330+ "isExpression": false,
331+ "asc": true,
332+ "nulls": "last"
333+ }
334+ ],
335+ "isUnique": false,
336+ "concurrently": false,
337+ "method": "btree",
338+ "with": {}
339+ }
340+ },
341+ "foreignKeys": {},
342+ "compositePrimaryKeys": {
343+ "graphql_operation_scope_id_id_pk": {
344+ "name": "graphql_operation_scope_id_id_pk",
345+ "columns": [
346+ "scope_id",
347+ "id"
348+ ]
349+ }
350+ },
351+ "uniqueConstraints": {},
352+ "policies": {},
353+ "checkConstraints": {},
354+ "isRLSEnabled": false
355+ },
356+ "public.graphql_source": {
357+ "name": "graphql_source",
358+ "schema": "",
359+ "columns": {
360+ "id": {
361+ "name": "id",
362+ "type": "text",
363+ "primaryKey": false,
364+ "notNull": true
365+ },
366+ "scope_id": {
367+ "name": "scope_id",
368+ "type": "text",
369+ "primaryKey": false,
370+ "notNull": true
371+ },
372+ "name": {
373+ "name": "name",
374+ "type": "text",
375+ "primaryKey": false,
376+ "notNull": true
377+ },
378+ "endpoint": {
379+ "name": "endpoint",
380+ "type": "text",
381+ "primaryKey": false,
382+ "notNull": true
383+ },
384+ "headers": {
385+ "name": "headers",
386+ "type": "jsonb",
387+ "primaryKey": false,
388+ "notNull": false
389+ }
390+ },
391+ "indexes": {
392+ "graphql_source_scope_id_idx": {
393+ "name": "graphql_source_scope_id_idx",
394+ "columns": [
395+ {
396+ "expression": "scope_id",
397+ "isExpression": false,
398+ "asc": true,
399+ "nulls": "last"
400+ }
401+ ],
402+ "isUnique": false,
403+ "concurrently": false,
404+ "method": "btree",
405+ "with": {}
406+ }
407+ },
408+ "foreignKeys": {},
409+ "compositePrimaryKeys": {
410+ "graphql_source_scope_id_id_pk": {
411+ "name": "graphql_source_scope_id_id_pk",
412+ "columns": [
413+ "scope_id",
414+ "id"
415+ ]
416+ }
417+ },
418+ "uniqueConstraints": {},
419+ "policies": {},
420+ "checkConstraints": {},
421+ "isRLSEnabled": false
422+ },
423+ "public.mcp_binding": {
424+ "name": "mcp_binding",
425+ "schema": "",
426+ "columns": {
427+ "id": {
428+ "name": "id",
429+ "type": "text",
430+ "primaryKey": false,
431+ "notNull": true
432+ },
433+ "scope_id": {
434+ "name": "scope_id",
435+ "type": "text",
436+ "primaryKey": false,
437+ "notNull": true
438+ },
439+ "source_id": {
440+ "name": "source_id",
441+ "type": "text",
442+ "primaryKey": false,
443+ "notNull": true
444+ },
445+ "binding": {
446+ "name": "binding",
447+ "type": "jsonb",
448+ "primaryKey": false,
449+ "notNull": true
450+ },
451+ "created_at": {
452+ "name": "created_at",
453+ "type": "timestamp",
454+ "primaryKey": false,
455+ "notNull": true
456+ }
457+ },
458+ "indexes": {
459+ "mcp_binding_scope_id_idx": {
460+ "name": "mcp_binding_scope_id_idx",
461+ "columns": [
462+ {
463+ "expression": "scope_id",
464+ "isExpression": false,
465+ "asc": true,
466+ "nulls": "last"
467+ }
468+ ],
469+ "isUnique": false,
470+ "concurrently": false,
471+ "method": "btree",
472+ "with": {}
473+ },
474+ "mcp_binding_source_id_idx": {
475+ "name": "mcp_binding_source_id_idx",
476+ "columns": [
477+ {
478+ "expression": "source_id",
479+ "isExpression": false,
480+ "asc": true,
481+ "nulls": "last"
482+ }
483+ ],
484+ "isUnique": false,
485+ "concurrently": false,
486+ "method": "btree",
487+ "with": {}
488+ }
489+ },
490+ "foreignKeys": {},
491+ "compositePrimaryKeys": {
492+ "mcp_binding_scope_id_id_pk": {
493+ "name": "mcp_binding_scope_id_id_pk",
494+ "columns": [
495+ "scope_id",
496+ "id"
497+ ]
498+ }
499+ },
500+ "uniqueConstraints": {},
501+ "policies": {},
502+ "checkConstraints": {},
503+ "isRLSEnabled": false
504+ },
505+ "public.mcp_oauth_session": {
506+ "name": "mcp_oauth_session",
507+ "schema": "",
508+ "columns": {
509+ "id": {
510+ "name": "id",
511+ "type": "text",
512+ "primaryKey": false,
513+ "notNull": true
514+ },
515+ "scope_id": {
516+ "name": "scope_id",
517+ "type": "text",
518+ "primaryKey": false,
519+ "notNull": true
520+ },
521+ "session": {
522+ "name": "session",
523+ "type": "jsonb",
524+ "primaryKey": false,
525+ "notNull": true
526+ },
527+ "expires_at": {
528+ "name": "expires_at",
529+ "type": "bigint",
530+ "primaryKey": false,
531+ "notNull": true
532+ },
533+ "created_at": {
534+ "name": "created_at",
535+ "type": "timestamp",
536+ "primaryKey": false,
537+ "notNull": true
538+ }
539+ },
540+ "indexes": {
541+ "mcp_oauth_session_scope_id_idx": {
542+ "name": "mcp_oauth_session_scope_id_idx",
543+ "columns": [
544+ {
545+ "expression": "scope_id",
546+ "isExpression": false,
547+ "asc": true,
548+ "nulls": "last"
549+ }
550+ ],
551+ "isUnique": false,
552+ "concurrently": false,
553+ "method": "btree",
554+ "with": {}
555+ }
556+ },
557+ "foreignKeys": {},
558+ "compositePrimaryKeys": {
559+ "mcp_oauth_session_scope_id_id_pk": {
560+ "name": "mcp_oauth_session_scope_id_id_pk",
561+ "columns": [
562+ "scope_id",
563+ "id"
564+ ]
565+ }
566+ },
567+ "uniqueConstraints": {},
568+ "policies": {},
569+ "checkConstraints": {},
570+ "isRLSEnabled": false
571+ },
572+ "public.mcp_source": {
573+ "name": "mcp_source",
574+ "schema": "",
575+ "columns": {
576+ "id": {
577+ "name": "id",
578+ "type": "text",
579+ "primaryKey": false,
580+ "notNull": true
581+ },
582+ "scope_id": {
583+ "name": "scope_id",
584+ "type": "text",
585+ "primaryKey": false,
586+ "notNull": true
587+ },
588+ "name": {
589+ "name": "name",
590+ "type": "text",
591+ "primaryKey": false,
592+ "notNull": true
593+ },
594+ "config": {
595+ "name": "config",
596+ "type": "jsonb",
597+ "primaryKey": false,
598+ "notNull": true
599+ },
600+ "created_at": {
601+ "name": "created_at",
602+ "type": "timestamp",
603+ "primaryKey": false,
604+ "notNull": true
605+ }
606+ },
607+ "indexes": {
608+ "mcp_source_scope_id_idx": {
609+ "name": "mcp_source_scope_id_idx",
610+ "columns": [
611+ {
612+ "expression": "scope_id",
613+ "isExpression": false,
614+ "asc": true,
615+ "nulls": "last"
616+ }
617+ ],
618+ "isUnique": false,
619+ "concurrently": false,
620+ "method": "btree",
621+ "with": {}
622+ }
623+ },
624+ "foreignKeys": {},
625+ "compositePrimaryKeys": {
626+ "mcp_source_scope_id_id_pk": {
627+ "name": "mcp_source_scope_id_id_pk",
628+ "columns": [
629+ "scope_id",
630+ "id"
631+ ]
632+ }
633+ },
634+ "uniqueConstraints": {},
635+ "policies": {},
636+ "checkConstraints": {},
637+ "isRLSEnabled": false
638+ },
639+ "public.openapi_oauth_session": {
640+ "name": "openapi_oauth_session",
641+ "schema": "",
642+ "columns": {
643+ "id": {
644+ "name": "id",
645+ "type": "text",
646+ "primaryKey": false,
647+ "notNull": true
648+ },
649+ "scope_id": {
650+ "name": "scope_id",
651+ "type": "text",
652+ "primaryKey": false,
653+ "notNull": true
654+ },
655+ "session": {
656+ "name": "session",
657+ "type": "jsonb",
658+ "primaryKey": false,
659+ "notNull": true
660+ },
661+ "created_at": {
662+ "name": "created_at",
663+ "type": "timestamp",
664+ "primaryKey": false,
665+ "notNull": true
666+ }
667+ },
668+ "indexes": {
669+ "openapi_oauth_session_scope_id_idx": {
670+ "name": "openapi_oauth_session_scope_id_idx",
671+ "columns": [
672+ {
673+ "expression": "scope_id",
674+ "isExpression": false,
675+ "asc": true,
676+ "nulls": "last"
677+ }
678+ ],
679+ "isUnique": false,
680+ "concurrently": false,
681+ "method": "btree",
682+ "with": {}
683+ }
684+ },
685+ "foreignKeys": {},
686+ "compositePrimaryKeys": {
687+ "openapi_oauth_session_scope_id_id_pk": {
688+ "name": "openapi_oauth_session_scope_id_id_pk",
689+ "columns": [
690+ "scope_id",
691+ "id"
692+ ]
693+ }
694+ },
695+ "uniqueConstraints": {},
696+ "policies": {},
697+ "checkConstraints": {},
698+ "isRLSEnabled": false
699+ },
700+ "public.openapi_operation": {
701+ "name": "openapi_operation",
702+ "schema": "",
703+ "columns": {
704+ "id": {
705+ "name": "id",
706+ "type": "text",
707+ "primaryKey": false,
708+ "notNull": true
709+ },
710+ "scope_id": {
711+ "name": "scope_id",
712+ "type": "text",
713+ "primaryKey": false,
714+ "notNull": true
715+ },
716+ "source_id": {
717+ "name": "source_id",
718+ "type": "text",
719+ "primaryKey": false,
720+ "notNull": true
721+ },
722+ "binding": {
723+ "name": "binding",
724+ "type": "jsonb",
725+ "primaryKey": false,
726+ "notNull": true
727+ }
728+ },
729+ "indexes": {
730+ "openapi_operation_scope_id_idx": {
731+ "name": "openapi_operation_scope_id_idx",
732+ "columns": [
733+ {
734+ "expression": "scope_id",
735+ "isExpression": false,
736+ "asc": true,
737+ "nulls": "last"
738+ }
739+ ],
740+ "isUnique": false,
741+ "concurrently": false,
742+ "method": "btree",
743+ "with": {}
744+ },
745+ "openapi_operation_source_id_idx": {
746+ "name": "openapi_operation_source_id_idx",
747+ "columns": [
748+ {
749+ "expression": "source_id",
750+ "isExpression": false,
751+ "asc": true,
752+ "nulls": "last"
753+ }
754+ ],
755+ "isUnique": false,
756+ "concurrently": false,
757+ "method": "btree",
758+ "with": {}
759+ }
760+ },
761+ "foreignKeys": {},
762+ "compositePrimaryKeys": {
763+ "openapi_operation_scope_id_id_pk": {
764+ "name": "openapi_operation_scope_id_id_pk",
765+ "columns": [
766+ "scope_id",
767+ "id"
768+ ]
769+ }
770+ },
771+ "uniqueConstraints": {},
772+ "policies": {},
773+ "checkConstraints": {},
774+ "isRLSEnabled": false
775+ },
776+ "public.openapi_source": {
777+ "name": "openapi_source",
778+ "schema": "",
779+ "columns": {
780+ "id": {
781+ "name": "id",
782+ "type": "text",
783+ "primaryKey": false,
784+ "notNull": true
785+ },
786+ "scope_id": {
787+ "name": "scope_id",
788+ "type": "text",
789+ "primaryKey": false,
790+ "notNull": true
791+ },
792+ "name": {
793+ "name": "name",
794+ "type": "text",
795+ "primaryKey": false,
796+ "notNull": true
797+ },
798+ "spec": {
799+ "name": "spec",
800+ "type": "text",
801+ "primaryKey": false,
802+ "notNull": true
803+ },
804+ "source_url": {
805+ "name": "source_url",
806+ "type": "text",
807+ "primaryKey": false,
808+ "notNull": false
809+ },
810+ "base_url": {
811+ "name": "base_url",
812+ "type": "text",
813+ "primaryKey": false,
814+ "notNull": false
815+ },
816+ "headers": {
817+ "name": "headers",
818+ "type": "jsonb",
819+ "primaryKey": false,
820+ "notNull": false
821+ },
822+ "oauth2": {
823+ "name": "oauth2",
824+ "type": "jsonb",
825+ "primaryKey": false,
826+ "notNull": false
827+ },
828+ "invocation_config": {
829+ "name": "invocation_config",
830+ "type": "jsonb",
831+ "primaryKey": false,
832+ "notNull": true
833+ }
834+ },
835+ "indexes": {
836+ "openapi_source_scope_id_idx": {
837+ "name": "openapi_source_scope_id_idx",
838+ "columns": [
839+ {
840+ "expression": "scope_id",
841+ "isExpression": false,
842+ "asc": true,
843+ "nulls": "last"
844+ }
845+ ],
846+ "isUnique": false,
847+ "concurrently": false,
848+ "method": "btree",
849+ "with": {}
850+ }
851+ },
852+ "foreignKeys": {},
853+ "compositePrimaryKeys": {
854+ "openapi_source_scope_id_id_pk": {
855+ "name": "openapi_source_scope_id_id_pk",
856+ "columns": [
857+ "scope_id",
858+ "id"
859+ ]
860+ }
861+ },
862+ "uniqueConstraints": {},
863+ "policies": {},
864+ "checkConstraints": {},
865+ "isRLSEnabled": false
866+ },
867+ "public.secret": {
868+ "name": "secret",
869+ "schema": "",
870+ "columns": {
871+ "id": {
872+ "name": "id",
873+ "type": "text",
874+ "primaryKey": false,
875+ "notNull": true
876+ },
877+ "scope_id": {
878+ "name": "scope_id",
879+ "type": "text",
880+ "primaryKey": false,
881+ "notNull": true
882+ },
883+ "name": {
884+ "name": "name",
885+ "type": "text",
886+ "primaryKey": false,
887+ "notNull": true
888+ },
889+ "provider": {
890+ "name": "provider",
891+ "type": "text",
892+ "primaryKey": false,
893+ "notNull": true
894+ },
895+ "created_at": {
896+ "name": "created_at",
897+ "type": "timestamp",
898+ "primaryKey": false,
899+ "notNull": true
900+ }
901+ },
902+ "indexes": {
903+ "secret_scope_id_idx": {
904+ "name": "secret_scope_id_idx",
905+ "columns": [
906+ {
907+ "expression": "scope_id",
908+ "isExpression": false,
909+ "asc": true,
910+ "nulls": "last"
911+ }
912+ ],
913+ "isUnique": false,
914+ "concurrently": false,
915+ "method": "btree",
916+ "with": {}
917+ },
918+ "secret_provider_idx": {
919+ "name": "secret_provider_idx",
920+ "columns": [
921+ {
922+ "expression": "provider",
923+ "isExpression": false,
924+ "asc": true,
925+ "nulls": "last"
926+ }
927+ ],
928+ "isUnique": false,
929+ "concurrently": false,
930+ "method": "btree",
931+ "with": {}
932+ }
933+ },
934+ "foreignKeys": {},
935+ "compositePrimaryKeys": {
936+ "secret_scope_id_id_pk": {
937+ "name": "secret_scope_id_id_pk",
938+ "columns": [
939+ "scope_id",
940+ "id"
941+ ]
942+ }
943+ },
944+ "uniqueConstraints": {},
945+ "policies": {},
946+ "checkConstraints": {},
947+ "isRLSEnabled": false
948+ },
949+ "public.source": {
950+ "name": "source",
951+ "schema": "",
952+ "columns": {
953+ "id": {
954+ "name": "id",
955+ "type": "text",
956+ "primaryKey": false,
957+ "notNull": true
958+ },
959+ "scope_id": {
960+ "name": "scope_id",
961+ "type": "text",
962+ "primaryKey": false,
963+ "notNull": true
964+ },
965+ "plugin_id": {
966+ "name": "plugin_id",
967+ "type": "text",
968+ "primaryKey": false,
969+ "notNull": true
970+ },
971+ "kind": {
972+ "name": "kind",
973+ "type": "text",
974+ "primaryKey": false,
975+ "notNull": true
976+ },
977+ "name": {
978+ "name": "name",
979+ "type": "text",
980+ "primaryKey": false,
981+ "notNull": true
982+ },
983+ "url": {
984+ "name": "url",
985+ "type": "text",
986+ "primaryKey": false,
987+ "notNull": false
988+ },
989+ "can_remove": {
990+ "name": "can_remove",
991+ "type": "boolean",
992+ "primaryKey": false,
993+ "notNull": true,
994+ "default": true
995+ },
996+ "can_refresh": {
997+ "name": "can_refresh",
998+ "type": "boolean",
999+ "primaryKey": false,
1000+ "notNull": true,
1001+ "default": false
1002+ },
1003+ "can_edit": {
1004+ "name": "can_edit",
1005+ "type": "boolean",
1006+ "primaryKey": false,
1007+ "notNull": true,
1008+ "default": false
1009+ },
1010+ "created_at": {
1011+ "name": "created_at",
1012+ "type": "timestamp",
1013+ "primaryKey": false,
1014+ "notNull": true
1015+ },
1016+ "updated_at": {
1017+ "name": "updated_at",
1018+ "type": "timestamp",
1019+ "primaryKey": false,
1020+ "notNull": true
1021+ }
1022+ },
1023+ "indexes": {
1024+ "source_scope_id_idx": {
1025+ "name": "source_scope_id_idx",
1026+ "columns": [
1027+ {
1028+ "expression": "scope_id",
1029+ "isExpression": false,
1030+ "asc": true,
1031+ "nulls": "last"
1032+ }
1033+ ],
1034+ "isUnique": false,
1035+ "concurrently": false,
1036+ "method": "btree",
1037+ "with": {}
1038+ },
1039+ "source_plugin_id_idx": {
1040+ "name": "source_plugin_id_idx",
1041+ "columns": [
1042+ {
1043+ "expression": "plugin_id",
1044+ "isExpression": false,
1045+ "asc": true,
1046+ "nulls": "last"
1047+ }
1048+ ],
1049+ "isUnique": false,
1050+ "concurrently": false,
1051+ "method": "btree",
1052+ "with": {}
1053+ }
1054+ },
1055+ "foreignKeys": {},
1056+ "compositePrimaryKeys": {
1057+ "source_scope_id_id_pk": {
1058+ "name": "source_scope_id_id_pk",
1059+ "columns": [
1060+ "scope_id",
1061+ "id"
1062+ ]
1063+ }
1064+ },
1065+ "uniqueConstraints": {},
1066+ "policies": {},
1067+ "checkConstraints": {},
1068+ "isRLSEnabled": false
1069+ },
1070+ "public.tool": {
1071+ "name": "tool",
1072+ "schema": "",
1073+ "columns": {
1074+ "id": {
1075+ "name": "id",
1076+ "type": "text",
1077+ "primaryKey": false,
1078+ "notNull": true
1079+ },
1080+ "scope_id": {
1081+ "name": "scope_id",
1082+ "type": "text",
1083+ "primaryKey": false,
1084+ "notNull": true
1085+ },
1086+ "source_id": {
1087+ "name": "source_id",
1088+ "type": "text",
1089+ "primaryKey": false,
1090+ "notNull": true
1091+ },
1092+ "plugin_id": {
1093+ "name": "plugin_id",
1094+ "type": "text",
1095+ "primaryKey": false,
1096+ "notNull": true
1097+ },
1098+ "name": {
1099+ "name": "name",
1100+ "type": "text",
1101+ "primaryKey": false,
1102+ "notNull": true
1103+ },
1104+ "description": {
1105+ "name": "description",
1106+ "type": "text",
1107+ "primaryKey": false,
1108+ "notNull": true
1109+ },
1110+ "input_schema": {
1111+ "name": "input_schema",
1112+ "type": "jsonb",
1113+ "primaryKey": false,
1114+ "notNull": false
1115+ },
1116+ "output_schema": {
1117+ "name": "output_schema",
1118+ "type": "jsonb",
1119+ "primaryKey": false,
1120+ "notNull": false
1121+ },
1122+ "created_at": {
1123+ "name": "created_at",
1124+ "type": "timestamp",
1125+ "primaryKey": false,
1126+ "notNull": true
1127+ },
1128+ "updated_at": {
1129+ "name": "updated_at",
1130+ "type": "timestamp",
1131+ "primaryKey": false,
1132+ "notNull": true
1133+ }
1134+ },
1135+ "indexes": {
1136+ "tool_scope_id_idx": {
1137+ "name": "tool_scope_id_idx",
1138+ "columns": [
1139+ {
1140+ "expression": "scope_id",
1141+ "isExpression": false,
1142+ "asc": true,
1143+ "nulls": "last"
1144+ }
1145+ ],
1146+ "isUnique": false,
1147+ "concurrently": false,
1148+ "method": "btree",
1149+ "with": {}
1150+ },
1151+ "tool_source_id_idx": {
1152+ "name": "tool_source_id_idx",
1153+ "columns": [
1154+ {
1155+ "expression": "source_id",
1156+ "isExpression": false,
1157+ "asc": true,
1158+ "nulls": "last"
1159+ }
1160+ ],
1161+ "isUnique": false,
1162+ "concurrently": false,
1163+ "method": "btree",
1164+ "with": {}
1165+ },
1166+ "tool_plugin_id_idx": {
1167+ "name": "tool_plugin_id_idx",
1168+ "columns": [
1169+ {
1170+ "expression": "plugin_id",
1171+ "isExpression": false,
1172+ "asc": true,
1173+ "nulls": "last"
1174+ }
1175+ ],
1176+ "isUnique": false,
1177+ "concurrently": false,
1178+ "method": "btree",
1179+ "with": {}
1180+ }
1181+ },
1182+ "foreignKeys": {},
1183+ "compositePrimaryKeys": {
1184+ "tool_scope_id_id_pk": {
1185+ "name": "tool_scope_id_id_pk",
1186+ "columns": [
1187+ "scope_id",
1188+ "id"
1189+ ]
1190+ }
1191+ },
1192+ "uniqueConstraints": {},
1193+ "policies": {},
1194+ "checkConstraints": {},
1195+ "isRLSEnabled": false
1196+ },
1197+ "public.workos_vault_metadata": {
1198+ "name": "workos_vault_metadata",
1199+ "schema": "",
1200+ "columns": {
1201+ "id": {
1202+ "name": "id",
1203+ "type": "text",
1204+ "primaryKey": false,
1205+ "notNull": true
1206+ },
1207+ "scope_id": {
1208+ "name": "scope_id",
1209+ "type": "text",
1210+ "primaryKey": false,
1211+ "notNull": true
1212+ },
1213+ "name": {
1214+ "name": "name",
1215+ "type": "text",
1216+ "primaryKey": false,
1217+ "notNull": true
1218+ },
1219+ "purpose": {
1220+ "name": "purpose",
1221+ "type": "text",
1222+ "primaryKey": false,
1223+ "notNull": false
1224+ },
1225+ "created_at": {
1226+ "name": "created_at",
1227+ "type": "timestamp",
1228+ "primaryKey": false,
1229+ "notNull": true
1230+ }
1231+ },
1232+ "indexes": {
1233+ "workos_vault_metadata_scope_id_idx": {
1234+ "name": "workos_vault_metadata_scope_id_idx",
1235+ "columns": [
1236+ {
1237+ "expression": "scope_id",
1238+ "isExpression": false,
1239+ "asc": true,
1240+ "nulls": "last"
1241+ }
1242+ ],
1243+ "isUnique": false,
1244+ "concurrently": false,
1245+ "method": "btree",
1246+ "with": {}
1247+ }
1248+ },
1249+ "foreignKeys": {},
1250+ "compositePrimaryKeys": {
1251+ "workos_vault_metadata_scope_id_id_pk": {
1252+ "name": "workos_vault_metadata_scope_id_id_pk",
1253+ "columns": [
1254+ "scope_id",
1255+ "id"
1256+ ]
1257+ }
1258+ },
1259+ "uniqueConstraints": {},
1260+ "policies": {},
1261+ "checkConstraints": {},
1262+ "isRLSEnabled": false
1263+ }
1264+ },
1265+ "enums": {},
1266+ "schemas": {},
1267+ "sequences": {},
1268+ "roles": {},
1269+ "policies": {},
1270+ "views": {},
1271+ "_meta": {
1272+ "columns": {},
1273+ "schemas": {},
1274+ "tables": {}
1275+ }
1276+}
apps/cloud/drizzle/meta/_journal.json+7−0
@@ -15,6 +15,13 @@ · 14 unmodified lines
1515 "when": 1776678968180,
1616 "tag": "0001_harsh_meltdown",
1717 "breakpoints": true
18+ },
19+ {
20+ "idx": 2,
21+ "version": "7",
22+ "when": 1776709954401,
23+ "tag": "0002_fat_white_tiger",
24+ "breakpoints": true
1825 }
1926 ]
2027 }
apps/cloud/src/services/executor-schema.ts+2−2
@@ -1,4 +1,4 @@
1−import { pgTable, text, boolean, timestamp, integer, jsonb, index, primaryKey } from "drizzle-orm/pg-core";
1+import { pgTable, text, boolean, timestamp, bigint, jsonb, index, primaryKey } from "drizzle-orm/pg-core";
22
33 export const source = pgTable("source", {
44 id: text('id').notNull(),
@@ -126,7 +126,7 @@ export const mcp_oauth_session = pgTable("mcp_oauth_session", { · 121 unmodified lines
126126 id: text('id').notNull(),
127127 scope_id: text('scope_id').notNull(),
128128 session: jsonb('session').notNull(),
129− expires_at: integer('expires_at').notNull(),
129+ expires_at: bigint('expires_at', { mode: 'number' }).notNull(),
130130 created_at: timestamp('created_at').notNull()
131131 }, (table) => [
132132 primaryKey({ columns: [table.scope_id, table.id] }),
apps/cloud/src/services/mcp-oauth.node.test.ts+429−0
@@ -0,0 +1,429 @@
1+// ---------------------------------------------------------------------------
2+// Cloud API × MCP OAuth — real HTTP end-to-end
3+// ---------------------------------------------------------------------------
4+//
5+// Drives the ProtectedCloudApi through the node-pool harness against a
6+// real in-process OAuth + MCP server (Node `http.createServer` bound to
7+// a random port). Every layer between the test and the plugin is real:
8+//
9+// test → HttpApiClient → in-process webHandler → ProtectedCloudApi
10+// → McpHandlers → mcpPlugin.startOAuth / completeOAuth
11+// → MCP SDK `auth()`
12+// → fake OAuth server (DCR, /authorize → 302, /token, AS metadata,
13+// protected resource metadata)
14+//
15+// Two scenarios:
16+//
17+// 1. Single user: startOAuth → follow redirect → completeOAuth. Asserts
18+// tokens landed in the invoker's secret store under the stable
19+// `mcp_<ns>_*` ids and that source-level OAuth state (clientInfo,
20+// AS URL) is echoed back on the response.
21+//
22+// 2. Two users, same source: second user's startOAuth passes the
23+// clientInformation the first user's completeOAuth returned. The
24+// DCR endpoint counter on the fake server must NOT tick — proof
25+// the DCR-hoist code path re-uses the existing client. Then both
26+// users' tokens must resolve independently at their own scope
27+// despite sharing the same secret id.
28+// ---------------------------------------------------------------------------
29+
30+import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
31+import { createServer, type Server } from "node:http";
32+import type { AddressInfo } from "node:net";
33+import { createHash, randomBytes } from "node:crypto";
34+
35+import { Effect } from "effect";
36+import { ScopeId, SecretId } from "@executor/sdk";
37+
38+import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
39+
40+// ---------------------------------------------------------------------------
41+// Fake OAuth + MCP server
42+// ---------------------------------------------------------------------------
43+
44+interface FakeServer {
45+ readonly url: string;
46+ readonly registrations: () => number;
47+ readonly tokens: () => number;
48+ readonly close: () => Promise<void>;
49+}
50+
51+const startFakeServer = async (): Promise<FakeServer> => {
52+ const clients = new Map<string, { redirect_uris: readonly string[] }>();
53+ const codes = new Map<
54+ string,
55+ { readonly clientId: string; readonly codeChallenge: string }
56+ >();
57+ const accessTokens = new Map<string, { readonly refresh: string }>();
58+ const refreshTokens = new Map<string, string>();
59+ let seq = 0;
60+ const next = (p: string) =>
61+ `${p}_${++seq}_${randomBytes(6).toString("hex")}`;
62+ let registrations = 0;
63+ let tokenCalls = 0;
64+
65+ const readBody = (req: import("node:http").IncomingMessage): Promise<string> =>
66+ new Promise((resolve, reject) => {
67+ let buf = "";
68+ req.on("data", (chunk) => (buf += chunk));
69+ req.on("end", () => resolve(buf));
70+ req.on("error", reject);
71+ });
72+
73+ const server: Server = createServer(async (req, res) => {
74+ const url = new URL(req.url!, `http://${req.headers.host}`);
75+ const send = (
76+ status: number,
77+ body: unknown,
78+ headers: Record<string, string> = {},
79+ ) => {
80+ const payload =
81+ typeof body === "string" ? body : JSON.stringify(body);
82+ res.writeHead(status, {
83+ "content-type":
84+ typeof body === "string" ? "text/plain" : "application/json",
85+ ...headers,
86+ });
87+ res.end(payload);
88+ };
89+
90+ try {
91+ if (url.pathname === "/.well-known/oauth-protected-resource") {
92+ const origin = `http://${req.headers.host}`;
93+ return send(200, {
94+ resource: origin,
95+ authorization_servers: [origin],
96+ bearer_methods_supported: ["header"],
97+ });
98+ }
99+
100+ if (url.pathname === "/.well-known/oauth-authorization-server") {
101+ const issuer = `http://${req.headers.host}`;
102+ return send(200, {
103+ issuer,
104+ authorization_endpoint: `${issuer}/authorize`,
105+ token_endpoint: `${issuer}/token`,
106+ registration_endpoint: `${issuer}/register`,
107+ response_types_supported: ["code"],
108+ grant_types_supported: ["authorization_code", "refresh_token"],
109+ code_challenge_methods_supported: ["S256"],
110+ token_endpoint_auth_methods_supported: ["none"],
111+ });
112+ }
113+
114+ if (url.pathname === "/register" && req.method === "POST") {
115+ const body = await readBody(req);
116+ const parsed = JSON.parse(body) as {
117+ readonly redirect_uris?: readonly string[];
118+ readonly grant_types?: readonly string[];
119+ readonly response_types?: readonly string[];
120+ };
121+ const clientId = next("client");
122+ clients.set(clientId, { redirect_uris: parsed.redirect_uris ?? [] });
123+ registrations += 1;
124+ return send(201, {
125+ client_id: clientId,
126+ client_id_issued_at: Math.floor(Date.now() / 1000),
127+ redirect_uris: parsed.redirect_uris ?? [],
128+ grant_types: parsed.grant_types ?? [
129+ "authorization_code",
130+ "refresh_token",
131+ ],
132+ response_types: parsed.response_types ?? ["code"],
133+ token_endpoint_auth_method: "none",
134+ });
135+ }
136+
137+ if (url.pathname === "/authorize" && req.method === "GET") {
138+ const clientId = url.searchParams.get("client_id") ?? "";
139+ const redirectUri = url.searchParams.get("redirect_uri") ?? "";
140+ const state = url.searchParams.get("state") ?? "";
141+ const codeChallenge = url.searchParams.get("code_challenge") ?? "";
142+ const method = url.searchParams.get("code_challenge_method") ?? "";
143+ if (!clients.has(clientId)) {
144+ return send(400, { error: "unknown_client" });
145+ }
146+ if (method !== "S256" || !codeChallenge) {
147+ return send(400, { error: "invalid_request" });
148+ }
149+ const code = next("code");
150+ codes.set(code, { clientId, codeChallenge });
151+ const destination = new URL(redirectUri);
152+ destination.searchParams.set("code", code);
153+ if (state) destination.searchParams.set("state", state);
154+ return send(302, "", { location: destination.toString() });
155+ }
156+
157+ if (url.pathname === "/token" && req.method === "POST") {
158+ tokenCalls += 1;
159+ const body = await readBody(req);
160+ const params = new URLSearchParams(body);
161+ const grant = params.get("grant_type");
162+
163+ if (grant === "authorization_code") {
164+ const code = params.get("code") ?? "";
165+ const verifier = params.get("code_verifier") ?? "";
166+ const record = codes.get(code);
167+ if (!record) return send(400, { error: "invalid_grant" });
168+ codes.delete(code);
169+ const computed = createHash("sha256")
170+ .update(verifier)
171+ .digest("base64url");
172+ if (computed !== record.codeChallenge) {
173+ return send(400, { error: "invalid_grant" });
174+ }
175+ const access = next("at");
176+ const refresh = next("rt");
177+ accessTokens.set(access, { refresh });
178+ refreshTokens.set(refresh, access);
179+ return send(200, {
180+ access_token: access,
181+ refresh_token: refresh,
182+ token_type: "Bearer",
183+ expires_in: 3600,
184+ });
185+ }
186+
187+ if (grant === "refresh_token") {
188+ const rt = params.get("refresh_token") ?? "";
189+ const prev = refreshTokens.get(rt);
190+ if (!prev) return send(400, { error: "invalid_grant" });
191+ refreshTokens.delete(rt);
192+ accessTokens.delete(prev);
193+ const access = next("at");
194+ const refresh = next("rt");
195+ accessTokens.set(access, { refresh });
196+ refreshTokens.set(refresh, access);
197+ return send(200, {
198+ access_token: access,
199+ refresh_token: refresh,
200+ token_type: "Bearer",
201+ expires_in: 3600,
202+ });
203+ }
204+
205+ return send(400, { error: "unsupported_grant_type" });
206+ }
207+
208+ // Default: 401 with WWW-Authenticate so any MCP probe on /mcp
209+ // gets the resource-metadata pointer the auth() discovery uses.
210+ if (url.pathname === "/mcp") {
211+ const origin = `http://${req.headers.host}`;
212+ return send(
213+ 401,
214+ { error: "unauthorized" },
215+ {
216+ "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
217+ },
218+ );
219+ }
220+
221+ send(404, { error: "not_found", path: url.pathname });
222+ } catch (e) {
223+ send(500, { error: "server_error", message: String(e) });
224+ }
225+ });
226+
227+ await new Promise<void>((resolve) =>
228+ server.listen(0, "127.0.0.1", () => resolve()),
229+ );
230+ const address = server.address() as AddressInfo;
231+
232+ return {
233+ url: `http://127.0.0.1:${address.port}`,
234+ registrations: () => registrations,
235+ tokens: () => tokenCalls,
236+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
237+ };
238+};
239+
240+// ---------------------------------------------------------------------------
241+// Helpers
242+// ---------------------------------------------------------------------------
243+
244+// Browser popup equivalent: GET the authorization URL, pull the code +
245+// state out of the 302 Location.
246+const followAuthorize = async (
247+ authorizationUrl: string,
248+): Promise<{ code: string; state: string }> => {
249+ const response = await fetch(authorizationUrl, { redirect: "manual" });
250+ expect(response.status).toBe(302);
251+ const location = response.headers.get("location");
252+ if (!location) throw new Error("no location header on authorize redirect");
253+ const dest = new URL(location);
254+ const code = dest.searchParams.get("code");
255+ const state = dest.searchParams.get("state");
256+ if (!code || !state) throw new Error(`redirect missing code/state: ${location}`);
257+ return { code, state };
258+};
259+
260+// ---------------------------------------------------------------------------
261+// Lifecycle
262+// ---------------------------------------------------------------------------
263+
264+let fake: FakeServer;
265+beforeAll(async () => {
266+ fake = await startFakeServer();
267+});
268+afterAll(async () => {
269+ await fake.close();
270+});
271+
272+// ---------------------------------------------------------------------------
273+// Tests
274+// ---------------------------------------------------------------------------
275+
276+describe("mcp oauth end-to-end (node pool, real OAuth + MCP server)", () => {
277+ it.effect(
278+ "startOAuth → authorize → completeOAuth writes tokens at the invoker scope",
279+ () =>
280+ Effect.gen(function* () {
281+ const orgId = `org_${crypto.randomUUID()}`;
282+ const userId = `user_${crypto.randomUUID()}`;
283+ const userScope = ScopeId.make(testUserOrgScopeId(userId, orgId));
284+ const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`;
285+ const accessTokenSecretId = `mcp_${namespace}_access_token`;
286+ const refreshTokenSecretId = `mcp_${namespace}_refresh_token`;
287+ const redirectUrl = "http://test.local/api/mcp/oauth/callback";
288+
289+ const started = yield* asUser(userId, orgId, (client) =>
290+ client.mcp.startOAuth({
291+ path: { scopeId: userScope },
292+ payload: {
293+ endpoint: `${fake.url}/mcp`,
294+ redirectUrl,
295+ accessTokenSecretId,
296+ refreshTokenSecretId,
297+ },
298+ }),
299+ );
300+ expect(started.sessionId).toMatch(/^mcp_oauth_/);
301+
302+ const { code, state } = yield* Effect.promise(() =>
303+ followAuthorize(started.authorizationUrl),
304+ );
305+ expect(state).toBe(started.sessionId);
306+
307+ const completed = yield* asUser(userId, orgId, (client) =>
308+ client.mcp.completeOAuth({
309+ path: { scopeId: userScope },
310+ payload: { state, code },
311+ }),
312+ );
313+ expect(completed.accessTokenSecretId).toBe(accessTokenSecretId);
314+ expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId);
315+ expect(completed.tokenType).toBe("Bearer");
316+ // DCR hoist: the source-level state the UI will persist on the
317+ // source's auth config, so the next user skips DCR entirely.
318+ expect(completed.clientInformation).not.toBeNull();
319+ expect(completed.authorizationServerUrl).not.toBeNull();
320+
321+ // Tokens landed at the user scope via the stable ids.
322+ const access = yield* asUser(userId, orgId, (client) =>
323+ client.secrets.resolve({
324+ path: {
325+ scopeId: userScope,
326+ secretId: SecretId.make(accessTokenSecretId),
327+ },
328+ }),
329+ );
330+ expect(access.value).toMatch(/^at_/);
331+
332+ const refresh = yield* asUser(userId, orgId, (client) =>
333+ client.secrets.resolve({
334+ path: {
335+ scopeId: userScope,
336+ secretId: SecretId.make(refreshTokenSecretId),
337+ },
338+ }),
339+ );
340+ expect(refresh.value).toMatch(/^rt_/);
341+ }),
342+ 30_000,
343+ );
344+
345+ it.effect(
346+ "second user on same source re-uses DCR client: registration endpoint is not re-hit",
347+ () =>
348+ Effect.gen(function* () {
349+ const orgId = `org_${crypto.randomUUID()}`;
350+ const userA = `user_${crypto.randomUUID()}`;
351+ const userB = `user_${crypto.randomUUID()}`;
352+ const scopeA = ScopeId.make(testUserOrgScopeId(userA, orgId));
353+ const scopeB = ScopeId.make(testUserOrgScopeId(userB, orgId));
354+ const namespace = `ns_${crypto.randomUUID().slice(0, 8)}`;
355+ const accessTokenSecretId = `mcp_${namespace}_access_token`;
356+ const refreshTokenSecretId = `mcp_${namespace}_refresh_token`;
357+ const endpoint = `${fake.url}/mcp`;
358+ const redirectUrl = "http://test.local/api/mcp/oauth/callback";
359+
360+ const regsBefore = fake.registrations();
361+
362+ // --- User A: full OAuth round-trip, fresh DCR. ---
363+ const startedA = yield* asUser(userA, orgId, (client) =>
364+ client.mcp.startOAuth({
365+ path: { scopeId: scopeA },
366+ payload: {
367+ endpoint,
368+ redirectUrl,
369+ accessTokenSecretId,
370+ refreshTokenSecretId,
371+ },
372+ }),
373+ );
374+ const redirA = yield* Effect.promise(() =>
375+ followAuthorize(startedA.authorizationUrl),
376+ );
377+ const completedA = yield* asUser(userA, orgId, (client) =>
378+ client.mcp.completeOAuth({
379+ path: { scopeId: scopeA },
380+ payload: { state: redirA.state, code: redirA.code },
381+ }),
382+ );
383+ expect(completedA.clientInformation).not.toBeNull();
384+ expect(fake.registrations()).toBe(regsBefore + 1);
385+
386+ // --- User B: re-uses A's client info; DCR must NOT fire again. ---
387+ const startedB = yield* asUser(userB, orgId, (client) =>
388+ client.mcp.startOAuth({
389+ path: { scopeId: scopeB },
390+ payload: {
391+ endpoint,
392+ redirectUrl,
393+ accessTokenSecretId,
394+ refreshTokenSecretId,
395+ clientInformation: completedA.clientInformation,
396+ authorizationServerUrl: completedA.authorizationServerUrl,
397+ resourceMetadataUrl: completedA.resourceMetadataUrl,
398+ },
399+ }),
400+ );
401+ const redirB = yield* Effect.promise(() =>
402+ followAuthorize(startedB.authorizationUrl),
403+ );
404+ yield* asUser(userB, orgId, (client) =>
405+ client.mcp.completeOAuth({
406+ path: { scopeId: scopeB },
407+ payload: { state: redirB.state, code: redirB.code },
408+ }),
409+ );
410+ expect(fake.registrations()).toBe(regsBefore + 1);
411+
412+ // --- Per-user tokens despite the shared secret id. ---
413+ const tokenA = yield* asUser(userA, orgId, (client) =>
414+ client.secrets.resolve({
415+ path: { scopeId: scopeA, secretId: SecretId.make(accessTokenSecretId) },
416+ }),
417+ );
418+ const tokenB = yield* asUser(userB, orgId, (client) =>
419+ client.secrets.resolve({
420+ path: { scopeId: scopeB, secretId: SecretId.make(accessTokenSecretId) },
421+ }),
422+ );
423+ expect(tokenA.value).not.toBe(tokenB.value);
424+ expect(tokenA.value).toMatch(/^at_/);
425+ expect(tokenB.value).toMatch(/^at_/);
426+ }),
427+ 30_000,
428+ );
429+});
apps/local/src/server/mcp-oauth.test.ts+458−0
@@ -0,0 +1,458 @@
1+// ---------------------------------------------------------------------------
2+// Local app × MCP OAuth — real HTTP end-to-end
3+// ---------------------------------------------------------------------------
4+//
5+// Mirrors apps/cloud/src/services/mcp-oauth.node.test.ts but for the local
6+// (sqlite) server. Drives the real LocalApi (core + mcp groups) against a
7+// real in-process OAuth + MCP server. Every layer between the test and the
8+// plugin is real:
9+//
10+// test → HttpApiClient → in-process webHandler → LocalApi
11+// → McpHandlers → mcpPlugin.startOAuth / completeOAuth
12+// → MCP SDK `auth()`
13+// → fake OAuth server (DCR, /authorize → 302, /token, AS metadata,
14+// protected resource metadata)
15+//
16+// Single-scope: local has one scope per project (`${folder}-${hash}`) so
17+// the OAuth flow lands tokens at that scope and `secrets.resolve` reads
18+// them back through the same provider (file-secrets in a tmpdir).
19+// ---------------------------------------------------------------------------
20+
21+import { afterAll, beforeAll, describe, expect, it } from "vitest";
22+import { createServer, type Server } from "node:http";
23+import type { AddressInfo } from "node:net";
24+import { createHash, randomBytes } from "node:crypto";
25+import { mkdtempSync, rmSync } from "node:fs";
26+import { tmpdir } from "node:os";
27+import { join } from "node:path";
28+import { Database } from "bun:sqlite";
29+import { drizzle } from "drizzle-orm/bun-sqlite";
30+import { migrate } from "drizzle-orm/bun-sqlite/migrator";
31+
32+import {
33+ FetchHttpClient,
34+ HttpApi,
35+ HttpApiBuilder,
36+ HttpApiClient,
37+ HttpMiddleware,
38+ HttpRouter,
39+ HttpServer,
40+} from "@effect/platform";
41+import { Effect, Layer } from "effect";
42+
43+import {
44+ addGroup,
45+ InternalError,
46+ observabilityMiddleware,
47+} from "@executor/api";
48+import {
49+ CoreHandlers,
50+ ExecutionEngineService,
51+ ExecutorService,
52+} from "@executor/api/server";
53+import { createExecutionEngine } from "@executor/execution";
54+import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
55+import {
56+ Scope,
57+ ScopeId,
58+ SecretId,
59+ collectSchemas,
60+ createExecutor,
61+} from "@executor/sdk";
62+import {
63+ makeSqliteAdapter,
64+ makeSqliteBlobStore,
65+} from "@executor/storage-file";
66+import { fileSecretsPlugin } from "@executor/plugin-file-secrets";
67+import { mcpPlugin } from "@executor/plugin-mcp";
68+import {
69+ McpExtensionService,
70+ McpGroup,
71+ McpHandlers,
72+} from "@executor/plugin-mcp/api";
73+
74+import * as executorSchema from "./executor-schema";
75+import { ErrorCaptureLive } from "./observability";
76+
77+// Shape of the test API: core + mcp group, with InternalError surfaced at
78+// the top level so `observabilityMiddleware` can land its typed-error
79+// bridge on every endpoint.
80+const TestApi = addGroup(McpGroup).addError(InternalError);
81+type TestApiShape = typeof TestApi extends HttpApi.HttpApi<
82+ infer _Id,
83+ infer Groups,
84+ infer ApiError,
85+ infer _ApiR
86+>
87+ ? HttpApiClient.Client<Groups, ApiError, never>
88+ : never;
89+
90+// ---------------------------------------------------------------------------
91+// Fake OAuth + MCP server (mirrors the cloud test)
92+// ---------------------------------------------------------------------------
93+
94+interface FakeServer {
95+ readonly url: string;
96+ readonly close: () => Promise<void>;
97+}
98+
99+const startFakeServer = async (): Promise<FakeServer> => {
100+ const clients = new Map<string, { redirect_uris: readonly string[] }>();
101+ const codes = new Map<
102+ string,
103+ { readonly clientId: string; readonly codeChallenge: string }
104+ >();
105+ const accessTokens = new Map<string, { readonly refresh: string }>();
106+ const refreshTokens = new Map<string, string>();
107+ let seq = 0;
108+ const next = (p: string) =>
109+ `${p}_${++seq}_${randomBytes(6).toString("hex")}`;
110+
111+ const readBody = (req: import("node:http").IncomingMessage): Promise<string> =>
112+ new Promise((resolve, reject) => {
113+ let buf = "";
114+ req.on("data", (chunk) => (buf += chunk));
115+ req.on("end", () => resolve(buf));
116+ req.on("error", reject);
117+ });
118+
119+ const server: Server = createServer(async (req, res) => {
120+ const url = new URL(req.url!, `http://${req.headers.host}`);
121+ const send = (
122+ status: number,
123+ body: unknown,
124+ headers: Record<string, string> = {},
125+ ) => {
126+ const payload = typeof body === "string" ? body : JSON.stringify(body);
127+ res.writeHead(status, {
128+ "content-type":
129+ typeof body === "string" ? "text/plain" : "application/json",
130+ ...headers,
131+ });
132+ res.end(payload);
133+ };
134+
135+ try {
136+ if (url.pathname === "/.well-known/oauth-protected-resource") {
137+ const origin = `http://${req.headers.host}`;
138+ return send(200, {
139+ resource: origin,
140+ authorization_servers: [origin],
141+ bearer_methods_supported: ["header"],
142+ });
143+ }
144+
145+ if (url.pathname === "/.well-known/oauth-authorization-server") {
146+ const issuer = `http://${req.headers.host}`;
147+ return send(200, {
148+ issuer,
149+ authorization_endpoint: `${issuer}/authorize`,
150+ token_endpoint: `${issuer}/token`,
151+ registration_endpoint: `${issuer}/register`,
152+ response_types_supported: ["code"],
153+ grant_types_supported: ["authorization_code", "refresh_token"],
154+ code_challenge_methods_supported: ["S256"],
155+ token_endpoint_auth_methods_supported: ["none"],
156+ });
157+ }
158+
159+ if (url.pathname === "/register" && req.method === "POST") {
160+ const body = await readBody(req);
161+ const parsed = JSON.parse(body) as {
162+ readonly redirect_uris?: readonly string[];
163+ readonly grant_types?: readonly string[];
164+ readonly response_types?: readonly string[];
165+ };
166+ const clientId = next("client");
167+ clients.set(clientId, { redirect_uris: parsed.redirect_uris ?? [] });
168+ return send(201, {
169+ client_id: clientId,
170+ client_id_issued_at: Math.floor(Date.now() / 1000),
171+ redirect_uris: parsed.redirect_uris ?? [],
172+ grant_types: parsed.grant_types ?? [
173+ "authorization_code",
174+ "refresh_token",
175+ ],
176+ response_types: parsed.response_types ?? ["code"],
177+ token_endpoint_auth_method: "none",
178+ });
179+ }
180+
181+ if (url.pathname === "/authorize" && req.method === "GET") {
182+ const clientId = url.searchParams.get("client_id") ?? "";
183+ const redirectUri = url.searchParams.get("redirect_uri") ?? "";
184+ const state = url.searchParams.get("state") ?? "";
185+ const codeChallenge = url.searchParams.get("code_challenge") ?? "";
186+ const method = url.searchParams.get("code_challenge_method") ?? "";
187+ if (!clients.has(clientId)) {
188+ return send(400, { error: "unknown_client" });
189+ }
190+ if (method !== "S256" || !codeChallenge) {
191+ return send(400, { error: "invalid_request" });
192+ }
193+ const code = next("code");
194+ codes.set(code, { clientId, codeChallenge });
195+ const destination = new URL(redirectUri);
196+ destination.searchParams.set("code", code);
197+ if (state) destination.searchParams.set("state", state);
198+ return send(302, "", { location: destination.toString() });
199+ }
200+
201+ if (url.pathname === "/token" && req.method === "POST") {
202+ const body = await readBody(req);
203+ const params = new URLSearchParams(body);
204+ const grant = params.get("grant_type");
205+
206+ if (grant === "authorization_code") {
207+ const code = params.get("code") ?? "";
208+ const verifier = params.get("code_verifier") ?? "";
209+ const record = codes.get(code);
210+ if (!record) return send(400, { error: "invalid_grant" });
211+ codes.delete(code);
212+ const computed = createHash("sha256")
213+ .update(verifier)
214+ .digest("base64url");
215+ if (computed !== record.codeChallenge) {
216+ return send(400, { error: "invalid_grant" });
217+ }
218+ const access = next("at");
219+ const refresh = next("rt");
220+ accessTokens.set(access, { refresh });
221+ refreshTokens.set(refresh, access);
222+ return send(200, {
223+ access_token: access,
224+ refresh_token: refresh,
225+ token_type: "Bearer",
226+ expires_in: 3600,
227+ });
228+ }
229+
230+ return send(400, { error: "unsupported_grant_type" });
231+ }
232+
233+ if (url.pathname === "/mcp") {
234+ const origin = `http://${req.headers.host}`;
235+ return send(
236+ 401,
237+ { error: "unauthorized" },
238+ {
239+ "www-authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`,
240+ },
241+ );
242+ }
243+
244+ send(404, { error: "not_found", path: url.pathname });
245+ } catch (e) {
246+ send(500, { error: "server_error", message: String(e) });
247+ }
248+ });
249+
250+ await new Promise<void>((resolve) =>
251+ server.listen(0, "127.0.0.1", () => resolve()),
252+ );
253+ const address = server.address() as AddressInfo;
254+
255+ return {
256+ url: `http://127.0.0.1:${address.port}`,
257+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
258+ };
259+};
260+
261+// ---------------------------------------------------------------------------
262+// In-process local API harness — tmpdir sqlite + minimal plugin set.
263+// ---------------------------------------------------------------------------
264+
265+const TEST_BASE_URL = "http://local.test";
266+const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
267+
268+interface Harness {
269+ readonly fetch: typeof globalThis.fetch;
270+ readonly scopeId: string;
271+ readonly dispose: () => Promise<void>;
272+}
273+
274+const startHarness = async (tmpDir: string): Promise<Harness> => {
275+ const sqlite = new Database(join(tmpDir, "data.db"));
276+ sqlite.exec("PRAGMA journal_mode = WAL");
277+ const db = drizzle(sqlite, { schema: executorSchema });
278+ migrate(db, { migrationsFolder: MIGRATIONS_FOLDER });
279+
280+ const scopeId = `test-${randomBytes(4).toString("hex")}`;
281+ const plugins = [
282+ mcpPlugin({ dangerouslyAllowStdioMCP: false }),
283+ fileSecretsPlugin({ directory: tmpDir }),
284+ ] as const;
285+ const schema = collectSchemas(plugins);
286+ const adapter = makeSqliteAdapter({ db, schema });
287+ const blobs = makeSqliteBlobStore({ db });
288+
289+ const scope = new Scope({
290+ id: ScopeId.make(scopeId),
291+ name: "test",
292+ createdAt: new Date(),
293+ });
294+
295+ const executor = await Effect.runPromise(
296+ createExecutor({ scopes: [scope], adapter, blobs, plugins }),
297+ );
298+
299+ const engine = createExecutionEngine({
300+ executor,
301+ codeExecutor: makeQuickJsExecutor(),
302+ });
303+
304+ const TestObservability = observabilityMiddleware(TestApi);
305+
306+ const TestApiBase = HttpApiBuilder.api(TestApi).pipe(
307+ Layer.provide(CoreHandlers),
308+ Layer.provide(McpHandlers),
309+ Layer.provide(TestObservability),
310+ Layer.provide(ErrorCaptureLive),
311+ );
312+
313+ const pluginExtensions = Layer.succeed(McpExtensionService, executor.mcp);
314+
315+ const { handler: webHandler, dispose: disposeHandler } =
316+ HttpApiBuilder.toWebHandler(
317+ TestApiBase.pipe(
318+ Layer.provideMerge(pluginExtensions),
319+ Layer.provideMerge(Layer.succeed(ExecutorService, executor)),
320+ Layer.provideMerge(Layer.succeed(ExecutionEngineService, engine)),
321+ Layer.provideMerge(HttpServer.layerContext),
322+ Layer.provideMerge(HttpRouter.setRouterConfig({ maxParamLength: 1000 })),
323+ ),
324+ { middleware: HttpMiddleware.logger },
325+ );
326+
327+ return {
328+ fetch: ((input: RequestInfo | URL, init?: RequestInit) =>
329+ webHandler(
330+ input instanceof Request ? input : new Request(input, init),
331+ )) as typeof globalThis.fetch,
332+ scopeId,
333+ dispose: async () => {
334+ await disposeHandler().catch(() => undefined);
335+ await Effect.runPromise(executor.close()).catch(() => undefined);
336+ sqlite.close();
337+ },
338+ };
339+};
340+
341+// ---------------------------------------------------------------------------
342+// Lifecycle
343+// ---------------------------------------------------------------------------
344+
345+let fake: FakeServer;
346+let tmpDir: string;
347+let harness: Harness;
348+
349+beforeAll(async () => {
350+ fake = await startFakeServer();
351+ tmpDir = mkdtempSync(join(tmpdir(), "executor-local-mcp-"));
352+ harness = await startHarness(tmpDir);
353+});
354+
355+afterAll(async () => {
356+ await harness.dispose();
357+ rmSync(tmpDir, { recursive: true, force: true });
358+ await fake.close();
359+});
360+
361+// ---------------------------------------------------------------------------
362+// Helpers
363+// ---------------------------------------------------------------------------
364+
365+const followAuthorize = async (
366+ authorizationUrl: string,
367+): Promise<{ code: string; state: string }> => {
368+ const response = await fetch(authorizationUrl, { redirect: "manual" });
369+ expect(response.status).toBe(302);
370+ const location = response.headers.get("location");
371+ if (!location) throw new Error("no location header on authorize redirect");
372+ const dest = new URL(location);
373+ const code = dest.searchParams.get("code");
374+ const state = dest.searchParams.get("state");
375+ if (!code || !state)
376+ throw new Error(`redirect missing code/state: ${location}`);
377+ return { code, state };
378+};
379+
380+// ---------------------------------------------------------------------------
381+// Test
382+// ---------------------------------------------------------------------------
383+
384+describe("local mcp oauth (real OAuth + MCP server)", () => {
385+ it("startOAuth → authorize → completeOAuth writes tokens at the scope", async () => {
386+ const clientLayer = FetchHttpClient.layer.pipe(
387+ Layer.provide(Layer.succeed(FetchHttpClient.Fetch, harness.fetch)),
388+ );
389+
390+ const namespace = `ns_${randomBytes(4).toString("hex")}`;
391+ const accessTokenSecretId = `mcp_${namespace}_access_token`;
392+ const refreshTokenSecretId = `mcp_${namespace}_refresh_token`;
393+ const redirectUrl = "http://local.test/api/mcp/oauth/callback";
394+ const scopeId = ScopeId.make(harness.scopeId);
395+
396+ const run = <A, E>(
397+ body: (client: TestApiShape) => Effect.Effect<A, E>,
398+ ): Effect.Effect<A, E> =>
399+ Effect.gen(function* () {
400+ const client = yield* HttpApiClient.make(TestApi, {
401+ baseUrl: TEST_BASE_URL,
402+ });
403+ return yield* body(client);
404+ }).pipe(Effect.provide(clientLayer)) as Effect.Effect<A, E>;
405+
406+ const started = await Effect.runPromise(
407+ run((client) =>
408+ client.mcp.startOAuth({
409+ path: { scopeId },
410+ payload: {
411+ endpoint: `${fake.url}/mcp`,
412+ redirectUrl,
413+ accessTokenSecretId,
414+ refreshTokenSecretId,
415+ },
416+ }),
417+ ),
418+ );
419+ expect(started.sessionId).toMatch(/^mcp_oauth_/);
420+
421+ const { code, state } = await followAuthorize(started.authorizationUrl);
422+ expect(state).toBe(started.sessionId);
423+
424+ const completed = await Effect.runPromise(
425+ run((client) =>
426+ client.mcp.completeOAuth({
427+ path: { scopeId },
428+ payload: { state, code },
429+ }),
430+ ),
431+ );
432+ expect(completed.accessTokenSecretId).toBe(accessTokenSecretId);
433+ expect(completed.refreshTokenSecretId).toBe(refreshTokenSecretId);
434+ expect(completed.tokenType).toBe("Bearer");
435+ expect(completed.clientInformation).not.toBeNull();
436+ expect(completed.authorizationServerUrl).not.toBeNull();
437+
438+ // Tokens landed at the (single) local scope and resolve back through
439+ // the same provider that the plugin wrote them to.
440+ const access = await Effect.runPromise(
441+ run((client) =>
442+ client.secrets.resolve({
443+ path: { scopeId, secretId: SecretId.make(accessTokenSecretId) },
444+ }),
445+ ),
446+ );
447+ expect(access.value).toMatch(/^at_/);
448+
449+ const refresh = await Effect.runPromise(
450+ run((client) =>
451+ client.secrets.resolve({
452+ path: { scopeId, secretId: SecretId.make(refreshTokenSecretId) },
453+ }),
454+ ),
455+ );
456+ expect(refresh.value).toMatch(/^rt_/);
457+ }, 30_000);
458+});
packages/core/sdk/src/executor.test.ts+1−2
@@ -1380,8 +1380,7 @@ describe("cross-scope read precedence + remove isolation (SDK)", () => { · 1379 unmodified lines
13801380 "sources.list dedupes by id, keeping the innermost row",
13811381 () =>
13821382 Effect.gen(function* () {
1383− const { execOuter, execInner, innerId } =
1384− yield* makeMarkerExecutors();
1383+ const { execOuter, execInner } = yield* makeMarkerExecutors();
13851384
13861385 yield* execOuter.marker.register("shared", "outer-name");
13871386 yield* execInner.marker.register("shared", "inner-name");
packages/plugins/mcp/src/api/group.ts+16−0
@@ -24,6 +24,8 @@ const namespaceParam = HttpApiSchema.param("namespace", Schema.String); · 23 unmodified lines
2424 // Auth payload (only for remote)
2525 // ---------------------------------------------------------------------------
2626
27+const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
28+
2729 const AuthPayload = Schema.Union(
2830 Schema.Struct({ kind: Schema.Literal("none") }),
2931 Schema.Struct({
@@ -39,6 +41,9 @@ const AuthPayload = Schema.Union( · 9 unmodified lines
3941 tokenType: Schema.optional(Schema.String),
4042 expiresAt: Schema.NullOr(Schema.Number),
4143 scope: Schema.NullOr(Schema.String),
44+ clientInformation: Schema.optional(Schema.NullOr(JsonObject)),
45+ authorizationServerUrl: Schema.optional(Schema.NullOr(Schema.String)),
46+ resourceMetadataUrl: Schema.optional(Schema.NullOr(Schema.String)),
4247 }),
4348 );
4449
@@ -108,6 +113,14 @@ const StartOAuthPayload = Schema.Struct({ · 63 unmodified lines
108113 endpoint: Schema.String,
109114 redirectUrl: Schema.String,
110115 queryParams: Schema.optional(Schema.NullOr(StringMap)),
116+ accessTokenSecretId: Schema.String,
117+ refreshTokenSecretId: Schema.optional(Schema.NullOr(Schema.String)),
118+ /** Source-level OAuth state captured by a previous user's flow. When
119+ * passed, DCR is skipped — the same client_id is re-used so the
120+ * source's auth config stays stable across users. */
121+ clientInformation: Schema.optional(Schema.NullOr(JsonObject)),
122+ authorizationServerUrl: Schema.optional(Schema.NullOr(Schema.String)),
123+ resourceMetadataUrl: Schema.optional(Schema.NullOr(Schema.String)),
111124 });
112125
113126 const CompleteOAuthPayload = Schema.Struct({
@@ -153,6 +166,9 @@ const CompleteOAuthResponse = Schema.Struct({ · 39 unmodified lines
153166 tokenType: Schema.String,
154167 expiresAt: Schema.NullOr(Schema.Number),
155168 scope: Schema.NullOr(Schema.String),
169+ clientInformation: Schema.NullOr(JsonObject),
170+ authorizationServerUrl: Schema.NullOr(Schema.String),
171+ resourceMetadataUrl: Schema.NullOr(Schema.String),
156172 });
157173
158174 // ---------------------------------------------------------------------------
packages/plugins/mcp/src/api/handlers.ts+5−0
@@ -204,6 +204,11 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 203 unmodified lines
204204 endpoint: payload.endpoint,
205205 redirectUrl: payload.redirectUrl,
206206 queryParams: payload.queryParams,
207+ accessTokenSecretId: payload.accessTokenSecretId,
208+ refreshTokenSecretId: payload.refreshTokenSecretId,
209+ clientInformation: payload.clientInformation,
210+ authorizationServerUrl: payload.authorizationServerUrl,
211+ resourceMetadataUrl: payload.resourceMetadataUrl,
207212 });
208213 })),
209214 )
packages/plugins/mcp/src/react/AddMcpSource.tsx+40−2
@@ -49,6 +49,22 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 48 unmodified lines
4949 return mcpPresets.find((p) => p.id === id);
5050 }
5151
52+// Stable secret ids for an MCP source's OAuth access/refresh tokens.
53+// The same ids are passed into `startOAuth` AND stored on the source's
54+// auth config, so at invoke time the per-user scope (which actually
55+// holds the value) resolves via shadowing over the source-level id.
56+// Keyed by namespace so reconnecting overwrites the previous token
57+// rather than leaking orphaned secret rows.
58+const mcpOAuthTokenSecretIds = (
59+ namespaceSlug: string,
60+): { readonly accessTokenSecretId: string; readonly refreshTokenSecretId: string } => {
61+ const prefix = `mcp_${namespaceSlug}`;
62+ return {
63+ accessTokenSecretId: `${prefix}_access_token`,
64+ refreshTokenSecretId: `${prefix}_refresh_token`,
65+ };
66+};
67+
5268 // ---------------------------------------------------------------------------
5369 // State machine (remote flow)
5470 // ---------------------------------------------------------------------------
@@ -59,6 +75,12 @@ type OAuthTokens = { · 4 unmodified lines
5975 tokenType: string;
6076 expiresAt: number | null;
6177 scope: string | null;
78+ /** Source-level OAuth state captured during the flow. Persisted on
79+ * the source's auth config so refreshes + future user OAuth flows
80+ * re-use the same DCR client + skip discovery. */
81+ clientInformation: Record<string, unknown> | null;
82+ authorizationServerUrl: string | null;
83+ resourceMetadataUrl: string | null;
6284 };
6385
6486 type ProbeResult = {
@@ -419,9 +441,19 @@ export default function AddMcpSource(props: { · 354 unmodified lines
419441 dispatch({ type: "oauth-start" });
420442 try {
421443 const redirectUrl = `${window.location.origin}/api/mcp/oauth/callback`;
444+ const namespaceSlug =
445+ slugifyNamespace(remoteIdentity.namespace) ||
446+ slugifyNamespace(probe?.namespace ?? "") ||
447+ "mcp";
448+ const tokenIds = mcpOAuthTokenSecretIds(namespaceSlug);
422449 const result = await doStartOAuth({
423450 path: { scopeId },
424− payload: { endpoint: state.url.trim(), redirectUrl },
451+ payload: {
452+ endpoint: state.url.trim(),
453+ redirectUrl,
454+ accessTokenSecretId: tokenIds.accessTokenSecretId,
455+ refreshTokenSecretId: tokenIds.refreshTokenSecretId,
456+ },
425457 });
426458 dispatch({ type: "oauth-waiting", sessionId: result.sessionId });
427459 oauthCleanup.current = openOAuthPopup(
@@ -437,6 +469,9 @@ export default function AddMcpSource(props: { · 9 unmodified lines
437469 tokenType: data.tokenType,
438470 expiresAt: data.expiresAt,
439471 scope: data.scope,
472+ clientInformation: data.clientInformation ?? null,
473+ authorizationServerUrl: data.authorizationServerUrl ?? null,
474+ resourceMetadataUrl: data.resourceMetadataUrl ?? null,
440475 },
441476 });
442477 } else {
@@ -454,7 +489,7 @@ export default function AddMcpSource(props: { · 11 unmodified lines
454489 error: e instanceof Error ? e.message : "Failed to start OAuth",
455490 });
456491 }
457− }, [state.url, scopeId, doStartOAuth]);
492+ }, [state.url, scopeId, doStartOAuth, remoteIdentity.namespace, probe?.namespace]);
458493
459494 const handleCancelOAuth = useCallback(() => {
460495 oauthCleanup.current?.();
@@ -482,6 +517,9 @@ export default function AddMcpSource(props: { · 21 unmodified lines
482517 tokenType: tokens.tokenType,
483518 expiresAt: tokens.expiresAt,
484519 scope: tokens.scope,
520+ clientInformation: tokens.clientInformation,
521+ authorizationServerUrl: tokens.authorizationServerUrl,
522+ resourceMetadataUrl: tokens.resourceMetadataUrl,
485523 }
486524 : { kind: "none" as const };
487525 const headers = Object.fromEntries(
packages/plugins/mcp/src/sdk/binding-store.ts+1−1
@@ -42,7 +42,7 @@ export const mcpSchema = defineSchema({ · 41 unmodified lines
4242 id: { type: "string", required: true },
4343 scope_id: { type: "string", required: true, index: true },
4444 session: { type: "json", required: true },
45− expires_at: { type: "number", required: true },
45+ expires_at: { type: "number", required: true, bigint: true },
4646 created_at: { type: "date", required: true },
4747 },
4848 },
packages/plugins/mcp/src/sdk/invoke.ts+16−3
@@ -34,10 +34,19 @@ const asRecord = (value: unknown): Record<string, unknown> => · 33 unmodified lines
3434 ? (value as Record<string, unknown>)
3535 : {};
3636
37−const connectionCacheKey = (sd: McpStoredSourceData): string =>
37+const connectionCacheKey = (
38+ sd: McpStoredSourceData,
39+ invokerScope: string,
40+): string =>
3841 sd.transport === "stdio"
3942 ? `stdio:${sd.command}`
40− : `remote:${sd.endpoint}`;
43+ : // Remote sources may resolve per-user secrets (OAuth tokens, header
44+ // auth) via scope shadowing, so two users invoking the same source
45+ // get different Authorization headers. The connection caches that
46+ // header in transport state, so the cache key must include the
47+ // invoking scope — otherwise user B re-uses user A's connection
48+ // (and user A's tokens).
49+ `remote:${invokerScope}:${sd.endpoint}`;
4150
4251 // ---------------------------------------------------------------------------
4352 // Elicitation bridge — decode incoming MCP ElicitRequest, route through
@@ -152,6 +161,10 @@ export interface InvokeMcpToolInput { · 108 unmodified lines
152161 readonly toolName: string;
153162 readonly args: unknown;
154163 readonly sourceData: McpStoredSourceData;
164+ /** Innermost executor scope id at invoke time. Mixed into the
165+ * connection cache key so per-user OAuth/secret resolution doesn't
166+ * collapse multiple users onto one shared connection. */
167+ readonly invokerScope: string;
155168 readonly resolveConnector: () => Effect.Effect<McpConnection, McpConnectionError>;
156169 readonly connectionCache: ScopedCache.ScopedCache<
157170 string,
@@ -173,7 +186,7 @@ export const invokeMcpTool = ( · 15 unmodified lines
173186 ? "stdio"
174187 : (input.sourceData.remoteTransport ?? "auto");
175188 return Effect.gen(function* () {
176− const cacheKey = connectionCacheKey(input.sourceData);
189+ const cacheKey = connectionCacheKey(input.sourceData, input.invokerScope);
177190 const args = asRecord(input.args);
178191
179192 // Register the connector for the cache lookup (side-channel pattern
packages/plugins/mcp/src/sdk/oauth.ts+29−2
@@ -37,6 +37,19 @@ export const McpOAuthSession = Schema.Struct({ · 36 unmodified lines
3737 endpoint: Schema.String,
3838 redirectUrl: Schema.String,
3939 codeVerifier: Schema.String,
40+ /**
41+ * Executor scope id where the minted access/refresh tokens will land.
42+ * Pinned at `startOAuth` time so token writes target the same scope
43+ * regardless of who's currently invoking. For per-user OAuth this is
44+ * the innermost (`ctx.scopes[0]`) scope; for org-shared installs it
45+ * can be the org scope.
46+ */
47+ tokenScope: Schema.String,
48+ /** Stable secret ids the minted tokens are written to. Stored once on
49+ * the source's auth config so per-user scope shadowing resolves to
50+ * the calling user's tokens at invoke time. */
51+ accessTokenSecretId: Schema.String,
52+ refreshTokenSecretId: Schema.NullOr(Schema.String),
4053 });
4154 export type McpOAuthSession = typeof McpOAuthSession.Type;
4255
@@ -93,12 +106,26 @@ export const startMcpOAuthAuthorization = (input: { · 50 unmodified lines
93106 endpoint: string;
94107 redirectUrl: string;
95108 state: string;
109+ /** Pre-existing DCR client + discovery URLs for the source. When
110+ * passed, the SDK skips registration and re-uses these values. */
111+ clientInformation?: OAuthClientInformationMixed | null;
112+ authorizationServerUrl?: string | null;
113+ resourceMetadataUrl?: string | null;
96114 }): Effect.Effect<McpOAuthStartResult, McpOAuthError> =>
97115 Effect.gen(function* () {
98116 let authorizationUrl: URL | undefined;
99117 let codeVerifier: string | undefined;
100− let discoveryState: OAuthDiscoveryState | undefined;
101− let clientInformation: OAuthClientInformationMixed | undefined;
118+ let discoveryState: OAuthDiscoveryState | undefined =
119+ input.authorizationServerUrl || input.resourceMetadataUrl
120+ ? {
121+ authorizationServerUrl:
122+ input.authorizationServerUrl ??
123+ new URL("/", input.endpoint).toString(),
124+ resourceMetadataUrl: input.resourceMetadataUrl ?? undefined,
125+ }
126+ : undefined;
127+ let clientInformation: OAuthClientInformationMixed | undefined =
128+ input.clientInformation ?? undefined;
102129
103130 const provider: OAuthClientProvider = {
104131 get redirectUrl() {
packages/plugins/mcp/src/sdk/plugin.ts+195−50
@@ -1,10 +1,17 @@
11 import { Duration, Effect, Exit, Scope, ScopedCache } from "effect";
22
3−import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
4−import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
3+import type {
4+ OAuthClientProvider,
5+ OAuthDiscoveryState,
6+} from "@modelcontextprotocol/sdk/client/auth.js";
7+import type {
8+ OAuthClientInformationMixed,
9+ OAuthTokens,
10+} from "@modelcontextprotocol/sdk/shared/auth.js";
511
612 import {
713 definePlugin,
14+ ScopeId,
815 SecretId,
916 SetSecretInput,
1017 SourceDetectionResult,
@@ -89,6 +96,30 @@ export interface McpOAuthStartInput { · 78 unmodified lines
8996 readonly endpoint: string;
9097 readonly redirectUrl: string;
9198 readonly queryParams?: Record<string, string> | null;
99+ /**
100+ * Executor scope id where the minted access/refresh tokens will land.
101+ * Defaults to `ctx.scopes[0].id` (innermost) — for a per-user stack
102+ * `[user, org]` that pins tokens to the user scope so the source's
103+ * stored `accessTokenSecretId` resolves per-user via shadowing.
104+ */
105+ readonly tokenScope?: string;
106+ /**
107+ * Pre-decided secret ids for the minted tokens. Mint deterministically
108+ * (e.g. `mcp_${namespace}_access_token`) so the source's stored
109+ * OAuth2 auth carries the same id everyone reads, and `ctx.secrets.get`
110+ * resolves per-user via scope fall-through.
111+ */
112+ readonly accessTokenSecretId: string;
113+ readonly refreshTokenSecretId?: string | null;
114+ /**
115+ * Source-level OAuth state captured by a previous user's flow. Pass
116+ * the values stored on the source's auth config to skip Dynamic
117+ * Client Registration — the new user's flow re-uses the same
118+ * client_id and discovery results.
119+ */
120+ readonly clientInformation?: Record<string, unknown> | null;
121+ readonly authorizationServerUrl?: string | null;
122+ readonly resourceMetadataUrl?: string | null;
92123 }
93124
94125 export interface McpOAuthStartResponse {
@@ -108,6 +139,12 @@ export interface McpOAuthCompleteResponse { · 13 unmodified lines
108139 readonly tokenType: string;
109140 readonly expiresAt: number | null;
110141 readonly scope: string | null;
142+ /** DCR client + discovery URLs captured during the flow. The UI
143+ * stores them on the source's auth config so refreshes don't
144+ * re-register or re-discover. */
145+ readonly clientInformation: Record<string, unknown> | null;
146+ readonly authorizationServerUrl: string | null;
147+ readonly resourceMetadataUrl: string | null;
111148 }
112149
113150 export interface McpProbeResult {
@@ -168,41 +205,81 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 54 unmodified lines
168205 outputSchema: entry.outputSchema,
169206 });
170207
171−const makeOAuthProvider = (
172− accessToken: string,
173− tokenType: string,
174− refreshToken?: string,
175−): OAuthClientProvider => ({
176− get redirectUrl() {
177− return "http://localhost/oauth/callback";
178− },
179− get clientMetadata() {
180− return {
181− redirect_uris: ["http://localhost/oauth/callback"],
182− grant_types: ["authorization_code", "refresh_token"] as string[],
183− response_types: ["code"] as string[],
184− token_endpoint_auth_method: "none" as const,
185− client_name: "Executor",
186− };
187− },
188− clientInformation: () => undefined,
189− saveClientInformation: () => {},
190− tokens: async (): Promise<OAuthTokens> => ({
191− access_token: accessToken,
192− token_type: tokenType,
193− ...(refreshToken ? { refresh_token: refreshToken } : {}),
194− }),
195− saveTokens: async () => {},
196− redirectToAuthorization: async () => {
197− throw new Error("MCP OAuth re-authorization required");
198− },
199− saveCodeVerifier: () => {},
200− codeVerifier: () => {
201− throw new Error("No active PKCE verifier");
202− },
203− saveDiscoveryState: () => {},
204− discoveryState: () => undefined,
205−});
208+interface OAuthProviderInputs {
209+ readonly accessToken: string;
210+ readonly tokenType: string;
211+ readonly refreshToken?: string;
212+ readonly expiresAt?: number | null;
213+ readonly scope?: string | null;
214+ /** Source-level state — same for every user. */
215+ readonly clientInformation?: OAuthClientInformationMixed | null;
216+ readonly authorizationServerUrl?: string | null;
217+ readonly resourceMetadataUrl?: string | null;
218+ readonly endpoint: string;
219+ /**
220+ * Called when the SDK refreshes tokens (grant_type=refresh_token).
221+ * Persisting new tokens back to per-user secrets is what closes the
222+ * refresh loop — without it the next invocation reads stale values.
223+ */
224+ readonly onRefresh?: (tokens: OAuthTokens) => Promise<void> | void;
225+}
226+
227+const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => {
228+ let currentTokens: OAuthTokens = {
229+ access_token: inputs.accessToken,
230+ token_type: inputs.tokenType,
231+ ...(inputs.refreshToken ? { refresh_token: inputs.refreshToken } : {}),
232+ ...(inputs.expiresAt
233+ ? { expires_in: Math.max(0, Math.floor((inputs.expiresAt - Date.now()) / 1000)) }
234+ : {}),
235+ ...(inputs.scope ? { scope: inputs.scope } : {}),
236+ };
237+ let clientInformation: OAuthClientInformationMixed | undefined =
238+ inputs.clientInformation ?? undefined;
239+ let discoveryState: OAuthDiscoveryState | undefined =
240+ inputs.authorizationServerUrl || inputs.resourceMetadataUrl
241+ ? {
242+ authorizationServerUrl:
243+ inputs.authorizationServerUrl ?? new URL("/", inputs.endpoint).toString(),
244+ resourceMetadataUrl: inputs.resourceMetadataUrl ?? undefined,
245+ }
246+ : undefined;
247+
248+ return {
249+ get redirectUrl() {
250+ return "http://localhost/oauth/callback";
251+ },
252+ get clientMetadata() {
253+ return {
254+ redirect_uris: ["http://localhost/oauth/callback"],
255+ grant_types: ["authorization_code", "refresh_token"] as string[],
256+ response_types: ["code"] as string[],
257+ token_endpoint_auth_method: "none" as const,
258+ client_name: "Executor",
259+ };
260+ },
261+ clientInformation: () => clientInformation,
262+ saveClientInformation: (ci) => {
263+ clientInformation = ci;
264+ },
265+ tokens: () => currentTokens,
266+ saveTokens: async (t) => {
267+ currentTokens = t;
268+ if (inputs.onRefresh) await inputs.onRefresh(t);
269+ },
270+ redirectToAuthorization: async () => {
271+ throw new Error("MCP OAuth re-authorization required");
272+ },
273+ saveCodeVerifier: () => {},
274+ codeVerifier: () => {
275+ throw new Error("No active PKCE verifier");
276+ },
277+ saveDiscoveryState: (s) => {
278+ discoveryState = s;
279+ },
280+ discoveryState: () => discoveryState,
281+ };
282+};
206283
207284 const remoteConnectionError = (message: string) =>
208285 new McpConnectionError({ transport: "remote", message });
@@ -263,11 +340,58 @@ const resolveConnectorInput = ( · 54 unmodified lines
263340 const refreshToken = auth.refreshTokenSecretId
264341 ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined
265342 : undefined;
266− authProvider = makeOAuthProvider(
343+ // Capture the resolved owning scope of these secrets so refreshed
344+ // tokens land back at the same per-user scope. `ctx.secrets.get`
345+ // walks the executor scope stack innermost-first, so the existing
346+ // value lives at whichever scope shadowed the source-level id —
347+ // we mirror that with `ctx.scopes[0]!.id`, matching the scope
348+ // chosen at startOAuth time.
349+ const tokenScope = ScopeId.make(ctx.scopes[0]!.id as string);
350+ const accessSecretId = auth.accessTokenSecretId;
351+ const refreshSecretId = auth.refreshTokenSecretId;
352+ authProvider = makeOAuthProvider({
267353 accessToken,
268− auth.tokenType ?? "Bearer",
354+ tokenType: auth.tokenType ?? "Bearer",
269355 refreshToken,
270− );
356+ expiresAt: auth.expiresAt,
357+ scope: auth.scope,
358+ clientInformation: auth.clientInformation as
359+ | OAuthClientInformationMixed
360+ | null
361+ | undefined,
362+ authorizationServerUrl: auth.authorizationServerUrl,
363+ resourceMetadataUrl: auth.resourceMetadataUrl,
364+ endpoint: sd.endpoint,
365+ onRefresh: async (tokens) => {
366+ // Persist refreshed tokens back to the calling user's scope
367+ // so subsequent invocations see the new value rather than
368+ // re-refreshing on every request. Uses runPromise because
369+ // OAuthClientProvider.saveTokens is an async callback, not
370+ // an Effect.
371+ await Effect.runPromise(
372+ ctx.secrets.set(
373+ new SetSecretInput({
374+ id: SecretId.make(accessSecretId),
375+ scope: tokenScope,
376+ name: "MCP OAuth Access Token",
377+ value: tokens.access_token,
378+ }),
379+ ),
380+ );
381+ if (tokens.refresh_token && refreshSecretId) {
382+ await Effect.runPromise(
383+ ctx.secrets.set(
384+ new SetSecretInput({
385+ id: SecretId.make(refreshSecretId),
386+ scope: tokenScope,
387+ name: "MCP OAuth Refresh Token",
388+ value: tokens.refresh_token,
389+ }),
390+ ),
391+ );
392+ }
393+ },
394+ });
271395 }
272396
273397 return {
@@ -736,10 +860,17 @@ export const mcpPlugin = definePlugin( · 462 unmodified lines
736860 }
737861
738862 const sessionId = `mcp_oauth_${crypto.randomUUID()}`;
863+ const tokenScope = input.tokenScope ?? (ctx.scopes[0]!.id as string);
739864 const started = yield* startMcpOAuthAuthorization({
740865 endpoint: fullEndpoint,
741866 redirectUrl: input.redirectUrl,
742867 state: sessionId,
868+ clientInformation: input.clientInformation as
869+ | OAuthClientInformationMixed
870+ | null
871+ | undefined,
872+ authorizationServerUrl: input.authorizationServerUrl,
873+ resourceMetadataUrl: input.resourceMetadataUrl,
743874 }).pipe(
744875 Effect.mapError((e) =>
745876 mcpOAuthError(`OAuth start failed: ${e.message}`),
@@ -748,7 +879,7 @@ export const mcpPlugin = definePlugin( · 2 unmodified lines
748879 );
749880
750881 yield* ctx.storage
751− .putOAuthSession(sessionId, ctx.scopes[0]!.id as string, {
882+ .putOAuthSession(sessionId, tokenScope, {
752883 endpoint: fullEndpoint,
753884 redirectUrl: input.redirectUrl,
754885 codeVerifier: started.codeVerifier,
@@ -757,6 +888,9 @@ export const mcpPlugin = definePlugin( · 2 unmodified lines
757888 resourceMetadata: started.resourceMetadata,
758889 authorizationServerMetadata: started.authorizationServerMetadata,
759890 clientInformation: started.clientInformation,
891+ tokenScope,
892+ accessTokenSecretId: input.accessTokenSecretId,
893+ refreshTokenSecretId: input.refreshTokenSecretId ?? null,
760894 })
761895 .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session"));
762896
@@ -796,13 +930,14 @@ export const mcpPlugin = definePlugin( · 33 unmodified lines
796930 Effect.withSpan("mcp.plugin.oauth.exchange_code"),
797931 );
798932
799− // MCP OAuth tokens are user-specific, so they land at the
800− // innermost scope. On a single-scope executor that's the
801− // only scope; on a per-user stack `[user, org]` it's the
802− // user scope — so tokens shadow org-level fallbacks for
803− // the caller's own invocations.
804− const tokenScope = ctx.scopes[0]!.id;
805− const accessSecretId = `mcp-oauth-access-${input.state}`;
933+ // Token storage is fully driven by the session: scope and
934+ // secret ids were chosen at startOAuth time and pinned to
935+ // the row. That keeps shadowing deterministic — every
936+ // user's OAuth flow on the same source writes to the same
937+ // ids, so the source's stored OAuth2 auth resolves
938+ // per-user via scope fall-through.
939+ const tokenScope = ScopeId.make(session.tokenScope);
940+ const accessSecretId = session.accessTokenSecretId;
806941 yield* ctx.secrets
807942 .set(
808943 new SetSecretInput({
@@ -819,8 +954,8 @@ export const mcpPlugin = definePlugin( · 10 unmodified lines
819954 );
820955
821956 let refreshTokenSecretId: string | null = null;
822− if (exchanged.tokens.refresh_token) {
823− const refreshId = `mcp-oauth-refresh-${input.state}`;
957+ if (exchanged.tokens.refresh_token && session.refreshTokenSecretId) {
958+ const refreshId = session.refreshTokenSecretId;
824959 yield* ctx.secrets
825960 .set(
826961 new SetSecretInput({
@@ -853,6 +988,15 @@ export const mcpPlugin = definePlugin( · 26 unmodified lines
853988 tokenType: exchanged.tokens.token_type ?? "Bearer",
854989 expiresAt,
855990 scope: exchanged.tokens.scope ?? null,
991+ // Echo the source-level OAuth state captured during this
992+ // flow. The UI persists it on the source's auth config so
993+ // refreshes (and any future user's OAuth) re-use the same
994+ // DCR client + skip discovery.
995+ clientInformation: exchanged.clientInformation as
996+ | Record<string, unknown>
997+ | null,
998+ authorizationServerUrl: exchanged.authorizationServerUrl,
999+ resourceMetadataUrl: exchanged.resourceMetadataUrl,
8561000 };
8571001 }).pipe(Effect.withSpan("mcp.plugin.complete_oauth"));
8581002
@@ -946,6 +1090,7 @@ export const mcpPlugin = definePlugin( · 87 unmodified lines
9461090 toolName: entry.binding.toolName,
9471091 args,
9481092 sourceData: sd,
1093+ invokerScope: ctx.scopes[0]!.id as string,
9491094 resolveConnector: () =>
9501095 resolveConnectorInput(sd, ctx, allowStdio).pipe(
9511096 Effect.flatMap((ci) => createMcpConnector(ci)),
packages/plugins/mcp/src/sdk/types.ts+24−0
@@ -15,6 +15,9 @@ export type McpTransport = typeof McpTransport.Type; · 14 unmodified lines
1515 // Connection auth (only applies to remote sources)
1616 // ---------------------------------------------------------------------------
1717
18+/** JSON object loosely typed — used for opaque OAuth state we just round-trip. */
19+const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
20+
1821 export const McpConnectionAuth = Schema.Union(
1922 Schema.Struct({ kind: Schema.Literal("none") }),
2023 Schema.Struct({
@@ -30,6 +33,27 @@ export const McpConnectionAuth = Schema.Union( · 9 unmodified lines
3033 tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
3134 expiresAt: Schema.NullOr(Schema.Number),
3235 scope: Schema.NullOr(Schema.String),
36+ /**
37+ * Source-level OAuth state shared by every user. Lives on the
38+ * source row (org scope) so DCR runs once per source instead of
39+ * once per user, and so refresh can use the same client_id the
40+ * upstream auth server originally registered.
41+ *
42+ * - `clientInformation`: DCR-issued client credentials (client_id,
43+ * optional client_secret). When present, no DCR happens on
44+ * subsequent OAuth flows or refreshes.
45+ * - `authorizationServerUrl` / `resourceMetadataUrl`: discovery
46+ * URLs captured at first OAuth so refreshes don't re-discover.
47+ */
48+ clientInformation: Schema.optionalWith(Schema.NullOr(JsonObject), {
49+ default: () => null,
50+ }),
51+ authorizationServerUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
52+ default: () => null,
53+ }),
54+ resourceMetadataUrl: Schema.optionalWith(Schema.NullOr(Schema.String), {
55+ default: () => null,
56+ }),
3357 }),
3458 );
3559 export type McpConnectionAuth = typeof McpConnectionAuth.Type;