@stackwright-pro/mcp 0.1.1 → 0.2.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/server.mjs CHANGED
@@ -22,8 +22,8 @@ function registerDataExplorerTools(server2) {
22
22
  },
23
23
  async ({ specPath, projectRoot }) => {
24
24
  const result = listEntities({
25
- specPath,
26
- projectRoot
25
+ ...specPath !== void 0 && { specPath },
26
+ ...projectRoot !== void 0 && { projectRoot }
27
27
  });
28
28
  if (!result.success) {
29
29
  return {
@@ -73,8 +73,8 @@ function registerDataExplorerTools(server2) {
73
73
  async ({ selectedEntities, excludePatterns, projectRoot }) => {
74
74
  const result = generateFilter({
75
75
  selectedEntities,
76
- excludePatterns,
77
- projectRoot: projectRoot || process.cwd()
76
+ ...excludePatterns !== void 0 && { excludePatterns },
77
+ projectRoot: projectRoot ?? process.cwd()
78
78
  });
79
79
  if (!result.success) {
80
80
  return {
@@ -94,8 +94,8 @@ function registerDataExplorerTools(server2) {
94
94
  lines.push(" endpoints:");
95
95
  if (result.filter.include && result.filter.include.length > 0) {
96
96
  lines.push(" include:");
97
- for (const path2 of result.filter.include) {
98
- lines.push(` - ${path2}`);
97
+ for (const path4 of result.filter.include) {
98
+ lines.push(` - ${path4}`);
99
99
  }
100
100
  }
101
101
  if (result.filter.exclude && result.filter.exclude.length > 0) {
@@ -137,7 +137,7 @@ function registerSecurityTools(server2) {
137
137
  },
138
138
  async ({ specPath, configPath }) => {
139
139
  let securityEnabled = false;
140
- let allowlist = [];
140
+ const allowlist = [];
141
141
  const configFile = configPath || path.join(process.cwd(), "stackwright.yml");
142
142
  if (fs.existsSync(configFile)) {
143
143
  try {
@@ -460,7 +460,7 @@ import { listEntities as listEntities2 } from "@stackwright-pro/cli-data-explore
460
460
  function registerDashboardTools(server2) {
461
461
  server2.tool(
462
462
  "stackwright_pro_generate_dashboard",
463
- "Generate a dashboard page configuration for displaying API data. Creates YAML content for a Stackwright page with collection_listing, data_table, or stats_grid content types. Use this after stackwright_pro_generate_filter to create pages for your API collections.",
463
+ "Generate a dashboard page configuration for displaying API data. Creates YAML content for a Stackwright page with grid, metric_card, data_table, and collection_list content types. Use this after stackwright_pro_generate_filter to create pages for your API collections.",
464
464
  {
465
465
  entities: z4.array(z4.string()).describe("Entity slugs to include in dashboard"),
466
466
  layout: z4.enum(["grid", "table", "mixed"]).optional().describe("Dashboard layout style"),
@@ -489,48 +489,44 @@ function registerDashboardTools(server2) {
489
489
  ];
490
490
  if (layout === "grid" || layout === "mixed") {
491
491
  for (const slug of entities) {
492
- yamlLines.push(` - stats_grid:`);
493
- yamlLines.push(` label: "${slug}-stats"`);
494
- yamlLines.push(` heading:`);
495
- yamlLines.push(
496
- ` text: "${slug.charAt(0).toUpperCase() + slug.slice(1)} Overview"`
497
- );
498
- yamlLines.push(` textSize: "h2"`);
499
- yamlLines.push(` stats:`);
500
- yamlLines.push(` - label: "Total"`);
501
- yamlLines.push(` value: "{{ ${slug}.count }}"`);
502
- yamlLines.push(` icon: "Database"`);
503
- yamlLines.push(` source: "${slug}"`);
504
- yamlLines.push(` background: "surface"`);
492
+ yamlLines.push(` - type: grid`);
493
+ yamlLines.push(` columns: 4`);
494
+ yamlLines.push(` items:`);
495
+ yamlLines.push(` - type: metric_card`);
496
+ yamlLines.push(` label: "Total"`);
497
+ yamlLines.push(` value: {{ ${slug}.count }}`);
498
+ yamlLines.push(` icon: Database`);
499
+ yamlLines.push(` - type: metric_card`);
500
+ yamlLines.push(` label: "Active"`);
501
+ yamlLines.push(` value: 0`);
502
+ yamlLines.push(` icon: CheckCircle`);
505
503
  yamlLines.push("");
506
504
  }
507
505
  }
508
- yamlLines.push(` - collection_listing:`);
509
- yamlLines.push(` label: "${entities[0]}-listing"`);
510
- yamlLines.push(` heading:`);
511
- yamlLines.push(
512
- ` text: "${entities.map((e) => e.charAt(0).toUpperCase() + e.slice(1)).join(" & ")}"`
513
- );
514
- yamlLines.push(` textSize: "h2"`);
515
- yamlLines.push(` collection: "${entities[0]}"`);
516
- yamlLines.push(` showFilters: true`);
517
- yamlLines.push(` showSearch: true`);
518
- const defaultCols = ['"id"', '"name"', '"status"', '"created_at"'];
506
+ yamlLines.push(` - type: collection_list`);
507
+ yamlLines.push(` label: ${entities[0]}-listing`);
508
+ yamlLines.push(` collection: ${entities[0]}`);
509
+ yamlLines.push(` showFilters: true`);
510
+ yamlLines.push(` showSearch: true`);
511
+ const defaultCols = ["id", "name", "status", "created_at"];
519
512
  yamlLines.push(
520
- ` columns: ${entityDetails[0]?.fields?.slice(0, 4).map((f) => `"${f.name}"`).join(", ") || defaultCols.join(", ")}`
513
+ ` columns: ${entityDetails[0]?.fields?.slice(0, 4).map((f) => f.name).join(", ") || defaultCols.join(", ")}`
521
514
  );
522
- yamlLines.push(` background: "background"`);
523
515
  yamlLines.push("");
524
516
  if (layout === "table") {
525
- yamlLines.push(` - data_table:`);
526
- yamlLines.push(` label: "${entities[0]}-table"`);
527
- yamlLines.push(` heading:`);
528
- yamlLines.push(` text: "Detailed View"`);
529
- yamlLines.push(` textSize: "h3"`);
530
- yamlLines.push(` collection: "${entities[0]}"`);
531
- yamlLines.push(` sortableColumns: true`);
532
- yamlLines.push(` exportable: true`);
533
- yamlLines.push(` background: "surface"`);
517
+ yamlLines.push(` - type: data_table`);
518
+ yamlLines.push(` label: ${entities[0]}-table`);
519
+ yamlLines.push(` collection: ${entities[0]}`);
520
+ yamlLines.push(` columns:`);
521
+ yamlLines.push(` - field: id`);
522
+ yamlLines.push(` header: ID`);
523
+ yamlLines.push(` sortable: true`);
524
+ yamlLines.push(` - field: name`);
525
+ yamlLines.push(` header: Name`);
526
+ yamlLines.push(` sortable: true`);
527
+ yamlLines.push(` - field: status`);
528
+ yamlLines.push(` header: Status`);
529
+ yamlLines.push(` type: badge`);
534
530
  }
535
531
  const yaml = yamlLines.join("\n");
536
532
  return {
@@ -632,15 +628,580 @@ ${yaml}
632
628
  );
633
629
  }
634
630
 
631
+ // src/tools/clarification.ts
632
+ import { z as z5 } from "zod";
633
+ import { spawn } from "child_process";
634
+ import { tmpdir } from "os";
635
+ import { join } from "path";
636
+ import { randomUUID } from "crypto";
637
+ import { existsSync, unlinkSync } from "fs";
638
+ var activeServers = /* @__PURE__ */ new Map();
639
+ async function startPythonServer(sessionId) {
640
+ const socketPath = join(tmpdir(), `otter-raft-${randomUUID()}.sock`);
641
+ const port = 8765 + Math.floor(Math.random() * 100);
642
+ return new Promise((resolve, reject) => {
643
+ const pythonPath = process.platform === "win32" ? "python" : "python3";
644
+ const packageRoot = findPythonPackageRoot();
645
+ const proc = spawn(
646
+ pythonPath,
647
+ ["-m", "stackwright_pro.raft.server", "--socket", socketPath, "--port", String(port)],
648
+ {
649
+ stdio: ["pipe", "pipe", "pipe"],
650
+ env: {
651
+ ...process.env,
652
+ PYTHONPATH: packageRoot
653
+ }
654
+ }
655
+ );
656
+ activeServers.set(sessionId, proc);
657
+ let startupOutput = "";
658
+ let started = false;
659
+ proc.stdout?.on("data", (data) => {
660
+ startupOutput += data.toString();
661
+ if (startupOutput.includes("Server ready") || startupOutput.includes("HTTP server")) {
662
+ started = true;
663
+ resolve({ port, socketPath });
664
+ }
665
+ });
666
+ proc.stderr?.on("data", (data) => {
667
+ if (!startupOutput.includes("Starting")) {
668
+ console.error("[Python Clarification]", data.toString().trim());
669
+ }
670
+ });
671
+ proc.on("error", (err) => {
672
+ if (!started) {
673
+ activeServers.delete(sessionId);
674
+ reject(new Error(`Failed to start Python server: ${err.message}`));
675
+ }
676
+ });
677
+ proc.on("exit", (code) => {
678
+ activeServers.delete(sessionId);
679
+ if (existsSync(socketPath)) {
680
+ try {
681
+ unlinkSync(socketPath);
682
+ } catch {
683
+ }
684
+ }
685
+ });
686
+ setTimeout(() => {
687
+ if (!started) {
688
+ proc.kill();
689
+ activeServers.delete(sessionId);
690
+ reject(new Error("Python server startup timeout"));
691
+ }
692
+ }, 1e4);
693
+ });
694
+ }
695
+ async function stopPythonServer(sessionId) {
696
+ const proc = activeServers.get(sessionId);
697
+ if (proc) {
698
+ proc.kill("SIGTERM");
699
+ activeServers.delete(sessionId);
700
+ }
701
+ }
702
+ async function httpRequest(host, port, path4, body) {
703
+ const http = await import("http");
704
+ return new Promise((resolve, reject) => {
705
+ const url = new URL(path4, `http://${host}:${port}`);
706
+ const reqOptions = {
707
+ hostname: host,
708
+ port,
709
+ path: url.pathname,
710
+ method: body ? "POST" : "GET",
711
+ headers: {
712
+ "Content-Type": "application/json"
713
+ }
714
+ };
715
+ const req = http.request(reqOptions, (res) => {
716
+ let data = "";
717
+ res.on("data", (chunk) => {
718
+ data += chunk.toString();
719
+ });
720
+ res.on("end", () => {
721
+ try {
722
+ const parsed = JSON.parse(data);
723
+ if (res.statusCode && res.statusCode >= 400) {
724
+ reject(new Error(parsed.detail || parsed.error || `HTTP ${res.statusCode}`));
725
+ } else {
726
+ resolve(parsed);
727
+ }
728
+ } catch (e) {
729
+ reject(new Error(`Failed to parse response: ${data}`));
730
+ }
731
+ });
732
+ });
733
+ req.on("error", reject);
734
+ if (body) {
735
+ req.write(JSON.stringify(body));
736
+ }
737
+ req.end();
738
+ });
739
+ }
740
+ function findPythonPackageRoot() {
741
+ const candidates = [
742
+ join(__dirname, "../../python/src"),
743
+ join(__dirname, "../../../python/src"),
744
+ join(process.cwd(), "python/src")
745
+ ];
746
+ for (const candidate of candidates) {
747
+ if (existsSync(candidate)) {
748
+ return candidate;
749
+ }
750
+ }
751
+ return process.cwd();
752
+ }
753
+ function registerClarificationTools(server2) {
754
+ server2.tool(
755
+ "stackwright_pro_clarify",
756
+ "Ask the user for clarification when an otter encounters ambiguity. This is for MID-EXECUTION questions, not upfront question collection. Use this when the otter needs user input to proceed. Returns the user's decision which should be used to continue execution.",
757
+ {
758
+ context: z5.string().optional().describe("Context about what the otter is trying to do"),
759
+ question_type: z5.enum(["closed_choice", "open_text", "conditional", "multi_step", "reconciliation"]).describe("Type of question being asked"),
760
+ question: z5.string().describe("The clarification question to ask the user"),
761
+ choices: z5.array(z5.string()).optional().describe("Options for closed_choice or multi_step questions"),
762
+ priority: z5.enum(["blocking", "preferred", "optional"]).optional().describe("How critical is this clarification? Default: preferred"),
763
+ target_field: z5.string().optional().describe("What field/config does this clarify?")
764
+ },
765
+ async ({ context, question_type, question, choices, priority = "preferred", target_field }) => {
766
+ const sessionId = `mcp_${randomUUID().slice(0, 8)}`;
767
+ try {
768
+ const { port } = await startPythonServer(sessionId);
769
+ await httpRequest(port === 8765 ? "127.0.0.1" : "127.0.0.1", port, "/sessions", {});
770
+ if (context) {
771
+ await httpRequest(
772
+ port === 8765 ? "127.0.0.1" : "127.0.0.1",
773
+ port,
774
+ `/sessions/${sessionId}/context`,
775
+ { context: { purpose: context } }
776
+ );
777
+ }
778
+ const request = {
779
+ ...context !== void 0 && { context },
780
+ question_type,
781
+ question,
782
+ ...choices !== void 0 && { choices },
783
+ priority,
784
+ ...target_field !== void 0 && { target_field }
785
+ };
786
+ const response = await httpRequest(
787
+ port === 8765 ? "127.0.0.1" : "127.0.0.1",
788
+ port,
789
+ "/clarify",
790
+ { request }
791
+ );
792
+ await stopPythonServer(sessionId);
793
+ const decision = response.decision;
794
+ const value = decision.value;
795
+ const source = decision.source;
796
+ const explicit = decision.explicit ? "explicitly" : "via fallback";
797
+ if (response.fallback_used) {
798
+ return {
799
+ content: [
800
+ {
801
+ type: "text",
802
+ text: `\u26A0\uFE0F Clarification fallback used: ${response.fallback_reason || "No user input available"}
803
+
804
+ Default value used: ${JSON.stringify(value)}
805
+
806
+ \u{1F4A1} Consider following up with the user later if this default isn't appropriate.`
807
+ }
808
+ ]
809
+ };
810
+ }
811
+ return {
812
+ content: [
813
+ {
814
+ type: "text",
815
+ text: `\u2705 User clarified (${source}): ${JSON.stringify(value)}
816
+
817
+ Use this value to continue execution.`
818
+ }
819
+ ]
820
+ };
821
+ } catch (error) {
822
+ await stopPythonServer(sessionId);
823
+ return {
824
+ content: [
825
+ {
826
+ type: "text",
827
+ text: `\u274C Clarification failed: ${error instanceof Error ? error.message : "Unknown error"}
828
+
829
+ Cannot proceed without user input. Consider:
830
+ 1. Using a reasonable default
831
+ 2. Asking the user directly in your response
832
+ 3. Skipping this step if optional`
833
+ }
834
+ ],
835
+ isError: true
836
+ };
837
+ }
838
+ }
839
+ );
840
+ server2.tool(
841
+ "stackwright_pro_detect_conflict",
842
+ "Detect when a user's stated preference conflicts with their selected choices. Use this to identify when users might be indecisive or misunderstood a question. Returns conflict details and resolution options.",
843
+ {
844
+ stated_preference: z5.string().describe("What the user said they wanted"),
845
+ selected_values: z5.record(z5.string(), z5.string()).describe("What the user actually selected")
846
+ },
847
+ async ({ stated_preference, selected_values }) => {
848
+ const sessionId = `mcp_${randomUUID().slice(0, 8)}`;
849
+ try {
850
+ const { port } = await startPythonServer(sessionId);
851
+ const response = await httpRequest(
852
+ port === 8765 ? "127.0.0.1" : "127.0.0.1",
853
+ port,
854
+ "/conflict",
855
+ { stated_preference, selected_values }
856
+ );
857
+ await stopPythonServer(sessionId);
858
+ if (response.conflict) {
859
+ const data = response.data;
860
+ return {
861
+ content: [
862
+ {
863
+ type: "text",
864
+ text: `\u26A0\uFE0F CONFLICT DETECTED
865
+
866
+ User stated: "${stated_preference}"
867
+ But selected: ${JSON.stringify(selected_values)}
868
+
869
+ Conflict: ${data.description}
870
+
871
+ Resolution options: ${data.options?.join(", ") || "Ask user to clarify"}
872
+
873
+ \u{1F4A1} Consider asking the user to reconcile this conflict.`
874
+ }
875
+ ]
876
+ };
877
+ }
878
+ return {
879
+ content: [
880
+ {
881
+ type: "text",
882
+ text: `\u2705 No conflict detected between stated preference and selections.`
883
+ }
884
+ ]
885
+ };
886
+ } catch (error) {
887
+ await stopPythonServer(sessionId);
888
+ return {
889
+ content: [
890
+ {
891
+ type: "text",
892
+ text: `\u274C Conflict detection failed: ${error instanceof Error ? error.message : "Unknown error"}`
893
+ }
894
+ ],
895
+ isError: true
896
+ };
897
+ }
898
+ }
899
+ );
900
+ server2.tool(
901
+ "stackwright_pro_get_defaults",
902
+ "Get the current clarification defaults from config. Use this to understand what fallback values will be used if user doesn't provide input.",
903
+ {
904
+ config_path: z5.string().optional().describe("Path to config file. Default: .stackwright/clarification.yaml")
905
+ },
906
+ async ({ config_path }) => {
907
+ return {
908
+ content: [
909
+ {
910
+ type: "text",
911
+ text: `\u{1F4CB} Clarification defaults
912
+
913
+ Configuration is loaded from:
914
+ - Environment: CLARIFICATION_* variables
915
+ - Config file: ${config_path || ".stackwright/clarification.yaml"}
916
+ - CLI args: --clarify-* flags
917
+
918
+ Default behaviors:
919
+ - allow_dont_know: true (users can skip)
920
+ - default_timeout: 120 seconds
921
+ - channel_priority: [tui, cli_args, config, defaults]
922
+
923
+ \u{1F4A1} Set CLARIFICATION_DEFAULT_<FIELD>=value to change defaults.`
924
+ }
925
+ ]
926
+ };
927
+ }
928
+ );
929
+ }
930
+
931
+ // src/tools/packages.ts
932
+ import { z as z6 } from "zod";
933
+ import { readFileSync, writeFileSync, existsSync as existsSync2, realpathSync, lstatSync } from "fs";
934
+ import { execSync } from "child_process";
935
+ import path2 from "path";
936
+ function registerPackageTools(server2) {
937
+ server2.tool(
938
+ "stackwright_pro_setup_packages",
939
+ "Ensures pro packages are present in a project's package.json. Safe to call multiple times \u2014 never overwrites existing version pins. Use this to bootstrap dependencies before specialist otters run.",
940
+ {
941
+ // FIX 3 (B-new-1): Zod v4 requires two-arg z.record(keySchema, valueSchema)
942
+ packages: z6.record(z6.string(), z6.string()).describe(
943
+ 'Dependencies to add. Record<packageName, version>. e.g. { "@stackwright-pro/auth": "latest" }'
944
+ ),
945
+ devPackages: z6.record(z6.string(), z6.string()).optional().describe(
946
+ "devDependencies to add. Same format as packages."
947
+ ),
948
+ scripts: z6.record(z6.string(), z6.string()).optional().describe(
949
+ "npm scripts to add. Only adds if key does not already exist."
950
+ ),
951
+ targetDir: z6.string().optional().describe(
952
+ "Project directory containing package.json. Defaults to process.cwd(). Must be an absolute path within the current working directory."
953
+ ),
954
+ runInstall: z6.boolean().optional().default(true).describe(
955
+ "Run pnpm install after writing package.json. Defaults to true."
956
+ )
957
+ },
958
+ async ({ packages, devPackages, scripts, targetDir, runInstall }) => {
959
+ const result = setupPackages({
960
+ packages,
961
+ devPackages,
962
+ scripts,
963
+ targetDir,
964
+ runInstall
965
+ });
966
+ const statusLine = result.success ? `\u2705 package.json updated: ${result.packageJsonPath}` : `\u274C Failed: ${result.error}`;
967
+ const lines = [
968
+ statusLine,
969
+ "",
970
+ result.added.length > 0 ? `Added (${result.added.length}): ${result.added.join(", ")}` : "Added: none",
971
+ result.skipped.length > 0 ? `Skipped/already present (${result.skipped.length}): ${result.skipped.join(", ")}` : "Skipped: none",
972
+ result.scriptsAdded.length > 0 ? `Scripts added (${result.scriptsAdded.length}): ${result.scriptsAdded.join(", ")}` : "Scripts added: none",
973
+ `pnpm install: ${result.installed ? "ran successfully" : result.success && runInstall ? "failed (non-fatal)" : "skipped"}`
974
+ ];
975
+ return {
976
+ content: [
977
+ {
978
+ type: "text",
979
+ text: lines.join("\n")
980
+ },
981
+ {
982
+ type: "text",
983
+ text: JSON.stringify(result)
984
+ }
985
+ ]
986
+ };
987
+ }
988
+ );
989
+ }
990
+ function setupPackages(opts) {
991
+ const emptyResult = {
992
+ added: [],
993
+ skipped: [],
994
+ scriptsAdded: [],
995
+ installed: false,
996
+ packageJsonPath: ""
997
+ };
998
+ try {
999
+ const cwd = process.cwd();
1000
+ const resolvedTarget = opts.targetDir ? path2.resolve(opts.targetDir) : cwd;
1001
+ const cwdWithSep = cwd.endsWith(path2.sep) ? cwd : cwd + path2.sep;
1002
+ if (resolvedTarget !== cwd && !resolvedTarget.startsWith(cwdWithSep)) {
1003
+ return {
1004
+ success: false,
1005
+ added: [],
1006
+ skipped: [],
1007
+ scriptsAdded: [],
1008
+ installed: false,
1009
+ packageJsonPath: "",
1010
+ // FIX 5 (M-4): do not leak absolute paths in error messages
1011
+ error: `Path traversal rejected: target directory is outside the allowed working directory`
1012
+ };
1013
+ }
1014
+ const preResolvePackageJsonPath = path2.join(resolvedTarget, "package.json");
1015
+ if (!existsSync2(preResolvePackageJsonPath)) {
1016
+ return {
1017
+ success: false,
1018
+ ...emptyResult,
1019
+ error: `No package.json found in ${resolvedTarget}`
1020
+ };
1021
+ }
1022
+ let realTarget;
1023
+ try {
1024
+ realTarget = realpathSync(resolvedTarget);
1025
+ } catch {
1026
+ return {
1027
+ success: false,
1028
+ added: [],
1029
+ skipped: [],
1030
+ scriptsAdded: [],
1031
+ installed: false,
1032
+ packageJsonPath: "",
1033
+ error: `Could not resolve real path of target directory`
1034
+ };
1035
+ }
1036
+ const realCwd = realpathSync(cwd);
1037
+ const realCwdWithSep = realCwd.endsWith(path2.sep) ? realCwd : realCwd + path2.sep;
1038
+ if (realTarget !== realCwd && !realTarget.startsWith(realCwdWithSep)) {
1039
+ return {
1040
+ success: false,
1041
+ added: [],
1042
+ skipped: [],
1043
+ scriptsAdded: [],
1044
+ installed: false,
1045
+ packageJsonPath: "",
1046
+ // FIX 5 (M-4): do not leak absolute paths in error messages
1047
+ error: `Path traversal rejected: target directory resolved to a location outside the allowed working directory`
1048
+ };
1049
+ }
1050
+ const realPackageJsonPath = path2.join(realTarget, "package.json");
1051
+ const pkgStat = lstatSync(realPackageJsonPath);
1052
+ if (pkgStat.isSymbolicLink()) {
1053
+ return {
1054
+ ...emptyResult,
1055
+ success: false,
1056
+ error: `package.json is a symlink \u2014 refusing to read or write`
1057
+ };
1058
+ }
1059
+ const VALID_PKG_NAME_RE = /^(@[a-z0-9][a-z0-9\-._~]*\/)?[a-z0-9][a-z0-9\-._~]*$/;
1060
+ const allPkgNames = [
1061
+ ...Object.keys(opts.packages ?? {}),
1062
+ ...Object.keys(opts.devPackages ?? {})
1063
+ ];
1064
+ for (const pkg of allPkgNames) {
1065
+ if (!VALID_PKG_NAME_RE.test(pkg)) {
1066
+ return {
1067
+ ...emptyResult,
1068
+ success: false,
1069
+ error: `Invalid package name: '${pkg}' \u2014 must match npm package name specification`
1070
+ };
1071
+ }
1072
+ }
1073
+ const SAFE_VERSION_RE = /^(workspace:[*^~]?|\*|latest|next|beta|alpha|canary|rc|[~^><=*|, ]*[\d.x*][\d.x*\-+a-zA-Z.~^><=*|, ]*)$/;
1074
+ const allVersionEntries = [
1075
+ ...Object.entries(opts.packages ?? {}),
1076
+ ...Object.entries(opts.devPackages ?? {})
1077
+ ];
1078
+ for (const [pkg, version] of allVersionEntries) {
1079
+ if (!SAFE_VERSION_RE.test(version.trim())) {
1080
+ return {
1081
+ ...emptyResult,
1082
+ success: false,
1083
+ error: `Unsafe version specifier for '${pkg}': '${version}' \u2014 only semver ranges, workspace:*, and dist-tags (latest/next/beta) are permitted`
1084
+ };
1085
+ }
1086
+ }
1087
+ const BLOCKED_LIFECYCLE_KEYS = /* @__PURE__ */ new Set([
1088
+ "preinstall",
1089
+ "install",
1090
+ "postinstall",
1091
+ "prepare",
1092
+ "prepublish",
1093
+ "prepublishOnly",
1094
+ "prepack",
1095
+ "postpack",
1096
+ "dependencies"
1097
+ // pnpm hook key
1098
+ ]);
1099
+ if (opts.scripts) {
1100
+ for (const key of Object.keys(opts.scripts)) {
1101
+ if (BLOCKED_LIFECYCLE_KEYS.has(key)) {
1102
+ return {
1103
+ ...emptyResult,
1104
+ success: false,
1105
+ error: `Blocked lifecycle script key: '${key}' \u2014 writing npm lifecycle hooks is not permitted`
1106
+ };
1107
+ }
1108
+ }
1109
+ }
1110
+ const raw = readFileSync(realPackageJsonPath, "utf8");
1111
+ const PackageJsonSchema = z6.object({
1112
+ // Zod v4: z.record(keySchema, valueSchema) — two-arg form required
1113
+ dependencies: z6.record(z6.string(), z6.string()).optional(),
1114
+ devDependencies: z6.record(z6.string(), z6.string()).optional(),
1115
+ scripts: z6.record(z6.string(), z6.string()).optional()
1116
+ }).passthrough();
1117
+ const schemaResult = PackageJsonSchema.safeParse(JSON.parse(raw));
1118
+ if (!schemaResult.success) {
1119
+ return {
1120
+ ...emptyResult,
1121
+ success: false,
1122
+ error: `Invalid package.json structure: ${schemaResult.error.message}`
1123
+ };
1124
+ }
1125
+ const parsed = schemaResult.data;
1126
+ const added = [];
1127
+ const skipped = [];
1128
+ const scriptsAdded = [];
1129
+ const claimedPkgs = /* @__PURE__ */ new Set([
1130
+ ...Object.keys(parsed.dependencies ?? {}),
1131
+ ...Object.keys(parsed.devDependencies ?? {})
1132
+ ]);
1133
+ parsed.dependencies = parsed.dependencies ?? {};
1134
+ for (const [pkg, version] of Object.entries(opts.packages)) {
1135
+ if (claimedPkgs.has(pkg)) {
1136
+ skipped.push(pkg);
1137
+ } else {
1138
+ parsed.dependencies[pkg] = version;
1139
+ claimedPkgs.add(pkg);
1140
+ added.push(pkg);
1141
+ }
1142
+ }
1143
+ if (opts.devPackages && Object.keys(opts.devPackages).length > 0) {
1144
+ parsed.devDependencies = parsed.devDependencies ?? {};
1145
+ for (const [pkg, version] of Object.entries(opts.devPackages)) {
1146
+ if (claimedPkgs.has(pkg)) {
1147
+ skipped.push(pkg);
1148
+ } else {
1149
+ parsed.devDependencies[pkg] = version;
1150
+ claimedPkgs.add(pkg);
1151
+ added.push(pkg);
1152
+ }
1153
+ }
1154
+ }
1155
+ if (opts.scripts && Object.keys(opts.scripts).length > 0) {
1156
+ parsed.scripts = parsed.scripts ?? {};
1157
+ for (const [key, value] of Object.entries(opts.scripts)) {
1158
+ if (parsed.scripts[key] === void 0) {
1159
+ parsed.scripts[key] = value;
1160
+ scriptsAdded.push(key);
1161
+ }
1162
+ }
1163
+ }
1164
+ writeFileSync(realPackageJsonPath, JSON.stringify(parsed, null, 2) + "\n");
1165
+ let installed = false;
1166
+ let installError;
1167
+ if (opts.runInstall) {
1168
+ try {
1169
+ execSync("pnpm install", { cwd: realTarget, stdio: "pipe", timeout: 6e4 });
1170
+ installed = true;
1171
+ } catch (err) {
1172
+ installed = false;
1173
+ const spawnErr = err;
1174
+ installError = spawnErr.stderr?.toString().trim() || (err instanceof Error ? err.message : "unknown error");
1175
+ }
1176
+ }
1177
+ return {
1178
+ success: true,
1179
+ added,
1180
+ skipped,
1181
+ scriptsAdded,
1182
+ installed,
1183
+ packageJsonPath: realPackageJsonPath,
1184
+ ...installError !== void 0 ? { installError } : {}
1185
+ };
1186
+ } catch (err) {
1187
+ return { ...emptyResult, success: false, error: err instanceof Error ? err.message : String(err) };
1188
+ }
1189
+ }
1190
+
635
1191
  // src/server.ts
1192
+ import { readFileSync as readFileSync2 } from "fs";
1193
+ import path3 from "path";
1194
+ var packageJson = JSON.parse(readFileSync2(path3.join(__dirname, "package.json"), "utf8"));
636
1195
  var server = new McpServer({
637
1196
  name: "stackwright-pro",
638
- version: "0.1.0"
1197
+ version: packageJson.version
639
1198
  });
640
1199
  registerDataExplorerTools(server);
641
1200
  registerSecurityTools(server);
642
1201
  registerIsrTools(server);
643
1202
  registerDashboardTools(server);
1203
+ registerClarificationTools(server);
1204
+ registerPackageTools(server);
644
1205
  async function main() {
645
1206
  const transport = new StdioServerTransport();
646
1207
  await server.connect(transport);