@xen-orchestra/rest-api 0.31.1 → 0.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/README.md +4 -4
  2. package/dist/acl-privileges/acl-privilege.controller.mjs +7 -2
  3. package/dist/acl-roles/acl-role.controller.mjs +13 -2
  4. package/dist/alarms/alarm.controller.mjs +3 -1
  5. package/dist/backup-archives/backup-archive.controller.mjs +3 -1
  6. package/dist/backup-jobs/backup-job.controller.mjs +12 -1
  7. package/dist/backup-logs/backup-log.controller.mjs +3 -1
  8. package/dist/backup-repositories/backup-repositories.controller.mjs +3 -1
  9. package/dist/events/event.controller.mjs +4 -1
  10. package/dist/groups/group.controller.mjs +10 -1
  11. package/dist/hosts/host.controller.mjs +16 -1
  12. package/dist/index.mjs +2 -0
  13. package/dist/mcp/mcp.controller.mjs +59 -0
  14. package/dist/mcp/mcp.helper.mjs +11 -0
  15. package/dist/messages/message.controller.mjs +3 -1
  16. package/dist/middlewares/mcp-gate.middleware.mjs +30 -0
  17. package/dist/networks/network.controller.mjs +9 -1
  18. package/dist/open-api/routes/routes.js +118 -5
  19. package/dist/pbds/pbd.controller.mjs +5 -1
  20. package/dist/pcis/pci.controller.mjs +3 -1
  21. package/dist/pgpus/pgpu.controller.mjs +3 -1
  22. package/dist/pifs/pif.controller.mjs +6 -1
  23. package/dist/pools/pool.controller.mjs +20 -1
  24. package/dist/proxies/proxy.controller.mjs +3 -1
  25. package/dist/restore-logs/restore-log.controller.mjs +5 -1
  26. package/dist/schedules/schedule.controller.mjs +4 -1
  27. package/dist/servers/server.controller.mjs +8 -1
  28. package/dist/sms/sm.controller.mjs +3 -1
  29. package/dist/srs/sr.controller.mjs +13 -1
  30. package/dist/tasks/task.controller.mjs +6 -1
  31. package/dist/users/user.controller.mjs +13 -2
  32. package/dist/vbds/vbd.controller.mjs +10 -1
  33. package/dist/vdi-snapshots/vdi-snapshot.controller.mjs +10 -1
  34. package/dist/vdis/vdi.controller.mjs +13 -1
  35. package/dist/vifs/vif.controller.mjs +10 -1
  36. package/dist/vm-controller/vm-controller.controller.mjs +9 -1
  37. package/dist/vm-snapshots/vm-snapshot.controller.mjs +11 -1
  38. package/dist/vm-templates/vm-template.controller.mjs +11 -1
  39. package/dist/vms/vm.controller.mjs +77 -2
  40. package/dist/vms/vm.service.mjs +17 -0
  41. package/dist/xoa/xoa.controller.mjs +4 -1
  42. package/eslint-rules/index.cjs +7 -0
  43. package/eslint-rules/require-mcp-expose.cjs +129 -0
  44. package/open-api/spec/swagger.json +1764 -495
  45. package/package.json +3 -3
  46. package/tsoa.json +2 -1
@@ -7,13 +7,14 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
- import { Example, Get, Path, Post, Query, Request, Response, Route, Security, Tags, SuccessResponse, Body, Put, Delete, Middlewares, } from 'tsoa';
10
+ import { Body, Delete, Example, Extension, Get, Middlewares, Patch, Path, Post, Put, Query, Request, Response, Route, Security, SuccessResponse, Tags, } from 'tsoa';
11
11
  import { json } from 'express';
12
12
  import { inject } from 'inversify';
13
13
  import { incorrectState, invalidParameters } from 'xo-common/api-errors.js';
14
14
  import { provide } from 'inversify-binding-decorators';
15
15
  import { PassThrough } from 'node:stream';
16
- import { acl } from '../middlewares/acl.middleware.mjs';
16
+ import { SUPPORTED_ACTIONS_BY_RESOURCE } from '@xen-orchestra/acl';
17
+ import { acl, actionsFromBody } from '../middlewares/acl.middleware.mjs';
17
18
  import { asynchronousActionResp, badRequestResp, createdResp, forbiddenOperationResp, incorrectStateResp, internalServerErrorResp, invalidParameters as invalidParametersResp, noContentResp, notFoundResp, unauthorizedResp, } from '../open-api/common/response.common.mjs';
18
19
  import { BASE_URL } from '../index.mjs';
19
20
  import { limitAndFilterArray, NDJSON_CONTENT_TYPE } from '../helpers/utils.helper.mjs';
@@ -28,6 +29,11 @@ import { partialVmBackupJobs, vmBackupJobIds } from '../open-api/oa-examples/bac
28
29
  import { messageIds, partialMessages } from '../open-api/oa-examples/message.oa-example.mjs';
29
30
  import { Task } from '@vates/task';
30
31
  const IGNORED_VDIS_TAG = '[NOSNAP]';
32
+ // `datasources` is managed through the dedicated `/vms/{id}/stats/data_source`
33
+ // endpoints, not as a direct VM property, so it cannot be updated via PATCH /vms.
34
+ const UPDATE_VM_ACTIONS = Object.keys(SUPPORTED_ACTIONS_BY_RESOURCE.vm.update)
35
+ .filter(action => action !== 'datasources')
36
+ .map(k => `update:${k}`);
31
37
  let VmController = class VmController extends XapiXoController {
32
38
  #vmService;
33
39
  #backupJobService;
@@ -74,6 +80,29 @@ let VmController = class VmController extends XapiXoController {
74
80
  getVm(id) {
75
81
  return this.getObject(id);
76
82
  }
83
+ /**
84
+ * Partial update of a VM. Only the fields present in the body are modified;
85
+ * everything else is left untouched.
86
+ *
87
+ * Operations are applied sequentially: if one fails, previously applied
88
+ * changes are not rolled back.
89
+ *
90
+ * Required privilege per field provided in the body:
91
+ * - resource: vm, action: update:<field> (e.g. update:nameLabel, update:cpus, ...)
92
+ *
93
+ * Special fields:
94
+ * - `xenStoreData` keys are automatically prefixed with `vm-data/` when missing
95
+ *
96
+ * @example id "f07ab729-c0e8-721c-45ec-f11276377030"
97
+ * @example body {
98
+ * "nameLabel": "web-prod-01",
99
+ * "nameDescription": "Production web frontend — managed by n8n",
100
+ * "notes": "Docker containers: nginx, app-1, app-2"
101
+ * }
102
+ */
103
+ async updateVm(id, body) {
104
+ await this.#vmService.updateVm(id, body);
105
+ }
77
106
  /**
78
107
  * Required privilege:
79
108
  * - resource: vm, action: delete
@@ -631,6 +660,7 @@ let VmController = class VmController extends XapiXoController {
631
660
  __decorate([
632
661
  Example(vmIds),
633
662
  Example(partialVms),
663
+ Extension('x-mcp-exposure', 'allow'),
634
664
  Get(''),
635
665
  Security('*', ['acl']),
636
666
  __param(0, Request()),
@@ -641,6 +671,7 @@ __decorate([
641
671
  __param(5, Query())
642
672
  ], VmController.prototype, "getVms", null);
643
673
  __decorate([
674
+ Extension('x-mcp-exposure', 'deny'),
644
675
  Get('{id}.{format}'),
645
676
  Middlewares(acl({ resource: 'vm', action: 'export', objectId: 'params.id' })),
646
677
  SuccessResponse(200, 'Download started', 'application/octet-stream'),
@@ -654,6 +685,7 @@ __decorate([
654
685
  ], VmController.prototype, "exportVm", null);
655
686
  __decorate([
656
687
  Example(vm),
688
+ Extension('x-mcp-exposure', 'allow'),
657
689
  Get('{id}'),
658
690
  Middlewares(acl({ resource: 'vm', action: 'read', objectId: 'params.id' })),
659
691
  Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
@@ -661,6 +693,25 @@ __decorate([
661
693
  __param(0, Path())
662
694
  ], VmController.prototype, "getVm", null);
663
695
  __decorate([
696
+ Patch('{id}'),
697
+ Middlewares([
698
+ json(),
699
+ acl({
700
+ resource: 'vm',
701
+ actions: actionsFromBody(UPDATE_VM_ACTIONS),
702
+ objectId: 'params.id',
703
+ }),
704
+ ]),
705
+ SuccessResponse(noContentResp.status, noContentResp.description),
706
+ Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
707
+ Response(notFoundResp.status, notFoundResp.description),
708
+ Response(invalidParametersResp.status, invalidParametersResp.description),
709
+ Response(internalServerErrorResp.status, internalServerErrorResp.description),
710
+ __param(0, Path()),
711
+ __param(1, Body())
712
+ ], VmController.prototype, "updateVm", null);
713
+ __decorate([
714
+ Extension('x-mcp-exposure', 'confirm'),
664
715
  Delete('{id}'),
665
716
  Middlewares(acl({ resource: 'vm', action: 'delete', objectId: 'params.id' })),
666
717
  SuccessResponse(noContentResp.status, noContentResp.description),
@@ -671,6 +722,7 @@ __decorate([
671
722
  ], VmController.prototype, "deleteVm", null);
672
723
  __decorate([
673
724
  Example(vmStatsExample),
725
+ Extension('x-mcp-exposure', 'deny'),
674
726
  Get('{id}/stats'),
675
727
  Middlewares(acl({ resource: 'vm', action: 'read', objectId: 'params.id' })),
676
728
  Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
@@ -680,6 +732,7 @@ __decorate([
680
732
  __param(1, Query())
681
733
  ], VmController.prototype, "getVmStats", null);
682
734
  __decorate([
735
+ Extension('x-mcp-exposure', 'confirm'),
683
736
  Put('{id}/stats/data_source/{data_source}'),
684
737
  Middlewares(acl({ resource: 'vm', action: 'update:datasources', objectId: 'params.id' })),
685
738
  SuccessResponse(noContentResp.status, noContentResp.description),
@@ -690,6 +743,7 @@ __decorate([
690
743
  __param(1, Path('data_source'))
691
744
  ], VmController.prototype, "addDataSource", null);
692
745
  __decorate([
746
+ Extension('x-mcp-exposure', 'confirm'),
693
747
  Delete('{id}/stats/data_source/{data_source}'),
694
748
  Middlewares(acl({ resource: 'vm', action: 'update:datasources', objectId: 'params.id' })),
695
749
  SuccessResponse(noContentResp.status, noContentResp.description),
@@ -701,6 +755,7 @@ __decorate([
701
755
  ], VmController.prototype, "deleteDataSource", null);
702
756
  __decorate([
703
757
  Example(taskLocation),
758
+ Extension('x-mcp-exposure', 'confirm'),
704
759
  Post('{id}/actions/start'),
705
760
  Middlewares([
706
761
  json(),
@@ -720,6 +775,7 @@ __decorate([
720
775
  ], VmController.prototype, "startVm", null);
721
776
  __decorate([
722
777
  Example(taskLocation),
778
+ Extension('x-mcp-exposure', 'confirm'),
723
779
  Post('{id}/actions/clean_shutdown'),
724
780
  Middlewares(acl({ resource: 'vm', action: 'shutdown:clean', objectId: 'params.id' })),
725
781
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -732,6 +788,7 @@ __decorate([
732
788
  ], VmController.prototype, "cleanShutdownVm", null);
733
789
  __decorate([
734
790
  Example(taskLocation),
791
+ Extension('x-mcp-exposure', 'confirm'),
735
792
  Post('{id}/actions/clean_reboot'),
736
793
  Middlewares(acl({ resource: 'vm', action: 'reboot:clean', objectId: 'params.id' })),
737
794
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -743,6 +800,7 @@ __decorate([
743
800
  ], VmController.prototype, "cleanRebootVm", null);
744
801
  __decorate([
745
802
  Example(taskLocation),
803
+ Extension('x-mcp-exposure', 'confirm'),
746
804
  Post('{id}/actions/hard_shutdown'),
747
805
  Middlewares(acl({ resource: 'vm', action: 'shutdown:hard', objectId: 'params.id' })),
748
806
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -755,6 +813,7 @@ __decorate([
755
813
  ], VmController.prototype, "hardShutdownVm", null);
756
814
  __decorate([
757
815
  Example(taskLocation),
816
+ Extension('x-mcp-exposure', 'confirm'),
758
817
  Post('{id}/actions/hard_reboot'),
759
818
  Middlewares(acl({ resource: 'vm', action: 'reboot:hard', objectId: 'params.id' })),
760
819
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -767,6 +826,7 @@ __decorate([
767
826
  ], VmController.prototype, "hardRebootVm", null);
768
827
  __decorate([
769
828
  Example(taskLocation),
829
+ Extension('x-mcp-exposure', 'confirm'),
770
830
  Post('{id}/actions/pause'),
771
831
  Middlewares(acl({ resource: 'vm', action: 'pause', objectId: 'params.id' })),
772
832
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -779,6 +839,7 @@ __decorate([
779
839
  ], VmController.prototype, "pauseVm", null);
780
840
  __decorate([
781
841
  Example(taskLocation),
842
+ Extension('x-mcp-exposure', 'confirm'),
782
843
  Post('{id}/actions/suspend'),
783
844
  Middlewares(acl({ resource: 'vm', action: 'suspend', objectId: 'params.id' })),
784
845
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -791,6 +852,7 @@ __decorate([
791
852
  ], VmController.prototype, "suspendVm", null);
792
853
  __decorate([
793
854
  Example(taskLocation),
855
+ Extension('x-mcp-exposure', 'confirm'),
794
856
  Post('{id}/actions/resume'),
795
857
  Middlewares(acl({ resource: 'vm', action: 'resume', objectId: 'params.id' })),
796
858
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -803,6 +865,7 @@ __decorate([
803
865
  ], VmController.prototype, "resumeVm", null);
804
866
  __decorate([
805
867
  Example(taskLocation),
868
+ Extension('x-mcp-exposure', 'confirm'),
806
869
  Post('{id}/actions/unpause'),
807
870
  Middlewares(acl({ resource: 'vm', action: 'unpause', objectId: 'params.id' })),
808
871
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -815,6 +878,7 @@ __decorate([
815
878
  ], VmController.prototype, "unpauseVm", null);
816
879
  __decorate([
817
880
  Example(taskLocation),
881
+ Extension('x-mcp-exposure', 'confirm'),
818
882
  Post('{id}/actions/revert_snapshot'),
819
883
  Middlewares([
820
884
  json(),
@@ -839,6 +903,7 @@ __decorate([
839
903
  ], VmController.prototype, "revertSnapshotVm", null);
840
904
  __decorate([
841
905
  Example(taskLocation),
906
+ Extension('x-mcp-exposure', 'confirm'),
842
907
  Post('{id}/actions/snapshot'),
843
908
  Middlewares([json(), acl({ resource: 'vm', action: 'snapshot', objectId: 'params.id' })]),
844
909
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -852,6 +917,7 @@ __decorate([
852
917
  ], VmController.prototype, "snapshotVm", null);
853
918
  __decorate([
854
919
  Example(taskLocation),
920
+ Extension('x-mcp-exposure', 'confirm'),
855
921
  Post('{id}/actions/clone'),
856
922
  Middlewares(json()),
857
923
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -865,6 +931,7 @@ __decorate([
865
931
  ], VmController.prototype, "cloneVm", null);
866
932
  __decorate([
867
933
  Example(genericAlarmsExample),
934
+ Extension('x-mcp-exposure', 'allow'),
868
935
  Get('{id}/alarms'),
869
936
  Security('*', ['acl']),
870
937
  Tags('alarms'),
@@ -879,6 +946,7 @@ __decorate([
879
946
  ], VmController.prototype, "getVmAlarms", null);
880
947
  __decorate([
881
948
  Example(vmVdis),
949
+ Extension('x-mcp-exposure', 'allow'),
882
950
  Get('{id}/vdis'),
883
951
  Security('*', ['acl']),
884
952
  Tags('vdis'),
@@ -894,6 +962,7 @@ __decorate([
894
962
  __decorate([
895
963
  Example(vmBackupJobIds),
896
964
  Example(partialVmBackupJobs),
965
+ Extension('x-mcp-exposure', 'allow'),
897
966
  Get('{id}/backup-jobs'),
898
967
  Security('*', ['acl']),
899
968
  Tags('backup-jobs'),
@@ -909,6 +978,7 @@ __decorate([
909
978
  __decorate([
910
979
  Example(messageIds),
911
980
  Example(partialMessages),
981
+ Extension('x-mcp-exposure', 'allow'),
912
982
  Get('{id}/messages'),
913
983
  Security('*', ['acl']),
914
984
  Tags('messages'),
@@ -924,6 +994,7 @@ __decorate([
924
994
  __decorate([
925
995
  Example(taskIds),
926
996
  Example(partialTasks),
997
+ Extension('x-mcp-exposure', 'allow'),
927
998
  Get('{id}/tasks'),
928
999
  Security('*', ['acl']),
929
1000
  Tags('tasks'),
@@ -937,6 +1008,7 @@ __decorate([
937
1008
  __param(6, Query())
938
1009
  ], VmController.prototype, "getVmTasks", null);
939
1010
  __decorate([
1011
+ Extension('x-mcp-exposure', 'confirm'),
940
1012
  Put('{id}/tags/{tag}'),
941
1013
  Middlewares(acl({ resource: 'vm', action: 'update:tags', objectId: 'params.id' })),
942
1014
  SuccessResponse(noContentResp.status, noContentResp.description),
@@ -946,6 +1018,7 @@ __decorate([
946
1018
  __param(1, Path())
947
1019
  ], VmController.prototype, "putVmTag", null);
948
1020
  __decorate([
1021
+ Extension('x-mcp-exposure', 'confirm'),
949
1022
  Delete('{id}/tags/{tag}'),
950
1023
  Middlewares(acl({ resource: 'vm', action: 'update:tags', objectId: 'params.id' })),
951
1024
  SuccessResponse(noContentResp.status, noContentResp.description),
@@ -956,6 +1029,7 @@ __decorate([
956
1029
  ], VmController.prototype, "deleteVmTag", null);
957
1030
  __decorate([
958
1031
  Example(vmDashboard),
1032
+ Extension('x-mcp-exposure', 'allow'),
959
1033
  Get('{id}/dashboard'),
960
1034
  Middlewares(acl({ resource: 'vm', action: 'read', objectId: 'params.id' })),
961
1035
  Response(forbiddenOperationResp.status, forbiddenOperationResp.description),
@@ -965,6 +1039,7 @@ __decorate([
965
1039
  ], VmController.prototype, "getVmDashboard", null);
966
1040
  __decorate([
967
1041
  Example(taskLocation),
1042
+ Extension('x-mcp-exposure', 'confirm'),
968
1043
  Post('{id}/actions/migrate'),
969
1044
  Middlewares(json()),
970
1045
  SuccessResponse(asynchronousActionResp.status, asynchronousActionResp.description),
@@ -146,6 +146,23 @@ export class VmService {
146
146
  });
147
147
  return alarms;
148
148
  }
149
+ async updateVm(id, body) {
150
+ const { resourceSet, share, ...editProps } = body;
151
+ // Touch the object so 404 is raised before any side effect.
152
+ void this.#restApi.getObject(id, 'VM');
153
+ const xoApp = this.#restApi.xoApp;
154
+ if (resourceSet !== undefined) {
155
+ await xoApp.setVmResourceSet(id, resourceSet, true);
156
+ }
157
+ else if (share) {
158
+ // `share: false` is a no-op.
159
+ await xoApp.shareVmResourceSet(id);
160
+ }
161
+ if (Object.keys(editProps).length === 0) {
162
+ return;
163
+ }
164
+ await xoApp.getXapi(id).editVm(id, editProps);
165
+ }
149
166
  #getDashboardQuickInfo(id) {
150
167
  const { power_state, uuid, name_description, CPUs, mainIpAddress, os_version, memory, creation, $pool, virtualizationMode, tags, $container, startTime, pvDriversDetected, pvDriversVersion, pvDriversUpToDate, } = this.#restApi.getObject(id, 'VM');
151
168
  return {
@@ -7,7 +7,7 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
7
7
  var __param = (this && this.__param) || function (paramIndex, decorator) {
8
8
  return function (target, key) { decorator(target, key, paramIndex); }
9
9
  };
10
- import { Controller, Example, Get, Query, Request, Response, Route, Security, Tags } from 'tsoa';
10
+ import { Controller, Example, Extension, Get, Query, Request, Response, Route, Security, Tags } from 'tsoa';
11
11
  import { inject } from 'inversify';
12
12
  import { PassThrough } from 'node:stream';
13
13
  import { provide } from 'inversify-binding-decorators';
@@ -58,6 +58,7 @@ let XoaController = class XoaController extends Controller {
58
58
  };
59
59
  __decorate([
60
60
  Example(xoaDashboard),
61
+ Extension('x-mcp-exposure', 'allow'),
61
62
  Get('dashboard'),
62
63
  Response(badRequestResp.status, badRequestResp.description),
63
64
  Response(unauthorizedResp.status, unauthorizedResp.description),
@@ -67,11 +68,13 @@ __decorate([
67
68
  __decorate([
68
69
  Security('none'),
69
70
  Example(pingResponse),
71
+ Extension('x-mcp-exposure', 'allow'),
70
72
  Get('ping')
71
73
  ], XoaController.prototype, "ping", null);
72
74
  __decorate([
73
75
  Security('none'),
74
76
  Example(guiRoutes),
77
+ Extension('x-mcp-exposure', 'allow'),
75
78
  Get('gui-routes')
76
79
  ], XoaController.prototype, "getGuiRoutes", null);
77
80
  XoaController = __decorate([
@@ -0,0 +1,7 @@
1
+ 'use strict'
2
+
3
+ module.exports = {
4
+ rules: {
5
+ 'require-mcp-expose': require('./require-mcp-expose.cjs'),
6
+ },
7
+ }
@@ -0,0 +1,129 @@
1
+ 'use strict'
2
+
3
+ /**
4
+ * Require `@Extension('x-mcp-exposure', 'allow' | 'confirm' | 'deny')` next to
5
+ * every `@Get/@Post/@Put/@Patch/@Delete` method. tsoa detects extension
6
+ * decorators by AST identifier name, so the `Extension` literal must appear in
7
+ * source — a `@McpExpose` wrapper would not be picked up.
8
+ *
9
+ * Scope: this is a SURFACE-CONTROL policy, not a security control. The value
10
+ * only decides which endpoints the official `@xen-orchestra/mcp` server turns
11
+ * into LLM tools; the REST API itself never reads `x-mcp-exposure`. Every
12
+ * endpoint stays gated by its own RBAC/ACL middleware regardless of this
13
+ * annotation, and any REST client with valid credentials (including a
14
+ * non-official MCP) can still call a `'deny'`-tagged endpoint directly. The
15
+ * rule only guarantees the exposure decision is made explicitly per endpoint —
16
+ * it does not harden the REST API.
17
+ */
18
+
19
+ const HTTP_METHOD_DECORATORS = new Set(['Get', 'Post', 'Put', 'Patch', 'Delete'])
20
+ const MCP_EXPOSURE_KEY = 'x-mcp-exposure'
21
+ const VALID_EXPOSURES = new Set(['allow', 'confirm', 'deny'])
22
+
23
+ function getDecoratorCalleeName(decorator) {
24
+ const expr = decorator.expression
25
+ if (!expr) return undefined
26
+ if (expr.type === 'CallExpression' && expr.callee && expr.callee.type === 'Identifier') {
27
+ return expr.callee.name
28
+ }
29
+ if (expr.type === 'Identifier') {
30
+ return expr.name
31
+ }
32
+ return undefined
33
+ }
34
+
35
+ function findHttpMethodDecorator(decorators) {
36
+ for (const decorator of decorators) {
37
+ const name = getDecoratorCalleeName(decorator)
38
+ if (name !== undefined && HTTP_METHOD_DECORATORS.has(name)) {
39
+ return { decorator, name }
40
+ }
41
+ }
42
+ return undefined
43
+ }
44
+
45
+ function getStringLiteralValue(node) {
46
+ if (!node) return undefined
47
+ if (node.type === 'Literal' && typeof node.value === 'string') return node.value
48
+ if (node.type === 'TemplateLiteral' && node.expressions.length === 0 && node.quasis.length === 1) {
49
+ return node.quasis[0].value.cooked
50
+ }
51
+ return undefined
52
+ }
53
+
54
+ function findMcpExposureExtension(decorators) {
55
+ for (const decorator of decorators) {
56
+ if (!decorator.expression || decorator.expression.type !== 'CallExpression') continue
57
+ const callee = decorator.expression.callee
58
+ if (!callee || callee.type !== 'Identifier' || callee.name !== 'Extension') continue
59
+
60
+ const [keyArg, valueArg] = decorator.expression.arguments
61
+ const key = getStringLiteralValue(keyArg)
62
+ if (key !== MCP_EXPOSURE_KEY) continue
63
+
64
+ return { decorator, valueArg }
65
+ }
66
+ return undefined
67
+ }
68
+
69
+ function getMethodName(methodNode) {
70
+ if (!methodNode.key) return '<anonymous>'
71
+ if (methodNode.key.type === 'Identifier') return methodNode.key.name
72
+ if (methodNode.key.type === 'Literal') return String(methodNode.key.value)
73
+ return '<computed>'
74
+ }
75
+
76
+ module.exports = {
77
+ meta: {
78
+ type: 'problem',
79
+ docs: {
80
+ description:
81
+ 'Every REST endpoint must declare its MCP exposure with @Extension("x-mcp-exposure", "allow" | "confirm" | "deny").',
82
+ },
83
+ schema: [],
84
+ messages: {
85
+ missingMcpExpose:
86
+ 'Endpoint "{{name}}" decorated with @{{httpMethod}} is missing an @Extension("x-mcp-exposure", ...) decorator. ' +
87
+ 'Add @Extension("x-mcp-exposure", "allow"), "confirm", or "deny" to make the MCP exposure decision explicit.',
88
+ invalidMcpExpose:
89
+ 'Endpoint "{{name}}" has @Extension("x-mcp-exposure", ...) with an invalid value. ' +
90
+ 'Expected one of: "allow", "confirm", "deny". Got: {{got}}.',
91
+ },
92
+ },
93
+ create(context) {
94
+ return {
95
+ MethodDefinition(node) {
96
+ if (node.kind === 'constructor') return
97
+ const decorators = node.decorators
98
+ if (!decorators || decorators.length === 0) return
99
+
100
+ const httpMethod = findHttpMethodDecorator(decorators)
101
+ if (!httpMethod) return
102
+
103
+ const methodName = getMethodName(node)
104
+ const mcpExposure = findMcpExposureExtension(decorators)
105
+
106
+ if (!mcpExposure) {
107
+ context.report({
108
+ node,
109
+ messageId: 'missingMcpExpose',
110
+ data: { name: methodName, httpMethod: httpMethod.name },
111
+ })
112
+ return
113
+ }
114
+
115
+ const exposureValue = getStringLiteralValue(mcpExposure.valueArg)
116
+ if (exposureValue === undefined || !VALID_EXPOSURES.has(exposureValue)) {
117
+ context.report({
118
+ node: mcpExposure.valueArg ?? mcpExposure.decorator,
119
+ messageId: 'invalidMcpExpose',
120
+ data: {
121
+ name: methodName,
122
+ got: exposureValue === undefined ? '<non-literal>' : JSON.stringify(exposureValue),
123
+ },
124
+ })
125
+ }
126
+ },
127
+ }
128
+ },
129
+ }