google-tools-mcp 1.0.6 → 1.0.8

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.
@@ -0,0 +1,170 @@
1
+ import { UserError } from 'fastmcp';
2
+ import { z } from 'zod';
3
+ import { getDocsClient } from '../../../clients.js';
4
+ import { DocumentIdParameter, TextFindParameter } from '../../../types.js';
5
+ import * as GDocsHelpers from '../../../googleDocsApiHelpers.js';
6
+
7
+ const RangeTarget = z.object({
8
+ startIndex: z.number().int().min(1).describe('Start of range (inclusive, 1-based).'),
9
+ endIndex: z.number().int().min(1).describe('End of range (exclusive).'),
10
+ }).refine((d) => d.endIndex > d.startIndex, {
11
+ message: 'endIndex must be greater than startIndex',
12
+ path: ['endIndex'],
13
+ });
14
+
15
+ const GetFormattingParameters = DocumentIdParameter.extend({
16
+ target: z
17
+ .union([RangeTarget, TextFindParameter])
18
+ .describe('Target by range indices or text search.'),
19
+ tabId: z
20
+ .string()
21
+ .optional()
22
+ .describe('The ID of the specific tab to read from. If not specified, reads from the first tab.'),
23
+ });
24
+
25
+ /**
26
+ * Extracts text style and paragraph style info from document elements
27
+ * that overlap the given range.
28
+ */
29
+ function extractFormattingFromContent(bodyContent, startIndex, endIndex) {
30
+ const textStyles = [];
31
+ const paragraphStyles = [];
32
+
33
+ for (const element of bodyContent) {
34
+ if (!element.paragraph) continue;
35
+
36
+ const para = element.paragraph;
37
+ // Check if this paragraph overlaps our range
38
+ const paraElements = para.elements || [];
39
+ let paragraphOverlaps = false;
40
+
41
+ for (const pe of paraElements) {
42
+ const elStart = pe.startIndex ?? 0;
43
+ const elEnd = pe.endIndex ?? 0;
44
+
45
+ // Check overlap with our target range
46
+ if (elEnd > startIndex && elStart < endIndex) {
47
+ paragraphOverlaps = true;
48
+ if (pe.textRun) {
49
+ const text = pe.textRun.content || '';
50
+ const style = pe.textRun.textStyle || {};
51
+ // Only include non-empty style properties
52
+ const cleanStyle = {};
53
+ if (style.bold) cleanStyle.bold = true;
54
+ if (style.italic) cleanStyle.italic = true;
55
+ if (style.underline) cleanStyle.underline = true;
56
+ if (style.strikethrough) cleanStyle.strikethrough = true;
57
+ if (style.fontSize) cleanStyle.fontSize = style.fontSize;
58
+ if (style.foregroundColor?.color?.rgbColor) cleanStyle.foregroundColor = style.foregroundColor.color.rgbColor;
59
+ if (style.backgroundColor?.color?.rgbColor) cleanStyle.backgroundColor = style.backgroundColor.color.rgbColor;
60
+ if (style.weightedFontFamily) cleanStyle.fontFamily = style.weightedFontFamily.fontFamily;
61
+ if (style.link) cleanStyle.link = style.link;
62
+ if (style.baselineOffset && style.baselineOffset !== 'BASELINE_OFFSET_UNSPECIFIED') cleanStyle.baselineOffset = style.baselineOffset;
63
+
64
+ textStyles.push({
65
+ startIndex: elStart,
66
+ endIndex: elEnd,
67
+ text: text.replace(/\n$/, ''),
68
+ style: cleanStyle,
69
+ });
70
+ }
71
+ }
72
+ }
73
+
74
+ if (paragraphOverlaps && para.paragraphStyle) {
75
+ const ps = para.paragraphStyle;
76
+ const cleanStyle = {};
77
+ if (ps.namedStyleType && ps.namedStyleType !== 'NORMAL_TEXT') cleanStyle.namedStyleType = ps.namedStyleType;
78
+ if (ps.alignment && ps.alignment !== 'START') cleanStyle.alignment = ps.alignment;
79
+ if (ps.indentStart?.magnitude) cleanStyle.indentStart = ps.indentStart.magnitude;
80
+ if (ps.indentEnd?.magnitude) cleanStyle.indentEnd = ps.indentEnd.magnitude;
81
+ if (ps.spaceAbove?.magnitude) cleanStyle.spaceAbove = ps.spaceAbove.magnitude;
82
+ if (ps.spaceBelow?.magnitude) cleanStyle.spaceBelow = ps.spaceBelow.magnitude;
83
+ if (ps.keepWithNext) cleanStyle.keepWithNext = true;
84
+ if (ps.lineSpacing) cleanStyle.lineSpacing = ps.lineSpacing;
85
+ if (ps.direction && ps.direction !== 'LEFT_TO_RIGHT') cleanStyle.direction = ps.direction;
86
+
87
+ // Always include namedStyleType even if NORMAL_TEXT for clarity
88
+ if (!cleanStyle.namedStyleType) cleanStyle.namedStyleType = 'NORMAL_TEXT';
89
+
90
+ paragraphStyles.push({
91
+ paragraphStartIndex: element.startIndex,
92
+ paragraphEndIndex: element.endIndex,
93
+ style: cleanStyle,
94
+ });
95
+ }
96
+ }
97
+
98
+ return { textStyles, paragraphStyles };
99
+ }
100
+
101
+ export function register(server) {
102
+ server.addTool({
103
+ name: 'getFormatting',
104
+ description: 'Returns the text styling (bold, italic, font, colors, etc.) and paragraph styling ' +
105
+ '(alignment, headings, spacing, indentation) for a specific range or found text in a Google Doc.',
106
+ parameters: GetFormattingParameters,
107
+ execute: async (args, { log }) => {
108
+ const docs = await getDocsClient();
109
+ log.info(`getFormatting on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
110
+ `${args.tabId ? `, tab=${args.tabId}` : ''}`);
111
+ try {
112
+ // Resolve target to numeric indices
113
+ let startIndex;
114
+ let endIndex;
115
+
116
+ if ('textToFind' in args.target) {
117
+ const range = await GDocsHelpers.findTextRange(
118
+ docs, args.documentId, args.target.textToFind,
119
+ args.target.matchInstance, args.tabId
120
+ );
121
+ if (!range) {
122
+ throw new UserError(
123
+ `Could not find instance ${args.target.matchInstance ?? 1} of text "${args.target.textToFind}"${args.tabId ? ` in tab ${args.tabId}` : ''}.`
124
+ );
125
+ }
126
+ startIndex = range.startIndex;
127
+ endIndex = range.endIndex;
128
+ } else {
129
+ startIndex = args.target.startIndex;
130
+ endIndex = args.target.endIndex;
131
+ }
132
+
133
+ // Fetch document with full content
134
+ const needsTabsContent = !!args.tabId;
135
+ const res = await docs.documents.get({
136
+ documentId: args.documentId,
137
+ ...(needsTabsContent && { includeTabsContent: true }),
138
+ fields: needsTabsContent ? '*' : 'body(content)',
139
+ });
140
+
141
+ let bodyContent;
142
+ if (args.tabId) {
143
+ const targetTab = GDocsHelpers.findTabById(res.data, args.tabId);
144
+ if (!targetTab) {
145
+ throw new UserError(`Tab with ID "${args.tabId}" not found in document.`);
146
+ }
147
+ bodyContent = targetTab.documentTab?.body?.content;
148
+ } else {
149
+ bodyContent = res.data.body?.content;
150
+ }
151
+
152
+ if (!bodyContent) {
153
+ throw new UserError('Document has no content.');
154
+ }
155
+
156
+ const result = extractFormattingFromContent(bodyContent, startIndex, endIndex);
157
+
158
+ return JSON.stringify({
159
+ range: { startIndex, endIndex },
160
+ textStyles: result.textStyles,
161
+ paragraphStyles: result.paragraphStyles,
162
+ }, null, 2);
163
+ } catch (error) {
164
+ log.error(`Error in getFormatting for doc ${args.documentId}: ${error.message || error}`);
165
+ if (error instanceof UserError) throw error;
166
+ throw new UserError(`Failed to get formatting: ${error.message || 'Unknown error'}`);
167
+ }
168
+ },
169
+ });
170
+ }
@@ -1,4 +1,6 @@
1
1
  import { register as applyParagraphStyle } from './applyParagraphStyle.js';
2
+ import { register as getFormatting } from './getFormatting.js';
2
3
  export function registerFormattingTools(server) {
3
4
  applyParagraphStyle(server);
5
+ getFormatting(server);
4
6
  }
@@ -1,7 +1,7 @@
1
1
  import { UserError } from 'fastmcp';
2
2
  import { z } from 'zod';
3
3
  import { getDocsClient } from '../../clients.js';
4
- import { DocumentIdParameter, TextFindParameter, TextStyleParameters } from '../../types.js';
4
+ import { DocumentIdParameter, TextFindParameter, TextStyleParameters, ParagraphStyleParameters } from '../../types.js';
5
5
  import * as GDocsHelpers from '../../googleDocsApiHelpers.js';
6
6
  const RangeTarget = z
7
7
  .object({
@@ -20,14 +20,15 @@ const ModifyTextParameters = DocumentIdParameter.extend({
20
20
  .union([RangeTarget, TextFindParameter, InsertionTarget])
21
21
  .describe('Target by range indices, text search, or insertion index.'),
22
22
  text: z.string().optional().describe('New text to insert or replace with.'),
23
- style: TextStyleParameters.optional().describe('Text formatting to apply.'),
23
+ style: TextStyleParameters.optional().describe('Text formatting to apply (bold, italic, font size, etc.).'),
24
+ paragraphStyle: ParagraphStyleParameters.optional().describe('Paragraph formatting to apply (alignment, indentation, headings, spacing, etc.).'),
24
25
  tabId: z
25
26
  .string()
26
27
  .optional()
27
28
  .describe('The ID of the specific tab to operate on. If not specified, operates on the first tab.'),
28
29
  })
29
- .refine((args) => args.text !== undefined || args.style !== undefined, {
30
- message: 'At least one of text or style must be provided.',
30
+ .refine((args) => args.text !== undefined || args.style !== undefined || args.paragraphStyle !== undefined, {
31
+ message: 'At least one of text, style, or paragraphStyle must be provided.',
31
32
  })
32
33
  .refine((args) => {
33
34
  if ('insertionIndex' in args.target && args.text === undefined)
@@ -39,9 +40,9 @@ const ModifyTextParameters = DocumentIdParameter.extend({
39
40
  * modifyText operation. Indices must already be resolved (no text-search here).
40
41
  */
41
42
  export function buildModifyTextRequests(opts) {
42
- const { startIndex, endIndex, text, style, tabId } = opts;
43
+ const { startIndex, endIndex, text, style, paragraphStyle, tabId } = opts;
43
44
  const requests = [];
44
- if (!text && !style)
45
+ if (!text && !style && !paragraphStyle)
45
46
  return requests;
46
47
  // 1. Delete existing content (only when replacing, not insert-only)
47
48
  if (endIndex !== undefined && text !== undefined) {
@@ -57,7 +58,7 @@ export function buildModifyTextRequests(opts) {
57
58
  location.tabId = tabId;
58
59
  requests.push({ insertText: { location, text } });
59
60
  }
60
- // 3. Apply formatting
61
+ // 3. Apply text formatting
61
62
  if (style) {
62
63
  const formatStart = startIndex;
63
64
  const formatEnd = text !== undefined
@@ -72,20 +73,36 @@ export function buildModifyTextRequests(opts) {
72
73
  }
73
74
  }
74
75
  }
76
+ // 4. Apply paragraph formatting
77
+ if (paragraphStyle) {
78
+ const formatStart = startIndex;
79
+ const formatEnd = text !== undefined
80
+ ? startIndex + text.length
81
+ : endIndex !== undefined
82
+ ? endIndex
83
+ : startIndex;
84
+ if (formatEnd > formatStart) {
85
+ const requestInfo = GDocsHelpers.buildUpdateParagraphStyleRequest(formatStart, formatEnd, paragraphStyle, tabId);
86
+ if (requestInfo) {
87
+ requests.push(requestInfo.request);
88
+ }
89
+ }
90
+ }
75
91
  return requests;
76
92
  }
77
93
  export function register(server) {
78
94
  server.addTool({
79
95
  name: 'modifyText',
80
96
  description: 'Combines text replacement/insertion and formatting in one atomic operation. ' +
81
- 'Can insert text at a position, replace a range or found text, apply formatting, ' +
82
- "or any combination. Use readGoogleDoc with format='json' to determine indices.",
97
+ 'Can insert text at a position, replace a range or found text, apply text styling (bold, italic, etc.), ' +
98
+ "apply paragraph styling (alignment, headings, spacing, etc.), or any combination. Use readGoogleDoc with format='json' to determine indices.",
83
99
  parameters: ModifyTextParameters,
84
100
  execute: async (args, { log }) => {
85
101
  const docs = await getDocsClient();
86
102
  log.info(`modifyText on doc ${args.documentId}: target=${JSON.stringify(args.target)}` +
87
103
  `${args.text !== undefined ? `, text="${args.text.substring(0, 50)}"` : ''}` +
88
104
  `${args.style ? `, style=${JSON.stringify(args.style)}` : ''}` +
105
+ `${args.paragraphStyle ? `, paragraphStyle=${JSON.stringify(args.paragraphStyle)}` : ''}` +
89
106
  `${args.tabId ? `, tab=${args.tabId}` : ''}`);
90
107
  try {
91
108
  // Verify tab exists if specified
@@ -131,6 +148,7 @@ export function register(server) {
131
148
  endIndex,
132
149
  text: args.text,
133
150
  style: args.style,
151
+ paragraphStyle: args.paragraphStyle,
134
152
  tabId: args.tabId,
135
153
  });
136
154
  if (requests.length === 0) {
@@ -144,7 +162,9 @@ export function register(server) {
144
162
  else if (args.text !== undefined)
145
163
  actions.push('inserted text');
146
164
  if (args.style)
147
- actions.push('applied formatting');
165
+ actions.push('applied text formatting');
166
+ if (args.paragraphStyle)
167
+ actions.push('applied paragraph formatting');
148
168
  return `Successfully ${actions.join(' and ')} at range ${startIndex}-${endIndex ?? startIndex + (args.text?.length ?? 0)}${args.tabId ? ` in tab ${args.tabId}` : ''}.`;
149
169
  }
150
170
  catch (error) {
@@ -73,14 +73,19 @@ export function register(server) {
73
73
  queryParts.push(`modifiedTime > '${cutoffDate}'`);
74
74
  }
75
75
 
76
- const response = await drive.files.list({
76
+ // Drive API does not allow orderBy when using fullText contains
77
+ const useFullText = !!args.query;
78
+ const listParams = {
77
79
  q: queryParts.join(' and '),
78
80
  pageSize: args.maxResults,
79
- orderBy: args.orderBy === 'name' ? 'name' : args.orderBy,
80
81
  fields: 'files(id,name,mimeType,modifiedTime,createdTime,size,webViewLink,owners(displayName,emailAddress))',
81
82
  supportsAllDrives: true,
82
83
  includeItemsFromAllDrives: true,
83
- });
84
+ };
85
+ if (!useFullText) {
86
+ listParams.orderBy = args.orderBy === 'name' ? 'name' : args.orderBy;
87
+ }
88
+ const response = await drive.files.list(listParams);
84
89
 
85
90
  const files = (response.data.files || []).map((file) => ({
86
91
  id: file.id,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "google-tools-mcp",
3
- "version": "1.0.6",
3
+ "version": "1.0.8",
4
4
  "description": "Combined Google Workspace MCP server (Drive, Docs, Sheets, Gmail) with lazy-loaded tool categories",
5
5
  "type": "module",
6
6
  "bin": {