# TecJustiça MCP v4.0 — Especificação de Novas Tools

## Documento de Referência para Implementação

**Autor:** Marcos Fonseca  
**Data:** 2026-02-14  
**Stack:** Python · FastMCP · Supabase PostgreSQL  
**Objetivo:** Redesenhar as tools do MCP TecJustiça seguindo padrões de Deep Agents (Claude Code, LangChain) e best practices da comunidade MCP.

---

## 1. Contexto e Motivação

### 1.1 Problema Atual

O MCP TecJustiça v3.0 possui 4 tools genéricas:

| Tool Atual | Problemas |
|---|---|
| `explorar_processo` | Faz muita coisa, retorno pesado, sem paginação com metadados |
| `consultar_documentos` | Mistura ls/read/grep/glob numa tool só |
| `consultar_movimentacoes` | Sem busca full-text real, sem snippets |
| `analisar` | Tool de LLM, não de dados — ok manter separada |

### 1.2 O que a Comunidade Ensina

Referências estudadas:

- **Philipp Schmid (HuggingFace)**: "MCP is a User Interface for AI agents, not a REST API wrapper. Design for outcomes, not operations."
- **Anthropic Engineering Blog**: "Code execution with MCP — agents scale better writing code to call tools instead of sequential tool calls."
- **LangChain Deep Agents**: Padrão filesystem (ls, read, grep, glob) para agentes que planejam e executam em sessões longas.
- **Claude Code**: TodoWrite + grep/glob/read como tools core. Tool results incluem "system hints" que guiam o próximo passo.
- **Arcade.dev (54 Patterns)**: "Working isn't the same as agent-usable."
- **Tinybird**: Paginação com metadados, descriptions como instruções, retornos enxutos.

### 1.3 Princípios de Design

1. **Outcomes, not Operations** — Uma tool de visão geral em vez de 3 chamadas separadas
2. **Retornos enxutos por padrão** — ls retorna metadados, grep retorna snippets, read retorna conteúdo
3. **Paginação obrigatória** — Toda lista retorna `total`, `limit`, `offset`, `has_more`
4. **Descriptions como instruções** — Dizem QUANDO usar E QUANDO NÃO usar
5. **Erros informativos** — Retornam sugestão de próximo passo, nunca exceção genérica
6. **Máximo 10 tools** — Cada tool consome ~400-500 tokens de definição no contexto
7. **Controle de tamanho** — Agente decide quanto ler via `max_chars` e `offset`

---

## 2. Esquema do Banco de Dados (Estado Atual)

### 2.1 Tabela `processos` (127 registros, RLS ativo)

```sql
-- Metadados do processo judicial vindos da API externa (PDPJ Extractor)
CREATE TABLE public.processos (
  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  job_id              uuid REFERENCES jobs(id),
  numero_processo     varchar UNIQUE NOT NULL,
  processo_id         varchar,
  sigla_tribunal      varchar,
  nome_tribunal       varchar,
  segmento_justica    varchar,
  data_ajuizamento    timestamptz,
  instancia           varchar,
  natureza            varchar,
  valor_causa         numeric,
  justica_gratuita    boolean DEFAULT false,
  nivel_sigilo        integer DEFAULT 0,
  classe_codigo       integer,
  classe_descricao    varchar,
  orgao_julgador_id   integer,
  orgao_julgador_nome varchar,
  cidade_ibge         varchar,
  cidade_nome         varchar,
  uf                  varchar,
  assuntos            jsonb,     -- Array de assuntos [{codigo, descricao}]
  partes              jsonb,     -- Array de partes [{nome, tipo, polo, advogados}]
  total_documentos    integer,
  total_movimentos    integer,
  raw_data            jsonb,
  synced_at           timestamptz DEFAULT now(),
  created_at          timestamptz DEFAULT now(),
  updated_at          timestamptz DEFAULT now()
);
```

### 2.2 Tabela `documents` (17.434 registros, RLS ativo)

```sql
CREATE TABLE public.documents (
  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  job_id              uuid REFERENCES jobs(id),       -- NULLABLE agora
  numero_processo     varchar NOT NULL,
  tipo                varchar(200),                    -- tipo legado
  data                varchar(20),                     -- data legado
  conteudo            text,                            -- CAMPO PRINCIPAL para grep
  metodo_extracao     varchar(50),
  paginas             integer,
  created_at          timestamptz DEFAULT now(),
  -- Campos novos (PDPJ Extractor)
  api_document_id     varchar,                         -- ID original da API externa
  nome                varchar,                         -- Nome completo com extensão
  sequencia           integer,                         -- Sequência no processo (maior = mais recente)
  id_codex            bigint,                          -- ID no sistema Codex
  id_origem           varchar,                         -- ID no sistema PJE de origem
  nivel_sigilo        varchar,                         -- PUBLICO, SEGREDO_JUSTICA, etc
  data_juntada        timestamptz,                     -- Data/hora de juntada
  tamanho_bytes       bigint,                          -- Tamanho do arquivo em bytes
  tamanho_texto       integer,                         -- Tamanho do texto extraído em caracteres
  tipo_arquivo        varchar,                         -- Tipo MIME: APPLICATION_PDF, TEXT_HTML
  tipo_codigo         integer,                         -- Código do tipo
  tipo_nome           varchar,                         -- Nome do tipo de documento
  signatarios         jsonb,                           -- Array JSON com signatários
  metadados_synced_at timestamptz                      -- Última sincronização
);

-- Índices existentes
CREATE INDEX idx_documents_job_id ON documents(job_id);
CREATE INDEX idx_documents_numero_processo ON documents(numero_processo);
CREATE INDEX idx_documents_tipo ON documents(tipo);
```

### 2.3 Tabela `movimentacoes` (12.769 registros, RLS ativo)

```sql
-- Timeline de movimentações processuais vindas da API externa (PDPJ Extractor)
CREATE TABLE public.movimentacoes (
  id                  uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  job_id              uuid REFERENCES jobs(id),
  numero_processo     varchar NOT NULL,
  sequencia           integer NOT NULL,
  id_codex            bigint,
  id_origem           varchar,
  data_hora           timestamptz NOT NULL,
  descricao           text NOT NULL,
  tipo_id             integer,
  tipo_nome           varchar,
  tipo_descricao      text,
  tipo_hierarquia     text,
  orgao_julgador_id   integer,
  orgao_julgador_nome varchar,
  documento_id        varchar,
  magistrado_nome     varchar,
  usuario_nome        varchar,
  complementos        jsonb,
  created_at          timestamptz DEFAULT now()
);
```

---

## 3. Migration SQL — Full-Text Search

**IMPORTANTE**: Executar ANTES de implementar as tools. Sem isso, `grep_documentos` funcionará mas será lento (scan sequencial em 17k registros).

### 3.1 Migration: Coluna FTS + Índice GIN

```sql
-- ============================================================
-- Migration: add_fts_documents
-- Descrição: Adiciona coluna tsvector gerada automaticamente
--            e índice GIN para busca full-text performática
-- ============================================================

-- 1. Coluna tsvector gerada automaticamente
--    Combina tipo_nome + nome + conteudo para busca unificada
--    Usa config 'portuguese' para stemming em PT-BR
ALTER TABLE public.documents
ADD COLUMN IF NOT EXISTS fts tsvector
GENERATED ALWAYS AS (
  to_tsvector('portuguese',
    coalesce(tipo_nome, '') || ' ' ||
    coalesce(nome, '') || ' ' ||
    coalesce(conteudo, '')
  )
) STORED;

-- 2. Índice GIN — essencial para performance
--    Sem ele: ~2-5 segundos por busca (seq scan em 17k registros)
--    Com ele: ~10-50ms por busca
CREATE INDEX IF NOT EXISTS idx_documents_fts
ON public.documents USING gin(fts);

-- 3. (Opcional) Extensão pg_trgm para busca fuzzy
--    Útil quando OCR gera texto com erros de digitação
CREATE EXTENSION IF NOT EXISTS pg_trgm;

-- 4. (Opcional) Índice trigram para busca fuzzy
--    Só habilitar se for usar modo "fuzzy" no grep
-- CREATE INDEX IF NOT EXISTS idx_documents_conteudo_trgm
-- ON public.documents USING gin(conteudo gin_trgm_ops);
```

### 3.2 Migration: Coluna FTS para Movimentações

```sql
-- ============================================================
-- Migration: add_fts_movimentacoes
-- Descrição: FTS para busca em descrições de movimentações
-- ============================================================

ALTER TABLE public.movimentacoes
ADD COLUMN IF NOT EXISTS fts tsvector
GENERATED ALWAYS AS (
  to_tsvector('portuguese',
    coalesce(descricao, '') || ' ' ||
    coalesce(tipo_nome, '') || ' ' ||
    coalesce(tipo_descricao, '')
  )
) STORED;

CREATE INDEX IF NOT EXISTS idx_movimentacoes_fts
ON public.movimentacoes USING gin(fts);
```

### 3.3 Migration: Função RPC `buscar_documentos`

```sql
-- ============================================================
-- Migration: create_rpc_buscar_documentos
-- Descrição: Função RPC para busca multi-modo em documentos
--            Chamada via supabase.rpc('buscar_documentos', {...})
-- ============================================================

CREATE OR REPLACE FUNCTION public.buscar_documentos(
  p_termo text,
  p_modo text DEFAULT 'fulltext',
  p_numero_processo text DEFAULT NULL,
  p_tipo_nome text DEFAULT NULL,
  p_limite int DEFAULT 20,
  p_offset int DEFAULT 0
)
RETURNS TABLE(
  id uuid,
  numero_processo varchar,
  tipo_nome varchar,
  nome varchar,
  sequencia int,
  data_juntada timestamptz,
  nivel_sigilo varchar,
  tamanho_texto int,
  relevancia float4,
  snippet text,
  total_count bigint
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
  RETURN QUERY
  WITH resultados AS (
    SELECT
      d.id,
      d.numero_processo,
      d.tipo_nome,
      d.nome,
      d.sequencia,
      d.data_juntada,
      d.nivel_sigilo,
      d.tamanho_texto,
      -- Cálculo de relevância por modo
      CASE p_modo
        WHEN 'fulltext' THEN
          ts_rank(d.fts, websearch_to_tsquery('portuguese', p_termo))
        ELSE 0.0
      END::float4 AS relevancia,
      -- Geração de snippet por modo
      CASE p_modo
        WHEN 'fulltext' THEN
          ts_headline('portuguese', d.conteudo,
            websearch_to_tsquery('portuguese', p_termo),
            'StartSel=>>>, StopSel=<<<, MaxWords=35, MinWords=15, MaxFragments=2')
        WHEN 'regex' THEN
          substring(d.conteudo FROM
            greatest(1, position(
              substring(d.conteudo FROM p_termo) IN d.conteudo
            ) - 100)
            FOR 300)
        ELSE -- ilike
          substring(d.conteudo FROM
            greatest(1, position(lower(p_termo) IN lower(d.conteudo)) - 100)
            FOR 300)
      END AS snippet,
      count(*) OVER() AS total_count
    FROM public.documents d
    WHERE
      -- Filtros opcionais
      (p_numero_processo IS NULL OR d.numero_processo = p_numero_processo)
      AND (p_tipo_nome IS NULL OR d.tipo_nome ILIKE '%' || p_tipo_nome || '%')
      -- Filtro por modo de busca
      AND CASE p_modo
        WHEN 'fulltext' THEN
          d.fts @@ websearch_to_tsquery('portuguese', p_termo)
        WHEN 'regex' THEN
          d.conteudo ~* p_termo
        ELSE -- ilike
          d.conteudo ILIKE '%' || p_termo || '%'
      END
      -- Só documentos com conteúdo
      AND d.conteudo IS NOT NULL
      AND d.conteudo != ''
  )
  SELECT * FROM resultados
  ORDER BY
    CASE WHEN p_modo = 'fulltext' THEN resultados.relevancia ELSE 0 END DESC,
    resultados.sequencia DESC NULLS LAST
  LIMIT p_limite
  OFFSET p_offset;
END;
$$;

-- Permissão para usuários autenticados
GRANT EXECUTE ON FUNCTION public.buscar_documentos TO authenticated;
```

### 3.4 Migration: Função RPC `buscar_movimentacoes`

```sql
-- ============================================================
-- Migration: create_rpc_buscar_movimentacoes
-- Descrição: Função RPC para busca full-text em movimentações
-- ============================================================

CREATE OR REPLACE FUNCTION public.buscar_movimentacoes(
  p_termo text,
  p_numero_processo text DEFAULT NULL,
  p_tipo_nome text DEFAULT NULL,
  p_limite int DEFAULT 30,
  p_offset int DEFAULT 0
)
RETURNS TABLE(
  id uuid,
  numero_processo varchar,
  sequencia int,
  data_hora timestamptz,
  descricao text,
  tipo_nome varchar,
  magistrado_nome varchar,
  orgao_julgador_nome varchar,
  snippet text,
  total_count bigint
)
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
BEGIN
  RETURN QUERY
  SELECT
    m.id,
    m.numero_processo,
    m.sequencia,
    m.data_hora,
    m.descricao,
    m.tipo_nome,
    m.magistrado_nome,
    m.orgao_julgador_nome,
    ts_headline('portuguese', m.descricao,
      websearch_to_tsquery('portuguese', p_termo),
      'StartSel=>>>, StopSel=<<<, MaxWords=35, MinWords=15'
    ) AS snippet,
    count(*) OVER() AS total_count
  FROM public.movimentacoes m
  WHERE
    m.fts @@ websearch_to_tsquery('portuguese', p_termo)
    AND (p_numero_processo IS NULL OR m.numero_processo = p_numero_processo)
    AND (p_tipo_nome IS NULL OR m.tipo_nome ILIKE '%' || p_tipo_nome || '%')
  ORDER BY m.data_hora DESC
  LIMIT p_limite
  OFFSET p_offset;
END;
$$;

GRANT EXECUTE ON FUNCTION public.buscar_movimentacoes TO authenticated;
```

### 3.5 Migration: Função RPC `stats_documentos`

```sql
-- ============================================================
-- Migration: create_rpc_stats_documentos
-- Descrição: Estatísticas agregadas dos documentos de um processo
--            Retorna contagens sem nenhum conteúdo de texto
-- ============================================================

CREATE OR REPLACE FUNCTION public.stats_documentos(
  p_numero_processo text
)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
AS $$
DECLARE
  resultado jsonb;
BEGIN
  SELECT jsonb_build_object(
    'numero_processo', p_numero_processo,
    'total_documentos', count(*),
    'com_conteudo', count(*) FILTER (WHERE conteudo IS NOT NULL AND conteudo != ''),
    'sem_conteudo', count(*) FILTER (WHERE conteudo IS NULL OR conteudo = ''),
    'tamanho_total_chars', coalesce(sum(tamanho_texto), 0),
    'tamanho_total_bytes', coalesce(sum(tamanho_bytes), 0),
    'por_tipo', (
      SELECT jsonb_object_agg(
        coalesce(sub.tipo_nome, 'Sem tipo'),
        jsonb_build_object('quantidade', sub.qtd, 'chars_total', sub.chars)
      )
      FROM (
        SELECT d2.tipo_nome, count(*) AS qtd, coalesce(sum(d2.tamanho_texto), 0) AS chars
        FROM public.documents d2
        WHERE d2.numero_processo = p_numero_processo
        GROUP BY d2.tipo_nome
        ORDER BY qtd DESC
      ) sub
    ),
    'por_sigilo', (
      SELECT jsonb_object_agg(
        coalesce(sub2.nivel_sigilo, 'NAO_INFORMADO'),
        sub2.qtd
      )
      FROM (
        SELECT d3.nivel_sigilo, count(*) AS qtd
        FROM public.documents d3
        WHERE d3.numero_processo = p_numero_processo
        GROUP BY d3.nivel_sigilo
      ) sub2
    ),
    'periodo', jsonb_build_object(
      'primeiro_documento', min(data_juntada),
      'ultimo_documento', max(data_juntada)
    )
  ) INTO resultado
  FROM public.documents
  WHERE numero_processo = p_numero_processo;

  RETURN resultado;
END;
$$;

GRANT EXECUTE ON FUNCTION public.stats_documentos TO authenticated;
```

### 3.6 Migration: Função RPC `localizar_no_documento`

```sql
-- ============================================================
-- Migration: create_rpc_localizar_no_documento
-- Descrição: Encontra todas as posições de um termo dentro de
--            um documento específico. Retorna offsets + snippets.
--            O agente usa os offsets com read_documento(offset=X)
--            para leitura cirúrgica do trecho que importa.
--
-- Performance: SQL puro (LANGUAGE sql), sem loops PL/pgSQL.
--   - Uma única leitura do documento (WITH doc)
--   - generate_series + position() nativos do PostgreSQL
--   - DISTINCT ON elimina duplicatas automaticamente
--   - LIMIT na CTE impede varredura desnecessária
--   - Retorna JSON pronto, sem pós-processamento no Python
-- ============================================================

CREATE OR REPLACE FUNCTION public.localizar_no_documento(
  p_document_id uuid,
  p_termo text,
  p_contexto_chars int DEFAULT 100,
  p_max_ocorrencias int DEFAULT 30
)
RETURNS jsonb
LANGUAGE sql
SECURITY DEFINER
AS $$
  WITH doc AS (
    -- Leitura única do documento
    SELECT
      conteudo,
      lower(conteudo) AS conteudo_lower,
      coalesce(tamanho_texto, length(conteudo)) AS tamanho
    FROM public.documents
    WHERE id = p_document_id
      AND conteudo IS NOT NULL
      AND conteudo != ''
  ),
  posicoes AS (
    -- Varrer texto em saltos do tamanho do termo
    -- position() encontra a primeira ocorrência a partir de cada bloco
    SELECT DISTINCT ON (pos)
      bloco - 1 + position(
        lower(p_termo) IN substring(doc.conteudo_lower FROM bloco)
      ) - 1 AS pos
    FROM doc,
         generate_series(1, doc.tamanho, greatest(1, length(p_termo))) AS bloco
    WHERE position(lower(p_termo) IN substring(doc.conteudo_lower FROM bloco)) > 0
    ORDER BY pos
    LIMIT p_max_ocorrencias
  ),
  com_snippet AS (
    -- Gerar snippet ao redor de cada posição
    SELECT
      p.pos AS posicao,
      substring(
        (SELECT conteudo FROM doc)
        FROM greatest(1, p.pos + 1 - p_contexto_chars)
        FOR (p_contexto_chars * 2 + length(p_termo))
      ) AS snippet
    FROM posicoes p
    ORDER BY p.pos
  )
  -- Retorno JSON pronto para o MCP
  SELECT jsonb_build_object(
    'document_id', p_document_id,
    'termo', p_termo,
    'total_ocorrencias', (SELECT count(*) FROM com_snippet),
    'tamanho_documento', (SELECT tamanho FROM doc),
    'ocorrencias', coalesce(
      (SELECT jsonb_agg(
        jsonb_build_object('posicao', posicao, 'snippet', snippet)
        ORDER BY posicao
      ) FROM com_snippet),
      '[]'::jsonb
    )
  );
$$;

GRANT EXECUTE ON FUNCTION public.localizar_no_documento TO authenticated;
```

---

## 4. Especificação das 10 Novas Tools (12 no total)

### Visão Geral da Arquitetura

```
                    ┌─────────────────────────────────────┐
                    │      TecJustiça MCP Server v4       │
                    │          (FastMCP + Python)          │
                    └─────────────────┬───────────────────┘
                                      │
      ┌───────────────────────────────┼───────────────────────────────┐
      │                               │                               │
┌─────┴──────┐               ┌────────┴───────┐               ┌──────┴─────┐
│ NAVEGAÇÃO  │               │     BUSCA      │               │  ANÁLISE   │
│            │               │                │               │            │
│ 1.listar   │               │ 6.grep_docs    │               │10.stats    │
│   _procs   │               │ 7.grep_movs    │               │   _docs    │
│ 2.visao    │               │ 8.glob_docs    │               │            │
│   _geral   │               │ 9.localizar    │               │ (analisar) │
│ 3.ls_docs  │               │   _no_doc      │               │ (whoami)   │
│ 4.ls_movs  │               │                │               │  [manter]  │
│ 5.read_doc │               │                │               │            │
└────────────┘               └────────────────┘               └────────────┘
      │                               │                               │
      └───────────────────────────────┼───────────────────────────────┘
                                      │
                    ┌─────────────────┴───────────────────┐
                    │       Supabase PostgreSQL            │
                    │   processos | documents | movs      │
                    │   + FTS (tsvector + GIN index)      │
                    │   + RPCs (buscar, localizar, stats) │
                    └─────────────────────────────────────┘
```

---

### 4.1 Tool: `listar_processos`

**Propósito**: Descobrir quais processos existem no sistema ou buscar por nome de parte. É a primeira tool quando o usuário não sabe o número do processo.

**Por que existe**: O agente precisa de um ponto de entrada para descobrir processos antes de poder usar `visao_geral_processo`. Busca por parte usa RPC existente `buscar_processos_por_parte`.

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def listar_processos(
    busca: str | None = None,
    limite: int = 20,
    offset: int = 0
) -> dict:
    """
    Lista processos disponíveis ou busca por nome de parte.

    QUANDO USAR: Descobrir quais processos existem no sistema.
    Buscar processo pelo nome de uma parte (autor, réu, advogado).
    Primeira tool a chamar quando o usuário menciona um processo
    sem dar o número.

    QUANDO NÃO USAR: Já tem o número do processo — use
    visao_geral_processo(). Quer documentos ou movimentações —
    use ls_documentos() ou ls_movimentacoes().

    Args:
        busca: Nome da parte para buscar (opcional). Se omitido, lista todos.
        limite: Máximo de resultados (padrão 20, máximo 50).
        offset: Pular N resultados para paginação.

    Returns:
        dict com:
        - processos: lista com numero_processo, classe, tribunal, partes
        - paginacao: {total, limit, offset, has_more}
    """
```

---

### 4.2 Tool: `visao_geral_processo`

**Propósito**: Visão consolidada de um processo. O agente chama esta tool após identificar o número do processo. Retorna tudo que ele precisa pra decidir os próximos passos, em UMA chamada.

**Por que existe**: Evita 3 round-trips separados (processo + count docs + count movs). Segue o princípio "Outcomes, not Operations".

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def visao_geral_processo(numero_processo: str) -> dict:
    """
    Visão consolidada de um processo judicial em UMA chamada.

    QUANDO USAR: Como PRIMEIRO passo de qualquer análise.
    Retorna metadados, partes, assuntos, contagem de documentos
    por tipo, e últimas movimentações.

    QUANDO NÃO USAR: Se já tem os metadados e precisa apenas
    buscar conteúdo. Nesse caso, use grep_documentos ou
    ls_documentos diretamente.

    Args:
        numero_processo: Número CNJ do processo.
                         Formato: 0000000-00.0000.0.00.0000

    Returns:
        dict com:
        - metadados: tribunal, classe, órgão julgador, valor, etc
        - partes: lista de partes com polo e advogados
        - assuntos: lista de assuntos do processo
        - stats_documentos: contagem por tipo (sem conteúdo)
        - ultimas_movimentacoes: 10 mais recentes (resumo)
        - dica: sugestão do próximo passo
    """
```

**Implementação de referência** (lógica interna):

```python
async def _visao_geral_processo(self, numero_processo: str) -> dict:
    # 1. Buscar metadados do processo
    processo = await self.supabase.table("processos") \
        .select("*") \
        .eq("numero_processo", numero_processo) \
        .maybe_single() \
        .execute()

    if not processo.data:
        return {
            "erro": f"Processo {numero_processo} não encontrado na base.",
            "dica": "Verifique o número CNJ. Formato: 0000000-00.0000.0.00.0000"
        }

    p = processo.data

    # 2. Stats de documentos (via RPC — sem carregar conteúdo)
    stats = await self.supabase.rpc(
        "stats_documentos",
        {"p_numero_processo": numero_processo}
    ).execute()

    # 3. Últimas 10 movimentações (só metadados)
    movs = await self.supabase.table("movimentacoes") \
        .select("sequencia, data_hora, tipo_nome, descricao, magistrado_nome") \
        .eq("numero_processo", numero_processo) \
        .order("data_hora", desc=True) \
        .limit(10) \
        .execute()

    # 4. Montar retorno consolidado
    return {
        "metadados": {
            "numero_processo": p["numero_processo"],
            "tribunal": p.get("nome_tribunal"),
            "sigla_tribunal": p.get("sigla_tribunal"),
            "instancia": p.get("instancia"),
            "classe": p.get("classe_descricao"),
            "natureza": p.get("natureza"),
            "orgao_julgador": p.get("orgao_julgador_nome"),
            "cidade": p.get("cidade_nome"),
            "uf": p.get("uf"),
            "data_ajuizamento": p.get("data_ajuizamento"),
            "valor_causa": float(p["valor_causa"]) if p.get("valor_causa") else None,
            "justica_gratuita": p.get("justica_gratuita"),
            "nivel_sigilo": p.get("nivel_sigilo"),
        },
        "partes": p.get("partes", []),
        "assuntos": p.get("assuntos", []),
        "stats_documentos": stats.data if stats.data else {},
        "ultimas_movimentacoes": [
            {
                "seq": m["sequencia"],
                "data": m["data_hora"],
                "tipo": m["tipo_nome"],
                "descricao": m["descricao"][:200],  # Truncar descrição longa
                "magistrado": m.get("magistrado_nome")
            }
            for m in (movs.data or [])
        ],
        "dica": (
            "Use ls_documentos para ver a lista completa de documentos, "
            "grep_documentos para buscar termos específicos no conteúdo, "
            "ou stats_documentos para contagens detalhadas."
        )
    }
```

---

### 4.3 Tool: `ls_documentos`

**Propósito**: Listar documentos com metadados leves. NUNCA retorna `conteudo`. É o equivalente a `ls` de um diretório.

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def ls_documentos(
    numero_processo: str,
    limite: int = 30,
    offset: int = 0,
    ordem: Literal["recentes", "antigos"] = "recentes"
) -> dict:
    """
    Lista documentos do processo com metadados leves (sem conteúdo).
    Equivalente a 'ls' — mostra o que existe, sem abrir.

    QUANDO USAR: Para ver quais documentos existem antes de
    decidir o que ler ou buscar. Use APÓS visao_geral_processo.

    QUANDO NÃO USAR: Se precisa buscar um termo no conteúdo,
    use grep_documentos. Se precisa filtrar por tipo ou nome,
    use glob_documentos.

    Args:
        numero_processo: Número CNJ do processo.
        limite: Máximo de registros (padrão 30, máximo 100).
        offset: Pular N registros (paginação).
        ordem: "recentes" (padrão) ou "antigos".

    Returns:
        dict com:
        - documentos: lista de metadados (id, tipo_nome, nome,
          sequencia, data_juntada, tamanho_texto, nivel_sigilo,
          metodo_extracao, signatarios)
        - paginacao: {total, limit, offset, has_more}
    """
```

**Implementação de referência**:

```python
async def _ls_documentos(self, numero_processo, limite, offset, ordem):
    # Limitar máximo
    limite = min(limite, 100)

    # Direção da ordem
    desc = (ordem == "recentes")

    # Query SEM campo conteudo
    query = self.supabase.table("documents") \
        .select(
            "id, numero_processo, tipo_nome, nome, sequencia, "
            "data_juntada, tamanho_texto, tamanho_bytes, nivel_sigilo, "
            "metodo_extracao, signatarios, tipo_arquivo",
            count="exact"  # Retorna total para paginação
        ) \
        .eq("numero_processo", numero_processo) \
        .order("sequencia", desc=desc) \
        .range(offset, offset + limite - 1)

    resultado = await query.execute()

    total = resultado.count or 0

    return {
        "documentos": resultado.data or [],
        "paginacao": {
            "total": total,
            "limit": limite,
            "offset": offset,
            "has_more": (offset + limite) < total
        },
        "dica": (
            "Use read_documento(id) para ler o conteúdo de um documento específico. "
            "Use grep_documentos para buscar termos no conteúdo."
        )
    }
```

---

### 4.4 Tool: `ls_movimentacoes`

**Propósito**: Listar movimentações processuais. Timeline do processo.

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def ls_movimentacoes(
    numero_processo: str,
    limite: int = 30,
    offset: int = 0,
    ordem: Literal["recentes", "antigos"] = "recentes"
) -> dict:
    """
    Lista movimentações do processo em ordem cronológica.
    Mostra a timeline do processo.

    QUANDO USAR: Para ver o histórico de movimentações.
    Use APÓS visao_geral_processo.

    QUANDO NÃO USAR: Se precisa buscar um termo específico
    nas movimentações, use grep_movimentacoes.

    Args:
        numero_processo: Número CNJ do processo.
        limite: Máximo de registros (padrão 30, máximo 100).
        offset: Pular N registros (paginação).
        ordem: "recentes" (padrão) ou "antigos".

    Returns:
        dict com:
        - movimentacoes: lista com sequencia, data_hora, tipo_nome,
          descricao (truncada em 300 chars), magistrado_nome,
          orgao_julgador_nome
        - paginacao: {total, limit, offset, has_more}
    """
```

**Implementação de referência**:

```python
async def _ls_movimentacoes(self, numero_processo, limite, offset, ordem):
    limite = min(limite, 100)
    desc = (ordem == "recentes")

    query = self.supabase.table("movimentacoes") \
        .select(
            "id, sequencia, data_hora, tipo_nome, descricao, "
            "magistrado_nome, orgao_julgador_nome, documento_id",
            count="exact"
        ) \
        .eq("numero_processo", numero_processo) \
        .order("data_hora", desc=desc) \
        .range(offset, offset + limite - 1)

    resultado = await query.execute()
    total = resultado.count or 0

    # Truncar descrições longas para economizar contexto
    movs = []
    for m in (resultado.data or []):
        m_copy = dict(m)
        if m_copy.get("descricao") and len(m_copy["descricao"]) > 300:
            m_copy["descricao"] = m_copy["descricao"][:300] + "..."
        movs.append(m_copy)

    return {
        "movimentacoes": movs,
        "paginacao": {
            "total": total,
            "limit": limite,
            "offset": offset,
            "has_more": (offset + limite) < total
        },
        "dica": (
            "Use grep_movimentacoes para buscar termos específicos. "
            "Para movimentações com documento vinculado, o campo "
            "documento_id permite cruzar com ls_documentos."
        )
    }
```

---

### 4.5 Tool: `read_documento`

**Propósito**: Ler o conteúdo completo (ou parcial) de UM documento. Equivalente a `cat` ou `read_file`. O agente controla quanto ler via `max_chars` e `offset`.

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def read_documento(
    document_id: str,
    max_chars: int = 10000,
    offset: int = 0
) -> dict:
    """
    Lê o conteúdo de UM documento específico.
    Equivalente a 'cat' ou 'read_file'.

    QUANDO USAR: Após identificar o documento via ls_documentos
    ou grep_documentos. Use para ler o texto completo ou parcial.

    QUANDO NÃO USAR: Se precisa buscar um termo em VÁRIOS
    documentos, use grep_documentos (mais eficiente).

    CONTROLE DE TAMANHO: O conteúdo pode ser muito grande
    (documentos jurídicos frequentemente têm 50k+ caracteres).
    Use max_chars para limitar. Se tem_mais=true, chame
    novamente com offset incrementado.

    Args:
        document_id: UUID do documento (obtido via ls ou grep).
        max_chars: Máximo de caracteres a retornar (padrão 10000).
                   Use valores menores (3000-5000) para economizar
                   contexto, ou maiores (20000-50000) se precisa
                   do texto completo.
        offset: Posição inicial no texto (para leitura paginada).
                0 = início do documento.

    Returns:
        dict com:
        - metadados: tipo_nome, nome, sequencia, data_juntada, etc
        - conteudo: texto do documento (limitado a max_chars)
        - leitura: {offset, chars_retornados, tamanho_total, tem_mais}
    """
```

**Implementação de referência**:

```python
async def _read_documento(self, document_id, max_chars, offset):
    # Buscar documento com conteúdo
    resultado = await self.supabase.table("documents") \
        .select(
            "id, numero_processo, tipo_nome, nome, sequencia, "
            "data_juntada, nivel_sigilo, tamanho_texto, "
            "metodo_extracao, signatarios, conteudo"
        ) \
        .eq("id", document_id) \
        .maybe_single() \
        .execute()

    if not resultado.data:
        return {
            "erro": f"Documento {document_id} não encontrado.",
            "dica": "Use ls_documentos para listar documentos disponíveis."
        }

    doc = resultado.data
    conteudo_completo = doc.get("conteudo") or ""
    tamanho_total = len(conteudo_completo)

    # Aplicar offset e limite
    trecho = conteudo_completo[offset:offset + max_chars]
    tem_mais = (offset + max_chars) < tamanho_total

    # Remover conteudo completo do retorno de metadados
    metadados = {k: v for k, v in doc.items() if k != "conteudo"}

    return {
        "metadados": metadados,
        "conteudo": trecho,
        "leitura": {
            "offset": offset,
            "chars_retornados": len(trecho),
            "tamanho_total": tamanho_total,
            "tem_mais": tem_mais,
            "proximo_offset": offset + max_chars if tem_mais else None
        },
        "dica": (
            f"Exibindo chars {offset}-{offset + len(trecho)} de {tamanho_total}."
            + (f" Para continuar, chame read_documento('{document_id}', "
               f"offset={offset + max_chars})." if tem_mais else " Fim do documento.")
        )
    }
```

---

### 4.6 Tool: `grep_documentos`

**Propósito**: Busca full-text/regex/ilike no conteúdo dos documentos. Retorna snippets com highlight, NUNCA o texto completo. É o equivalente a `grep`.

**Esta é a tool mais importante do MCP.**

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def grep_documentos(
    padrao: str,
    modo: Literal["fulltext", "regex", "ilike"] = "fulltext",
    numero_processo: str | None = None,
    tipo_nome: str | None = None,
    limite: int = 20,
    offset: int = 0
) -> dict:
    """
    Busca padrão no conteúdo dos documentos (como grep).
    Retorna snippets com highlight (>>>termo<<<), NÃO o texto
    completo. Use read_documento para ler o texto completo
    de um match específico.

    QUANDO USAR: Para encontrar menções a termos, artigos de lei,
    valores monetários ou nomes dentro do texto dos documentos.

    QUANDO NÃO USAR: Para filtrar por tipo ou nome do documento
    (use glob_documentos). Para ler o texto completo de um
    documento já identificado (use read_documento).

    MODOS:
    - fulltext: Busca semântica em português com ranking de
      relevância. Suporta operadores: "dano moral" (frase),
      "dano OR material" (alternativa), "-trabalhista" (exclusão).
      MAIS RÁPIDO — usa índice GIN.
    - regex: Busca por padrão regex POSIX. Útil para extrair
      dados estruturados como valores (R\$\s*[\d\.]+,\d{2}),
      artigos de lei (Art\.\s*\d+), CPFs, datas.
      MAIS LENTO — scan sequencial.
    - ilike: Busca exata case-insensitive. Simples mas lenta.
      Útil para termos que o FTS não encontra.

    Args:
        padrao: Termo de busca ou padrão regex.
        modo: "fulltext" (padrão), "regex" ou "ilike".
        numero_processo: Filtrar por processo específico (opcional).
        tipo_nome: Filtrar por tipo de documento (opcional, parcial).
                   Ex: "sentença", "petição", "decisão"
        limite: Máximo de resultados (padrão 20).
        offset: Paginação.

    Returns:
        dict com:
        - matches: lista de {id, numero_processo, tipo_nome, nome,
          sequencia, data_juntada, relevancia, snippet}
        - paginacao: {total, limit, offset, has_more}
    """
```

**Implementação de referência**:

```python
async def _grep_documentos(self, padrao, modo, numero_processo,
                            tipo_nome, limite, offset):
    # Chamar RPC do Supabase (função SQL criada na migration)
    resultado = await self.supabase.rpc("buscar_documentos", {
        "p_termo": padrao,
        "p_modo": modo,
        "p_numero_processo": numero_processo,
        "p_tipo_nome": tipo_nome,
        "p_limite": limite,
        "p_offset": offset
    }).execute()

    dados = resultado.data or []
    total = dados[0]["total_count"] if dados else 0

    # Formatar retorno limpo
    matches = [
        {
            "id": d["id"],
            "numero_processo": d["numero_processo"],
            "tipo_nome": d["tipo_nome"],
            "nome": d["nome"],
            "sequencia": d["sequencia"],
            "data_juntada": d["data_juntada"],
            "nivel_sigilo": d["nivel_sigilo"],
            "tamanho_texto": d["tamanho_texto"],
            "relevancia": d["relevancia"],
            "snippet": d["snippet"]
        }
        for d in dados
    ]

    return {
        "matches": matches,
        "paginacao": {
            "total": int(total),
            "limit": limite,
            "offset": offset,
            "has_more": (offset + limite) < int(total)
        },
        "dica": (
            "Use read_documento(id) para ler o conteúdo completo "
            "de qualquer match. Snippets com >>>termo<<< mostram "
            "onde o padrão aparece no texto."
        )
    }
```

---

### 4.7 Tool: `grep_movimentacoes`

**Propósito**: Busca full-text nas descrições das movimentações.

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def grep_movimentacoes(
    padrao: str,
    numero_processo: str | None = None,
    tipo_nome: str | None = None,
    limite: int = 30,
    offset: int = 0
) -> dict:
    """
    Busca full-text nas descrições das movimentações processuais.
    Retorna snippets com highlight (>>>termo<<<).

    QUANDO USAR: Para encontrar movimentações específicas como
    "sentença", "citação", "penhora", "audiência" ou nomes
    de magistrados no histórico do processo.

    QUANDO NÃO USAR: Para ver a timeline completa, use
    ls_movimentacoes. Para buscar no conteúdo dos documentos,
    use grep_documentos.

    Args:
        padrao: Termo de busca (sintaxe web: "tutela urgência",
                "sentença OR acórdão", etc).
        numero_processo: Filtrar por processo específico (opcional).
        tipo_nome: Filtrar por tipo de movimentação (opcional).
        limite: Máximo de resultados (padrão 30).
        offset: Paginação.

    Returns:
        dict com:
        - matches: lista de {id, numero_processo, sequencia,
          data_hora, tipo_nome, descricao, magistrado_nome,
          orgao_julgador_nome, snippet}
        - paginacao: {total, limit, offset, has_more}
    """
```

**Implementação de referência**:

```python
async def _grep_movimentacoes(self, padrao, numero_processo,
                               tipo_nome, limite, offset):
    resultado = await self.supabase.rpc("buscar_movimentacoes", {
        "p_termo": padrao,
        "p_numero_processo": numero_processo,
        "p_tipo_nome": tipo_nome,
        "p_limite": limite,
        "p_offset": offset
    }).execute()

    dados = resultado.data or []
    total = dados[0]["total_count"] if dados else 0

    matches = [
        {
            "id": d["id"],
            "numero_processo": d["numero_processo"],
            "sequencia": d["sequencia"],
            "data_hora": d["data_hora"],
            "tipo_nome": d["tipo_nome"],
            "descricao": d["descricao"][:300],
            "magistrado_nome": d["magistrado_nome"],
            "orgao_julgador_nome": d["orgao_julgador_nome"],
            "snippet": d["snippet"]
        }
        for d in dados
    ]

    return {
        "matches": matches,
        "paginacao": {
            "total": int(total),
            "limit": limite,
            "offset": offset,
            "has_more": (offset + limite) < int(total)
        }
    }
```

---

### 4.8 Tool: `glob_documentos`

**Propósito**: Filtrar documentos por padrão no nome ou tipo (wildcard). Retorna metadados sem conteúdo. Equivalente a `glob` ou `find`.

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def glob_documentos(
    numero_processo: str,
    padrao_nome: str | None = None,
    padrao_tipo: str | None = None,
    nivel_sigilo: str | None = None,
    limite: int = 50,
    offset: int = 0
) -> dict:
    """
    Filtra documentos por nome, tipo ou sigilo (como glob/find).
    Retorna metadados SEM conteúdo. Usa ILIKE (case-insensitive,
    busca parcial automática).

    QUANDO USAR: Para encontrar documentos por tipo
    ("sentença", "petição inicial", "laudo") ou por nome
    do arquivo, sem buscar no conteúdo do texto.

    QUANDO NÃO USAR: Para buscar DENTRO do conteúdo dos
    documentos, use grep_documentos.

    Args:
        numero_processo: Número CNJ do processo.
        padrao_nome: Filtro no campo 'nome' do documento.
                     Ex: "sentenca", "peticao_inicial".
                     Busca parcial, case-insensitive.
        padrao_tipo: Filtro no campo 'tipo_nome'.
                     Ex: "Sentença", "Petição", "Decisão",
                     "Certidão", "Contestação".
        nivel_sigilo: Filtro por nível de sigilo.
                      Ex: "PUBLICO", "SEGREDO_JUSTICA".
        limite: Máximo de resultados (padrão 50).
        offset: Paginação.

    Returns:
        dict com:
        - documentos: lista de metadados (sem conteúdo)
        - paginacao: {total, limit, offset, has_more}
    """
```

**Implementação de referência**:

```python
async def _glob_documentos(self, numero_processo, padrao_nome,
                            padrao_tipo, nivel_sigilo, limite, offset):
    limite = min(limite, 100)

    query = self.supabase.table("documents") \
        .select(
            "id, numero_processo, tipo_nome, nome, sequencia, "
            "data_juntada, tamanho_texto, tamanho_bytes, nivel_sigilo, "
            "metodo_extracao, signatarios, tipo_arquivo",
            count="exact"
        ) \
        .eq("numero_processo", numero_processo)

    # Aplicar filtros opcionais (ILIKE = case-insensitive, parcial)
    if padrao_nome:
        query = query.ilike("nome", f"%{padrao_nome}%")
    if padrao_tipo:
        query = query.ilike("tipo_nome", f"%{padrao_tipo}%")
    if nivel_sigilo:
        query = query.eq("nivel_sigilo", nivel_sigilo)

    query = query.order("sequencia", desc=True) \
        .range(offset, offset + limite - 1)

    resultado = await query.execute()
    total = resultado.count or 0

    return {
        "documentos": resultado.data or [],
        "paginacao": {
            "total": total,
            "limit": limite,
            "offset": offset,
            "has_more": (offset + limite) < total
        },
        "dica": (
            "Use read_documento(id) para ler o conteúdo. "
            "Use grep_documentos para buscar termos no texto."
        )
    }
```

---

### 4.9 Tool: `stats_documentos`

**Propósito**: Contagens e métricas agregadas. Retorna ZERO conteúdo de texto. Útil para o agente entender a dimensão do processo antes de mergulhar.

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def stats_documentos(numero_processo: str) -> dict:
    """
    Estatísticas agregadas dos documentos do processo.
    NÃO retorna conteúdo de texto, apenas contagens e métricas.

    QUANDO USAR: Para ter visão quantitativa do acervo documental
    ANTES de decidir quais documentos buscar ou ler. Útil para
    processos grandes onde é preciso priorizar.

    QUANDO NÃO USAR: Se já sabe o que precisa e quer buscar
    diretamente. Nesse caso, use grep_documentos.

    Args:
        numero_processo: Número CNJ do processo.

    Returns:
        dict com:
        - total_documentos, com_conteudo, sem_conteudo
        - tamanho_total_chars, tamanho_total_bytes
        - por_tipo: {tipo_nome: {quantidade, chars_total}}
        - por_sigilo: {nivel: quantidade}
        - periodo: {primeiro_documento, ultimo_documento}
    """
```

**Implementação de referência**:

```python
async def _stats_documentos(self, numero_processo):
    resultado = await self.supabase.rpc(
        "stats_documentos",
        {"p_numero_processo": numero_processo}
    ).execute()

    if not resultado.data:
        return {
            "erro": f"Nenhum documento encontrado para {numero_processo}.",
            "dica": "Verifique o numero_processo com visao_geral_processo."
        }

    return resultado.data
```

---

### 4.10 Tool: `localizar_no_documento`

**Propósito**: Ponte entre grep e read. Depois que `grep_documentos` identifica QUAIS documentos contêm um termo, esta tool encontra ONDE exatamente dentro de um documento específico, retornando posições (offsets) que o agente usa para chamar `read_documento` cirurgicamente.

**Por que existe**: Documentos jurídicos têm 50k+ caracteres. Ler tudo é desperdício de contexto. Esta tool permite o agente "mirar" antes de "ler" — exatamente como Claude Code faz `grep` → `read_file(linhas X-Y)`.

**Arquitetura de navegação em 3 camadas**:

```
┌─────────────────────────────────────────────────────────┐
│                    CAMADA 1: LOCALIZAR                   │
│                   (grep_documentos)                      │
│                                                         │
│  "Onde está 'dano moral' no processo?"                  │
│  Escopo: TODOS os documentos do processo                │
│  Retorna: quais docs + 1 snippet por doc                │
│  SQL: to_tsvector + ts_headline (índice GIN)            │
└─────────────────────┬───────────────────────────────────┘
                      │ doc_id
                      ▼
┌─────────────────────────────────────────────────────────┐
│                    CAMADA 2: MAPEAR                      │
│               (localizar_no_documento)                   │
│                                                         │
│  "Onde exatamente dentro DESTE documento?"              │
│  Escopo: UM documento específico                        │
│  Retorna: todas as posições (offsets) + snippet curto   │
│  SQL: position() + substring() em SQL puro              │
└─────────────────────┬───────────────────────────────────┘
                      │ offset
                      ▼
┌─────────────────────────────────────────────────────────┐
│                    CAMADA 3: LER                         │
│                  (read_documento)                        │
│                                                         │
│  "Me dá 500 chars ao redor da posição 14230"            │
│  Escopo: trecho controlado de UM documento              │
│  Retorna: texto contínuo (tamanho controlado)           │
│  SQL: substring(conteudo FROM offset FOR limit)         │
└─────────────────────────────────────────────────────────┘
```

**Fluxo prático do agente**:

```
1. grep_documentos("dano moral", numero_processo="...")
   → match: doc_id="abc" (Sentença), snippet="...>>>dano moral<<<..."

2. localizar_no_documento("abc", "dano moral")
   → 3 ocorrências: posição 14230, 38750, 42100
   → cada uma com snippet curto de contexto

3. read_documento("abc", offset=14100, max_chars=500)
   → "...fixo dano moral no valor de R$ 50.000,00..."
   (leitura cirúrgica, só o trecho que importa)
```

**Especificação da tool**:

```python
@mcp.tool(
    annotations={
        "readOnlyHint": True,
        "openWorldHint": False
    }
)
async def localizar_no_documento(
    document_id: str,
    termo: str,
    contexto_chars: int = 100,
    max_ocorrencias: int = 30
) -> dict:
    """
    Encontra TODAS as posições de um termo dentro de UM documento.
    Retorna offset exato + snippet de cada ocorrência.
    Use os offsets com read_documento(offset=...) para ler
    o contexto completo ao redor de cada match.

    QUANDO USAR: Após grep_documentos identificar que um
    documento contém o termo. Esta tool mapeia ONDE dentro
    do documento, para leitura cirúrgica com read_documento.

    QUANDO NÃO USAR: Para buscar em VÁRIOS documentos,
    use grep_documentos. Se já sabe o offset, vá direto
    para read_documento.

    FLUXO TÍPICO:
      grep_documentos("dano moral") → doc ABC tem match
      localizar_no_documento("ABC", "dano moral") → offsets
      read_documento("ABC", offset=14100, max_chars=500) → texto

    Args:
        document_id: UUID do documento (de grep ou ls).
        termo: Texto a buscar (case-insensitive, busca exata).
        contexto_chars: Chars de contexto ao redor de cada
                        match no snippet (padrão 100).
        max_ocorrencias: Máximo de ocorrências (padrão 30).

    Returns:
        dict com:
        - total_ocorrencias: quantas vezes o termo aparece
        - tamanho_documento: tamanho total em chars
        - ocorrencias: [{posicao, snippet}, ...]
          (posicao é 0-indexed, compatível com offset do read)
    """
```

**Implementação de referência**:

```python
async def _localizar_no_documento(self, document_id, termo,
                                   contexto_chars, max_ocorrencias):
    resultado = await self.supabase.rpc(
        "localizar_no_documento",
        {
            "p_document_id": document_id,
            "p_termo": termo,
            "p_contexto_chars": contexto_chars,
            "p_max_ocorrencias": max_ocorrencias
        }
    ).execute()

    if not resultado.data:
        return {
            "erro": f"Documento {document_id} não encontrado ou sem conteúdo.",
            "dica": "Use ls_documentos para listar documentos disponíveis."
        }

    data = resultado.data
    ocorrencias = data.get("ocorrencias", [])

    return {
        "document_id": document_id,
        "termo": termo,
        "total_ocorrencias": data.get("total_ocorrencias", 0),
        "tamanho_documento": data.get("tamanho_documento", 0),
        "ocorrencias": ocorrencias,
        "dica": (
            "Use read_documento(document_id, offset=POSICAO, max_chars=500) "
            "para ler o contexto completo ao redor de cada ocorrência. "
            "O campo 'posicao' é compatível com o parâmetro 'offset'."
            if ocorrencias else
            "Termo não encontrado neste documento. Tente outro termo "
            "ou use grep_documentos para buscar em todos os documentos."
        )
    }
```

**Comparação das 3 camadas de navegação**:

| Operação | Escopo | O que retorna | Custo contexto |
|---|---|---|---|
| `grep_documentos` | Todos os docs | Quais docs + 1 snippet/doc | Baixo (~200 tokens/match) |
| `localizar_no_documento` | 1 documento | Todas as posições + snippets | Médio (~100 tokens/ocorrência) |
| `read_documento` | 1 trecho | Texto contínuo no offset | Controlado pelo agente |

---

## 5. Tool Existente: `analisar` (MANTER)

A tool `analisar` (que usa LLM internamente) deve ser MANTIDA como está. Ela complementa as tools de dados:

- Tools de dados (1-8): Fornecem informação bruta ao agente
- Tool `analisar`: Processa informação com LLM internamente

**Sugestão**: Renomear para `tj_analisar` seguindo convenção de naming com prefixo do serviço.

---

## 6. Padrão de Retorno (Contrato)

Todas as tools DEVEM seguir este padrão de retorno:

### 6.1 Retorno de Sucesso (com lista)

```json
{
  "documentos": [...],
  "paginacao": {
    "total": 234,
    "limit": 30,
    "offset": 0,
    "has_more": true
  },
  "dica": "Use read_documento(id) para ler o conteúdo completo."
}
```

### 6.2 Retorno de Sucesso (sem lista)

```json
{
  "metadados": {...},
  "conteudo": "texto...",
  "leitura": {
    "offset": 0,
    "chars_retornados": 5000,
    "tamanho_total": 48230,
    "tem_mais": true,
    "proximo_offset": 5000
  },
  "dica": "Para continuar, chame read_documento('xxx', offset=5000)."
}
```

### 6.3 Retorno de Erro

```json
{
  "erro": "Descrição clara do que aconteceu.",
  "dica": "Sugestão específica do que fazer. Ex: Use ls_documentos para listar."
}
```

**NUNCA** lançar exceções Python que cheguem ao agente. Sempre retornar dict com `erro` + `dica`.

---

## 7. Naming Convention

Seguir o padrão recomendado pela comunidade: `{servico}_{acao}_{recurso}`.

Porém, como o MCP client já prefixa com o nome do servidor (`Tecjustica:`), as tools podem ter nomes curtos e claros:

| Tool | Nome Final (no MCP) |
|---|---|
| `listar_processos` | `Tecjustica:listar_processos` |
| `visao_geral_processo` | `Tecjustica:visao_geral_processo` |
| `ls_documentos` | `Tecjustica:ls_documentos` |
| `ls_movimentacoes` | `Tecjustica:ls_movimentacoes` |
| `read_documento` | `Tecjustica:read_documento` |
| `grep_documentos` | `Tecjustica:grep_documentos` |
| `grep_movimentacoes` | `Tecjustica:grep_movimentacoes` |
| `glob_documentos` | `Tecjustica:glob_documentos` |
| `localizar_no_documento` | `Tecjustica:localizar_no_documento` |
| `stats_documentos` | `Tecjustica:stats_documentos` |
| `analisar` | `Tecjustica:analisar` (manter) |
| `whoami` | `Tecjustica:whoami` (manter) |

---

## 8. Fluxo Típico do Agente

### 8.1 Análise Rápida de Processo

```
Agente:
1. visao_geral_processo("0001234-56.2024.8.06.0001")
   → Recebe metadados, partes, stats, últimas movimentações

2. grep_documentos("sentença procedente", numero_processo="...")
   → Encontra 2 matches com snippets

3. read_documento("uuid-da-sentenca", max_chars=20000)
   → Lê o texto da sentença

4. Responde ao usuário com análise
```

### 8.2 Extração de Dados Estruturados

```
Agente:
1. visao_geral_processo("...")
   → Identifica que tem 234 documentos

2. stats_documentos("...")
   → Vê que tem 12 decisões, 3 sentenças

3. glob_documentos("...", padrao_tipo="Decisão")
   → Lista as 12 decisões com metadados

4. grep_documentos("tutela", numero_processo="...", tipo_nome="Decisão")
   → Encontra decisões sobre tutela

5. read_documento("uuid-decisao-1", max_chars=15000)
   → Lê a decisão específica
```

### 8.3 Navegação Profunda em Documento Grande (3 Camadas)

```
Agente:
1. grep_documentos("dano moral", numero_processo="...")
   → match: doc_id="abc" (Sentença, 48.000 chars)

2. localizar_no_documento("abc", "dano moral")
   → 4 ocorrências: posição 14230, 28100, 38750, 42100
   → snippets curtos de cada

3. read_documento("abc", offset=14100, max_chars=500)
   → "...fixo dano moral no valor de R$ 50.000,00..."
   (leitura cirúrgica, 500 chars ao redor do match)

4. read_documento("abc", offset=38600, max_chars=500)
   → "...condeno réu em dano moral de R$ 30.000,00..."
   (outra ocorrência, mesmo documento)
```

### 8.4 Busca com Regex

```
Agente:
1. grep_documentos("R\\$\\s*[\\d\\.]+,\\d{2}", modo="regex", numero_processo="...")
   → Encontra todos os valores monetários mencionados

2. grep_documentos("Art\\.\\s*\\d+.*Lei\\s*\\d", modo="regex", numero_processo="...")
   → Encontra referências a artigos de lei
```

---

## 9. Checklist de Implementação

### 9.1 Banco de Dados (executar primeiro)

- [ ] Aplicar migration `add_fts_documents` (coluna fts + índice GIN)
- [ ] Aplicar migration `add_fts_movimentacoes` (coluna fts + índice GIN)
- [ ] Criar função RPC `buscar_documentos`
- [ ] Criar função RPC `buscar_movimentacoes`
- [ ] Criar função RPC `stats_documentos`
- [ ] Criar função RPC `localizar_no_documento`
- [ ] Testar RPCs via Supabase Dashboard (SQL Editor)
- [ ] Verificar que RLS não bloqueia as RPCs (SECURITY DEFINER)

### 9.2 MCP Server (depois do banco)

- [ ] Implementar `visao_geral_processo`
- [ ] Implementar `ls_documentos`
- [ ] Implementar `ls_movimentacoes`
- [ ] Implementar `read_documento`
- [ ] Implementar `grep_documentos`
- [ ] Implementar `grep_movimentacoes`
- [ ] Implementar `glob_documentos`
- [ ] Implementar `localizar_no_documento`
- [ ] Implementar `stats_documentos`
- [ ] Remover/deprecar tools antigas (`explorar_processo`, `consultar_documentos`, `consultar_movimentacoes`)
- [ ] Manter tool `analisar` como está
- [ ] Atualizar descriptions de todas as tools

### 9.3 Testes

- [ ] Testar cada tool individualmente com número de processo real
- [ ] Testar paginação (offset > 0, has_more = true/false)
- [ ] Testar grep_documentos nos 3 modos (fulltext, regex, ilike)
- [ ] Testar read_documento com offset (leitura paginada)
- [ ] Testar localizar_no_documento + read_documento (fluxo grep→localizar→read)
- [ ] Testar retorno de erro (processo inexistente, documento sem conteúdo)
- [ ] Testar com agente real (Claude Code ou Claude.ai via MCP)
- [ ] Verificar que nenhuma tool retorna campo `conteudo` completo exceto `read_documento` (trecho controlado), `grep_documentos` (snippets) e `localizar_no_documento` (snippets curtos)

### 9.4 Performance

- [ ] Verificar que `grep_documentos` com modo `fulltext` usa o índice GIN (EXPLAIN ANALYZE)
- [ ] Verificar tempo de resposta < 500ms para ls e glob
- [ ] Verificar tempo de resposta < 2s para grep em fulltext
- [ ] Monitorar tamanho dos retornos (meta: < 5000 tokens por chamada)

---

## 10. Notas sobre RLS (Row Level Security)

As tabelas `documents`, `movimentacoes` e `processos` já têm RLS ativo. As funções RPC devem usar `SECURITY DEFINER` para que as queries internas respeitem as policies existentes sem precisar de bypass.

Verificar que o `user_id` das policies está sendo filtrado corretamente nos joins via `jobs.user_id`. A chain de FK é:

```
auth.users.id
  ↓ (user_id)
jobs.id
  ↓ (job_id)
documents.job_id / movimentacoes.job_id / processos.job_id
```

Se as RPCs com `SECURITY DEFINER` não respeitarem RLS, trocar para `SECURITY INVOKER` e adicionar filtro de `user_id` explícito nos JOINs dentro das funções.

---

## 11. Referências

- [Philipp Schmid — MCP Best Practices](https://www.philschmid.de/mcp-best-practices)
- [Anthropic — Code Execution with MCP](https://www.anthropic.com/engineering/code-execution-with-mcp)
- [LangChain Deep Agents — Middleware](https://docs.langchain.com/oss/python/deepagents/middleware)
- [Arcade.dev — 54 Patterns for MCP Tools](https://blog.arcade.dev/mcp-tool-patterns)
- [Klavis AI — Less is More MCP Design](https://www.klavis.ai/blog/less-is-more-mcp-design-patterns-for-ai-agents)
- [MCP Specification — Pagination](https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination)
- [Tinybird — Analytics Agents Best Practices](https://www.tinybird.co/docs/forward/analytics-agents/best-practices)
- [Claude Code — TodoWrite System Prompt](https://gist.github.com/wong2/e0f34aac66caf890a332f7b6f9e2ba8f)
