07-03-02 — Desafio: MCP Server para API SharePoint

⏱ 10 minFontes validadas em: 2026-04-29

O desafio

Sua missão: criar um MCP server que permita ao Copilot do VS Code responder perguntas como "Qual a versão atual do manual de onboarding no SharePoint?" ou "Liste todos os documentos de arquitetura aprovados nos últimos 30 dias."

Tecnologias: Python, Microsoft Graph API, MSAL (autenticação Entra ID), MCP SDK.

Pré-requisitos: App Registration no Entra ID

Antes do código, você precisa registrar uma aplicação no Azure Entra ID com as permissões corretas:

  1. Azure Portal → Entra ID → App registrations → New registration
  2. Nome: sharepoint-mcp-server
  3. Em "API permissions", adicione: Sites.Read.All, Files.Read.All (Microsoft Graph, Application permissions)
  4. Grant admin consent
  5. Em "Certificates & secrets", crie um client secret
  6. Anote: tenant_id, client_id, client_secret
⚠️ Permissões de Application vs Delegated

O server MCP roda como processo de background, sem usuário logado. Por isso usamos Application permissions (Sites.Read.All), não Delegated. Isso significa que o server tem acesso a todos os sites — implemente filtros se necessário para limitar o escopo a sites específicos.

Instalação

pip install mcp msal httpx python-dotenv

Código completo: sharepoint_mcp_server.py

import os
import httpx
import msal
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent
from dotenv import load_dotenv
import json
from datetime import datetime, timedelta

load_dotenv()

# ---- CONFIGURAÇÃO ----
TENANT_ID = os.environ["ENTRA_TENANT_ID"]
CLIENT_ID = os.environ["ENTRA_CLIENT_ID"]
CLIENT_SECRET = os.environ["ENTRA_CLIENT_SECRET"]
GRAPH_BASE = "https://graph.microsoft.com/v1.0"

# ---- AUTENTICAÇÃO ----
_token_cache: dict = {}

def get_access_token() -> str:
    """Obtém token MSAL com cache simples."""
    now = datetime.utcnow()
    
    if _token_cache.get("expires_at") and now < _token_cache["expires_at"]:
        return _token_cache["token"]
    
    app = msal.ConfidentialClientApplication(
        CLIENT_ID,
        authority=f"https://login.microsoftonline.com/{TENANT_ID}",
        client_credential=CLIENT_SECRET
    )
    result = app.acquire_token_for_client(
        scopes=["https://graph.microsoft.com/.default"]
    )
    
    if "access_token" not in result:
        raise RuntimeError(f"Falha na autenticação: {result.get('error_description')}")
    
    _token_cache["token"] = result["access_token"]
    _token_cache["expires_at"] = now + timedelta(seconds=result.get("expires_in", 3600) - 60)
    return _token_cache["token"]

def graph_headers() -> dict:
    return {
        "Authorization": f"Bearer {get_access_token()}",
        "Accept": "application/json"
    }

# ---- MCP SERVER ----
server = Server("sharepoint-mcp")

@server.list_tools()
async def list_tools() -> list[Tool]:
    return [
        Tool(
            name="list_sharepoint_sites",
            description=(
                "Lista sites do SharePoint da organização. "
                "Use para descobrir quais sites existem antes de buscar documentos."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "search": {
                        "type": "string",
                        "description": "Filtro opcional pelo nome do site (ex: 'RH', 'TI', 'Projetos')"
                    }
                }
            }
        ),
        Tool(
            name="search_documents",
            description=(
                "Busca documentos no SharePoint por palavras-chave. "
                "Retorna título, URL, autor e data de modificação. "
                "Use para encontrar documentos específicos por conteúdo ou nome."
            ),
            inputSchema={
                "type": "object",
                "properties": {
                    "query": {
                        "type": "string",
                        "description": "Termo de busca (ex: 'manual onboarding', 'arquitetura microsserviços')"
                    },
                    "site_id": {
                        "type": "string",
                        "description": "ID do site para limitar a busca (opcional). Use list_sharepoint_sites para obter IDs."
                    },
                    "limit": {
                        "type": "integer",
                        "description": "Número máximo de resultados (padrão: 10, máximo: 25)",
                        "default": 10
                    }
                },
                "required": ["query"]
            }
        )
    ]

@server.call_tool()
async def call_tool(name: str, arguments: dict) -> list[TextContent]:
    
    if name == "list_sharepoint_sites":
        return await list_sites(arguments.get("search"))
    
    elif name == "search_documents":
        return await search_docs(
            query=arguments["query"],
            site_id=arguments.get("site_id"),
            limit=min(arguments.get("limit", 10), 25)
        )
    
    raise ValueError(f"Tool desconhecida: {name}")

async def list_sites(search: str | None = None) -> list[TextContent]:
    """Lista sites SharePoint via Graph API."""
    url = f"{GRAPH_BASE}/sites"
    params = {"$top": 20, "$select": "id,displayName,webUrl,description"}
    
    if search:
        params["$search"] = f'"{search}"'
    
    async with httpx.AsyncClient() as client:
        resp = await client.get(url, headers=graph_headers(), params=params)
        resp.raise_for_status()
        data = resp.json()
    
    sites = data.get("value", [])
    if not sites:
        return [TextContent(type="text", text="Nenhum site encontrado.")]
    
    lines = ["**Sites SharePoint encontrados:**\n"]
    for site in sites:
        lines.append(f"- **{site['displayName']}**")
        lines.append(f"  ID: `{site['id']}`")
        lines.append(f"  URL: {site.get('webUrl', 'N/A')}")
        if site.get("description"):
            lines.append(f"  Descrição: {site['description'][:100]}")
        lines.append("")
    
    return [TextContent(type="text", text="\n".join(lines))]

async def search_docs(query: str, site_id: str | None, limit: int) -> list[TextContent]:
    """Busca documentos via Graph Search API."""
    
    search_body = {
        "requests": [{
            "entityTypes": ["driveItem"],
            "query": {"queryString": query},
            "from": 0,
            "size": limit,
            "fields": ["name", "webUrl", "lastModifiedDateTime", "createdBy", "size", "parentReference"]
        }]
    }
    
    # Adicionar filtro de site se especificado
    if site_id:
        search_body["requests"][0]["query"]["queryString"] += f" site:{site_id}"
    
    async with httpx.AsyncClient() as client:
        resp = await client.post(
            f"{GRAPH_BASE}/search/query",
            headers=graph_headers(),
            json=search_body
        )
        resp.raise_for_status()
        data = resp.json()
    
    hits = []
    for response in data.get("value", []):
        for result in response.get("hitsContainers", []):
            hits.extend(result.get("hits", []))
    
    if not hits:
        return [TextContent(type="text", text=f"Nenhum documento encontrado para: '{query}'")]
    
    lines = [f"**Documentos encontrados para '{query}':**\n"]
    for hit in hits:
        resource = hit.get("resource", {})
        name = resource.get("name", "Sem nome")
        url = resource.get("webUrl", "#")
        modified = resource.get("lastModifiedDateTime", "")[:10] if resource.get("lastModifiedDateTime") else "?"
        author = resource.get("createdBy", {}).get("user", {}).get("displayName", "Desconhecido")
        
        lines.append(f"- **{name}**")
        lines.append(f"  URL: {url}")
        lines.append(f"  Modificado: {modified} | Autor: {author}")
        lines.append("")
    
    return [TextContent(type="text", text="\n".join(lines))]

if __name__ == "__main__":
    import asyncio
    asyncio.run(stdio_server(server))

Arquivo .env

ENTRA_TENANT_ID=seu-tenant-id-aqui
ENTRA_CLIENT_ID=seu-client-id-aqui
ENTRA_CLIENT_SECRET=seu-client-secret-aqui

Configurando no VS Code

// .vscode/settings.json
{
  "github.copilot.chat.mcpServers": {
    "sharepoint": {
      "command": "python",
      "args": ["${workspaceFolder}/sharepoint_mcp_server.py"],
      "env": {
        "ENTRA_TENANT_ID": "${env:ENTRA_TENANT_ID}",
        "ENTRA_CLIENT_ID": "${env:ENTRA_CLIENT_ID}",
        "ENTRA_CLIENT_SECRET": "${env:ENTRA_CLIENT_SECRET}"
      }
    }
  }
}

Testando no Claude Desktop

// claude_desktop_config.json
{
  "mcpServers": {
    "sharepoint": {
      "command": "python",
      "args": ["C:/projetos/sharepoint_mcp_server.py"],
      "env": {
        "ENTRA_TENANT_ID": "seu-tenant-id",
        "ENTRA_CLIENT_ID": "seu-client-id",
        "ENTRA_CLIENT_SECRET": "seu-secret"
      }
    }
  }
}

Após configurar, você pode perguntar diretamente no chat: "Liste os sites do SharePoint relacionados a RH" ou "Busque documentos sobre arquitetura de microsserviços no SharePoint".

🪟 Alternativa: usar o MCP server oficial da Microsoft

A Microsoft disponibilizou um MCP server oficial para Microsoft 365 (inclui SharePoint) em github.com/microsoft/Microsoft365-MCP-Server. O server customizado deste desafio é útil quando você precisa de lógica de negócio específica ou acesso a metadados customizados do SharePoint.

Próximos passos sugeridos

  • Adicionar tool get_document_content que lê o conteúdo de um arquivo via Graph API
  • Hospedar como Azure Function (ver 07-01-03) para acesso remoto por toda a equipe
  • Adicionar cache Redis para reduzir chamadas à Graph API
  • Implementar autenticação via Managed Identity ao invés de client secret

Fontes

  1. Microsoft Graph API — SharePoint Resources (learn.microsoft.com)
  2. Microsoft Graph Search API Overview (learn.microsoft.com)
  3. MCP Quickstart Server (modelcontextprotocol.io)
  4. Quickstart: Register an app in Entra ID (learn.microsoft.com)