@plosson/agentio 0.5.13 → 0.6.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plosson/agentio",
3
- "version": "0.5.13",
3
+ "version": "0.6.0",
4
4
  "description": "CLI for LLM agents to interact with communication and tracking services",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -35,7 +35,8 @@
35
35
  "dev": "bun run src/index.ts",
36
36
  "build": "bun build src/index.ts --outdir dist --target node",
37
37
  "build:native": "bun build src/index.ts --compile --minify --sourcemap --bytecode --define BUILD_VERSION=\"\\\"$(bun -e 'console.log(require(\"./package.json\").version)')\\\"\" --outfile dist/agentio",
38
- "typecheck": "tsc --noEmit"
38
+ "typecheck": "tsc --noEmit",
39
+ "test": "bun test"
39
40
  },
40
41
  "engines": {
41
42
  "node": ">=18"
@@ -53,6 +54,7 @@
53
54
  "@googleapis/sheets": "^13.0.1",
54
55
  "@googleapis/tasks": "^12.0.0",
55
56
  "@inquirer/prompts": "^8.2.0",
57
+ "@modelcontextprotocol/sdk": "^1.29.0",
56
58
  "@whiskeysockets/baileys": "^7.0.0-rc.9",
57
59
  "commander": "^14.0.2",
58
60
  "google-auth-library": "^10.0.0",
package/src/auth/oauth.ts CHANGED
@@ -15,6 +15,7 @@ const GCHAT_SCOPES = [
15
15
  'https://www.googleapis.com/auth/chat.messages.readonly', // read messages (get operations)
16
16
  'https://www.googleapis.com/auth/chat.spaces.readonly', // read space info and list
17
17
  'https://www.googleapis.com/auth/chat.memberships.readonly', // read space members
18
+ 'https://www.googleapis.com/auth/directory.readonly', // resolve user IDs to names/emails via People API
18
19
  'https://www.googleapis.com/auth/userinfo.email', // get user email for profile naming
19
20
  ];
20
21
 
@@ -96,6 +96,11 @@ export function registerGChatCommands(program: Command): void {
96
96
  }
97
97
  }
98
98
 
99
+ // Unescape shell-escaped characters (e.g. zsh history expansion: \! → !)
100
+ if (text) {
101
+ text = text.replace(/\\!/g, '!');
102
+ }
103
+
99
104
  const { client, profile } = await getGChatClient(options.profile);
100
105
  await enforceWriteAccess('gchat', profile, 'send message');
101
106
  const result = await client.send({
@@ -0,0 +1,147 @@
1
+ import { Command } from 'commander';
2
+ import { readFile, writeFile } from 'fs/promises';
3
+ import { existsSync } from 'fs';
4
+ import { join } from 'path';
5
+ import { listProfiles } from '../config/config-manager';
6
+ import { interactiveCheckbox } from '../utils/interactive';
7
+ import { CliError, handleError } from '../utils/errors';
8
+ import {
9
+ parseServiceProfiles,
10
+ startMcpServer,
11
+ type ServiceProfilePair,
12
+ } from '../mcp/server';
13
+
14
+ const MCP_JSON = '.mcp.json';
15
+
16
+ /**
17
+ * Load existing .mcp.json or return empty structure.
18
+ */
19
+ async function loadMcpJson(
20
+ dir: string
21
+ ): Promise<Record<string, unknown>> {
22
+ const filePath = join(dir, MCP_JSON);
23
+ if (!existsSync(filePath)) {
24
+ return {};
25
+ }
26
+ const content = await readFile(filePath, 'utf-8');
27
+ return JSON.parse(content) as Record<string, unknown>;
28
+ }
29
+
30
+ /**
31
+ * Write .mcp.json, merging with existing content.
32
+ */
33
+ async function writeMcpJson(
34
+ dir: string,
35
+ pairs: ServiceProfilePair[]
36
+ ): Promise<string> {
37
+ const filePath = join(dir, MCP_JSON);
38
+ const existing = await loadMcpJson(dir);
39
+
40
+ const mcpServers = (existing.mcpServers as Record<string, unknown>) || {};
41
+
42
+ // Build the args list
43
+ const serveArgs = pairs.map((p) =>
44
+ p.profile ? `${p.service}:${p.profile}` : p.service
45
+ );
46
+
47
+ mcpServers['agentio'] = {
48
+ command: 'agentio',
49
+ args: ['mcp', 'serve', ...serveArgs],
50
+ };
51
+
52
+ existing.mcpServers = mcpServers;
53
+
54
+ await writeFile(filePath, JSON.stringify(existing, null, 2) + '\n');
55
+ return filePath;
56
+ }
57
+
58
+ /**
59
+ * Interactive mode: let user pick from configured profiles.
60
+ */
61
+ async function interactiveInstall(): Promise<ServiceProfilePair[]> {
62
+ const allProfiles = await listProfiles();
63
+ const choices: Array<{
64
+ name: string;
65
+ value: ServiceProfilePair;
66
+ checked?: boolean;
67
+ }> = [];
68
+
69
+ for (const { service, profiles } of allProfiles) {
70
+ if (profiles.length === 0) continue;
71
+ for (const profile of profiles) {
72
+ choices.push({
73
+ name: `${service}:${profile.name}`,
74
+ value: { service, profile: profile.name },
75
+ });
76
+ }
77
+ }
78
+
79
+ if (choices.length === 0) {
80
+ throw new CliError(
81
+ 'CONFIG_ERROR',
82
+ 'No profiles configured',
83
+ 'Add profiles first with: agentio <service> profile add'
84
+ );
85
+ }
86
+
87
+ const selected = await interactiveCheckbox<ServiceProfilePair>({
88
+ message: 'Select services to expose via MCP',
89
+ choices,
90
+ required: true,
91
+ });
92
+
93
+ return selected;
94
+ }
95
+
96
+ export function registerMcpCommands(program: Command): void {
97
+ const mcp = program
98
+ .command('mcp')
99
+ .description('MCP server operations');
100
+
101
+ // serve subcommand
102
+ mcp
103
+ .command('serve')
104
+ .description('Start stdio MCP server exposing CLI commands as tools')
105
+ .argument('<pairs...>', 'Service:profile pairs (e.g., gmail:work slack:team rss)')
106
+ .action(async (pairArgs: string[]) => {
107
+ try {
108
+ const pairs = parseServiceProfiles(pairArgs);
109
+ await startMcpServer(pairs);
110
+ } catch (error) {
111
+ handleError(error);
112
+ }
113
+ });
114
+
115
+ // install subcommand
116
+ mcp
117
+ .command('install')
118
+ .description('Install MCP server config into .mcp.json')
119
+ .argument('[pairs...]', 'Service:profile pairs (interactive if omitted)')
120
+ .action(async (pairArgs: string[]) => {
121
+ try {
122
+ let pairs: ServiceProfilePair[];
123
+
124
+ if (pairArgs && pairArgs.length > 0) {
125
+ pairs = parseServiceProfiles(pairArgs);
126
+ } else {
127
+ pairs = await interactiveInstall();
128
+ }
129
+
130
+ if (pairs.length === 0) {
131
+ console.log('No services selected.');
132
+ return;
133
+ }
134
+
135
+ const filePath = await writeMcpJson(process.cwd(), pairs);
136
+
137
+ const serveArgs = pairs
138
+ .map((p) => (p.profile ? `${p.service}:${p.profile}` : p.service))
139
+ .join(' ');
140
+
141
+ console.log(`Wrote ${filePath}`);
142
+ console.log(`\nMCP server command: agentio mcp serve ${serveArgs}`);
143
+ } catch (error) {
144
+ handleError(error);
145
+ }
146
+ });
147
+ }
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ import { registerWhatsAppCommands } from './commands/whatsapp';
22
22
  // Agentio utilities
23
23
  import { registerClaudeCommands } from './commands/claude';
24
24
  import { registerConfigCommands } from './commands/config';
25
+ import { registerMcpCommands } from './commands/mcp';
25
26
  import { registerDocsCommand } from './commands/docs';
26
27
  import { registerGatewayCommands } from './commands/gateway';
27
28
  import { registerReauthCommand } from './commands/reauth';
@@ -65,10 +66,16 @@ registerWhatsAppCommands(program);
65
66
  // Agentio utilities
66
67
  registerClaudeCommands(program);
67
68
  registerConfigCommands(program);
69
+ registerMcpCommands(program);
68
70
  registerDocsCommand(program);
69
71
  registerGatewayCommands(program);
70
72
  registerReauthCommand(program);
71
73
  registerStatusCommand(program);
72
74
  registerUpdateCommand(program);
73
75
 
76
+ // Show help (exit 0) when no command is provided
77
+ program.action(() => {
78
+ program.help();
79
+ });
80
+
74
81
  program.parse();
@@ -0,0 +1,136 @@
1
+ import { describe, test, expect, beforeEach, afterEach } from 'bun:test';
2
+ import { mkdtemp, writeFile, readFile, rm } from 'fs/promises';
3
+ import { join } from 'path';
4
+ import { tmpdir } from 'os';
5
+ import { existsSync } from 'fs';
6
+
7
+ // Re-implement the core install logic here for unit testing
8
+ // (the actual command uses process.cwd() and commander, so we test the JSON generation)
9
+
10
+ interface ServiceProfilePair {
11
+ service: string;
12
+ profile?: string;
13
+ }
14
+
15
+ async function loadMcpJson(dir: string): Promise<Record<string, unknown>> {
16
+ const filePath = join(dir, '.mcp.json');
17
+ if (!existsSync(filePath)) {
18
+ return {};
19
+ }
20
+ const content = await readFile(filePath, 'utf-8');
21
+ return JSON.parse(content) as Record<string, unknown>;
22
+ }
23
+
24
+ async function writeMcpJson(
25
+ dir: string,
26
+ pairs: ServiceProfilePair[]
27
+ ): Promise<string> {
28
+ const filePath = join(dir, '.mcp.json');
29
+ const existing = await loadMcpJson(dir);
30
+ const mcpServers = (existing.mcpServers as Record<string, unknown>) || {};
31
+
32
+ const serveArgs = pairs.map((p) =>
33
+ p.profile ? `${p.service}:${p.profile}` : p.service
34
+ );
35
+
36
+ mcpServers['agentio'] = {
37
+ command: 'agentio',
38
+ args: ['mcp', 'serve', ...serveArgs],
39
+ };
40
+
41
+ existing.mcpServers = mcpServers;
42
+ await writeFile(filePath, JSON.stringify(existing, null, 2) + '\n');
43
+ return filePath;
44
+ }
45
+
46
+ describe('MCP install (.mcp.json generation)', () => {
47
+ let tmpDir: string;
48
+
49
+ beforeEach(async () => {
50
+ tmpDir = await mkdtemp(join(tmpdir(), 'agentio-mcp-test-'));
51
+ });
52
+
53
+ afterEach(async () => {
54
+ await rm(tmpDir, { recursive: true, force: true });
55
+ });
56
+
57
+ test('creates .mcp.json with correct structure', async () => {
58
+ await writeMcpJson(tmpDir, [
59
+ { service: 'gmail', profile: 'work' },
60
+ { service: 'rss' },
61
+ ]);
62
+
63
+ const content = JSON.parse(
64
+ await readFile(join(tmpDir, '.mcp.json'), 'utf-8')
65
+ );
66
+
67
+ expect(content).toEqual({
68
+ mcpServers: {
69
+ agentio: {
70
+ command: 'agentio',
71
+ args: ['mcp', 'serve', 'gmail:work', 'rss'],
72
+ },
73
+ },
74
+ });
75
+ });
76
+
77
+ test('preserves other servers in existing .mcp.json', async () => {
78
+ // Write an existing .mcp.json with another server
79
+ await writeFile(
80
+ join(tmpDir, '.mcp.json'),
81
+ JSON.stringify({
82
+ mcpServers: {
83
+ 'other-server': {
84
+ command: 'other',
85
+ args: ['serve'],
86
+ },
87
+ },
88
+ })
89
+ );
90
+
91
+ await writeMcpJson(tmpDir, [{ service: 'gmail', profile: 'work' }]);
92
+
93
+ const content = JSON.parse(
94
+ await readFile(join(tmpDir, '.mcp.json'), 'utf-8')
95
+ );
96
+
97
+ // Both servers should be present
98
+ expect(content.mcpServers['other-server']).toEqual({
99
+ command: 'other',
100
+ args: ['serve'],
101
+ });
102
+ expect(content.mcpServers['agentio']).toEqual({
103
+ command: 'agentio',
104
+ args: ['mcp', 'serve', 'gmail:work'],
105
+ });
106
+ });
107
+
108
+ test('updates existing agentio entry in .mcp.json', async () => {
109
+ // First install
110
+ await writeMcpJson(tmpDir, [{ service: 'gmail', profile: 'work' }]);
111
+
112
+ // Second install with different services
113
+ await writeMcpJson(tmpDir, [
114
+ { service: 'slack', profile: 'team' },
115
+ { service: 'rss' },
116
+ ]);
117
+
118
+ const content = JSON.parse(
119
+ await readFile(join(tmpDir, '.mcp.json'), 'utf-8')
120
+ );
121
+
122
+ // Should be updated, not duplicated
123
+ expect(content.mcpServers['agentio']).toEqual({
124
+ command: 'agentio',
125
+ args: ['mcp', 'serve', 'slack:team', 'rss'],
126
+ });
127
+ });
128
+
129
+ test('creates .mcp.json when it does not exist', async () => {
130
+ expect(existsSync(join(tmpDir, '.mcp.json'))).toBe(false);
131
+
132
+ await writeMcpJson(tmpDir, [{ service: 'rss' }]);
133
+
134
+ expect(existsSync(join(tmpDir, '.mcp.json'))).toBe(true);
135
+ });
136
+ });
@@ -0,0 +1,39 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { parseServiceProfiles } from '../server';
3
+
4
+ describe('parseServiceProfiles', () => {
5
+ test('parses service:profile pairs', () => {
6
+ const pairs = parseServiceProfiles(['gmail:work', 'slack:team']);
7
+ expect(pairs).toEqual([
8
+ { service: 'gmail', profile: 'work' },
9
+ { service: 'slack', profile: 'team' },
10
+ ]);
11
+ });
12
+
13
+ test('parses service without profile', () => {
14
+ const pairs = parseServiceProfiles(['rss']);
15
+ expect(pairs).toEqual([{ service: 'rss' }]);
16
+ });
17
+
18
+ test('handles mixed services with and without profiles', () => {
19
+ const pairs = parseServiceProfiles(['gmail:work', 'rss', 'jira:myteam']);
20
+ expect(pairs).toEqual([
21
+ { service: 'gmail', profile: 'work' },
22
+ { service: 'rss' },
23
+ { service: 'jira', profile: 'myteam' },
24
+ ]);
25
+ });
26
+
27
+ test('handles empty array', () => {
28
+ const pairs = parseServiceProfiles([]);
29
+ expect(pairs).toEqual([]);
30
+ });
31
+
32
+ test('handles profile with colons in name', () => {
33
+ // Edge case: profile name itself contains a colon
34
+ const pairs = parseServiceProfiles(['gmail:work:extra']);
35
+ expect(pairs).toEqual([
36
+ { service: 'gmail', profile: 'work:extra' },
37
+ ]);
38
+ });
39
+ });
@@ -0,0 +1,136 @@
1
+ import { describe, test, expect } from 'bun:test';
2
+ import { Command } from 'commander';
3
+ import { collectMcpTools } from '../tools';
4
+ import { registerRssCommands } from '../../commands/rss';
5
+ import { registerGmailCommands } from '../../commands/gmail';
6
+ import { registerSlackCommands } from '../../commands/slack';
7
+ import { registerWhatsAppCommands } from '../../commands/whatsapp';
8
+
9
+ function buildProgram(...registers: ((p: Command) => void)[]): Command {
10
+ const program = new Command();
11
+ program.name('agentio');
12
+ for (const reg of registers) {
13
+ reg(program);
14
+ }
15
+ return program;
16
+ }
17
+
18
+ describe('collectMcpTools', () => {
19
+ test('collects leaf commands for a simple service (rss)', () => {
20
+ const program = buildProgram(registerRssCommands);
21
+ const tools = collectMcpTools(program, 'rss');
22
+
23
+ const names = tools.map((t) => t.name);
24
+ expect(names).toContain('rss_articles');
25
+ expect(names).toContain('rss_get');
26
+ expect(names).toContain('rss_info');
27
+ expect(names).toHaveLength(3);
28
+ });
29
+
30
+ test('excludes profile subcommands', () => {
31
+ const program = buildProgram(registerGmailCommands);
32
+ const tools = collectMcpTools(program, 'gmail');
33
+
34
+ const names = tools.map((t) => t.name);
35
+ // Should not contain profile-related tools
36
+ expect(names.some((n) => n.includes('profile'))).toBe(false);
37
+ });
38
+
39
+ test('excludes --profile from options', () => {
40
+ const program = buildProgram(registerGmailCommands);
41
+ const tools = collectMcpTools(program, 'gmail');
42
+
43
+ for (const tool of tools) {
44
+ const optLongs = tool.options.map((o) => o.long);
45
+ expect(optLongs).not.toContain('profile');
46
+ expect(Object.keys(tool.inputSchema.properties)).not.toContain('profile');
47
+ }
48
+ });
49
+
50
+ test('returns empty array for unknown service', () => {
51
+ const program = buildProgram(registerRssCommands);
52
+ const tools = collectMcpTools(program, 'nonexistent');
53
+ expect(tools).toHaveLength(0);
54
+ });
55
+
56
+ test('marks required positional arguments as required in schema', () => {
57
+ const program = buildProgram(registerRssCommands);
58
+ const tools = collectMcpTools(program, 'rss');
59
+
60
+ const getTool = tools.find((t) => t.name === 'rss_get')!;
61
+ expect(getTool.inputSchema.required).toContain('url');
62
+ expect(getTool.inputSchema.required).toContain('article_id');
63
+ });
64
+
65
+ test('does not mark options as required in schema', () => {
66
+ const program = buildProgram(registerRssCommands);
67
+ const tools = collectMcpTools(program, 'rss');
68
+
69
+ const articlesTool = tools.find((t) => t.name === 'rss_articles')!;
70
+ // --limit and --since should NOT be in required
71
+ expect(articlesTool.inputSchema.required).not.toContain('limit');
72
+ expect(articlesTool.inputSchema.required).not.toContain('since');
73
+ });
74
+
75
+ test('identifies boolean options correctly', () => {
76
+ const program = buildProgram(registerGmailCommands);
77
+ const tools = collectMcpTools(program, 'gmail');
78
+
79
+ const sendTool = tools.find((t) => t.name === 'gmail_send')!;
80
+ const htmlProp = sendTool.inputSchema.properties['html'];
81
+ expect(htmlProp).toBeDefined();
82
+ expect(htmlProp.type).toBe('boolean');
83
+ });
84
+
85
+ test('handles nested commands (whatsapp inbox/outbox/group)', () => {
86
+ const program = buildProgram(registerWhatsAppCommands);
87
+ const tools = collectMcpTools(program, 'whatsapp');
88
+
89
+ const names = tools.map((t) => t.name);
90
+ expect(names).toContain('whatsapp_inbox_pull');
91
+ expect(names).toContain('whatsapp_inbox_get');
92
+ expect(names).toContain('whatsapp_inbox_ack');
93
+ expect(names).toContain('whatsapp_inbox_reply');
94
+ expect(names).toContain('whatsapp_outbox_send');
95
+ expect(names).toContain('whatsapp_group_list');
96
+ });
97
+
98
+ test('tool has correct commandPath', () => {
99
+ const program = buildProgram(registerWhatsAppCommands);
100
+ const tools = collectMcpTools(program, 'whatsapp');
101
+
102
+ const pullTool = tools.find((t) => t.name === 'whatsapp_inbox_pull')!;
103
+ expect(pullTool.commandPath).toEqual(['whatsapp', 'inbox', 'pull']);
104
+ });
105
+
106
+ test('tool inputSchema has valid JSON Schema structure', () => {
107
+ const program = buildProgram(registerRssCommands);
108
+ const tools = collectMcpTools(program, 'rss');
109
+
110
+ for (const tool of tools) {
111
+ expect(tool.inputSchema.type).toBe('object');
112
+ expect(tool.inputSchema.properties).toBeDefined();
113
+ expect(typeof tool.inputSchema.properties).toBe('object');
114
+
115
+ // All property values should have a type
116
+ for (const [, prop] of Object.entries(tool.inputSchema.properties)) {
117
+ expect(prop.type).toBeDefined();
118
+ expect(['string', 'boolean']).toContain(prop.type);
119
+ }
120
+ }
121
+ });
122
+
123
+ test('collects tools from multiple services independently', () => {
124
+ const program = buildProgram(registerRssCommands, registerSlackCommands);
125
+
126
+ const rssTools = collectMcpTools(program, 'rss');
127
+ const slackTools = collectMcpTools(program, 'slack');
128
+
129
+ expect(rssTools.length).toBeGreaterThan(0);
130
+ expect(slackTools.length).toBeGreaterThan(0);
131
+
132
+ // No cross-contamination
133
+ expect(rssTools.every((t) => t.name.startsWith('rss_'))).toBe(true);
134
+ expect(slackTools.every((t) => t.name.startsWith('slack_'))).toBe(true);
135
+ });
136
+ });
@@ -0,0 +1,259 @@
1
+ import { Server } from '@modelcontextprotocol/sdk/server/index.js';
2
+ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
3
+ import {
4
+ ListToolsRequestSchema,
5
+ CallToolRequestSchema,
6
+ } from '@modelcontextprotocol/sdk/types.js';
7
+ import { Command } from 'commander';
8
+ import { collectMcpTools, type McpToolDefinition } from './tools.js';
9
+
10
+ // Import all service registrations
11
+ import { registerDiscourseCommands } from '../commands/discourse';
12
+ import { registerGCalCommands } from '../commands/gcal';
13
+ import { registerGChatCommands } from '../commands/gchat';
14
+ import { registerGDocsCommands } from '../commands/gdocs';
15
+ import { registerGDriveCommands } from '../commands/gdrive';
16
+ import { registerGitHubCommands } from '../commands/github';
17
+ import { registerGmailCommands } from '../commands/gmail';
18
+ import { registerGSheetsCommands } from '../commands/gsheets';
19
+ import { registerGTasksCommands } from '../commands/gtasks';
20
+ import { registerJiraCommands } from '../commands/jira';
21
+ import { registerRssCommands } from '../commands/rss';
22
+ import { registerSlackCommands } from '../commands/slack';
23
+ import { registerSqlCommands } from '../commands/sql';
24
+ import { registerTelegramCommands } from '../commands/telegram';
25
+ import { registerWhatsAppCommands } from '../commands/whatsapp';
26
+
27
+ const SERVICE_REGISTRATIONS: Record<string, (program: Command) => void> = {
28
+ discourse: registerDiscourseCommands,
29
+ gcal: registerGCalCommands,
30
+ gchat: registerGChatCommands,
31
+ gdocs: registerGDocsCommands,
32
+ gdrive: registerGDriveCommands,
33
+ github: registerGitHubCommands,
34
+ gmail: registerGmailCommands,
35
+ gsheets: registerGSheetsCommands,
36
+ gtasks: registerGTasksCommands,
37
+ jira: registerJiraCommands,
38
+ rss: registerRssCommands,
39
+ slack: registerSlackCommands,
40
+ sql: registerSqlCommands,
41
+ telegram: registerTelegramCommands,
42
+ whatsapp: registerWhatsAppCommands,
43
+ };
44
+
45
+ export interface ServiceProfilePair {
46
+ service: string;
47
+ profile?: string;
48
+ }
49
+
50
+ /**
51
+ * Parse "service:profile" pairs from argv.
52
+ */
53
+ export function parseServiceProfiles(args: string[]): ServiceProfilePair[] {
54
+ return args.map((arg) => {
55
+ const colonIndex = arg.indexOf(':');
56
+ if (colonIndex === -1) {
57
+ return { service: arg };
58
+ }
59
+ return {
60
+ service: arg.substring(0, colonIndex),
61
+ profile: arg.substring(colonIndex + 1),
62
+ };
63
+ });
64
+ }
65
+
66
+ /**
67
+ * Build a commander program with only the requested services registered.
68
+ */
69
+ function buildProgram(services: string[]): Command {
70
+ const program = new Command();
71
+ program.name('agentio').exitOverride();
72
+
73
+ for (const service of services) {
74
+ const register = SERVICE_REGISTRATIONS[service];
75
+ if (register) {
76
+ register(program);
77
+ }
78
+ }
79
+
80
+ return program;
81
+ }
82
+
83
+ /**
84
+ * Execute a command by capturing stdout output.
85
+ * Builds argv from the tool definition + input arguments, injects --profile.
86
+ */
87
+ async function executeCommand(
88
+ program: Command,
89
+ tool: McpToolDefinition,
90
+ input: Record<string, unknown>,
91
+ profile?: string
92
+ ): Promise<string> {
93
+ const argv = ['node', 'agentio', ...tool.commandPath];
94
+
95
+ // Add positional arguments in order
96
+ for (const argDef of tool.args) {
97
+ const value = input[argDef.name];
98
+ if (value !== undefined && value !== null) {
99
+ if (argDef.variadic && Array.isArray(value)) {
100
+ for (const v of value) {
101
+ argv.push(String(v));
102
+ }
103
+ } else {
104
+ argv.push(String(value));
105
+ }
106
+ }
107
+ }
108
+
109
+ // Add options
110
+ for (const optDef of tool.options) {
111
+ const paramName = optDef.long.replace(/-([a-z])/g, (_, c: string) =>
112
+ c.toUpperCase()
113
+ );
114
+ const value = input[paramName];
115
+ if (value !== undefined && value !== null) {
116
+ if (typeof value === 'boolean') {
117
+ if (value) {
118
+ argv.push(`--${optDef.long}`);
119
+ }
120
+ } else if (Array.isArray(value)) {
121
+ for (const v of value) {
122
+ argv.push(`--${optDef.long}`, String(v));
123
+ }
124
+ } else {
125
+ argv.push(`--${optDef.long}`, String(value));
126
+ }
127
+ }
128
+ }
129
+
130
+ // Inject --profile if provided
131
+ if (profile) {
132
+ argv.push('--profile', profile);
133
+ }
134
+
135
+ // Capture stdout
136
+ const chunks: string[] = [];
137
+ const originalLog = console.log;
138
+ const originalWrite = process.stdout.write;
139
+
140
+ console.log = (...args: unknown[]) => {
141
+ chunks.push(args.map(String).join(' '));
142
+ };
143
+
144
+ process.stdout.write = ((
145
+ chunk: string | Uint8Array,
146
+ ...rest: unknown[]
147
+ ): boolean => {
148
+ if (typeof chunk === 'string') {
149
+ chunks.push(chunk);
150
+ } else {
151
+ chunks.push(Buffer.from(chunk).toString());
152
+ }
153
+ return true;
154
+ }) as typeof process.stdout.write;
155
+
156
+ try {
157
+ await program.parseAsync(argv);
158
+ } finally {
159
+ console.log = originalLog;
160
+ process.stdout.write = originalWrite;
161
+ }
162
+
163
+ return chunks.join('\n');
164
+ }
165
+
166
+ /**
167
+ * Start the MCP stdio server with the given service:profile pairs.
168
+ */
169
+ export async function startMcpServer(
170
+ pairs: ServiceProfilePair[]
171
+ ): Promise<void> {
172
+ const services = [...new Set(pairs.map((p) => p.service))];
173
+
174
+ // Validate services
175
+ for (const service of services) {
176
+ if (!SERVICE_REGISTRATIONS[service]) {
177
+ console.error(`Unknown service: ${service}`);
178
+ process.exit(1);
179
+ }
180
+ }
181
+
182
+ // Build a profile lookup: service → profile
183
+ const profileMap = new Map<string, string | undefined>();
184
+ for (const pair of pairs) {
185
+ profileMap.set(pair.service, pair.profile);
186
+ }
187
+
188
+ // Build commander program and collect tools
189
+ const program = buildProgram(services);
190
+ const allTools: McpToolDefinition[] = [];
191
+ for (const service of services) {
192
+ allTools.push(...collectMcpTools(program, service));
193
+ }
194
+
195
+ if (allTools.length === 0) {
196
+ console.error('No tools found for the specified services');
197
+ process.exit(1);
198
+ }
199
+
200
+ // Create MCP server
201
+ const server = new Server(
202
+ { name: 'agentio', version: '1.0.0' },
203
+ { capabilities: { tools: {} } }
204
+ );
205
+
206
+ // Handle list tools
207
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
208
+ return {
209
+ tools: allTools.map((tool) => ({
210
+ name: tool.name,
211
+ description: tool.description,
212
+ inputSchema: tool.inputSchema,
213
+ })),
214
+ };
215
+ });
216
+
217
+ // Handle call tool
218
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
219
+ const { name, arguments: args } = request.params;
220
+ const tool = allTools.find((t) => t.name === name);
221
+
222
+ if (!tool) {
223
+ return {
224
+ content: [{ type: 'text' as const, text: `Unknown tool: ${name}` }],
225
+ isError: true,
226
+ };
227
+ }
228
+
229
+ // Determine the service from the tool name to look up the profile
230
+ const service = tool.commandPath[0];
231
+ const profile = profileMap.get(service);
232
+
233
+ // Build a fresh program for each call to avoid state leaks
234
+ const execProgram = buildProgram(services);
235
+
236
+ try {
237
+ const output = await executeCommand(
238
+ execProgram,
239
+ tool,
240
+ (args as Record<string, unknown>) || {},
241
+ profile
242
+ );
243
+ return {
244
+ content: [{ type: 'text' as const, text: output || '(no output)' }],
245
+ };
246
+ } catch (error: unknown) {
247
+ const message =
248
+ error instanceof Error ? error.message : String(error);
249
+ return {
250
+ content: [{ type: 'text' as const, text: `Error: ${message}` }],
251
+ isError: true,
252
+ };
253
+ }
254
+ });
255
+
256
+ // Start stdio transport
257
+ const transport = new StdioServerTransport();
258
+ await server.connect(transport);
259
+ }
@@ -0,0 +1,153 @@
1
+ import { Command } from 'commander';
2
+
3
+ export interface McpToolDefinition {
4
+ name: string;
5
+ description: string;
6
+ inputSchema: {
7
+ type: 'object';
8
+ properties: Record<string, { type: string; description?: string }>;
9
+ required?: string[];
10
+ };
11
+ /** The full commander path segments, e.g. ['gmail', 'search'] */
12
+ commandPath: string[];
13
+ /** Arguments in order, with metadata */
14
+ args: Array<{ name: string; required: boolean; variadic: boolean }>;
15
+ /** Options (long flag name → flag string for commander) */
16
+ options: Array<{ long: string; flags: string; required: boolean }>;
17
+ }
18
+
19
+ /**
20
+ * Walk the commander tree for a given service and produce MCP tool definitions.
21
+ * Excludes profile subcommands and strips --profile from options.
22
+ */
23
+ export function collectMcpTools(
24
+ program: Command,
25
+ service: string
26
+ ): McpToolDefinition[] {
27
+ const help = program.createHelp();
28
+ const serviceCmd = help
29
+ .visibleCommands(program)
30
+ .find((c) => c.name() === service);
31
+
32
+ if (!serviceCmd) {
33
+ return [];
34
+ }
35
+
36
+ const results: McpToolDefinition[] = [];
37
+ walkCommand(serviceCmd, [service], results);
38
+ return results;
39
+ }
40
+
41
+ function walkCommand(
42
+ cmd: Command,
43
+ path: string[],
44
+ results: McpToolDefinition[]
45
+ ): void {
46
+ const help = cmd.createHelp();
47
+ const subcommands = help
48
+ .visibleCommands(cmd)
49
+ .filter((c) => c.name() !== 'help');
50
+
51
+ // Skip profile subcommand tree entirely
52
+ const nonProfileSubs = subcommands.filter((c) => c.name() !== 'profile');
53
+
54
+ if (nonProfileSubs.length === 0) {
55
+ // Leaf command — create a tool definition
56
+ const tool = buildToolDefinition(cmd, path);
57
+ if (tool) {
58
+ results.push(tool);
59
+ }
60
+ return;
61
+ }
62
+
63
+ // If this command itself has arguments or meaningful options, also expose it
64
+ const args = help.visibleArguments(cmd);
65
+ const opts = help
66
+ .visibleOptions(cmd)
67
+ .filter(
68
+ (o) => !o.long?.includes('help') && o.long !== '--profile'
69
+ );
70
+ if (args.length > 0 || opts.length > 0) {
71
+ const tool = buildToolDefinition(cmd, path);
72
+ if (tool) {
73
+ results.push(tool);
74
+ }
75
+ }
76
+
77
+ // Recurse into non-profile subcommands
78
+ for (const sub of nonProfileSubs) {
79
+ walkCommand(sub, [...path, sub.name()], results);
80
+ }
81
+ }
82
+
83
+ function buildToolDefinition(
84
+ cmd: Command,
85
+ path: string[]
86
+ ): McpToolDefinition | null {
87
+ const help = cmd.createHelp();
88
+ const toolName = path.join('_');
89
+ const description = cmd.description() || toolName;
90
+
91
+ const properties: Record<string, { type: string; description?: string }> = {};
92
+ const required: string[] = [];
93
+
94
+ // Arguments
95
+ const argDefs: McpToolDefinition['args'] = [];
96
+ for (const arg of help.visibleArguments(cmd)) {
97
+ const paramName = arg.name().replace(/[^a-zA-Z0-9_]/g, '_');
98
+ properties[paramName] = {
99
+ type: 'string',
100
+ ...(arg.description ? { description: arg.description } : {}),
101
+ };
102
+ if (arg.required) {
103
+ required.push(paramName);
104
+ }
105
+ argDefs.push({
106
+ name: paramName,
107
+ required: arg.required,
108
+ variadic: arg.variadic,
109
+ });
110
+ }
111
+
112
+ // Options (skip --help, --profile)
113
+ const optDefs: McpToolDefinition['options'] = [];
114
+ for (const opt of help.visibleOptions(cmd)) {
115
+ if (opt.long?.includes('help')) continue;
116
+ if (opt.long === '--profile') continue;
117
+
118
+ const longName = opt.long?.replace(/^--/, '') || '';
119
+ if (!longName) continue;
120
+
121
+ // Convert kebab-case to camelCase for the property name
122
+ const paramName = longName.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
123
+
124
+ // Determine type: boolean flags vs value options
125
+ const isBoolean = opt.flags && !opt.flags.includes('<') && !opt.flags.includes('[');
126
+
127
+ properties[paramName] = {
128
+ type: isBoolean ? 'boolean' : 'string',
129
+ ...(opt.description ? { description: opt.description } : {}),
130
+ };
131
+
132
+ // Note: Commander's opt.required means "value required when flag is used",
133
+ // not "flag must be provided". Options are always optional in the MCP schema.
134
+ optDefs.push({
135
+ long: longName,
136
+ flags: opt.flags,
137
+ required: false,
138
+ });
139
+ }
140
+
141
+ return {
142
+ name: toolName,
143
+ description,
144
+ inputSchema: {
145
+ type: 'object',
146
+ properties,
147
+ ...(required.length > 0 ? { required } : {}),
148
+ },
149
+ commandPath: path,
150
+ args: argDefs,
151
+ options: optDefs,
152
+ };
153
+ }
@@ -15,8 +15,14 @@ import type {
15
15
  GChatSpace,
16
16
  } from '../../types/gchat';
17
17
 
18
+ interface ResolvedUser {
19
+ displayName: string;
20
+ email?: string;
21
+ }
22
+
18
23
  export class GChatClient implements ServiceClient {
19
24
  private credentials: GChatCredentials;
25
+ private userCache = new Map<string, ResolvedUser>();
20
26
 
21
27
  constructor(credentials: GChatCredentials) {
22
28
  this.credentials = credentials;
@@ -223,6 +229,11 @@ export class GChatClient implements ServiceClient {
223
229
  });
224
230
 
225
231
  const messages = response.data.messages || [];
232
+
233
+ // Resolve unique sender IDs to display names via People API
234
+ const senderIds = [...new Set(messages.map(m => m.sender?.name).filter(Boolean))] as string[];
235
+ await this.resolveUsers(senderIds, auth);
236
+
226
237
  return messages.map((msg: chat_v1.Schema$Message) => {
227
238
  const gchatMsg: GChatMessage = {
228
239
  name: msg.name || '',
@@ -231,12 +242,7 @@ export class GChatClient implements ServiceClient {
231
242
  updateTime: (msg as Record<string, unknown>).lastUpdateTime as string || new Date().toISOString(),
232
243
  };
233
244
  if (msg.text) gchatMsg.text = msg.text;
234
- if (msg.sender?.name) {
235
- gchatMsg.sender = {
236
- name: msg.sender.name,
237
- displayName: msg.sender.displayName || msg.sender.name,
238
- };
239
- }
245
+ gchatMsg.sender = this.enrichSender(msg);
240
246
  if (msg.thread?.name) {
241
247
  gchatMsg.thread = {
242
248
  name: msg.thread.name,
@@ -270,6 +276,12 @@ export class GChatClient implements ServiceClient {
270
276
  }
271
277
 
272
278
  const msg = response.data as chat_v1.Schema$Message;
279
+
280
+ // Resolve sender
281
+ if (msg.sender?.name) {
282
+ await this.resolveUsers([msg.sender.name], auth);
283
+ }
284
+
273
285
  const gchatMsg: GChatMessage = {
274
286
  name: msg.name || '',
275
287
  createTime: msg.createTime || new Date().toISOString(),
@@ -277,12 +289,7 @@ export class GChatClient implements ServiceClient {
277
289
  updateTime: (msg as Record<string, unknown>).lastUpdateTime as string || new Date().toISOString(),
278
290
  };
279
291
  if (msg.text) gchatMsg.text = msg.text;
280
- if (msg.sender?.name) {
281
- gchatMsg.sender = {
282
- name: msg.sender.name,
283
- displayName: msg.sender.displayName || msg.sender.name,
284
- };
285
- }
292
+ gchatMsg.sender = this.enrichSender(msg);
286
293
  if (msg.thread?.name) {
287
294
  gchatMsg.thread = {
288
295
  name: msg.thread.name,
@@ -306,15 +313,29 @@ export class GChatClient implements ServiceClient {
306
313
  const chat = gchat({ version: 'v1', auth: auth as any });
307
314
 
308
315
  try {
309
- const response = await chat.spaces.list({});
310
-
311
- const spaces = response.data.spaces || [];
312
- return spaces.map((space: chat_v1.Schema$Space) => ({
313
- name: space.name || '',
314
- displayName: space.displayName || 'Unnamed',
315
- type: (space.type as 'ROOM' | 'DM') || 'ROOM',
316
- description: space.spaceDetails?.description || undefined,
317
- }));
316
+ const allSpaces: GChatSpace[] = [];
317
+ let pageToken: string | undefined;
318
+
319
+ do {
320
+ const response = await chat.spaces.list({
321
+ pageSize: 100,
322
+ pageToken,
323
+ });
324
+
325
+ const spaces = response.data.spaces || [];
326
+ for (const space of spaces) {
327
+ allSpaces.push({
328
+ name: space.name || '',
329
+ displayName: space.displayName || 'Unnamed',
330
+ type: (space.type as 'ROOM' | 'DM') || 'ROOM',
331
+ description: space.spaceDetails?.description || undefined,
332
+ });
333
+ }
334
+
335
+ pageToken = response.data.nextPageToken || undefined;
336
+ } while (pageToken);
337
+
338
+ return allSpaces;
318
339
  } catch (err) {
319
340
  const code = this.getErrorCode(err);
320
341
  const message = this.getErrorMessage(err);
@@ -326,6 +347,45 @@ export class GChatClient implements ServiceClient {
326
347
  }
327
348
  }
328
349
 
350
+ private async resolveUsers(userIds: string[], auth: OAuth2Client): Promise<void> {
351
+ const unknown = userIds.filter(id => !this.userCache.has(id));
352
+ if (unknown.length === 0) return;
353
+
354
+ const token = await auth.getAccessToken();
355
+ if (!token.token) return;
356
+
357
+ // Resolve users in parallel via People API
358
+ await Promise.all(unknown.map(async (userId) => {
359
+ try {
360
+ // userId is like "users/123456", extract the numeric part
361
+ const personId = userId.replace('users/', '');
362
+ const res = await fetch(
363
+ `https://people.googleapis.com/v1/people/${personId}?personFields=names,emailAddresses`,
364
+ { headers: { Authorization: `Bearer ${token.token}` } }
365
+ );
366
+ if (!res.ok) return;
367
+ const data = await res.json() as Record<string, any>;
368
+ const name = data.names?.[0]?.displayName;
369
+ const email = data.emailAddresses?.[0]?.value;
370
+ if (name) {
371
+ this.userCache.set(userId, { displayName: name, email });
372
+ }
373
+ } catch {
374
+ // Silently skip unresolvable users
375
+ }
376
+ }));
377
+ }
378
+
379
+ private enrichSender(msg: chat_v1.Schema$Message): GChatMessage['sender'] {
380
+ if (!msg.sender?.name) return undefined;
381
+ const cached = this.userCache.get(msg.sender.name);
382
+ return {
383
+ name: msg.sender.name,
384
+ displayName: cached?.displayName || msg.sender.displayName || msg.sender.name,
385
+ email: cached?.email,
386
+ };
387
+ }
388
+
329
389
  private getErrorCode(err: unknown): ErrorCode {
330
390
  if (err && typeof err === 'object') {
331
391
  const error = err as Record<string, unknown>;
@@ -1,6 +1,7 @@
1
1
  export interface GChatSender {
2
2
  name: string;
3
3
  displayName: string;
4
+ email?: string;
4
5
  avatarUrl?: string;
5
6
  }
6
7
 
@@ -132,7 +132,10 @@ export function printGChatMessageList(messages: GChatMessage[]): void {
132
132
  const msg = messages[i];
133
133
  console.log(`[${i + 1}] ${msg.name}`);
134
134
  if (msg.sender) {
135
- console.log(` From: ${msg.sender.displayName || 'Unknown'}`);
135
+ const from = msg.sender.email
136
+ ? `${msg.sender.displayName} <${msg.sender.email}>`
137
+ : msg.sender.displayName || 'Unknown';
138
+ console.log(` From: ${from}`);
136
139
  }
137
140
  if (msg.text) {
138
141
  const snippet = msg.text.length > 100 ? msg.text.substring(0, 100) + '...' : msg.text;
@@ -146,7 +149,10 @@ export function printGChatMessageList(messages: GChatMessage[]): void {
146
149
  export function printGChatMessage(msg: GChatMessage): void {
147
150
  console.log(`ID: ${msg.name}`);
148
151
  if (msg.sender) {
149
- console.log(`From: ${msg.sender.displayName || 'Unknown'}`);
152
+ const from = msg.sender.email
153
+ ? `${msg.sender.displayName} <${msg.sender.email}>`
154
+ : msg.sender.displayName || 'Unknown';
155
+ console.log(`From: ${from}`);
150
156
  }
151
157
  console.log(`Date: ${msg.createTime}`);
152
158
  if (msg.thread) {