Zotero Source And Crossref Verification 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: Implement opt-in Zotero source search for qiongli_literature_search and safer Zotero writes with Crossref DOI verification, conservative enrichment, and review-state tags.
Architecture: Keep the current local-first Zotero bridge. Add small MCPB-side modules for Zotero source search, Crossref verification, and review tag policy; wire them into server/index.mjs and server/zotero/tools.mjs without making Zotero a default discovery provider. Reuse the existing Crossref provider and Zotero companion endpoints.
Tech Stack: Node 18 ESM, node:test, Python unittest, zero runtime dependencies for the MCPB, existing Zotero companion JavaScript.
File Structure
- Modify:
packages/qiongli-literature-mcpb/server/index.mjs- Add tool schema fields for
include_zotero,zotero_limit,zotero_tag, andzotero_collection_path. - Call Zotero source search only when
include_zotero: true. - Add Zotero provider accounting and diagnostics without changing default provider behavior.
- Add tool schema fields for
- Modify:
packages/qiongli-literature-mcpb/server/diagnostics.mjs- Preserve
source_typein provider and query diagnostics when a provider response supplies it.
- Preserve
- Modify:
packages/qiongli-literature-mcpb/server/normalize.mjs- Preserve optional fields used by local Zotero results:
source_type,zotero,local_zotero_match,verification, andreview_status.
- Preserve optional fields used by local Zotero results:
- Create:
packages/qiongli-literature-mcpb/server/zotero/search-source.mjs- Convert literature search intent into companion search payloads.
- Normalize compact Zotero companion results to Qiongli result records.
- Annotate external results with
local_zotero_match. - Convert companion failures into non-fatal warnings and provider diagnostics.
- Create:
packages/qiongli-literature-mcpb/server/zotero/crossref-verifier.mjs- Use existing
searchCrossrefDOI singleton lookup for DOI-bearing records. - Fill blank metadata fields from Crossref.
- Report
verified,conflict,not_found,unavailable, orskipped.
- Use existing
- Create:
packages/qiongli-literature-mcpb/server/zotero/review-tags.mjs- Generate default review tags and Crossref status tags.
- Merge user-supplied tags without duplicates.
- Resolve default review collection path from topic when supplied.
- Modify:
packages/qiongli-literature-mcpb/server/zotero/records.mjs- Preserve
verification,review_status, and enriched tags through normalization. - Map review tags into Zotero item tags.
- Preserve
- Modify:
packages/qiongli-literature-mcpb/server/zotero/tools.mjs- Run Crossref verification before mapping Zotero items.
- Add review tags by default.
- Return per-record verification traces and review status.
- Modify:
packages/qiongli-literature-mcpb/server/zotero/exporters.mjs- Include review tags in CSL-JSON, RIS, and BibTeX exports.
- Add verification counts to
zotero-import-report.md.
- Modify:
packages/qiongli-literature-mcpb/manifest.json- Declare new tool schema fields and user config for default review tags, review collection, and Crossref verification.
- Modify:
packages/qiongli-literature-mcpb/README.md- Document explicit Zotero source search and Crossref verification.
- Modify:
docs/advanced/mcp-zotero-integration.md- Document default-off
include_zotero, review tags, and Crossref DOI metadata limits.
- Document default-off
- Modify:
docs/zh/advanced/mcp-zotero-integration.md- Mirror the English docs.
- Modify:
content/skills/B_literature/reference-manager-bridge.md- Update skill guidance for optional local Zotero source search and candidate-review imports.
- Modify:
packages/qiongli-literature-mcpb/test/tools.test.mjs- Add schema, search integration, and routing tests.
- Modify:
packages/qiongli-literature-mcpb/test/zotero.test.mjs- Add Zotero source helper, Crossref verifier, review tag, and upsert tests.
- Modify:
packages/qiongli-literature-mcpb/test/providers.test.mjs- Add normalizer preservation tests beside the existing normalize tests.
- Modify:
packages/qiongli-zotero-companion/test/bridge.test.mjs- Add a narrow assertion that companion upsert preserves incoming review tags.
- Modify:
tests/test_literature_mcpb_artifact.py- Assert new MCPB packaging expectations.
Task 1: Baseline And Schema Tests
Files:
Modify:
packages/qiongli-literature-mcpb/test/tools.test.mjsModify:
tests/test_literature_mcpb_artifact.py[ ] Step 1: Run baseline tests
Run:
npm --prefix packages/qiongli-literature-mcpb test
npm --prefix packages/qiongli-zotero-companion test
python3 -m unittest tests.test_literature_mcpb_artifact tests.test_zotero_companion_artifactExpected: all tests pass before the new feature work starts.
- [ ] Step 2: Write failing tool schema tests
Append these assertions to the existing zotero tool schemas expose dry-run and import fallback controls test in packages/qiongli-literature-mcpb/test/tools.test.mjs:
const searchTool = TOOL_DECLARATIONS.find((tool) => tool.name === "qiongli_literature_search");
assert.equal(searchTool.inputSchema.properties.include_zotero.type, "boolean");
assert.equal(searchTool.inputSchema.properties.zotero_limit.type, "number");
assert.equal(searchTool.inputSchema.properties.zotero_tag.type, "string");
assert.equal(searchTool.inputSchema.properties.zotero_collection_path.type, "string");
assert.equal(upsertTool.inputSchema.properties.verify_crossref.type, "boolean");
assert.deepEqual(upsertTool.inputSchema.properties.crossref_enrichment.enum, ["fill_blank", "off"]);
assert.ok(upsertTool.inputSchema.properties.review_tags);
assert.ok(upsertTool.inputSchema.properties.review_collection_path);Add this assertion to test_literature_mcpb_manifest_declares_sensitive_config in tests/test_literature_mcpb_artifact.py:
self.assertIn("zotero_default_review_tags", manifest["user_config"])
self.assertIn("zotero_default_review_collection_path", manifest["user_config"])
self.assertIn("zotero_crossref_verification_enabled", manifest["user_config"])- [ ] Step 3: Run tests to verify they fail
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/tools.test.mjs
python3 -m unittest tests.test_literature_mcpb_artifactExpected: FAIL because schema fields and manifest config do not exist yet.
- [ ] Step 4: Add minimal schema and manifest entries
In packages/qiongli-literature-mcpb/server/index.mjs, add these properties to the qiongli_literature_search schema:
include_zotero: {
type: "boolean",
default: false
},
zotero_limit: {
type: "number"
},
zotero_tag: {
type: "string"
},
zotero_collection_path: {
type: "string"
},Add these properties to the qiongli_zotero_upsert_references schema:
verify_crossref: {
type: "boolean",
default: true
},
crossref_enrichment: {
type: "string",
enum: ["fill_blank", "off"],
default: "fill_blank"
},
review_tags: {
type: "array",
items: {
type: "string"
}
},
review_collection_path: {
type: "string"
},In packages/qiongli-literature-mcpb/manifest.json, add user config fields:
"zotero_default_review_tags": {
"type": "string",
"title": "Default Zotero review tags",
"description": "Comma-separated Qiongli tags added to Zotero imports that still need review.",
"default": "qiongli:imported,qiongli:needs-review"
},
"zotero_default_review_collection_path": {
"type": "string",
"title": "Default Zotero review collection path",
"description": "Optional collection path for newly imported references that need review.",
"default": ""
},
"zotero_crossref_verification_enabled": {
"type": "boolean",
"title": "Verify Zotero imports with Crossref DOI metadata",
"description": "When enabled, DOI-bearing Zotero imports use Crossref registry metadata to fill blank fields and report conflicts.",
"default": true
}Add these env mappings under server.mcp_config.env:
"QIONGLI_ZOTERO_DEFAULT_REVIEW_TAGS": "${user_config.zotero_default_review_tags}",
"QIONGLI_ZOTERO_DEFAULT_REVIEW_COLLECTION_PATH": "${user_config.zotero_default_review_collection_path}",
"QIONGLI_ZOTERO_CROSSREF_VERIFICATION_ENABLED": "${user_config.zotero_crossref_verification_enabled}"- [ ] Step 5: Verify schema tests pass
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/tools.test.mjs
python3 -m unittest tests.test_literature_mcpb_artifactExpected: PASS for the new schema assertions.
- [ ] Step 6: Commit schema work
Run:
git add packages/qiongli-literature-mcpb/server/index.mjs packages/qiongli-literature-mcpb/manifest.json packages/qiongli-literature-mcpb/test/tools.test.mjs tests/test_literature_mcpb_artifact.py
git commit -m "feat(zotero): expose source and verification options"Task 2: Preserve Zotero Metadata In Normalized Results
Files:
Modify:
packages/qiongli-literature-mcpb/server/normalize.mjsModify:
packages/qiongli-literature-mcpb/test/providers.test.mjs[ ] Step 1: Write failing normalizer preservation test
Add this test near the existing normalizeResult tests in packages/qiongli-literature-mcpb/test/providers.test.mjs:
test("normalizeResult preserves local Zotero metadata and verification fields", () => {
const normalized = normalizeResult({
title: "Local Paper",
year: 2024,
doi: "https://doi.org/10.1000/local",
provider: "zotero",
source_id: "ABC123",
source_type: "local_reference_database",
zotero: {
item_key: "ABC123",
select_uri: "zotero://select/library/items/ABC123",
tags: ["qiongli:needs-review"],
collections: ["Qiongli/topic"]
},
local_zotero_match: {
item_key: "ABC123",
match_basis: "doi",
select_uri: "zotero://select/library/items/ABC123"
},
review_status: "needs_review",
verification: {
crossref: {
status: "verified",
doi: "10.1000/local",
filled_fields: ["venue"],
conflicts: []
}
}
});
assert.equal(normalized.source_type, "local_reference_database");
assert.equal(normalized.zotero.item_key, "ABC123");
assert.equal(normalized.local_zotero_match.match_basis, "doi");
assert.equal(normalized.review_status, "needs_review");
assert.equal(normalized.verification.crossref.status, "verified");
});- [ ] Step 2: Run test to verify it fails
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/providers.test.mjsExpected: FAIL because normalizeResult drops these optional fields.
- [ ] Step 3: Preserve optional metadata in
normalize.mjs
Add helper functions after normalizeLinkedRecords:
function clonePlainObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return JSON.parse(JSON.stringify(value));
}
function normalizeSourceType(value) {
return cleanString(value);
}Add these properties at the end of the object returned by normalizeResult:
source_type: normalizeSourceType(record?.source_type),
zotero: clonePlainObject(record?.zotero),
local_zotero_match: clonePlainObject(record?.local_zotero_match),
review_status: cleanString(record?.review_status),
verification: clonePlainObject(record?.verification)- [ ] Step 4: Verify normalizer tests pass
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/providers.test.mjsExpected: PASS.
- [ ] Step 5: Commit normalizer preservation
Run:
git add packages/qiongli-literature-mcpb/server/normalize.mjs packages/qiongli-literature-mcpb/test/providers.test.mjs
git commit -m "feat(mcpb): preserve zotero result metadata"Task 3: Zotero Source Search Helper
Files:
Create:
packages/qiongli-literature-mcpb/server/zotero/search-source.mjsModify:
packages/qiongli-literature-mcpb/test/zotero.test.mjs[ ] Step 1: Write failing helper tests
Import the helpers in packages/qiongli-literature-mcpb/test/zotero.test.mjs:
import {
annotateLocalZoteroMatches,
normalizeZoteroSourceResults,
resolveZoteroSourceOptions,
zoteroSourceSearchPayload
} from "../server/zotero/search-source.mjs";Add these tests:
test("resolveZoteroSourceOptions keeps Zotero disabled unless explicitly requested", () => {
assert.equal(resolveZoteroSourceOptions({}).include, false);
assert.equal(resolveZoteroSourceOptions({ include_zotero: false }).include, false);
assert.equal(resolveZoteroSourceOptions({ include_zotero: true, zotero_limit: 7 }).include, true);
assert.equal(resolveZoteroSourceOptions({ include_zotero: true, zotero_limit: 7 }).limit, 7);
});
test("zoteroSourceSearchPayload maps DOI and topic intents conservatively", () => {
assert.deepEqual(
zoteroSourceSearchPayload({
intent: { doi: "10.1000/example", query: "10.1000/example", exactTitle: false },
input: {}
}),
{ doi: "10.1000/example" }
);
assert.deepEqual(
zoteroSourceSearchPayload({
intent: { doi: null, query: "platform governance", exactTitle: false },
input: { zotero_tag: "project:platform", zotero_collection_path: "Qiongli/platform" }
}),
{ title: "platform governance", tag: "project:platform", collection_path: "Qiongli/platform" }
);
});
test("normalizeZoteroSourceResults maps compact local items to provider results", () => {
const results = normalizeZoteroSourceResults([
{
item_key: "ABC123",
title: "Local Paper",
doi: "10.1000/local",
year: 2024,
item_type: "journalArticle",
select_uri: "zotero://select/library/items/ABC123",
tags: ["qiongli:verified"],
collections: ["Qiongli/topic"]
}
]);
assert.equal(results[0].provider, "zotero");
assert.equal(results[0].source_type, "local_reference_database");
assert.equal(results[0].source_id, "ABC123");
assert.equal(results[0].zotero.item_key, "ABC123");
});
test("annotateLocalZoteroMatches marks external DOI matches without dropping results", () => {
const annotated = annotateLocalZoteroMatches({
externalResults: [
{ title: "External Paper", doi: "10.1000/match", year: 2024, provider: "openalex" },
{ title: "Other Paper", doi: "10.1000/other", year: 2024, provider: "openalex" }
],
zoteroResults: [
{
title: "Local Paper",
doi: "10.1000/match",
year: 2024,
provider: "zotero",
zotero: { item_key: "ABC123", select_uri: "zotero://select/library/items/ABC123" }
}
]
});
assert.equal(annotated[0].local_zotero_match.item_key, "ABC123");
assert.equal(annotated[0].local_zotero_match.match_basis, "doi");
assert.equal(annotated[1].local_zotero_match, undefined);
});- [ ] Step 2: Run test to verify it fails
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/zotero.test.mjsExpected: FAIL because search-source.mjs does not exist.
- [ ] Step 3: Implement
search-source.mjs
Create packages/qiongli-literature-mcpb/server/zotero/search-source.mjs:
import { postCompanionJson } from "./client.mjs";
const DEFAULT_ZOTERO_LIMIT = 25;
const MAX_ZOTERO_LIMIT = 200;
export function resolveZoteroSourceOptions(input = {}, { perProviderLimit = DEFAULT_ZOTERO_LIMIT } = {}) {
const include = input.include_zotero === true;
const requestedLimit = integerOrNull(input.zotero_limit);
const fallbackLimit = Math.max(1, Math.min(perProviderLimit, DEFAULT_ZOTERO_LIMIT));
return {
include,
limit: include ? Math.min(Math.max(requestedLimit ?? fallbackLimit, 1), MAX_ZOTERO_LIMIT) : 0,
tag: cleanString(input.zotero_tag),
collection_path: cleanString(input.zotero_collection_path)
};
}
export function zoteroSourceSearchPayload({ intent = {}, input = {}, sourceOptions = {} } = {}) {
const payload = {};
if (intent.doi) {
payload.doi = intent.doi;
} else {
payload.title = cleanString(intent.query);
}
if (sourceOptions.tag ?? cleanString(input.zotero_tag)) {
payload.tag = sourceOptions.tag ?? cleanString(input.zotero_tag);
}
if (sourceOptions.collection_path ?? cleanString(input.zotero_collection_path)) {
payload.collection_path = sourceOptions.collection_path ?? cleanString(input.zotero_collection_path);
}
if (sourceOptions.limit) {
payload.limit = sourceOptions.limit;
}
return payload;
}
export async function searchZoteroSource({ config, intent, input, sourceOptions, context = {} }) {
const payload = zoteroSourceSearchPayload({ intent, input, sourceOptions });
try {
const response = await postCompanionJson(config, "/qiongli/search", payload, context);
if (response.status === "error") {
return {
provider: "zotero",
query_id: "zotero",
query: payload.doi ?? payload.title ?? "",
results: [],
error: response.error_code ?? "zotero_companion_missing",
request_count: 1,
attempts: 1,
source_type: "local_reference_database"
};
}
return {
provider: "zotero",
query_id: "zotero",
query: payload.doi ?? payload.title ?? "",
results: normalizeZoteroSourceResults(response.results).slice(0, sourceOptions.limit),
error: null,
request_count: 1,
attempts: 1,
source_type: "local_reference_database"
};
} catch (error) {
return {
provider: "zotero",
query_id: "zotero",
query: payload.doi ?? payload.title ?? "",
results: [],
error: zoteroSourceErrorCode(error),
request_count: 1,
attempts: 1,
source_type: "local_reference_database"
};
}
}
export function normalizeZoteroSourceResults(items = []) {
return (Array.isArray(items) ? items : []).map((item) => ({
title: cleanString(item.title),
authors: Array.isArray(item.authors) ? item.authors : [],
year: integerOrNull(item.year),
doi: cleanString(item.doi),
url: cleanString(item.url),
abstract: cleanString(item.abstract),
venue: cleanString(item.venue),
document_type: cleanString(item.item_type ?? item.document_type),
citation_count: null,
reference_count: null,
citations: [],
references: [],
provider: "zotero",
source_id: cleanString(item.item_key),
source_type: "local_reference_database",
zotero: {
item_key: cleanString(item.item_key),
select_uri: cleanString(item.select_uri),
tags: Array.isArray(item.tags) ? item.tags.filter(Boolean) : [],
collections: Array.isArray(item.collections) ? item.collections.filter(Boolean) : []
}
})).filter((item) => item.title || item.doi || item.source_id);
}
export function annotateLocalZoteroMatches({ externalResults = [], zoteroResults = [] } = {}) {
const byDoi = new Map();
const byTitleYear = new Map();
for (const result of zoteroResults) {
if (result.doi) {
byDoi.set(String(result.doi).toLowerCase(), result);
}
const titleKey = titleYearKey(result);
if (titleKey) {
byTitleYear.set(titleKey, result);
}
}
return externalResults.map((result) => {
const doiMatch = result.doi ? byDoi.get(String(result.doi).toLowerCase()) : null;
const titleMatch = doiMatch ? null : byTitleYear.get(titleYearKey(result));
const match = doiMatch ?? titleMatch;
if (!match) {
return result;
}
return {
...result,
local_zotero_match: {
item_key: match.zotero?.item_key ?? match.source_id ?? "",
match_basis: doiMatch ? "doi" : "title_year",
select_uri: match.zotero?.select_uri ?? ""
}
};
});
}
export function zoteroSourceWarning(response) {
if (!response?.error) {
return null;
}
return response.error === "zotero_not_running" ? "zotero_not_running" : "zotero_companion_missing";
}
function zoteroSourceErrorCode(error) {
const message = String(error?.message ?? "");
return message.includes("ECONNREFUSED") || message.includes("fetch failed")
? "zotero_not_running"
: "zotero_companion_missing";
}
function titleYearKey(record) {
const title = comparableTitle(record?.title);
if (!title) {
return "";
}
return `${title}:${integerOrNull(record?.year) ?? ""}`;
}
function comparableTitle(value) {
return String(value ?? "")
.normalize("NFKD")
.toLowerCase()
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
.trim()
.replace(/\s+/g, " ");
}
function integerOrNull(value) {
if (Number.isInteger(value)) {
return value;
}
const numeric = typeof value === "number" ? value : Number(String(value ?? "").trim());
return Number.isFinite(numeric) ? Math.trunc(numeric) : null;
}
function cleanString(value) {
if (typeof value !== "string") {
return null;
}
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;
}- [ ] Step 4: Verify helper tests pass
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/zotero.test.mjsExpected: PASS.
- [ ] Step 5: Commit source helper
Run:
git add packages/qiongli-literature-mcpb/server/zotero/search-source.mjs packages/qiongli-literature-mcpb/test/zotero.test.mjs
git commit -m "feat(zotero): normalize local source results"Task 4: Integrate Opt-In Zotero Source Into Main Search
Files:
Modify:
packages/qiongli-literature-mcpb/server/index.mjsModify:
packages/qiongli-literature-mcpb/server/diagnostics.mjsModify:
packages/qiongli-literature-mcpb/test/tools.test.mjs[ ] Step 1: Write failing default-off search test
Add this test to packages/qiongli-literature-mcpb/test/tools.test.mjs after the blank-query test:
test("handleSearch does not call Zotero source by default", async () => {
const urls = [];
const response = await handleSearch(
{ query: "platform governance", per_provider_limit: 1 },
{
env: {
QIONGLI_MCPB_OPENALEX_API_KEY: "openalex-secret-key"
},
fetchImpl: async (url) => {
urls.push(String(url));
return {
ok: true,
status: 200,
json: async () => ({
results: [
{
id: "https://openalex.org/W1",
title: "Platform Governance",
publication_year: 2024,
doi: "https://doi.org/10.1000/platform"
}
]
})
};
}
}
);
assert.equal(response.status, "ok");
assert.deepEqual(response.providers.attempted, ["openalex"]);
assert.equal(urls.some((url) => url.includes("/qiongli/search")), false);
});- [ ] Step 2: Write failing opt-in Zotero search test
Add:
test("handleSearch includes Zotero source only when requested", async () => {
const calls = [];
const response = await handleSearch(
{ query: "platform governance", include_zotero: true, zotero_limit: 5, per_provider_limit: 1 },
{
env: {
QIONGLI_MCPB_OPENALEX_API_KEY: "openalex-secret-key"
},
fetchImpl: async (url, options = {}) => {
calls.push({ url: String(url), body: options.body ? JSON.parse(options.body) : null });
if (String(url).endsWith("/qiongli/search")) {
return {
ok: true,
status: 200,
json: async () => ({
status: "ok",
results: [
{
item_key: "ZOT123",
title: "Local Platform Governance",
doi: "10.1000/local-platform",
year: 2023,
item_type: "journalArticle",
select_uri: "zotero://select/library/items/ZOT123",
tags: ["qiongli:verified"],
collections: ["Qiongli/platform"]
}
]
})
};
}
return {
ok: true,
status: 200,
json: async () => ({
results: [
{
id: "https://openalex.org/W1",
title: "Platform Governance",
publication_year: 2024,
doi: "https://doi.org/10.1000/platform"
}
]
})
};
}
}
);
assert.deepEqual(response.providers.attempted, ["openalex", "zotero"]);
assert.equal(response.providers.successful.includes("zotero"), true);
assert.equal(response.results.some((result) => result.provider === "zotero"), true);
assert.equal(response.results.find((result) => result.provider === "zotero").source_type, "local_reference_database");
assert.equal(response.diagnostics.providers.find((provider) => provider.provider === "zotero").source_type, "local_reference_database");
assert.equal(calls.find((call) => call.url.endsWith("/qiongli/search")).body.title, "platform governance");
});- [ ] Step 3: Write failing local match annotation test
Add:
test("handleSearch annotates external results that already exist in Zotero", async () => {
const response = await handleSearch(
{ query: "platform governance", include_zotero: true, per_provider_limit: 1 },
{
env: {
QIONGLI_MCPB_OPENALEX_API_KEY: "openalex-secret-key"
},
fetchImpl: async (url, options = {}) => {
if (String(url).endsWith("/qiongli/search")) {
return {
ok: true,
status: 200,
json: async () => ({
status: "ok",
results: [
{
item_key: "ZOT123",
title: "Platform Governance",
doi: "10.1000/platform",
year: 2024,
select_uri: "zotero://select/library/items/ZOT123"
}
]
})
};
}
return {
ok: true,
status: 200,
json: async () => ({
results: [
{
id: "https://openalex.org/W1",
title: "Platform Governance",
publication_year: 2024,
doi: "https://doi.org/10.1000/platform"
}
]
})
};
}
}
);
const openAlexResult = response.results.find((result) => result.provider === "openalex");
assert.equal(openAlexResult.local_zotero_match.item_key, "ZOT123");
assert.equal(openAlexResult.local_zotero_match.match_basis, "doi");
});- [ ] Step 4: Write failing missing companion warning test
Add:
test("handleSearch keeps external results when requested Zotero source is unavailable", async () => {
const response = await handleSearch(
{ query: "platform governance", include_zotero: true, per_provider_limit: 1 },
{
env: {
QIONGLI_MCPB_OPENALEX_API_KEY: "openalex-secret-key"
},
fetchImpl: async (url) => {
if (String(url).endsWith("/qiongli/search")) {
return {
ok: false,
status: 404,
json: async () => ({})
};
}
return {
ok: true,
status: 200,
json: async () => ({
results: [
{
id: "https://openalex.org/W1",
title: "Platform Governance",
publication_year: 2024,
doi: "https://doi.org/10.1000/platform"
}
]
})
};
}
}
);
assert.equal(response.status, "ok");
assert.equal(response.results.some((result) => result.provider === "openalex"), true);
assert.equal(response.providers.failed.includes("zotero"), true);
assert.equal(response.warnings.includes("zotero_companion_missing"), true);
});- [ ] Step 5: Run tests to verify they fail
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/tools.test.mjsExpected: FAIL because handleSearch does not use Zotero source.
- [ ] Step 6: Wire Zotero source into
handleSearch
In packages/qiongli-literature-mcpb/server/diagnostics.mjs, preserve source type in provider diagnostics:
function providerDiagnostic(response) {
const resultCount = Array.isArray(response?.results) ? response.results.length : 0;
const requestCount = numericStat(response?.request_count, 1);
const diagnostic = {
provider: response?.provider ?? "unknown",
status: response?.error ? "failed" : "success",
result_count: resultCount,
request_count: requestCount,
attempts: numericStat(response?.attempts, requestCount),
error: response?.error ?? null
};
if (response?.source_type) {
diagnostic.source_type = response.source_type;
}
return diagnostic;
}In aggregateProviderDiagnostics, add source_type: null to the summary object:
source_type: nullSet it while aggregating:
summary.source_type ??= response?.source_type ?? null;Return it only when present:
return Array.from(summaries.values()).map((summary) => {
const diagnostic = {
provider: summary.provider,
status: summary.success_count > 0 ? "success" : "failed",
result_count: summary.result_count,
request_count: summary.request_count,
attempts: summary.attempts,
error: summary.success_count > 0 ? null : summary.error
};
if (summary.source_type) {
diagnostic.source_type = summary.source_type;
}
return diagnostic;
});At the top of packages/qiongli-literature-mcpb/server/index.mjs, extend the Zotero imports:
import { resolveZoteroConfig } from "./zotero/config.mjs";
import {
annotateLocalZoteroMatches,
resolveZoteroSourceOptions,
searchZoteroSource,
zoteroSourceWarning
} from "./zotero/search-source.mjs";Inside handleSearch, after const options = resolveSearchOptions(...), add:
const zoteroSourceOptions = resolveZoteroSourceOptions(input, {
perProviderLimit: options.perProviderLimit
});After external provider responses are created, add:
const zoteroResponses = [];
if (zoteroSourceOptions.include) {
zoteroResponses.push(await searchZoteroSource({
config: resolveZoteroConfig({ env: context.env ?? process.env, input }),
intent,
input,
sourceOptions: zoteroSourceOptions,
context
}));
}
const allResponses = [...responses, ...zoteroResponses];Change provider outcome and result aggregation to use all responses and include zotero in attempted providers only when requested:
const attemptedWithZotero = zoteroSourceOptions.include ? [...attempted, "zotero"] : attempted;
const { successful, failed } = providerOutcomes(attemptedWithZotero, allResponses);
const externalResults = [];
const zoteroResults = [];
for (const response of allResponses) {
if (response.error) {
continue;
}
if (response.provider === "zotero") {
zoteroResults.push(...response.results);
} else {
externalResults.push(...response.results);
}
}
const results = [
...annotateLocalZoteroMatches({ externalResults, zoteroResults }),
...zoteroResults
];Use attemptedWithZotero and allResponses when building evidence and diagnostics:
const evidence = buildEvidence({
attemptedProviders: attemptedWithZotero,
successfulProviders: successful,
failedProviders: failed,
resultCount: results.length
}); const diagnostics = searchDiagnostics({
responses: allResponses,
rawResults: results,
dedupedResults,
filteredResults,
outputResults,
queryPlan
});Before returning, merge Zotero warnings:
const zoteroWarnings = zoteroResponses.map(zoteroSourceWarning).filter(Boolean);
const warnings = appendSearchWarnings(evidence.warnings, outputResults, options, diagnostics);Return:
warnings: [...new Set([...warnings, ...zoteroWarnings])],- [ ] Step 7: Verify search integration tests pass
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/tools.test.mjsExpected: PASS.
- [ ] Step 8: Commit search integration
Run:
git add packages/qiongli-literature-mcpb/server/index.mjs packages/qiongli-literature-mcpb/server/diagnostics.mjs packages/qiongli-literature-mcpb/test/tools.test.mjs
git commit -m "feat(mcpb): include zotero as opt-in source"Task 5: Crossref Verifier Helper
Files:
Create:
packages/qiongli-literature-mcpb/server/zotero/crossref-verifier.mjsModify:
packages/qiongli-literature-mcpb/test/zotero.test.mjs[ ] Step 1: Write failing Crossref verifier tests
Import the verifier:
import { verifyRecordWithCrossref } from "../server/zotero/crossref-verifier.mjs";Add tests:
test("verifyRecordWithCrossref fills blank fields from DOI metadata", async () => {
const result = await verifyRecordWithCrossref({
record: { title: "Incoming Title", doi: "10.1000/verified" },
config: { crossrefEmail: "[email protected]" },
fetchImpl: async () => ({
ok: true,
status: 200,
json: async () => ({
message: {
title: ["Incoming Title"],
DOI: "10.1000/verified",
author: [{ given: "Alex", family: "Smith" }],
issued: { "date-parts": [[2024]] },
"container-title": ["Journal of Tests"],
type: "journal-article",
URL: "https://doi.org/10.1000/verified"
}
})
})
});
assert.equal(result.verification.crossref.status, "verified");
assert.equal(result.record.title, "Incoming Title");
assert.equal(result.record.year, 2024);
assert.equal(result.record.venue, "Journal of Tests");
assert.deepEqual(result.verification.crossref.filled_fields.sort(), ["authors", "document_type", "url", "venue", "year"].sort());
});
test("verifyRecordWithCrossref preserves non-empty incoming fields by default", async () => {
const result = await verifyRecordWithCrossref({
record: { title: "Incoming Title", authors: ["Original Author"], year: 2023, venue: "Original Venue", doi: "10.1000/preserve" },
config: {},
fetchImpl: async () => ({
ok: true,
status: 200,
json: async () => ({
message: {
title: ["Incoming Title"],
DOI: "10.1000/preserve",
author: [{ given: "Alex", family: "Smith" }],
issued: { "date-parts": [[2024]] },
"container-title": ["Crossref Venue"]
}
})
})
});
assert.equal(result.record.authors[0], "Original Author");
assert.equal(result.record.year, 2023);
assert.equal(result.record.venue, "Original Venue");
});
test("verifyRecordWithCrossref reports material title conflicts", async () => {
const result = await verifyRecordWithCrossref({
record: { title: "Working Paper Title", year: 2024, doi: "10.1000/conflict" },
config: {},
fetchImpl: async () => ({
ok: true,
status: 200,
json: async () => ({
message: {
title: ["Published Article Title"],
DOI: "10.1000/conflict",
issued: { "date-parts": [[2024]] }
}
})
})
});
assert.equal(result.verification.crossref.status, "conflict");
assert.equal(result.verification.crossref.conflicts[0].field, "title");
assert.equal(result.record.title, "Working Paper Title");
});
test("verifyRecordWithCrossref skips records without DOI", async () => {
const result = await verifyRecordWithCrossref({
record: { title: "No DOI Paper" },
config: {},
fetchImpl: async () => {
throw new Error("should not fetch");
}
});
assert.equal(result.verification.crossref.status, "skipped");
assert.equal(result.record.title, "No DOI Paper");
});- [ ] Step 2: Run test to verify it fails
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/zotero.test.mjsExpected: FAIL because crossref-verifier.mjs does not exist.
- [ ] Step 3: Implement
crossref-verifier.mjs
Create packages/qiongli-literature-mcpb/server/zotero/crossref-verifier.mjs:
import { searchCrossref } from "../providers/crossref.mjs";
import { normalizeReferenceRecord } from "./records.mjs";
export async function verifyRecordWithCrossref({
record,
config = {},
fetchImpl,
enabled = true,
enrichment = "fill_blank"
} = {}) {
const normalized = normalizeReferenceRecord(record);
if (!enabled || !normalized.doi) {
return withVerification(normalized, {
status: "skipped",
doi: normalized.doi || null,
filled_fields: [],
conflicts: []
});
}
const response = await searchCrossref({
query: normalized.doi,
doi: normalized.doi,
limit: 1,
email: config.crossrefEmail,
fetchImpl
});
if (response.error) {
return withVerification(normalized, {
status: "unavailable",
doi: normalized.doi,
filled_fields: [],
conflicts: [],
warning: response.error
});
}
const candidate = response.results?.[0];
if (!candidate) {
return withVerification(normalized, {
status: "not_found",
doi: normalized.doi,
filled_fields: [],
conflicts: []
});
}
const conflicts = crossrefConflicts(normalized, candidate);
const { record: enriched, filledFields } = enrichment === "fill_blank"
? fillBlankFields(normalized, candidate)
: { record: normalized, filledFields: [] };
return withVerification(enriched, {
status: conflicts.length > 0 ? "conflict" : "verified",
doi: normalized.doi,
filled_fields: filledFields,
conflicts
});
}
export async function verifyRecordsWithCrossref({ records = [], config = {}, fetchImpl, enabled = true, enrichment = "fill_blank" } = {}) {
const verified = [];
for (const record of records) {
verified.push(await verifyRecordWithCrossref({ record, config, fetchImpl, enabled, enrichment }));
}
return verified;
}
export function crossrefStatusTag(status) {
if (status === "verified") {
return "qiongli:crossref-verified";
}
if (status === "conflict") {
return "qiongli:metadata-conflict";
}
if (status === "unavailable") {
return "qiongli:verification-unavailable";
}
return "qiongli:metadata-unverified";
}
function withVerification(record, crossref) {
return {
record: {
...record,
verification: {
...(record.verification ?? {}),
crossref
}
},
verification: {
crossref
}
};
}
function fillBlankFields(record, candidate) {
const filledFields = [];
const enriched = { ...record };
for (const field of ["title", "authors", "year", "doi", "url", "abstract", "venue", "document_type", "reference_count", "references"]) {
const value = candidate[field];
if (isBlank(enriched[field]) && !isBlank(value)) {
enriched[field] = value;
filledFields.push(field);
}
}
return { record: enriched, filledFields };
}
function crossrefConflicts(record, candidate) {
const conflicts = [];
if (record.title && candidate.title && titleConflict(record.title, candidate.title)) {
conflicts.push({ field: "title", incoming: record.title, crossref: candidate.title });
}
if (record.year && candidate.year && record.year !== candidate.year) {
conflicts.push({ field: "year", incoming: record.year, crossref: candidate.year });
}
return conflicts;
}
function titleConflict(left, right) {
const leftTokens = new Set(comparableTitle(left).split(" ").filter(Boolean));
const rightTokens = new Set(comparableTitle(right).split(" ").filter(Boolean));
if (leftTokens.size === 0 || rightTokens.size === 0) {
return false;
}
let overlap = 0;
for (const token of leftTokens) {
if (rightTokens.has(token)) {
overlap += 1;
}
}
return overlap / Math.max(leftTokens.size, rightTokens.size) < 0.5;
}
function comparableTitle(value) {
return String(value ?? "")
.normalize("NFKD")
.toLowerCase()
.replace(/[\u0300-\u036f]/g, "")
.replace(/[^\p{Letter}\p{Number}]+/gu, " ")
.trim()
.replace(/\s+/g, " ");
}
function isBlank(value) {
if (Array.isArray(value)) {
return value.length === 0;
}
return value === "" || value === null || value === undefined;
}- [ ] Step 4: Verify Crossref verifier tests pass
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/zotero.test.mjsExpected: PASS.
- [ ] Step 5: Commit verifier
Run:
git add packages/qiongli-literature-mcpb/server/zotero/crossref-verifier.mjs packages/qiongli-literature-mcpb/test/zotero.test.mjs
git commit -m "feat(zotero): verify imports with crossref"Task 6: Review Tags, Upsert Verification, And Companion Tag Preservation
Files:
Create:
packages/qiongli-literature-mcpb/server/zotero/review-tags.mjsModify:
packages/qiongli-literature-mcpb/server/zotero/config.mjsModify:
packages/qiongli-literature-mcpb/server/zotero/records.mjsModify:
packages/qiongli-literature-mcpb/server/zotero/tools.mjsModify:
packages/qiongli-literature-mcpb/test/zotero.test.mjsModify:
packages/qiongli-zotero-companion/test/bridge.test.mjs[ ] Step 1: Write failing review tag tests
Import review tag helpers:
import { mergeReviewTags, reviewStatusForVerification } from "../server/zotero/review-tags.mjs";Add tests to packages/qiongli-literature-mcpb/test/zotero.test.mjs:
test("mergeReviewTags adds imported needs-review source and crossref tags", () => {
const tags = mergeReviewTags({
baseTags: ["custom"],
provider: "openalex",
crossrefStatus: "verified",
defaultReviewTags: ["qiongli:imported", "qiongli:needs-review"]
});
assert.deepEqual(tags, [
"custom",
"qiongli:imported",
"qiongli:needs-review",
"qiongli:source:openalex",
"qiongli:crossref-verified"
]);
});
test("reviewStatusForVerification keeps newly imported verified records in needs_review", () => {
assert.equal(reviewStatusForVerification({ writeStatus: "created", crossrefStatus: "verified" }), "needs_review");
assert.equal(reviewStatusForVerification({ writeStatus: "unchanged", crossrefStatus: "verified" }), "unchanged");
assert.equal(reviewStatusForVerification({ writeStatus: "skipped", crossrefStatus: "skipped" }), "skipped");
});Add a new handleZoteroUpsertReferences test:
test("handleZoteroUpsertReferences verifies DOI records with Crossref and sends review tags", async () => {
const requests = [];
const result = await handleZoteroUpsertReferences({
dry_run: false,
records: [{ title: "Crossref Verified", doi: "10.1000/verified", provider: "openalex" }]
}, {
env: {},
fetchImpl: async (url, options = {}) => {
requests.push({ url: String(url), body: options.body ? JSON.parse(options.body) : null });
if (String(url).includes("api.crossref.org")) {
return {
ok: true,
status: 200,
json: async () => ({
message: {
title: ["Crossref Verified"],
DOI: "10.1000/verified",
issued: { "date-parts": [[2024]] },
"container-title": ["Journal of Tests"]
}
})
};
}
return {
ok: true,
status: 200,
json: async () => ({
status: "ok",
dry_run: false,
results: [{ status: "created", item_key: "NEW1" }]
})
};
}
});
const upsertBody = requests.find((request) => request.url.endsWith("/qiongli/upsertItems")).body;
assert.equal(upsertBody.items[0].publicationTitle, "Journal of Tests");
assert.deepEqual(
upsertBody.items[0].tags.map((tag) => tag.tag),
["qiongli:imported", "qiongli:needs-review", "qiongli:source:openalex", "qiongli:crossref-verified"]
);
assert.equal(result.results[0].review_status, "needs_review");
assert.equal(result.verification[0].crossref.status, "verified");
});- [ ] Step 2: Write failing companion tag preservation test
In packages/qiongli-zotero-companion/test/bridge.test.mjs, add:
test("upsertItems preserves incoming Qiongli review tags on created items", async () => {
const calls = [];
const runtime = {
listItems: async () => [],
createItem: async (item) => {
calls.push(item);
return { key: "NEW1", ...item };
}
};
const result = await upsertItems({
dry_run: false,
items: [
{
title: "Tagged Paper",
tags: [
{ tag: "qiongli:imported" },
{ tag: "qiongli:needs-review" }
]
}
]
}, runtime);
assert.deepEqual(calls[0].tags, [
{ tag: "qiongli:imported" },
{ tag: "qiongli:needs-review" }
]);
assert.equal(result.results[0].item.tags[0], "qiongli:imported");
});- [ ] Step 3: Run tests to verify they fail
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/zotero.test.mjs
npm --prefix packages/qiongli-zotero-companion testExpected: FAIL because review tag helper and upsert verification are not wired yet.
- [ ] Step 4: Implement review tag helper
Create packages/qiongli-literature-mcpb/server/zotero/review-tags.mjs:
import { crossrefStatusTag } from "./crossref-verifier.mjs";
export const DEFAULT_REVIEW_TAGS = ["qiongli:imported", "qiongli:needs-review"];
export function resolveDefaultReviewTags(config = {}, input = {}) {
const fromInput = normalizeStringList(input.review_tags);
if (fromInput.length > 0) {
return fromInput;
}
const fromConfig = normalizeStringList(config.default_review_tags);
return fromConfig.length > 0 ? fromConfig : DEFAULT_REVIEW_TAGS;
}
export function mergeReviewTags({ baseTags = [], provider = "", crossrefStatus = "skipped", defaultReviewTags = DEFAULT_REVIEW_TAGS } = {}) {
return normalizeStringList([
...normalizeStringList(baseTags),
...normalizeStringList(defaultReviewTags),
provider ? `qiongli:source:${provider}` : "",
crossrefStatusTag(crossrefStatus)
]);
}
export function reviewStatusForVerification({ writeStatus = "", crossrefStatus = "" } = {}) {
if (writeStatus === "unchanged") {
return "unchanged";
}
if (writeStatus === "skipped") {
return "skipped";
}
return "needs_review";
}
export function normalizeStringList(value) {
const values = Array.isArray(value) ? value : typeof value === "string" ? value.split(",") : [];
const output = [];
const seen = new Set();
for (const item of values) {
const cleaned = String(item ?? "").trim();
if (!cleaned) {
continue;
}
const key = cleaned.toLowerCase();
if (seen.has(key)) {
continue;
}
seen.add(key);
output.push(cleaned);
}
return output;
}- [ ] Step 5: Extend Zotero config for review and verification settings
In packages/qiongli-literature-mcpb/server/zotero/config.mjs, add to the returned object:
default_review_tags: cleanString(input.review_tags ?? env.QIONGLI_ZOTERO_DEFAULT_REVIEW_TAGS),
default_review_collection_path: cleanString(input.review_collection_path ?? env.QIONGLI_ZOTERO_DEFAULT_REVIEW_COLLECTION_PATH),
crossref_verification_enabled: readBoolean(input.verify_crossref ?? env.QIONGLI_ZOTERO_CROSSREF_VERIFICATION_ENABLED, true)Keep QIONGLI_ZOTERO_DEFAULT_COLLECTION_PATH unchanged for backward compatibility.
- [ ] Step 6: Preserve verification and review fields through record normalization
In packages/qiongli-literature-mcpb/server/zotero/records.mjs, add to the normalized record object:
verification: clonePlainObject(record.verification),
review_status: cleanString(record.review_status)Add helper:
function clonePlainObject(value) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
return null;
}
return JSON.parse(JSON.stringify(value));
}- [ ] Step 7: Wire Crossref verification and review tags in upsert
In packages/qiongli-literature-mcpb/server/zotero/tools.mjs, import:
import { readConfig } from "../config.mjs";
import { verifyRecordsWithCrossref } from "./crossref-verifier.mjs";
import { mergeReviewTags, resolveDefaultReviewTags, reviewStatusForVerification } from "./review-tags.mjs";Replace the current deduped flow in handleZoteroUpsertReferences with:
const providerConfig = context.config ?? readConfig(context.env ?? process.env);
const verifiedRecords = await verifyRecordsWithCrossref({
records,
config: providerConfig,
fetchImpl: context.fetchImpl,
enabled: input.verify_crossref !== false && config.crossref_verification_enabled,
enrichment: input.crossref_enrichment ?? "fill_blank"
});
const enrichedRecords = verifiedRecords.map((entry) => entry.record);
const deduped = dedupeReferenceRecords(enrichedRecords);
const defaultReviewTags = resolveDefaultReviewTags(config, input);
const itemPayloadRecords = deduped.records.map((record) => {
const crossrefStatus = record.verification?.crossref?.status ?? "skipped";
return {
record,
tags: mergeReviewTags({
baseTags: [...normalizeStringList(input.tags), ...record.tags],
provider: record.provider,
crossrefStatus,
defaultReviewTags
})
};
});Build payload items from itemPayloadRecords:
records: itemPayloadRecords.map((entry) => entry.record),
items: itemPayloadRecords.map((entry) => mapRecordToZoteroItem(entry.record, { tags: entry.tags }))After companion response, attach verification and review status:
const verification = itemPayloadRecords.map((entry) => entry.record.verification ?? { crossref: { status: "skipped", filled_fields: [], conflicts: [] } });
const responseResults = Array.isArray(response.results) ? response.results : [];
const results = responseResults.map((entry, index) => ({
...entry,
review_status: reviewStatusForVerification({
writeStatus: entry.status,
crossrefStatus: verification[index]?.crossref?.status
}),
verification: verification[index]
}));Return results and top-level verification in both success and error branches.
- [ ] Step 8: Verify review tag and upsert tests pass
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/zotero.test.mjs
npm --prefix packages/qiongli-zotero-companion testExpected: PASS.
- [ ] Step 9: Commit review tag and verification upsert work
Run:
git add packages/qiongli-literature-mcpb/server/zotero/config.mjs packages/qiongli-literature-mcpb/server/zotero/review-tags.mjs packages/qiongli-literature-mcpb/server/zotero/records.mjs packages/qiongli-literature-mcpb/server/zotero/tools.mjs packages/qiongli-literature-mcpb/test/zotero.test.mjs packages/qiongli-zotero-companion/test/bridge.test.mjs
git commit -m "feat(zotero): tag verified import candidates"Task 7: Import File Fallback And Documentation
Files:
Modify:
packages/qiongli-literature-mcpb/server/zotero/exporters.mjsModify:
packages/qiongli-literature-mcpb/test/zotero.test.mjsModify:
packages/qiongli-literature-mcpb/README.mdModify:
docs/advanced/mcp-zotero-integration.mdModify:
docs/zh/advanced/mcp-zotero-integration.mdModify:
content/skills/B_literature/reference-manager-bridge.md[ ] Step 1: Write failing import fallback report test
Add to packages/qiongli-literature-mcpb/test/zotero.test.mjs:
test("exportImportFiles includes review tags and verification counts", () => {
const output = exportImportFiles({
records: [
{
title: "Verified Export",
year: 2024,
doi: "10.1000/export",
tags: ["qiongli:needs-review", "qiongli:crossref-verified"],
verification: { crossref: { status: "verified", filled_fields: [], conflicts: [] } }
},
{
title: "Conflict Export",
year: 2024,
doi: "10.1000/conflict",
tags: ["qiongli:metadata-conflict"],
verification: { crossref: { status: "conflict", filled_fields: [], conflicts: [{ field: "title" }] } }
}
]
});
assert.ok(output.files["references.json"].includes("qiongli:needs-review"));
assert.ok(output.files["references.ris"].includes("KW - qiongli:metadata-conflict"));
assert.ok(output.files["bibliography.bib"].includes("qiongli:crossref-verified"));
assert.ok(output.files["zotero-import-report.md"].includes("- Crossref verified: 1"));
assert.ok(output.files["zotero-import-report.md"].includes("- Metadata conflicts: 1"));
});- [ ] Step 2: Run test to verify it fails
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/zotero.test.mjsExpected: FAIL because report counts do not exist yet.
- [ ] Step 3: Update exporter report
In packages/qiongli-literature-mcpb/server/zotero/exporters.mjs, add:
function verificationCounts(records) {
const counts = {
verified: 0,
conflict: 0,
unverified: 0,
unavailable: 0
};
for (const record of records) {
const status = record.verification?.crossref?.status ?? "skipped";
if (status === "verified") {
counts.verified += 1;
} else if (status === "conflict") {
counts.conflict += 1;
} else if (status === "unavailable") {
counts.unavailable += 1;
} else {
counts.unverified += 1;
}
}
return counts;
}In importReport, compute counts:
const counts = verificationCounts(records);Add these lines after the export format summary:
"",
"## Verification Summary",
"",
`- Crossref verified: ${counts.verified}`,
`- Metadata conflicts: ${counts.conflict}`,
`- Unverified or no DOI: ${counts.unverified}`,
`- Verification unavailable: ${counts.unavailable}`,
"",
"Crossref verification uses DOI registry metadata and is not human verification.",- [ ] Step 4: Verify exporter tests pass
Run:
npm --prefix packages/qiongli-literature-mcpb test -- test/zotero.test.mjsExpected: PASS.
- [ ] Step 5: Update docs
In packages/qiongli-literature-mcpb/README.md, add a subsection under Local Zotero Reference Database:
### Opt-in Zotero source search
`qiongli_literature_search` does not search Zotero by default. Pass
`include_zotero: true` to include the local Zotero library as an additional
reference source. Local-only records return `provider: "zotero"` and external
records can include `local_zotero_match` when the DOI or title/year already
exists in Zotero.
### Crossref verification before Zotero writes
DOI-bearing imports use Crossref DOI registry metadata by default to fill blank
fields before writing to Zotero. Crossref metadata is not human verification, so
new or updated items still receive `qiongli:needs-review`.In docs/advanced/mcp-zotero-integration.md and docs/zh/advanced/mcp-zotero-integration.md, add the same concepts in prose:
- Zotero is default-off in main search and only used with
include_zotero: true. - Writes add
qiongli:importedandqiongli:needs-review. - Crossref uses DOI registry metadata to fill blank fields.
- Conflicts produce
qiongli:metadata-conflict.
In content/skills/B_literature/reference-manager-bridge.md, update Zotero Integration Modes so local Zotero has two explicit modes:
| Local Zotero source search | Search inside the user's existing Zotero library only when explicitly requested | `qiongli_literature_search` with `include_zotero: true` |
| Local Zotero sync | Write selected candidate references to Zotero with review tags | `qiongli_zotero_upsert_references` |- [ ] Step 6: Verify docs mention required terms
Run:
rg "include_zotero|local_zotero_match|qiongli:needs-review|Crossref verification|metadata-conflict|registry metadata" packages/qiongli-literature-mcpb/README.md docs/advanced/mcp-zotero-integration.md docs/zh/advanced/mcp-zotero-integration.md content/skills/B_literature/reference-manager-bridge.mdExpected: each required concept appears in at least one English doc and the Chinese advanced doc mirrors the user-facing behavior.
- [ ] Step 7: Commit fallback docs
Run:
git add packages/qiongli-literature-mcpb/server/zotero/exporters.mjs packages/qiongli-literature-mcpb/test/zotero.test.mjs packages/qiongli-literature-mcpb/README.md docs/advanced/mcp-zotero-integration.md docs/zh/advanced/mcp-zotero-integration.md content/skills/B_literature/reference-manager-bridge.md
git commit -m "docs(zotero): document verified import workflow"Task 8: Packaging And Final Verification
Files:
Modify:
tests/test_literature_mcpb_artifact.pyVerify: all touched files.
[ ] Step 1: Write or update packaging expectations
In tests/test_literature_mcpb_artifact.py, update test_build_literature_mcpb_contains_required_files to assert these files are included:
self.assertIn("server/zotero/search-source.mjs", names)
self.assertIn("server/zotero/crossref-verifier.mjs", names)
self.assertIn("server/zotero/review-tags.mjs", names)- [ ] Step 2: Run packaging test
Run:
python3 -m unittest tests.test_literature_mcpb_artifactExpected: PASS because the MCPB package script includes server/ recursively.
- [ ] Step 3: Commit packaging expectations
Run:
git add tests/test_literature_mcpb_artifact.py
git commit -m "test(mcpb): package zotero source helpers"- [ ] Step 4: Run full MCPB tests
Run:
npm --prefix packages/qiongli-literature-mcpb testExpected: all Node tests pass.
- [ ] Step 5: Run companion tests
Run:
npm --prefix packages/qiongli-zotero-companion testExpected: all companion tests pass.
- [ ] Step 6: Run Python contract and artifact tests
Run:
python3 -m unittest tests.test_literature_mcpb_artifact tests.test_zotero_companion_artifact tests.test_literature_contract tests.test_mcp_provider_docsExpected: all selected Python tests pass.
- [ ] Step 7: Run boundary checks
Run:
rg "secret-key|desktop-secret|api-key-value|/Users/|/private/tmp" packages/qiongli-literature-mcpb/server/zotero packages/qiongli-literature-mcpb/test/zotero.test.mjs packages/qiongli-zotero-companion/test/bridge.test.mjs docs/advanced/mcp-zotero-integration.md docs/zh/advanced/mcp-zotero-integration.md content/skills/B_literature/reference-manager-bridge.mdExpected: no matches except intentional fixture-secret tests outside the new Zotero files. If this command reports a new local path or fixture secret inside new Zotero code/docs, remove it before continuing.
- [ ] Step 8: Verify clean worktree
Run:
git status --shortExpected: no output.
Completion Audit
Before marking the implementation complete, verify each acceptance criterion from docs/superpowers/specs/2026-06-19-zotero-source-crossref-verification-design.md:
- Main search behavior is unchanged unless
include_zotero: true. include_zotero: truereturns local Zotero results withprovider: "zotero"andsource_type: "local_reference_database".- External provider results can carry
local_zotero_match. - Zotero writes default to dry-run and include review-state tags in write payloads.
- DOI-bearing imports run Crossref verification by default unless disabled.
- Crossref fills blank fields only under default policy.
- Metadata conflicts appear in
verification.crossref.conflictsand tags. - Import fallback files include review tags and verification counts.
- Packaged artifacts contain the new helper modules.
- No local attachment paths or secrets are included.