Skip to content

Instantly share code, notes, and snippets.

@jpoehnelt
Created June 25, 2025 19:41
Show Gist options
  • Select an option

  • Save jpoehnelt/b1dc5c827d5fa2da3aaacc5ec6468d36 to your computer and use it in GitHub Desktop.

Select an option

Save jpoehnelt/b1dc5c827d5fa2da3aaacc5ec6468d36 to your computer and use it in GitHub Desktop.
Apps Script MCP server
/**
* A constant array defining the tools that this server makes available.
* Each tool is an object with a name, description, and an input schema.
*/
const AVAILABLE_TOOLS = [
{
name: "read_recent_email",
description:
"Reads the subject line of the most recent email thread in your inbox.",
inputSchema: {
type: "object",
properties: {},
required: [],
},
},
];
/**
* Main entry point for the Google Apps Script web app when it receives a POST request.
* This function handles the entire JSON-RPC request lifecycle, including
* authentication, request parsing, processing, and response generation.
*
* @param {object} e The Google Apps Script event object for a POST request.
* @param {object} e.parameter - URL query parameters. Expects a 'token' for authentication.
* @param {string} e.postData.contents - The raw JSON string payload of the POST body.
* @returns {GoogleAppsScript.Content.TextOutput} A TextOutput object with a
* MIME type of JSON, containing the JSON-RPC response.
*/
function doPost(e) {
try {
// --- Authentication (using Query Parameter) ---
const authToken =
PropertiesService.getScriptProperties().getProperty("MCP_AUTH_TOKEN");
if (!authToken) {
const err = createErrorResponse_(-32001, null);
return ContentService.createTextOutput(JSON.stringify(err)).setMimeType(
ContentService.MimeType.JSON,
);
}
// Read the token from a URL query parameter named 'token'.
const clientToken = e.parameter.token;
if (!clientToken) {
const err = createErrorResponse_(-32002, null);
return ContentService.createTextOutput(JSON.stringify(err)).setMimeType(
ContentService.MimeType.JSON,
);
}
if (clientToken !== authToken) {
const err = createErrorResponse_(-32003, null);
return ContentService.createTextOutput(JSON.stringify(err)).setMimeType(
ContentService.MimeType.JSON,
);
}
// --- MCP Request Processing ---
const payload = JSON.parse(e?.postData?.contents ?? "{}");
const response = Array.isArray(payload)
? payload.map(processSingleRequest_).filter((r) => r !== null)
: processSingleRequest_(payload);
if (response && (!Array.isArray(response) || response.length > 0)) {
return ContentService.createTextOutput(
JSON.stringify(response),
).setMimeType(ContentService.MimeType.JSON);
}
// Return an empty response for notifications or empty batch requests.
return ContentService.createTextOutput();
} catch (err) {
// For unexpected errors, provide the specific error message for better debugging.
const errorResponse = createErrorResponse_(-32603, null, `An internal server error occurred: ${err}`);
return ContentService.createTextOutput(JSON.stringify(errorResponse)).setMimeType(
ContentService.MimeType.JSON,
);
}
}
/**
* Processes a single JSON-RPC request object. It acts as a router,
* validating the request and directing it to the appropriate method handler.
*
* @param {object} request - A JSON-RPC 2.0 request object.
* @param {string} request.jsonrpc - Must be "2.0".
* @param {string} request.method - The name of the method to be invoked.
* @param {object} [request.params] - The parameters for the method.
* @param {string|number|null} [request.id] - The request identifier. If null, it's a notification.
* @returns {object|null} A JSON-RPC 2.0 response object, or null for notifications.
*/
function processSingleRequest_(request) {
if (
!request ||
request.jsonrpc !== "2.0" ||
typeof request.method !== "string"
) {
return createErrorResponse_(-32600, request?.id || null);
}
const { id, method, params } = request;
switch (method) {
// --- Lifecycle Management Methods ---
case "initialize":
return createSuccessResponse_(
{
protocolVersion: "2025-03-26",
capabilities: {
tools: {
listChanged: false,
},
},
serverInfo: {
name: "AppsScript-MCP-Server",
version: "1.0.1",
},
},
id,
);
case "initialized":
case "notifications/initialized":
console.log("MCP session initialized by client.");
return null; // Return null for notifications, as no response is sent.
// --- Tooling Methods ---
case "tools/list":
return createSuccessResponse_({ tools: AVAILABLE_TOOLS }, id);
case "tools/call": {
const tool = AVAILABLE_TOOLS.find((t) => t.name === params.name);
if (!tool) {
// Provide a more specific message than the default "Method not found".
return createErrorResponse_(-32601, id, `Unknown tool: '${params.name}'`);
}
// Maps the tool name string to its actual function implementation.
const toolFunctions = {
read_recent_email: _tool_read_recent_email_,
};
const toolResult = toolFunctions[params.name](params.arguments);
return createSuccessResponse_(toolResult, id);
}
default:
// Provide a more specific message than the default "Method not found".
return createErrorResponse_(-32601, id, `Method not found: ${method}`);
}
}
/**
* Executes the 'read_recent_email' tool. This function requires the script
* to have Gmail permissions (`gmail.readonly` scope). It fetches the most
* recent email thread and returns its subject line.
*
* @param {object} args - The arguments for the tool (currently unused).
* @returns {{content: Array<{type: string, text: string}>, isError: boolean}}
* An object containing the result of the tool execution. `isError` is
* true if any part of the execution fails.
*/
function tool_read_recent_email_(args) {
try {
const thread = GmailApp.getInboxThreads(0, 1)[0];
const message = thread.getMessages()[0];
const subject = message.getSubject();
return {
content: [
{
type: "text",
text: `The subject of the most recent email is: "${subject}"`,
},
],
isError: false,
};
} catch (e) {
// Catches errors if GmailApp fails (e.g., no emails, permissions issue).
return {
content: [{ type: "text", text: `Tool execution failed: ${e}` }],
isError: true,
};
}
}
/**
* A Map defining standard and implementation-specific JSON-RPC 2.0 errors.
* This provides a single source of truth for error codes and their messages.
* @type {Map<number, {message: string}>}
*/
const RPC_ERRORS = new Map([
// Standard JSON-RPC 2.0 Errors
[-32700, { message: "Parse error" }],
[-32600, { message: "Invalid Request" }],
[-32601, { message: "Method not found" }],
[-32602, { message: "Invalid params" }],
[-32603, { message: "Internal error" }],
// Implementation-defined server errors
[-32001, { message: "Server configuration error: Auth token not set." }],
[-32002, { message: "Unauthorized: Missing 'token' query parameter in the URL." }],
[-32003, { message: "Forbidden: Invalid token." }],
]);
/**
* Creates a standard JSON-RPC 2.0 success response object.
*
* @param {any} result - The data to be sent as the result of the request.
* @param {string|number|null} id - The ID of the original request.
* @returns {{jsonrpc: string, id: string|number|null, result: any}}
* A formatted JSON-RPC success object.
*/
function createSuccessResponse_(result, id = null) {
return { jsonrpc: "2.0", id, result };
}
/**
* Creates a standard JSON-RPC 2.0 error response object using the RPC_ERRORS map.
*
* @param {number} code - The error code, which must be a key in the RPC_ERRORS map.
* @param {string|number|null} [id=null] - The ID of the original request.
* @param {string} [customMessage=null] - An optional message to override the default
* message from the map. Useful for adding specific context to generic errors.
* @returns {{jsonrpc: string, id: string|number|null, error: {code: number, message: string}}}
* A formatted JSON-RPC error object.
*/
function createErrorResponse_(code, id = null, customMessage = null) {
const errorTemplate = RPC_ERRORS.get(code) || RPC_ERRORS.get(-32603); // Default to Internal Error
const message = customMessage || errorTemplate.message;
return {
jsonrpc: "2.0",
id: id !== undefined ? id : null,
error: { code, message },
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment