Skip to content

Commit 0f33f41

Browse files
tikazyqclaude
andauthored
feat(mcp): add adapter-aware MCP server (#299)
* feat(mcp): add adapter-aware MCP server Closes #270. Adds rust/leanspec-mcp and packages/mcp, restoring the MCP integration that was deprecated in the spec-framework pivot and rebuilding it on top of the adapter layer instead of the markdown-specific SpecLoader. The same set of tools now works against markdown, GitHub, ADO, and Jira projects: list_specs · get_spec · create_spec · update_spec · search_specs get_schema · get_capabilities Tool input schemas and parameter descriptions are generated from the active adapter's SpecSchema at startup, so enum constraints reflect the project's real options and field descriptions come from each FieldDef's ai_hint. validate_spec, get_dependencies, and get_stats are advertised but refuse non-markdown projects with a structured ADAPTER_NOT_SUPPORTED error that points callers at get_spec + get_schema, matching the issue's markdown-only guard contract. The Node.js wrapper at packages/mcp delegates to the Rust binary over stdio and discovers it via the same platform-package layout as the CLI. * fix(mcp): address Copilot review feedback on PR #299 - doc_to_json now returns Result<Value, McpToolError> so serialization failures surface as a structured INTERNAL_ERROR instead of silently producing a `null` document. - update_spec inputSchema wraps each field property in `oneOf: [<kind>, {"type":"null"}]` so MCP clients that validate against the schema can send JSON null to clear a field — matching the runtime behaviour that translates null into UpdateRequest::clear. - list_specs inputSchema now exposes the semantic aliases (status/priority/tags/assignee/reviewer) and every adapter field as filter properties, each shaped as `oneOf: [string, string[]]`, with enum constraints applied for enum-typed fields. additionalProperties switches from `false` to `{oneOf: [string, string[]]}` so adapter- specific field keys not declared on the active schema still pass schema validation, matching the implementation that already forwards them through to the adapter. - Rename integration test "tools_list_advertises_all_seven_core_tools" to "tools_list_advertises_core_and_markdown_only_tools" since it actually asserts on all 10 tools. - lib.rs architecture diagram now shows main.rs constructing ServerState (which calls AdapterRegistry::from_project once), and tools using state.adapter rather than per-call resolution. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 951a936 commit 0f33f41

22 files changed

Lines changed: 2414 additions & 0 deletions

packages/mcp/bin/leanspec-mcp.js

Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
#!/usr/bin/env node
2+
/**
3+
* LeanSpec MCP Server Wrapper
4+
*
5+
* Spawns the platform-specific Rust `leanspec-mcp` binary and pipes stdio
6+
* through unchanged. The binary speaks MCP over stdin/stdout, so this
7+
* wrapper only handles binary discovery — no protocol translation.
8+
*
9+
* Discovery order:
10+
* 1. rust/target/{debug,release}/leanspec-mcp (local dev)
11+
* 2. @leanspec/mcp-<platform>/leanspec-mcp (npm platform package)
12+
* 3. ./binaries/<platform>/leanspec-mcp (local binaries fallback)
13+
*/
14+
15+
import { spawn } from 'child_process';
16+
import { createRequire } from 'module';
17+
import { fileURLToPath } from 'url';
18+
import { dirname, join } from 'path';
19+
import { accessSync, openSync, readSync, closeSync } from 'fs';
20+
21+
const require = createRequire(import.meta.url);
22+
const __filename = fileURLToPath(import.meta.url);
23+
const __dirname = dirname(__filename);
24+
25+
const DEBUG = process.env.LEANSPEC_DEBUG === '1';
26+
const debug = (...args) => DEBUG && console.error('[leanspec-mcp debug]', ...args);
27+
28+
const PLATFORM_MAP = {
29+
darwin: { x64: 'darwin-x64', arm64: 'darwin-arm64' },
30+
linux: { x64: 'linux-x64' },
31+
win32: { x64: 'windows-x64' },
32+
};
33+
34+
const MACHO_MAGICS = new Set([
35+
0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe, 0xbebafeca,
36+
]);
37+
38+
function readHeaderBytes(filePath) {
39+
const fd = openSync(filePath, 'r');
40+
try {
41+
const buffer = Buffer.alloc(4);
42+
const bytesRead = readSync(fd, buffer, 0, 4, 0);
43+
return bytesRead === 4 ? buffer : null;
44+
} finally {
45+
closeSync(fd);
46+
}
47+
}
48+
49+
function isValidBinaryHeader(filePath, platform) {
50+
try {
51+
const header = readHeaderBytes(filePath);
52+
if (!header) return false;
53+
if (platform === 'linux') {
54+
return header[0] === 0x7f && header[1] === 0x45 && header[2] === 0x4c && header[3] === 0x46;
55+
}
56+
if (platform === 'win32') {
57+
return header[0] === 0x4d && header[1] === 0x5a;
58+
}
59+
if (platform === 'darwin') {
60+
const magicBE = header.readUInt32BE(0);
61+
const magicLE = header.readUInt32LE(0);
62+
return MACHO_MAGICS.has(magicBE) || MACHO_MAGICS.has(magicLE);
63+
}
64+
return false;
65+
} catch (error) {
66+
debug('Failed to read binary header:', error.message);
67+
return false;
68+
}
69+
}
70+
71+
function isExecutableBinary(filePath, platform) {
72+
if (!isValidBinaryHeader(filePath, platform)) {
73+
debug('Invalid binary header:', filePath);
74+
return false;
75+
}
76+
return true;
77+
}
78+
79+
function getBinaryPath() {
80+
const platform = process.platform;
81+
const arch = process.arch;
82+
debug('Platform detection:', { platform, arch });
83+
84+
const platformKey = PLATFORM_MAP[platform]?.[arch];
85+
if (!platformKey) {
86+
console.error(`Unsupported platform: ${platform}-${arch}`);
87+
console.error('Supported: macOS (x64/arm64), Linux (x64), Windows (x64)');
88+
process.exit(1);
89+
}
90+
91+
const isWindows = platform === 'win32';
92+
const binaryName = isWindows ? 'leanspec-mcp.exe' : 'leanspec-mcp';
93+
const packageName = `@leanspec/mcp-${platformKey}`;
94+
95+
// 1. Local Rust build (dev workflow).
96+
for (const targetDir of ['debug', 'release']) {
97+
try {
98+
const rustPath = join(__dirname, '..', '..', '..', 'rust', 'target', targetDir, binaryName);
99+
debug(`Trying rust ${targetDir} binary:`, rustPath);
100+
accessSync(rustPath);
101+
if (isExecutableBinary(rustPath, platform)) {
102+
debug(`Found rust ${targetDir} binary:`, rustPath);
103+
return rustPath;
104+
}
105+
} catch (e) {
106+
debug(`Rust ${targetDir} binary not found:`, e.message);
107+
}
108+
}
109+
110+
// 2. Platform npm package shared with the CLI distribution.
111+
try {
112+
const resolvedPath = require.resolve(`${packageName}/${binaryName}`);
113+
if (isExecutableBinary(resolvedPath, platform)) {
114+
debug('Found platform package binary:', resolvedPath);
115+
return resolvedPath;
116+
}
117+
} catch (e) {
118+
debug('Platform package not found:', packageName, '-', e.message);
119+
}
120+
121+
// 3. Local binaries directory populated by scripts/copy-rust-binaries.mjs.
122+
try {
123+
const localPath = join(__dirname, '..', 'binaries', platformKey, binaryName);
124+
debug('Trying local binaries path:', localPath);
125+
accessSync(localPath);
126+
if (isExecutableBinary(localPath, platform)) {
127+
debug('Found local binary:', localPath);
128+
return localPath;
129+
}
130+
} catch (e) {
131+
debug('Local binary not found:', e.message);
132+
}
133+
134+
console.error(`leanspec-mcp binary not found for ${platform}-${arch}`);
135+
console.error('');
136+
console.error('Install the LeanSpec CLI to get the MCP binary:');
137+
console.error(' npm install -g leanspec');
138+
console.error('');
139+
console.error('Or run from a checkout: `cargo build -p leanspec-mcp --release`.');
140+
process.exit(1);
141+
}
142+
143+
const binaryPath = getBinaryPath();
144+
const args = process.argv.slice(2);
145+
debug('Spawning binary:', binaryPath);
146+
147+
const child = spawn(binaryPath, args, {
148+
stdio: 'inherit',
149+
windowsHide: true,
150+
});
151+
152+
child.on('exit', (code, signal) => {
153+
debug('Binary exited:', { code, signal });
154+
if (signal) {
155+
process.kill(process.pid, signal);
156+
return;
157+
}
158+
process.exit(code ?? 1);
159+
});
160+
161+
child.on('error', (err) => {
162+
console.error('Failed to start leanspec-mcp:', err.message);
163+
debug('Spawn error:', err);
164+
process.exit(1);
165+
});
166+
167+
const forwardSignal = (sig) => {
168+
try {
169+
child.kill(sig);
170+
} catch (e) {
171+
debug('Signal forward failed:', e.message);
172+
}
173+
};
174+
process.on('SIGINT', () => forwardSignal('SIGINT'));
175+
process.on('SIGTERM', () => forwardSignal('SIGTERM'));

packages/mcp/package.json

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
{
2+
"name": "@leanspec/mcp",
3+
"version": "0.2.28",
4+
"description": "Model Context Protocol server for LeanSpec — adapter-aware spec management for AI agents",
5+
"type": "module",
6+
"bin": {
7+
"leanspec-mcp": "./bin/leanspec-mcp.js"
8+
},
9+
"scripts": {
10+
"typecheck": "echo 'No TypeScript source to check'"
11+
},
12+
"keywords": [
13+
"leanspec",
14+
"mcp",
15+
"model-context-protocol",
16+
"ai",
17+
"agent",
18+
"spec"
19+
],
20+
"author": "Marvin Zhang",
21+
"license": "MIT",
22+
"repository": {
23+
"type": "git",
24+
"url": "https://github.com/codervisor/leanspec.git"
25+
},
26+
"homepage": "https://lean-spec.dev",
27+
"bugs": {
28+
"url": "https://github.com/codervisor/leanspec/issues"
29+
},
30+
"files": [
31+
"bin/",
32+
"binaries/",
33+
"README.md",
34+
"LICENSE"
35+
],
36+
"engines": {
37+
"node": ">=20"
38+
}
39+
}

rust/Cargo.lock

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

rust/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ members = [
33
"leanspec-core",
44
"leanspec-cli",
55
"leanspec-http",
6+
"leanspec-mcp",
67
]
78
resolver = "2"
89

rust/leanspec-mcp/Cargo.toml

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
[package]
2+
authors.workspace = true
3+
description = "Model Context Protocol server for LeanSpec — adapter-aware spec management for AI agents"
4+
edition.workspace = true
5+
homepage.workspace = true
6+
license.workspace = true
7+
name = "leanspec-mcp"
8+
repository.workspace = true
9+
rust-version.workspace = true
10+
version.workspace = true
11+
12+
[lib]
13+
name = "leanspec_mcp"
14+
path = "src/lib.rs"
15+
16+
[[bin]]
17+
name = "leanspec-mcp"
18+
path = "src/main.rs"
19+
20+
[dependencies]
21+
chrono.workspace = true
22+
leanspec-core = {path = "../leanspec-core", features = ["storage", "git", "github", "ado", "jira"]}
23+
serde.workspace = true
24+
serde_json.workspace = true
25+
serde_yaml.workspace = true
26+
thiserror.workspace = true
27+
tokio.workspace = true
28+
29+
[dev-dependencies]
30+
pretty_assertions.workspace = true
31+
tempfile.workspace = true

rust/leanspec-mcp/src/error.rs

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
//! Tool error vocabulary.
2+
//!
3+
//! `McpToolError` is mapped to a structured JSON-RPC error (`errorCode` in
4+
//! `data`) by the dispatch layer so MCP clients can react programmatically
5+
//! without parsing free-form strings.
6+
7+
use leanspec_core::adapters::AdapterError;
8+
use thiserror::Error;
9+
10+
#[derive(Debug, Error)]
11+
pub enum McpToolError {
12+
#[error("invalid request: {0}")]
13+
InvalidRequest(String),
14+
15+
#[error("spec not found: {0}")]
16+
NotFound(String),
17+
18+
#[error("validation failed: {0}")]
19+
Validation(String),
20+
21+
#[error("operation not supported by adapter '{adapter}': {reason}")]
22+
NotSupported { adapter: String, reason: String },
23+
24+
#[error("authentication failed: {0}")]
25+
Unauthorized(String),
26+
27+
#[error("backend error: {0}")]
28+
Backend(String),
29+
30+
#[error("adapter init failed: {0}")]
31+
AdapterInit(String),
32+
33+
#[error("internal error: {0}")]
34+
Internal(String),
35+
}
36+
37+
impl McpToolError {
38+
/// Stable error code surfaced in the JSON-RPC error `data.errorCode`.
39+
pub fn code(&self) -> &'static str {
40+
match self {
41+
Self::InvalidRequest(_) => "INVALID_REQUEST",
42+
Self::NotFound(_) => "SPEC_NOT_FOUND",
43+
Self::Validation(_) => "VALIDATION_FAILED",
44+
Self::NotSupported { .. } => "ADAPTER_NOT_SUPPORTED",
45+
Self::Unauthorized(_) => "UNAUTHORIZED",
46+
Self::Backend(_) => "BACKEND_ERROR",
47+
Self::AdapterInit(_) => "ADAPTER_INIT_FAILED",
48+
Self::Internal(_) => "INTERNAL_ERROR",
49+
}
50+
}
51+
52+
/// JSON-RPC numeric code. Application errors use the conventional
53+
/// `-32000` range; `-32602` is the standard "Invalid params" code.
54+
pub fn jsonrpc_code(&self) -> i32 {
55+
match self {
56+
Self::InvalidRequest(_) => -32602,
57+
_ => -32000,
58+
}
59+
}
60+
}
61+
62+
impl From<AdapterError> for McpToolError {
63+
fn from(err: AdapterError) -> Self {
64+
match err {
65+
AdapterError::NotFound(id) => Self::NotFound(id),
66+
AdapterError::NotSupported { adapter, operation } => Self::NotSupported {
67+
adapter,
68+
reason: format!("operation '{operation}' is unsupported"),
69+
},
70+
AdapterError::InvalidField { adapter, reason } => {
71+
Self::Validation(format!("{adapter}: {reason}"))
72+
}
73+
AdapterError::AuthError { adapter, reason } => {
74+
Self::Unauthorized(format!("{adapter}: {reason}"))
75+
}
76+
AdapterError::ConfigError(reason) => Self::AdapterInit(reason),
77+
AdapterError::ParseError { reason, .. } => Self::Validation(reason),
78+
AdapterError::BackendError { adapter, reason } => {
79+
Self::Backend(format!("{adapter}: {reason}"))
80+
}
81+
AdapterError::RateLimit { .. } => Self::Backend(err.to_string()),
82+
AdapterError::IoError(e) => Self::Internal(e.to_string()),
83+
}
84+
}
85+
}

0 commit comments

Comments
 (0)