Skip to content

feat(mcp): add adapter-aware MCP server#299

Merged
tikazyq merged 2 commits into
mainfrom
claude/implement-issue-270-KVOUB
May 19, 2026
Merged

feat(mcp): add adapter-aware MCP server#299
tikazyq merged 2 commits into
mainfrom
claude/implement-issue-270-KVOUB

Conversation

@tikazyq

@tikazyq tikazyq commented May 19, 2026

Copy link
Copy Markdown
Contributor

Closes #270.

Summary

Re-introduces rust/leanspec-mcp and packages/mcp — the MCP integration that was removed in the spec-framework pivot (e03d895 / 39bd6c9) — and rebuilds it on top of the adapter layer instead of the markdown-specific SpecLoader it bypassed before.

The same set of tools now works against markdown, GitHub, ADO, and Jira projects without prompt-side backend knowledge:

Tool Adapter scope
list_specs all adapters
get_spec all adapters
create_spec all adapters (subject to capabilities().supports_create)
update_spec all adapters (subject to capabilities().supports_update)
search_specs all adapters
get_schema all adapters — returns the active adapter's full SpecSchema
get_capabilities all adapters — returns AdapterCapabilities
validate_spec, get_dependencies, get_stats markdown-only — return structured ADAPTER_NOT_SUPPORTED otherwise

Schema-driven tool descriptions

Tool input schemas and parameter descriptions are generated from the active adapter's SpecSchema at startup. Enum fields surface their declared options as JSON Schema enum constraints, and each parameter description is taken from the corresponding FieldDef::ai_hint — exactly the differentiator called out in the spec.

For the markdown adapter, smoke-testing the binary shows create_spec.status advertised as:

{
  "type": "string",
  "enum": ["draft", "planned", "in-progress", "complete", "archived"],
  "description": "Current workflow state of this spec"
}

Adapter routing

AdapterRegistry::from_project() is called once at startup in ServerState::from_project, and the resulting Arc<ServerState> is shared across every JSON-RPC handler. GitHub / ADO / Jira's resolve_inline cost (label/state fetches) is paid at construction so per-call latency stays predictable.

packages/mcp wrapper

The Node.js wrapper mirrors the existing packages/cli pattern — it spawns the leanspec-mcp Rust binary and pipes stdio through unchanged. Binary discovery checks the local Rust target, then the @leanspec/mcp-<platform> npm platform package, then packages/mcp/binaries/<platform>/ (the layout scripts/copy-rust-binaries.mjs already produces). SIGINT/SIGTERM are forwarded to the child.

Test plan

  • cargo test -p leanspec-mcp — 15 tests pass (6 unit + 9 integration)
    • create_spec inputSchema includes status enum constraint
    • create_spec with invalid status returns VALIDATION_FAILED
    • validate_spec on non-markdown adapter returns ADAPTER_NOT_SUPPORTED
    • list_specs routes through the active adapter (fake remote adapter test)
    • get_schema returns full field definitions including enum options
    • tools/list advertises all 10 tools
  • cargo clippy -p leanspec-mcp --all-targets -- -D warnings clean
  • cargo clippy --all-targets -- -D warnings clean for the full workspace
  • cargo fmt --check clean
  • End-to-end stdio smoke test: initializetools/listget_schemaget_capabilities round-trip
  • Pre-existing leanspec-cli regression test failure (test_regression_special_characters_in_name) verified unrelated by re-running on a stash of the working tree

https://claude.ai/code/session_01YMBFwEEmcN74zpXph81YfF


Generated by Claude Code

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.
Copilot AI review requested due to automatic review settings May 19, 2026 05:50

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR re-introduces the LeanSpec MCP server and Node wrapper, rebuilding the MCP layer on top of the adapter abstraction so the same MCP tools work across markdown and remote backends (GitHub/ADO/Jira), with schema-driven tool input schemas.

Changes:

  • Adds rust/leanspec-mcp JSON-RPC stdio server with adapter-routed tools (list_specs, get_spec, create_spec, update_spec, search_specs, get_schema, get_capabilities) plus markdown-only guarded tools.
  • Generates tools/list inputSchema dynamically from the active adapter SpecSchema (including enum constraints and ai_hint descriptions).
  • Adds packages/mcp Node.js bin wrapper that discovers/spawns the Rust leanspec-mcp binary and forwards stdio/signals.

Reviewed changes

Copilot reviewed 20 out of 22 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
rust/leanspec-mcp/tests/integration.rs End-to-end integration tests for MCP request handling and adapter routing
rust/leanspec-mcp/src/tools/update_spec.rs Implements adapter-backed update_spec with merge + clear semantics
rust/leanspec-mcp/src/tools/search_specs.rs Implements adapter-backed full-text search_specs
rust/leanspec-mcp/src/tools/mod.rs Tool registry (definitions + dispatch) including markdown-only catalog entries
rust/leanspec-mcp/src/tools/markdown_only.rs Markdown-only tools with structured ADAPTER_NOT_SUPPORTED guard
rust/leanspec-mcp/src/tools/list_specs.rs Adapter-backed listing with semantic alias mapping in request args
rust/leanspec-mcp/src/tools/get_spec.rs Adapter-backed get_spec
rust/leanspec-mcp/src/tools/get_schema.rs Exposes active adapter SpecSchema via MCP
rust/leanspec-mcp/src/tools/get_capabilities.rs Exposes adapter capabilities via MCP
rust/leanspec-mcp/src/tools/doc.rs Centralized SpecDoc → JSON conversion helper
rust/leanspec-mcp/src/tools/create_spec.rs Implements adapter-backed create_spec + JSON→FieldValue parsing/validation
rust/leanspec-mcp/src/state.rs ServerState construction with adapter resolved once at startup
rust/leanspec-mcp/src/schema_to_input.rs SpecSchema → JSON Schema translation for MCP tool input schemas
rust/leanspec-mcp/src/protocol.rs MCP JSON-RPC request/response wire types
rust/leanspec-mcp/src/main.rs Stdio loop entrypoint for the Rust MCP server
rust/leanspec-mcp/src/lib.rs Request dispatch (initialize, tools/list, tools/call) + result wrapping
rust/leanspec-mcp/src/error.rs MCP tool error vocabulary + mapping from AdapterError
rust/leanspec-mcp/Cargo.toml New Rust crate manifest for leanspec-mcp
rust/Cargo.toml Adds leanspec-mcp to the Rust workspace members
rust/Cargo.lock Locks new crate and dependency graph updates
packages/mcp/package.json New Node package manifest for @leanspec/mcp
packages/mcp/bin/leanspec-mcp.js Node launcher that discovers/spawns the Rust leanspec-mcp binary

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +133 to +167
/// Build the `inputSchema` object for `update_spec`: id required, all
/// other fields optional.
pub fn update_input_schema(schema: &SpecSchema) -> Value {
let mut properties = Map::new();

properties.insert(
"id".into(),
json!({
"type": "string",
"description": "Adapter-native identifier of the spec to update."
}),
);
properties.insert(
"title".into(),
json!({
"type": "string",
"description": "New title. Omit to leave unchanged."
}),
);

for field in &schema.fields {
if field.key == "title" || field.key == "id" {
continue;
}
let prop = field_to_property(field);
properties.insert(prop.key, prop.schema);
}

json!({
"type": "object",
"properties": properties,
"required": ["id"],
"additionalProperties": false
})
}
Comment on lines +169 to +231
/// Build the `inputSchema` for `list_specs`: filter fields are taken from
/// the schema (each accepts an array of strings).
pub fn list_input_schema(schema: &SpecSchema) -> Value {
let mut properties = Map::new();

for field in &schema.fields {
if matches!(field.kind, FieldKind::Enum { .. }) {
let mut item_prop = Map::new();
item_prop.insert("type".into(), json!("string"));
if let FieldKind::Enum {
options,
allow_custom,
..
} = &field.kind
{
if !options.is_empty() && !*allow_custom {
item_prop.insert(
"enum".into(),
json!(options.iter().map(|o| o.value.clone()).collect::<Vec<_>>()),
);
}
}
let description = format!("Filter by {} (one or more values).", field.label);
properties.insert(
field.key.clone(),
json!({
"type": "array",
"items": Value::Object(item_prop),
"description": description
}),
);
}
}

properties.insert(
"text".into(),
json!({
"type": "string",
"description": "Free-text filter applied to title and body content."
}),
);
properties.insert(
"include_archived".into(),
json!({
"type": "boolean",
"description": "Include archived specs in the result (default false)."
}),
);
properties.insert(
"limit".into(),
json!({
"type": "integer",
"minimum": 1,
"description": "Maximum number of specs to return."
}),
);

json!({
"type": "object",
"properties": properties,
"additionalProperties": false
})
}
Comment thread rust/leanspec-mcp/src/tools/doc.rs Outdated
Comment on lines +10 to +11
pub fn doc_to_json(doc: &SpecDoc) -> Value {
serde_json::to_value(doc).unwrap_or(Value::Null)
Comment thread rust/leanspec-mcp/src/lib.rs Outdated
Comment on lines +10 to +24
//! ```text
//! stdio ─┐
//! │ JSON-RPC framing (protocol.rs)
//! ↓
//! dispatch (lib.rs::handle_request)
//! ↓
//! tools::* ──► AdapterRegistry::from_project()
//! │
//! └─► markdown / github / ado / jira
//! ```
//!
//! Adapter init is performed once at startup (`ServerState::from_project`)
//! and shared across every tool call via `Arc<ServerState>`. For
//! network-backed schemas, the adapter's `resolve_inline` baking happens
//! during construction so per-call latency stays predictable.
Comment on lines +87 to +115
#[test]
fn tools_list_advertises_all_seven_core_tools() {
let tmp = TempDir::new().unwrap();
let state = markdown_state(&tmp);

let req = parse_request("tools/list", json!({}), 2);
let resp = rt().block_on(handle_request(state, req));

let tools = resp.result.as_ref().unwrap()["tools"]
.as_array()
.unwrap()
.clone();
let names: Vec<String> = tools
.iter()
.map(|t| t["name"].as_str().unwrap().to_string())
.collect();

for required in &[
"list_specs",
"get_spec",
"create_spec",
"update_spec",
"search_specs",
"get_schema",
"get_capabilities",
"validate_spec",
"get_dependencies",
"get_stats",
] {
- 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.
@tikazyq tikazyq merged commit 0f33f41 into main May 19, 2026
2 of 3 checks passed
@tikazyq tikazyq deleted the claude/implement-issue-270-KVOUB branch May 19, 2026 06:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

spec(mcp): MCP Layer Generalization

3 participants