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
16 },16 },
17 ],17 ],
18 },18 },
19 "ignorePatterns": [".astro/", "**/routeTree.gen.ts"],19 "ignorePatterns": [".astro/", "**/routeTree.gen.ts", ".references/"],
20}20}
apps/cloud/drizzle/0002_fat_white_tiger.sql+1−0
@@ -0,0 +1 @@
1ALTER 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
15 "when": 1776678968180,15 "when": 1776678968180,
16 "tag": "0001_harsh_meltdown",16 "tag": "0001_harsh_meltdown",
17 "breakpoints": true17 "breakpoints": true
18 },
19 {
20 "idx": 2,
21 "version": "7",
22 "when": 1776709954401,
23 "tag": "0002_fat_white_tiger",
24 "breakpoints": true
18 }25 }
19 ]26 ]
20}27}
apps/cloud/src/services/executor-schema.ts+2−2
@@ -1,4 +1,4 @@
1import { pgTable, text, boolean, timestamp, integer, jsonb, index, primaryKey } from "drizzle-orm/pg-core";1import { pgTable, text, boolean, timestamp, bigint, jsonb, index, primaryKey } from "drizzle-orm/pg-core";
2 2
3export const source = pgTable("source", {3export const source = pgTable("source", {
4 id: text('id').notNull(),4 id: text('id').notNull(),
@@ -126,7 +126,7 @@ export const mcp_oauth_session = pgTable("mcp_oauth_session", { · 121 unmodified lines
126 id: text('id').notNull(),126 id: text('id').notNull(),
127 scope_id: text('scope_id').notNull(),127 scope_id: text('scope_id').notNull(),
128 session: jsonb('session').notNull(),128 session: jsonb('session').notNull(),
129 expires_at: integer('expires_at').notNull(),129 expires_at: bigint('expires_at', { mode: 'number' }).notNull(),
130 created_at: timestamp('created_at').notNull()130 created_at: timestamp('created_at').notNull()
131}, (table) => [131}, (table) => [
132 primaryKey({ columns: [table.scope_id, table.id] }),132 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
30import { afterAll, beforeAll, describe, expect, it } from "@effect/vitest";
31import { createServer, type Server } from "node:http";
32import type { AddressInfo } from "node:net";
33import { createHash, randomBytes } from "node:crypto";
34
35import { Effect } from "effect";
36import { ScopeId, SecretId } from "@executor/sdk";
37
38import { asUser, testUserOrgScopeId } from "./__test-harness__/api-harness";
39
40// ---------------------------------------------------------------------------
41// Fake OAuth + MCP server
42// ---------------------------------------------------------------------------
43
44interface FakeServer {
45 readonly url: string;
46 readonly registrations: () => number;
47 readonly tokens: () => number;
48 readonly close: () => Promise<void>;
49}
50
51const 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.
246const 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
264let fake: FakeServer;
265beforeAll(async () => {
266 fake = await startFakeServer();
267});
268afterAll(async () => {
269 await fake.close();
270});
271
272// ---------------------------------------------------------------------------
273// Tests
274// ---------------------------------------------------------------------------
275
276describe("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
21import { afterAll, beforeAll, describe, expect, it } from "vitest";
22import { createServer, type Server } from "node:http";
23import type { AddressInfo } from "node:net";
24import { createHash, randomBytes } from "node:crypto";
25import { mkdtempSync, rmSync } from "node:fs";
26import { tmpdir } from "node:os";
27import { join } from "node:path";
28import { Database } from "bun:sqlite";
29import { drizzle } from "drizzle-orm/bun-sqlite";
30import { migrate } from "drizzle-orm/bun-sqlite/migrator";
31
32import {
33 FetchHttpClient,
34 HttpApi,
35 HttpApiBuilder,
36 HttpApiClient,
37 HttpMiddleware,
38 HttpRouter,
39 HttpServer,
40} from "@effect/platform";
41import { Effect, Layer } from "effect";
42
43import {
44 addGroup,
45 InternalError,
46 observabilityMiddleware,
47} from "@executor/api";
48import {
49 CoreHandlers,
50 ExecutionEngineService,
51 ExecutorService,
52} from "@executor/api/server";
53import { createExecutionEngine } from "@executor/execution";
54import { makeQuickJsExecutor } from "@executor/runtime-quickjs";
55import {
56 Scope,
57 ScopeId,
58 SecretId,
59 collectSchemas,
60 createExecutor,
61} from "@executor/sdk";
62import {
63 makeSqliteAdapter,
64 makeSqliteBlobStore,
65} from "@executor/storage-file";
66import { fileSecretsPlugin } from "@executor/plugin-file-secrets";
67import { mcpPlugin } from "@executor/plugin-mcp";
68import {
69 McpExtensionService,
70 McpGroup,
71 McpHandlers,
72} from "@executor/plugin-mcp/api";
73
74import * as executorSchema from "./executor-schema";
75import { 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.
80const TestApi = addGroup(McpGroup).addError(InternalError);
81type 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
94interface FakeServer {
95 readonly url: string;
96 readonly close: () => Promise<void>;
97}
98
99const 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
265const TEST_BASE_URL = "http://local.test";
266const MIGRATIONS_FOLDER = join(import.meta.dirname, "../../drizzle");
267
268interface Harness {
269 readonly fetch: typeof globalThis.fetch;
270 readonly scopeId: string;
271 readonly dispose: () => Promise<void>;
272}
273
274const 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
345let fake: FakeServer;
346let tmpDir: string;
347let harness: Harness;
348
349beforeAll(async () => {
350 fake = await startFakeServer();
351 tmpDir = mkdtempSync(join(tmpdir(), "executor-local-mcp-"));
352 harness = await startHarness(tmpDir);
353});
354
355afterAll(async () => {
356 await harness.dispose();
357 rmSync(tmpDir, { recursive: true, force: true });
358 await fake.close();
359});
360
361// ---------------------------------------------------------------------------
362// Helpers
363// ---------------------------------------------------------------------------
364
365const 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
384describe("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
1380 "sources.list dedupes by id, keeping the innermost row",1380 "sources.list dedupes by id, keeping the innermost row",
1381 () =>1381 () =>
1382 Effect.gen(function* () {1382 Effect.gen(function* () {
1383 const { execOuter, execInner, innerId } =1383 const { execOuter, execInner } = yield* makeMarkerExecutors();
1384 yield* makeMarkerExecutors();
1385 1384
1386 yield* execOuter.marker.register("shared", "outer-name");1385 yield* execOuter.marker.register("shared", "outer-name");
1387 yield* execInner.marker.register("shared", "inner-name");1386 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
24// Auth payload (only for remote)24// Auth payload (only for remote)
25// ---------------------------------------------------------------------------25// ---------------------------------------------------------------------------
26 26
27const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
28
27const AuthPayload = Schema.Union(29const AuthPayload = Schema.Union(
28 Schema.Struct({ kind: Schema.Literal("none") }),30 Schema.Struct({ kind: Schema.Literal("none") }),
29 Schema.Struct({31 Schema.Struct({
@@ -39,6 +41,9 @@ const AuthPayload = Schema.Union( · 9 unmodified lines
39 tokenType: Schema.optional(Schema.String),41 tokenType: Schema.optional(Schema.String),
40 expiresAt: Schema.NullOr(Schema.Number),42 expiresAt: Schema.NullOr(Schema.Number),
41 scope: Schema.NullOr(Schema.String),43 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)),
42 }),47 }),
43);48);
44 49
@@ -108,6 +113,14 @@ const StartOAuthPayload = Schema.Struct({ · 63 unmodified lines
108 endpoint: Schema.String,113 endpoint: Schema.String,
109 redirectUrl: Schema.String,114 redirectUrl: Schema.String,
110 queryParams: Schema.optional(Schema.NullOr(StringMap)),115 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)),
111});124});
112 125
113const CompleteOAuthPayload = Schema.Struct({126const CompleteOAuthPayload = Schema.Struct({
@@ -153,6 +166,9 @@ const CompleteOAuthResponse = Schema.Struct({ · 39 unmodified lines
153 tokenType: Schema.String,166 tokenType: Schema.String,
154 expiresAt: Schema.NullOr(Schema.Number),167 expiresAt: Schema.NullOr(Schema.Number),
155 scope: Schema.NullOr(Schema.String),168 scope: Schema.NullOr(Schema.String),
169 clientInformation: Schema.NullOr(JsonObject),
170 authorizationServerUrl: Schema.NullOr(Schema.String),
171 resourceMetadataUrl: Schema.NullOr(Schema.String),
156});172});
157 173
158// ---------------------------------------------------------------------------174// ---------------------------------------------------------------------------
packages/plugins/mcp/src/api/handlers.ts+5−0
@@ -204,6 +204,11 @@ export const McpHandlers = HttpApiBuilder.group(ExecutorApiWithMcp, "mcp", (hand · 203 unmodified lines
204 endpoint: payload.endpoint,204 endpoint: payload.endpoint,
205 redirectUrl: payload.redirectUrl,205 redirectUrl: payload.redirectUrl,
206 queryParams: payload.queryParams,206 queryParams: payload.queryParams,
207 accessTokenSecretId: payload.accessTokenSecretId,
208 refreshTokenSecretId: payload.refreshTokenSecretId,
209 clientInformation: payload.clientInformation,
210 authorizationServerUrl: payload.authorizationServerUrl,
211 resourceMetadataUrl: payload.resourceMetadataUrl,
207 });212 });
208 })),213 })),
209 )214 )
packages/plugins/mcp/src/react/AddMcpSource.tsx+40−2
@@ -49,6 +49,22 @@ function findPreset(id: string | undefined): McpPreset | undefined { · 48 unmodified lines
49 return mcpPresets.find((p) => p.id === id);49 return mcpPresets.find((p) => p.id === id);
50}50}
51 51
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.
58const 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
52// ---------------------------------------------------------------------------68// ---------------------------------------------------------------------------
53// State machine (remote flow)69// State machine (remote flow)
54// ---------------------------------------------------------------------------70// ---------------------------------------------------------------------------
@@ -59,6 +75,12 @@ type OAuthTokens = { · 4 unmodified lines
59 tokenType: string;75 tokenType: string;
60 expiresAt: number | null;76 expiresAt: number | null;
61 scope: string | null;77 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;
62};84};
63 85
64type ProbeResult = {86type ProbeResult = {
@@ -419,9 +441,19 @@ export default function AddMcpSource(props: { · 354 unmodified lines
419 dispatch({ type: "oauth-start" });441 dispatch({ type: "oauth-start" });
420 try {442 try {
421 const redirectUrl = `${window.location.origin}/api/mcp/oauth/callback`;443 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);
422 const result = await doStartOAuth({449 const result = await doStartOAuth({
423 path: { scopeId },450 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 },
425 });457 });
426 dispatch({ type: "oauth-waiting", sessionId: result.sessionId });458 dispatch({ type: "oauth-waiting", sessionId: result.sessionId });
427 oauthCleanup.current = openOAuthPopup(459 oauthCleanup.current = openOAuthPopup(
@@ -437,6 +469,9 @@ export default function AddMcpSource(props: { · 9 unmodified lines
437 tokenType: data.tokenType,469 tokenType: data.tokenType,
438 expiresAt: data.expiresAt,470 expiresAt: data.expiresAt,
439 scope: data.scope,471 scope: data.scope,
472 clientInformation: data.clientInformation ?? null,
473 authorizationServerUrl: data.authorizationServerUrl ?? null,
474 resourceMetadataUrl: data.resourceMetadataUrl ?? null,
440 },475 },
441 });476 });
442 } else {477 } else {
@@ -454,7 +489,7 @@ export default function AddMcpSource(props: { · 11 unmodified lines
454 error: e instanceof Error ? e.message : "Failed to start OAuth",489 error: e instanceof Error ? e.message : "Failed to start OAuth",
455 });490 });
456 }491 }
457 }, [state.url, scopeId, doStartOAuth]);492 }, [state.url, scopeId, doStartOAuth, remoteIdentity.namespace, probe?.namespace]);
458 493
459 const handleCancelOAuth = useCallback(() => {494 const handleCancelOAuth = useCallback(() => {
460 oauthCleanup.current?.();495 oauthCleanup.current?.();
@@ -482,6 +517,9 @@ export default function AddMcpSource(props: { · 21 unmodified lines
482 tokenType: tokens.tokenType,517 tokenType: tokens.tokenType,
483 expiresAt: tokens.expiresAt,518 expiresAt: tokens.expiresAt,
484 scope: tokens.scope,519 scope: tokens.scope,
520 clientInformation: tokens.clientInformation,
521 authorizationServerUrl: tokens.authorizationServerUrl,
522 resourceMetadataUrl: tokens.resourceMetadataUrl,
485 }523 }
486 : { kind: "none" as const };524 : { kind: "none" as const };
487 const headers = Object.fromEntries(525 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
42 id: { type: "string", required: true },42 id: { type: "string", required: true },
43 scope_id: { type: "string", required: true, index: true },43 scope_id: { type: "string", required: true, index: true },
44 session: { type: "json", required: true },44 session: { type: "json", required: true },
45 expires_at: { type: "number", required: true },45 expires_at: { type: "number", required: true, bigint: true },
46 created_at: { type: "date", required: true },46 created_at: { type: "date", required: true },
47 },47 },
48 },48 },
packages/plugins/mcp/src/sdk/invoke.ts+16−3
@@ -34,10 +34,19 @@ const asRecord = (value: unknown): Record<string, unknown> => · 33 unmodified lines
34 ? (value as Record<string, unknown>)34 ? (value as Record<string, unknown>)
35 : {};35 : {};
36 36
37const connectionCacheKey = (sd: McpStoredSourceData): string =>37const connectionCacheKey = (
38 sd: McpStoredSourceData,
39 invokerScope: string,
40): string =>
38 sd.transport === "stdio"41 sd.transport === "stdio"
39 ? `stdio:${sd.command}`42 ? `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}`;
41 50
42// ---------------------------------------------------------------------------51// ---------------------------------------------------------------------------
43// Elicitation bridge — decode incoming MCP ElicitRequest, route through52// Elicitation bridge — decode incoming MCP ElicitRequest, route through
@@ -152,6 +161,10 @@ export interface InvokeMcpToolInput { · 108 unmodified lines
152 readonly toolName: string;161 readonly toolName: string;
153 readonly args: unknown;162 readonly args: unknown;
154 readonly sourceData: McpStoredSourceData;163 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;
155 readonly resolveConnector: () => Effect.Effect<McpConnection, McpConnectionError>;168 readonly resolveConnector: () => Effect.Effect<McpConnection, McpConnectionError>;
156 readonly connectionCache: ScopedCache.ScopedCache<169 readonly connectionCache: ScopedCache.ScopedCache<
157 string,170 string,
@@ -173,7 +186,7 @@ export const invokeMcpTool = ( · 15 unmodified lines
173 ? "stdio"186 ? "stdio"
174 : (input.sourceData.remoteTransport ?? "auto");187 : (input.sourceData.remoteTransport ?? "auto");
175 return Effect.gen(function* () {188 return Effect.gen(function* () {
176 const cacheKey = connectionCacheKey(input.sourceData);189 const cacheKey = connectionCacheKey(input.sourceData, input.invokerScope);
177 const args = asRecord(input.args);190 const args = asRecord(input.args);
178 191
179 // Register the connector for the cache lookup (side-channel pattern192 // 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
37 endpoint: Schema.String,37 endpoint: Schema.String,
38 redirectUrl: Schema.String,38 redirectUrl: Schema.String,
39 codeVerifier: Schema.String,39 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),
40});53});
41export type McpOAuthSession = typeof McpOAuthSession.Type;54export type McpOAuthSession = typeof McpOAuthSession.Type;
42 55
@@ -93,12 +106,26 @@ export const startMcpOAuthAuthorization = (input: { · 50 unmodified lines
93 endpoint: string;106 endpoint: string;
94 redirectUrl: string;107 redirectUrl: string;
95 state: string;108 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;
96}): Effect.Effect<McpOAuthStartResult, McpOAuthError> =>114}): Effect.Effect<McpOAuthStartResult, McpOAuthError> =>
97 Effect.gen(function* () {115 Effect.gen(function* () {
98 let authorizationUrl: URL | undefined;116 let authorizationUrl: URL | undefined;
99 let codeVerifier: string | undefined;117 let codeVerifier: string | undefined;
100 let discoveryState: OAuthDiscoveryState | undefined;118 let discoveryState: OAuthDiscoveryState | undefined =
101 let clientInformation: OAuthClientInformationMixed | 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;
102 129
103 const provider: OAuthClientProvider = {130 const provider: OAuthClientProvider = {
104 get redirectUrl() {131 get redirectUrl() {
packages/plugins/mcp/src/sdk/plugin.ts+195−50
@@ -1,10 +1,17 @@
1import { Duration, Effect, Exit, Scope, ScopedCache } from "effect";1import { Duration, Effect, Exit, Scope, ScopedCache } from "effect";
2 2
3import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";3import type {
4import type { OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";4 OAuthClientProvider,
5 OAuthDiscoveryState,
6} from "@modelcontextprotocol/sdk/client/auth.js";
7import type {
8 OAuthClientInformationMixed,
9 OAuthTokens,
10} from "@modelcontextprotocol/sdk/shared/auth.js";
5 11
6import {12import {
7 definePlugin,13 definePlugin,
14 ScopeId,
8 SecretId,15 SecretId,
9 SetSecretInput,16 SetSecretInput,
10 SourceDetectionResult,17 SourceDetectionResult,
@@ -89,6 +96,30 @@ export interface McpOAuthStartInput { · 78 unmodified lines
89 readonly endpoint: string;96 readonly endpoint: string;
90 readonly redirectUrl: string;97 readonly redirectUrl: string;
91 readonly queryParams?: Record<string, string> | null;98 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;
92}123}
93 124
94export interface McpOAuthStartResponse {125export interface McpOAuthStartResponse {
@@ -108,6 +139,12 @@ export interface McpOAuthCompleteResponse { · 13 unmodified lines
108 readonly tokenType: string;139 readonly tokenType: string;
109 readonly expiresAt: number | null;140 readonly expiresAt: number | null;
110 readonly scope: string | null;141 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;
111}148}
112 149
113export interface McpProbeResult {150export interface McpProbeResult {
@@ -168,41 +205,81 @@ const toBinding = (entry: McpToolManifestEntry): McpToolBinding => · 54 unmodified lines
168 outputSchema: entry.outputSchema,205 outputSchema: entry.outputSchema,
169 });206 });
170 207
171const makeOAuthProvider = (208interface OAuthProviderInputs {
172 accessToken: string,209 readonly accessToken: string;
173 tokenType: string,210 readonly tokenType: string;
174 refreshToken?: string,211 readonly refreshToken?: string;
175): OAuthClientProvider => ({212 readonly expiresAt?: number | null;
176 get redirectUrl() {213 readonly scope?: string | null;
177 return "http://localhost/oauth/callback";214 /** Source-level state — same for every user. */
178 },215 readonly clientInformation?: OAuthClientInformationMixed | null;
179 get clientMetadata() {216 readonly authorizationServerUrl?: string | null;
180 return {217 readonly resourceMetadataUrl?: string | null;
181 redirect_uris: ["http://localhost/oauth/callback"],218 readonly endpoint: string;
182 grant_types: ["authorization_code", "refresh_token"] as string[],219 /**
183 response_types: ["code"] as string[],220 * Called when the SDK refreshes tokens (grant_type=refresh_token).
184 token_endpoint_auth_method: "none" as const,221 * Persisting new tokens back to per-user secrets is what closes the
185 client_name: "Executor",222 * refresh loop — without it the next invocation reads stale values.
186 };223 */
187 },224 readonly onRefresh?: (tokens: OAuthTokens) => Promise<void> | void;
188 clientInformation: () => undefined,225}
189 saveClientInformation: () => {},226
190 tokens: async (): Promise<OAuthTokens> => ({227const makeOAuthProvider = (inputs: OAuthProviderInputs): OAuthClientProvider => {
191 access_token: accessToken,228 let currentTokens: OAuthTokens = {
192 token_type: tokenType,229 access_token: inputs.accessToken,
193 ...(refreshToken ? { refresh_token: refreshToken } : {}),230 token_type: inputs.tokenType,
194 }),231 ...(inputs.refreshToken ? { refresh_token: inputs.refreshToken } : {}),
195 saveTokens: async () => {},232 ...(inputs.expiresAt
196 redirectToAuthorization: async () => {233 ? { expires_in: Math.max(0, Math.floor((inputs.expiresAt - Date.now()) / 1000)) }
197 throw new Error("MCP OAuth re-authorization required");234 : {}),
198 },235 ...(inputs.scope ? { scope: inputs.scope } : {}),
199 saveCodeVerifier: () => {},236 };
200 codeVerifier: () => {237 let clientInformation: OAuthClientInformationMixed | undefined =
201 throw new Error("No active PKCE verifier");238 inputs.clientInformation ?? undefined;
202 },239 let discoveryState: OAuthDiscoveryState | undefined =
203 saveDiscoveryState: () => {},240 inputs.authorizationServerUrl || inputs.resourceMetadataUrl
204 discoveryState: () => undefined,241 ? {
205});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};
206 283
207const remoteConnectionError = (message: string) =>284const remoteConnectionError = (message: string) =>
208 new McpConnectionError({ transport: "remote", message });285 new McpConnectionError({ transport: "remote", message });
@@ -263,11 +340,58 @@ const resolveConnectorInput = ( · 54 unmodified lines
263 const refreshToken = auth.refreshTokenSecretId340 const refreshToken = auth.refreshTokenSecretId
264 ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined341 ? (yield* ctx.secrets.get(auth.refreshTokenSecretId)) ?? undefined
265 : undefined;342 : 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({
267 accessToken,353 accessToken,
268 auth.tokenType ?? "Bearer",354 tokenType: auth.tokenType ?? "Bearer",
269 refreshToken,355 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 });
271 }395 }
272 396
273 return {397 return {
@@ -736,10 +860,17 @@ export const mcpPlugin = definePlugin( · 462 unmodified lines
736 }860 }
737 861
738 const sessionId = `mcp_oauth_${crypto.randomUUID()}`;862 const sessionId = `mcp_oauth_${crypto.randomUUID()}`;
863 const tokenScope = input.tokenScope ?? (ctx.scopes[0]!.id as string);
739 const started = yield* startMcpOAuthAuthorization({864 const started = yield* startMcpOAuthAuthorization({
740 endpoint: fullEndpoint,865 endpoint: fullEndpoint,
741 redirectUrl: input.redirectUrl,866 redirectUrl: input.redirectUrl,
742 state: sessionId,867 state: sessionId,
868 clientInformation: input.clientInformation as
869 | OAuthClientInformationMixed
870 | null
871 | undefined,
872 authorizationServerUrl: input.authorizationServerUrl,
873 resourceMetadataUrl: input.resourceMetadataUrl,
743 }).pipe(874 }).pipe(
744 Effect.mapError((e) =>875 Effect.mapError((e) =>
745 mcpOAuthError(`OAuth start failed: ${e.message}`),876 mcpOAuthError(`OAuth start failed: ${e.message}`),
@@ -748,7 +879,7 @@ export const mcpPlugin = definePlugin( · 2 unmodified lines
748 );879 );
749 880
750 yield* ctx.storage881 yield* ctx.storage
751 .putOAuthSession(sessionId, ctx.scopes[0]!.id as string, {882 .putOAuthSession(sessionId, tokenScope, {
752 endpoint: fullEndpoint,883 endpoint: fullEndpoint,
753 redirectUrl: input.redirectUrl,884 redirectUrl: input.redirectUrl,
754 codeVerifier: started.codeVerifier,885 codeVerifier: started.codeVerifier,
@@ -757,6 +888,9 @@ export const mcpPlugin = definePlugin( · 2 unmodified lines
757 resourceMetadata: started.resourceMetadata,888 resourceMetadata: started.resourceMetadata,
758 authorizationServerMetadata: started.authorizationServerMetadata,889 authorizationServerMetadata: started.authorizationServerMetadata,
759 clientInformation: started.clientInformation,890 clientInformation: started.clientInformation,
891 tokenScope,
892 accessTokenSecretId: input.accessTokenSecretId,
893 refreshTokenSecretId: input.refreshTokenSecretId ?? null,
760 })894 })
761 .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session"));895 .pipe(Effect.withSpan("mcp.plugin.oauth.persist_session"));
762 896
@@ -796,13 +930,14 @@ export const mcpPlugin = definePlugin( · 33 unmodified lines
796 Effect.withSpan("mcp.plugin.oauth.exchange_code"),930 Effect.withSpan("mcp.plugin.oauth.exchange_code"),
797 );931 );
798 932
799 // MCP OAuth tokens are user-specific, so they land at the933 // Token storage is fully driven by the session: scope and
800 // innermost scope. On a single-scope executor that's the934 // secret ids were chosen at startOAuth time and pinned to
801 // only scope; on a per-user stack `[user, org]` it's the935 // the row. That keeps shadowing deterministic — every
802 // user scope — so tokens shadow org-level fallbacks for936 // user's OAuth flow on the same source writes to the same
803 // the caller's own invocations.937 // ids, so the source's stored OAuth2 auth resolves
804 const tokenScope = ctx.scopes[0]!.id;938 // per-user via scope fall-through.
805 const accessSecretId = `mcp-oauth-access-${input.state}`;939 const tokenScope = ScopeId.make(session.tokenScope);
940 const accessSecretId = session.accessTokenSecretId;
806 yield* ctx.secrets941 yield* ctx.secrets
807 .set(942 .set(
808 new SetSecretInput({943 new SetSecretInput({
@@ -819,8 +954,8 @@ export const mcpPlugin = definePlugin( · 10 unmodified lines
819 );954 );
820 955
821 let refreshTokenSecretId: string | null = null;956 let refreshTokenSecretId: string | null = null;
822 if (exchanged.tokens.refresh_token) {957 if (exchanged.tokens.refresh_token && session.refreshTokenSecretId) {
823 const refreshId = `mcp-oauth-refresh-${input.state}`;958 const refreshId = session.refreshTokenSecretId;
824 yield* ctx.secrets959 yield* ctx.secrets
825 .set(960 .set(
826 new SetSecretInput({961 new SetSecretInput({
@@ -853,6 +988,15 @@ export const mcpPlugin = definePlugin( · 26 unmodified lines
853 tokenType: exchanged.tokens.token_type ?? "Bearer",988 tokenType: exchanged.tokens.token_type ?? "Bearer",
854 expiresAt,989 expiresAt,
855 scope: exchanged.tokens.scope ?? null,990 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,
856 };1000 };
857 }).pipe(Effect.withSpan("mcp.plugin.complete_oauth"));1001 }).pipe(Effect.withSpan("mcp.plugin.complete_oauth"));
858 1002
@@ -946,6 +1090,7 @@ export const mcpPlugin = definePlugin( · 87 unmodified lines
946 toolName: entry.binding.toolName,1090 toolName: entry.binding.toolName,
947 args,1091 args,
948 sourceData: sd,1092 sourceData: sd,
1093 invokerScope: ctx.scopes[0]!.id as string,
949 resolveConnector: () =>1094 resolveConnector: () =>
950 resolveConnectorInput(sd, ctx, allowStdio).pipe(1095 resolveConnectorInput(sd, ctx, allowStdio).pipe(
951 Effect.flatMap((ci) => createMcpConnector(ci)),1096 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
15// Connection auth (only applies to remote sources)15// Connection auth (only applies to remote sources)
16// ---------------------------------------------------------------------------16// ---------------------------------------------------------------------------
17 17
18/** JSON object loosely typed — used for opaque OAuth state we just round-trip. */
19const JsonObject = Schema.Record({ key: Schema.String, value: Schema.Unknown });
20
18export const McpConnectionAuth = Schema.Union(21export const McpConnectionAuth = Schema.Union(
19 Schema.Struct({ kind: Schema.Literal("none") }),22 Schema.Struct({ kind: Schema.Literal("none") }),
20 Schema.Struct({23 Schema.Struct({
@@ -30,6 +33,27 @@ export const McpConnectionAuth = Schema.Union( · 9 unmodified lines
30 tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),33 tokenType: Schema.optionalWith(Schema.String, { default: () => "Bearer" }),
31 expiresAt: Schema.NullOr(Schema.Number),34 expiresAt: Schema.NullOr(Schema.Number),
32 scope: Schema.NullOr(Schema.String),35 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 }),
33 }),57 }),
34);58);
35export type McpConnectionAuth = typeof McpConnectionAuth.Type;59export type McpConnectionAuth = typeof McpConnectionAuth.Type;