gogcli-mcp-contacts 2.8.0 → 2.18.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/manifest.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "manifest_version": "0.3",
4
4
  "name": "gogcli-mcp-contacts",
5
5
  "display_name": "gogcli (Contacts)",
6
- "version": "2.8.0",
6
+ "version": "2.18.0",
7
7
  "description": "Extended Google Contacts for Claude via gogcli — auth + Contacts + Workspace directory (People API)",
8
8
  "author": {
9
9
  "name": "Chris Hall",
@@ -58,6 +58,18 @@
58
58
  "name": "gog_auth_add",
59
59
  "description": "Authorize a Google account via browser-based OAuth"
60
60
  },
61
+ {
62
+ "name": "gog_auth_health",
63
+ "description": "Live per-account token health: validity, age, and pre-expiry warnings (detects invalid_grant)"
64
+ },
65
+ {
66
+ "name": "gog_auth_add_url",
67
+ "description": "Begin remote/headless OAuth (step 1): return a sign-in URL to open in any browser"
68
+ },
69
+ {
70
+ "name": "gog_auth_add_complete",
71
+ "description": "Complete remote/headless OAuth (step 2): exchange the pasted redirect URL and store the token"
72
+ },
61
73
  {
62
74
  "name": "gog_auth_list",
63
75
  "description": "List all Google accounts stored in gogcli"
@@ -128,7 +140,7 @@
128
140
  },
129
141
  {
130
142
  "name": "gog_contacts_dedupe",
131
- "description": "Find likely duplicate contacts (preview only)"
143
+ "description": "Find duplicate contacts; optionally merge and delete redundants"
132
144
  },
133
145
  {
134
146
  "name": "gog_contacts_directory_list",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gogcli-mcp-contacts",
3
- "version": "2.8.0",
3
+ "version": "2.18.0",
4
4
  "mcpName": "io.github.chrischall/gogcli-mcp-contacts",
5
5
  "description": "Extended Google Contacts + People MCP server via gogcli — auth + Contacts + Workspace directory (People API)",
6
6
  "author": "Claude Code (AI) <https://www.anthropic.com/claude>",
@@ -19,11 +19,12 @@
19
19
  "build": "tsc --noEmit && npm run bundle",
20
20
  "bundle": "node ../../scripts/bundle.js src/index.ts dist/index.js",
21
21
  "typecheck": "tsc --noEmit",
22
- "test": "vitest run",
22
+ "test": "vitest run --coverage",
23
23
  "test:watch": "vitest",
24
24
  "test:coverage": "vitest run --coverage"
25
25
  },
26
26
  "dependencies": {
27
+ "@chrischall/mcp-utils": "^0.13.3",
27
28
  "@modelcontextprotocol/sdk": "^1.29.0",
28
29
  "zod": "^4.4.3"
29
30
  },
package/src/index.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  #!/usr/bin/env node
2
- import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
- import { createServer, registerAuthTools, registerContactsTools } from '../../gogcli-mcp/src/lib.js';
2
+ import { runMcp } from '@chrischall/mcp-utils';
3
+ import { VERSION, authToolsFor, registerContactsTools } from '../../gogcli-mcp/src/lib.js';
4
4
  import { registerExtraContactsTools } from './tools/contacts-extra.js';
5
5
 
6
- const server = createServer({ name: 'gogcli-contacts' });
7
- registerAuthTools(server);
8
- registerContactsTools(server);
9
- registerExtraContactsTools(server);
10
-
11
- const transport = new StdioServerTransport();
12
- await server.connect(transport);
6
+ await runMcp({
7
+ name: 'gogcli-contacts',
8
+ version: VERSION,
9
+ tools: [authToolsFor('contacts'), registerContactsTools, registerExtraContactsTools],
10
+ });
@@ -100,7 +100,7 @@ export function registerExtraContactsTools(server: McpServer): void {
100
100
  account: accountParam,
101
101
  },
102
102
  }, async ({ resourceName, account }) => {
103
- return runOrDiagnose(['contacts', 'delete', resourceName], { account });
103
+ return runOrDiagnose(['contacts', 'delete', resourceName, '--force'], { account }); // gog gates this op; without --force the runner's --no-input makes it refuse
104
104
  });
105
105
 
106
106
  server.registerTool('gog_contacts_export', {
@@ -127,17 +127,24 @@ export function registerExtraContactsTools(server: McpServer): void {
127
127
  });
128
128
 
129
129
  server.registerTool('gog_contacts_dedupe', {
130
- description: 'Find likely duplicate personal contacts (preview only — does not modify anything).',
131
- annotations: { readOnlyHint: true },
130
+ description: 'Find likely duplicate personal contacts. Defaults to a read-only preview of the merge plan; set apply to actually merge each duplicate group and delete the redundant contacts (etag-checked, with ambiguous or unmergeable groups refused). Scope a risky apply with resource. Always preview first.',
131
+ annotations: { destructiveHint: true },
132
132
  inputSchema: {
133
133
  match: z.string().optional().describe('Match fields, comma-separated from email,phone,name (default: email,phone)'),
134
- max: z.number().optional().describe('Max contacts to scan (0 = all)'),
134
+ max: z.number().optional().describe('Max contacts to scan (0 = all). Mutually exclusive with resource — gog rejects passing both.'),
135
+ resource: z.array(z.string()).optional().describe('Limit dedupe to these exact contact resource names (e.g. people/c123), repeatable — useful to scope an apply to known duplicates. Mutually exclusive with max.'),
136
+ apply: z.boolean().optional().describe('Merge each duplicate group and DELETE the redundant contacts. Without this the tool only previews the plan. Destructive — preview first.'),
137
+ failEmpty: z.boolean().optional().describe('Exit with an error (code 3) when no duplicates are found, instead of succeeding empty.'),
135
138
  account: accountParam,
136
139
  },
137
- }, async ({ match, max, account }) => {
140
+ }, async ({ match, max, resource, apply, failEmpty, account }) => {
138
141
  const args = ['contacts', 'dedupe'];
139
142
  if (match) args.push(`--match=${match}`);
140
143
  if (max !== undefined) args.push(`--max=${max}`);
144
+ if (resource) for (const r of resource) args.push(`--resource=${r}`);
145
+ if (apply) args.push('--apply');
146
+ if (failEmpty) args.push('--fail-empty');
147
+ if (apply) args.push('--force'); // gog gates this op; without --force the runner's --no-input makes it refuse
141
148
  return runOrDiagnose(args, { account });
142
149
  });
143
150
 
@@ -1,7 +1,8 @@
1
1
  import { describe, it, expect, vi, beforeEach } from 'vitest';
2
2
  import { registerExtraContactsTools } from '../../src/tools/contacts-extra.js';
3
3
  import * as lib from '../../../gogcli-mcp/src/lib.js';
4
- import { setupHandlers, toText, type ToolHandler } from '../../../gogcli-mcp/tests/helpers/test-harness.js';
4
+ import { createTestHarness, type TestHarness } from '@chrischall/mcp-utils/test';
5
+ import { rawTextResult } from '@chrischall/mcp-utils';
5
6
 
6
7
  vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
7
8
  const actual = await importOriginal<typeof lib>();
@@ -11,41 +12,41 @@ vi.mock('../../../gogcli-mcp/src/lib.js', async (importOriginal) => {
11
12
  };
12
13
  });
13
14
 
14
- let handlers: Map<string, ToolHandler>;
15
+ let harness: TestHarness;
15
16
 
16
- beforeEach(() => {
17
+ beforeEach(async () => {
17
18
  vi.clearAllMocks();
18
- vi.mocked(lib.runOrDiagnose).mockResolvedValue(toText('{}'));
19
- handlers = setupHandlers(registerExtraContactsTools);
19
+ vi.mocked(lib.runOrDiagnose).mockResolvedValue(rawTextResult('{}'));
20
+ harness = await createTestHarness(registerExtraContactsTools);
20
21
  });
21
22
 
22
23
  describe('gog_people_me', () => {
23
24
  it('calls runOrDiagnose with people me', async () => {
24
- await handlers.get('gog_people_me')!({});
25
+ await harness.callTool('gog_people_me', {});
25
26
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['people', 'me'], { account: undefined });
26
27
  });
27
28
 
28
29
  it('forwards account', async () => {
29
- await handlers.get('gog_people_me')!({ account: 'a@b.com' });
30
+ await harness.callTool('gog_people_me', { account: 'a@b.com' });
30
31
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['people', 'me'], { account: 'a@b.com' });
31
32
  });
32
33
  });
33
34
 
34
35
  describe('gog_people_get', () => {
35
36
  it('calls runOrDiagnose with userId', async () => {
36
- await handlers.get('gog_people_get')!({ userId: 'people/c123' });
37
+ await harness.callTool('gog_people_get', { userId: 'people/c123' });
37
38
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['people', 'get', 'people/c123'], { account: undefined });
38
39
  });
39
40
  });
40
41
 
41
42
  describe('gog_people_search', () => {
42
43
  it('calls runOrDiagnose with query', async () => {
43
- await handlers.get('gog_people_search')!({ query: 'alice' });
44
+ await harness.callTool('gog_people_search', { query: 'alice' });
44
45
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['people', 'search', 'alice'], { account: undefined });
45
46
  });
46
47
 
47
48
  it('passes pagination flags', async () => {
48
- await handlers.get('gog_people_search')!({ query: 'alice', max: 100, page: 'tok', all: true });
49
+ await harness.callTool('gog_people_search', { query: 'alice', max: 100, page: 'tok', all: true });
49
50
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
50
51
  ['people', 'search', 'alice', '--max=100', '--page=tok', '--all'],
51
52
  { account: undefined },
@@ -53,19 +54,19 @@ describe('gog_people_search', () => {
53
54
  });
54
55
 
55
56
  it('omits --all when false', async () => {
56
- await handlers.get('gog_people_search')!({ query: 'x', all: false });
57
+ await harness.callTool('gog_people_search', { query: 'x', all: false });
57
58
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['people', 'search', 'x'], { account: undefined });
58
59
  });
59
60
  });
60
61
 
61
62
  describe('gog_people_relations', () => {
62
63
  it('calls runOrDiagnose with no userId', async () => {
63
- await handlers.get('gog_people_relations')!({});
64
+ await harness.callTool('gog_people_relations', {});
64
65
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['people', 'relations'], { account: undefined });
65
66
  });
66
67
 
67
68
  it('passes userId and --type when provided', async () => {
68
- await handlers.get('gog_people_relations')!({ userId: 'people/c123', type: 'manager' });
69
+ await harness.callTool('gog_people_relations', { userId: 'people/c123', type: 'manager' });
69
70
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
70
71
  ['people', 'relations', 'people/c123', '--type=manager'],
71
72
  { account: undefined },
@@ -75,12 +76,12 @@ describe('gog_people_relations', () => {
75
76
 
76
77
  describe('gog_contacts_update', () => {
77
78
  it('calls runOrDiagnose with just resourceName', async () => {
78
- await handlers.get('gog_contacts_update')!({ resourceName: 'people/c1' });
79
+ await harness.callTool('gog_contacts_update', { resourceName: 'people/c1' });
79
80
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'update', 'people/c1'], { account: undefined });
80
81
  });
81
82
 
82
83
  it('passes all fields including empty-string clears', async () => {
83
- await handlers.get('gog_contacts_update')!({
84
+ await harness.callTool('gog_contacts_update', {
84
85
  resourceName: 'people/c1',
85
86
  given: 'Ada',
86
87
  family: 'Lovelace',
@@ -106,26 +107,26 @@ describe('gog_contacts_update', () => {
106
107
  });
107
108
 
108
109
  it('omits --ignore-etag when false', async () => {
109
- await handlers.get('gog_contacts_update')!({ resourceName: 'people/c1', ignoreEtag: false });
110
+ await harness.callTool('gog_contacts_update', { resourceName: 'people/c1', ignoreEtag: false });
110
111
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'update', 'people/c1'], { account: undefined });
111
112
  });
112
113
  });
113
114
 
114
115
  describe('gog_contacts_delete', () => {
115
116
  it('calls runOrDiagnose with resourceName', async () => {
116
- await handlers.get('gog_contacts_delete')!({ resourceName: 'people/c1' });
117
- expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'delete', 'people/c1'], { account: undefined });
117
+ await harness.callTool('gog_contacts_delete', { resourceName: 'people/c1' });
118
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'delete', 'people/c1', '--force'], { account: undefined });
118
119
  });
119
120
  });
120
121
 
121
122
  describe('gog_contacts_export', () => {
122
123
  it('calls runOrDiagnose with no options', async () => {
123
- await handlers.get('gog_contacts_export')!({});
124
+ await harness.callTool('gog_contacts_export', {});
124
125
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'export'], { account: undefined });
125
126
  });
126
127
 
127
128
  it('passes selector and all flags', async () => {
128
- await handlers.get('gog_contacts_export')!({
129
+ await harness.callTool('gog_contacts_export', {
129
130
  selector: 'people/c1',
130
131
  query: 'ada',
131
132
  all: true,
@@ -140,34 +141,51 @@ describe('gog_contacts_export', () => {
140
141
  });
141
142
 
142
143
  it('omits --all when false', async () => {
143
- await handlers.get('gog_contacts_export')!({ all: false });
144
+ await harness.callTool('gog_contacts_export', { all: false });
144
145
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'export'], { account: undefined });
145
146
  });
146
147
  });
147
148
 
148
149
  describe('gog_contacts_dedupe', () => {
149
150
  it('calls runOrDiagnose with no options', async () => {
150
- await handlers.get('gog_contacts_dedupe')!({});
151
+ await harness.callTool('gog_contacts_dedupe', {});
151
152
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'dedupe'], { account: undefined });
152
153
  });
153
154
 
154
155
  it('passes --match and --max', async () => {
155
- await handlers.get('gog_contacts_dedupe')!({ match: 'name', max: 100 });
156
+ await harness.callTool('gog_contacts_dedupe', { match: 'name', max: 100 });
156
157
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
157
158
  ['contacts', 'dedupe', '--match=name', '--max=100'],
158
159
  { account: undefined },
159
160
  );
160
161
  });
162
+
163
+ it('passes --apply, repeatable --resource, and --fail-empty', async () => {
164
+ await harness.callTool('gog_contacts_dedupe', {
165
+ apply: true,
166
+ resource: ['people/c1', 'people/c2'],
167
+ failEmpty: true,
168
+ });
169
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(
170
+ ['contacts', 'dedupe', '--resource=people/c1', '--resource=people/c2', '--apply', '--fail-empty', '--force'],
171
+ { account: undefined },
172
+ );
173
+ });
174
+
175
+ it('omits --apply and --fail-empty when false', async () => {
176
+ await harness.callTool('gog_contacts_dedupe', { apply: false, failEmpty: false });
177
+ expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'dedupe'], { account: undefined });
178
+ });
161
179
  });
162
180
 
163
181
  describe('gog_contacts_directory_list', () => {
164
182
  it('calls runOrDiagnose with no options', async () => {
165
- await handlers.get('gog_contacts_directory_list')!({});
183
+ await harness.callTool('gog_contacts_directory_list', {});
166
184
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'directory', 'list'], { account: undefined });
167
185
  });
168
186
 
169
187
  it('passes pagination flags', async () => {
170
- await handlers.get('gog_contacts_directory_list')!({ max: 50, page: 'tok', all: true });
188
+ await harness.callTool('gog_contacts_directory_list', { max: 50, page: 'tok', all: true });
171
189
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
172
190
  ['contacts', 'directory', 'list', '--max=50', '--page=tok', '--all'],
173
191
  { account: undefined },
@@ -175,19 +193,19 @@ describe('gog_contacts_directory_list', () => {
175
193
  });
176
194
 
177
195
  it('omits --all when false', async () => {
178
- await handlers.get('gog_contacts_directory_list')!({ all: false });
196
+ await harness.callTool('gog_contacts_directory_list', { all: false });
179
197
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'directory', 'list'], { account: undefined });
180
198
  });
181
199
  });
182
200
 
183
201
  describe('gog_contacts_other_list', () => {
184
202
  it('calls runOrDiagnose with no options', async () => {
185
- await handlers.get('gog_contacts_other_list')!({});
203
+ await harness.callTool('gog_contacts_other_list', {});
186
204
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'other', 'list'], { account: undefined });
187
205
  });
188
206
 
189
207
  it('passes pagination flags', async () => {
190
- await handlers.get('gog_contacts_other_list')!({ max: 100, page: 'tok', all: true });
208
+ await harness.callTool('gog_contacts_other_list', { max: 100, page: 'tok', all: true });
191
209
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
192
210
  ['contacts', 'other', 'list', '--max=100', '--page=tok', '--all'],
193
211
  { account: undefined },
@@ -195,19 +213,19 @@ describe('gog_contacts_other_list', () => {
195
213
  });
196
214
 
197
215
  it('omits --all when false', async () => {
198
- await handlers.get('gog_contacts_other_list')!({ all: false });
216
+ await harness.callTool('gog_contacts_other_list', { all: false });
199
217
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'other', 'list'], { account: undefined });
200
218
  });
201
219
  });
202
220
 
203
221
  describe('gog_contacts_other_search', () => {
204
222
  it('calls runOrDiagnose with query', async () => {
205
- await handlers.get('gog_contacts_other_search')!({ query: 'ada' });
223
+ await harness.callTool('gog_contacts_other_search', { query: 'ada' });
206
224
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['contacts', 'other', 'search', 'ada'], { account: undefined });
207
225
  });
208
226
 
209
227
  it('passes --max when provided', async () => {
210
- await handlers.get('gog_contacts_other_search')!({ query: 'ada', max: 25 });
228
+ await harness.callTool('gog_contacts_other_search', { query: 'ada', max: 25 });
211
229
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(
212
230
  ['contacts', 'other', 'search', 'ada', '--max=25'],
213
231
  { account: undefined },
@@ -217,12 +235,12 @@ describe('gog_contacts_other_search', () => {
217
235
 
218
236
  describe('gog_people_raw', () => {
219
237
  it('calls runOrDiagnose with userId', async () => {
220
- await handlers.get('gog_people_raw')!({ userId: 'people/c123' });
238
+ await harness.callTool('gog_people_raw', { userId: 'people/c123' });
221
239
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['people', 'raw', 'people/c123'], { account: undefined });
222
240
  });
223
241
 
224
242
  it('passes --person-fields and --pretty when provided', async () => {
225
- await handlers.get('gog_people_raw')!({
243
+ await harness.callTool('gog_people_raw', {
226
244
  userId: 'people/c123',
227
245
  personFields: 'names,emailAddresses',
228
246
  pretty: true,
@@ -234,7 +252,7 @@ describe('gog_people_raw', () => {
234
252
  });
235
253
 
236
254
  it('omits --pretty when false', async () => {
237
- await handlers.get('gog_people_raw')!({ userId: 'people/c123', pretty: false });
255
+ await harness.callTool('gog_people_raw', { userId: 'people/c123', pretty: false });
238
256
  expect(lib.runOrDiagnose).toHaveBeenCalledWith(['people', 'raw', 'people/c123'], { account: undefined });
239
257
  });
240
258
  });
package/tsconfig.json CHANGED
@@ -5,5 +5,5 @@
5
5
  "rootDir": "../"
6
6
  },
7
7
  "include": ["src/**/*", "../gogcli-mcp/src/**/*"],
8
- "exclude": ["node_modules", "dist"]
8
+ "exclude": ["node_modules", "dist", "../gogcli-mcp/src/worker.ts"]
9
9
  }