07-03-02 — Desafio: MCP Server para API SharePoint
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:
- Azure Portal → Entra ID → App registrations → New registration
- Nome:
sharepoint-mcp-server - Em "API permissions", adicione:
Sites.Read.All,Files.Read.All(Microsoft Graph, Application permissions) - Grant admin consent
- Em "Certificates & secrets", crie um client secret
- Anote:
tenant_id,client_id,client_secret
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".
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_contentque 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