@myspec/mcp-server 0.5.0-next.112 → 0.5.0-next.114

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 (3) hide show
  1. package/README.md +29 -0
  2. package/dist/index.js +775 -71
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -303,6 +303,7 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
303
303
  | `list_spec_sessions` | `project_id`, optional `archive_filter` (`active`/`archived`/`all`), optional `status`, optional `query`, optional `limit`, optional `offset` | Paginated session metadata plus `session_summary` and `context_size_bytes` — never the context itself or the chat transcript. Default 10 per page, capped at 20 |
304
304
  | `get_spec_session` | `session_id`, optional `include_context` | Session metadata (status, counters, timestamps, `archived_at`), `session_summary`, `context_size_bytes` and `context_keys`. `include_context=true` returns the full context under a 1 MiB ceiling (above it the tool errors with the actual size). The chat transcript is never returned |
305
305
  | `rename_spec_session` | `session_id`, `session_summary` | Sets `context.sessionSummary` (max 500 chars) via read-merge-write, preserving every other context key; works on archived sessions; refuses when the context is not a JSON object. Returns `renamed`, `session_id`, `session_summary` |
306
+ | `start_spec_session` | `project_id`, `mode` (`generate`/`edit`), `prompt`, optional `workflow_id`, `language`, `model_id`, `attachment_ids[]`, `spec_file_ids[]`, `session_title` | Starts a NEW chat session with an opening prompt you compose, runs its first turn through the webapp, and returns `session_url`, `assistant_text`, `session_id`, `project_id`, `pending_action` and `next_step_instruction` (plus `first_turn_error` / `warnings` when relevant). One call = one real session and real model spend, so call it once per intent and check `list_spec_sessions` before retrying after a timeout. Waits up to 75 s for the first reply — one budget covering both the request and the stream that follows — then returns `pending_action: turn_still_running` while the turn finishes in the background. `attachment_ids` / `spec_file_ids` are capped at 25 each and resolved before the session is created. See [Brownfield sessions](#brownfield-sessions-start-the-bridge-first) |
306
307
  | `archive_spec_session` | `session_id` | Archives the session (idempotent — re-archiving keeps the original `archived_at`); the required confirmation step before `delete_spec_session` |
307
308
  | `unarchive_spec_session` | `session_id` | Returns an archived session to the active list |
308
309
  | `delete_spec_session` | `session_id` | Soft-deletes an ARCHIVED session. Refuses non-archived sessions with an archive-first error and never archives on the caller's behalf |
@@ -313,6 +314,34 @@ Local state is stored under `~/.myspec/` (alongside the on-disk cache root), spl
313
314
  | `write_artifact_revision` | `artifact_id`, `files[]`, optional `base_revision` (patch mode), `remove[]`, `expected_version`, `message`, `entry_path` | Appends an immutable revision. Full mode replaces the whole bundle; patch mode (with `base_revision`) sends only changed files plus paths to `remove`. A stale `expected_version` is reported as a conflict naming the new head — re-read with `get_artifact` and retry |
314
315
  | `rollback_artifact` | `artifact_id`, `revision_number`, optional `expected_version`, `message` | Restores an earlier revision by APPENDING a revision with its files (`git revert` semantics); history is never rewritten, but only the newest N revisions are retained (50 by default, `ARTIFACT_MAX_REVISIONS` on the platform), so a pruned target answers not found |
315
316
 
317
+ ### Brownfield sessions: start the bridge first
318
+
319
+ `start_spec_session` hands MySpec a prompt; it does **not** hand it your files.
320
+ For a **brownfield** generate workflow (`openspec-brownfield-v1`,
321
+ `myspec-brownfield-v1`) the cloud agent reads your codebase over the reverse-MCP
322
+ bridge, so the ordering is a contract:
323
+
324
+ ```bash
325
+ # 1. Start the bridge and LEAVE IT RUNNING
326
+ npx -y @myspec/mcp-server reverse --root <path>
327
+ # 2. Then call start_spec_session
328
+ ```
329
+
330
+ Reverse-MCP presence is keyed by **user**, not by session, so a bridge already
331
+ running under the same account is automatically reachable by the new session's
332
+ first turn — there is nothing to wire up per session. That is also why the
333
+ bridge must **outlive the tool call**: the session keeps exploring the codebase
334
+ while you work in the webapp.
335
+
336
+ The tool warns (it never refuses) when it can see that no bridge is connected.
337
+ If it cannot tell — no agent URL discovered yet, or the ai-agent is unreachable,
338
+ or the presence check itself takes longer than 3 s — it says nothing and starts
339
+ the session anyway.
340
+
341
+ The live check runs for a brownfield **generate** workflow only. An edit session
342
+ against an existing codebase (`myspec-edit-brownfield-v1`) wants the bridge just
343
+ as much; it simply starts without asking.
344
+
316
345
  ### Download destinations
317
346
 
318
347
  `download_spec_file` prefers the **`.specs/` folder in the MCP server's working
package/dist/index.js CHANGED
@@ -2876,6 +2876,35 @@ var PlatformClient = class {
2876
2876
  async getSpecSession(sessionId) {
2877
2877
  return this.withTokenRetry((jwt, c) => c.session.getSpecSession(sessionId, jwt));
2878
2878
  }
2879
+ /**
2880
+ * Creates a spec session with its context seeded in the SAME request
2881
+ * (CR-003 AC1, AR-005).
2882
+ *
2883
+ * The context must be complete here, not written by a follow-up update: the
2884
+ * follow-up can land AFTER the first chat request, leaving
2885
+ * `context.workflow.workflowId` unset so the ai-agent falls back to its
2886
+ * default edit workflow (issue #1045).
2887
+ */
2888
+ async createSession(projectId, context) {
2889
+ return this.withTokenRetry((jwt, c) => c.session.createSpecSession({ projectId, context }, jwt));
2890
+ }
2891
+ /**
2892
+ * A currently-valid access token, for a tool that must make its own
2893
+ * authenticated request rather than go through the platform clients
2894
+ * (CR-003 AC2) — `start_spec_session` calls the webapp's chat proxy.
2895
+ *
2896
+ * Paired with {@link forceRefreshAccessToken}, mirroring the
2897
+ * `getAccessToken` / `forceRefresh` pair `discoverConfig` already consumes,
2898
+ * so the one-refresh-and-retry idiom is written once and used identically
2899
+ * everywhere (AR-016).
2900
+ */
2901
+ async getAccessToken() {
2902
+ return this.tokenManager.getValidAccessToken();
2903
+ }
2904
+ /** Forces a token refresh and returns the new token. See {@link getAccessToken}. */
2905
+ async forceRefreshAccessToken() {
2906
+ return this.tokenManager.forceRefresh();
2907
+ }
2879
2908
  /**
2880
2909
  * Writes ONLY the session's `context` field (rename's read-merge-write).
2881
2910
  * Deliberately narrow: status, chatHistory and the counters stay
@@ -5046,6 +5075,8 @@ var FILE_TYPE_BY_BASENAME = {
5046
5075
  requirements: "requirements",
5047
5076
  solution: "solution",
5048
5077
  tasks: "tasks",
5078
+ // Enterprise greenfield quality plan (generate-greenfield-enterprise).
5079
+ quality: "quality",
5049
5080
  proposal: "proposal"
5050
5081
  };
5051
5082
  function deriveFileType(filePath) {
@@ -5060,6 +5091,7 @@ var SPEC_FILE_TYPES = [
5060
5091
  "requirements",
5061
5092
  "solution",
5062
5093
  "tasks",
5094
+ "quality",
5063
5095
  "proposal",
5064
5096
  "openspec-spec"
5065
5097
  ];
@@ -5537,46 +5569,650 @@ var revokeStreamTokenTool = {
5537
5569
  }
5538
5570
  };
5539
5571
 
5572
+ // src/server/tools/start_spec_session.ts
5573
+ import { z as z37 } from "zod";
5574
+
5575
+ // src/server/tools/start-session-core.ts
5576
+ var START_SESSION_WAIT_MS = 75e3;
5577
+ var MAX_REFERENCE_IDS = 25;
5578
+ var SESSION_TITLE_MAX_LENGTH = 500;
5579
+ function httpErrorStatus(err) {
5580
+ if (typeof err !== "object" || err === null) {
5581
+ return void 0;
5582
+ }
5583
+ const candidate = err.status;
5584
+ if (typeof candidate === "number") {
5585
+ return candidate;
5586
+ }
5587
+ const message = err.message;
5588
+ if (typeof message !== "string") {
5589
+ return void 0;
5590
+ }
5591
+ const match = /HTTP (\d{3}):/.exec(message);
5592
+ return match ? Number(match[1]) : void 0;
5593
+ }
5594
+ var SPEC_SESSION_LANGUAGES = ["en", "zh", "ja", "vi"];
5595
+ var GENERATE_WORKFLOW_VARIANT_REFS = [
5596
+ {
5597
+ id: "myspec-greenfield-v1",
5598
+ workflowId: "generate-greenfield-myspec",
5599
+ mode: "generate",
5600
+ scope: "greenfield"
5601
+ },
5602
+ {
5603
+ id: "enterprise-greenfield-v1",
5604
+ workflowId: "generate-greenfield-enterprise",
5605
+ mode: "generate",
5606
+ scope: "greenfield"
5607
+ },
5608
+ {
5609
+ id: "speckit-greenfield-v1",
5610
+ workflowId: "generate-greenfield-speckit",
5611
+ mode: "generate",
5612
+ scope: "greenfield"
5613
+ },
5614
+ {
5615
+ id: "openspec-brownfield-v1",
5616
+ workflowId: "generate-brownfield-openspec",
5617
+ mode: "generate",
5618
+ scope: "brownfield"
5619
+ },
5620
+ {
5621
+ id: "myspec-brownfield-v1",
5622
+ workflowId: "generate-brownfield-myspec",
5623
+ mode: "generate",
5624
+ scope: "brownfield"
5625
+ }
5626
+ ];
5627
+ var EDIT_WORKFLOW_VARIANT_REFS = [
5628
+ {
5629
+ id: "myspec-edit-v1",
5630
+ workflowId: "edit-greenfield-myspec",
5631
+ mode: "edit",
5632
+ scope: "greenfield"
5633
+ },
5634
+ {
5635
+ id: "myspec-edit-brownfield-v1",
5636
+ workflowId: "edit-brownfield-myspec",
5637
+ mode: "edit",
5638
+ scope: "brownfield"
5639
+ }
5640
+ ];
5641
+ var DEFAULT_GENERATE_WORKFLOW_ID = "myspec-greenfield-v1";
5642
+ var DEFAULT_EDIT_WORKFLOW_ID = "myspec-edit-v1";
5643
+ var ALL_WORKFLOW_VARIANT_REFS = [
5644
+ ...GENERATE_WORKFLOW_VARIANT_REFS,
5645
+ ...EDIT_WORKFLOW_VARIANT_REFS
5646
+ ];
5647
+ function knownIdList() {
5648
+ return ALL_WORKFLOW_VARIANT_REFS.map((v) => v.id).join(", ");
5649
+ }
5650
+ function defaultVariant(mode) {
5651
+ const id = mode === "generate" ? DEFAULT_GENERATE_WORKFLOW_ID : DEFAULT_EDIT_WORKFLOW_ID;
5652
+ const variant = ALL_WORKFLOW_VARIANT_REFS.find((v) => v.id === id);
5653
+ if (!variant) {
5654
+ throw new Error(`workflow catalog is missing its default variant "${id}"`);
5655
+ }
5656
+ return variant;
5657
+ }
5658
+ function resolveWorkflow(mode, workflowId) {
5659
+ if (workflowId === void 0 || workflowId === "") {
5660
+ return { ok: true, variant: defaultVariant(mode) };
5661
+ }
5662
+ const variant = ALL_WORKFLOW_VARIANT_REFS.find((v) => v.id === workflowId);
5663
+ if (!variant) {
5664
+ return {
5665
+ ok: false,
5666
+ message: `workflow_id "${workflowId}" is not a known MySpec workflow. Pass one of: ${knownIdList()} \u2014 or omit workflow_id to use the default for mode "${mode}".`
5667
+ };
5668
+ }
5669
+ if (variant.mode !== mode) {
5670
+ return {
5671
+ ok: false,
5672
+ message: `mode "${mode}" contradicts workflow_id "${workflowId}", which is a "${variant.mode}" workflow. Pass mode "${variant.mode}" with this workflow_id, or omit workflow_id to use the default "${mode}" workflow.`
5673
+ };
5674
+ }
5675
+ return { ok: true, variant };
5676
+ }
5677
+ var OPENSPEC_ROOT = "openspec";
5678
+ var OPENSPEC_CHANGES_DIR = "changes";
5679
+ var OPENSPEC_ARCHIVE_DIR = "archive";
5680
+ function bundleNameFromSpecFilePath(filePath) {
5681
+ const segments = filePath.split("/");
5682
+ if (segments[0] === "specs" && segments.length >= 3) {
5683
+ return segments.slice(1, -1).join("/");
5684
+ }
5685
+ if (segments[0] === OPENSPEC_ROOT && segments[1] === OPENSPEC_CHANGES_DIR && segments.length >= 4) {
5686
+ const changeId = segments[2];
5687
+ return changeId === OPENSPEC_ARCHIVE_DIR ? "" : changeId ?? "";
5688
+ }
5689
+ return "";
5690
+ }
5691
+ function specFileDisplayName(filePath) {
5692
+ return filePath.split("/").pop() ?? filePath;
5693
+ }
5694
+ function specFilePartRef(file) {
5695
+ return {
5696
+ specFileId: file.id,
5697
+ name: specFileDisplayName(file.filePath),
5698
+ specType: file.fileType,
5699
+ bundleName: bundleNameFromSpecFilePath(file.filePath),
5700
+ filePath: file.filePath
5701
+ };
5702
+ }
5703
+ function buildPromptParts(prompt, attachments, specFiles) {
5704
+ const parts = [{ type: "text", text: prompt }];
5705
+ for (const attachment of attachments) {
5706
+ parts.push({
5707
+ type: "data-user-attachment-ref",
5708
+ data: {
5709
+ id: attachment.id,
5710
+ name: attachment.name,
5711
+ mimeType: attachment.mimeType,
5712
+ size: attachment.size
5713
+ }
5714
+ });
5715
+ }
5716
+ for (const specFile of specFiles) {
5717
+ parts.push({
5718
+ type: "data-user-specfile-ref",
5719
+ data: {
5720
+ specFileId: specFile.specFileId,
5721
+ name: specFile.name,
5722
+ specType: specFile.specType,
5723
+ bundleName: specFile.bundleName,
5724
+ filePath: specFile.filePath
5725
+ }
5726
+ });
5727
+ }
5728
+ return parts;
5729
+ }
5730
+ function buildInitialSessionContext(workflowId, sessionTitle) {
5731
+ return {
5732
+ workflow: { version: 1, workflowId },
5733
+ ...sessionTitle === void 0 || sessionTitle === "" ? {} : { sessionSummary: sessionTitle }
5734
+ };
5735
+ }
5736
+ function stripTrailingSlash3(value) {
5737
+ return value.endsWith("/") ? value.slice(0, -1) : value;
5738
+ }
5739
+ function buildFirstTurnRequest(input) {
5740
+ return {
5741
+ url: `${stripTrailingSlash3(input.webappUrl)}/api/v1/chat/${input.sessionId}`,
5742
+ method: "POST",
5743
+ headers: {
5744
+ authorization: `Bearer ${input.accessToken}`,
5745
+ "content-type": "application/json",
5746
+ // The header ai-agent already segments analytics on (#81).
5747
+ "x-client-type": "mcp-server"
5748
+ },
5749
+ body: {
5750
+ parts: input.parts,
5751
+ ...input.modelId === void 0 ? {} : { modelId: input.modelId },
5752
+ ...input.language === void 0 ? {} : { language: input.language },
5753
+ version: 1
5754
+ }
5755
+ };
5756
+ }
5757
+ var CARD_PENDING_ACTIONS = {
5758
+ "data-mcq-form": "mcq_form_pending",
5759
+ "data-spec-file": "spec_file_pending_decision",
5760
+ "data-review-feedback": "review_feedback_pending",
5761
+ "data-user-choice-prompt": "user_choice_pending"
5762
+ };
5763
+ function cardPendingAction(frame) {
5764
+ const type = typeof frame.type === "string" ? frame.type : "";
5765
+ const action = CARD_PENDING_ACTIONS[type];
5766
+ if (action === void 0) {
5767
+ return null;
5768
+ }
5769
+ if (type === "data-spec-file" || type === "data-review-feedback") {
5770
+ const data = frame.data;
5771
+ if (typeof data === "object" && data !== null && data.superseded === true) {
5772
+ return null;
5773
+ }
5774
+ }
5775
+ return action;
5776
+ }
5777
+ function applyFrame(raw, state) {
5778
+ if (raw === "[DONE]") {
5779
+ return;
5780
+ }
5781
+ let frame;
5782
+ try {
5783
+ const parsed = JSON.parse(raw);
5784
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
5785
+ return;
5786
+ }
5787
+ frame = parsed;
5788
+ } catch {
5789
+ return;
5790
+ }
5791
+ if (frame.type === "text-delta") {
5792
+ const delta = typeof frame.delta === "string" ? frame.delta : "";
5793
+ state.text += delta;
5794
+ return;
5795
+ }
5796
+ const card = cardPendingAction(frame);
5797
+ if (card !== null) {
5798
+ state.card = card;
5799
+ }
5800
+ }
5801
+ function readFirstTurnStream(body) {
5802
+ const state = {
5803
+ text: "",
5804
+ card: null,
5805
+ error: null
5806
+ };
5807
+ const drained = (async () => {
5808
+ const reader = body.getReader();
5809
+ const decoder = new TextDecoder();
5810
+ let buffer = "";
5811
+ for (; ; ) {
5812
+ const { done, value } = await reader.read();
5813
+ if (done) {
5814
+ break;
5815
+ }
5816
+ buffer += decoder.decode(value, { stream: true });
5817
+ let boundary = buffer.indexOf("\n\n");
5818
+ while (boundary !== -1) {
5819
+ const frame = buffer.slice(0, boundary);
5820
+ buffer = buffer.slice(boundary + 2);
5821
+ for (const line of frame.split("\n")) {
5822
+ if (line.startsWith("data:")) {
5823
+ applyFrame(line.slice(5).trim(), state);
5824
+ }
5825
+ }
5826
+ boundary = buffer.indexOf("\n\n");
5827
+ }
5828
+ }
5829
+ for (const line of buffer.split("\n")) {
5830
+ if (line.startsWith("data:")) {
5831
+ applyFrame(line.slice(5).trim(), state);
5832
+ }
5833
+ }
5834
+ })().catch((err) => {
5835
+ state.error = err instanceof Error ? err.message : String(err);
5836
+ });
5837
+ return {
5838
+ drained,
5839
+ snapshot: () => ({
5840
+ assistantText: state.text,
5841
+ pendingCard: state.card,
5842
+ streamError: state.error
5843
+ })
5844
+ };
5845
+ }
5846
+ function awaitWithinBudget(drained, waitMs) {
5847
+ return new Promise((resolve3) => {
5848
+ const timer = setTimeout(() => {
5849
+ resolve3(false);
5850
+ }, waitMs);
5851
+ void drained.then(
5852
+ () => {
5853
+ clearTimeout(timer);
5854
+ resolve3(true);
5855
+ },
5856
+ () => {
5857
+ clearTimeout(timer);
5858
+ resolve3(true);
5859
+ }
5860
+ );
5861
+ });
5862
+ }
5863
+ function raceWithinBudget(promise, waitMs) {
5864
+ return new Promise((resolve3) => {
5865
+ const timer = setTimeout(() => {
5866
+ resolve3({ status: "expired" });
5867
+ }, waitMs);
5868
+ void promise.then(
5869
+ (value) => {
5870
+ clearTimeout(timer);
5871
+ resolve3({ status: "settled", value });
5872
+ },
5873
+ (error) => {
5874
+ clearTimeout(timer);
5875
+ resolve3({ status: "failed", error });
5876
+ }
5877
+ );
5878
+ });
5879
+ }
5880
+ async function drainLateFirstTurn(body) {
5881
+ if (body === null || body === void 0) {
5882
+ return;
5883
+ }
5884
+ const reader = body.getReader();
5885
+ try {
5886
+ for (; ; ) {
5887
+ const chunk = await reader.read();
5888
+ if (chunk.done) {
5889
+ return;
5890
+ }
5891
+ }
5892
+ } catch {
5893
+ }
5894
+ }
5895
+ function startWaitBudget(totalMs = START_SESSION_WAIT_MS) {
5896
+ const deadline = Date.now() + totalMs;
5897
+ return {
5898
+ remaining: () => Math.max(0, deadline - Date.now())
5899
+ };
5900
+ }
5901
+ var ERROR_DETAIL_MAX_BYTES = 1024;
5902
+ var ERROR_DETAIL_MAX_CHARS = 300;
5903
+ async function readErrorDetail(body, waitMs = 2e3) {
5904
+ if (body === null || body === void 0 || body.locked) {
5905
+ return "";
5906
+ }
5907
+ const reader = body.getReader();
5908
+ const read = (async () => {
5909
+ const decoder = new TextDecoder();
5910
+ let text2 = "";
5911
+ let bytes = 0;
5912
+ for (; ; ) {
5913
+ const chunk = await reader.read();
5914
+ if (chunk.done) {
5915
+ return text2;
5916
+ }
5917
+ bytes += chunk.value.byteLength;
5918
+ text2 += decoder.decode(chunk.value, { stream: true });
5919
+ if (bytes >= ERROR_DETAIL_MAX_BYTES) {
5920
+ return text2;
5921
+ }
5922
+ }
5923
+ })();
5924
+ const race = await raceWithinBudget(read, waitMs);
5925
+ void reader.cancel().catch(() => void 0);
5926
+ if (race.status !== "settled") {
5927
+ return "";
5928
+ }
5929
+ const text = race.value.replace(/\s+/g, " ").trim();
5930
+ return text.length > ERROR_DETAIL_MAX_CHARS ? `${text.slice(0, ERROR_DETAIL_MAX_CHARS)}\u2026` : text;
5931
+ }
5932
+ function buildSessionUrl(webappUrl, projectId, sessionId) {
5933
+ return `${stripTrailingSlash3(webappUrl)}/projects/${projectId}/chats/${sessionId}`;
5934
+ }
5935
+ var HANDOFF_INSTRUCTION = "Give session_url to the user and tell them to open it in MySpec to continue this session. Your part is done: do NOT try to answer MySpec, drive the workflow, or call this tool again for the same intent.";
5936
+ function buildStartSessionResult(input) {
5937
+ const instruction = [HANDOFF_INSTRUCTION];
5938
+ if (input.pendingAction === "turn_still_running") {
5939
+ instruction.push(
5940
+ "The first turn is still in progress; it is being finished in the background, and opening the URL will show the completed turn."
5941
+ );
5942
+ }
5943
+ if (input.firstTurnError !== void 0) {
5944
+ instruction.push(
5945
+ "The session was created but its first turn could not be started, so the prompt has not been sent yet; the user can send it from the session page."
5946
+ );
5947
+ }
5948
+ return {
5949
+ // Every result for a session that WAS created carries the identifiers,
5950
+ // including one reporting a failed first turn (AR-011 AC8).
5951
+ session_url: buildSessionUrl(input.webappUrl, input.projectId, input.sessionId),
5952
+ session_id: input.sessionId,
5953
+ project_id: input.projectId,
5954
+ assistant_text: input.assistantText,
5955
+ pending_action: input.pendingAction,
5956
+ ...input.firstTurnError === void 0 ? {} : { first_turn_error: input.firstTurnError },
5957
+ ...input.warnings === void 0 || input.warnings.length === 0 ? {} : { warnings: [...input.warnings] },
5958
+ next_step_instruction: instruction.join(" ")
5959
+ };
5960
+ }
5961
+ var START_SESSION_DESCRIPTION_BASE = "Start a NEW MySpec chat session on the user's behalf with an opening prompt you compose, run its first turn, and hand back a URL for the user to continue in the MySpec webapp. Compose the prompt yourself from what you know about this repository and the user's intent \u2014 that context is the whole point, and it is lost if the user has to retype it. ONE invocation starts exactly ONE real chat session and consumes model tokens billed to the token owner, so call it ONCE PER INTENT, never in a loop. After a timeout or a transport error, call list_spec_sessions BEFORE invoking again \u2014 the session may already exist, and a retry would create a duplicate. Your responsibility ENDS at relaying the returned session_url to the user: do NOT attempt to drive the MySpec workflow yourself. Arguments: project_id, mode (generate | edit) and prompt are required; workflow_id, language, model_id, attachment_ids, spec_file_ids and session_title are optional. Returns session_url, assistant_text, session_id, project_id, pending_action and next_step_instruction; plus first_turn_error when the session was created but its first turn could not run, and (stdio only) warnings when something about the setup will limit the session \u2014 neither of which means the session is missing, so relay session_url either way.";
5962
+
5963
+ // src/server/tools/start_spec_session.ts
5964
+ var BRIDGE_COMMAND = "npx -y @myspec/mcp-server reverse --root <path>";
5965
+ var inputSchema35 = {
5966
+ project_id: z37.string().min(1).describe("UUID of the project to start the session in."),
5967
+ mode: z37.enum(["generate", "edit"]).describe(
5968
+ "'generate' writes a new spec bundle from scratch; 'edit' changes the spec files of an existing bundle."
5969
+ ),
5970
+ prompt: z37.string().min(1).describe(
5971
+ "The opening message. Write it as the user would: what they want specced, and the repository context you have gathered that they would otherwise have to retype."
5972
+ ),
5973
+ workflow_id: z37.string().optional().describe(
5974
+ "Workflow variant id, e.g. myspec-greenfield-v1, enterprise-greenfield-v1, speckit-greenfield-v1, openspec-brownfield-v1, myspec-brownfield-v1, myspec-edit-v1, myspec-edit-brownfield-v1. Omit to use the default for the chosen mode. Must agree with mode \u2014 a contradiction is rejected, not silently resolved."
5975
+ ),
5976
+ language: z37.enum(SPEC_SESSION_LANGUAGES).optional().describe(
5977
+ "Output language the session locks to on its first turn. Defaults to 'en'. A session's language is immutable once set."
5978
+ ),
5979
+ model_id: z37.string().optional().describe("Model id to run the session with, e.g. openai:gpt-6-astra."),
5980
+ attachment_ids: z37.array(z37.string().min(1)).max(MAX_REFERENCE_IDS).optional().describe(
5981
+ `Project attachment ids to cite in the opening message, as the composer @-mentions do. At most ${String(MAX_REFERENCE_IDS)}.`
5982
+ ),
5983
+ spec_file_ids: z37.array(z37.string().min(1)).max(MAX_REFERENCE_IDS).optional().describe(
5984
+ `Existing spec file ids to cite in the opening message. Required in practice for edit sessions. At most ${String(MAX_REFERENCE_IDS)}.`
5985
+ ),
5986
+ session_title: z37.string().min(1).max(SESSION_TITLE_MAX_LENGTH).optional().describe(
5987
+ `Human-readable session title, stored as context.sessionSummary (max ${String(SESSION_TITLE_MAX_LENGTH)} characters). A workflow that names the session itself during its interview will overwrite it, exactly as in the webapp.`
5988
+ )
5989
+ };
5990
+ var BRIDGE_ORDERING_NOTE = " BROWNFIELD WORKFLOWS: start the reverse-MCP bridge with `" + BRIDGE_COMMAND + "` BEFORE calling this tool, so the very first turn can already read the local codebase. Keep the bridge RUNNING after this tool returns \u2014 the session keeps reading the local codebase while the user works in the webapp.";
5991
+ function httpUrlOrNull(value) {
5992
+ if (value === void 0 || value === "") {
5993
+ return null;
5994
+ }
5995
+ try {
5996
+ const parsed = new URL(value);
5997
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? value : null;
5998
+ } catch {
5999
+ return null;
6000
+ }
6001
+ }
6002
+ function notFoundAsNull(err) {
6003
+ if (httpErrorStatus(err) === 404) {
6004
+ return null;
6005
+ }
6006
+ throw err;
6007
+ }
6008
+ async function resolveAttachments(ctx, projectId, ids) {
6009
+ const found = await Promise.all(
6010
+ ids.map((id) => ctx.client.getAttachmentMetadata(id).catch(notFoundAsNull))
6011
+ );
6012
+ const refs = [];
6013
+ const unresolved = [];
6014
+ ids.forEach((id, index) => {
6015
+ const attachment = found[index];
6016
+ if (attachment?.projectId !== projectId) {
6017
+ unresolved.push(id);
6018
+ return;
6019
+ }
6020
+ refs.push({
6021
+ id: attachment.id,
6022
+ name: attachment.name,
6023
+ mimeType: attachment.mimeType,
6024
+ size: attachment.fileSizeBytes
6025
+ });
6026
+ });
6027
+ return { refs, unresolved };
6028
+ }
6029
+ async function resolveSpecFiles(ctx, projectId, ids) {
6030
+ const found = await Promise.all(
6031
+ ids.map((id) => ctx.client.getFileMetadata(id).catch(notFoundAsNull))
6032
+ );
6033
+ const refs = [];
6034
+ const unresolved = [];
6035
+ ids.forEach((id, index) => {
6036
+ const file = found[index];
6037
+ if (file?.projectId !== projectId) {
6038
+ unresolved.push(id);
6039
+ return;
6040
+ }
6041
+ refs.push(specFilePartRef({ id: file.id, filePath: file.filePath, fileType: file.fileType }));
6042
+ });
6043
+ return { refs, unresolved };
6044
+ }
6045
+ var startSpecSessionTool = {
6046
+ name: "start_spec_session",
6047
+ description: START_SESSION_DESCRIPTION_BASE + BRIDGE_ORDERING_NOTE,
6048
+ inputSchema: inputSchema35,
6049
+ handler: async (args, ctx) => {
6050
+ const webappUrl = httpUrlOrNull(ctx.webappUrl);
6051
+ if (webappUrl === null) {
6052
+ return errorResult(
6053
+ "start_spec_session is unavailable: this server has no webapp URL. No session was created. Check --user-auth-url / MYSPEC_USER_AUTH_URL, which the webapp URL is derived from."
6054
+ );
6055
+ }
6056
+ const resolution = resolveWorkflow(args.mode, args.workflow_id);
6057
+ if (!resolution.ok) {
6058
+ return errorResult(`start_spec_session was NOT started: ${resolution.message}`);
6059
+ }
6060
+ const budget = startWaitBudget();
6061
+ const [attachments, specFiles] = await Promise.all([
6062
+ resolveAttachments(ctx, args.project_id, args.attachment_ids ?? []),
6063
+ resolveSpecFiles(ctx, args.project_id, args.spec_file_ids ?? [])
6064
+ ]);
6065
+ const unresolved = [...attachments.unresolved, ...specFiles.unresolved];
6066
+ if (unresolved.length > 0) {
6067
+ return errorResult(
6068
+ `start_spec_session was NOT started: these ids do not resolve within project ${args.project_id}: ${unresolved.join(", ")}. No session was created.`
6069
+ );
6070
+ }
6071
+ const warnings = [];
6072
+ if (resolution.variant.mode === "generate" && resolution.variant.scope === "brownfield") {
6073
+ const connected = await ctx.probeReverseMcpPresence?.().catch(() => null);
6074
+ if (connected === false) {
6075
+ warnings.push(
6076
+ `No reverse-MCP bridge is connected for this account, so this brownfield session cannot read the local codebase yet. Start one with \`${BRIDGE_COMMAND}\` and leave it running.`
6077
+ );
6078
+ }
6079
+ }
6080
+ const session = await ctx.client.createSession(
6081
+ args.project_id,
6082
+ buildInitialSessionContext(resolution.variant.workflowId, args.session_title)
6083
+ );
6084
+ const failedWith = (assistantText, detail) => jsonResult(
6085
+ buildStartSessionResult({
6086
+ webappUrl,
6087
+ projectId: args.project_id,
6088
+ sessionId: session.id,
6089
+ assistantText,
6090
+ pendingAction: "none",
6091
+ firstTurnError: detail,
6092
+ warnings
6093
+ })
6094
+ );
6095
+ const failed = (detail) => failedWith("", detail);
6096
+ const parts = buildPromptParts(args.prompt, attachments.refs, specFiles.refs);
6097
+ const sendFirstTurn = (token) => {
6098
+ const request = buildFirstTurnRequest({
6099
+ webappUrl,
6100
+ sessionId: session.id,
6101
+ accessToken: token,
6102
+ parts,
6103
+ modelId: args.model_id,
6104
+ language: args.language
6105
+ });
6106
+ return fetch(request.url, {
6107
+ method: request.method,
6108
+ headers: request.headers,
6109
+ body: JSON.stringify(request.body)
6110
+ });
6111
+ };
6112
+ const send = async (token) => {
6113
+ const pending = sendFirstTurn(token);
6114
+ const race = await raceWithinBudget(pending, budget.remaining());
6115
+ if (race.status === "failed") {
6116
+ throw race.error;
6117
+ }
6118
+ if (race.status === "expired") {
6119
+ const lateDrain = pending.then((late) => drainLateFirstTurn(late.body)).catch(() => void 0);
6120
+ void lateDrain;
6121
+ return "expired";
6122
+ }
6123
+ return race.value;
6124
+ };
6125
+ let response;
6126
+ let rejectedOnce = false;
6127
+ try {
6128
+ response = await send(ctx.accessToken ?? await ctx.client.getAccessToken());
6129
+ if (response !== "expired" && response.status === 401) {
6130
+ rejectedOnce = true;
6131
+ await readErrorDetail(response.body);
6132
+ response = await send(await ctx.client.forceRefreshAccessToken());
6133
+ }
6134
+ } catch (err) {
6135
+ return failed(
6136
+ `the first-turn request to the webapp failed: ${err instanceof Error ? err.message : String(err)}`
6137
+ );
6138
+ }
6139
+ if (response === "expired") {
6140
+ const seconds = String(START_SESSION_WAIT_MS / 1e3);
6141
+ return failed(
6142
+ rejectedOnce ? `the webapp rejected the access token (HTTP 401) and the retry ran out of the ${seconds}s budget, so the first turn did not run` : `the webapp did not answer the first-turn request within ${seconds}s; the turn may still be running`
6143
+ );
6144
+ }
6145
+ if (!response.ok || response.body === null) {
6146
+ const base = response.status === 401 ? "the webapp rejected the access token twice (HTTP 401), so the first turn did not run" : `the webapp chat endpoint answered HTTP ${String(response.status)}`;
6147
+ const detail = redactSensitive(await readErrorDetail(response.body));
6148
+ return failed(detail === "" ? base : `${base}: ${detail}`);
6149
+ }
6150
+ const stream = readFirstTurnStream(response.body);
6151
+ const completed = await awaitWithinBudget(stream.drained, budget.remaining());
6152
+ const snapshot = stream.snapshot();
6153
+ if (completed && snapshot.streamError !== null) {
6154
+ return failedWith(snapshot.assistantText, `the first-turn stream ended early: ${redactSensitive(snapshot.streamError)}`);
6155
+ }
6156
+ const pendingAction = completed ? snapshot.pendingCard ?? "none" : "turn_still_running";
6157
+ return jsonResult(
6158
+ buildStartSessionResult({
6159
+ webappUrl,
6160
+ projectId: args.project_id,
6161
+ sessionId: session.id,
6162
+ assistantText: snapshot.assistantText,
6163
+ pendingAction,
6164
+ warnings
6165
+ })
6166
+ );
6167
+ }
6168
+ };
6169
+
5540
6170
  // src/server/server.ts
5541
6171
  function buildServer(deps) {
5542
6172
  const server = new McpServer({
5543
6173
  name: "myspec-mcp-server",
5544
6174
  version: deps.version ?? "0.1.0"
5545
6175
  });
5546
- registerTool(server, listProjectsTool, deps.client);
5547
- registerTool(server, getProjectTool, deps.client);
5548
- registerTool(server, getSpecFileTool, deps.client);
5549
- registerTool(server, readSpecFileTool, deps.client);
5550
- registerTool(server, downloadSpecFileTool, deps.client);
5551
- registerTool(server, listSpecFileTool, deps.client);
5552
- registerTool(server, getAttachmentTool, deps.client);
5553
- registerTool(server, uploadAttachmentTool, deps.client);
5554
- registerTool(server, createProjectTool, deps.client);
5555
- registerTool(server, updateProjectTool, deps.client);
5556
- registerTool(server, archiveProjectTool, deps.client);
5557
- registerTool(server, unarchiveProjectTool, deps.client);
5558
- registerTool(server, deleteProjectTool, deps.client);
5559
- registerTool(server, uploadSpecFileTool, deps.client);
5560
- registerTool(server, updateSpecFileTool, deps.client);
5561
- registerTool(server, moveSpecFileToTrashTool, deps.client);
5562
- registerTool(server, restoreSpecFileFromTrashTool, deps.client);
5563
- registerTool(server, listAttachmentsTool, deps.client);
5564
- registerTool(server, readAttachmentTool, deps.client);
5565
- registerTool(server, listSpecSessionsTool, deps.client);
5566
- registerTool(server, getSpecSessionTool, deps.client);
5567
- registerTool(server, renameSpecSessionTool, deps.client);
5568
- registerTool(server, archiveSpecSessionTool, deps.client);
5569
- registerTool(server, unarchiveSpecSessionTool, deps.client);
5570
- registerTool(server, deleteSpecSessionTool, deps.client);
5571
- registerTool(server, listArtifactsTool, deps.client);
5572
- registerTool(server, getArtifactTool, deps.client);
5573
- registerTool(server, readArtifactFileTool, deps.client);
5574
- registerTool(server, createArtifactTool, deps.client);
5575
- registerTool(server, writeArtifactRevisionTool, deps.client);
5576
- registerTool(server, rollbackArtifactTool, deps.client);
5577
- registerTool(server, createStreamTokenTool, deps.client);
5578
- registerTool(server, listStreamTokensTool, deps.client);
5579
- registerTool(server, revokeStreamTokenTool, deps.client);
6176
+ const ctx = {
6177
+ client: deps.client,
6178
+ webappUrl: deps.webappUrl,
6179
+ probeReverseMcpPresence: deps.probeReverseMcpPresence
6180
+ };
6181
+ registerTool(server, listProjectsTool, ctx);
6182
+ registerTool(server, getProjectTool, ctx);
6183
+ registerTool(server, getSpecFileTool, ctx);
6184
+ registerTool(server, readSpecFileTool, ctx);
6185
+ registerTool(server, downloadSpecFileTool, ctx);
6186
+ registerTool(server, listSpecFileTool, ctx);
6187
+ registerTool(server, getAttachmentTool, ctx);
6188
+ registerTool(server, uploadAttachmentTool, ctx);
6189
+ registerTool(server, createProjectTool, ctx);
6190
+ registerTool(server, updateProjectTool, ctx);
6191
+ registerTool(server, archiveProjectTool, ctx);
6192
+ registerTool(server, unarchiveProjectTool, ctx);
6193
+ registerTool(server, deleteProjectTool, ctx);
6194
+ registerTool(server, uploadSpecFileTool, ctx);
6195
+ registerTool(server, updateSpecFileTool, ctx);
6196
+ registerTool(server, moveSpecFileToTrashTool, ctx);
6197
+ registerTool(server, restoreSpecFileFromTrashTool, ctx);
6198
+ registerTool(server, listAttachmentsTool, ctx);
6199
+ registerTool(server, readAttachmentTool, ctx);
6200
+ registerTool(server, listSpecSessionsTool, ctx);
6201
+ registerTool(server, getSpecSessionTool, ctx);
6202
+ registerTool(server, renameSpecSessionTool, ctx);
6203
+ registerTool(server, archiveSpecSessionTool, ctx);
6204
+ registerTool(server, unarchiveSpecSessionTool, ctx);
6205
+ registerTool(server, deleteSpecSessionTool, ctx);
6206
+ registerTool(server, listArtifactsTool, ctx);
6207
+ registerTool(server, getArtifactTool, ctx);
6208
+ registerTool(server, readArtifactFileTool, ctx);
6209
+ registerTool(server, createArtifactTool, ctx);
6210
+ registerTool(server, writeArtifactRevisionTool, ctx);
6211
+ registerTool(server, rollbackArtifactTool, ctx);
6212
+ registerTool(server, createStreamTokenTool, ctx);
6213
+ registerTool(server, listStreamTokensTool, ctx);
6214
+ registerTool(server, revokeStreamTokenTool, ctx);
6215
+ registerTool(server, startSpecSessionTool, ctx);
5580
6216
  return server;
5581
6217
  }
5582
6218
  async function startStdioServer(deps) {
@@ -5584,10 +6220,10 @@ async function startStdioServer(deps) {
5584
6220
  const transport = new StdioServerTransport();
5585
6221
  await server.connect(transport);
5586
6222
  }
5587
- function wrapToolHandler(tool, client) {
6223
+ function wrapToolHandler(tool, ctx) {
5588
6224
  return async (args) => {
5589
6225
  try {
5590
- return await tool.handler(args, { client });
6226
+ return await tool.handler(args, ctx);
5591
6227
  } catch (err) {
5592
6228
  if (err instanceof NeedsLoginError) {
5593
6229
  return errorResult(err.message);
@@ -5597,14 +6233,14 @@ function wrapToolHandler(tool, client) {
5597
6233
  }
5598
6234
  };
5599
6235
  }
5600
- function registerTool(server, tool, client) {
6236
+ function registerTool(server, tool, ctx) {
5601
6237
  server.registerTool(
5602
6238
  tool.name,
5603
6239
  {
5604
6240
  description: tool.description,
5605
6241
  inputSchema: tool.inputSchema
5606
6242
  },
5607
- wrapToolHandler(tool, client)
6243
+ wrapToolHandler(tool, ctx)
5608
6244
  );
5609
6245
  }
5610
6246
 
@@ -5730,7 +6366,7 @@ async function canonicaliseRoot(root) {
5730
6366
  import { promises as fs4 } from "fs";
5731
6367
  import { join as join2, relative } from "path";
5732
6368
  import { spawn } from "child_process";
5733
- import { z as z37 } from "zod";
6369
+ import { z as z38 } from "zod";
5734
6370
  import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
5735
6371
 
5736
6372
  // src/reverse/ignore.ts
@@ -6035,37 +6671,37 @@ var READ_FILE_MAX_LINES = 2e3;
6035
6671
  var LIST_DIR_HARD_CAP = 5e3;
6036
6672
  var LIST_DIR_DEFAULT_CAP = 1e3;
6037
6673
  var GREP_MAX_MATCHES_DEFAULT = 200;
6038
- var listDirInput = z37.object({
6039
- path: z37.string(),
6040
- recursive: z37.boolean().optional(),
6041
- maxEntries: z37.number().int().positive().optional(),
6042
- includeIgnored: z37.boolean().optional()
6674
+ var listDirInput = z38.object({
6675
+ path: z38.string(),
6676
+ recursive: z38.boolean().optional(),
6677
+ maxEntries: z38.number().int().positive().optional(),
6678
+ includeIgnored: z38.boolean().optional()
6043
6679
  });
6044
- var readFileInput = z37.object({
6045
- path: z37.string(),
6046
- offset: z37.number().int().nonnegative().optional(),
6047
- limit: z37.number().int().positive().optional(),
6048
- includeIgnored: z37.boolean().optional()
6680
+ var readFileInput = z38.object({
6681
+ path: z38.string(),
6682
+ offset: z38.number().int().nonnegative().optional(),
6683
+ limit: z38.number().int().positive().optional(),
6684
+ includeIgnored: z38.boolean().optional()
6049
6685
  });
6050
- var grepInput = z37.object({
6051
- pattern: z37.string().describe(
6686
+ var grepInput = z38.object({
6687
+ pattern: z38.string().describe(
6052
6688
  "Search pattern. Interpreted as a regular expression by default (ripgrep syntax), e.g. `foo|bar` or `[Rr]ole`. Set `isRegex: false` to match the pattern as an exact literal string instead."
6053
6689
  ),
6054
- path: z37.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
6055
- isRegex: z37.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
6056
- caseSensitive: z37.boolean().optional(),
6057
- maxMatches: z37.number().int().positive().optional(),
6058
- contextLines: z37.number().int().nonnegative().optional(),
6059
- includeIgnored: z37.boolean().optional()
6690
+ path: z38.string().optional().describe("File or directory under --root to search. Defaults to the whole root."),
6691
+ isRegex: z38.boolean().optional().describe("Treat `pattern` as a regex (default true). Set false for literal-string search."),
6692
+ caseSensitive: z38.boolean().optional(),
6693
+ maxMatches: z38.number().int().positive().optional(),
6694
+ contextLines: z38.number().int().nonnegative().optional(),
6695
+ includeIgnored: z38.boolean().optional()
6060
6696
  });
6061
- var packCodebaseInput = z37.object({
6062
- subpath: z37.string().optional(),
6063
- includePatterns: z37.string().optional(),
6064
- ignorePatterns: z37.string().optional()
6697
+ var packCodebaseInput = z38.object({
6698
+ subpath: z38.string().optional(),
6699
+ includePatterns: z38.string().optional(),
6700
+ ignorePatterns: z38.string().optional()
6065
6701
  });
6066
- var packCodebaseReadPageInput = z37.object({
6067
- outputId: z37.string(),
6068
- page: z37.number().int().positive()
6702
+ var packCodebaseReadPageInput = z38.object({
6703
+ outputId: z38.string(),
6704
+ page: z38.number().int().positive()
6069
6705
  });
6070
6706
  function structuredError(error, message, extra) {
6071
6707
  const payload = { error, message, ...extra ?? {} };
@@ -6593,7 +7229,7 @@ function jsonSchemaFromZod(schema) {
6593
7229
  return zodToJson(schema);
6594
7230
  }
6595
7231
  function zodToJson(schema) {
6596
- if (schema instanceof z37.ZodObject) {
7232
+ if (schema instanceof z38.ZodObject) {
6597
7233
  const shape = schema.shape;
6598
7234
  const properties = {};
6599
7235
  const required = [];
@@ -6608,19 +7244,19 @@ function zodToJson(schema) {
6608
7244
  if (required.length > 0) out.required = required;
6609
7245
  return out;
6610
7246
  }
6611
- if (schema instanceof z37.ZodOptional) {
7247
+ if (schema instanceof z38.ZodOptional) {
6612
7248
  return zodToJson(schema.unwrap());
6613
7249
  }
6614
- if (schema instanceof z37.ZodString) {
7250
+ if (schema instanceof z38.ZodString) {
6615
7251
  return { type: "string" };
6616
7252
  }
6617
- if (schema instanceof z37.ZodNumber) {
7253
+ if (schema instanceof z38.ZodNumber) {
6618
7254
  return { type: "number" };
6619
7255
  }
6620
- if (schema instanceof z37.ZodBoolean) {
7256
+ if (schema instanceof z38.ZodBoolean) {
6621
7257
  return { type: "boolean" };
6622
7258
  }
6623
- if (schema instanceof z37.ZodEnum) {
7259
+ if (schema instanceof z38.ZodEnum) {
6624
7260
  return { type: "string", enum: schema.options };
6625
7261
  }
6626
7262
  return { type: "string" };
@@ -6868,6 +7504,58 @@ function sleep2(ms) {
6868
7504
  return new Promise((resolve3) => setTimeout(resolve3, ms));
6869
7505
  }
6870
7506
 
7507
+ // src/reverse/presence-probe.ts
7508
+ var REVERSE_MCP_STATUS_PATH = "/api/me/reverse-mcp/status";
7509
+ var PRESENCE_PROBE_TIMEOUT_MS = 3e3;
7510
+ function reverseMcpStatusUrl(reverseUrl) {
7511
+ let parsed;
7512
+ try {
7513
+ parsed = new URL(reverseUrl);
7514
+ } catch {
7515
+ return void 0;
7516
+ }
7517
+ if (parsed.protocol === "wss:") {
7518
+ parsed.protocol = "https:";
7519
+ } else if (parsed.protocol === "ws:") {
7520
+ parsed.protocol = "http:";
7521
+ } else {
7522
+ return void 0;
7523
+ }
7524
+ return `${parsed.origin}${REVERSE_MCP_STATUS_PATH}`;
7525
+ }
7526
+ function createReverseMcpPresenceProbe(deps) {
7527
+ const fetchImpl = deps.fetchImpl ?? fetch;
7528
+ return async () => {
7529
+ try {
7530
+ const reverseUrl = await deps.loadReverseUrl();
7531
+ if (reverseUrl === void 0 || reverseUrl === "") {
7532
+ return null;
7533
+ }
7534
+ const endpoint = reverseMcpStatusUrl(reverseUrl);
7535
+ if (endpoint === void 0) {
7536
+ return null;
7537
+ }
7538
+ const token = await deps.getAccessToken();
7539
+ const response = await fetchImpl(endpoint, {
7540
+ method: "GET",
7541
+ headers: { Authorization: `Bearer ${token}` },
7542
+ signal: AbortSignal.timeout(PRESENCE_PROBE_TIMEOUT_MS)
7543
+ });
7544
+ if (!response.ok) {
7545
+ return null;
7546
+ }
7547
+ const body = await response.json();
7548
+ if (typeof body !== "object" || body === null) {
7549
+ return null;
7550
+ }
7551
+ const connected = body.connected;
7552
+ return typeof connected === "boolean" ? connected : null;
7553
+ } catch {
7554
+ return null;
7555
+ }
7556
+ };
7557
+ }
7558
+
6871
7559
  // src/index.ts
6872
7560
  function parseArgs(argv) {
6873
7561
  const [first, ...rest] = argv;
@@ -7246,7 +7934,23 @@ async function runServe(flags) {
7246
7934
  }),
7247
7935
  tokenManager
7248
7936
  });
7249
- await startStdioServer({ client, version: readVersion() });
7937
+ await startStdioServer({
7938
+ client,
7939
+ version: readVersion(),
7940
+ // Already resolved above — `start_spec_session` needs it, and CR-002 AC3
7941
+ // forbids a new CLI option or environment variable for it.
7942
+ webappUrl: config.webappUrl,
7943
+ // Reads the agent URL a previous discovery saved, so a brownfield session
7944
+ // started with no bridge running gets a warning (AR-015). Fails open: no
7945
+ // saved URL, or an unreachable ai-agent, simply means no warning.
7946
+ probeReverseMcpPresence: createReverseMcpPresenceProbe({
7947
+ loadReverseUrl: async () => {
7948
+ const saved = await store.load().catch(() => null);
7949
+ return saved?.aiAgentMcpReverseUrl;
7950
+ },
7951
+ getAccessToken: () => tokenManager.getValidAccessToken()
7952
+ })
7953
+ });
7250
7954
  }
7251
7955
  function isCliEntry() {
7252
7956
  const entry = process.argv[1];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@myspec/mcp-server",
3
- "version": "0.5.0-next.112",
3
+ "version": "0.5.0-next.114",
4
4
  "description": "MySpec MCP server — exposes MySpec platform projects, files and attachments to MCP-aware clients via OAuth-authenticated access tokens.",
5
5
  "type": "module",
6
6
  "repository": {