MCP Provider Status Parity 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: Make Qiongli MCP provider status, literature search, and external evidence adapter semantics unambiguous across Python full MCP, Node literature MCPB, generated workflow guidance, docs, and tests.
Architecture: Keep qiongli_literature_status, qiongli_search_plan, and qiongli_literature_search as the canonical literature-provider path backed by shared provider config. Keep qiongli_collect_evidence as a backward-compatible filesystem/builtin/external-command evidence adapter, but make direct academic-provider calls return a precise external-command diagnostic that cannot be mistaken for provider-config status. Treat provider discovery, platform-native full-text candidate search, Zotero attachment verification, and retrieval-manifest evidence limits as separate layers. Add parity tests so Python full MCP, CLI doctor/config output, Node MCPB manifests, Zotero companion output, and docs cannot drift again.
Tech Stack: Python 3.12 MCP bridge modules, Node ESM MCPB manifest/server files, unittest, node:test, Markdown docs, Qiongli generated workflow materialization.
Current Audit Findings
Severity labels use release-impact priority.
P1:
qiongli_collect_evidencereportsopenalex not_configuredfor the external command adapter, not the OpenAlex API provider config.- Evidence:
packages/python-qiongli/src/qiongli/bridges/mcp_tool_handlers.pydelegatesqiongli_collect_evidencetoMCPConnector().collect(...);packages/python-qiongli/src/qiongli/bridges/mcp_connectors.pyreturnsExternal MCP not configured. Set RESEARCH_MCP_OPENALEX_CMD.when no external command is configured. - Impact: A user can have OpenAlex configured in
~/.config/qiongli/providers.jsonand successfulqiongli_literature_search, whileqiongli_collect_evidencestill saysnot_configured. - Fix: Preserve adapter behavior, but report
not_configured_scope: external_command_adapter, include the redacted provider-config status, and point callers toqiongli_literature_status/qiongli_literature_search.
- Evidence:
P1: Python full MCP
literature_toolsoutput omitsqiongli_search_plan.- Evidence:
packages/python-qiongli/src/qiongli/bridges/mcp_tool_handlers.pyexposesqiongli_search_plan, butpackages/python-qiongli/src/qiongli/bridges/mcp_cli.pylists only status/search/export inLITERATURE_TOOLS. - Impact:
qiongli mcp doctor --jsonandqiongli mcp config example --jsonunder-report the hybrid routing tool. - Fix: Add
qiongli_search_plantoLITERATURE_TOOLSimmediately afterqiongli_literature_status.
- Evidence:
P1: Install docs incorrectly imply
qiongli_literature_searchrequires the full Python runtime in some paths.- Evidence:
docs/guide/install.mdanddocs/zh/guide/install.mdstate the bundled Claude Code plugin covers provider/search/status, then includeqiongli_literature_searchin the full-runtime-only list. - Impact: Users may install full runtime unnecessarily or misdiagnose bundled MCPB/plugin-lite as unable to search.
- Fix: State that bundled Node literature MCP and full CLI MCP both expose
qiongli_literature_search; full runtime is needed for Python-backed orchestrator/task tools.
- Evidence:
P2: Full MCP docs mix provider-config readiness and external command evidence adapters.
- Evidence:
docs/advanced/cross-platform-mcp.mddescribesqiongli_collect_evidencealongside provider config/status tools for "MCP/provider readiness". - Impact: Encourages exactly the false inference that caused the OpenAlex misdiagnosis.
- Fix: Split the docs into "provider config/status/search tools" and "external evidence adapter tools".
- Evidence:
P2: Source workflow guidance is stale relative to generated installed guidance.
- Evidence:
content/workflow/SKILL.mdsays Desktop/Web MCPB is for OpenAlex and Semantic Scholar, whilepackages/python-qiongli/src/qiongli/subject_materializer.pyand the installed skill mention OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv. - Impact: Re-materializing from source can regenerate older provider guidance.
- Fix: Update source workflow guidance and materializer text in the same change.
- Evidence:
P3: No release-boundary issue found.
- Evidence: The inconsistency is inside source, generated text, docs, and tests. No marketplace catalog files, secrets, absolute local paths, or third-party bundles were introduced by this fix scope.
- Fix: Keep changes scoped to Qiongli MCP source/docs/tests and generated guidance text.
P1: Search-result defaults are split across Python full MCP and Node MCPB.
- Evidence: Node MCPB uses normal-search default 25 and review default 50, but Python
run_scholarly_searchusesDEFAULT_PER_QUERY_LIMIT = 20, and individual provider fallback defaults in Node provider modules are still 10. - Impact: Users can see different result counts depending on whether they call Python full MCP, Node MCPB, direct provider helpers, or old calls that omit
per_provider_limit. - Fix: Standardize normal topic search default to 25 per provider/query, review/systematic-review default to 50, and keep explicit
per_provider_limit/search_depthas the way to go higher.
- Evidence: Node MCPB uses normal-search default 25 and review default 50, but Python
P1: Literature discovery does not prove full-text availability.
- Evidence: OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv search mostly return metadata, abstracts, identifiers, links, and occasional OA/PDF hints. Qiongli already has
retrieval_manifest.csv,fulltext-fetcher, andfulltext-retrieval, but Node MCPB normalization currently does not preserve full-text/OA candidate fields consistently. - Impact: A search can be broad in metadata coverage but weak for claims that require full-text reading. Without retrieval diagnostics, "coverage" and "readable evidence" are conflated.
- Fix: Preserve OA/full-text candidate URLs in search results, require retrieval status tracking for review-grade claims, and report coverage as separate discovery, dedup, retrieval, and evidence-limit metrics.
- Evidence: OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv search mostly return metadata, abstracts, identifiers, links, and occasional OA/PDF hints. Qiongli already has
P1: Platform-native LLM search and Zotero attachment evidence are not modeled as separate full-text candidate/verification layers.
- Evidence:
qiongli_search_planalready tells the active agent to executenative_search_queries, but it does not emit dedicated full-text candidate queries or an expected candidate payload.packages/qiongli-zotero-companion/chrome/content/qiongli-bridge.jsandpackages/qiongli-zotero-companion/bootstrap.jsreturn compact item metadata without attachment summaries, so MCPB cannot tell whether Zotero has a local PDF or only a citation record. - Impact: Native LLM search can find PDF/PMC/arXiv/author-page full-text candidates, but those results are not auditable. Zotero can be the strongest local truth source, but Qiongli currently cannot reliably distinguish "reference exists in Zotero" from "verified full-text attachment exists in Zotero".
- Fix: Add
native_fulltext_queriesand candidate schema guidance to search plans, keep active agents responsible for executing platform search, preserve candidate provenance ascandidate_only, and upgrade the Zotero companion/MCPB normalization path to expose attachment-level verification with match basis and confidence.
- Evidence:
File Structure
Modify:
packages/python-qiongli/src/qiongli/bridges/mcp_connectors.py- Responsibility: external command adapter resolution and evidence diagnostics.
Modify:
packages/python-qiongli/src/qiongli/bridges/mcp_tool_handlers.py- Responsibility: full Python MCP tool schemas/descriptions and tool dispatch.
Modify:
packages/python-qiongli/src/qiongli/bridges/mcp_cli.py- Responsibility:
qiongli mcp doctorandqiongli mcp config exampletool inventory output.
- Responsibility:
Modify:
tests/test_mcp_connectors.py- Responsibility: connector behavior, including external-command status scope and provider-config diagnostics.
Modify:
tests/test_mcp_tool_handlers.py- Responsibility: Python full MCP schema/tool behavior regression tests.
Modify:
tests/test_mcp_cli.py- Responsibility: CLI doctor/config example output parity. Preserve the existing wizard payload-reader edits in this file.
Create:
tests/test_mcp_tool_surface_parity.py- Responsibility: cross-surface parity tests for common literature-provider tools across Python full MCP and Node MCPB manifest.
Modify:
packages/python-qiongli/src/qiongli/bridges/providers/literature_search.py- Responsibility: Python full MCP search defaults, review-depth limits, and search diagnostics.
Modify:
tests/test_literature_search.py- Responsibility: Python search default and limit regression tests.
Modify:
packages/qiongli-literature-mcpb/server/providers/openalex.mjsModify:
packages/qiongli-literature-mcpb/server/providers/semantic-scholar.mjsModify:
packages/qiongli-literature-mcpb/server/providers/crossref.mjsModify:
packages/qiongli-literature-mcpb/server/providers/pubmed.mjsModify:
packages/qiongli-literature-mcpb/server/providers/arxiv.mjs- Responsibility: Node provider fallback defaults when called directly without resolved search options.
Modify:
packages/qiongli-literature-mcpb/server/normalize.mjs- Responsibility: preserve OA/full-text candidate fields in normalized search results.
Modify:
packages/qiongli-literature-mcpb/server/search-plan.mjsModify:
packages/python-qiongli/src/qiongli/bridges/hybrid_search_router.py- Responsibility: emit platform-native full-text candidate search queries while keeping active agents, not MCP servers, responsible for executing native search.
Modify:
packages/qiongli-literature-mcpb/server/providers/openalex.mjsModify:
packages/qiongli-literature-mcpb/server/providers/semantic-scholar.mjsModify:
packages/qiongli-literature-mcpb/server/providers/arxiv.mjs- Responsibility: map provider OA/PDF hints into normalized fields.
Modify:
packages/qiongli-literature-mcpb/server/zotero/search-source.mjs- Responsibility: normalize Zotero attachment summaries into local full-text evidence hints and local match diagnostics.
Modify:
packages/qiongli-zotero-companion/chrome/content/qiongli-bridge.jsModify:
packages/qiongli-zotero-companion/bootstrap.jsModify:
packages/qiongli-zotero-companion/README.md- Responsibility: expose attachment-level metadata from Zotero safely, with raw local paths gated behind explicit request flags.
Modify:
packages/qiongli-literature-mcpb/test/tools.test.mjsModify:
packages/qiongli-literature-mcpb/test/providers.test.mjs- Responsibility: Node default limit and OA/full-text candidate preservation tests.
Modify:
packages/qiongli-literature-mcpb/test/zotero.test.mjsModify:
packages/qiongli-zotero-companion/test/bridge.test.mjsModify:
tests/test_hybrid_search_router.py- Responsibility: native full-text query planning, Zotero attachment exposure, and Zotero result normalization tests.
Modify:
tests/test_literature_contract.py- Responsibility: workflow guidance contract tests for status/search-plan routing and
qiongli_collect_evidencenon-authority.
- Responsibility: workflow guidance contract tests for status/search-plan routing and
Modify:
tests/test_mcp_provider_docs.pyandtests/test_cli_setup_docs.py- Responsibility: docs contract tests that guard install and MCP-provider wording.
Modify:
content/workflow/SKILL.md- Responsibility: canonical workflow source guidance.
Modify:
packages/python-qiongli/src/qiongli/subject_materializer.py- Responsibility: generated workflow guidance text inserted into materialized subject packages.
Modify:
README.md,README_CN.md- Responsibility: top-level install/provider guidance.
Modify:
docs/reference/cli.md,docs/zh/reference/cli.md- Responsibility: CLI/MCP tool inventory.
Modify:
docs/advanced/cross-platform-mcp.md- Responsibility: full MCP vs bundled MCPB semantics and evidence-adapter boundary.
Modify:
docs/guide/install.md,docs/zh/guide/install.md- Responsibility: install-surface guidance.
Modify:
docs/advanced/qiongli-cli-plugin-structure.html- Responsibility: static advanced reference tool list if it remains manually maintained.
Task 1: Clarify qiongli_collect_evidence Diagnostics
Files:
Modify:
packages/python-qiongli/src/qiongli/bridges/mcp_connectors.pyModify:
tests/test_mcp_connectors.py[ ] Step 1: Write the failing connector test
Append this test to tests/test_mcp_connectors.py after test_builtin_scholarly_search_reports_provider_config:
def test_direct_literature_provider_without_external_command_reports_adapter_scope(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
config_home = root / "config"
with mock.patch.dict(
os.environ,
{"QIONGLI_CONFIG_HOME": str(config_home)},
clear=False,
):
set_provider_value("openalex", "api-key", "openalex-key")
evidence = self.connector.collect("openalex", {"topic": "demo"}, root)
self.assertEqual(evidence.provider, "openalex")
self.assertEqual(evidence.status, "not_configured")
self.assertIn("external command adapter", evidence.summary)
self.assertIn("RESEARCH_MCP_OPENALEX_CMD", evidence.provenance)
self.assertEqual(evidence.data["not_configured_scope"], "external_command_adapter")
self.assertEqual(evidence.data["provider_config_status"], "configured")
self.assertEqual(evidence.data["provider_config"]["openalex"], "configured")
self.assertEqual(evidence.data["recommended_status_tool"], "qiongli_literature_status")
self.assertEqual(evidence.data["recommended_search_tool"], "qiongli_literature_search")- [ ] Step 2: Run the failing test
Run:
uv run python -m unittest tests.test_mcp_connectors.MCPConnectorTests.test_direct_literature_provider_without_external_command_reports_adapter_scope -vExpected: FAIL because the current not_configured response has no data scope or recommendation fields.
- [ ] Step 3: Implement scoped diagnostics
In packages/python-qiongli/src/qiongli/bridges/mcp_connectors.py, add this constant near MCPProviderResolution:
DIRECT_LITERATURE_PROVIDER_KEYS = {
"openalex",
"semantic_scholar",
"crossref",
"pubmed",
"arxiv",
}In the if not command: block inside _collect_external_command, replace the returned MCPEvidence(...) with:
config_summary = provider_config_summary(resolve_provider_config(cwd=cwd))
provider_key = self._provider_config_key(provider)
data: dict[str, Any] = {
"not_configured_scope": "external_command_adapter",
"external_command_env": env_name,
"provider_config": config_summary,
}
if provider_key in DIRECT_LITERATURE_PROVIDER_KEYS:
data.update(
{
"provider_config_status": config_summary.get(provider_key, "missing"),
"recommended_status_tool": "qiongli_literature_status",
"recommended_search_tool": "qiongli_literature_search",
}
)
summary = (
f"External command adapter for {provider} is not configured. "
f"Set {env_name} only if you intend to use a separate external MCP command. "
"Use qiongli_literature_status or qiongli_literature_search to check "
"the built-in literature provider config."
)
else:
summary = f"External command adapter not configured. Set {env_name}."
return MCPEvidence(
provider=provider,
status="not_configured",
summary=summary,
provenance=[env_name],
data=data,
)Add this helper method near _provider_env_var:
def _provider_config_key(self, provider: str) -> str:
key = provider.strip().lower().replace("-", "_")
if key == "semantic_scholar":
return "semantic_scholar"
return keyUpdate the import at the top of mcp_connectors.py:
from bridges.provider_config import (
provider_config_env,
provider_config_summary,
resolve_provider_config,
)- [ ] Step 4: Run the connector tests
Run:
uv run python -m unittest tests.test_mcp_connectors.MCPConnectorTests.test_direct_literature_provider_without_external_command_reports_adapter_scope tests.test_mcp_connectors.MCPConnectorTests.test_builtin_scholarly_search_reports_provider_config -vExpected: PASS.
- [ ] Step 5: Commit
git add packages/python-qiongli/src/qiongli/bridges/mcp_connectors.py tests/test_mcp_connectors.py
git commit -m "fix(mcp): clarify external evidence adapter diagnostics"Task 2: Make qiongli_collect_evidence Tool Schema Unambiguous
Files:
Modify:
packages/python-qiongli/src/qiongli/bridges/mcp_tool_handlers.pyModify:
tests/test_mcp_tool_handlers.py[ ] Step 1: Write the failing schema and behavior tests
Append these tests to tests/test_mcp_tool_handlers.py after test_collect_evidence_tool_uses_existing_connector:
def test_collect_evidence_description_names_external_command_boundary(self) -> None:
definitions = {tool["name"]: tool for tool in MCP_TOOL_DEFINITIONS}
description = definitions["qiongli_collect_evidence"]["description"]
self.assertIn("external command adapters", description)
self.assertIn("qiongli_literature_status", description)
self.assertIn("qiongli_literature_search", description)
def test_collect_evidence_openalex_not_configured_is_not_provider_config_status(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
with mock.patch.dict(
"os.environ",
{"QIONGLI_CONFIG_HOME": str(root / "config")},
clear=False,
):
set_provider_value("openalex", "api-key", "openalex-secret-key")
result = call_qiongli_tool(
"qiongli_collect_evidence",
{
"cwd": str(root),
"provider": "openalex",
"task_packet": {"topic": "demo-topic"},
},
)
payload = result["structuredContent"]["evidence"]
rendered = json.dumps(result, sort_keys=True)
self.assertFalse(result["isError"])
self.assertEqual(payload["status"], "not_configured")
self.assertEqual(payload["data"]["not_configured_scope"], "external_command_adapter")
self.assertEqual(payload["data"]["provider_config_status"], "configured")
self.assertEqual(payload["data"]["recommended_status_tool"], "qiongli_literature_status")
self.assertEqual(payload["data"]["recommended_search_tool"], "qiongli_literature_search")
self.assertNotIn("openalex-secret-key", rendered)- [ ] Step 2: Run the failing tests
Run:
uv run python -m unittest tests.test_mcp_tool_handlers.MCPToolHandlerTests.test_collect_evidence_description_names_external_command_boundary tests.test_mcp_tool_handlers.MCPToolHandlerTests.test_collect_evidence_openalex_not_configured_is_not_provider_config_status -vExpected: first test FAILS until the description is updated; second test passes only after Task 1.
- [ ] Step 3: Update the tool description
In packages/python-qiongli/src/qiongli/bridges/mcp_tool_handlers.py, replace the qiongli_collect_evidence description with:
"description": (
"Collect evidence from filesystem, built-in workflow adapters, or externally "
"configured command adapters. Do not use this to judge built-in literature "
"provider config; use qiongli_literature_status or qiongli_literature_search "
"for OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv provider status/search."
),- [ ] Step 4: Run the MCP tool handler tests
Run:
uv run python -m unittest tests.test_mcp_tool_handlers.MCPToolHandlerTests.test_tool_definitions_include_config_and_evidence_tools tests.test_mcp_tool_handlers.MCPToolHandlerTests.test_collect_evidence_description_names_external_command_boundary tests.test_mcp_tool_handlers.MCPToolHandlerTests.test_collect_evidence_openalex_not_configured_is_not_provider_config_status -vExpected: PASS.
- [ ] Step 5: Commit
git add packages/python-qiongli/src/qiongli/bridges/mcp_tool_handlers.py tests/test_mcp_tool_handlers.py
git commit -m "fix(mcp): document collect evidence adapter boundary"Task 3: Restore Python Full MCP Tool Inventory Parity
Files:
Modify:
packages/python-qiongli/src/qiongli/bridges/mcp_cli.pyModify:
tests/test_mcp_cli.py[ ] Step 1: Write failing CLI inventory assertions
In tests/test_mcp_cli.py, add this assertion to test_mcp_cli_doctor_json_reports_shared_provider_config after the existing qiongli_literature_search assertion:
self.assertIn("qiongli_search_plan", payload["literature_tools"])Add this assertion to test_mcp_cli_config_example_for_codex_json after the existing qiongli_literature_search assertion:
self.assertIn("qiongli_search_plan", payload["literature_tools"])Add this assertion to test_mcp_cli_config_example_for_antigravity_json after the existing qiongli_literature_search assertion:
self.assertIn("qiongli_search_plan", payload["literature_tools"])- [ ] Step 2: Run the failing CLI tests
Run:
uv run python -m unittest tests.test_mcp_cli.MCPCLITests.test_mcp_cli_doctor_json_reports_shared_provider_config tests.test_mcp_cli.MCPCLITests.test_mcp_cli_config_example_for_codex_json tests.test_mcp_cli.MCPCLITests.test_mcp_cli_config_example_for_antigravity_json -vExpected: FAIL because qiongli_search_plan is missing from LITERATURE_TOOLS.
- [ ] Step 3: Add
qiongli_search_plantoLITERATURE_TOOLS
In packages/python-qiongli/src/qiongli/bridges/mcp_cli.py, change LITERATURE_TOOLS to:
LITERATURE_TOOLS = [
"qiongli_literature_status",
"qiongli_search_plan",
"qiongli_literature_search",
"qiongli_literature_export_evidence",
]- [ ] Step 4: Run the CLI tests
Run:
uv run python -m unittest tests.test_mcp_cli -vExpected: PASS. If the wizard server cannot bind in the local sandbox, the existing test helper may skip that environment-specific case.
- [ ] Step 5: Commit
git add packages/python-qiongli/src/qiongli/bridges/mcp_cli.py tests/test_mcp_cli.py
git commit -m "fix(mcp): include search plan in literature tool inventory"Task 4: Add Cross-Surface MCP Tool Parity Tests
Files:
Create:
tests/test_mcp_tool_surface_parity.py[ ] Step 1: Create the failing parity test file
Create tests/test_mcp_tool_surface_parity.py:
from __future__ import annotations
import json
from pathlib import Path
import unittest
from bridges.mcp_cli import LITERATURE_TOOLS
from bridges.mcp_tool_handlers import MCP_TOOL_DEFINITIONS
REPO_ROOT = Path(__file__).resolve().parents[1]
COMMON_LITERATURE_PROVIDER_TOOLS = {
"qiongli_literature_status",
"qiongli_search_plan",
"qiongli_config_status",
"qiongli_configure_provider",
"qiongli_save_provider_config",
"qiongli_open_config_wizard",
"qiongli_literature_search",
"qiongli_literature_export_evidence",
}
class MCPToolSurfaceParityTests(unittest.TestCase):
def test_python_full_mcp_exposes_common_literature_provider_tools(self) -> None:
names = {tool["name"] for tool in MCP_TOOL_DEFINITIONS}
self.assertTrue(COMMON_LITERATURE_PROVIDER_TOOLS.issubset(names))
def test_python_cli_literature_tool_inventory_matches_router_surface(self) -> None:
self.assertEqual(
LITERATURE_TOOLS,
[
"qiongli_literature_status",
"qiongli_search_plan",
"qiongli_literature_search",
"qiongli_literature_export_evidence",
],
)
def test_node_mcpb_manifest_exposes_common_literature_provider_tools(self) -> None:
manifest_path = REPO_ROOT / "packages" / "qiongli-literature-mcpb" / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
names = {tool["name"] for tool in manifest["tools"]}
self.assertTrue(COMMON_LITERATURE_PROVIDER_TOOLS.issubset(names))
def test_collect_evidence_is_python_full_external_adapter_not_mcpb_provider_status(self) -> None:
python_names = {tool["name"] for tool in MCP_TOOL_DEFINITIONS}
manifest_path = REPO_ROOT / "packages" / "qiongli-literature-mcpb" / "manifest.json"
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
node_names = {tool["name"] for tool in manifest["tools"]}
self.assertIn("qiongli_collect_evidence", python_names)
self.assertNotIn("qiongli_collect_evidence", node_names)
if __name__ == "__main__":
unittest.main()- [ ] Step 2: Run the failing parity tests
Run:
uv run python -m unittest tests.test_mcp_tool_surface_parity -vExpected: FAIL until Task 3 adds qiongli_search_plan to LITERATURE_TOOLS.
- [ ] Step 3: Re-run after Task 3
Run:
uv run python -m unittest tests.test_mcp_tool_surface_parity -vExpected: PASS.
- [ ] Step 4: Commit
git add tests/test_mcp_tool_surface_parity.py
git commit -m "test(mcp): add literature tool surface parity checks"Task 5: Update Canonical Workflow Guidance and Materializer Text
Files:
Modify:
content/workflow/SKILL.mdModify:
packages/python-qiongli/src/qiongli/subject_materializer.pyModify:
tests/test_literature_contract.py[ ] Step 1: Write failing workflow contract tests
Append these tests to tests/test_literature_contract.py:
def test_workflow_guidance_rejects_collect_evidence_as_provider_status_source(self) -> None:
content = (REPO_ROOT / "content" / "workflow" / "SKILL.md").read_text(encoding="utf-8")
self.assertIn("Do not use `qiongli_collect_evidence` to judge", content)
self.assertIn("`qiongli_literature_status`", content)
self.assertIn("`qiongli_literature_search`", content)
def test_workflow_guidance_lists_all_bundled_literature_providers(self) -> None:
content = (REPO_ROOT / "content" / "workflow" / "SKILL.md").read_text(encoding="utf-8")
for provider in ("OpenAlex", "Semantic Scholar", "Crossref", "PubMed", "arXiv"):
self.assertIn(provider, content)- [ ] Step 2: Run the failing contract tests
Run:
uv run python -m unittest tests.test_literature_contract.LiteratureContractTests.test_workflow_guidance_rejects_collect_evidence_as_provider_status_source tests.test_literature_contract.LiteratureContractTests.test_workflow_guidance_lists_all_bundled_literature_providers -vExpected: FAIL because the guidance does not yet explicitly reject qiongli_collect_evidence as provider status authority, and source text may still list only OpenAlex/Semantic Scholar in one Desktop/Web sentence.
- [ ] Step 3: Update
content/workflow/SKILL.md
Under ## Literature Provider Configuration, add this bullet after the qiongli_literature_status / qiongli_search_plan bullets:
- Do not use `qiongli_collect_evidence` to judge built-in literature provider configuration. That tool is a filesystem/builtin/external-command evidence adapter; direct provider names such as `openalex` require a separate `RESEARCH_MCP_OPENALEX_CMD`. Use `qiongli_literature_status`, `qiongli_config_status`, `qiongli_test_provider`, and `qiongli_literature_search` to judge OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv provider availability.Replace the Desktop/Web MCPB sentence with:
- Desktop/Web users need the Qiongli Literature Provider `.mcpb` (`qiongli-literature-provider.mcpb`) or another configured provider MCP before claiming `provider_connected` literature search. The MCPB is the separate local Claude Desktop provider for OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv configuration/search. arXiv is enabled without credentials. Platform-native search alone is `native_only`, not `provider_connected`; if no provider MCP/MCPB and no platform-native search is available, record the run as `strategy_only`.- [ ] Step 4: Update
subject_materializer.py
In packages/python-qiongli/src/qiongli/subject_materializer.py, find the provider guidance list near the existing Desktop/Web users need... string and add the same qiongli_collect_evidence boundary bullet. Confirm the Desktop/Web MCPB string matches the text from Step 3.
- [ ] Step 5: Run contract tests
Run:
uv run python -m unittest tests.test_literature_contract -vExpected: PASS.
- [ ] Step 6: Commit
git add content/workflow/SKILL.md packages/python-qiongli/src/qiongli/subject_materializer.py tests/test_literature_contract.py
git commit -m "docs(workflow): clarify literature provider status authority"Task 6: Fix CLI and Cross-Platform MCP Documentation
Files:
Modify:
docs/reference/cli.mdModify:
docs/zh/reference/cli.mdModify:
docs/advanced/cross-platform-mcp.mdModify:
README.mdModify:
README_CN.mdModify:
tests/test_mcp_provider_docs.py[ ] Step 1: Write failing docs contract tests
Append these tests to tests/test_mcp_provider_docs.py:
def test_cli_reference_lists_literature_status_search_plan_and_search(self) -> None:
content = (REPO_ROOT / "docs" / "reference" / "cli.md").read_text(encoding="utf-8")
for tool in (
"qiongli_literature_status",
"qiongli_search_plan",
"qiongli_literature_search",
"qiongli_literature_export_evidence",
):
self.assertIn(tool, content)
def test_cross_platform_docs_separate_collect_evidence_from_provider_status(self) -> None:
content = (REPO_ROOT / "docs" / "advanced" / "cross-platform-mcp.md").read_text(
encoding="utf-8"
)
self.assertIn("External evidence adapter", content)
self.assertIn("Do not use `qiongli_collect_evidence` to judge", content)
self.assertIn("RESEARCH_MCP_<PROVIDER>_CMD", content)- [ ] Step 2: Run the failing docs tests
Run:
uv run python -m unittest tests.test_mcp_provider_docs -vExpected: FAIL until docs are updated.
- [ ] Step 3: Update
docs/reference/cli.md
Replace the MCP tools list under "MCP tools exposed by the server" with:
MCP tools exposed by the full Python server:
- `qiongli_literature_status`
- `qiongli_search_plan`
- `qiongli_literature_search`
- `qiongli_literature_export_evidence`
- `qiongli_config_status`
- `qiongli_save_provider_config`
- `qiongli_configure_provider`
- `qiongli_open_config_wizard`
- `qiongli_list_provider_env`
- `qiongli_test_provider`
- `qiongli_collect_evidence` — filesystem/builtin/external-command evidence adapter. Do not use it to judge OpenAlex/Semantic Scholar/Crossref/PubMed/arXiv provider config; direct provider names require `RESEARCH_MCP_<PROVIDER>_CMD`.
- `qiongli_subject_status`
- `qiongli_subject_update`
- `qiongli_orchestrator_route`
- `qiongli_orchestrator_doctor`
- `qiongli_task_plan`
- `qiongli_task_run`- [ ] Step 4: Update
docs/zh/reference/cli.md
Replace the matching Chinese tools sentence with:
full Python server 暴露的 MCP tools 包括 `qiongli_literature_status`、`qiongli_search_plan`、`qiongli_literature_search`、`qiongli_literature_export_evidence`、`qiongli_config_status`、`qiongli_save_provider_config`、`qiongli_configure_provider`、`qiongli_open_config_wizard`、`qiongli_list_provider_env`、`qiongli_test_provider`、`qiongli_collect_evidence`、`qiongli_subject_status`、`qiongli_subject_update`、`qiongli_orchestrator_route`、`qiongli_orchestrator_doctor`、`qiongli_task_plan` 和 `qiongli_task_run`。其中 `qiongli_collect_evidence` 是 filesystem / builtin / external-command evidence adapter;不要用它判断 OpenAlex、Semantic Scholar、Crossref、PubMed 或 arXiv 的 provider config。直接传入 `openalex` 这类 provider name 时,它检查的是 `RESEARCH_MCP_<PROVIDER>_CMD` 外部命令。- [ ] Step 5: Update
docs/advanced/cross-platform-mcp.md
Replace this bullet:
- `qiongli_config_status`, `qiongli_configure_provider`, `qiongli_save_provider_config`, and `qiongli_collect_evidence` for MCP/provider readiness.with:
- `qiongli_config_status`, `qiongli_configure_provider`, `qiongli_save_provider_config`, `qiongli_list_provider_env`, and `qiongli_test_provider` for provider configuration and redacted readiness.
- `qiongli_collect_evidence` for filesystem, builtin workflow adapters, and external evidence adapter commands. Do not use `qiongli_collect_evidence` to judge built-in literature provider config. Direct academic provider names such as `openalex` require `RESEARCH_MCP_<PROVIDER>_CMD`; OpenAlex/Semantic Scholar/Crossref/PubMed/arXiv provider availability is checked through `qiongli_literature_status` and `qiongli_literature_search`.Also replace the Claude Code bundled plugin sentence that says full runtime includes qiongli_literature_search with:
This bundled runtime covers literature-provider tools such as provider configuration, status, search, search planning, and evidence export without requiring the `qiongli` CLI. Use `qiongli install --profile full --target claude --surface plugin` when Claude Code needs Python-backed orchestration tools, including `qiongli_orchestrator_route`, `qiongli_orchestrator_doctor`, `qiongli_task_plan`, or `qiongli_task_run`.- [ ] Step 6: Update top-level README files
In README.md, replace the provider paragraph around the .mcpb sentence with:
Provider credentials stay in provider config, not generated skill bundles. Use `qiongli provider setup` for OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv-supported literature workflows, then `qiongli provider doctor` to verify. The `qiongli-literature-provider` `.mcpb` exposes `qiongli_literature_status`, `qiongli_search_plan`, `qiongli_literature_search`, `qiongli_literature_export_evidence`, `qiongli_config_status`, `qiongli_configure_provider`, and `qiongli_save_provider_config` for Codex/Desktop flows; statuses include `provider_connected`, `native_only`, and `strategy_only` depending on provider and platform search availability. `qiongli_collect_evidence` is an external evidence adapter path and must not be used as the OpenAlex provider-config check.In README_CN.md, use:
Provider 凭据保存在 provider config,不写进生成的 skill bundle。使用 `qiongli provider setup` 配置 OpenAlex、Semantic Scholar、Crossref、PubMed 和 arXiv 支持的文献 workflow,再用 `qiongli provider doctor` 验证。`qiongli-literature-provider` `.mcpb` 为 Codex/Desktop 流程暴露 `qiongli_literature_status`、`qiongli_search_plan`、`qiongli_literature_search`、`qiongli_literature_export_evidence`、`qiongli_config_status`、`qiongli_configure_provider` 和 `qiongli_save_provider_config`;状态会根据 provider 和平台原生搜索可用性区分 `provider_connected`、`native_only` 和 `strategy_only`。`qiongli_collect_evidence` 是 external evidence adapter 路径,不能作为 OpenAlex provider config 检查。- [ ] Step 7: Run docs tests
Run:
uv run python -m unittest tests.test_mcp_provider_docs -vExpected: PASS.
- [ ] Step 8: Commit
git add docs/reference/cli.md docs/zh/reference/cli.md docs/advanced/cross-platform-mcp.md README.md README_CN.md tests/test_mcp_provider_docs.py
git commit -m "docs(mcp): separate provider status from evidence adapters"Task 7: Fix Install Guide Runtime Boundary Wording
Files:
Modify:
docs/guide/install.mdModify:
docs/zh/guide/install.mdModify:
tests/test_cli_setup_docs.py[ ] Step 1: Write failing install docs tests
Append these tests to tests/test_cli_setup_docs.py:
def test_install_docs_do_not_mark_literature_search_full_runtime_only(self) -> None:
content = (REPO_ROOT / "docs" / "guide" / "install.md").read_text(encoding="utf-8")
self.assertIn("bundled runtime covers literature-provider tools", content)
self.assertNotIn(
"Full runtime commands, including `qiongli_literature_search`",
content,
)
self.assertIn("Python-backed orchestration tools", content)
def test_zh_install_docs_do_not_mark_literature_search_full_runtime_only(self) -> None:
content = (REPO_ROOT / "docs" / "zh" / "guide" / "install.md").read_text(encoding="utf-8")
self.assertIn("内置 literature-provider tools", content)
self.assertNotIn(
"需要 `qiongli_literature_search`、`qiongli_task_plan`",
content,
)
self.assertIn("Python-backed orchestration tools", content)- [ ] Step 2: Run the failing install docs tests
Run:
uv run python -m unittest tests.test_cli_setup_docs -vExpected: FAIL until wording is updated.
- [ ] Step 3: Update English install guide
In docs/guide/install.md, replace the Claude Code plugin paragraph with:
The Claude Code plugin also bundles the zero-dependency Node literature-provider MCP runtime under `mcp/qiongli-literature-provider/`, using the same provider, search, search-plan, evidence-export, and status tools as the Codex plugin. It covers literature-provider MCP without installing the `qiongli` CLI. Python-backed orchestration tools, including `qiongli_orchestrator_route`, `qiongli_task_plan`, `qiongli_task_run`, and `qiongli_orchestrator_doctor`, require the full runtime: `pipx install qiongli`. Then run `qiongli install --profile full --target claude --surface plugin` to generate a local Claude Code plugin that launches the unified `qiongli mcp serve --transport stdio` server. Use `--target antigravity` to generate the Antigravity plugin with its root `mcp_config.json`, `--target hermes` for Hermes MCP config, or `--target all --surface plugin` when Codex/Claude Code/Antigravity should use local plugins and Hermes should receive managed full MCP config.- [ ] Step 4: Update Chinese install guide
In docs/zh/guide/install.md, replace the matching Claude Code plugin paragraph with:
Claude Code marketplace plugin 也内置 `mcp/qiongli-literature-provider/` 下的零依赖 Node literature-provider MCP runtime,提供与 Codex plugin 相同的 provider、search、search-plan、evidence-export 和 status tools。只使用这些内置 literature-provider tools 时,不需要安装 `qiongli` CLI。`qiongli_orchestrator_route`、`qiongli_task_plan`、`qiongli_task_run` 和 `qiongli_orchestrator_doctor` 这类 Python-backed orchestration tools 需要完整运行时:`pipx install qiongli`。然后运行 `qiongli install --profile full --target claude --surface plugin` 生成本地 Claude Code plugin,并由这个 plugin 启动统一的 `qiongli mcp serve --transport stdio` server。`--target antigravity` 会生成带 root `mcp_config.json` 的 Antigravity plugin,`--target hermes` 写入 Hermes MCP config;`--target all --surface plugin` 会让 Codex / Claude Code / Antigravity 使用本地 plugin,同时给 Hermes 写入受管理的 full MCP client 配置。- [ ] Step 5: Run install docs tests
Run:
uv run python -m unittest tests.test_cli_setup_docs -vExpected: PASS.
- [ ] Step 6: Commit
git add docs/guide/install.md docs/zh/guide/install.md tests/test_cli_setup_docs.py
git commit -m "docs(install): clarify bundled literature MCP search support"Task 8: Update Static Advanced Reference If Still Maintained
Files:
Modify:
docs/advanced/qiongli-cli-plugin-structure.html[ ] Step 1: Inspect static source ownership
Run:
rg -n "qiongli-cli-plugin-structure|qiongli_collect_evidence|qiongli_literature_search" docs tooling scripts packages -SExpected: Identify whether docs/advanced/qiongli-cli-plugin-structure.html is generated from a source file. If a generator exists, update the source and regenerate. If no generator exists, edit the HTML directly.
- [ ] Step 2: Update the tool list
Ensure the HTML tool list includes:
<li><code>qiongli_literature_status</code></li>
<li><code>qiongli_search_plan</code></li>
<li><code>qiongli_literature_search</code></li>
<li><code>qiongli_literature_export_evidence</code></li>
<li><code>qiongli_config_status</code></li>
<li><code>qiongli_configure_provider</code></li>
<li><code>qiongli_save_provider_config</code></li>
<li><code>qiongli_collect_evidence</code> <span>external command evidence adapter, not provider-config status</span></li>- [ ] Step 3: Verify no stale full-runtime-only search claim remains
Run:
rg -n "Full runtime commands, including `qiongli_literature_search`|需要 `qiongli_literature_search`|OpenAlex and Semantic Scholar configuration" docs content packages README.md README_CN.md -SExpected: no matches for stale full-runtime-only search claims or two-provider-only MCPB descriptions.
- [ ] Step 4: Commit
git add docs/advanced/qiongli-cli-plugin-structure.html
git commit -m "docs(mcp): refresh static plugin structure reference"Task 9: Standardize Literature Search Defaults
Files:
Modify:
packages/python-qiongli/src/qiongli/bridges/providers/literature_search.pyModify:
packages/python-qiongli/src/qiongli/bridges/literature_mcp_tools.pyModify:
tests/test_literature_search.pyModify:
tests/test_mcp_literature_tools.pyModify:
packages/qiongli-literature-mcpb/server/providers/openalex.mjsModify:
packages/qiongli-literature-mcpb/server/providers/semantic-scholar.mjsModify:
packages/qiongli-literature-mcpb/server/providers/crossref.mjsModify:
packages/qiongli-literature-mcpb/server/providers/pubmed.mjsModify:
packages/qiongli-literature-mcpb/server/providers/arxiv.mjsModify:
packages/qiongli-literature-mcpb/test/config.test.mjsModify:
packages/qiongli-literature-mcpb/test/providers.test.mjs[ ] Step 1: Write failing Python default-limit tests
In tests/test_literature_search.py, change test_run_scholarly_search_uses_wider_default_per_query_limit so it expects 25:
self.assertEqual(result["data"]["per_query_limit"], 25)
self.assertTrue(seen_limits)
self.assertTrue(all(limit == 25 for limit in seen_limits))Append this review-mode test after it:
def test_run_scholarly_search_uses_review_default_per_query_limit(self) -> None:
seen_limits: list[int] = []
def fake_search(query: str, limit: int) -> dict[str, object]:
seen_limits.append(limit)
return {"data": [{"paperId": query, "title": query, "year": 2024}]}
result = run_scholarly_search(
{
"topic": "qualitative governance systematic review",
"search_mode": "review",
"keywords": ["governance", "firms"],
},
fake_search,
retrieved_at="2026-03-25T12:00:00+00:00",
)
self.assertEqual(result["data"]["per_query_limit"], 50)
self.assertTrue(seen_limits)
self.assertTrue(all(limit == 50 for limit in seen_limits))- [ ] Step 2: Run the failing Python search tests
Run:
uv run python -m unittest tests.test_literature_search.LiteratureSearchTests.test_run_scholarly_search_uses_wider_default_per_query_limit tests.test_literature_search.LiteratureSearchTests.test_run_scholarly_search_uses_review_default_per_query_limit -vExpected: FAIL because Python default is still 20 and has no review-mode default.
- [ ] Step 3: Implement Python default tiers
In packages/python-qiongli/src/qiongli/bridges/providers/literature_search.py, replace the current default constants with:
STANDARD_PER_QUERY_LIMIT = 25
REVIEW_PER_QUERY_LIMIT = 50
DEEP_PER_QUERY_LIMIT = 100
MAX_PER_QUERY_LIMIT = 200Replace _resolve_per_query_limit(...) with:
def _resolve_per_query_limit(task_packet: dict[str, Any]) -> int:
default_limit = _default_per_query_limit(task_packet)
for key in ("per_query_limit", "per_provider_limit", "limit", "search_limit"):
value = task_packet.get(key)
try:
parsed = int(str(value).strip())
except (TypeError, ValueError):
continue
return max(1, min(parsed, MAX_PER_QUERY_LIMIT))
return default_limit
def _default_per_query_limit(task_packet: dict[str, Any]) -> int:
raw_depth = str(task_packet.get("search_depth", "") or "").strip().lower().replace("-", "_")
raw_mode = str(task_packet.get("search_mode", "") or "").strip().lower().replace("-", "_")
raw_paper_type = str(task_packet.get("paper_type", "") or "").strip().lower().replace("-", "_")
if raw_depth == "deep":
return DEEP_PER_QUERY_LIMIT
if raw_depth in {"review", "systematic_review"}:
return REVIEW_PER_QUERY_LIMIT
if raw_mode in {"review", "systematic_review", "literature_review", "lit_review"}:
return REVIEW_PER_QUERY_LIMIT
if raw_paper_type in {"review", "systematic_review", "literature_review", "lit_review"}:
return REVIEW_PER_QUERY_LIMIT
return STANDARD_PER_QUERY_LIMIT- [ ] Step 4: Preserve
search_depthin Python MCP task packets
In packages/python-qiongli/src/qiongli/bridges/literature_mcp_tools.py, add this field to the dict returned by _task_packet_from_search_args(...):
"search_depth": args.get("search_depth", args.get("searchDepth")),- [ ] Step 5: Add MCP task-packet default test
Append this test to tests/test_mcp_literature_tools.py:
def test_literature_search_review_mode_defaults_to_fifty_per_provider(self) -> None:
provider_calls: list[int] = []
def openalex_search(translation: dict[str, object], limit: int) -> dict[str, object]:
provider_calls.append(limit)
return {"data": []}
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
with mock.patch.dict("os.environ", {"QIONGLI_CONFIG_HOME": str(root / "config")}, clear=True):
set_provider_value("openalex", "api-key", "openalex-secret-key")
with mock.patch("bridges.literature_mcp_tools.openalex_client.search", openalex_search):
result = call_qiongli_tool(
"qiongli_literature_search",
{"query": "AI feedback systematic review", "search_mode": "review"},
)
self.assertFalse(result["isError"])
self.assertEqual(provider_calls, [50])
self.assertEqual(result["structuredContent"]["data"]["per_query_limit"], 50)- [ ] Step 6: Run focused Python tests
Run:
uv run python -m unittest tests.test_literature_search tests.test_mcp_literature_tools -vExpected: PASS.
- [ ] Step 7: Update Node provider fallback constants
In each of these files, change const DEFAULT_LIMIT = 10; to const DEFAULT_LIMIT = 25;:
packages/qiongli-literature-mcpb/server/providers/openalex.mjs
packages/qiongli-literature-mcpb/server/providers/semantic-scholar.mjs
packages/qiongli-literature-mcpb/server/providers/crossref.mjs
packages/qiongli-literature-mcpb/server/providers/pubmed.mjs
packages/qiongli-literature-mcpb/server/providers/arxiv.mjsKeep packages/qiongli-literature-mcpb/server/index.mjs unchanged: it already has REVIEW_DEFAULT_LIMIT = 50, STANDARD_MAX_LIMIT = 50, and REVIEW_MAX_LIMIT = 200.
- [ ] Step 8: Add Node fallback default tests
In packages/qiongli-literature-mcpb/test/providers.test.mjs, add provider-specific assertions where provider URL assertions already exist. For example, in the OpenAlex no-limit request test, assert:
assert.equal(requestedUrl.searchParams.get("per-page"), "25");For Semantic Scholar, Crossref, PubMed, and arXiv no-limit tests, assert the provider-specific request parameter is 25:
assert.equal(requestedUrl.searchParams.get("limit"), "25");
assert.equal(requestedUrl.searchParams.get("rows"), "25");
assert.equal(requestedUrl.searchParams.get("retmax"), "25");
assert.equal(requestedUrl.searchParams.get("max_results"), "25");- [ ] Step 9: Run Node provider tests
Run:
node --test packages/qiongli-literature-mcpb/test/providers.test.mjs packages/qiongli-literature-mcpb/test/config.test.mjs packages/qiongli-literature-mcpb/test/tools.test.mjsExpected: PASS.
- [ ] Step 10: Commit
git add \
packages/python-qiongli/src/qiongli/bridges/providers/literature_search.py \
packages/python-qiongli/src/qiongli/bridges/literature_mcp_tools.py \
tests/test_literature_search.py \
tests/test_mcp_literature_tools.py \
packages/qiongli-literature-mcpb/server/providers/openalex.mjs \
packages/qiongli-literature-mcpb/server/providers/semantic-scholar.mjs \
packages/qiongli-literature-mcpb/server/providers/crossref.mjs \
packages/qiongli-literature-mcpb/server/providers/pubmed.mjs \
packages/qiongli-literature-mcpb/server/providers/arxiv.mjs \
packages/qiongli-literature-mcpb/test/providers.test.mjs
git commit -m "fix(search): standardize literature result defaults"Task 10: Preserve Full-Text Access Hints in Search Results
Files:
Modify:
packages/qiongli-literature-mcpb/server/normalize.mjsModify:
packages/qiongli-literature-mcpb/server/providers/openalex.mjsModify:
packages/qiongli-literature-mcpb/server/providers/semantic-scholar.mjsModify:
packages/qiongli-literature-mcpb/server/providers/arxiv.mjsModify:
packages/qiongli-literature-mcpb/test/providers.test.mjsModify:
packages/qiongli-literature-mcpb/test/tools.test.mjsModify:
content/skills/B_literature/fulltext-fetcher.mdModify:
content/templates/paper-note.md[ ] Step 1: Write failing Node normalization test
Append this test to packages/qiongli-literature-mcpb/test/tools.test.mjs near the search schema tests:
test("normalizeResult preserves full-text access candidate fields", () => {
const result = normalizeResult({
title: "OA Paper",
open_access_pdf_url: "https://example.org/paper.pdf",
access_url: "https://example.org/paper",
fulltext_status: "not_retrieved:oa_candidate",
evidence_limit: "abstract_only"
});
assert.equal(result.open_access_pdf_url, "https://example.org/paper.pdf");
assert.equal(result.access_url, "https://example.org/paper");
assert.equal(result.fulltext_status, "not_retrieved:oa_candidate");
assert.equal(result.evidence_limit, "abstract_only");
});If normalizeResult is not imported in that file, add:
import { normalizeResult } from "../server/normalize.mjs";- [ ] Step 2: Run the failing normalization test
Run:
node --test packages/qiongli-literature-mcpb/test/tools.test.mjsExpected: FAIL because normalizeResult currently drops these fields.
- [ ] Step 3: Preserve fields in
normalizeResult
In packages/qiongli-literature-mcpb/server/normalize.mjs, add these fields to the returned object in normalizeResult(record):
open_access_pdf_url: cleanString(record?.open_access_pdf_url ?? record?.openAccessPdf?.url),
access_url: cleanString(record?.access_url),
fulltext_status: cleanString(record?.fulltext_status),
evidence_limit: cleanString(record?.evidence_limit),
license: cleanString(record?.license),- [ ] Step 4: Map OpenAlex OA candidate URLs
In packages/qiongli-literature-mcpb/server/providers/openalex.mjs, add this helper near openAlexUrl(...):
function openAlexPdfUrl(work) {
return (
work?.best_oa_location?.pdf_url ??
work?.primary_location?.pdf_url ??
work?.open_access?.oa_url ??
null
);
}In mapWork(work), add:
open_access_pdf_url: openAlexPdfUrl(work),
access_url: openAlexPdfUrl(work) ?? openAlexUrl(work),
fulltext_status: openAlexPdfUrl(work) ? "not_retrieved:oa_candidate" : "metadata_only",
evidence_limit: work?.abstract_inverted_index ? "abstract_only" : "metadata_only",
license: work?.best_oa_location?.license ?? work?.primary_location?.license,- [ ] Step 5: Map Semantic Scholar OA candidate URLs
In packages/qiongli-literature-mcpb/server/providers/semantic-scholar.mjs, add this helper near doiFor(...):
function openAccessPdfUrl(paper) {
return typeof paper?.openAccessPdf?.url === "string" ? paper.openAccessPdf.url : null;
}In BASE_FIELDS, ensure openAccessPdf is included:
"openAccessPdf",In mapPaper(paper), add:
open_access_pdf_url: openAccessPdfUrl(paper),
access_url: openAccessPdfUrl(paper) ?? paper?.url,
fulltext_status: openAccessPdfUrl(paper) ? "not_retrieved:oa_candidate" : "metadata_only",
evidence_limit: paper?.abstract ? "abstract_only" : "metadata_only",- [ ] Step 6: Map arXiv PDF URLs
In packages/qiongli-literature-mcpb/server/providers/arxiv.mjs, where the record is built from an entry, add:
open_access_pdf_url: pdfUrl,
access_url: pdfUrl ?? absUrl,
fulltext_status: pdfUrl ? "not_retrieved:oa_candidate" : "metadata_only",
evidence_limit: abstract ? "abstract_only" : "metadata_only",Use the local variable names already present in the arXiv mapper. If they differ, keep the same meaning: PDF URL first, abstract/entry landing URL second.
- [ ] Step 7: Add provider mapping tests
In packages/qiongli-literature-mcpb/test/providers.test.mjs, add assertions to existing OpenAlex, Semantic Scholar, and arXiv normalization tests:
assert.equal(result.open_access_pdf_url, "https://example.org/paper.pdf");
assert.equal(result.fulltext_status, "not_retrieved:oa_candidate");
assert.equal(result.evidence_limit, "abstract_only");Use the fixture field names for each provider:
best_oa_location: { pdf_url: "https://example.org/paper.pdf", license: "cc-by" }
openAccessPdf: { url: "https://example.org/paper.pdf" }
<link title="pdf" href="https://arxiv.org/pdf/1234.5678" />- [ ] Step 8: Update evidence-boundary docs
In content/skills/B_literature/fulltext-fetcher.md, under "Provider Ownership Boundary", add:
Search providers can identify full-text candidates, but they do not prove that the full text was retrieved or read. Treat `open_access_pdf_url` and `access_url` as retrieval candidates until `retrieval_manifest.csv` records `retrieved_oa`, `retrieved_preprint`, or a controlled `not_retrieved:*` status.In content/templates/paper-note.md, under "Evidence Boundary", add:
Metadata search coverage and full-text access are separate. A paper found through OpenAlex, Semantic Scholar, Crossref, PubMed, or arXiv remains `abstract_only` or `metadata_only` until the retrieval manifest records a readable full-text version.- [ ] Step 9: Run Node and contract tests
Run:
node --test packages/qiongli-literature-mcpb/test/tools.test.mjs packages/qiongli-literature-mcpb/test/providers.test.mjs
uv run python -m unittest tests.test_literature_contract -vExpected: PASS.
- [ ] Step 10: Commit
git add \
packages/qiongli-literature-mcpb/server/normalize.mjs \
packages/qiongli-literature-mcpb/server/providers/openalex.mjs \
packages/qiongli-literature-mcpb/server/providers/semantic-scholar.mjs \
packages/qiongli-literature-mcpb/server/providers/arxiv.mjs \
packages/qiongli-literature-mcpb/test/providers.test.mjs \
packages/qiongli-literature-mcpb/test/tools.test.mjs \
content/skills/B_literature/fulltext-fetcher.md \
content/templates/paper-note.md
git commit -m "fix(search): preserve full text access candidates"Task 11: Add Native Full-Text Candidate and Zotero Attachment Verification Layer
Files:
Modify:
packages/qiongli-literature-mcpb/server/search-plan.mjsModify:
packages/python-qiongli/src/qiongli/bridges/hybrid_search_router.pyModify:
packages/qiongli-literature-mcpb/test/tools.test.mjsModify:
tests/test_hybrid_search_router.pyModify:
packages/qiongli-zotero-companion/chrome/content/qiongli-bridge.jsModify:
packages/qiongli-zotero-companion/bootstrap.jsModify:
packages/qiongli-zotero-companion/test/bridge.test.mjsModify:
packages/qiongli-zotero-companion/README.mdModify:
packages/qiongli-literature-mcpb/server/zotero/search-source.mjsModify:
packages/qiongli-literature-mcpb/test/zotero.test.mjs[ ] Step 1: Write failing Node search-plan tests for native full-text candidate queries
Append this test to packages/qiongli-literature-mcpb/test/tools.test.mjs near the existing buildHybridSearchPlan tests:
test("buildHybridSearchPlan emits native full-text candidate queries separately", () => {
const plan = buildHybridSearchPlan(
{
query: "AI feedback in education",
platform: "codex",
native_search_available: true,
native_search_tools: ["codex_web_search"],
search_mode: "review"
},
"provider_connected",
{ openalex: "configured", arxiv: "configured" }
);
assert.equal(plan.search_execution_mode, "hybrid_search");
assert.equal(plan.native_search_queries.length, 1);
assert.equal(plan.native_fulltext_queries.length, 1);
assert.equal(plan.native_fulltext_queries[0].tool, "codex_web_search");
assert.equal(plan.native_fulltext_queries[0].purpose, "fulltext_candidate_discovery");
assert.equal(plan.native_fulltext_queries[0].candidate_status, "candidate_only");
assert.match(plan.native_fulltext_queries[0].query, /PDF/);
assert.match(plan.native_fulltext_queries[0].query, /full text/);
assert.equal(plan.native_fulltext_queries[0].provenance_label, "native:codex_web_search");
assert.deepEqual(plan.native_fulltext_candidate_schema.required, [
"query_id",
"source_agent",
"url",
"title",
"candidate_status",
"retrieved_at"
]);
assert.ok(
plan.agent_instructions.includes(
"Use native_fulltext_queries only to discover candidate URLs; do not mark full text as retrieved from search snippets."
)
);
assert.ok(
plan.merge_policy.fulltext_candidate_records.includes("candidate_only")
);
});- [ ] Step 2: Write failing Python hybrid-router tests
Append this test to tests/test_hybrid_search_router.py:
def test_native_fulltext_queries_are_separate_candidate_discovery(self) -> None:
plan = build_hybrid_search_plan(
{
"query": "AI feedback in education",
"platform": "codex",
"native_search_available": True,
"native_search_tools": ["codex_web_search"],
"search_mode": "review",
},
provider_capability_mode="provider_connected",
provider_status={"openalex": "configured", "arxiv": "configured"},
)
self.assertEqual(plan["search_execution_mode"], "hybrid_search")
self.assertEqual(len(plan["native_search_queries"]), 1)
self.assertEqual(len(plan["native_fulltext_queries"]), 1)
fulltext_query = plan["native_fulltext_queries"][0]
self.assertEqual(fulltext_query["purpose"], "fulltext_candidate_discovery")
self.assertEqual(fulltext_query["candidate_status"], "candidate_only")
self.assertEqual(fulltext_query["provenance_label"], "native:codex_web_search")
self.assertIn("PDF", fulltext_query["query"])
self.assertIn("full text", fulltext_query["query"])
self.assertEqual(
plan["native_fulltext_candidate_schema"]["required"],
["query_id", "source_agent", "url", "title", "candidate_status", "retrieved_at"],
)
self.assertIn("candidate_only", plan["merge_policy"]["fulltext_candidate_records"])- [ ] Step 3: Run the failing search-plan tests
Run:
node --test packages/qiongli-literature-mcpb/test/tools.test.mjs
uv run python -m unittest tests.test_hybrid_search_router.HybridSearchRouterTests.test_native_fulltext_queries_are_separate_candidate_discovery -vExpected: FAIL because plans do not yet expose native_fulltext_queries or native_fulltext_candidate_schema.
- [ ] Step 4: Implement Node native full-text candidate planning
In packages/qiongli-literature-mcpb/server/search-plan.mjs, append these entries to AGENT_INSTRUCTIONS:
"Use native_fulltext_queries only to discover candidate URLs; do not mark full text as retrieved from search snippets.",
"Write native_fulltext_candidates with candidate_only status until retrieval_manifest.csv verifies readable text."Add these helpers after buildNativeQueries(...):
function buildNativeFulltextQueries(entries, platform, nativeTools, filters, nativeEnabled) {
if (!nativeEnabled || entries.length === 0) {
return [];
}
return nativeTools.flatMap((tool) =>
entries.map((entry) => ({
tool,
platform,
query_id: entry.query_id,
query: fulltextCandidateQuery(entry.query),
source: entry.source,
purpose: "fulltext_candidate_discovery",
candidate_status: "candidate_only",
filters: { ...filters },
expected_candidate_fields: [
"query_id",
"source_agent",
"url",
"title",
"doi",
"access_type",
"snippet",
"candidate_status",
"retrieved_at"
],
provenance_label: `native:${tool}`
}))
);
}
function fulltextCandidateQuery(query) {
return `${query} (PDF OR "full text" OR preprint OR "author manuscript" OR repository OR PMC OR arXiv)`;
}
function nativeFulltextCandidateSchema() {
return {
artifact_type: "qiongli_native_fulltext_candidate_schema",
required: ["query_id", "source_agent", "url", "title", "candidate_status", "retrieved_at"],
optional: ["doi", "access_type", "snippet", "license", "version_label"],
status_values: ["candidate_only"],
evidence_rule: "Search snippets and URLs do not prove retrieved full text. Upgrade only through retrieval_manifest.csv."
};
}Change executionSequence(providerQueries, nativeQueries) to accept nativeFulltextQueries:
function executionSequence(providerQueries, nativeQueries, nativeFulltextQueries) {Add this block after the existing native-search block:
if (nativeFulltextQueries.length > 0) {
sequence.push({
actor: "agent",
action: "execute platform-native full-text candidate search",
queries: "native_fulltext_queries"
});
}Change the merge step inputs to:
inputs: ["provider_queries", "native_search_queries", "native_fulltext_candidates", "user_corpus"]In mergePolicy(), add:
fulltext_candidate_records: "Keep native full-text search outputs as candidate_only until retrieval_manifest.csv verifies readable text.",In buildHybridSearchPlan(...), build the new query array next to nativeQueries:
const nativeFulltextQueries = buildNativeFulltextQueries(
entries,
platform,
tools,
filters,
["hybrid_search", "native_only"].includes(mode)
);Add these fields to the returned plan:
native_fulltext_queries: nativeFulltextQueries,
native_fulltext_candidate_schema: nativeFulltextCandidateSchema(),And pass the new array to the sequence builder:
execution_sequence: executionSequence(providerQueries, nativeQueries, nativeFulltextQueries),- [ ] Step 5: Implement Python native full-text candidate planning
In packages/python-qiongli/src/qiongli/bridges/hybrid_search_router.py, append these strings to AGENT_INSTRUCTIONS:
"Use native_fulltext_queries only to discover candidate URLs; do not mark full text as retrieved from search snippets.",
"Write native_fulltext_candidates with candidate_only status until retrieval_manifest.csv verifies readable text.",Add these helpers after _native_search_queries(...):
def _native_fulltext_queries(
query_entries: list[dict[str, str]],
platform: str,
native_search_tools: list[str],
filters: dict[str, Any],
) -> list[dict[str, Any]]:
return [
{
"tool": tool,
"platform": platform,
"query_id": entry["query_id"],
"query": _fulltext_candidate_query(entry["query"]),
"source": entry["source"],
"purpose": "fulltext_candidate_discovery",
"candidate_status": "candidate_only",
"filters": dict(filters),
"expected_candidate_fields": [
"query_id",
"source_agent",
"url",
"title",
"doi",
"access_type",
"snippet",
"candidate_status",
"retrieved_at",
],
"provenance_label": f"native:{tool}",
}
for tool in native_search_tools
for entry in query_entries
]
def _fulltext_candidate_query(query: str) -> str:
return f'{query} (PDF OR "full text" OR preprint OR "author manuscript" OR repository OR PMC OR arXiv)'
def _native_fulltext_candidate_schema() -> dict[str, Any]:
return {
"artifact_type": "qiongli_native_fulltext_candidate_schema",
"required": ["query_id", "source_agent", "url", "title", "candidate_status", "retrieved_at"],
"optional": ["doi", "access_type", "snippet", "license", "version_label"],
"status_values": ["candidate_only"],
"evidence_rule": "Search snippets and URLs do not prove retrieved full text. Upgrade only through retrieval_manifest.csv.",
}In build_hybrid_search_plan(...), build the new query array after native_search_queries:
native_fulltext_queries = (
_native_fulltext_queries(query_entries, platform, native_search_tools, filters)
if search_execution_mode in {"hybrid_search", "native_only"}
else []
)Add these fields to the returned plan:
"native_fulltext_queries": native_fulltext_queries,
"native_fulltext_candidate_schema": _native_fulltext_candidate_schema(),Change the execution-sequence call to:
"execution_sequence": _execution_sequence(provider_queries, native_search_queries, native_fulltext_queries),Change _execution_sequence(...) to accept the new argument:
def _execution_sequence(
provider_queries: list[dict[str, Any]],
native_search_queries: list[dict[str, Any]],
native_fulltext_queries: list[dict[str, Any]],
) -> list[dict[str, Any]]:Add this block after the native-search block:
if native_fulltext_queries:
sequence.append(
{
"actor": "agent",
"action": "execute platform-native full-text candidate search",
"queries": "native_fulltext_queries",
}
)Change the merge step inputs to:
"inputs": ["provider_queries", "native_search_queries", "native_fulltext_candidates", "user_corpus"],Add this merge policy field:
"fulltext_candidate_records": "Keep native full-text search outputs as candidate_only until retrieval_manifest.csv verifies readable text.",- [ ] Step 6: Run search-plan tests
Run:
node --test packages/qiongli-literature-mcpb/test/tools.test.mjs
uv run python -m unittest tests.test_hybrid_search_router -vExpected: PASS.
- [ ] Step 7: Write failing Zotero companion attachment tests
In packages/qiongli-zotero-companion/test/bridge.test.mjs, update the import list:
normalizeAttachments,Append these tests after toCompactItem returns no local file paths:
test("toCompactItem exposes attachment summaries without local paths by default", () => {
const compact = toCompactItem({
key: "ABC123",
title: "Local Paper",
DOI: "10.1000/local",
attachments: [
{
key: "ATT123",
parent_item_key: "ABC123",
title: "Local Paper PDF",
filename: "local-paper.pdf",
mime_type: "application/pdf",
link_mode: "imported_file",
path: "/zotero-fixture/storage/ATT123/local-paper.pdf",
url: "",
local_file_available: true
}
]
});
assert.equal(compact.attachments.length, 1);
assert.equal(compact.attachments[0].attachment_key, "ATT123");
assert.equal(compact.attachments[0].filename, "local-paper.pdf");
assert.equal(compact.attachments[0].mime_type, "application/pdf");
assert.equal(compact.attachments[0].local_file_available, true);
assert.equal(compact.attachments[0].select_uri, "zotero://select/library/items/ATT123");
assert.equal(Object.hasOwn(compact.attachments[0], "path"), false);
});
test("toCompactItem exposes attachment paths only when explicitly requested", () => {
const compact = toCompactItem(
{
key: "ABC123",
attachments: [
{
key: "ATT123",
filename: "local-paper.pdf",
mime_type: "application/pdf",
path: "/zotero-fixture/storage/ATT123/local-paper.pdf"
}
]
},
{ include_attachment_paths: true }
);
assert.equal(compact.attachments[0].path, "/zotero-fixture/storage/ATT123/local-paper.pdf");
});
test("normalizeAttachments keeps only structured attachment metadata", () => {
assert.deepEqual(
normalizeAttachments([
{ key: "A", filename: "a.pdf", mime_type: "application/pdf" },
{ filename: "missing-key.pdf" },
null
]),
[
{
attachment_key: "A",
title: "",
filename: "a.pdf",
mime_type: "application/pdf",
link_mode: "",
url: "",
select_uri: "zotero://select/library/items/A",
local_file_available: false
}
]
);
});In the bootstrap startup registers endpoints from Zotero 8 and 9 global object test, add attachment methods to libraryItem:
getAttachments: () => [99],Add a fake attachment lookup to Zotero.Items:
getAsync: async (id) => ({
key: id === 99 ? "ATT123" : "UNKNOWN",
itemType: "attachment",
getField: (field) => ({
title: "Platform Governance PDF",
filename: "platform-governance.pdf",
contentType: "application/pdf",
url: ""
})[field] ?? "",
getFilePath: () => "/zotero-fixture/storage/ATT123/platform-governance.pdf",
attachmentLinkMode: "imported_file"
})Add assertions after the /qiongli/search response checks:
assert.equal(response.body.results[0].attachments.length, 1);
assert.equal(response.body.results[0].attachments[0].attachment_key, "ATT123");
assert.equal(response.body.results[0].attachments[0].mime_type, "application/pdf");
assert.equal(response.body.results[0].attachments[0].local_file_available, true);
assert.equal(Object.hasOwn(response.body.results[0].attachments[0], "path"), false);- [ ] Step 8: Run the failing Zotero companion tests
Run:
node --test packages/qiongli-zotero-companion/test/bridge.test.mjsExpected: FAIL because toCompactItem does not accept attachment options, normalizeAttachments is not exported, and bootstrap does not collect attachments.
- [ ] Step 9: Implement Zotero companion attachment summaries
In packages/qiongli-zotero-companion/chrome/content/qiongli-bridge.js, update searchLocalItems(...) so the query controls path exposure:
.map((item) => toCompactItem(item, query));Change the exported compact mapper to:
export function toCompactItem(item = {}, options = {}) {
return {
item_key: item.key ?? "",
title: item.title ?? "",
doi: normalizeDoi(item.DOI ?? item.doi),
year: normalizeYear(item.date ?? item.year),
item_type: item.itemType ?? "",
select_uri: item.key ? `zotero://select/library/items/${item.key}` : "",
tags: Array.isArray(item.tags) ? item.tags.map((tag) => tag.tag ?? tag).filter(Boolean) : [],
collections: Array.isArray(item.collections) ? item.collections : [],
attachments: normalizeAttachments(item.attachments, options)
};
}Add this exported helper before normalizeDoi(...):
export function normalizeAttachments(value = [], options = {}) {
const includePaths = options.include_attachment_paths === true || options.includeAttachmentPaths === true;
const attachments = Array.isArray(value) ? value : [];
return attachments
.map((attachment) => {
if (!attachment || typeof attachment !== "object") {
return null;
}
const key = String(attachment.attachment_key ?? attachment.key ?? "").trim();
if (!key) {
return null;
}
const normalized = {
attachment_key: key,
title: String(attachment.title ?? "").trim(),
filename: String(attachment.filename ?? "").trim(),
mime_type: String(attachment.mime_type ?? attachment.contentType ?? "").trim(),
link_mode: String(attachment.link_mode ?? attachment.linkMode ?? "").trim(),
url: String(attachment.url ?? "").trim(),
select_uri: `zotero://select/library/items/${key}`,
local_file_available: Boolean(attachment.local_file_available ?? attachment.path)
};
if (includePaths && attachment.path) {
normalized.path = String(attachment.path);
}
return normalized;
})
.filter(Boolean);
}In packages/qiongli-zotero-companion/bootstrap.js, change listItems() to collect plain objects asynchronously:
const regularItems = asArray(rawItems).filter((item) => typeof item.isRegularItem !== "function" || item.isRegularItem());
const plainItems = [];
for (const item of regularItems) {
plainItems.push(await itemToPlainObject(Zotero, item));
}
return plainItems;Change the /qiongli/search response mapper to pass the query options:
results: items.filter((item) => itemMatchesQuery(item, query)).map((item) => toCompactItem(item, query))Change itemToPlainObject(item) to:
async function itemToPlainObject(Zotero, item) {
return {
key: item.key,
itemType: item.itemType,
title: getField(item, "title"),
DOI: getField(item, "DOI"),
url: getField(item, "url"),
abstractNote: getField(item, "abstractNote"),
publicationTitle: getField(item, "publicationTitle"),
date: getField(item, "date"),
tags: typeof item.getTags === "function" ? item.getTags() : [],
collections: typeof item.getCollections === "function" ? item.getCollections() : [],
attachments: await attachmentPlainObjects(Zotero, item)
};
}Add this helper after itemToPlainObject(...):
async function attachmentPlainObjects(Zotero, item) {
const attachmentIds = typeof item.getAttachments === "function" ? asArray(item.getAttachments()) : [];
const attachments = [];
for (const id of attachmentIds) {
const attachment = typeof Zotero.Items?.getAsync === "function"
? await Zotero.Items.getAsync(id)
: typeof Zotero.Items?.get === "function"
? Zotero.Items.get(id)
: null;
if (!attachment) {
continue;
}
const path = typeof attachment.getFilePath === "function" ? attachment.getFilePath() : "";
attachments.push({
key: attachment.key,
parent_item_key: item.key,
title: getField(attachment, "title"),
filename: getField(attachment, "filename"),
mime_type: getField(attachment, "contentType") || attachment.attachmentContentType || "",
link_mode: String(attachment.attachmentLinkMode ?? ""),
path,
url: getField(attachment, "url"),
local_file_available: Boolean(path)
});
}
return attachments;
}Change toCompactItem(item) in bootstrap.js to match the source module:
function toCompactItem(item, options) {
return {
item_key: item.key ?? "",
title: item.title ?? "",
doi: normalizeDoi(item.DOI ?? item.doi),
year: parseYear(item.date ?? item.year),
item_type: item.itemType ?? "",
select_uri: item.key ? `zotero://select/library/items/${item.key}` : "",
tags: Array.isArray(item.tags) ? item.tags.map((tag) => tag.tag ?? tag).filter(Boolean) : [],
collections: Array.isArray(item.collections) ? item.collections : [],
attachments: normalizeAttachments(item.attachments, options)
};
}Add the non-exported normalizeAttachments(...) helper to bootstrap.js before parseJson(...), using the same body as the source helper but without the export keyword.
- [ ] Step 10: Run Zotero companion tests
Run:
node --test packages/qiongli-zotero-companion/test/bridge.test.mjsExpected: PASS.
- [ ] Step 11: Write failing MCPB Zotero normalization tests
In packages/qiongli-literature-mcpb/test/zotero.test.mjs, append:
test("normalizeZoteroSourceResults maps attachment summaries to full-text hints", () => {
const results = normalizeZoteroSourceResults([
{
item_key: "ABC123",
title: "Local Paper",
doi: "10.1000/local",
year: 2024,
abstract: "Local abstract",
attachments: [
{
attachment_key: "ATT123",
filename: "local-paper.pdf",
mime_type: "application/pdf",
url: "",
select_uri: "zotero://select/library/items/ATT123",
local_file_available: true
}
]
}
]);
assert.equal(results[0].provider, "zotero");
assert.equal(results[0].fulltext_status, "retrieved_zotero");
assert.equal(results[0].evidence_limit, "full_text");
assert.equal(results[0].access_url, "zotero://select/library/items/ATT123");
assert.equal(results[0].zotero.attachments.length, 1);
assert.equal(results[0].zotero.attachments[0].attachment_key, "ATT123");
});
test("annotateLocalZoteroMatches includes match confidence and attachment status", () => {
const annotated = annotateLocalZoteroMatches({
externalResults: [
{ title: "External Paper", doi: "10.1000/match", year: 2024, provider: "openalex" }
],
zoteroResults: [
{
title: "Local Paper",
doi: "10.1000/match",
year: 2024,
provider: "zotero",
fulltext_status: "retrieved_zotero",
zotero: {
item_key: "ABC123",
select_uri: "zotero://select/library/items/ABC123",
attachments: [{ attachment_key: "ATT123", mime_type: "application/pdf" }]
}
}
]
});
assert.equal(annotated[0].local_zotero_match.item_key, "ABC123");
assert.equal(annotated[0].local_zotero_match.match_basis, "doi");
assert.equal(annotated[0].local_zotero_match.match_confidence, 1);
assert.equal(annotated[0].local_zotero_match.fulltext_status, "retrieved_zotero");
assert.equal(annotated[0].local_zotero_match.attachments.length, 1);
});- [ ] Step 12: Run failing MCPB Zotero tests
Run:
node --test packages/qiongli-literature-mcpb/test/zotero.test.mjsExpected: FAIL because Zotero source normalization drops attachment and full-text status fields.
- [ ] Step 13: Implement MCPB Zotero attachment normalization
In packages/qiongli-literature-mcpb/server/zotero/search-source.mjs, add these helpers before normalizeZoteroSourceResults(...):
function normalizeZoteroAttachments(value = []) {
const attachments = Array.isArray(value) ? value : [];
return attachments
.map((attachment) => {
if (!attachment || typeof attachment !== "object") {
return null;
}
const attachmentKey = cleanString(attachment.attachment_key ?? attachment.key);
if (!attachmentKey) {
return null;
}
return {
attachment_key: attachmentKey,
title: cleanString(attachment.title) ?? "",
filename: cleanString(attachment.filename) ?? "",
mime_type: cleanString(attachment.mime_type ?? attachment.contentType) ?? "",
link_mode: cleanString(attachment.link_mode ?? attachment.linkMode) ?? "",
url: cleanString(attachment.url),
select_uri: cleanString(attachment.select_uri) ?? `zotero://select/library/items/${attachmentKey}`,
local_file_available: Boolean(attachment.local_file_available)
};
})
.filter(Boolean);
}
function bestFulltextAttachment(attachments = []) {
return attachments.find((attachment) => attachment.mime_type === "application/pdf")
?? attachments.find((attachment) => attachment.local_file_available)
?? null;
}
function zoteroFulltextStatus(attachment) {
if (!attachment) {
return "metadata_only";
}
return attachment.local_file_available ? "retrieved_zotero" : "not_retrieved:zotero_attachment_candidate";
}
function zoteroEvidenceLimit(item, attachment) {
if (attachment?.local_file_available) {
return "full_text";
}
return cleanString(item?.abstract ?? item?.abstractNote) ? "abstract_only" : "metadata_only";
}Inside normalizeZoteroSourceResults(...), before returning each mapped item, compute:
const attachments = normalizeZoteroAttachments(item.attachments);
const fulltextAttachment = bestFulltextAttachment(attachments);Add these fields to the returned object:
access_url: fulltextAttachment?.url ?? fulltextAttachment?.select_uri ?? cleanString(item.url ?? item.URL),
fulltext_status: zoteroFulltextStatus(fulltextAttachment),
evidence_limit: zoteroEvidenceLimit(item, fulltextAttachment),Add the attachment summary to the nested zotero object:
attachments,
fulltext_status: zoteroFulltextStatus(fulltextAttachment),
fulltext_attachment_key: fulltextAttachment?.attachment_key ?? null,In annotateLocalZoteroMatches(...), extend local_zotero_match to:
local_zotero_match: {
item_key: match.zotero?.item_key ?? match.source_id ?? "",
match_basis: doiMatch ? "doi" : "title_year",
match_confidence: doiMatch ? 1 : 0.75,
select_uri: match.zotero?.select_uri ?? "",
fulltext_status: match.zotero?.fulltext_status ?? match.fulltext_status ?? "metadata_only",
attachments: match.zotero?.attachments ?? []
}- [ ] Step 14: Update Zotero companion README
In packages/qiongli-zotero-companion/README.md, add this section after the endpoint list:
## Attachment Metadata
`POST /qiongli/search` returns compact Zotero item records plus attachment summaries when local attachments exist. Attachment summaries include the Zotero attachment key, file name, MIME type, link mode, Zotero select URI, URL, and `local_file_available`.
Raw local file paths are omitted by default. Send `include_attachment_paths: true` only when a local resolver explicitly needs paths for controlled full-text retrieval. Qiongli treats Zotero attachment data as local verification evidence; provider or native search URLs remain `candidate_only` until `retrieval_manifest.csv` records a retrieved or unresolved status.- [ ] Step 15: Run full native/Zotero focused tests
Run:
node --test packages/qiongli-literature-mcpb/test/tools.test.mjs packages/qiongli-literature-mcpb/test/zotero.test.mjs
node --test packages/qiongli-zotero-companion/test/bridge.test.mjs
uv run python -m unittest tests.test_hybrid_search_router -vExpected: PASS.
- [ ] Step 16: Commit
git add \
packages/qiongli-literature-mcpb/server/search-plan.mjs \
packages/python-qiongli/src/qiongli/bridges/hybrid_search_router.py \
packages/qiongli-literature-mcpb/test/tools.test.mjs \
tests/test_hybrid_search_router.py \
packages/qiongli-zotero-companion/chrome/content/qiongli-bridge.js \
packages/qiongli-zotero-companion/bootstrap.js \
packages/qiongli-zotero-companion/test/bridge.test.mjs \
packages/qiongli-zotero-companion/README.md \
packages/qiongli-literature-mcpb/server/zotero/search-source.mjs \
packages/qiongli-literature-mcpb/test/zotero.test.mjs
git commit -m "feat(literature): verify native full text candidates with zotero"Task 12: Document Coverage and Completeness Semantics
Files:
Modify:
docs/advanced/mcp-providers-setup.mdModify:
docs/advanced/rigorous-literature-search.mdModify:
content/workflow/references/stage-B-literature.mdModify:
content/templates/search-diagnostics.mdModify:
tests/test_literature_contract.pyModify:
tests/test_mcp_provider_docs.py[ ] Step 1: Add contract tests for coverage semantics
Append this test to tests/test_literature_contract.py:
def test_stage_b_distinguishes_discovery_coverage_from_fulltext_access(self) -> None:
content = (LAYOUT.references / "stage-B-literature.md").read_text(encoding="utf-8")
self.assertIn("discovery coverage", content)
self.assertIn("full-text access coverage", content)
self.assertIn("retrieval_manifest.csv", content)
self.assertIn("native_fulltext_queries", content)
self.assertIn("Zotero attachment", content)
self.assertIn("metadata_only", content)Append this test to tests/test_mcp_provider_docs.py:
def test_rigorous_search_docs_define_non_exhaustive_coverage_metrics(self) -> None:
content = (REPO_ROOT / "docs" / "advanced" / "rigorous-literature-search.md").read_text(
encoding="utf-8"
)
for phrase in (
"No provider can prove absolute completeness",
"known-item recall",
"duplicate saturation",
"full-text access coverage",
"native_fulltext_queries",
"Zotero attachment verification",
"evidence_limit",
):
self.assertIn(phrase, content)- [ ] Step 2: Run the failing docs tests
Run:
uv run python -m unittest tests.test_literature_contract.LiteratureContractTests.test_stage_b_distinguishes_discovery_coverage_from_fulltext_access tests.test_mcp_provider_docs.MCPProviderDocsTests.test_rigorous_search_docs_define_non_exhaustive_coverage_metrics -vExpected: FAIL until docs are updated.
- [ ] Step 3: Update Stage B reference
In content/workflow/references/stage-B-literature.md, under "Search diagnostics gate", add:
### Coverage and access semantics
Discovery coverage and full-text access coverage are separate:
- `discovery coverage`: how broad and reproducible the metadata search was across providers, query variants, years, venues, document types, citation snowballing, and known-item recall.
- `full-text access coverage`: how many sought reports have a controlled `retrieval_manifest.csv` status such as `retrieved_oa`, `retrieved_preprint`, `abstract_only`, or `not_retrieved:<reason>`.
- `native_fulltext_queries`: platform-native LLM search queries that the active agent may execute to discover PDF, PMC, arXiv, repository, author-manuscript, or publisher full-text candidates. These outputs stay `candidate_only` until retrieval status is recorded.
- `Zotero attachment verification`: local Zotero attachment metadata that can distinguish citation-only Zotero matches from records with a local or linked PDF attachment.
- `evidence_limit`: what the workflow is allowed to claim from a record: `full_text`, `abstract_only`, `metadata_only`, or `unavailable`.
No search provider, native LLM search tool, or local Zotero library proves absolute completeness. Review-grade claims require a reproducible search log, deduplication, known-item recall checks, citation snowballing where appropriate, Zotero attachment verification where available, and retrieval status for every included or sought report.- [ ] Step 4: Update rigorous search docs
In docs/advanced/rigorous-literature-search.md, add a section named ## Coverage Is Audited, Not Guaranteed:
## Coverage Is Audited, Not Guaranteed
No provider can prove absolute completeness. OpenAlex, Semantic Scholar, Crossref, PubMed, and arXiv provide metadata, abstracts, identifiers, citation/reference links, and sometimes open-access or PDF candidates. They do not guarantee that every relevant paper exists in the provider index, that every full text is accessible, or that the returned full text is the version that should be cited.
Platform-native LLM search can improve recall for full-text entry points through `native_fulltext_queries`, especially for PDFs, author manuscripts, PMC pages, arXiv versions, repositories, publisher landing pages, and institutional copies. Treat these as `candidate_only` records with source URL and retrieval time. A native search snippet is not full-text evidence by itself.
Zotero attachment verification is the local-library check. A Zotero item match by DOI or title-year proves that the reference exists locally; a Zotero PDF attachment summary proves that a local or linked full-text candidate exists. The retrieval manifest still records whether that attachment was readable and which version was used.
Use these coverage checks before claiming review-grade search:
- `provider coverage`: at least two productive scholarly providers for broad reviews, unless the protocol justifies a narrower source.
- `known-item recall`: seed papers expected by the reviewer or protocol must be found by at least one query/provider path.
- `query-block coverage`: each required concept block has nonzero hits or an explicit zero-hit explanation.
- `duplicate saturation`: additional providers or query variants mostly return already-seen records after deduplication.
- `snowball coverage`: backward and forward citation checks are logged when the review protocol requires them.
- `native full-text candidate coverage`: `native_fulltext_queries` were executed when platform-native search was available, and their candidate URLs were logged with source-agent provenance.
- `Zotero attachment coverage`: local Zotero matches record match basis, match confidence, and attachment status so citation-only matches are not counted as full-text retrieval.
- `full-text access coverage`: every sought report has a `retrieval_manifest.csv` status and every extracted claim records `evidence_limit`.
Treat full text as confirmed only after retrieval:
- `full_text`: a readable full-text version was retrieved or verified.
- `abstract_only`: only an abstract or structured abstract was available.
- `metadata_only`: only metadata fields were available.
- `unavailable`: no reliable metadata or text source was available.- [ ] Step 5: Update search diagnostics template
In content/templates/search-diagnostics.md, add fields for:
## Coverage Semantics
- discovery_coverage_basis:
- native_fulltext_candidate_coverage:
- zotero_attachment_coverage:
- known_item_recall:
- duplicate_saturation:
- full_text_access_coverage:
- evidence_limit_distribution:
- cannot_claim_absolute_completeness: true- [ ] Step 6: Run docs tests
Run:
uv run python -m unittest tests.test_literature_contract tests.test_mcp_provider_docs -vExpected: PASS.
- [ ] Step 7: Commit
git add \
docs/advanced/mcp-providers-setup.md \
docs/advanced/rigorous-literature-search.md \
content/workflow/references/stage-B-literature.md \
content/templates/search-diagnostics.md \
tests/test_literature_contract.py \
tests/test_mcp_provider_docs.py
git commit -m "docs(literature): define search coverage and full text limits"Task 13: Run Final Verification
Files:
Test-only execution.
[ ] Step 1: Run focused Python tests
Run:
uv run python -m unittest \
tests.test_mcp_connectors \
tests.test_mcp_tool_handlers \
tests.test_mcp_literature_tools \
tests.test_literature_search \
tests.test_hybrid_search_router \
tests.test_mcp_cli \
tests.test_mcp_tool_surface_parity \
tests.test_literature_contract \
tests.test_mcp_provider_docs \
tests.test_cli_setup_docs \
-vExpected: PASS.
- [ ] Step 2: Run Node MCPB tests
Run:
node --test packages/qiongli-literature-mcpb/test/tools.test.mjs packages/qiongli-literature-mcpb/test/config.test.mjs packages/qiongli-literature-mcpb/test/providers.test.mjs packages/qiongli-literature-mcpb/test/zotero.test.mjs
node --test packages/qiongli-zotero-companion/test/bridge.test.mjsExpected: PASS.
- [ ] Step 3: Run stale wording scan
Run:
rg -n "Full runtime commands, including `qiongli_literature_search`|需要 `qiongli_literature_search`、`qiongli_task_plan`|OpenAlex and Semantic Scholar configuration|qiongli_collect_evidence.*provider readiness|provider readiness.*qiongli_collect_evidence" README.md README_CN.md docs content packages tests -SExpected: no matches.
- [ ] Step 4: Run boundary and secret scan
Run:
rg -n "openalex-secret-key|semantic.*secret|api[_-]?key.*[A-Za-z0-9]{20,}|/(Users|private/tmp)/" README.md README_CN.md docs content packages tests -SExpected: no real secrets or machine-local paths. Test fixture strings such as openalex-secret-key may appear only inside tests that assert secrets are not echoed.
- [ ] Step 5: Commit verification-only updates if any were needed
If verification required small test/doc corrections, commit them:
git add <corrected-files>
git commit -m "test(mcp): lock provider status parity regressions"If no corrections were needed, do not create an empty commit.
Release Notes Draft
### Fixed
- Clarified that `qiongli_collect_evidence` reports external command-adapter readiness, not built-in OpenAlex/Semantic Scholar/Crossref/PubMed/arXiv provider configuration.
- Added `qiongli_search_plan` to Python full MCP literature tool inventory output.
- Updated MCP and install docs so bundled Node literature MCP and full CLI MCP both advertise provider status, search planning, literature search, and evidence export accurately.
- Added native full-text candidate planning so active agents can use platform-native LLM search for auditable PDF/full-text candidate discovery without treating snippets as retrieved evidence.
- Added Zotero attachment verification planning so local Zotero records can distinguish citation-only matches from verified local PDF/full-text attachment matches.
### Tests
- Added regression coverage for direct `qiongli_collect_evidence` academic-provider calls with configured provider credentials.
- Added cross-surface parity checks for Python full MCP and Node MCPB literature-provider tools.
- Added native full-text query planning and Zotero attachment normalization coverage.Self-Review
- Spec coverage: The plan covers the root OpenAlex misdiagnosis, Python MCP tool inventory drift, Node/Python surface parity, install docs, workflow guidance, generated guidance, search default widening, full-text candidate preservation, platform-native LLM full-text candidate search, Zotero attachment verification, coverage semantics, and repo-boundary checks.
- Placeholder scan: No task relies on unspecified implementation. Each code-changing task includes concrete snippets and exact verification commands.
- Type consistency: New diagnostics use
MCPEvidence.dataasdict[str, Any], existingprovider_config_summary(...)return type asdict[str, str], and existing MCP result shapes throughstructuredContent.
Plan complete and saved to docs/superpowers/plans/2026-07-03-mcp-provider-status-parity.md. Two execution options:
1. Subagent-Driven (recommended) - dispatch a fresh subagent per task, review between tasks, fast iteration.
2. Inline Execution - execute tasks in this session using executing-plans, batch execution with checkpoints.