@nextfreelatech/xpec-mcp 1.0.1
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/LICENSE +201 -0
- package/README.md +450 -0
- package/dist/cli.js +242 -0
- package/dist/cli.js.map +1 -0
- package/dist/client.js +269 -0
- package/dist/client.js.map +1 -0
- package/dist/config.js +159 -0
- package/dist/config.js.map +1 -0
- package/dist/errors.js +100 -0
- package/dist/errors.js.map +1 -0
- package/dist/http.js +147 -0
- package/dist/http.js.map +1 -0
- package/dist/index.js +9 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.js +29 -0
- package/dist/logger.js.map +1 -0
- package/dist/resources.js +181 -0
- package/dist/resources.js.map +1 -0
- package/dist/server.js +84 -0
- package/dist/server.js.map +1 -0
- package/dist/tools.js +398 -0
- package/dist/tools.js.map +1 -0
- package/package.json +43 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Config + binding resolution (per Xpec specs "mcp-server" §3+§6
|
|
2
|
+
// and "mcp-workspace-tools" §4). Phase 4 of the Workspaces epic introduces
|
|
3
|
+
// an aggregation layer above Products, so a project binding can name either
|
|
4
|
+
// a Workspace, a Product, or both.
|
|
5
|
+
//
|
|
6
|
+
// Precedence per binding key (workspaceId, productId):
|
|
7
|
+
// 1. `.xpec.json` at the project root
|
|
8
|
+
// 2. `XPEC_WORKSPACE_ID` / `XPEC_PRODUCT_ID` env vars
|
|
9
|
+
// 3. unset
|
|
10
|
+
//
|
|
11
|
+
// Effective binding mode is computed from the resolved pair:
|
|
12
|
+
// * both set → "workspace+product"
|
|
13
|
+
// * workspaceId only → "workspace"
|
|
14
|
+
// * productId only → "product" (orphan / pre-aggregation Product)
|
|
15
|
+
// * neither → "discovery" (only list_workspaces / list_products
|
|
16
|
+
// orphan-only are usable until ids are passed)
|
|
17
|
+
//
|
|
18
|
+
// API URL precedence:
|
|
19
|
+
// 1. `--api-url` command-line flag (parsed in cli.ts)
|
|
20
|
+
// 2. `apiUrl` in `.xpec.json`
|
|
21
|
+
// 3. `XPEC_API_URL` environment variable
|
|
22
|
+
// 4. https://app.xpec.com (default)
|
|
23
|
+
import { readFileSync } from "node:fs";
|
|
24
|
+
import { resolve as resolvePath } from "node:path";
|
|
25
|
+
export const DEFAULT_API_URL = "https://app.xpec.com";
|
|
26
|
+
export const PROJECT_CONFIG_FILENAME = ".xpec.json";
|
|
27
|
+
export class ConfigError extends Error {
|
|
28
|
+
constructor(message) {
|
|
29
|
+
super(message);
|
|
30
|
+
this.name = "ConfigError";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function parseProjectFile(cwd, fileReader) {
|
|
34
|
+
const path = resolvePath(cwd, PROJECT_CONFIG_FILENAME);
|
|
35
|
+
const raw = fileReader(path);
|
|
36
|
+
if (raw === null)
|
|
37
|
+
return null;
|
|
38
|
+
let parsed;
|
|
39
|
+
try {
|
|
40
|
+
parsed = JSON.parse(raw);
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
throw new ConfigError(`${PROJECT_CONFIG_FILENAME} is not valid JSON: ${err.message}`);
|
|
44
|
+
}
|
|
45
|
+
if (!parsed || typeof parsed !== "object") {
|
|
46
|
+
throw new ConfigError(`${PROJECT_CONFIG_FILENAME} must contain a JSON object.`);
|
|
47
|
+
}
|
|
48
|
+
return parsed;
|
|
49
|
+
}
|
|
50
|
+
function defaultFileReader(path) {
|
|
51
|
+
try {
|
|
52
|
+
return readFileSync(path, "utf8");
|
|
53
|
+
}
|
|
54
|
+
catch (err) {
|
|
55
|
+
if (err.code === "ENOENT")
|
|
56
|
+
return null;
|
|
57
|
+
throw err;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function normalizeApiUrl(value) {
|
|
61
|
+
return value.replace(/\/+$/, "");
|
|
62
|
+
}
|
|
63
|
+
export function resolveConfig(overrides = {}) {
|
|
64
|
+
const cwd = overrides.cwd ?? process.cwd();
|
|
65
|
+
const env = overrides.env ?? process.env;
|
|
66
|
+
const fileReader = overrides.fileReader ?? defaultFileReader;
|
|
67
|
+
const file = parseProjectFile(cwd, fileReader);
|
|
68
|
+
// workspaceId
|
|
69
|
+
let workspaceId = null;
|
|
70
|
+
let workspaceSource = "none";
|
|
71
|
+
if (file &&
|
|
72
|
+
typeof file.workspaceId === "string" &&
|
|
73
|
+
file.workspaceId.length > 0) {
|
|
74
|
+
workspaceId = file.workspaceId;
|
|
75
|
+
workspaceSource = "config-file";
|
|
76
|
+
}
|
|
77
|
+
else if (typeof env.XPEC_WORKSPACE_ID === "string" &&
|
|
78
|
+
env.XPEC_WORKSPACE_ID.length > 0) {
|
|
79
|
+
workspaceId = env.XPEC_WORKSPACE_ID;
|
|
80
|
+
workspaceSource = "env";
|
|
81
|
+
}
|
|
82
|
+
// productId
|
|
83
|
+
let productId = null;
|
|
84
|
+
let productSource = "none";
|
|
85
|
+
if (file && typeof file.productId === "string" && file.productId.length > 0) {
|
|
86
|
+
productId = file.productId;
|
|
87
|
+
productSource = "config-file";
|
|
88
|
+
}
|
|
89
|
+
else if (typeof env.XPEC_PRODUCT_ID === "string" &&
|
|
90
|
+
env.XPEC_PRODUCT_ID.length > 0) {
|
|
91
|
+
productId = env.XPEC_PRODUCT_ID;
|
|
92
|
+
productSource = "env";
|
|
93
|
+
}
|
|
94
|
+
// Phase 4 of the Workspaces epic: both ids are optional and the
|
|
95
|
+
// server starts in discovery mode when both are absent. The previous
|
|
96
|
+
// "productId required when the file is present" rule is removed; an
|
|
97
|
+
// empty `{}` file just means "no binding".
|
|
98
|
+
// apiUrl
|
|
99
|
+
let apiUrl = DEFAULT_API_URL;
|
|
100
|
+
let apiUrlSource = "default";
|
|
101
|
+
if (overrides.apiUrl) {
|
|
102
|
+
apiUrl = overrides.apiUrl;
|
|
103
|
+
apiUrlSource = "argument";
|
|
104
|
+
}
|
|
105
|
+
else if (file && typeof file.apiUrl === "string" && file.apiUrl.length > 0) {
|
|
106
|
+
apiUrl = file.apiUrl;
|
|
107
|
+
apiUrlSource = "config-file";
|
|
108
|
+
}
|
|
109
|
+
else if (typeof env.XPEC_API_URL === "string" &&
|
|
110
|
+
env.XPEC_API_URL.length > 0) {
|
|
111
|
+
apiUrl = env.XPEC_API_URL;
|
|
112
|
+
apiUrlSource = "env";
|
|
113
|
+
}
|
|
114
|
+
apiUrl = normalizeApiUrl(apiUrl);
|
|
115
|
+
const allowInsecure = overrides.allowInsecure ?? false;
|
|
116
|
+
if (!allowInsecure && !apiUrl.startsWith("https://") && !isLocalUrl(apiUrl)) {
|
|
117
|
+
throw new ConfigError(`apiUrl "${apiUrl}" is not HTTPS. Pass --allow-insecure to opt in (intended for self-hosted dev only).`);
|
|
118
|
+
}
|
|
119
|
+
// token
|
|
120
|
+
const token = typeof env.XPEC_API_TOKEN === "string" &&
|
|
121
|
+
env.XPEC_API_TOKEN.length > 0
|
|
122
|
+
? env.XPEC_API_TOKEN
|
|
123
|
+
: null;
|
|
124
|
+
// telemetry
|
|
125
|
+
const telemetryEnabled = env.XPEC_TELEMETRY !== "0";
|
|
126
|
+
const bindingMode = workspaceId && productId
|
|
127
|
+
? "workspace+product"
|
|
128
|
+
: workspaceId
|
|
129
|
+
? "workspace"
|
|
130
|
+
: productId
|
|
131
|
+
? "product"
|
|
132
|
+
: "discovery";
|
|
133
|
+
return {
|
|
134
|
+
apiUrl,
|
|
135
|
+
apiUrlSource,
|
|
136
|
+
token,
|
|
137
|
+
workspaceId,
|
|
138
|
+
workspaceSource,
|
|
139
|
+
productId,
|
|
140
|
+
productSource,
|
|
141
|
+
bindingMode,
|
|
142
|
+
telemetryEnabled,
|
|
143
|
+
allowInsecure,
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
// Localhost / loopback URLs are commonly used during dev — let `http://localhost…`
|
|
147
|
+
// through without `--allow-insecure`. Anything else over HTTP needs the flag.
|
|
148
|
+
function isLocalUrl(url) {
|
|
149
|
+
try {
|
|
150
|
+
const parsed = new URL(url);
|
|
151
|
+
return (parsed.hostname === "localhost" ||
|
|
152
|
+
parsed.hostname === "127.0.0.1" ||
|
|
153
|
+
parsed.hostname === "::1");
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"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"}
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// MCP-side error mapping (per Xpec spec "mcp-server" §5 "Error mapping").
|
|
2
|
+
// Translates Xpec API error envelopes into MCP error codes the
|
|
3
|
+
// agent can reason about, each carrying a short remediation string.
|
|
4
|
+
export class McpToolError extends Error {
|
|
5
|
+
code;
|
|
6
|
+
remediation;
|
|
7
|
+
constructor(code, message, remediation) {
|
|
8
|
+
super(message);
|
|
9
|
+
this.code = code;
|
|
10
|
+
this.remediation = remediation;
|
|
11
|
+
this.name = "McpToolError";
|
|
12
|
+
}
|
|
13
|
+
toFailure() {
|
|
14
|
+
return {
|
|
15
|
+
code: this.code,
|
|
16
|
+
message: this.message,
|
|
17
|
+
remediation: this.remediation,
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const REMEDIATIONS = {
|
|
22
|
+
AUTH_FAILED: "Regenerate a Personal Access Token from /settings/developer and update XPEC_API_TOKEN.",
|
|
23
|
+
TOKEN_EXPIRED: "The token expired. Generate a new one from /settings/developer.",
|
|
24
|
+
TOKEN_REVOKED: "The token was revoked. Generate a new one from /settings/developer.",
|
|
25
|
+
TOKEN_SCOPE_MISMATCH: "The token isn't scoped to this product. Use a token whose allowlist includes it, or remove the allowlist.",
|
|
26
|
+
PRODUCT_NOT_BOUND: "Call list_products, pick one, then add it to .xpec.json or set XPEC_PRODUCT_ID.",
|
|
27
|
+
WORKSPACE_NOT_BOUND: "Call list_workspaces, pick one, then add it to .xpec.json as `workspaceId` or set XPEC_WORKSPACE_ID.",
|
|
28
|
+
PRODUCT_TYPE_MISMATCH: "The filter you passed isn't compatible with this product's type. Drop the filter or call against a matching product.",
|
|
29
|
+
NOT_IN_WORKSPACE: "This tool requires a Workspace binding. Set `workspaceId` in .xpec.json or pass it explicitly.",
|
|
30
|
+
LEGACY_BINDING_DETECTED: 'Edit .xpec.json: rename the "workspaceId" field to "productId" (the value points at a Product under the new model). To bind to a Workspace, create one and set both ids.',
|
|
31
|
+
SPEC_LOCKED: "The spec is in REVIEWED state. Call start_new_version first to enter Draft.",
|
|
32
|
+
OPEN_QUESTIONS_PRESENT: "Resolve or dismiss the open questions on this spec before proceeding.",
|
|
33
|
+
STALE_VERSION: "Re-read the spec to pick up the current OCC version, then retry with that version.",
|
|
34
|
+
RATE_LIMITED: "You hit the per-token rate limit. Wait until the Retry-After window passes and try again.",
|
|
35
|
+
NOT_FOUND: "The resource doesn't exist or isn't visible to this token.",
|
|
36
|
+
VALIDATION_ERROR: "Inspect the details — at least one argument failed schema validation.",
|
|
37
|
+
INTERNAL_ERROR: "The Xpec API hit an unexpected error. Retry once; if it persists, contact support.",
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* Map an HTTP response (status + parsed body) onto an `McpToolError`. The
|
|
41
|
+
* body's `error.code` takes precedence when present, falling back to the
|
|
42
|
+
* status code's standard meaning.
|
|
43
|
+
*/
|
|
44
|
+
export function mapApiError(status, body) {
|
|
45
|
+
const apiCode = body?.error?.code;
|
|
46
|
+
const apiMessage = body?.error?.message ?? `Request failed with status ${status}.`;
|
|
47
|
+
const code = pickStructuredCode(status, apiCode, body?.error?.details);
|
|
48
|
+
return new McpToolError(code, apiMessage, REMEDIATIONS[code]);
|
|
49
|
+
}
|
|
50
|
+
function pickStructuredCode(status, apiCode, details) {
|
|
51
|
+
// Prefer explicit codes on the API error envelope or in `details.code`.
|
|
52
|
+
const explicitCode = apiCode ?? extractDetailCode(details);
|
|
53
|
+
if (explicitCode) {
|
|
54
|
+
if (explicitCode === "TOKEN_SCOPE_MISMATCH")
|
|
55
|
+
return "TOKEN_SCOPE_MISMATCH";
|
|
56
|
+
if (explicitCode === "TOKEN_EXPIRED")
|
|
57
|
+
return "TOKEN_EXPIRED";
|
|
58
|
+
if (explicitCode === "TOKEN_REVOKED")
|
|
59
|
+
return "TOKEN_REVOKED";
|
|
60
|
+
if (explicitCode === "PRODUCT_TYPE_MISMATCH")
|
|
61
|
+
return "PRODUCT_TYPE_MISMATCH";
|
|
62
|
+
if (explicitCode === "NOT_IN_WORKSPACE")
|
|
63
|
+
return "NOT_IN_WORKSPACE";
|
|
64
|
+
if (explicitCode === "SPEC_LOCKED")
|
|
65
|
+
return "SPEC_LOCKED";
|
|
66
|
+
if (explicitCode === "OPEN_QUESTIONS_PRESENT")
|
|
67
|
+
return "OPEN_QUESTIONS_PRESENT";
|
|
68
|
+
if (explicitCode === "STALE_VERSION")
|
|
69
|
+
return "STALE_VERSION";
|
|
70
|
+
if (explicitCode === "AUTH_REQUIRED" || explicitCode === "AUTH_FAILED")
|
|
71
|
+
return "AUTH_FAILED";
|
|
72
|
+
}
|
|
73
|
+
// Fallback by status code.
|
|
74
|
+
if (status === 401)
|
|
75
|
+
return "AUTH_FAILED";
|
|
76
|
+
if (status === 403)
|
|
77
|
+
return "TOKEN_SCOPE_MISMATCH";
|
|
78
|
+
if (status === 404)
|
|
79
|
+
return "NOT_FOUND";
|
|
80
|
+
if (status === 409)
|
|
81
|
+
return "STALE_VERSION";
|
|
82
|
+
if (status === 422)
|
|
83
|
+
return "VALIDATION_ERROR";
|
|
84
|
+
if (status === 429)
|
|
85
|
+
return "RATE_LIMITED";
|
|
86
|
+
return "INTERNAL_ERROR";
|
|
87
|
+
}
|
|
88
|
+
function extractDetailCode(details) {
|
|
89
|
+
if (details &&
|
|
90
|
+
typeof details === "object" &&
|
|
91
|
+
"code" in details &&
|
|
92
|
+
typeof details.code === "string") {
|
|
93
|
+
return details.code;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
export function buildClientFailure(code, message) {
|
|
98
|
+
return { code, message, remediation: REMEDIATIONS[code] };
|
|
99
|
+
}
|
|
100
|
+
//# sourceMappingURL=errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"errors.js","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"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"}
|
package/dist/http.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
// HTTP/SSE transport (per Xpec spec "mcp-server" §3 BDD
|
|
2
|
+
// "HTTP+SSE transport for hosted agents" + §5 "HTTP/SSE transport").
|
|
3
|
+
//
|
|
4
|
+
// This is the hosted-agent path. The MCP SDK's StreamableHTTPServerTransport
|
|
5
|
+
// auto-detects whether the caller wants SSE streaming or a direct JSON
|
|
6
|
+
// response based on the Accept header, so a single `/mcp` endpoint covers
|
|
7
|
+
// both shapes — that's intentional in the modern MCP wire format.
|
|
8
|
+
//
|
|
9
|
+
// CORS defaults to deny: only same-origin requests (no Origin header) and
|
|
10
|
+
// origins explicitly listed via `--cors-origin` are allowed. This keeps an
|
|
11
|
+
// agent that picks up the local server URL out of reach for a malicious
|
|
12
|
+
// page that happens to be open in the user's browser.
|
|
13
|
+
import { createServer } from "node:http";
|
|
14
|
+
import { randomUUID } from "node:crypto";
|
|
15
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
16
|
+
import { logger } from "./logger.js";
|
|
17
|
+
import { buildServer } from "./server.js";
|
|
18
|
+
export const DEFAULT_HTTP_PORT = 7345;
|
|
19
|
+
export const DEFAULT_HTTP_HOST = "127.0.0.1";
|
|
20
|
+
const MCP_PATH = "/mcp";
|
|
21
|
+
const HEALTH_PATH = "/healthz";
|
|
22
|
+
/**
|
|
23
|
+
* Start the HTTP/SSE MCP server. Resolves once the listener is bound so the
|
|
24
|
+
* caller knows the actual port (useful when port=0 picks an ephemeral port
|
|
25
|
+
* during tests).
|
|
26
|
+
*/
|
|
27
|
+
export async function startHttpServer(options) {
|
|
28
|
+
const port = options.port ?? DEFAULT_HTTP_PORT;
|
|
29
|
+
const host = options.host ?? DEFAULT_HTTP_HOST;
|
|
30
|
+
const allowedOrigins = options.corsOrigins ?? [];
|
|
31
|
+
const mcpServer = buildServer({ config: options.config });
|
|
32
|
+
// Stateful mode — the transport keeps an MCP session alive across
|
|
33
|
+
// multiple requests so the agent's `initialize` handshake is honoured
|
|
34
|
+
// for every follow-up tool/resource call.
|
|
35
|
+
const transport = new StreamableHTTPServerTransport({
|
|
36
|
+
sessionIdGenerator: () => randomUUID(),
|
|
37
|
+
});
|
|
38
|
+
const server = createServer(async (req, res) => {
|
|
39
|
+
const url = req.url ?? "/";
|
|
40
|
+
const method = req.method ?? "GET";
|
|
41
|
+
// CORS preflight handling — applied to every request before dispatch.
|
|
42
|
+
if (!applyCors(req, res, allowedOrigins)) {
|
|
43
|
+
// applyCors already wrote the rejection response.
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
if (method === "OPTIONS") {
|
|
47
|
+
res.statusCode = 204;
|
|
48
|
+
res.end();
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (url === HEALTH_PATH && method === "GET") {
|
|
52
|
+
res.statusCode = 200;
|
|
53
|
+
res.setHeader("content-type", "application/json");
|
|
54
|
+
res.end(JSON.stringify({ ok: true }));
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
if (url === MCP_PATH || url.startsWith(`${MCP_PATH}?`)) {
|
|
58
|
+
try {
|
|
59
|
+
await transport.handleRequest(req, res);
|
|
60
|
+
}
|
|
61
|
+
catch (err) {
|
|
62
|
+
logger.error("http transport handleRequest failed", {
|
|
63
|
+
err: err.message,
|
|
64
|
+
});
|
|
65
|
+
if (!res.headersSent) {
|
|
66
|
+
res.statusCode = 500;
|
|
67
|
+
res.setHeader("content-type", "application/json");
|
|
68
|
+
res.end(JSON.stringify({ error: { code: "INTERNAL_ERROR" } }));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
res.statusCode = 404;
|
|
74
|
+
res.setHeader("content-type", "application/json");
|
|
75
|
+
res.end(JSON.stringify({ error: { code: "NOT_FOUND" } }));
|
|
76
|
+
});
|
|
77
|
+
await mcpServer.connect(transport);
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
server.once("error", reject);
|
|
80
|
+
server.listen(port, host, () => {
|
|
81
|
+
const address = server.address();
|
|
82
|
+
const boundPort = address && typeof address === "object" ? address.port : port;
|
|
83
|
+
logger.info("mcp server connected", {
|
|
84
|
+
transport: "http",
|
|
85
|
+
host,
|
|
86
|
+
port: boundPort,
|
|
87
|
+
apiUrl: options.config.apiUrl,
|
|
88
|
+
apiUrlSource: options.config.apiUrlSource,
|
|
89
|
+
productId: options.config.productId ?? undefined,
|
|
90
|
+
corsOrigins: allowedOrigins,
|
|
91
|
+
});
|
|
92
|
+
if (allowedOrigins.includes("*")) {
|
|
93
|
+
logger.warn("CORS origin '*' is permissive — restrict for production");
|
|
94
|
+
}
|
|
95
|
+
resolve({
|
|
96
|
+
port: boundPort,
|
|
97
|
+
host,
|
|
98
|
+
close: () => new Promise((resolveClose, rejectClose) => {
|
|
99
|
+
server.close((err) => (err ? rejectClose(err) : resolveClose()));
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
106
|
+
// CORS
|
|
107
|
+
// ──────────────────────────────────────────────────────────────────────────
|
|
108
|
+
/**
|
|
109
|
+
* Returns true when the request is permitted to proceed. Writes the 403
|
|
110
|
+
* response itself when the origin is rejected so the caller can early-exit.
|
|
111
|
+
*
|
|
112
|
+
* Exported for unit tests so the policy can be evaluated without standing
|
|
113
|
+
* up an actual HTTP server.
|
|
114
|
+
*/
|
|
115
|
+
export function applyCors(req, res, allowedOrigins) {
|
|
116
|
+
const origin = req.headers.origin;
|
|
117
|
+
// Same-origin / non-browser request: no Origin header at all. Always
|
|
118
|
+
// allowed — the browser only attaches Origin to cross-origin fetches.
|
|
119
|
+
if (!origin)
|
|
120
|
+
return true;
|
|
121
|
+
if (allowedOrigins.includes("*")) {
|
|
122
|
+
res.setHeader("access-control-allow-origin", "*");
|
|
123
|
+
setStandardCorsHeaders(res);
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
if (allowedOrigins.includes(origin)) {
|
|
127
|
+
res.setHeader("access-control-allow-origin", origin);
|
|
128
|
+
res.setHeader("vary", "Origin");
|
|
129
|
+
setStandardCorsHeaders(res);
|
|
130
|
+
return true;
|
|
131
|
+
}
|
|
132
|
+
res.statusCode = 403;
|
|
133
|
+
res.setHeader("content-type", "application/json");
|
|
134
|
+
res.end(JSON.stringify({
|
|
135
|
+
error: {
|
|
136
|
+
code: "CORS_ORIGIN_NOT_ALLOWED",
|
|
137
|
+
message: `Origin "${origin}" is not in the CORS allowlist.`,
|
|
138
|
+
},
|
|
139
|
+
}));
|
|
140
|
+
return false;
|
|
141
|
+
}
|
|
142
|
+
function setStandardCorsHeaders(res) {
|
|
143
|
+
res.setHeader("access-control-allow-methods", "GET, POST, OPTIONS");
|
|
144
|
+
res.setHeader("access-control-allow-headers", "authorization, content-type, mcp-session-id, mcp-protocol-version, last-event-id");
|
|
145
|
+
res.setHeader("access-control-expose-headers", "mcp-session-id, etag");
|
|
146
|
+
}
|
|
147
|
+
//# sourceMappingURL=http.js.map
|
package/dist/http.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.js","sourceRoot":"","sources":["../src/http.ts"],"names":[],"mappings":"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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Public entry for the @nextfreelatech/xpec-mcp package. Most users invoke the
|
|
2
|
+
// CLI via `npx -y @nextfreelatech/xpec-mcp`; this module exists so library
|
|
3
|
+
// consumers (e.g. an integration test or an embedded MCP gateway) can
|
|
4
|
+
// build the same server programmatically.
|
|
5
|
+
export { buildServer, runStdio } from "./server.js";
|
|
6
|
+
export { resolveConfig, ConfigError, DEFAULT_API_URL } from "./config.js";
|
|
7
|
+
export { XpecClient, } from "./client.js";
|
|
8
|
+
export { McpToolError, mapApiError, buildClientFailure, } from "./errors.js";
|
|
9
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,+EAA+E;AAC/E,2EAA2E;AAC3E,sEAAsE;AACtE,0CAA0C;AAE1C,OAAO,EAAE,WAAW,EAAE,QAAQ,EAAE,MAAM,aAAa,CAAC;AACpD,OAAO,EAAE,aAAa,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AAC1E,OAAO,EACL,UAAU,GAGX,MAAM,aAAa,CAAC;AACrB,OAAO,EACL,YAAY,EACZ,WAAW,EACX,kBAAkB,GAGnB,MAAM,aAAa,CAAC"}
|
package/dist/logger.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Structured stderr logger for the MCP server. Stderr (not stdout) because
|
|
2
|
+
// stdio transport reserves stdout for JSON-RPC frames — anything else there
|
|
3
|
+
// breaks the agent's MCP client. One JSON object per line so the agent's
|
|
4
|
+
// parent process can parse logs without a regex.
|
|
5
|
+
const LEVEL_RANK = {
|
|
6
|
+
debug: 10,
|
|
7
|
+
info: 20,
|
|
8
|
+
warn: 30,
|
|
9
|
+
error: 40,
|
|
10
|
+
};
|
|
11
|
+
const minLevel = process.env.XPEC_LOG_LEVEL ?? "info";
|
|
12
|
+
function emit(level, message, context = {}) {
|
|
13
|
+
if (LEVEL_RANK[level] < LEVEL_RANK[minLevel])
|
|
14
|
+
return;
|
|
15
|
+
const line = JSON.stringify({
|
|
16
|
+
t: new Date().toISOString(),
|
|
17
|
+
level,
|
|
18
|
+
msg: message,
|
|
19
|
+
...context,
|
|
20
|
+
});
|
|
21
|
+
process.stderr.write(`${line}\n`);
|
|
22
|
+
}
|
|
23
|
+
export const logger = {
|
|
24
|
+
debug: (message, ctx) => emit("debug", message, ctx),
|
|
25
|
+
info: (message, ctx) => emit("info", message, ctx),
|
|
26
|
+
warn: (message, ctx) => emit("warn", message, ctx),
|
|
27
|
+
error: (message, ctx) => emit("error", message, ctx),
|
|
28
|
+
};
|
|
29
|
+
//# sourceMappingURL=logger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"logger.js","sourceRoot":"","sources":["../src/logger.ts"],"names":[],"mappings":"AAAA,2EAA2E;AAC3E,4EAA4E;AAC5E,yEAAyE;AACzE,iDAAiD;AAgBjD,MAAM,UAAU,GAA6B;IAC3C,KAAK,EAAE,EAAE;IACT,IAAI,EAAE,EAAE;IACR,IAAI,EAAE,EAAE;IACR,KAAK,EAAE,EAAE;CACV,CAAC;AAEF,MAAM,QAAQ,GACX,OAAO,CAAC,GAAG,CAAC,cAAuC,IAAI,MAAM,CAAC;AAEjE,SAAS,IAAI,CAAC,KAAe,EAAE,OAAe,EAAE,UAAsB,EAAE;IACtE,IAAI,UAAU,CAAC,KAAK,CAAC,GAAG,UAAU,CAAC,QAAQ,CAAC;QAAE,OAAO;IACrD,MAAM,IAAI,GAAG,IAAI,CAAC,SAAS,CAAC;QAC1B,CAAC,EAAE,IAAI,IAAI,EAAE,CAAC,WAAW,EAAE;QAC3B,KAAK;QACL,GAAG,EAAE,OAAO;QACZ,GAAG,OAAO;KACX,CAAC,CAAC;IACH,OAAO,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG,IAAI,IAAI,CAAC,CAAC;AACpC,CAAC;AAED,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,KAAK,EAAE,CAAC,OAAe,EAAE,GAAgB,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC;IACzE,IAAI,EAAE,CAAC,OAAe,EAAE,GAAgB,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,CAAC;IACvE,IAAI,EAAE,CAAC,OAAe,EAAE,GAAgB,EAAE,EAAE,CAAC,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,GAAG,CAAC;IACvE,KAAK,EAAE,CAAC,OAAe,EAAE,GAAgB,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,OAAO,EAAE,GAAG,CAAC;CAC1E,CAAC"}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// Resource provider for the MCP server. Phase 4 doubles the surface so
|
|
2
|
+
// agents can address either a Product or a Workspace via stable URIs:
|
|
3
|
+
//
|
|
4
|
+
// xpec://product/{productId} — collection: lists Product specs
|
|
5
|
+
// xpec://product/{productId}/spec/{specId} — Product spec Markdown body
|
|
6
|
+
// xpec://workspace/{workspaceId} — collection: lists Workspace specs
|
|
7
|
+
// xpec://workspace/{workspaceId}/spec/{specId} — Workspace spec Markdown body
|
|
8
|
+
//
|
|
9
|
+
// Collection URIs are registered as fixed resources so the agent can
|
|
10
|
+
// list-then-pin. Individual specs are exposed via ResourceTemplates so the
|
|
11
|
+
// agent can read any spec in the bound scope by id. ETag/304 behavior is
|
|
12
|
+
// unchanged (per mcp-workspace-tools.md §3).
|
|
13
|
+
import { ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
14
|
+
import { McpToolError } from "./errors.js";
|
|
15
|
+
import { logger } from "./logger.js";
|
|
16
|
+
export function registerResources(server, deps) {
|
|
17
|
+
const { client, config } = deps;
|
|
18
|
+
if (!config.productId && !config.workspaceId) {
|
|
19
|
+
// Discovery mode — no bound scope means no resource surface. The agent
|
|
20
|
+
// can call list_products / list_workspaces to discover ids; resources
|
|
21
|
+
// become available after the user binds a scope and reconnects.
|
|
22
|
+
logger.debug("resources skipped — no binding (discovery mode)");
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (config.workspaceId) {
|
|
26
|
+
registerWorkspaceResources(server, client, config.workspaceId);
|
|
27
|
+
}
|
|
28
|
+
if (config.productId) {
|
|
29
|
+
registerProductResources(server, client, config.productId);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function registerProductResources(server, client, productId) {
|
|
33
|
+
const wsId = productId;
|
|
34
|
+
const collectionUri = `xpec://product/${wsId}`;
|
|
35
|
+
server.registerResource("product-specs", collectionUri, {
|
|
36
|
+
title: "Product specifications",
|
|
37
|
+
description: `Specifications in product ${wsId}. Read this resource to enumerate the specs an agent can pin into context.`,
|
|
38
|
+
mimeType: "application/json",
|
|
39
|
+
}, async (uri) => {
|
|
40
|
+
try {
|
|
41
|
+
const res = await client.listSpecifications(wsId, { limit: 100 });
|
|
42
|
+
return {
|
|
43
|
+
contents: [
|
|
44
|
+
{
|
|
45
|
+
uri: uri.toString(),
|
|
46
|
+
mimeType: "application/json",
|
|
47
|
+
text: JSON.stringify(res.body, null, 2),
|
|
48
|
+
},
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
throw mapResourceError(err);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
// Per-spec template — the agent reads a Markdown body by spec id.
|
|
57
|
+
// The template's `{specId}` slot maps onto a string variable.
|
|
58
|
+
const template = new ResourceTemplate(`xpec://product/${wsId}/spec/{specId}`, {
|
|
59
|
+
list: undefined,
|
|
60
|
+
});
|
|
61
|
+
server.registerResource("specification-body", template, {
|
|
62
|
+
title: "Specification Markdown",
|
|
63
|
+
description: "Read the current Markdown body of a specification. The resource etag mirrors the spec's optimistic-concurrency version, so the client can re-read after change notifications without comparing content.",
|
|
64
|
+
mimeType: "text/markdown",
|
|
65
|
+
}, async (uri, variables) => {
|
|
66
|
+
const specId = String(variables.specId ?? "");
|
|
67
|
+
if (!specId) {
|
|
68
|
+
throw new McpToolError("VALIDATION_ERROR", "Resource URI is missing a specId.", "Use the URI shape xpec://product/{productId}/spec/{specId}.");
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const res = await client.readSpecification(specId, { format: "raw" });
|
|
72
|
+
if ("notModified" in res) {
|
|
73
|
+
// Tool reads don't carry If-None-Match, so 304 should not occur.
|
|
74
|
+
return {
|
|
75
|
+
contents: [
|
|
76
|
+
{
|
|
77
|
+
uri: uri.toString(),
|
|
78
|
+
mimeType: "text/markdown",
|
|
79
|
+
text: "",
|
|
80
|
+
},
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
const body = res.body;
|
|
85
|
+
const text = typeof body?.content === "string" ? body.content : "";
|
|
86
|
+
return {
|
|
87
|
+
contents: [
|
|
88
|
+
{
|
|
89
|
+
uri: uri.toString(),
|
|
90
|
+
mimeType: "text/markdown",
|
|
91
|
+
text,
|
|
92
|
+
},
|
|
93
|
+
],
|
|
94
|
+
// SDK passes through `_meta` on the resource result; the etag is
|
|
95
|
+
// surfaced for clients that want to short-circuit re-reads.
|
|
96
|
+
_meta: {
|
|
97
|
+
etag: res.etag ?? body?.etag ?? null,
|
|
98
|
+
},
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
throw mapResourceError(err);
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function registerWorkspaceResources(server, client, workspaceId) {
|
|
107
|
+
const collectionUri = `xpec://workspace/${workspaceId}`;
|
|
108
|
+
server.registerResource("workspace-specs", collectionUri, {
|
|
109
|
+
title: "Workspace specifications",
|
|
110
|
+
description: `Specifications in workspace ${workspaceId}. Read this resource to enumerate the cross-product specs an agent can pin into context.`,
|
|
111
|
+
mimeType: "application/json",
|
|
112
|
+
}, async (uri) => {
|
|
113
|
+
try {
|
|
114
|
+
const res = await client.listSpecificationsForWorkspace(workspaceId, {
|
|
115
|
+
limit: 100,
|
|
116
|
+
});
|
|
117
|
+
return {
|
|
118
|
+
contents: [
|
|
119
|
+
{
|
|
120
|
+
uri: uri.toString(),
|
|
121
|
+
mimeType: "application/json",
|
|
122
|
+
text: JSON.stringify(res.body, null, 2),
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
throw mapResourceError(err);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
const template = new ResourceTemplate(`xpec://workspace/${workspaceId}/spec/{specId}`, {
|
|
132
|
+
list: undefined,
|
|
133
|
+
});
|
|
134
|
+
server.registerResource("workspace-specification-body", template, {
|
|
135
|
+
title: "Workspace specification Markdown",
|
|
136
|
+
description: "Read the current Markdown body of a workspace-scoped specification. Etag mirrors the spec's OCC version (Phase 4 mcp-workspace-tools.md §3).",
|
|
137
|
+
mimeType: "text/markdown",
|
|
138
|
+
}, async (uri, variables) => {
|
|
139
|
+
const specId = String(variables.specId ?? "");
|
|
140
|
+
if (!specId) {
|
|
141
|
+
throw new McpToolError("VALIDATION_ERROR", "Resource URI is missing a specId.", "Use the URI shape xpec://workspace/{workspaceId}/spec/{specId}.");
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
const res = await client.readSpecification(specId, { format: "raw" });
|
|
145
|
+
if ("notModified" in res) {
|
|
146
|
+
return {
|
|
147
|
+
contents: [
|
|
148
|
+
{
|
|
149
|
+
uri: uri.toString(),
|
|
150
|
+
mimeType: "text/markdown",
|
|
151
|
+
text: "",
|
|
152
|
+
},
|
|
153
|
+
],
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const body = res.body;
|
|
157
|
+
const text = typeof body?.content === "string" ? body.content : "";
|
|
158
|
+
return {
|
|
159
|
+
contents: [
|
|
160
|
+
{
|
|
161
|
+
uri: uri.toString(),
|
|
162
|
+
mimeType: "text/markdown",
|
|
163
|
+
text,
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
_meta: {
|
|
167
|
+
etag: res.etag ?? body?.etag ?? null,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
catch (err) {
|
|
172
|
+
throw mapResourceError(err);
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
function mapResourceError(err) {
|
|
177
|
+
if (err instanceof McpToolError)
|
|
178
|
+
return err;
|
|
179
|
+
return new McpToolError("INTERNAL_ERROR", err?.message ?? "Resource read failed.", "Retry once; if the failure persists, check the MCP server logs.");
|
|
180
|
+
}
|
|
181
|
+
//# sourceMappingURL=resources.js.map
|