@plosson/agentio 0.5.13 → 0.5.15

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.5.15",
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",
@@ -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
+ }