google-forms-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ # Google Forms MCP
2
+
3
+ MCP server for Google Forms with OAuth token-file auth.
4
+
5
+ ## Breaking Change (v1 auth migration)
6
+
7
+ `GOOGLE_REFRESH_TOKEN` is no longer used.
8
+
9
+ Run one-time auth instead:
10
+
11
+ ```bash
12
+ GOOGLE_CLIENT_ID="your-client-id" \
13
+ GOOGLE_CLIENT_SECRET="your-client-secret" \
14
+ npx -y google-forms-mcp auth
15
+ ```
16
+
17
+ This stores the refresh token at `~/.config/google-docs-mcp/token.json` (respects `XDG_CONFIG_HOME`).
18
+
19
+ ## Setup
20
+
21
+ 1. Enable APIs in Google Cloud:
22
+ - Google Forms API
23
+ - Google Drive API
24
+ - Google Docs API
25
+ - Google Sheets API
26
+
27
+ 2. Create OAuth 2.0 credentials (Desktop app).
28
+ 3. Run auth command above once.
29
+ 4. Add MCP config:
30
+
31
+ ```json
32
+ {
33
+ "mcpServers": {
34
+ "google-forms-mcp": {
35
+ "command": "npx",
36
+ "args": ["-y", "google-forms-mcp"],
37
+ "env": {
38
+ "GOOGLE_CLIENT_ID": "your-client-id",
39
+ "GOOGLE_CLIENT_SECRET": "your-client-secret"
40
+ },
41
+ "type": "stdio"
42
+ }
43
+ }
44
+ }
45
+ ```
46
+
47
+ ## Shared Token with `google-docs-mcp`
48
+
49
+ This project intentionally shares token storage with `google-docs-mcp` for DRY setup.
50
+
51
+ `google-forms-mcp auth` requests superset scopes:
52
+ - `forms`
53
+ - `drive`
54
+ - `documents`
55
+ - `spreadsheets`
56
+
57
+ That keeps one token usable for both MCP servers.
58
+
59
+ ## Tools
60
+
61
+ | Tool | Description |
62
+ |------|-------------|
63
+ | `create_form` | Create form |
64
+ | `get_form` | Get form details |
65
+ | `list_forms` | List all forms |
66
+ | `update_form` | Update title/description |
67
+ | `delete_form` | Delete form |
68
+ | `add_section` | Add visual divider (no page break) |
69
+ | `add_page` | Add page break (requires Next button) |
70
+ | `add_text_question` | Add text question |
71
+ | `add_multiple_choice_question` | Add radio/checkbox question |
72
+ | `update_question` | Modify question |
73
+ | `delete_question` | Remove question |
74
+ | `get_form_responses` | Get responses |
75
+
76
+ ## Item Order (Critical)
77
+
78
+ All items are added at index `0`.
79
+ The last item you add appears first in the form.
80
+
81
+ ## Multiple Choice Options
82
+
83
+ Options support both formats:
84
+ - Simple: `["Option A", "Option B"]`
85
+ - With description: `[{ "label": "Option A", "description": "Details" }]`
86
+
87
+ Use `multiSelect: true` for checkboxes.
88
+
89
+ ## License
90
+
91
+ MIT
package/dist/auth.d.ts ADDED
@@ -0,0 +1,6 @@
1
+ import { OAuth2Client, JWT } from 'google-auth-library';
2
+ export declare const SCOPES: readonly ["https://www.googleapis.com/auth/forms", "https://www.googleapis.com/auth/drive", "https://www.googleapis.com/auth/documents", "https://www.googleapis.com/auth/spreadsheets"];
3
+ export declare function getConfigDir(): string;
4
+ export declare function getTokenPath(): string;
5
+ export declare function authorize(): Promise<OAuth2Client | JWT>;
6
+ export declare function runAuthFlow(): Promise<void>;
package/dist/auth.js ADDED
@@ -0,0 +1,186 @@
1
+ import { google } from 'googleapis';
2
+ import { JWT } from 'google-auth-library';
3
+ import * as fs from 'fs/promises';
4
+ import * as http from 'http';
5
+ import * as os from 'os';
6
+ import * as path from 'path';
7
+ import { fileURLToPath } from 'url';
8
+ import { logger } from './logger.js';
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = path.dirname(__filename);
11
+ const projectRootDir = path.resolve(__dirname, '..');
12
+ const CREDENTIALS_PATH = path.join(projectRootDir, 'credentials.json');
13
+ export const SCOPES = [
14
+ 'https://www.googleapis.com/auth/forms',
15
+ 'https://www.googleapis.com/auth/drive',
16
+ 'https://www.googleapis.com/auth/documents',
17
+ 'https://www.googleapis.com/auth/spreadsheets',
18
+ ];
19
+ export function getConfigDir() {
20
+ const xdg = process.env.XDG_CONFIG_HOME;
21
+ const base = xdg || path.join(os.homedir(), '.config');
22
+ return path.join(base, 'google-docs-mcp');
23
+ }
24
+ export function getTokenPath() {
25
+ return path.join(getConfigDir(), 'token.json');
26
+ }
27
+ async function loadClientSecrets() {
28
+ const envId = process.env.GOOGLE_CLIENT_ID;
29
+ const envSecret = process.env.GOOGLE_CLIENT_SECRET;
30
+ if (envId && envSecret) {
31
+ return { client_id: envId, client_secret: envSecret };
32
+ }
33
+ try {
34
+ const content = await fs.readFile(CREDENTIALS_PATH, 'utf8');
35
+ const keys = JSON.parse(content);
36
+ const key = keys.installed || keys.web;
37
+ if (!key) {
38
+ throw new Error('Could not find client secrets in credentials.json.');
39
+ }
40
+ return {
41
+ client_id: key.client_id,
42
+ client_secret: key.client_secret,
43
+ };
44
+ }
45
+ catch (error) {
46
+ if (error.code === 'ENOENT') {
47
+ throw new Error('No OAuth credentials found. Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET ' +
48
+ 'environment variables, or place a credentials.json file in the project root.');
49
+ }
50
+ throw error;
51
+ }
52
+ }
53
+ async function authorizeWithServiceAccount() {
54
+ const serviceAccountPath = process.env.SERVICE_ACCOUNT_PATH;
55
+ const impersonateUser = process.env.GOOGLE_IMPERSONATE_USER;
56
+ if (!serviceAccountPath) {
57
+ throw new Error('SERVICE_ACCOUNT_PATH is required for service account authentication.');
58
+ }
59
+ try {
60
+ const keyFileContent = await fs.readFile(serviceAccountPath, 'utf8');
61
+ const serviceAccountKey = JSON.parse(keyFileContent);
62
+ const auth = new JWT({
63
+ email: serviceAccountKey.client_email,
64
+ key: serviceAccountKey.private_key,
65
+ scopes: [...SCOPES],
66
+ subject: impersonateUser,
67
+ });
68
+ await auth.authorize();
69
+ if (impersonateUser) {
70
+ logger.info(`Service Account authentication successful, impersonating: ${impersonateUser}`);
71
+ }
72
+ else {
73
+ logger.info('Service Account authentication successful!');
74
+ }
75
+ return auth;
76
+ }
77
+ catch (error) {
78
+ if (error.code === 'ENOENT') {
79
+ logger.error(`FATAL: Service account key file not found at path: ${serviceAccountPath}`);
80
+ throw new Error('Service account key file not found. Please check the path in SERVICE_ACCOUNT_PATH.');
81
+ }
82
+ logger.error('FATAL: Error loading or authorizing the service account key:', error.message);
83
+ throw new Error('Failed to authorize using the service account. Ensure the key file is valid and the path is correct.');
84
+ }
85
+ }
86
+ async function loadSavedCredentialsIfExist() {
87
+ try {
88
+ const tokenPath = getTokenPath();
89
+ const content = await fs.readFile(tokenPath, 'utf8');
90
+ const credentials = JSON.parse(content);
91
+ const { client_secret, client_id } = await loadClientSecrets();
92
+ const client = new google.auth.OAuth2(client_id, client_secret);
93
+ client.setCredentials(credentials);
94
+ return client;
95
+ }
96
+ catch {
97
+ return null;
98
+ }
99
+ }
100
+ async function saveCredentials(client) {
101
+ if (!client.credentials.refresh_token) {
102
+ throw new Error('No refresh token received. Run auth again and ensure consent is granted.');
103
+ }
104
+ const { client_secret, client_id } = await loadClientSecrets();
105
+ const configDir = getConfigDir();
106
+ await fs.mkdir(configDir, { recursive: true });
107
+ const payload = JSON.stringify({
108
+ type: 'authorized_user',
109
+ client_id,
110
+ client_secret,
111
+ refresh_token: client.credentials.refresh_token,
112
+ }, null, 2);
113
+ const tokenPath = getTokenPath();
114
+ await fs.writeFile(tokenPath, payload);
115
+ logger.info(`Token stored to ${tokenPath}`);
116
+ }
117
+ async function authenticate() {
118
+ const { client_secret, client_id } = await loadClientSecrets();
119
+ const server = http.createServer();
120
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
121
+ const address = server.address();
122
+ if (!address || typeof address === 'string') {
123
+ server.close();
124
+ throw new Error('Failed to start OAuth callback server.');
125
+ }
126
+ const redirectUri = `http://127.0.0.1:${address.port}`;
127
+ const oauthClient = new google.auth.OAuth2(client_id, client_secret, redirectUri);
128
+ const authorizeUrl = oauthClient.generateAuthUrl({
129
+ access_type: 'offline',
130
+ prompt: 'consent',
131
+ scope: SCOPES.join(' '),
132
+ });
133
+ logger.info('Authorize this app by visiting this URL:');
134
+ logger.info(authorizeUrl);
135
+ const code = await new Promise((resolve, reject) => {
136
+ server.on('request', (req, res) => {
137
+ const reqUrl = req.url;
138
+ if (!reqUrl) {
139
+ res.writeHead(400, { 'Content-Type': 'text/html' });
140
+ res.end('<h1>Invalid request</h1><p>You can close this tab.</p>');
141
+ return;
142
+ }
143
+ const url = new URL(reqUrl, redirectUri);
144
+ const authCode = url.searchParams.get('code');
145
+ const error = url.searchParams.get('error');
146
+ if (error) {
147
+ res.writeHead(200, { 'Content-Type': 'text/html' });
148
+ res.end('<h1>Authorization failed</h1><p>You can close this tab.</p>');
149
+ reject(new Error(`Authorization error: ${error}`));
150
+ server.close();
151
+ return;
152
+ }
153
+ if (!authCode) {
154
+ res.writeHead(200, { 'Content-Type': 'text/html' });
155
+ res.end('<h1>No code found</h1><p>You can close this tab.</p>');
156
+ return;
157
+ }
158
+ res.writeHead(200, { 'Content-Type': 'text/html' });
159
+ res.end('<h1>Authorization successful!</h1><p>You can close this tab.</p>');
160
+ resolve(authCode);
161
+ server.close();
162
+ });
163
+ });
164
+ const { tokens } = await oauthClient.getToken(code);
165
+ oauthClient.setCredentials(tokens);
166
+ await saveCredentials(oauthClient);
167
+ logger.info('Authentication successful!');
168
+ return oauthClient;
169
+ }
170
+ export async function authorize() {
171
+ if (process.env.SERVICE_ACCOUNT_PATH) {
172
+ logger.info('Service account path detected. Attempting service account authentication...');
173
+ return authorizeWithServiceAccount();
174
+ }
175
+ logger.info('Attempting OAuth 2.0 authentication...');
176
+ const client = await loadSavedCredentialsIfExist();
177
+ if (client) {
178
+ logger.info('Using saved credentials.');
179
+ return client;
180
+ }
181
+ logger.info('No saved token found. Starting interactive authentication flow...');
182
+ return authenticate();
183
+ }
184
+ export async function runAuthFlow() {
185
+ await authenticate();
186
+ }
@@ -0,0 +1,9 @@
1
+ import { drive_v3, forms_v1 } from 'googleapis';
2
+ import { OAuth2Client, JWT } from 'google-auth-library';
3
+ export declare function initializeGoogleClient(): Promise<{
4
+ authClient: JWT | OAuth2Client | null;
5
+ googleForms: forms_v1.Forms;
6
+ googleDrive: drive_v3.Drive;
7
+ }>;
8
+ export declare function getFormsClient(): Promise<forms_v1.Forms>;
9
+ export declare function getDriveClient(): Promise<drive_v3.Drive>;
@@ -0,0 +1,53 @@
1
+ import { google } from 'googleapis';
2
+ import { UserError } from 'fastmcp';
3
+ import { authorize } from './auth.js';
4
+ import { logger } from './logger.js';
5
+ let authClient = null;
6
+ let googleForms = null;
7
+ let googleDrive = null;
8
+ export async function initializeGoogleClient() {
9
+ if (googleForms && googleDrive) {
10
+ return { authClient, googleForms, googleDrive };
11
+ }
12
+ if (!authClient) {
13
+ try {
14
+ logger.info('Attempting to authorize Google API client...');
15
+ const client = await authorize();
16
+ authClient = client;
17
+ googleForms = google.forms({ version: 'v1', auth: authClient });
18
+ googleDrive = google.drive({ version: 'v3', auth: authClient });
19
+ logger.info('Google API client authorized successfully.');
20
+ }
21
+ catch (error) {
22
+ logger.error('FATAL: Failed to initialize Google API client:', error);
23
+ authClient = null;
24
+ googleForms = null;
25
+ googleDrive = null;
26
+ throw new Error('Google client initialization failed. Cannot start server tools.');
27
+ }
28
+ }
29
+ if (authClient && !googleForms) {
30
+ googleForms = google.forms({ version: 'v1', auth: authClient });
31
+ }
32
+ if (authClient && !googleDrive) {
33
+ googleDrive = google.drive({ version: 'v3', auth: authClient });
34
+ }
35
+ if (!googleForms || !googleDrive) {
36
+ throw new Error('Google Forms and Drive clients could not be initialized.');
37
+ }
38
+ return { authClient, googleForms, googleDrive };
39
+ }
40
+ export async function getFormsClient() {
41
+ const { googleForms: forms } = await initializeGoogleClient();
42
+ if (!forms) {
43
+ throw new UserError('Google Forms client is not initialized. Authentication may have failed during startup.');
44
+ }
45
+ return forms;
46
+ }
47
+ export async function getDriveClient() {
48
+ const { googleDrive: drive } = await initializeGoogleClient();
49
+ if (!drive) {
50
+ throw new UserError('Google Drive client is not initialized. Authentication may have failed during startup.');
51
+ }
52
+ return drive;
53
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/index.js ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ import { FastMCP } from 'fastmcp';
3
+ import { initializeGoogleClient } from './clients.js';
4
+ import { logger } from './logger.js';
5
+ import { registerAllTools } from './tools/index.js';
6
+ if (process.argv[2] === 'auth') {
7
+ const { runAuthFlow } = await import('./auth.js');
8
+ try {
9
+ await runAuthFlow();
10
+ logger.info('Authorization complete. You can now start the MCP server.');
11
+ process.exit(0);
12
+ }
13
+ catch (error) {
14
+ logger.error('Authorization failed:', error?.message || error);
15
+ process.exit(1);
16
+ }
17
+ }
18
+ process.on('uncaughtException', (error) => {
19
+ logger.error('Uncaught Exception:', error);
20
+ });
21
+ process.on('unhandledRejection', (reason) => {
22
+ logger.error('Unhandled Promise Rejection:', reason);
23
+ });
24
+ const server = new FastMCP({
25
+ name: 'Google Forms MCP Server',
26
+ version: '1.0.0',
27
+ });
28
+ registerAllTools(server);
29
+ try {
30
+ await initializeGoogleClient();
31
+ logger.info('Starting Google Forms MCP server...');
32
+ server.start({ transportType: 'stdio' });
33
+ logger.info('MCP server running using stdio. Awaiting client connection...');
34
+ }
35
+ catch (startError) {
36
+ logger.error('FATAL: Server failed to start:', startError?.message || startError);
37
+ process.exit(1);
38
+ }
@@ -0,0 +1,7 @@
1
+ export declare function refreshLogLevel(): void;
2
+ export declare const logger: {
3
+ debug(...args: unknown[]): void;
4
+ info(...args: unknown[]): void;
5
+ warn(...args: unknown[]): void;
6
+ error(...args: unknown[]): void;
7
+ };
package/dist/logger.js ADDED
@@ -0,0 +1,43 @@
1
+ const LOG_LEVELS = {
2
+ debug: 0,
3
+ info: 1,
4
+ warn: 2,
5
+ error: 3,
6
+ silent: 4,
7
+ };
8
+ function resolveLevel() {
9
+ const env = process.env.LOG_LEVEL?.toLowerCase();
10
+ if (env && env in LOG_LEVELS) {
11
+ return env;
12
+ }
13
+ return 'info';
14
+ }
15
+ let currentLevel = resolveLevel();
16
+ export function refreshLogLevel() {
17
+ currentLevel = resolveLevel();
18
+ }
19
+ function shouldLog(level) {
20
+ return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
21
+ }
22
+ export const logger = {
23
+ debug(...args) {
24
+ if (shouldLog('debug')) {
25
+ console.error('[DEBUG]', ...args);
26
+ }
27
+ },
28
+ info(...args) {
29
+ if (shouldLog('info')) {
30
+ console.error('[INFO]', ...args);
31
+ }
32
+ },
33
+ warn(...args) {
34
+ if (shouldLog('warn')) {
35
+ console.error('[WARN]', ...args);
36
+ }
37
+ },
38
+ error(...args) {
39
+ if (shouldLog('error')) {
40
+ console.error('[ERROR]', ...args);
41
+ }
42
+ },
43
+ };
@@ -0,0 +1,16 @@
1
+ import type { FastMCP } from 'fastmcp';
2
+ import { forms_v1 } from 'googleapis';
3
+ import { z } from 'zod';
4
+ export declare const OptionSchema: z.ZodUnion<[z.ZodString, z.ZodObject<{
5
+ label: z.ZodString;
6
+ description: z.ZodOptional<z.ZodString>;
7
+ }, "strip", z.ZodTypeAny, {
8
+ label: string;
9
+ description?: string | undefined;
10
+ }, {
11
+ label: string;
12
+ description?: string | undefined;
13
+ }>]>;
14
+ export type FormChoice = z.infer<typeof OptionSchema>;
15
+ export declare function formatOptions(options: FormChoice[]): forms_v1.Schema$Option[];
16
+ export declare function registerFormsTools(server: FastMCP): void;
@@ -0,0 +1,365 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDriveClient, getFormsClient } from '../clients.js';
4
+ export const OptionSchema = z.union([
5
+ z.string(),
6
+ z.object({
7
+ label: z.string(),
8
+ description: z.string().optional(),
9
+ }),
10
+ ]);
11
+ export function formatOptions(options) {
12
+ return options.map((opt) => {
13
+ if (typeof opt === 'string') {
14
+ return { value: opt };
15
+ }
16
+ const value = opt.description ? `${opt.label} – ${opt.description}` : opt.label;
17
+ return { value };
18
+ });
19
+ }
20
+ function jsonResponse(data) {
21
+ return JSON.stringify(data, null, 2);
22
+ }
23
+ export function registerFormsTools(server) {
24
+ server.addTool({
25
+ name: 'create_form',
26
+ description: 'Create a new Google Form',
27
+ parameters: z.object({
28
+ title: z.string().describe('Form title'),
29
+ description: z.string().optional().describe('Form description'),
30
+ }),
31
+ execute: async ({ title, description }) => {
32
+ const forms = await getFormsClient();
33
+ const res = await forms.forms.create({
34
+ requestBody: {
35
+ info: { title, documentTitle: title, description },
36
+ },
37
+ });
38
+ const formId = res.data.formId;
39
+ return jsonResponse({
40
+ formId,
41
+ title,
42
+ description: description || '',
43
+ responderUri: `https://docs.google.com/forms/d/${formId}/viewform`,
44
+ });
45
+ },
46
+ });
47
+ server.addTool({
48
+ name: 'get_form',
49
+ description: 'Get form details',
50
+ parameters: z.object({
51
+ formId: z.string().describe('Form ID'),
52
+ }),
53
+ execute: async ({ formId }) => {
54
+ const forms = await getFormsClient();
55
+ const res = await forms.forms.get({ formId });
56
+ return jsonResponse(res.data);
57
+ },
58
+ });
59
+ server.addTool({
60
+ name: 'list_forms',
61
+ description: 'List all Google Forms',
62
+ parameters: z.object({
63
+ limit: z.number().optional().default(10).describe('Max forms to return'),
64
+ }),
65
+ execute: async ({ limit }) => {
66
+ const drive = await getDriveClient();
67
+ const res = await drive.files.list({
68
+ q: "mimeType='application/vnd.google-apps.form'",
69
+ pageSize: limit,
70
+ fields: 'files(id, name, createdTime, modifiedTime, webViewLink)',
71
+ orderBy: 'modifiedTime desc',
72
+ });
73
+ const result = (res.data.files || []).map((file) => ({
74
+ formId: file.id,
75
+ title: file.name,
76
+ createdTime: file.createdTime,
77
+ modifiedTime: file.modifiedTime,
78
+ editUrl: file.webViewLink,
79
+ responderUrl: `https://docs.google.com/forms/d/e/${file.id}/viewform`,
80
+ }));
81
+ return jsonResponse({ count: result.length, forms: result });
82
+ },
83
+ });
84
+ server.addTool({
85
+ name: 'update_form',
86
+ description: 'Update form title and/or description',
87
+ parameters: z.object({
88
+ formId: z.string().describe('Form ID'),
89
+ title: z.string().optional().describe('New title'),
90
+ description: z.string().optional().describe('New description'),
91
+ }),
92
+ execute: async ({ formId, title, description }) => {
93
+ if (!title && description === undefined) {
94
+ throw new UserError('At least title or description is required');
95
+ }
96
+ const forms = await getFormsClient();
97
+ const updateFormInfo = {};
98
+ const updateMask = [];
99
+ if (title) {
100
+ updateFormInfo.title = title;
101
+ updateMask.push('title');
102
+ }
103
+ if (description !== undefined) {
104
+ updateFormInfo.description = description;
105
+ updateMask.push('description');
106
+ }
107
+ await forms.forms.batchUpdate({
108
+ formId,
109
+ requestBody: {
110
+ requests: [{ updateFormInfo: { info: updateFormInfo, updateMask: updateMask.join(',') } }],
111
+ },
112
+ });
113
+ return jsonResponse({ success: true, message: 'Form updated', title, description });
114
+ },
115
+ });
116
+ server.addTool({
117
+ name: 'delete_form',
118
+ description: 'Delete a Google Form permanently',
119
+ parameters: z.object({
120
+ formId: z.string().describe('Form ID'),
121
+ }),
122
+ execute: async ({ formId }) => {
123
+ const drive = await getDriveClient();
124
+ await drive.files.delete({ fileId: formId });
125
+ return jsonResponse({ success: true, message: `Form ${formId} deleted` });
126
+ },
127
+ });
128
+ server.addTool({
129
+ name: 'add_section',
130
+ description: 'Add a section header/divider (no page break, single page)',
131
+ parameters: z.object({
132
+ formId: z.string().describe('Form ID'),
133
+ title: z.string().describe('Header title'),
134
+ description: z.string().optional().describe('Header description'),
135
+ }),
136
+ execute: async ({ formId, title, description }) => {
137
+ const forms = await getFormsClient();
138
+ const item = {
139
+ title,
140
+ textItem: {},
141
+ };
142
+ if (description) {
143
+ item.description = description;
144
+ }
145
+ await forms.forms.batchUpdate({
146
+ formId,
147
+ requestBody: {
148
+ requests: [{ createItem: { item, location: { index: 0 } } }],
149
+ },
150
+ });
151
+ return jsonResponse({ success: true, message: 'Section added', title, description });
152
+ },
153
+ });
154
+ server.addTool({
155
+ name: 'add_page',
156
+ description: 'Add a page break (requires clicking Next to continue)',
157
+ parameters: z.object({
158
+ formId: z.string().describe('Form ID'),
159
+ title: z.string().describe('Section title'),
160
+ description: z.string().optional().describe('Section description'),
161
+ }),
162
+ execute: async ({ formId, title, description }) => {
163
+ const forms = await getFormsClient();
164
+ const item = {
165
+ title,
166
+ pageBreakItem: {},
167
+ };
168
+ if (description) {
169
+ item.description = description;
170
+ }
171
+ await forms.forms.batchUpdate({
172
+ formId,
173
+ requestBody: {
174
+ requests: [{ createItem: { item, location: { index: 0 } } }],
175
+ },
176
+ });
177
+ return jsonResponse({ success: true, message: 'Page added', title, description });
178
+ },
179
+ });
180
+ server.addTool({
181
+ name: 'add_text_question',
182
+ description: 'Add a text question to the form',
183
+ parameters: z.object({
184
+ formId: z.string().describe('Form ID'),
185
+ question: z.string().describe('Question title'),
186
+ description: z.string().optional().describe('Help text'),
187
+ required: z.boolean().optional().default(false).describe('Required?'),
188
+ }),
189
+ execute: async ({ formId, question, description, required }) => {
190
+ const forms = await getFormsClient();
191
+ const item = {
192
+ title: question,
193
+ questionItem: {
194
+ question: {
195
+ required,
196
+ textQuestion: {},
197
+ },
198
+ },
199
+ };
200
+ if (description) {
201
+ item.description = description;
202
+ }
203
+ await forms.forms.batchUpdate({
204
+ formId,
205
+ requestBody: {
206
+ requests: [{ createItem: { item, location: { index: 0 } } }],
207
+ },
208
+ });
209
+ return jsonResponse({ success: true, message: 'Text question added', question, required });
210
+ },
211
+ });
212
+ server.addTool({
213
+ name: 'add_multiple_choice_question',
214
+ description: 'Add a multiple choice question to the form',
215
+ parameters: z.object({
216
+ formId: z.string().describe('Form ID'),
217
+ question: z.string().describe('Question title'),
218
+ options: z.array(OptionSchema).describe('Choices (string or {label, description})'),
219
+ required: z.boolean().optional().default(false).describe('Required?'),
220
+ includeOther: z.boolean().optional().default(true).describe('Add "Other" option'),
221
+ multiSelect: z.boolean().optional().default(false).describe('Allow multiple selections (checkboxes)'),
222
+ }),
223
+ execute: async ({ formId, question, options, required, includeOther, multiSelect }) => {
224
+ const forms = await getFormsClient();
225
+ const choices = formatOptions(options);
226
+ if (includeOther) {
227
+ choices.push({ isOther: true });
228
+ }
229
+ const item = {
230
+ title: question,
231
+ questionItem: {
232
+ question: {
233
+ required,
234
+ choiceQuestion: {
235
+ type: multiSelect ? 'CHECKBOX' : 'RADIO',
236
+ options: choices,
237
+ },
238
+ },
239
+ },
240
+ };
241
+ await forms.forms.batchUpdate({
242
+ formId,
243
+ requestBody: {
244
+ requests: [{ createItem: { item, location: { index: 0 } } }],
245
+ },
246
+ });
247
+ return jsonResponse({
248
+ success: true,
249
+ message: 'Multiple choice question added',
250
+ question,
251
+ options,
252
+ required,
253
+ });
254
+ },
255
+ });
256
+ server.addTool({
257
+ name: 'update_question',
258
+ description: 'Update an existing question',
259
+ parameters: z.object({
260
+ formId: z.string().describe('Form ID'),
261
+ index: z.number().describe('Question index (0-based)'),
262
+ question: z.string().optional().describe('New title'),
263
+ description: z.string().optional().describe('New description'),
264
+ options: z.array(OptionSchema).optional().describe('New options (for choice questions)'),
265
+ required: z.boolean().optional().describe('Required?'),
266
+ }),
267
+ execute: async ({ formId, index, question, description, options, required }) => {
268
+ const forms = await getFormsClient();
269
+ const form = await forms.forms.get({ formId });
270
+ const items = form.data.items || [];
271
+ if (index < 0 || index >= items.length) {
272
+ throw new UserError(`Invalid index: ${index}. Form has ${items.length} items.`);
273
+ }
274
+ const existingItem = items[index];
275
+ if (!existingItem?.itemId) {
276
+ throw new UserError(`Item at index ${index} is missing an itemId.`);
277
+ }
278
+ const updateMask = [];
279
+ const item = {};
280
+ if (question !== undefined) {
281
+ item.title = question;
282
+ updateMask.push('title');
283
+ }
284
+ if (description !== undefined) {
285
+ item.description = description;
286
+ updateMask.push('description');
287
+ }
288
+ if (existingItem.questionItem) {
289
+ item.questionItem = { question: {} };
290
+ if (required !== undefined) {
291
+ item.questionItem.question = { required };
292
+ updateMask.push('questionItem.question.required');
293
+ }
294
+ if (options && existingItem.questionItem.question?.choiceQuestion) {
295
+ const choices = formatOptions(options);
296
+ item.questionItem.question = {
297
+ ...item.questionItem.question,
298
+ choiceQuestion: {
299
+ ...existingItem.questionItem.question.choiceQuestion,
300
+ options: choices,
301
+ },
302
+ };
303
+ updateMask.push('questionItem.question.choiceQuestion.options');
304
+ }
305
+ }
306
+ if (updateMask.length === 0) {
307
+ throw new UserError('At least one field to update is required');
308
+ }
309
+ await forms.forms.batchUpdate({
310
+ formId,
311
+ requestBody: {
312
+ requests: [
313
+ {
314
+ updateItem: {
315
+ item: { itemId: existingItem.itemId, ...item },
316
+ location: { index },
317
+ updateMask: updateMask.join(','),
318
+ },
319
+ },
320
+ ],
321
+ },
322
+ });
323
+ return jsonResponse({
324
+ success: true,
325
+ message: `Question at index ${index} updated`,
326
+ updated: updateMask,
327
+ });
328
+ },
329
+ });
330
+ server.addTool({
331
+ name: 'delete_question',
332
+ description: 'Delete a question from the form by index',
333
+ parameters: z.object({
334
+ formId: z.string().describe('Form ID'),
335
+ index: z.number().describe('Question index (0-based)'),
336
+ }),
337
+ execute: async ({ formId, index }) => {
338
+ const forms = await getFormsClient();
339
+ const form = await forms.forms.get({ formId });
340
+ const items = form.data.items || [];
341
+ if (index < 0 || index >= items.length) {
342
+ throw new UserError(`Invalid index: ${index}. Form has ${items.length} items.`);
343
+ }
344
+ await forms.forms.batchUpdate({
345
+ formId,
346
+ requestBody: {
347
+ requests: [{ deleteItem: { location: { index } } }],
348
+ },
349
+ });
350
+ return jsonResponse({ success: true, message: `Question at index ${index} deleted` });
351
+ },
352
+ });
353
+ server.addTool({
354
+ name: 'get_form_responses',
355
+ description: 'Get form responses',
356
+ parameters: z.object({
357
+ formId: z.string().describe('Form ID'),
358
+ }),
359
+ execute: async ({ formId }) => {
360
+ const forms = await getFormsClient();
361
+ const res = await forms.forms.responses.list({ formId });
362
+ return jsonResponse(res.data);
363
+ },
364
+ });
365
+ }
@@ -0,0 +1,2 @@
1
+ import type { FastMCP } from 'fastmcp';
2
+ export declare function registerAllTools(server: FastMCP): void;
@@ -0,0 +1,4 @@
1
+ import { registerFormsTools } from './forms.js';
2
+ export function registerAllTools(server) {
3
+ registerFormsTools(server);
4
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "google-forms-mcp",
3
+ "version": "1.0.0",
4
+ "description": "MCP server for Google Forms",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "dist/index.js",
8
+ "bin": {
9
+ "google-forms-mcp": "./dist/index.js"
10
+ },
11
+ "files": [
12
+ "dist/",
13
+ "README.md",
14
+ "LICENSE"
15
+ ],
16
+ "scripts": {
17
+ "build": "tsc",
18
+ "start": "node ./dist/index.js",
19
+ "test": "vitest run",
20
+ "prepublishOnly": "npm run build"
21
+ },
22
+ "dependencies": {
23
+ "fastmcp": "^3.24.0",
24
+ "google-auth-library": "^10.5.0",
25
+ "googleapis": "^171.4.0",
26
+ "zod": "^3.24.2"
27
+ },
28
+ "devDependencies": {
29
+ "@types/node": "^22.14.1",
30
+ "typescript": "^5.8.3",
31
+ "vitest": "^4.0.18"
32
+ }
33
+ }