← Files Pesquisa MirandasTechARCHIVED FILE
scripts/zotero_mcp.py
9.26 KB · Oct 3, 2026 · 06:34 UTC
#!/usr/bin/env python3
"""Conector MCP da biblioteca Zotero (servidor stdio).
Expõe a biblioteca local do Zotero como ferramentas para Claude Code, Claude Desktop e Codex CLI,
usando a API local (Zotero 7+ aberto, com «Permitir que outros aplicativos neste computador se
comuniquem com o Zotero» ligado em Configurações → Avançado) e, quando o Better BibTeX estiver
instalado, a exportação dele. Só lê a biblioteca; nunca altera nada.
Executar: uv run --with mcp python zotero_mcp.py (ou: pip install mcp && python zotero_mcp.py)
"""
import json, re, urllib.parse, urllib.request
try: # mcp 2.x
from mcp.server.mcpserver import MCPServer as FastMCP
except ImportError: # mcp 1.x
from mcp.server.fastmcp import FastMCP
BASE = "http://127.0.0.1:23119"
mcp = FastMCP("zotero", instructions=(
"Biblioteca Zotero do usuário, lida pela API local. Use para localizar itens, montar "
"referências no estilo ABNT, auditar metadados e exportar BibTeX. Nunca invente referências: "
"se um item não aparece na busca, diga que não está na biblioteca."))
def _get(path, **q):
url = f"{BASE}{path}" + ("?" + urllib.parse.urlencode(q) if q else "")
try:
with urllib.request.urlopen(url, timeout=60) as r:
raw = r.read().decode("utf-8")
return json.loads(raw) if r.headers.get_content_type() == "application/json" else raw
except Exception as e:
raise RuntimeError(f"Zotero não respondeu em {BASE} ({e}). Abra o Zotero e ligue a API local "
"em Configurações → Avançado → «Permitir que outros aplicativos…».")
def _todos(path, **q):
out, start = [], 0
while True:
lote = _get(path, limit=100, start=start, **q)
out += lote
if len(lote) < 100: return out
start += 100
def _colecao_id(nome):
for c in _todos("/api/users/0/collections"):
if c["data"]["name"].lower() == nome.lower(): return c["key"]
raise RuntimeError(f"Coleção não encontrada: {nome}")
def _resumo(it):
d = it["data"]; aut = ", ".join(f"{c.get('lastName','')}" for c in d.get("creators", [])[:3])
if len(d.get("creators", [])) > 3: aut += " et al."
return {"chave": it["key"], "tipo": d["itemType"], "titulo": d.get("title", ""), "autores": aut,
"ano": (re.search(r"\d{4}", d.get("date", "") or "") or [None])[0] if d.get("date") else None,
"publicacao": d.get("publicationTitle") or d.get("publisher") or "", "doi": d.get("DOI", ""),
"etiquetas": [t["tag"] for t in d.get("tags", [])]}
@mcp.tool()
def status() -> str:
"""Verifica se o Zotero está aberto com a API local ligada e quantos itens e coleções há.
Sem parâmetros. Exemplo: status()"""
n = len(_todos("/api/users/0/items/top")); c = len(_todos("/api/users/0/collections"))
return f"Zotero acessível em {BASE}: {n} itens de nível superior, {c} coleções."
@mcp.tool()
def listar_colecoes() -> list[dict]:
"""Lista as coleções da biblioteca (nome, chave, coleção-mãe, número de itens).
Sem parâmetros. Exemplo: listar_colecoes()"""
return [{"nome": c["data"]["name"], "chave": c["key"], "mae": c["data"].get("parentCollection") or None,
"itens": c["meta"].get("numItems", 0)} for c in _todos("/api/users/0/collections")]
@mcp.tool()
def buscar_itens(texto: str = "", colecao: str = "", etiqueta: str = "", limite: int = 25) -> list[dict]:
"""Busca itens por texto (título, autor, ano — «Todos os campos»), opcionalmente dentro de uma
coleção (pelo nome) e/ou com uma etiqueta. Retorna chave, tipo, título, autores, ano, DOI e etiquetas.
Parâmetros: texto (o que procurar), colecao (nome exato da coleção), etiqueta (nome da etiqueta), limite (1–100).
Exemplos: buscar_itens(texto="PRISMA 2020") · buscar_itens(colecao="2 Incluídos", limite=100) · buscar_itens(etiqueta="duplicata")
Sem nenhum parâmetro devolve os primeiros 25 itens da biblioteca."""
path = f"/api/users/0/collections/{_colecao_id(colecao)}/items/top" if colecao else "/api/users/0/items/top"
q = {"limit": max(1, min(limite, 100)), "qmode": "everything"}
if texto: q["q"] = texto
if etiqueta: q["tag"] = etiqueta
return [_resumo(it) for it in _get(path, **q)]
@mcp.tool()
def detalhar_item(chave: str) -> dict:
"""Todos os metadados de um item (pela chave de 8 caracteres) e a lista de anexos e notas.
Parâmetro: chave (a «chave» devolvida por buscar_itens). Exemplo: detalhar_item(chave="LXS55N5P")"""
it = _get(f"/api/users/0/items/{chave}"); filhos = _get(f"/api/users/0/items/{chave}/children")
return {"item": it["data"], "filhos": [{"chave": f["key"], "tipo": f["data"]["itemType"],
"titulo": f["data"].get("title", ""), "contentType": f["data"].get("contentType", "")} for f in filhos]}
@mcp.tool()
def referencia_abnt(chaves: list[str] | None = None, colecao: str = "", estilo: str = "associacao-brasileira-de-normas-tecnicas",
modo: str = "bibliografia") -> str:
"""Gera referências formatadas pelo próprio Zotero (processador CSL). Padrão: estilo ABNT autor-data;
alternativas: «associacao-brasileira-de-normas-tecnicas-numerico», «apa», «ieee» etc. (precisa estar instalado).
modo «bibliografia» devolve a lista; modo «citacao» devolve as citações no texto, ex.: (SILVA, 2024).
Parâmetros: chaves (lista de chaves) OU colecao (nome); estilo; modo.
Exemplos: referencia_abnt(chaves=["LXS55N5P"]) · referencia_abnt(colecao="2 Incluídos") · referencia_abnt(chaves=["LXS55N5P"], modo="citacao")"""
q = {"format": "bib" if modo == "bibliografia" else "citation", "style": estilo, "locale": "pt-BR", "limit": 100}
if chaves: path, q["itemKey"] = "/api/users/0/items", ",".join(chaves)
elif colecao: path = f"/api/users/0/collections/{_colecao_id(colecao)}/items/top"
else: path = "/api/users/0/items/top"
raw = _get(path, **q)
if q["format"] == "bib":
ents = re.findall(r'<div class="csl-entry"[^>]*>(.*?)</div>', raw, re.S)
txt = [re.sub(r"<[^>]+>", "", e).replace("&", "&").strip() for e in ents]
return "\n".join(txt) if txt else raw
return re.sub(r"<[^>]+>", "", raw)
@mcp.tool()
def auditar(colecao: str = "") -> dict:
"""Aponta o que sai errado na lista de referências: itens sem DOI, sem PDF, sem autor ou ano,
título em CAIXA ALTA, capturados como «Página da web», anexos órfãos e prováveis duplicados. Só lê.
Parâmetro opcional: colecao (nome). Exemplos: auditar() · auditar(colecao="2 Incluídos")"""
path = f"/api/users/0/collections/{_colecao_id(colecao)}/items/top" if colecao else "/api/users/0/items/top"
itens = _todos(path); filhos = {}
for it in _todos("/api/users/0/items"):
p = it["data"].get("parentItem")
if p: filhos.setdefault(p, []).append(it["data"])
CIT = {"journalArticle","book","bookSection","thesis","conferencePaper","report","preprint","standard","statute","document","manuscript","dataset","patent"}
pend, vistos = [], {}
for it in itens:
d = it["data"]; tipo = d["itemType"]; titulo = (d.get("title") or "").strip(); k = it["key"]; p = []
if tipo in ("attachment", "note"): p.append("anexo/nota sem item pai")
if tipo == "webpage": p.append("capturado como Página da web")
if not titulo: p.append("sem título")
letras = [c for c in titulo if c.isalpha()]
if len(letras) > 12 and sum(c.isupper() for c in letras) / len(letras) > 0.8: p.append("título em CAIXA ALTA")
if tipo in CIT and not d.get("creators"): p.append("sem autor")
if tipo in CIT and not re.search(r"\d{4}", d.get("date", "") or ""): p.append("sem ano")
doi = (d.get("DOI") or "").strip()
if tipo in ("journalArticle", "conferencePaper", "preprint", "dataset") and not doi: p.append("sem DOI")
if doi and not re.match(r"^10\.\S+/\S+$", doi): p.append("DOI mal formado")
if tipo in CIT and not any(f.get("contentType") == "application/pdf" for f in filhos.get(k, [])): p.append("sem PDF anexado")
ch = doi.lower() if doi else re.sub(r"\W+", "", titulo.lower())
if ch:
if ch in vistos: p.append(f"provável duplicado de {vistos[ch]}")
else: vistos[ch] = k
if p: pend.append({"chave": k, "tipo": tipo, "titulo": titulo[:80], "pendencias": p})
return {"itens_auditados": len(itens), "com_pendencias": len(pend), "pendencias": pend,
"regra": "Corrija no item do Zotero, nunca na lista do documento (slide 49 do manual)."}
@mcp.tool()
def exportar_bbt(colecao: str, formato: str = "biblatex") -> str:
"""Exporta uma coleção pelo Better BibTeX (formato «bibtex», «biblatex» ou «json» = CSL JSON).
Exige o plugin Better BibTeX instalado e ativo. Devolve o conteúdo do arquivo.
Parâmetros: colecao (nome, obrigatório), formato. Exemplo: exportar_bbt(colecao="2 Incluídos", formato="biblatex")"""
url = f"{BASE}/better-bibtex/export/collection?/{urllib.parse.quote(colecao)}.{formato}"
try:
with urllib.request.urlopen(url, timeout=120) as r: return r.read().decode("utf-8")
except Exception as e:
raise RuntimeError(f"Exportação BBT falhou ({e}). O Better BibTeX está instalado e a coleção «{colecao}» existe?")
if __name__ == "__main__":
mcp.run()
SHA-256: b65bcf765575eba769d502c1ecb7742ef05589e5c35ad8e11181e86439dad916