Architectuuroverzicht
Een Workspace MCP-server combineert meerdere Google API's onder een enkele MCP-interface. Je AI-assistent kan dan naadloos over diensten heen werken: een e-mail lezen, een meeting plannen op basis van de inhoud en het agendapunt opslaan in Drive.
AI-client (Claude Desktop / eigen app)
|
| MCP (JSON-RPC)
|
Workspace MCP-server
| | |
Calendar Gmail Drive
API API API
|
Google OAuth 2.0
De server biedt per Workspace-actie een afzonderlijke tool aan. De AI-client ziet de naam, de beschrijving en het invoerschema van elke tool en roept ze aan met JSON-RPC. Welk transport je daaronder kiest (stdio of Streamable HTTP) hangt af van waar de server draait.
Google Cloud setup
Voordat je code schrijft, zet je het Google Cloud-project op:
- Ga naar console.cloud.google.com.
- Maak een nieuw project aan of selecteer een bestaand project.
- Schakel de volgende API's in: Google Calendar API, Gmail API, Google Drive API.
- Stel het OAuth-toestemmingsscherm in en voeg jezelf als testgebruiker toe zolang de app in testmodus staat.
- Ga naar Credentials en maak een OAuth 2.0 client-ID aan van het type Desktop app.
- Download het
credentials.json-bestand en bewaar het buiten je versiebeheer.
Scopes minimaliseren
Vraag alleen de scopes aan die je echt nodig hebt. Voor lezen is een .readonly-scope voldoende. Minimale scopes verkleinen het risico bij een gelekte token en versnellen de Google-verificatie als je later naar productie gaat.
Project opzetten
mkdir workspace-mcp-server
cd workspace-mcp-server
npm init -y
npm install @modelcontextprotocol/sdk googleapis google-auth-library zod
npm install --save-dev typescript @types/node tsx
De googleapis-package (ruim v170 in juni 2026) bundelt de Calendar-, Gmail- en Drive-clients. google-auth-library (v10-reeks) levert de OAuth2Client. Pin exacte versies in je package.json en commit de lockfile, zodat een latere major-update je server niet stilletjes breekt.
Token-beheer
import { OAuth2Client } from "google-auth-library";
import fs from "fs/promises";
import path from "path";
const TOKEN_PATH = path.join(process.env.HOME ?? ".", ".workspace-mcp-tokens.json");
export async function getAuthClient(): Promise<OAuth2Client> {
const credentials = JSON.parse(
await fs.readFile("credentials.json", "utf-8")
);
const { client_id, client_secret, redirect_uris } = credentials.installed;
const client = new OAuth2Client(client_id, client_secret, redirect_uris[0]);
try {
const tokens = JSON.parse(await fs.readFile(TOKEN_PATH, "utf-8"));
client.setCredentials(tokens);
client.on("tokens", async (newTokens) => {
const existing = JSON.parse(await fs.readFile(TOKEN_PATH, "utf-8").catch(() => "{}"));
await fs.writeFile(TOKEN_PATH, JSON.stringify({ ...existing, ...newTokens }), { mode: 0o600 });
});
} catch {
throw new Error("Geen tokens gevonden. Voer eerst de auth-flow uit.");
}
return client;
}
De tokens-listener vangt automatisch een vernieuwd access-token op zodra de oude verloopt en schrijft het terug naar schijf met restrictieve rechten (0o600), zodat alleen de eigenaar het bestand kan lezen.
Calendar-tools
In de huidige MCP TypeScript-SDK registreer je een tool met registerTool: een naam, een metadata-object met description en inputSchema, en een handler. Dit vervangt de oudere positionele server.tool(...)-vorm.
import { google } from "googleapis";
import { z } from "zod";
export function registerCalendarTools(server: McpServer, auth: OAuth2Client) {
const calendar = google.calendar({ version: "v3", auth });
server.registerTool(
"list_upcoming_events",
{
description: "Haal aankomende agenda-items op. Gebruik dit om te zien wat er gepland staat of om beschikbaarheid te controleren.",
inputSchema: z.object({
days: z.number().int().min(1).max(30).default(7).describe("Aantal dagen vooruit (1-30)"),
max_results: z.number().int().min(1).max(50).default(10),
}),
},
async ({ days, max_results }) => {
const timeMax = new Date();
timeMax.setDate(timeMax.getDate() + days);
const res = await calendar.events.list({
calendarId: "primary",
timeMin: new Date().toISOString(),
timeMax: timeMax.toISOString(),
maxResults: max_results,
singleEvents: true,
orderBy: "startTime",
});
return {
content: [{
type: "text",
text: JSON.stringify(res.data.items?.map(e => ({
id: e.id,
title: e.summary,
start: e.start?.dateTime ?? e.start?.date,
end: e.end?.dateTime ?? e.end?.date,
attendees: e.attendees?.map(a => a.email),
}))),
}],
};
}
);
server.registerTool(
"create_calendar_event",
{
description: "Maak een nieuw agenda-item aan in Google Calendar.",
inputSchema: z.object({
title: z.string(),
start_datetime: z.string().describe("ISO 8601: 2026-06-15T14:00:00"),
end_datetime: z.string().describe("ISO 8601: 2026-06-15T15:00:00"),
attendees: z.array(z.string().email()).optional(),
description: z.string().optional(),
add_meet: z.boolean().default(false),
}),
},
async ({ title, start_datetime, end_datetime, attendees, description, add_meet }) => {
const event: Record<string, unknown> = {
summary: title,
description,
start: { dateTime: start_datetime, timeZone: "Europe/Amsterdam" },
end: { dateTime: end_datetime, timeZone: "Europe/Amsterdam" },
attendees: attendees?.map(email => ({ email })),
};
if (add_meet) {
event.conferenceData = {
createRequest: { requestId: crypto.randomUUID() },
};
}
const res = await calendar.events.insert({
calendarId: "primary",
requestBody: event,
conferenceDataVersion: add_meet ? 1 : 0,
});
return {
content: [{ type: "text", text: `Event aangemaakt: ${res.data.htmlLink}` }],
};
}
);
}
Voorspelbare requestId voor Meet
Gebruik crypto.randomUUID() in plaats van Math.random() voor de requestId van een Meet-conferentie. Het is cryptografisch sterk en is in Node ingebouwd zonder extra import, zodat dubbele of botsende request-id's praktisch uitgesloten zijn.
Gmail-tools
export function registerGmailTools(server: McpServer, auth: OAuth2Client) {
const gmail = google.gmail({ version: "v1", auth });
server.registerTool(
"list_emails",
{
description: "Haal recente e-mails op uit Gmail. Gebruik een zoekopdracht om te filteren.",
inputSchema: z.object({
query: z.string().default("in:inbox").describe("Gmail-zoeksyntax: is:unread, from:name@example.com"),
max_results: z.number().int().min(1).max(20).default(5),
}),
},
async ({ query, max_results }) => {
const list = await gmail.users.messages.list({
userId: "me",
q: query,
maxResults: max_results,
});
const messages = await Promise.all(
(list.data.messages ?? []).map(async (msg) => {
const full = await gmail.users.messages.get({
userId: "me",
id: msg.id!,
format: "metadata",
metadataHeaders: ["Subject", "From", "Date"],
});
const headers = full.data.payload?.headers ?? [];
return {
id: msg.id,
subject: headers.find(h => h.name === "Subject")?.value,
from: headers.find(h => h.name === "From")?.value,
date: headers.find(h => h.name === "Date")?.value,
snippet: full.data.snippet,
};
})
);
return { content: [{ type: "text", text: JSON.stringify(messages) }] };
}
);
}
Houd format: "metadata" aan voor lijstweergaven. Je haalt dan alleen de kopregels op in plaats van de volledige mailinhoud, wat sneller is en minder quota verbruikt. Pas dit aan naar format: "full" in een aparte tool wanneer de assistent een specifieke mail echt moet lezen.
Alles samenbrengen
Voor lokaal gebruik (bijvoorbeeld in Claude Desktop) koppel je de server via stdio. De client start het proces en praat via standaard in- en uitvoer.
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
async function main() {
const auth = await getAuthClient();
const server = new McpServer({ name: "workspace-server", version: "1.0.0" });
registerCalendarTools(server, auth);
registerGmailTools(server, auth);
const transport = new StdioServerTransport();
await server.connect(transport);
console.error("Workspace MCP-server gestart");
}
main().catch(console.error);
Server op afstand: Streamable HTTP in plaats van SSE
Wil je de server op afstand benaderen of delen binnen een team, gebruik dan Streamable HTTP. Het oudere HTTP+SSE-transport is per de protocolversie van 2025-03-26 deprecated, omdat het twee verbindingen vereist, niet hervatbaar is en lastig te herstellen na een netwerkonderbreking. Streamable HTTP werkt via een enkel HTTP-endpoint dat zowel request/response als streaming afhandelt.
Beveilig een HTTP-server altijd
Een Workspace MCP-server kan e-mail lezen en agenda-items aanmaken namens jou. Stel een HTTP-endpoint nooit open zonder authenticatie. Zet er minimaal Bearer-token-validatie voor, beperk de toegestane hosts (DNS-rebinding-bescherming) en draai bij voorkeur achter een reverse proxy met TLS. Bewaar OAuth-tokens versleuteld en gescheiden per gebruiker.
Streamable HTTP-transport (schets)
Gebruik de Streamable-HTTP-transportklasse uit de SDK met een sessionIdGenerator voor sessiestaat, of undefined voor een eenvoudiger, stateless opzet zonder hervatbaarheid. Koppel die transport aan dezelfde McpServer als hierboven en plaats er je eigen middleware voor die het Bearer-token controleert voordat een request de server bereikt. De toolregistratie blijft identiek, alleen het transport verandert.
Hoe voer ik de eerste OAuth-flow uit?
Voeg een apart script toe dat de authorization-URL print, de teruggegeven code opvangt en de tokens opslaat in het pad uit TOKEN_PATH. Dit doe je eenmalig. Daarna vernieuwt de client het access-token automatisch zolang je het refresh-token bewaart.
Waarom geen HTTP+SSE-transport meer?
Het HTTP+SSE-transport is sinds de protocolversie van maart 2025 als verouderd gemarkeerd. Het vergt twee aparte verbindingen, ondersteunt geen hervatten en is fragiel bij netwerkonderbrekingen. Streamable HTTP gebruikt een enkel endpoint en is het aanbevolen transport voor servers op afstand.
Kan ik ook Teams of Outlook ondersteunen?
Ja. Vervang de Google-libraries door de Microsoft Graph-SDK en pas de tool-handlers aan. De opzet van de MCP-server en de toolregistratie blijft gelijk.
Hoe beperk ik toegang per gebruiker?
Bewaar tokens gescheiden per gebruiker-ID en valideer bij elke tool-aanroep welke gebruiker de aanroep doet via de sessie-context. Geef nooit een gedeeld token aan meerdere personen.
Werkt dit ook in een team?
Ja, maar elk teamlid doorloopt zijn eigen OAuth-flow. Een gedeeld service-account met domeinbrede delegatie is een alternatief voor gedeelde resources, maar vraagt extra zorg rond rechten en logging.
Waarom verschijnt mijn nieuwe tool niet in de client?
Herstart de client zodat die de toollijst opnieuw ophaalt, en controleer dat de toolnaam uniek is en het invoerschema geldig valideert. Een fout in het Zod-schema laat de registratie stilletjes falen.