@myspec/mcp-server 0.5.0-next.113 → 0.5.0-next.115
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/README.md +29 -0
- package/dist/index.js +772 -71
- 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
|
|
@@ -5540,46 +5569,650 @@ var revokeStreamTokenTool = {
|
|
|
5540
5569
|
}
|
|
5541
5570
|
};
|
|
5542
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
|
+
|
|
5543
6170
|
// src/server/server.ts
|
|
5544
6171
|
function buildServer(deps) {
|
|
5545
6172
|
const server = new McpServer({
|
|
5546
6173
|
name: "myspec-mcp-server",
|
|
5547
6174
|
version: deps.version ?? "0.1.0"
|
|
5548
6175
|
});
|
|
5549
|
-
|
|
5550
|
-
|
|
5551
|
-
|
|
5552
|
-
|
|
5553
|
-
|
|
5554
|
-
registerTool(server,
|
|
5555
|
-
registerTool(server,
|
|
5556
|
-
registerTool(server,
|
|
5557
|
-
registerTool(server,
|
|
5558
|
-
registerTool(server,
|
|
5559
|
-
registerTool(server,
|
|
5560
|
-
registerTool(server,
|
|
5561
|
-
registerTool(server,
|
|
5562
|
-
registerTool(server,
|
|
5563
|
-
registerTool(server,
|
|
5564
|
-
registerTool(server,
|
|
5565
|
-
registerTool(server,
|
|
5566
|
-
registerTool(server,
|
|
5567
|
-
registerTool(server,
|
|
5568
|
-
registerTool(server,
|
|
5569
|
-
registerTool(server,
|
|
5570
|
-
registerTool(server,
|
|
5571
|
-
registerTool(server,
|
|
5572
|
-
registerTool(server,
|
|
5573
|
-
registerTool(server,
|
|
5574
|
-
registerTool(server,
|
|
5575
|
-
registerTool(server,
|
|
5576
|
-
registerTool(server,
|
|
5577
|
-
registerTool(server,
|
|
5578
|
-
registerTool(server,
|
|
5579
|
-
registerTool(server,
|
|
5580
|
-
registerTool(server,
|
|
5581
|
-
registerTool(server,
|
|
5582
|
-
registerTool(server,
|
|
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);
|
|
5583
6216
|
return server;
|
|
5584
6217
|
}
|
|
5585
6218
|
async function startStdioServer(deps) {
|
|
@@ -5587,10 +6220,10 @@ async function startStdioServer(deps) {
|
|
|
5587
6220
|
const transport = new StdioServerTransport();
|
|
5588
6221
|
await server.connect(transport);
|
|
5589
6222
|
}
|
|
5590
|
-
function wrapToolHandler(tool,
|
|
6223
|
+
function wrapToolHandler(tool, ctx) {
|
|
5591
6224
|
return async (args) => {
|
|
5592
6225
|
try {
|
|
5593
|
-
return await tool.handler(args,
|
|
6226
|
+
return await tool.handler(args, ctx);
|
|
5594
6227
|
} catch (err) {
|
|
5595
6228
|
if (err instanceof NeedsLoginError) {
|
|
5596
6229
|
return errorResult(err.message);
|
|
@@ -5600,14 +6233,14 @@ function wrapToolHandler(tool, client) {
|
|
|
5600
6233
|
}
|
|
5601
6234
|
};
|
|
5602
6235
|
}
|
|
5603
|
-
function registerTool(server, tool,
|
|
6236
|
+
function registerTool(server, tool, ctx) {
|
|
5604
6237
|
server.registerTool(
|
|
5605
6238
|
tool.name,
|
|
5606
6239
|
{
|
|
5607
6240
|
description: tool.description,
|
|
5608
6241
|
inputSchema: tool.inputSchema
|
|
5609
6242
|
},
|
|
5610
|
-
wrapToolHandler(tool,
|
|
6243
|
+
wrapToolHandler(tool, ctx)
|
|
5611
6244
|
);
|
|
5612
6245
|
}
|
|
5613
6246
|
|
|
@@ -5733,7 +6366,7 @@ async function canonicaliseRoot(root) {
|
|
|
5733
6366
|
import { promises as fs4 } from "fs";
|
|
5734
6367
|
import { join as join2, relative } from "path";
|
|
5735
6368
|
import { spawn } from "child_process";
|
|
5736
|
-
import { z as
|
|
6369
|
+
import { z as z38 } from "zod";
|
|
5737
6370
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
5738
6371
|
|
|
5739
6372
|
// src/reverse/ignore.ts
|
|
@@ -6038,37 +6671,37 @@ var READ_FILE_MAX_LINES = 2e3;
|
|
|
6038
6671
|
var LIST_DIR_HARD_CAP = 5e3;
|
|
6039
6672
|
var LIST_DIR_DEFAULT_CAP = 1e3;
|
|
6040
6673
|
var GREP_MAX_MATCHES_DEFAULT = 200;
|
|
6041
|
-
var listDirInput =
|
|
6042
|
-
path:
|
|
6043
|
-
recursive:
|
|
6044
|
-
maxEntries:
|
|
6045
|
-
includeIgnored:
|
|
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()
|
|
6046
6679
|
});
|
|
6047
|
-
var readFileInput =
|
|
6048
|
-
path:
|
|
6049
|
-
offset:
|
|
6050
|
-
limit:
|
|
6051
|
-
includeIgnored:
|
|
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()
|
|
6052
6685
|
});
|
|
6053
|
-
var grepInput =
|
|
6054
|
-
pattern:
|
|
6686
|
+
var grepInput = z38.object({
|
|
6687
|
+
pattern: z38.string().describe(
|
|
6055
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."
|
|
6056
6689
|
),
|
|
6057
|
-
path:
|
|
6058
|
-
isRegex:
|
|
6059
|
-
caseSensitive:
|
|
6060
|
-
maxMatches:
|
|
6061
|
-
contextLines:
|
|
6062
|
-
includeIgnored:
|
|
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()
|
|
6063
6696
|
});
|
|
6064
|
-
var packCodebaseInput =
|
|
6065
|
-
subpath:
|
|
6066
|
-
includePatterns:
|
|
6067
|
-
ignorePatterns:
|
|
6697
|
+
var packCodebaseInput = z38.object({
|
|
6698
|
+
subpath: z38.string().optional(),
|
|
6699
|
+
includePatterns: z38.string().optional(),
|
|
6700
|
+
ignorePatterns: z38.string().optional()
|
|
6068
6701
|
});
|
|
6069
|
-
var packCodebaseReadPageInput =
|
|
6070
|
-
outputId:
|
|
6071
|
-
page:
|
|
6702
|
+
var packCodebaseReadPageInput = z38.object({
|
|
6703
|
+
outputId: z38.string(),
|
|
6704
|
+
page: z38.number().int().positive()
|
|
6072
6705
|
});
|
|
6073
6706
|
function structuredError(error, message, extra) {
|
|
6074
6707
|
const payload = { error, message, ...extra ?? {} };
|
|
@@ -6596,7 +7229,7 @@ function jsonSchemaFromZod(schema) {
|
|
|
6596
7229
|
return zodToJson(schema);
|
|
6597
7230
|
}
|
|
6598
7231
|
function zodToJson(schema) {
|
|
6599
|
-
if (schema instanceof
|
|
7232
|
+
if (schema instanceof z38.ZodObject) {
|
|
6600
7233
|
const shape = schema.shape;
|
|
6601
7234
|
const properties = {};
|
|
6602
7235
|
const required = [];
|
|
@@ -6611,19 +7244,19 @@ function zodToJson(schema) {
|
|
|
6611
7244
|
if (required.length > 0) out.required = required;
|
|
6612
7245
|
return out;
|
|
6613
7246
|
}
|
|
6614
|
-
if (schema instanceof
|
|
7247
|
+
if (schema instanceof z38.ZodOptional) {
|
|
6615
7248
|
return zodToJson(schema.unwrap());
|
|
6616
7249
|
}
|
|
6617
|
-
if (schema instanceof
|
|
7250
|
+
if (schema instanceof z38.ZodString) {
|
|
6618
7251
|
return { type: "string" };
|
|
6619
7252
|
}
|
|
6620
|
-
if (schema instanceof
|
|
7253
|
+
if (schema instanceof z38.ZodNumber) {
|
|
6621
7254
|
return { type: "number" };
|
|
6622
7255
|
}
|
|
6623
|
-
if (schema instanceof
|
|
7256
|
+
if (schema instanceof z38.ZodBoolean) {
|
|
6624
7257
|
return { type: "boolean" };
|
|
6625
7258
|
}
|
|
6626
|
-
if (schema instanceof
|
|
7259
|
+
if (schema instanceof z38.ZodEnum) {
|
|
6627
7260
|
return { type: "string", enum: schema.options };
|
|
6628
7261
|
}
|
|
6629
7262
|
return { type: "string" };
|
|
@@ -6871,6 +7504,58 @@ function sleep2(ms) {
|
|
|
6871
7504
|
return new Promise((resolve3) => setTimeout(resolve3, ms));
|
|
6872
7505
|
}
|
|
6873
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
|
+
|
|
6874
7559
|
// src/index.ts
|
|
6875
7560
|
function parseArgs(argv) {
|
|
6876
7561
|
const [first, ...rest] = argv;
|
|
@@ -7249,7 +7934,23 @@ async function runServe(flags) {
|
|
|
7249
7934
|
}),
|
|
7250
7935
|
tokenManager
|
|
7251
7936
|
});
|
|
7252
|
-
await startStdioServer({
|
|
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
|
+
});
|
|
7253
7954
|
}
|
|
7254
7955
|
function isCliEntry() {
|
|
7255
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.
|
|
3
|
+
"version": "0.5.0-next.115",
|
|
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": {
|