@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 +4 -2
- package/src/commands/gchat.ts +5 -0
- package/src/commands/mcp.ts +147 -0
- package/src/index.ts +7 -0
- package/src/mcp/__tests__/install.test.ts +136 -0
- package/src/mcp/__tests__/server.test.ts +39 -0
- package/src/mcp/__tests__/tools.test.ts +136 -0
- package/src/mcp/server.ts +259 -0
- package/src/mcp/tools.ts +153 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@plosson/agentio",
|
|
3
|
-
"version": "0.5.
|
|
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",
|
package/src/commands/gchat.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/mcp/tools.ts
ADDED
|
@@ -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
|
+
}
|