feat(mcp): add adapter-aware MCP server#299
Merged
Merged
Conversation
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.
There was a problem hiding this comment.
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-mcpJSON-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/listinputSchemadynamically from the active adapterSpecSchema(including enum constraints andai_hintdescriptions). - Adds
packages/mcpNode.js bin wrapper that discovers/spawns the Rustleanspec-mcpbinary 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 on lines
+10
to
+11
| pub fn doc_to_json(doc: &SpecDoc) -> Value { | ||
| serde_json::to_value(doc).unwrap_or(Value::Null) |
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.
This was referenced May 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #270.
Summary
Re-introduces
rust/leanspec-mcpandpackages/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-specificSpecLoaderit bypassed before.The same set of tools now works against markdown, GitHub, ADO, and Jira projects without prompt-side backend knowledge:
list_specsget_speccreate_speccapabilities().supports_create)update_speccapabilities().supports_update)search_specsget_schemaSpecSchemaget_capabilitiesAdapterCapabilitiesvalidate_spec,get_dependencies,get_statsADAPTER_NOT_SUPPORTEDotherwiseSchema-driven tool descriptions
Tool input schemas and parameter descriptions are generated from the active adapter's
SpecSchemaat startup. Enum fields surface their declared options as JSON Schemaenumconstraints, and each parameter description is taken from the correspondingFieldDef::ai_hint— exactly the differentiator called out in the spec.For the markdown adapter, smoke-testing the binary shows
create_spec.statusadvertised 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 inServerState::from_project, and the resultingArc<ServerState>is shared across every JSON-RPC handler. GitHub / ADO / Jira'sresolve_inlinecost (label/state fetches) is paid at construction so per-call latency stays predictable.packages/mcpwrapperThe Node.js wrapper mirrors the existing
packages/clipattern — it spawns theleanspec-mcpRust binary and pipes stdio through unchanged. Binary discovery checks the local Rust target, then the@leanspec/mcp-<platform>npm platform package, thenpackages/mcp/binaries/<platform>/(the layoutscripts/copy-rust-binaries.mjsalready produces). SIGINT/SIGTERM are forwarded to the child.Test plan
cargo test -p leanspec-mcp— 15 tests pass (6 unit + 9 integration)create_specinputSchemaincludesstatusenum constraintcreate_specwith invalid status returnsVALIDATION_FAILEDvalidate_specon non-markdown adapter returnsADAPTER_NOT_SUPPORTEDlist_specsroutes through the active adapter (fake remote adapter test)get_schemareturns full field definitions including enum optionstools/listadvertises all 10 toolscargo clippy -p leanspec-mcp --all-targets -- -D warningscleancargo clippy --all-targets -- -D warningsclean for the full workspacecargo fmt --checkcleaninitialize→tools/list→get_schema→get_capabilitiesround-tripleanspec-cliregression test failure (test_regression_special_characters_in_name) verified unrelated by re-running on a stash of the working treehttps://claude.ai/code/session_01YMBFwEEmcN74zpXph81YfF
Generated by Claude Code