Marketplace Lite Self-Contained MCP Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Replace marketplace plugin dependence on user-installed Node with a plugin-bundled Rust Literature Provider MCP executable while preserving the Python Full CLI as the complete runtime.
Architecture: Add external MCP contracts first, then build a Rust Lite MCP runtime under packages/qiongli-lite-mcp/ that implements the marketplace-safe literature/provider subset. Marketplace plugin builders package target-specific Rust binaries at a stable plugin-local path, while Python Full CLI remains unchanged except for parity tests and documentation that explain the two runtime profiles.
Tech Stack: Rust 1.78+, Cargo, serde, serde_json, thiserror, reqwest with rustls-tls and blocking client, quick-xml, Python 3.12 release scripts and unittest, stdio JSON-RPC MCP, existing Node MCP runtime as a temporary parity reference.
Scope Guardrails
- Do not remove
packages/python-qiongli/or changeqiongli mcp serve --transport stdio. - Do not migrate Full CLI to Rust in this project.
- Do not remove
packages/qiongli-literature-mcpb/in the first implementation pass. - Do not require users to install Node, Python, npm, pip, Cargo, Rust, or the Qiongli CLI for Marketplace Lite.
- Do not launch local agents from the Rust Lite MCP runtime.
- Do not silently install the Zotero Companion.
- Do not put provider secrets in manifests, artifacts, tests, or logs.
- Do not rely on shell wrapper scripts as the marketplace entrypoint.
File Structure
- Create
content/mcp-contracts/lite-tools.json: canonical overlapping Lite tool definitions. - Create
content/mcp-contracts/provider-config.schema.json: provider config schema used by Rust Lite and Python Full. - Create
content/mcp-contracts/literature-result.schema.json: normalized literature result contract. - Create
content/mcp-contracts/literature-diagnostics.schema.json: diagnostics contract. - Create
content/mcp-contracts/search-plan.schema.json: search-plan contract. - Create
content/mcp-contracts/zotero-import-files.schema.json: Zotero import-file contract. - Create
content/mcp-contracts/fixtures/: mocked provider responses and expected normalized output. - Create
packages/qiongli-lite-mcp/Cargo.toml: Rust package manifest for the self-contained Lite MCP runtime. - Create
packages/qiongli-lite-mcp/src/main.rs: executable entrypoint. - Create
packages/qiongli-lite-mcp/src/mcp/protocol.rs: JSON-RPC request/response types and stdio helpers. - Create
packages/qiongli-lite-mcp/src/mcp/server.rs: initialize, ping, tools/list, and tools/call dispatch. - Create
packages/qiongli-lite-mcp/src/tools/definitions.rs: tool definitions loaded fromcontent/mcp-contracts/lite-tools.json. - Create
packages/qiongli-lite-mcp/src/config/provider_config.rs: shared provider config path, read, write, summary, redaction. - Create
packages/qiongli-lite-mcp/src/config/wizard.rs: local loopback provider setup page. - Create
packages/qiongli-lite-mcp/src/providers/search.rs: provider search orchestration and normalization. - Create
packages/qiongli-lite-mcp/src/providers/openalex.rs: OpenAlex client. - Create
packages/qiongli-lite-mcp/src/providers/semantic_scholar.rs: Semantic Scholar client. - Create
packages/qiongli-lite-mcp/src/providers/crossref.rs: Crossref client. - Create
packages/qiongli-lite-mcp/src/providers/pubmed.rs: PubMed E-Utilities client. - Create
packages/qiongli-lite-mcp/src/providers/arxiv.rs: arXiv Atom client. - Create
packages/qiongli-lite-mcp/src/searchplan.rs: search-plan response builder. - Create
packages/qiongli-lite-mcp/src/zotero/export.rs: CSL JSON, RIS, BibTeX, and import report exporters. - Create
packages/qiongli-lite-mcp/src/zotero/companion.rs: loopback-only Zotero Companion probing and calls. - Create
packages/qiongli-lite-mcp/src/orchestrator/preview.rs: preview-only route and task-plan handlers. - Create
tooling/scripts/build_lite_mcp.py: platform binary build and staging script. - Create
scripts/build_lite_mcp.py: wrapper that delegates totooling/scripts/build_lite_mcp.py. - Modify
tooling/scripts/build_plugin_artifacts.py: copy Rust Lite binary into marketplace/direct plugin artifacts and update MCP command entries. - Modify
content/distribution/platform-targets.yaml: introducemarketplace-lite-binarybundled MCP mode and Rust runtime source metadata. - Modify
tooling/scripts/validate_marketplace_install.py: assert marketplace artifacts use plugin-local binary commands. - Modify
tests/test_plugin_artifacts.py: assert Codex, Claude Code, and direct Desktop plugin manifests no longer callnodeonce binary mode is enabled. - Modify
tests/test_literature_mcpb_artifact.py: keep legacy MCPB tests and add binary MCPB tests after the migration switch. - Create
tests/test_lite_mcp_binary_artifacts.py: release staging tests for binary names, permissions, and manifest command paths. - Create
tests/test_lite_mcp_contract.py: Python-driven black-box tests that run the Rust Lite MCP executable. - Create
tests/test_mcp_contract_fixtures.py: Python Full CLI contract fixture validation. - Modify
docs/advanced/cross-platform-mcp.md,docs/advanced/plugin-first-architecture.md,docs/guide/install.md,README.md, andREADME_CN.md: describe Rust Marketplace Lite versus Python Full CLI.
Task 1: Add External MCP Contracts And Fixtures
Files:
Create:
content/mcp-contracts/lite-tools.jsonCreate:
content/mcp-contracts/provider-config.schema.jsonCreate:
content/mcp-contracts/literature-result.schema.jsonCreate:
content/mcp-contracts/literature-diagnostics.schema.jsonCreate:
content/mcp-contracts/search-plan.schema.jsonCreate:
content/mcp-contracts/zotero-import-files.schema.jsonCreate:
content/mcp-contracts/fixtures/openalex-search-response.jsonCreate:
content/mcp-contracts/fixtures/expected-normalized-results.jsonCreate:
tests/test_mcp_contract_fixtures.py[ ] Step 1: Write failing contract fixture test
Create tests/test_mcp_contract_fixtures.py:
from __future__ import annotations
import json
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CONTRACT_ROOT = REPO_ROOT / "content" / "mcp-contracts"
class MCPContractFixtureTests(unittest.TestCase):
def test_lite_tools_contract_declares_required_tools(self) -> None:
tools = json.loads((CONTRACT_ROOT / "lite-tools.json").read_text(encoding="utf-8"))
names = {tool["name"] for tool in tools["tools"]}
self.assertTrue(
{
"qiongli_config_status",
"qiongli_configure_provider",
"qiongli_save_provider_config",
"qiongli_open_config_wizard",
"qiongli_literature_status",
"qiongli_search_plan",
"qiongli_literature_search",
"qiongli_literature_export_evidence",
"qiongli_zotero_status",
"qiongli_zotero_export_import_files",
"qiongli_orchestrator_route",
"qiongli_task_plan",
}.issubset(names)
)
def test_expected_normalized_results_fixture_has_stable_shape(self) -> None:
payload = json.loads(
(CONTRACT_ROOT / "fixtures" / "expected-normalized-results.json").read_text(
encoding="utf-8"
)
)
first = payload["results"][0]
self.assertEqual(first["title"], "A Test Paper")
self.assertEqual(first["doi"], "10.1234/example")
self.assertEqual(first["year"], 2025)
self.assertEqual(first["providers"], ["openalex"])- [ ] Step 2: Run RED
Run:
.venv/bin/python -m unittest tests.test_mcp_contract_fixtures -vExpected: fail because contract files do not exist.
- [ ] Step 3: Add
lite-tools.json
Create content/mcp-contracts/lite-tools.json:
{
"schema_version": "1.0",
"tools": [
{
"name": "qiongli_config_status",
"description": "Return redacted Qiongli provider configuration status.",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"name": "qiongli_save_provider_config",
"description": "Save explicit Qiongli provider config values from chat or scripts. Prefer qiongli_configure_provider for API keys.",
"inputSchema": {
"type": "object",
"required": ["provider", "field", "value"],
"properties": {
"provider": { "type": "string" },
"field": { "type": "string" },
"value": { "type": "string" }
},
"additionalProperties": false
}
},
{
"name": "qiongli_configure_provider",
"description": "Open a local browser-based setup page for Qiongli provider credentials.",
"inputSchema": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": ["openalex", "semantic_scholar", "semantic-scholar", "crossref", "pubmed"]
},
"host": { "type": "string", "default": "127.0.0.1" },
"port": { "type": "integer", "default": 0 }
},
"additionalProperties": false
}
},
{
"name": "qiongli_open_config_wizard",
"description": "Compatibility alias for qiongli_configure_provider.",
"inputSchema": {
"type": "object",
"properties": {
"provider": {
"type": "string",
"enum": ["openalex", "semantic_scholar", "semantic-scholar", "crossref", "pubmed"]
},
"host": { "type": "string", "default": "127.0.0.1" },
"port": { "type": "integer", "default": 0 }
},
"additionalProperties": false
}
},
{
"name": "qiongli_literature_status",
"description": "Report configured literature providers and capability mode without exposing secrets.",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": false
}
},
{
"name": "qiongli_search_plan",
"description": "Plan provider MCP and platform-native literature search routing.",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": { "type": "string" },
"search_mode": { "type": "string" },
"native_search_usable": { "type": "boolean", "default": false }
},
"additionalProperties": false
}
},
{
"name": "qiongli_literature_search",
"description": "Search academic literature using configured OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv providers.",
"inputSchema": {
"type": "object",
"required": ["query"],
"properties": {
"query": { "type": "string" },
"search_mode": { "type": "string" },
"limit": { "type": "integer" },
"per_provider_limit": { "type": "integer" },
"total_limit": { "type": "integer" }
},
"additionalProperties": true
}
},
{
"name": "qiongli_literature_export_evidence",
"description": "Export an auditable provider capability, search plan, diagnostics, and result snapshot.",
"inputSchema": {
"type": "object",
"properties": {
"query": { "type": "string" },
"results": { "type": "array" },
"diagnostics": { "type": "object" }
},
"additionalProperties": true
}
},
{
"name": "qiongli_zotero_status",
"description": "Report local Zotero Desktop connector, Qiongli companion, and import-file fallback availability.",
"inputSchema": {
"type": "object",
"properties": {},
"additionalProperties": true
}
},
{
"name": "qiongli_zotero_export_import_files",
"description": "Generate Zotero-compatible CSL-JSON, RIS, BibTeX, and import-report files from Qiongli references.",
"inputSchema": {
"type": "object",
"properties": {
"records": { "type": "array" },
"formats": { "type": "array", "items": { "type": "string" } }
},
"additionalProperties": true
}
},
{
"name": "qiongli_orchestrator_route",
"description": "Preview whether the client should use skill-only routing or Full CLI orchestration.",
"inputSchema": {
"type": "object",
"required": ["request"],
"properties": {
"request": { "type": "string" },
"platform": { "type": "string" }
},
"additionalProperties": true
}
},
{
"name": "qiongli_task_plan",
"description": "Render a Qiongli task execution plan without launching runtime agents.",
"inputSchema": {
"type": "object",
"required": ["task_id", "paper_type", "topic"],
"properties": {
"task_id": { "type": "string" },
"paper_type": { "type": "string" },
"topic": { "type": "string" }
},
"additionalProperties": true
}
}
]
}- [ ] Step 4: Add normalized result fixture
Create content/mcp-contracts/fixtures/openalex-search-response.json:
{
"results": [
{
"id": "https://openalex.org/W1",
"display_name": "A Test Paper",
"publication_year": 2025,
"doi": "https://doi.org/10.1234/example",
"primary_location": {
"source": { "display_name": "Journal of Tests" }
}
}
]
}Create content/mcp-contracts/fixtures/expected-normalized-results.json:
{
"results": [
{
"title": "A Test Paper",
"doi": "10.1234/example",
"year": 2025,
"venue": "Journal of Tests",
"provider": "openalex",
"providers": ["openalex"]
}
]
}- [ ] Step 5: Add minimal schema files
Create each schema file with "schema_version": "1.0" and explicit required fields used by the fixtures. Use this shape for literature-result.schema.json:
{
"schema_version": "1.0",
"type": "object",
"required": ["results"],
"properties": {
"results": {
"type": "array",
"items": {
"type": "object",
"required": ["title", "provider", "providers"],
"properties": {
"title": { "type": "string" },
"doi": { "type": "string" },
"year": { "type": "integer" },
"venue": { "type": "string" },
"provider": { "type": "string" },
"providers": { "type": "array", "items": { "type": "string" } }
}
}
}
}
}- [ ] Step 6: Run GREEN
Run:
.venv/bin/python -m unittest tests.test_mcp_contract_fixtures -vExpected: pass.
- [ ] Step 7: Commit
git add content/mcp-contracts tests/test_mcp_contract_fixtures.py
git commit -m "test(mcp): add lite runtime contracts"Task 2: Add Contract Tests For No-Runtime Marketplace Manifests
Files:
Modify:
tests/test_plugin_artifacts.pyCreate:
tests/test_lite_mcp_binary_artifacts.pyModify:
content/distribution/platform-targets.yaml[ ] Step 1: Add failing Codex manifest assertion
In tests/test_plugin_artifacts.py, update _assert_codex_mcp_server so the stable expected command is plugin-local and not Node:
self.assertNotEqual(server["command"], "node")
self.assertEqual(server["command"], "./bin/qiongli-literature-provider")
self.assertEqual(server["args"], ["--transport", "stdio"])- [ ] Step 2: Add failing Claude manifest assertion
In _assert_claude_manifest_mcp_server and _assert_claude_zip_manifest_mcp_server, replace the Node expectations with:
self.assertNotEqual(server["command"], "node")
self.assertEqual(
server["command"],
"${CLAUDE_PLUGIN_ROOT}/bin/qiongli-literature-provider",
)
self.assertEqual(server["args"], ["--transport", "stdio"])
self.assertEqual(server["cwd"], "${CLAUDE_PLUGIN_ROOT}")- [ ] Step 3: Add artifact binary presence tests
Create tests/test_lite_mcp_binary_artifacts.py:
from __future__ import annotations
import json
import tarfile
import tempfile
import unittest
import zipfile
from pathlib import Path
from scripts.build_plugin_artifacts import build_artifacts
from qiongli.source_layout import RepoLayout
REPO_ROOT = Path(__file__).resolve().parents[1]
class LiteMCPBinaryArtifactTests(unittest.TestCase):
def test_codex_plugin_contains_lite_mcp_binary(self) -> None:
tag = (RepoLayout(REPO_ROOT).workflow / "VERSION").read_text(encoding="utf-8").strip()
with tempfile.TemporaryDirectory() as tmp_dir:
artifacts = build_artifacts(REPO_ROOT, tag, Path(tmp_dir))
codex = next(path for path in artifacts if "-codex-plugin-" in path.name)
with tarfile.open(codex, "r:gz") as archive:
names = set(archive.getnames())
member = next(name for name in names if name.endswith("/plugins/qiongli/.mcp.json"))
manifest = json.loads(archive.extractfile(member).read().decode("utf-8"))
self.assertTrue(any(name.endswith("/plugins/qiongli/bin/qiongli-literature-provider") for name in names))
server = manifest["mcpServers"]["qiongli"]
self.assertEqual(server["command"], "./bin/qiongli-literature-provider")
self.assertEqual(server["args"], ["--transport", "stdio"])
def test_direct_desktop_plugin_contains_lite_mcp_binary(self) -> None:
tag = (RepoLayout(REPO_ROOT).workflow / "VERSION").read_text(encoding="utf-8").strip()
with tempfile.TemporaryDirectory() as tmp_dir:
artifacts = build_artifacts(REPO_ROOT, tag, Path(tmp_dir))
desktop = next(path for path in artifacts if "claude-desktop-plugin" in path.name)
with zipfile.ZipFile(desktop) as archive:
names = set(archive.namelist())
manifest = json.loads(archive.read("qiongli/.claude-plugin/plugin.json").decode("utf-8"))
self.assertIn("qiongli/bin/qiongli-literature-provider", names)
server = manifest["mcpServers"]["qiongli"]
self.assertEqual(server["command"], "${CLAUDE_PLUGIN_ROOT}/bin/qiongli-literature-provider")
self.assertEqual(server["args"], ["--transport", "stdio"])- [ ] Step 4: Run RED tests
Run:
.venv/bin/python -m unittest tests.test_plugin_artifacts tests.test_lite_mcp_binary_artifacts -vExpected: failures showing manifests still call node and plugin artifacts do not contain bin/qiongli-literature-provider.
Task 3: Scaffold The Rust Lite MCP Runtime
Files:
Create:
packages/qiongli-lite-mcp/Cargo.tomlCreate:
packages/qiongli-lite-mcp/src/main.rsCreate:
packages/qiongli-lite-mcp/src/lib.rsCreate:
packages/qiongli-lite-mcp/src/mcp/mod.rsCreate:
packages/qiongli-lite-mcp/src/mcp/server.rsCreate:
packages/qiongli-lite-mcp/src/tools/mod.rsCreate:
packages/qiongli-lite-mcp/src/tools/definitions.rsCreate:
packages/qiongli-lite-mcp/tests/mcp_server.rs[ ] Step 1: Add failing Rust MCP initialize/list integration test
Create packages/qiongli-lite-mcp/tests/mcp_server.rs:
use qiongli_lite_mcp::mcp::server::{McpRequest, McpServer};
use serde_json::json;
#[test]
fn initialize_and_tools_list_return_json_rpc_results() {
let server = McpServer::new("qiongli-literature-provider", "0.1.0");
let initialize = server.handle(McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(json!(1)),
method: "initialize".to_string(),
params: Some(json!({"protocolVersion": "2025-11-25"})),
});
assert_eq!(initialize["result"]["serverInfo"]["name"], "qiongli-literature-provider");
let tools = server.handle(McpRequest {
jsonrpc: "2.0".to_string(),
id: Some(json!(2)),
method: "tools/list".to_string(),
params: Some(json!({})),
});
let names: Vec<&str> = tools["result"]["tools"]
.as_array()
.unwrap()
.iter()
.map(|tool| tool["name"].as_str().unwrap())
.collect();
assert!(names.contains(&"qiongli_literature_status"));
}- [ ] Step 2: Run RED
Run:
cd packages/qiongli-lite-mcp && cargo testExpected: fail because the Rust package does not exist.
- [ ] Step 3: Add Cargo manifest
Create packages/qiongli-lite-mcp/Cargo.toml:
[package]
name = "qiongli-lite-mcp"
version = "0.1.0"
edition = "2021"
license = "MIT"
[[bin]]
name = "qiongli-literature-provider"
path = "src/main.rs"
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "1"
reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] }
quick-xml = "0.36"
url = "2"- [ ] Step 4: Add minimal Rust library and server
Create packages/qiongli-lite-mcp/src/lib.rs:
pub mod mcp;
pub mod tools;Create packages/qiongli-lite-mcp/src/mcp/mod.rs:
pub mod server;Create packages/qiongli-lite-mcp/src/tools/mod.rs:
pub mod definitions;Create packages/qiongli-lite-mcp/src/tools/definitions.rs:
use serde::Serialize;
use serde_json::json;
#[derive(Debug, Clone, Serialize)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
#[serde(rename = "inputSchema")]
pub input_schema: serde_json::Value,
}
pub fn lite_tool_definitions() -> Vec<ToolDefinition> {
vec![ToolDefinition {
name: "qiongli_literature_status".to_string(),
description: "Report configured literature providers and capability mode without exposing secrets.".to_string(),
input_schema: json!({
"type": "object",
"properties": {},
"additionalProperties": false
}),
}]
}Create packages/qiongli-lite-mcp/src/mcp/server.rs:
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use crate::tools::definitions::lite_tool_definitions;
#[derive(Debug, Clone, Deserialize)]
pub struct McpRequest {
pub jsonrpc: String,
pub id: Option<Value>,
pub method: String,
pub params: Option<Value>,
}
#[derive(Debug, Clone)]
pub struct McpServer {
name: String,
version: String,
}
impl McpServer {
pub fn new(name: impl Into<String>, version: impl Into<String>) -> Self {
Self {
name: name.into(),
version: version.into(),
}
}
pub fn handle(&self, request: McpRequest) -> Value {
match request.method.as_str() {
"initialize" => self.result(request.id, json!({
"protocolVersion": request
.params
.as_ref()
.and_then(|params| params.get("protocolVersion"))
.and_then(Value::as_str)
.unwrap_or("2025-11-25"),
"capabilities": { "tools": {} },
"serverInfo": {
"name": self.name,
"version": self.version
}
})),
"tools/list" => self.result(request.id, json!({
"tools": lite_tool_definitions()
})),
_ => self.error(request.id, -32601, format!("Method not found: {}", request.method)),
}
}
fn result(&self, id: Option<Value>, result: Value) -> Value {
json!({"jsonrpc": "2.0", "id": id, "result": result})
}
fn error(&self, id: Option<Value>, code: i64, message: String) -> Value {
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
}
}Create packages/qiongli-lite-mcp/src/main.rs:
use std::io::{self, BufRead, Write};
use qiongli_lite_mcp::mcp::server::{McpRequest, McpServer};
use serde_json::json;
fn main() {
let server = McpServer::new("qiongli-literature-provider", env!("CARGO_PKG_VERSION"));
let stdin = io::stdin();
let mut stdout = io::stdout();
for line in stdin.lock().lines() {
let Ok(line) = line else {
continue;
};
if line.trim().is_empty() {
continue;
}
let response = match serde_json::from_str::<McpRequest>(&line) {
Ok(request) => server.handle(request),
Err(_) => json!({
"jsonrpc": "2.0",
"id": null,
"error": {"code": -32700, "message": "Parse error"}
}),
};
writeln!(stdout, "{}", response).expect("failed to write MCP response");
stdout.flush().expect("failed to flush MCP response");
}
}- [ ] Step 5: Run GREEN
Run:
cd packages/qiongli-lite-mcp && cargo testExpected: pass.
- [ ] Step 6: Commit
git add packages/qiongli-lite-mcp
git commit -m "feat(lite-mcp): scaffold rust MCP runtime"Task 4: Implement Provider Config Parity In Rust
Files:
Create:
packages/qiongli-lite-mcp/src/config/mod.rsCreate:
packages/qiongli-lite-mcp/src/config/provider_config.rsCreate:
packages/qiongli-lite-mcp/tests/provider_config.rsModify:
packages/qiongli-lite-mcp/src/lib.rsModify:
packages/qiongli-lite-mcp/src/mcp/server.rsModify:
packages/qiongli-lite-mcp/src/tools/definitions.rs[ ] Step 1: Write provider config tests
Create packages/qiongli-lite-mcp/tests/provider_config.rs:
use qiongli_lite_mcp::config::provider_config::{
provider_config_path, save_provider_value, summary,
};
#[test]
fn provider_config_path_uses_qiongli_config_home() {
let temp = tempfile_dir();
std::env::set_var("QIONGLI_CONFIG_HOME", &temp);
let path = provider_config_path();
assert_eq!(path, temp.join("providers.json"));
std::env::remove_var("QIONGLI_CONFIG_HOME");
}
#[test]
fn save_and_summarize_provider_value_without_exposing_secret() {
let temp = tempfile_dir();
std::env::set_var("QIONGLI_CONFIG_HOME", &temp);
save_provider_value("openalex", "api_key", "openalex-secret-key").unwrap();
let status = summary().unwrap();
let serialized = serde_json::to_string(&status).unwrap();
assert_eq!(status.providers["openalex"], "configured");
assert_eq!(status.providers["arxiv"], "configured");
assert!(!serialized.contains("openalex-secret-key"));
std::env::remove_var("QIONGLI_CONFIG_HOME");
}
fn tempfile_dir() -> std::path::PathBuf {
let mut path = std::env::temp_dir();
path.push(format!(
"qiongli-lite-mcp-test-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir_all(&path).unwrap();
path
}- [ ] Step 2: Run RED
Run:
cd packages/qiongli-lite-mcp && cargo test --test provider_configExpected: fail because the config module is missing.
- [ ] Step 3: Add config module
Create packages/qiongli-lite-mcp/src/config/mod.rs:
pub mod provider_config;Add pub mod config; to src/lib.rs.
Create packages/qiongli-lite-mcp/src/config/provider_config.rs:
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("unsupported provider field: {0}.{1}")]
UnsupportedField(String, String),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct ProviderFile {
#[serde(default = "default_version")]
version: u64,
#[serde(default)]
providers: BTreeMap<String, BTreeMap<String, serde_json::Value>>,
}
#[derive(Debug, Clone, Serialize)]
pub struct ProviderSummary {
pub status: String,
pub capability_mode: String,
pub providers: BTreeMap<String, String>,
pub missing: Vec<String>,
}
fn default_version() -> u64 {
1
}
pub fn provider_config_path() -> PathBuf {
if let Ok(root) = std::env::var("QIONGLI_CONFIG_HOME") {
let trimmed = root.trim();
if !trimmed.is_empty() {
return PathBuf::from(trimmed).join("providers.json");
}
}
std::env::var_os("HOME")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".config")
.join("qiongli")
.join("providers.json")
}
pub fn save_provider_value(provider: &str, field: &str, value: &str) -> Result<PathBuf, ConfigError> {
let provider = normalize(provider);
let field = normalize(field);
if !is_supported_field(&provider, &field) {
return Err(ConfigError::UnsupportedField(provider, field));
}
let path = provider_config_path();
let mut file = read_provider_file()?;
file.version = 1;
let entry = file.providers.entry(provider).or_default();
entry.insert("enabled".to_string(), serde_json::Value::Bool(true));
entry.insert(field, serde_json::Value::String(value.to_string()));
write_provider_file(&path, &file)?;
Ok(path)
}
pub fn summary() -> Result<ProviderSummary, ConfigError> {
let file = read_provider_file()?;
let mut providers = BTreeMap::from([
("openalex".to_string(), "missing".to_string()),
("semantic_scholar".to_string(), "missing".to_string()),
("crossref".to_string(), "missing".to_string()),
("pubmed".to_string(), "missing".to_string()),
("arxiv".to_string(), "configured".to_string()),
]);
let mut missing = Vec::new();
for (provider, field, aliases) in provider_fields() {
if configured_field(&file, provider, field, aliases) {
providers.insert(provider.to_string(), "configured".to_string());
} else {
missing.push(format!("{provider}.{field}"));
}
}
let capability_mode = if providers.values().any(|status| status == "configured") {
"provider_connected"
} else {
"strategy_only"
};
Ok(ProviderSummary {
status: "ok".to_string(),
capability_mode: capability_mode.to_string(),
providers,
missing,
})
}
fn read_provider_file() -> Result<ProviderFile, ConfigError> {
let path = provider_config_path();
if !path.is_file() {
return Ok(ProviderFile::default());
}
let text = fs::read_to_string(path)?;
Ok(serde_json::from_str(&text)?)
}
fn write_provider_file(path: &PathBuf, file: &ProviderFile) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, format!("{}\n", serde_json::to_string_pretty(file)?))?;
Ok(())
}
fn configured_field(file: &ProviderFile, provider: &str, field: &str, aliases: &[&str]) -> bool {
if let Some(entry) = file.providers.get(provider) {
if entry
.get(field)
.and_then(serde_json::Value::as_str)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
{
return true;
}
}
aliases.iter().any(|alias| {
std::env::var(alias)
.map(|value| !value.trim().is_empty())
.unwrap_or(false)
})
}
fn is_supported_field(provider: &str, field: &str) -> bool {
provider_fields()
.iter()
.any(|(known_provider, known_field, _)| *known_provider == provider && *known_field == field)
}
fn normalize(value: &str) -> String {
value.trim().to_ascii_lowercase().replace('-', "_")
}
fn provider_fields() -> Vec<(&'static str, &'static str, Vec<&'static str>)> {
vec![
("openalex", "api_key", vec!["QIONGLI_OPENALEX_API_KEY", "OPENALEX_API_KEY", "QIONGLI_MCPB_OPENALEX_API_KEY"]),
("openalex", "email", vec!["QIONGLI_OPENALEX_EMAIL", "OPENALEX_EMAIL", "QIONGLI_MCPB_OPENALEX_EMAIL"]),
("semantic_scholar", "api_key", vec!["QIONGLI_SEMANTIC_SCHOLAR_API_KEY", "SEMANTIC_SCHOLAR_API_KEY", "S2_API_KEY", "QIONGLI_MCPB_SEMANTIC_SCHOLAR_API_KEY"]),
("crossref", "email", vec!["QIONGLI_CROSSREF_EMAIL", "CROSSREF_EMAIL", "QIONGLI_MCPB_CROSSREF_EMAIL"]),
("pubmed", "api_key", vec!["QIONGLI_NCBI_API_KEY", "NCBI_API_KEY", "PUBMED_API_KEY", "QIONGLI_MCPB_PUBMED_API_KEY"]),
]
}- [ ] Step 4: Wire config tools into MCP dispatch
Add tool definitions for qiongli_config_status, qiongli_save_provider_config, qiongli_configure_provider, and qiongli_open_config_wizard. Update McpServer::handle so tools/call dispatches at least qiongli_config_status and qiongli_save_provider_config.
- [ ] Step 5: Run GREEN
Run:
cd packages/qiongli-lite-mcp && cargo testExpected: pass.
- [ ] Step 6: Commit
git add packages/qiongli-lite-mcp
git commit -m "feat(lite-mcp): share provider config contract"Task 5: Add Search Plan And Literature Status
Files:
Create:
packages/qiongli-lite-mcp/src/searchplan.rsCreate:
packages/qiongli-lite-mcp/tests/searchplan.rsModify:
packages/qiongli-lite-mcp/src/lib.rsModify:
packages/qiongli-lite-mcp/src/mcp/server.rsModify:
packages/qiongli-lite-mcp/src/tools/definitions.rs[ ] Step 1: Write search plan tests
Create packages/qiongli-lite-mcp/tests/searchplan.rs:
use qiongli_lite_mcp::searchplan::{build_search_plan, SearchPlanInput};
#[test]
fn search_plan_records_hybrid_mode_when_provider_and_native_are_available() {
let plan = build_search_plan(SearchPlanInput {
query: "platform governance".to_string(),
search_mode: Some("review".to_string()),
provider_connected: true,
native_search_usable: true,
});
assert_eq!(plan.search_execution_mode, "hybrid_search");
assert_eq!(plan.provider_capability_mode, "provider_connected");
assert_eq!(plan.native_search_queries, vec!["platform governance"]);
}- [ ] Step 2: Run RED
Run:
cd packages/qiongli-lite-mcp && cargo test --test searchplanExpected: fail because searchplan does not exist.
- [ ] Step 3: Implement search plan builder
Create packages/qiongli-lite-mcp/src/searchplan.rs:
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct SearchPlanInput {
pub query: String,
pub search_mode: Option<String>,
pub provider_connected: bool,
pub native_search_usable: bool,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchPlan {
pub query: String,
pub search_mode: String,
pub search_execution_mode: String,
pub provider_capability_mode: String,
pub native_search_queries: Vec<String>,
}
pub fn build_search_plan(input: SearchPlanInput) -> SearchPlan {
let search_execution_mode = match (input.provider_connected, input.native_search_usable) {
(true, true) => "hybrid_search",
(true, false) => "provider_connected",
(false, true) => "native_only",
(false, false) => "strategy_only",
};
let provider_capability_mode = if input.provider_connected {
"provider_connected"
} else {
"strategy_only"
};
SearchPlan {
native_search_queries: vec![input.query.clone()],
query: input.query,
search_mode: input.search_mode.unwrap_or_else(|| "topic".to_string()),
search_execution_mode: search_execution_mode.to_string(),
provider_capability_mode: provider_capability_mode.to_string(),
}
}Add pub mod searchplan; to src/lib.rs.
- [ ] Step 4: Add literature status and search plan MCP handlers
Wire qiongli_literature_status to provider config summary and qiongli_search_plan to build_search_plan. Include provider capabilities for OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv.
- [ ] Step 5: Run GREEN
Run:
cd packages/qiongli-lite-mcp && cargo testExpected: pass.
- [ ] Step 6: Commit
git add packages/qiongli-lite-mcp
git commit -m "feat(lite-mcp): add literature status and planning"Task 6: Add Provider Search Clients
Files:
Create:
packages/qiongli-lite-mcp/src/providers/mod.rsCreate:
packages/qiongli-lite-mcp/src/providers/search.rsCreate:
packages/qiongli-lite-mcp/src/providers/openalex.rsCreate:
packages/qiongli-lite-mcp/src/providers/semantic_scholar.rsCreate:
packages/qiongli-lite-mcp/src/providers/crossref.rsCreate:
packages/qiongli-lite-mcp/src/providers/pubmed.rsCreate:
packages/qiongli-lite-mcp/src/providers/arxiv.rsCreate:
packages/qiongli-lite-mcp/tests/providers.rsModify:
packages/qiongli-lite-mcp/src/lib.rsModify:
packages/qiongli-lite-mcp/src/mcp/server.rs[ ] Step 1: Write OpenAlex normalization test
Create packages/qiongli-lite-mcp/tests/providers.rs:
use qiongli_lite_mcp::providers::openalex::normalize_openalex_response;
#[test]
fn openalex_response_normalizes_title_year_and_doi() {
let fixture = include_str!("../../../content/mcp-contracts/fixtures/openalex-search-response.json");
let results = normalize_openalex_response(fixture).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].title, "A Test Paper");
assert_eq!(results[0].doi.as_deref(), Some("10.1234/example"));
assert_eq!(results[0].year, Some(2025));
assert_eq!(results[0].provider, "openalex");
}- [ ] Step 2: Run RED
Run:
cd packages/qiongli-lite-mcp && cargo test --test providersExpected: fail because provider modules are missing.
- [ ] Step 3: Add common provider types
Create packages/qiongli-lite-mcp/src/providers/mod.rs:
pub mod arxiv;
pub mod crossref;
pub mod openalex;
pub mod pubmed;
pub mod search;
pub mod semantic_scholar;Create packages/qiongli-lite-mcp/src/providers/search.rs:
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct SearchInput {
pub query: String,
pub search_mode: Option<String>,
pub limit: Option<usize>,
pub per_provider_limit: Option<usize>,
pub total_limit: Option<usize>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct LiteratureResult {
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub doi: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub year: Option<i64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub venue: Option<String>,
pub provider: String,
pub providers: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchDiagnostics {
pub status: String,
pub provider_counts: std::collections::BTreeMap<String, usize>,
pub warnings: Vec<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct SearchOutput {
pub status: String,
pub results: Vec<LiteratureResult>,
pub diagnostics: SearchDiagnostics,
}Add pub mod providers; to src/lib.rs.
- [ ] Step 4: Implement OpenAlex normalizer
Create packages/qiongli-lite-mcp/src/providers/openalex.rs:
use serde::Deserialize;
use thiserror::Error;
use crate::providers::search::LiteratureResult;
#[derive(Debug, Error)]
pub enum ProviderError {
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Deserialize)]
struct OpenAlexResponse {
#[serde(default)]
results: Vec<OpenAlexWork>,
}
#[derive(Debug, Deserialize)]
struct OpenAlexWork {
display_name: Option<String>,
publication_year: Option<i64>,
doi: Option<String>,
primary_location: Option<OpenAlexLocation>,
}
#[derive(Debug, Deserialize)]
struct OpenAlexLocation {
source: Option<OpenAlexSource>,
}
#[derive(Debug, Deserialize)]
struct OpenAlexSource {
display_name: Option<String>,
}
pub fn normalize_openalex_response(payload: &str) -> Result<Vec<LiteratureResult>, ProviderError> {
let response: OpenAlexResponse = serde_json::from_str(payload)?;
Ok(response
.results
.into_iter()
.filter_map(|work| {
let title = work.display_name?;
Some(LiteratureResult {
title,
doi: work.doi.as_deref().map(normalize_doi),
year: work.publication_year,
venue: work
.primary_location
.and_then(|location| location.source)
.and_then(|source| source.display_name),
provider: "openalex".to_string(),
providers: vec!["openalex".to_string()],
})
})
.collect())
}
fn normalize_doi(raw: &str) -> String {
raw.trim()
.trim_start_matches("https://doi.org/")
.trim_start_matches("http://doi.org/")
.trim_start_matches("doi:")
.to_ascii_lowercase()
}- [ ] Step 5: Add remaining provider tests and normalizers
Add tests in tests/providers.rs for:
semantic_scholar_response_normalizes_title_year_and_doicrossref_response_normalizes_title_year_and_doipubmed_summary_response_normalizes_title_year_and_doiarxiv_atom_response_normalizes_title_year_and_url
Create one fixture per provider under content/mcp-contracts/fixtures/. Each normalizer returns Vec<LiteratureResult> and must not perform network calls.
- [ ] Step 6: Add network clients after normalizers pass
Add one client function per provider:
pub fn search_openalex(client: &reqwest::blocking::Client, base_url: &str, input: &SearchInput) -> Result<Vec<LiteratureResult>, ProviderError>Use the same pattern for Semantic Scholar, Crossref, PubMed, and arXiv. Unit tests for network clients must use local mock HTTP servers or pure response normalizers; no unit test may call an external provider.
- [ ] Step 7: Wire
qiongli_literature_search
Add a tool definition and dispatch handler. The handler returns:
{
"status": "ok",
"search_plan": {},
"diagnostics": {},
"results": []
}- [ ] Step 8: Run GREEN
Run:
cd packages/qiongli-lite-mcp && cargo testExpected: pass.
- [ ] Step 9: Commit
git add content/mcp-contracts packages/qiongli-lite-mcp
git commit -m "feat(lite-mcp): add rust provider search"Task 7: Add Evidence Export And Zotero Import Files
Files:
Create:
packages/qiongli-lite-mcp/src/zotero/mod.rsCreate:
packages/qiongli-lite-mcp/src/zotero/export.rsCreate:
packages/qiongli-lite-mcp/tests/zotero_export.rsModify:
packages/qiongli-lite-mcp/src/lib.rsModify:
packages/qiongli-lite-mcp/src/mcp/server.rsModify:
packages/qiongli-lite-mcp/src/tools/definitions.rs[ ] Step 1: Write Zotero export tests
Create packages/qiongli-lite-mcp/tests/zotero_export.rs:
use qiongli_lite_mcp::providers::search::LiteratureResult;
use qiongli_lite_mcp::zotero::export::export_import_files;
#[test]
fn export_import_files_includes_ris_bibtex_csl_and_report() {
let files = export_import_files(vec![LiteratureResult {
title: "A Test Paper".to_string(),
doi: Some("10.1234/example".to_string()),
year: Some(2025),
venue: Some("Journal of Tests".to_string()),
provider: "openalex".to_string(),
providers: vec!["openalex".to_string()],
}]);
assert!(files.contains_key("references.json"));
assert!(files.contains_key("references.ris"));
assert!(files.contains_key("bibliography.bib"));
assert!(files.contains_key("zotero-import-report.md"));
assert!(files["references.ris"].contains("TY - JOUR"));
assert!(files["bibliography.bib"].contains("@article"));
}- [ ] Step 2: Run RED
Run:
cd packages/qiongli-lite-mcp && cargo test --test zotero_exportExpected: fail because Zotero export module is missing.
- [ ] Step 3: Implement Zotero export files
Create packages/qiongli-lite-mcp/src/zotero/mod.rs:
pub mod export;Create packages/qiongli-lite-mcp/src/zotero/export.rs:
use std::collections::BTreeMap;
use crate::providers::search::LiteratureResult;
pub fn export_import_files(records: Vec<LiteratureResult>) -> BTreeMap<String, String> {
let mut files = BTreeMap::new();
files.insert(
"references.json".to_string(),
serde_json::to_string_pretty(&records).unwrap_or_else(|_| "[]".to_string()),
);
files.insert("references.ris".to_string(), to_ris(&records));
files.insert("bibliography.bib".to_string(), to_bibtex(&records));
files.insert("zotero-import-report.md".to_string(), to_report(&records));
files
}
fn to_ris(records: &[LiteratureResult]) -> String {
records
.iter()
.map(|record| {
format!(
"TY - JOUR\nTI - {}\nPY - {}\nDO - {}\nER -\n",
record.title,
record.year.map(|year| year.to_string()).unwrap_or_default(),
record.doi.clone().unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn to_bibtex(records: &[LiteratureResult]) -> String {
records
.iter()
.enumerate()
.map(|(index, record)| {
format!(
"@article{{qiongli{},\n title = {{{}}},\n year = {{{}}},\n doi = {{{}}}\n}}\n",
index + 1,
record.title,
record.year.map(|year| year.to_string()).unwrap_or_default(),
record.doi.clone().unwrap_or_default()
)
})
.collect::<Vec<_>>()
.join("\n")
}
fn to_report(records: &[LiteratureResult]) -> String {
format!("# Zotero Import Report\n\nRecords: {}\n", records.len())
}Add pub mod zotero; to src/lib.rs.
- [ ] Step 4: Wire evidence export and Zotero export MCP handlers
Add qiongli_literature_export_evidence and qiongli_zotero_export_import_files dispatch handlers. They must not require Zotero Desktop or the Zotero Companion.
- [ ] Step 5: Run GREEN
Run:
cd packages/qiongli-lite-mcp && cargo testExpected: pass.
- [ ] Step 6: Commit
git add packages/qiongli-lite-mcp
git commit -m "feat(lite-mcp): export evidence and Zotero files"Task 8: Add Optional Zotero Companion Bridge
Files:
Create:
packages/qiongli-lite-mcp/src/zotero/companion.rsCreate:
packages/qiongli-lite-mcp/tests/zotero_companion.rsModify:
packages/qiongli-lite-mcp/src/zotero/mod.rsModify:
packages/qiongli-lite-mcp/src/mcp/server.rsModify:
packages/qiongli-lite-mcp/src/tools/definitions.rs[ ] Step 1: Write loopback enforcement test
Create packages/qiongli-lite-mcp/tests/zotero_companion.rs:
use qiongli_lite_mcp::zotero::companion::CompanionClient;
#[test]
fn rejects_non_loopback_connector_url() {
let result = CompanionClient::new("http://example.com:23119");
assert!(result.is_err());
}
#[test]
fn accepts_loopback_connector_url() {
let result = CompanionClient::new("http://127.0.0.1:23119");
assert!(result.is_ok());
}- [ ] Step 2: Run RED
Run:
cd packages/qiongli-lite-mcp && cargo test --test zotero_companionExpected: fail because Companion client is missing.
- [ ] Step 3: Implement loopback-only Companion client
Create packages/qiongli-lite-mcp/src/zotero/companion.rs:
use thiserror::Error;
use url::Url;
#[derive(Debug, Error)]
pub enum CompanionError {
#[error("Zotero connector URL must use localhost or 127.0.0.1")]
NonLoopback,
#[error("invalid URL: {0}")]
InvalidUrl(#[from] url::ParseError),
}
#[derive(Debug, Clone)]
pub struct CompanionClient {
base_url: Url,
}
impl CompanionClient {
pub fn new(raw: &str) -> Result<Self, CompanionError> {
let base_url = Url::parse(raw)?;
let host = base_url.host_str().unwrap_or("");
if host != "127.0.0.1" && host != "localhost" {
return Err(CompanionError::NonLoopback);
}
Ok(Self { base_url })
}
pub fn base_url(&self) -> &Url {
&self.base_url
}
}Add pub mod companion; to src/zotero/mod.rs.
- [ ] Step 4: Wire Zotero status
Add qiongli_zotero_status. It returns:
{
"status": "fallback_only",
"connector": {"available": false},
"companion": {"available": false},
"fallback_import_files": {
"available": true,
"formats": ["references.json", "references.ris", "bibliography.bib", "zotero-import-report.md"]
}
}When a loopback connector is reachable and Companion endpoint responds, return status: "ok".
- [ ] Step 5: Run GREEN
Run:
cd packages/qiongli-lite-mcp && cargo testExpected: pass.
- [ ] Step 6: Commit
git add packages/qiongli-lite-mcp
git commit -m "feat(lite-mcp): support optional Zotero companion"Task 9: Add Preview-Only Orchestrator Tools
Files:
Create:
packages/qiongli-lite-mcp/src/orchestrator/mod.rsCreate:
packages/qiongli-lite-mcp/src/orchestrator/preview.rsCreate:
packages/qiongli-lite-mcp/tests/orchestrator_preview.rsModify:
packages/qiongli-lite-mcp/src/lib.rsModify:
packages/qiongli-lite-mcp/src/mcp/server.rsModify:
packages/qiongli-lite-mcp/src/tools/definitions.rs[ ] Step 1: Write preview behavior tests
Create packages/qiongli-lite-mcp/tests/orchestrator_preview.rs:
use qiongli_lite_mcp::orchestrator::preview::{build_task_plan, TaskPlanInput};
#[test]
fn task_plan_preview_does_not_allow_agent_execution() {
let plan = build_task_plan(TaskPlanInput {
task_id: "B1".to_string(),
paper_type: "systematic-review".to_string(),
topic: "ai-feedback".to_string(),
});
assert_eq!(plan.mode, "preview");
assert_eq!(plan.runtime_profile, "marketplace_lite");
assert!(!plan.run_agents_allowed);
}- [ ] Step 2: Run RED
Run:
cd packages/qiongli-lite-mcp && cargo test --test orchestrator_previewExpected: fail because preview module is missing.
- [ ] Step 3: Implement preview module
Create packages/qiongli-lite-mcp/src/orchestrator/mod.rs:
pub mod preview;Create packages/qiongli-lite-mcp/src/orchestrator/preview.rs:
use serde::Serialize;
#[derive(Debug, Clone)]
pub struct TaskPlanInput {
pub task_id: String,
pub paper_type: String,
pub topic: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct TaskPlanPreview {
pub mode: String,
pub runtime_profile: String,
pub run_agents_allowed: bool,
pub task_id: String,
pub paper_type: String,
pub topic: String,
}
pub fn build_task_plan(input: TaskPlanInput) -> TaskPlanPreview {
TaskPlanPreview {
mode: "preview".to_string(),
runtime_profile: "marketplace_lite".to_string(),
run_agents_allowed: false,
task_id: input.task_id,
paper_type: input.paper_type,
topic: input.topic,
}
}Add pub mod orchestrator; to src/lib.rs.
- [ ] Step 4: Wire route and task-plan MCP handlers
Add qiongli_orchestrator_route and qiongli_task_plan. Both return runtime_profile: "marketplace_lite" and never launch local agents.
- [ ] Step 5: Run GREEN
Run:
cd packages/qiongli-lite-mcp && cargo testExpected: pass.
- [ ] Step 6: Commit
git add packages/qiongli-lite-mcp
git commit -m "feat(lite-mcp): add preview workflow tools"Task 10: Build Platform Binaries For Release Staging
Files:
Create:
tooling/scripts/build_lite_mcp.pyCreate:
scripts/build_lite_mcp.pyCreate:
tests/test_lite_mcp_binary_artifacts.pyModify:
tooling/scripts/build_plugin_artifacts.py[ ] Step 1: Add build script test
Extend tests/test_lite_mcp_binary_artifacts.py:
def test_build_lite_mcp_stages_current_platform_binary(self) -> None:
from tooling.scripts.build_lite_mcp import build_current_platform
with tempfile.TemporaryDirectory() as tmp_dir:
binary = build_current_platform(REPO_ROOT, Path(tmp_dir))
self.assertTrue(binary.is_file())
self.assertEqual(binary.name, "qiongli-literature-provider")- [ ] Step 2: Run RED
Run:
.venv/bin/python -m unittest tests.test_lite_mcp_binary_artifacts -vExpected: fail because tooling/scripts/build_lite_mcp.py is missing.
- [ ] Step 3: Implement Rust build script
Create tooling/scripts/build_lite_mcp.py with:
build_current_platform(root: Path, out_dir: Path) -> Pathbuild_target(root: Path, out_dir: Path, target: str) -> Pathbuild_all_platforms(root: Path, out_dir: Path) -> list[Path]- target matrix:
aarch64-apple-darwinx86_64-apple-darwinx86_64-unknown-linux-gnuaarch64-unknown-linux-gnux86_64-pc-windows-msvc
- command:
cargo build --release --manifest-path packages/qiongli-lite-mcp/Cargo.toml - target-specific command:
cargo build --release --target <target> --manifest-path packages/qiongli-lite-mcp/Cargo.toml - executable permission fix for non-Windows artifacts
Create scripts/build_lite_mcp.py using the wrapper style used by the existing scripts/build_literature_mcpb.py.
- [ ] Step 4: Run GREEN
Run:
.venv/bin/python -m unittest tests.test_lite_mcp_binary_artifacts -vExpected: pass on the current development platform.
- [ ] Step 5: Commit
git add tooling/scripts/build_lite_mcp.py scripts/build_lite_mcp.py tests/test_lite_mcp_binary_artifacts.py
git commit -m "build(lite-mcp): stage rust binaries"Task 11: Switch Marketplace Plugin Packaging To Rust Lite Binary
Files:
Modify:
tooling/scripts/build_plugin_artifacts.pyModify:
content/distribution/platform-targets.yamlModify:
tooling/scripts/validate_marketplace_install.pyModify:
tests/test_plugin_artifacts.pyModify:
tests/test_lite_mcp_binary_artifacts.py[ ] Step 1: Update manifest writers
Change _write_claude_manifest so mcpServers uses:
"mcpServers": {
plugin.mcp_server_name: {
"command": "${CLAUDE_PLUGIN_ROOT}/bin/qiongli-literature-provider",
"args": ["--transport", "stdio"],
"cwd": "${CLAUDE_PLUGIN_ROOT}",
}
}Change _write_codex_mcp_manifest so it uses:
"mcpServers": {
server_name: {
"command": "./bin/qiongli-literature-provider",
"args": ["--transport", "stdio"],
"cwd": ".",
"startup_timeout_sec": 20,
"tool_timeout_sec": 60,
}
}- [ ] Step 2: Add binary copy helper
Add _copy_lite_mcp_runtime(root, dest_plugin_root) that calls build_current_platform(root, work_dir) during local artifact tests and copies the resulting executable to:
dest_plugin_root / "bin" / "qiongli-literature-provider"- [ ] Step 3: Update platform targets
Set Codex, Claude Code, and Claude Desktop direct plugin targets:
bundled_mcp_mode: marketplace-lite-binary
source_inputs:
- content/distribution/plugins.yaml
- content/workflow/**
- packages/qiongli-lite-mcp/**
required_paths:
- bin/qiongli-literature-providerRemove mcp/qiongli-literature-provider/index.mjs from required paths for these marketplace-lite binary targets.
- [ ] Step 4: Update validator
In tooling/scripts/validate_marketplace_install.py, assert that marketplace plugin manifests do not include:
"command": "node""command": "python""command": "qiongli"mcp/qiongli-literature-provider/index.mjs
Assert the expected binary command path for each platform manifest.
- [ ] Step 5: Run GREEN
Run:
.venv/bin/python -m unittest tests.test_plugin_artifacts tests.test_lite_mcp_binary_artifacts -v
.venv/bin/python tooling/scripts/validate_marketplace_install.py --skip-client-activationExpected: pass.
- [ ] Step 6: Commit
git add tooling/scripts/build_plugin_artifacts.py content/distribution/platform-targets.yaml tooling/scripts/validate_marketplace_install.py tests/test_plugin_artifacts.py tests/test_lite_mcp_binary_artifacts.py
git commit -m "feat(marketplace): bundle rust lite MCP executable"Task 12: Add Black-Box MCP Binary Contract Tests
Files:
Create:
tests/test_lite_mcp_contract.pyModify:
tooling/scripts/build_lite_mcp.py[ ] Step 1: Add subprocess MCP contract test
Create tests/test_lite_mcp_contract.py:
from __future__ import annotations
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from tooling.scripts.build_lite_mcp import build_current_platform
REPO_ROOT = Path(__file__).resolve().parents[1]
class LiteMCPContractTests(unittest.TestCase):
def test_binary_initializes_and_lists_tools_without_node_or_python(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
binary = build_current_platform(REPO_ROOT, Path(tmp_dir))
process = subprocess.run(
[str(binary), "--transport", "stdio"],
input=(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25"}}\n'
'{"jsonrpc":"2.0","id":2,"method":"tools/list","params":{}}\n'
),
text=True,
capture_output=True,
check=False,
timeout=10,
)
self.assertEqual(process.returncode, 0, msg=process.stderr)
lines = [json.loads(line) for line in process.stdout.splitlines() if line.strip()]
self.assertEqual(lines[0]["result"]["serverInfo"]["name"], "qiongli-literature-provider")
names = {tool["name"] for tool in lines[1]["result"]["tools"]}
self.assertIn("qiongli_literature_status", names)
self.assertIn("qiongli_literature_search", names)
self.assertIn("qiongli_zotero_export_import_files", names)- [ ] Step 2: Add config status black-box test
Append a test that sets QIONGLI_CONFIG_HOME to a temp directory, calls qiongli_save_provider_config, then calls qiongli_config_status and asserts the raw secret does not appear in stdout.
- [ ] Step 3: Run GREEN
Run:
.venv/bin/python -m unittest tests.test_lite_mcp_contract -vExpected: pass.
- [ ] Step 4: Commit
git add tests/test_lite_mcp_contract.py tooling/scripts/build_lite_mcp.py
git commit -m "test(lite-mcp): verify rust binary MCP contract"Task 13: Add Python Full CLI Parity Tests
Files:
Create:
tests/test_lite_full_mcp_parity.pyModify:
tests/test_mcp_contract_fixtures.py[ ] Step 1: Add tools/list parity test
Create tests/test_lite_full_mcp_parity.py:
from __future__ import annotations
import json
import subprocess
import tempfile
import unittest
from pathlib import Path
from tooling.scripts.build_lite_mcp import build_current_platform
REPO_ROOT = Path(__file__).resolve().parents[1]
class LiteFullMCPParityTests(unittest.TestCase):
def test_lite_tools_are_subset_of_full_cli_tools(self) -> None:
contract = json.loads(
(REPO_ROOT / "content" / "mcp-contracts" / "lite-tools.json").read_text(
encoding="utf-8"
)
)
expected_lite_names = {tool["name"] for tool in contract["tools"]}
with tempfile.TemporaryDirectory() as tmp_dir:
binary = build_current_platform(REPO_ROOT, Path(tmp_dir))
lite = subprocess.run(
[str(binary), "--transport", "stdio"],
input='{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}\n',
text=True,
capture_output=True,
check=False,
timeout=10,
)
lite_names = {
tool["name"]
for tool in json.loads(lite.stdout.splitlines()[0])["result"]["tools"]
}
self.assertTrue(expected_lite_names.issubset(lite_names))Add a second test for Python Full CLI after the Full CLI tool list is easy to launch in CI. It should assert that overlapping Lite tool names are present in qiongli mcp serve --transport stdio.
- [ ] Step 2: Run parity tests
Run:
.venv/bin/python -m unittest tests.test_lite_full_mcp_parity -vExpected: pass after the Lite binary tools/list is contract-complete.
- [ ] Step 3: Commit
git add tests/test_lite_full_mcp_parity.py tests/test_mcp_contract_fixtures.py
git commit -m "test(mcp): compare lite and full tool contracts"Task 14: Update MCPB To Reuse The Rust Lite Runtime
Files:
Modify:
packages/qiongli-literature-mcpb/manifest.jsonModify:
packages/qiongli-literature-mcpb/README.mdModify:
tooling/scripts/build_literature_mcpb.pyModify:
tests/test_literature_mcpb_artifact.py[ ] Step 1: Add binary MCPB tests while keeping legacy tests explicit
In tests/test_literature_mcpb_artifact.py, add:
def test_literature_mcpb_uses_self_contained_runtime(self) -> None:
manifest = json.loads((PACKAGE_ROOT / "manifest.json").read_text(encoding="utf-8"))
mcp_config = manifest["server"]["mcp_config"]
self.assertNotEqual(mcp_config["command"], "node")
self.assertIn("qiongli-literature-provider", mcp_config["command"])
self.assertEqual(mcp_config["args"], ["--transport", "stdio"])- [ ] Step 2: Run RED
Run:
.venv/bin/python -m unittest tests.test_literature_mcpb_artifact -vExpected: fail because MCPB still declares Node.
- [ ] Step 3: Update MCPB build to include Rust binary
Change tooling/scripts/build_literature_mcpb.py so it stages:
manifest.json
README.md
bin/qiongli-literature-providerKeep Node server packaging behind a --legacy-node flag for one release train.
- [ ] Step 4: Update MCPB manifest and README
Set server.mcp_config.command to the package-local binary path supported by Claude Desktop MCPB. Keep user config fields unchanged.
- [ ] Step 5: Run GREEN
Run:
.venv/bin/python -m unittest tests.test_literature_mcpb_artifact -vExpected: pass.
- [ ] Step 6: Commit
git add packages/qiongli-literature-mcpb/manifest.json packages/qiongli-literature-mcpb/README.md tooling/scripts/build_literature_mcpb.py tests/test_literature_mcpb_artifact.py
git commit -m "feat(mcpb): package rust lite MCP executable"Task 15: Document Rust Marketplace Lite Versus Python Full CLI
Files:
Modify:
docs/advanced/cross-platform-mcp.mdModify:
docs/advanced/plugin-first-architecture.mdModify:
docs/guide/install.mdModify:
README.mdModify:
README_CN.mdModify:
content/workflow/SKILL.md[ ] Step 1: Update product model docs
Add this table to docs/advanced/cross-platform-mcp.md:
| Runtime profile | Install path | Requires user Node/Python | Main capabilities |
|---|---|---:|---|
| Marketplace Lite | Codex, Claude Code, or Claude Desktop plugin marketplace/direct plugin | No | Rust-built Literature Provider MCP, config wizard, search planning, evidence export, Zotero import files, optional Zotero Companion bridge |
| Full CLI | `qiongli install --profile full` or `qiongli mcp serve --transport stdio` | Python Qiongli runtime | Full MCP tools, orchestrator, task-run, project guidance, local agent execution, doctor checks |- [ ] Step 2: Update install guide language
Replace language that says marketplace plugins bundle Node MCP with language that says marketplace plugins bundle a Rust-built self-contained local Lite MCP executable.
- [ ] Step 3: Update workflow skill runtime notes
In content/workflow/SKILL.md, replace Node-specific Desktop guidance with:
- Marketplace Lite plugin installs include a Rust-built self-contained local Literature Provider MCP runtime. They do not require user-installed Node, Python, npm, pip, Cargo, Rust, or the full Qiongli CLI for provider search tools.
- Full local orchestration remains a Python Full CLI capability. Use `qiongli mcp serve --transport stdio` when `qiongli_task_run` or local agent execution is required.
- Zotero local-library search and direct writes require Zotero Desktop plus the Qiongli Zotero Companion. Without the Companion, use the Lite MCP import-file export tools.- [ ] Step 4: Run docs checks
Run:
.venv/bin/python -m unittest tests.test_literature_contract tests.test_plugin_distribution_contract -v
git diff --checkExpected: pass.
- [ ] Step 5: Commit
git add docs/advanced/cross-platform-mcp.md docs/advanced/plugin-first-architecture.md docs/guide/install.md README.md README_CN.md content/workflow/SKILL.md
git commit -m "docs(mcp): explain rust marketplace lite runtime"Task 16: Full Regression And Release Boundary Review
Files:
Test only
Review: all files changed by Tasks 1-15
[ ] Step 1: Run Rust tests
cd packages/qiongli-lite-mcp && cargo testExpected: pass.
- [ ] Step 2: Run Python MCP and packaging tests
.venv/bin/python -m unittest \
tests.test_mcp_contract_fixtures \
tests.test_lite_mcp_contract \
tests.test_lite_mcp_binary_artifacts \
tests.test_lite_full_mcp_parity \
tests.test_plugin_artifacts \
tests.test_literature_mcpb_artifact \
tests.test_plugin_distribution_contract \
tests.test_mcp_client_config \
tests.test_mcp_literature_tools \
-vExpected: pass.
- [ ] Step 3: Run platform target validation
.venv/bin/python tooling/scripts/validate_platform_targets.pyExpected: no validation errors.
- [ ] Step 4: Run marketplace validator
.venv/bin/python tooling/scripts/validate_marketplace_install.py --skip-client-activationExpected: no validation errors and marketplace binary mode reported for Codex, Claude Code, and Claude Desktop direct plugin artifacts.
- [ ] Step 5: Run secret and boundary scan
rg -n "(/[U]sers/|/[p]rivate/|BEGI[N] (RSA|OPENSSH|EC|DSA) PRIVATE KEY|secre[t]:|toke[n]:|passwor[d]:|OPENALEX_API_KEY=.*|S2_API_KEY=.*)" \
content/mcp-contracts \
packages/qiongli-lite-mcp \
tooling/scripts/build_lite_mcp.py \
tooling/scripts/build_plugin_artifacts.py \
packages/qiongli-literature-mcpb \
docs/advanced/cross-platform-mcp.md \
README.md \
README_CN.mdExpected: no matches.
- [ ] Step 6: Run diff hygiene
git diff --checkExpected: no whitespace errors.
- [ ] Step 7: Commit final integration adjustments
git add content/mcp-contracts packages/qiongli-lite-mcp tooling/scripts scripts tests docs README.md README_CN.md content
git commit -m "chore(lite-mcp): verify rust marketplace runtime"Execution Notes
- Rust and Cargo are maintainer/development dependencies only. End users of Marketplace Lite must not need Rust, Cargo, Node, or Python.
- If a marketplace cannot select platform-specific artifacts, publish separate platform-specific plugin artifacts or keep that marketplace on the legacy Node provider until platform selection is available.
- Keep Full CLI tests in every regression run. A passing Rust Lite MCP build is not a substitute for Python full-runtime verification.
- Do not start a Full CLI Rust migration from this plan. That requires a separate spec because it would touch orchestrator, installer, project guidance, local agent execution, and release automation.
Self-Review
- Spec coverage: the plan covers Rust self-contained runtime, marketplace packaging, MCPB migration, provider config parity, Zotero boundaries, preview orchestrator tools, Python Full CLI preservation, docs, and release validation.
- Plan scan: tasks name concrete files, commands, expected failures, expected pass states, and commit boundaries.
- Boundary check: changes stay in Qiongli source, generated release scripts, contracts, and docs. No marketplace catalog repository or local machine path is copied into this repository.