DEV Community

Cover image for Apps Script MCP Server

Apps Script MCP Server

Here is some sample code to get you started with using Apps Script and doPost() and ContentService with JSON-RPC as a MCP Server.

Requirements

  • Access to https://script.google.com (Apps Script)
  • Ability to deploy Apps Script web app to Anyone. Some Workspace domains restrict this.
  • Not much else!

Prior work to highlight

A basic implementation

The basic implementation involves a few different parts:

  1. doPost() - Apps Script version of POST HTTP endpoint. There are some gotchas to the implementation around redirects!
  2. Tokens to authorize the request
  3. https://www.jsonrpc.org - used by MCP for the transport between client and server
  4. Tools - listing and calling
  5. Apps Script Manifest and deployment

doPost()

The doPost() function is providing the authorization and passing the RPC call to another function.

/** * 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, ); } } 
Enter fullscreen mode Exit fullscreen mode

JSON-RPC

Here is the code for the handling of the RPC:

 /** * 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}`); } } 
Enter fullscreen mode Exit fullscreen mode

There are also some helpers:

 /** * 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 }, }; } 
Enter fullscreen mode Exit fullscreen mode

Tools

Each of the tools are defined in array:

/** * 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: [], }, }, ]; 
Enter fullscreen mode Exit fullscreen mode

With an accompanying Apps Script function:

 /** * 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, }; } } 
Enter fullscreen mode Exit fullscreen mode

Manifest

When deploying the Apps Script Web App, you should choose execute as "Me" and open access to "Anyone":

Apps Script Deploy dialog

{ "timeZone": "America/Denver", "dependencies": {}, "exceptionLogging": "STACKDRIVER", "runtimeVersion": "V8", "webapp": { "executeAs": "USER_DEPLOYING", "access": "ANYONE_ANONYMOUS" } } 
Enter fullscreen mode Exit fullscreen mode

Because we are using a token in the URL query parameters, we can have access set to "Anyone".

Testing

Before trying to test this server, try running the doPost or any other function to trigger the OAuth2 flow for the first time. This won't happen when called by an MCP client!

When I copy the deployment url into MCP inspector and add the ?token=YOUR_TOKEN_HERE to the url, I can use the MCP server!

MCP inspector screenshot showing tool call to Apps Script MCP server

Full code below

Top comments (1)

Collapse
 
dotallio profile image
Dotallio

Really appreciate how clear the error handling and routing is here. Have you tried expanding this to handle a wider set of Google services or tools?