import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import {
extractBearerToken,
OAUTH_PROXY_PATHS,
protectedResourceMetadata,
proxyToAuthServer,
unauthorized401Response,
validateToken,
} from "./auth";
import { AUTH_SERVER_URL, MCP_PORT } from "./config";
// Based on the official MCP SDK PR for Web Standard support:
// https://github.com/modelcontextprotocol/typescript-sdk/pull/1209
import { FetchStreamableHTTPServerTransport } from "./fetch-streamable-http-transport";
import { getEmail, getUnreadMessages, googleEndpoints } from "./gmail";
function getServer() {
const server = new McpServer({
name: "gmail-helper",
version: "1.0.0",
});
server.registerTool(
"get_unread_emails",
{
description: "Get unread emails from Gmail account",
},
async (request) => {
const token = request.authInfo?.token;
if (!token) {
return {
content: [{ type: "text", text: "No auth token provided" }],
};
}
const messages = await getUnreadMessages(token);
// for each message, fetch the full email content
const emails = await Promise.all(
messages.map(async (message) => {
const email = await getEmail(token, message.id);
const { id, threadId, snippet, payload } = email;
const { headers } = payload;
const subject = headers.find(
(header) => header.name === "Subject",
)?.value;
// from looks like Google <no-reply@accounts.google.com> so we should parse it to
// get just the value between the angle brackets
const from = headers.find(
(header) => header.name === "From",
)?.value;
const sender = from?.match(/<([^>]+)>/)
? from.match(/<([^>]+)>/)![1]
: from;
return {
emailId: id,
threadId,
snippet,
subject,
sender,
};
}),
);
return {
content: [
{
type: "text",
text: JSON.stringify(emails, null, 2),
},
],
};
},
);
// https://developers.google.com/workspace/gmail/api/reference/rest/v1/users.messages#Message
// The requested threadId must be specified on the Message or Draft.Message you supply with your request.
// The References and In-Reply-To headers must be set in compliance with the RFC 2822 standard.
// The Subject headers must match (with Re: prefix)
server.registerTool(
"create_draft_reply",
{
description:
"Create a draft reply to an email. The draft will be properly threaded with the original conversation.",
inputSchema: {
emailId: z.string().describe("The ID of the email to reply to"),
threadId: z
.string()
.describe("The thread ID the reply belongs to"),
replyBody: z.string().describe("The body content of the reply"),
},
},
async ({ emailId, threadId, replyBody }, { authInfo }) => {
const token = authInfo?.token;
if (!token) {
return {
content: [{ type: "text", text: "No auth token provided" }],
};
}
const originalEmail = await getEmail(token, emailId);
const headers = originalEmail.payload.headers;
// get the fields we need to construct the draft reply
const originalSubject = headers.find(
(h) => h.name.toLowerCase() === "subject",
)?.value;
const originalFrom = headers.find(
(h) => h.name.toLowerCase() === "from",
)?.value;
const originalReferences = headers.find(
(h) => h.name.toLowerCase() === "references",
)?.value;
if (!originalSubject || !originalFrom) {
return {
content: [
{
type: "text",
text: "Could not extract required headers from original email",
},
],
};
}
const replySubject = originalSubject.startsWith("Re:")
? originalSubject
: `Re: ${originalSubject}`;
const referencesHeader = originalReferences
? `${originalReferences} ${emailId}`
: emailId;
// get the reply-to address (the original sender)
const toAddress = originalFrom;
const rawMessage = [
`To: ${toAddress}`,
`Subject: ${replySubject}`,
`In-Reply-To: ${emailId}`,
`References: ${referencesHeader}`,
"Content-Type: text/plain; charset=utf-8",
"",
replyBody,
].join("\r\n");
const encodedMessage = Buffer.from(rawMessage)
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const response = await fetch(googleEndpoints.postDraft, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
message: {
threadId: threadId,
raw: encodedMessage,
},
}),
});
if (!response.ok) {
const errorText = await response.text();
return {
content: [
{
type: "text",
text: `Failed to create draft: ${response.statusText} - ${errorText}`,
},
],
};
}
const draft = (await response.json()) as {
id: string;
message: { threadId: string };
};
return {
content: [
{
type: "text",
text: JSON.stringify(
{
draftId: draft.id,
threadId: draft.message.threadId,
},
null,
2,
),
},
],
};
},
);
return server;
}
// Store active transports by session ID for session management
const transports = new Map<string, FetchStreamableHTTPServerTransport>();
async function main() {
const httpServer = Bun.serve({
port: MCP_PORT,
idleTimeout: 255,
routes: {
// RFC 9728 Protected Resource Metadata endpoint
// https://datatracker.ietf.org/doc/html/rfc9728#section-3
"/.well-known/oauth-protected-resource": {
GET: () => Response.json(protectedResourceMetadata),
},
},
// Handle MCP requests via the default fetch handler
async fetch(req) {
const url = new URL(req.url);
// Skip auth for well-known endpoints
if (url.pathname === "/.well-known/oauth-protected-resource") {
return Response.json(protectedResourceMetadata);
}
// Proxy OAuth authorization server metadata to auth server
if (url.pathname === "/.well-known/oauth-authorization-server") {
const response = await fetch(
`${AUTH_SERVER_URL}/.well-known/oauth-authorization-server`,
);
return new Response(response.body, {
status: response.status,
headers: response.headers,
});
}
// Proxy OAuth endpoints to auth server
if (OAUTH_PROXY_PATHS.includes(url.pathname)) {
return proxyToAuthServer(req, url);
}
// for other endpoints, need to check auth headers
const authHeader = req.headers.get("Authorization");
const token = extractBearerToken(authHeader);
if (!token) {
return unauthorized401Response(
"invalid_request",
"Missing bearer token",
);
}
// validate the token and get google access token
const { valid, scopes, googleAccessToken } =
await validateToken(token);
if (!valid) {
return unauthorized401Response(
"invalid_token",
"The access token is invalid or expired",
);
}
// requests to the mcp server handled here
if (url.pathname === "/mcp") {
// Check for existing session
const sessionId = req.headers.get("mcp-session-id");
if (sessionId && transports.has(sessionId)) {
// Reuse existing transport for this session
const transport = transports.get(sessionId)!;
// need to pass through the google access token and scopes to authenticate
// tool requests with
Object.assign(req, {
auth: { token: googleAccessToken, scopes },
});
return transport.handleRequest(req);
}
// For new sessions or initialisation, create new transport and server
const server = getServer();
const transport = new FetchStreamableHTTPServerTransport({
sessionIdGenerator: () => crypto.randomUUID(),
onsessioninitialized: (sessionId) => {
transports.set(sessionId, transport);
console.error(`Session initialised: ${sessionId}`);
},
onsessionclosed: (sessionId) => {
transports.delete(sessionId);
console.error(`Session closed: ${sessionId}`);
},
});
await server.connect(transport);
Object.assign(req, {
auth: { token: googleAccessToken, scopes },
});
return transport.handleRequest(req);
}
return Response.json({ error: "Not Found" }, { status: 404 });
},
});
console.error(`MCP Server running at ${httpServer.url}`);
}
main().catch((error) => {
console.error("Fatal error in main():", error);
if (
error instanceof Error &&
(error as NodeJS.ErrnoException).code === "EADDRINUSE"
) {
console.error(
"Port already in use. Try using lsof -i :${PORT} to see what is running on that port.",
);
}
process.exit(1);
});