@evantahler/mcpx 0.17.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -8,6 +8,26 @@ trigger: when the user wants to interact with external services, APIs, or MCP to
8
8
 
9
9
  You have access to external tools via `mcpx`. Use this workflow:
10
10
 
11
+ ## 0. Self-authorize (if needed)
12
+
13
+ By default, Claude Code prompts the user for every `mcpx exec` call. If the user has approved `Bash(mcpx allow:*)` (you cannot do this yourself — the user must approve it when prompted), you can grant yourself granular permissions before executing:
14
+
15
+ ```bash
16
+ mcpx allow <server> <tool>
17
+ ```
18
+
19
+ This writes a specific pattern to `.claude/settings.local.json`. After that, `mcpx exec <server> <tool>` runs without prompting.
20
+
21
+ For broader access:
22
+
23
+ ```bash
24
+ mcpx allow <server> # all tools on a server
25
+ mcpx allow --all-read # search, info, list, servers, ping, etc.
26
+ mcpx allow --all # all mcpx exec calls
27
+ ```
28
+
29
+ **Important:** If the user has not approved `Bash(mcpx allow:*)`, skip this step — Claude Code will prompt the user directly for each `mcpx exec` call instead.
30
+
11
31
  ## 1. Search for tools
12
32
 
13
33
  ```bash
@@ -152,6 +172,15 @@ mcpx deauth <server> # remove stored auth
152
172
  | `mcpx task get <server> <taskId>` | Get task status |
153
173
  | `mcpx task result <server> <taskId>` | Retrieve completed task result |
154
174
  | `mcpx task cancel <server> <taskId>` | Cancel a running task |
175
+ | `mcpx allow <server>` | Allow agent to exec all tools on a server |
176
+ | `mcpx allow <server> <tools...>` | Allow specific tools only |
177
+ | `mcpx allow --all` | Allow all mcpx exec calls |
178
+ | `mcpx allow --all-read` | Allow read-only commands |
179
+ | `mcpx allow --list` | Show current mcpx permissions |
180
+ | `mcpx allow --cursor <server>` | Target Cursor instead of Claude |
181
+ | `mcpx deny <server>` | Remove server permissions |
182
+ | `mcpx deny --all` | Remove all mcpx permissions |
183
+ | `mcpx deny --cursor <server>` | Target Cursor instead of Claude |
155
184
  | `mcpx check-update` | Check for a newer version of mcpx |
156
185
  | `mcpx upgrade` | Upgrade mcpx to the latest version|
157
186
 
@@ -202,6 +231,22 @@ mcpx deauth <server> # remove stored auth
202
231
  | `--project` | Install to project location (default) |
203
232
  | `-f, --force` | Overwrite if file already exists |
204
233
 
234
+ ## Programmatic Usage (TypeScript SDK)
235
+
236
+ For agents that don't have shell access (remote, persistent, or isolated agents), mcpx can be used as a TypeScript library:
237
+
238
+ ```typescript
239
+ import { McpxClient } from "@evantahler/mcpx";
240
+
241
+ const client = new McpxClient();
242
+ const results = await client.search("send a message");
243
+ const tool = await client.info("arcade", "Slack_SendMessage");
244
+ const result = await client.exec("arcade", "Slack_SendMessage", { channel: "#general", message: "hello" });
245
+ await client.close();
246
+ ```
247
+
248
+ The SDK follows the same search → inspect → exec workflow. Server management (add, remove, auth) is still done via the CLI.
249
+
205
250
  ## Environment variables
206
251
 
207
252
  | Variable | Purpose | Default |
@@ -101,6 +101,22 @@ mcpx task list my-server
101
101
 
102
102
  Some servers request user input mid-operation. mcpx handles this automatically in interactive mode. Use `-N` / `--no-interactive` to decline all elicitation (for scripts/CI), or `--json` to handle elicitation programmatically via stdin/stdout.
103
103
 
104
+ ## 6. Self-authorize (if needed)
105
+
106
+ Cursor prompts you for every `mcpx exec` call. You can grant yourself granular permissions:
107
+
108
+ ```bash
109
+ mcpx allow <server> --cursor # all tools on a server
110
+ mcpx allow <server> <tool> --cursor # specific tool
111
+ mcpx allow --all-read --cursor # search, info, list, etc.
112
+ mcpx allow --all --cursor # all mcpx exec calls
113
+ mcpx allow --list --cursor # show current permissions
114
+ mcpx deny <server> --cursor # revoke server permissions
115
+ mcpx deny --all --cursor # revoke all permissions
116
+ ```
117
+
118
+ This writes `Shell(mcpx exec:server:*)` patterns to `.cursor/cli.json`.
119
+
104
120
  ## Authentication
105
121
 
106
122
  ```bash
@@ -132,7 +148,7 @@ mcpx deauth <server> # remove stored auth
132
148
  | `mcpx index -i` | Show index status |
133
149
  | `mcpx auth <server>` | Authenticate with OAuth |
134
150
  | `mcpx auth <server> -s` | Check token status and TTL |
135
- | `mcpx auth <server> -r` | Force token refresh |
151
+ | `mcpx auth <server> -r` | Force token refresh |
136
152
  | `mcpx auth <server> --no-index` | Authenticate without rebuilding index |
137
153
  | `mcpx deauth <server>` | Remove stored authentication |
138
154
  | `mcpx ping` | Check connectivity to all servers |
@@ -152,8 +168,15 @@ mcpx deauth <server> # remove stored auth
152
168
  | `mcpx task get <server> <taskId>` | Get task status |
153
169
  | `mcpx task result <server> <taskId>` | Retrieve completed task result |
154
170
  | `mcpx task cancel <server> <taskId>` | Cancel a running task |
155
- | `mcpx check-update` | Check for a newer version of mcpx |
156
- | `mcpx upgrade` | Upgrade mcpx to the latest version|
171
+ | `mcpx allow <server> --cursor` | Allow exec all tools on a server |
172
+ | `mcpx allow <server> <tools...> --cursor` | Allow specific tools only |
173
+ | `mcpx allow --all --cursor` | Allow all mcpx exec calls |
174
+ | `mcpx allow --all-read --cursor` | Allow read-only commands |
175
+ | `mcpx allow --list --cursor` | Show current permissions |
176
+ | `mcpx deny <server> --cursor` | Remove server permissions |
177
+ | `mcpx deny --all --cursor` | Remove all mcpx permissions |
178
+ | `mcpx check-update` | Check for a newer version of mcpx |
179
+ | `mcpx upgrade` | Upgrade mcpx to the latest version|
157
180
 
158
181
  ## Global flags
159
182
 
@@ -202,6 +225,22 @@ mcpx deauth <server> # remove stored auth
202
225
  | `--project` | Install to project location (default) |
203
226
  | `-f, --force` | Overwrite if file already exists |
204
227
 
228
+ ## Programmatic Usage (TypeScript SDK)
229
+
230
+ For agents that don't have shell access (remote, persistent, or isolated agents), mcpx can be used as a TypeScript library:
231
+
232
+ ```typescript
233
+ import { McpxClient } from "@evantahler/mcpx";
234
+
235
+ const client = new McpxClient();
236
+ const results = await client.search("send a message");
237
+ const tool = await client.info("arcade", "Slack_SendMessage");
238
+ const result = await client.exec("arcade", "Slack_SendMessage", { channel: "#general", message: "hello" });
239
+ await client.close();
240
+ ```
241
+
242
+ The SDK follows the same search → inspect → exec workflow. Server management (add, remove, auth) is still done via the CLI.
243
+
205
244
  ## Environment variables
206
245
 
207
246
  | Variable | Purpose | Default |
package/README.md CHANGED
@@ -4,10 +4,11 @@ A command-line interface for MCP servers. **curl for MCP.**
4
4
 
5
5
  The internet is debating CLI vs MCP like they're competitors. [They're not.](https://arcade.dev/blog/curl-for-mcp)
6
6
 
7
- Two audiences:
7
+ Three audiences:
8
8
 
9
- 1. **AI/LLM agents** that prefer shelling out over maintaining persistent MCP connections — better for token management, progressive tool discovery, and sharing a single pool of MCP servers across multiple agents on one machine
10
- 2. **MCP developers** who need a fast way to discover, debug, and test their servers from the terminal
9
+ 1. **Coding agents** (Claude Code, Cursor) that prefer shelling out over maintaining persistent MCP connections — better for token management, progressive tool discovery, and sharing a single pool of MCP servers across multiple agents on one machine
10
+ 2. **Non-coding agents** that need programmatic access to MCP tools from TypeScript — remote, persistent, or isolated agents that don't have a shell
11
+ 3. **MCP developers** who need a fast way to discover, debug, and test their servers from the terminal
11
12
 
12
13
  ## Install
13
14
 
@@ -104,6 +105,14 @@ mcpx search -n 5 "manage pull requests"
104
105
  | `mcpx task get <server> <taskId>` | Get task status |
105
106
  | `mcpx task result <server> <taskId>` | Retrieve completed task result |
106
107
  | `mcpx task cancel <server> <taskId>` | Cancel a running task |
108
+ | `mcpx allow <server>` | Allow an agent to exec all tools on a server |
109
+ | `mcpx allow <server> <tools...>` | Allow specific tools only |
110
+ | `mcpx allow --all` | Allow all mcpx exec calls |
111
+ | `mcpx allow --all-read` | Allow read-only commands (search, info, list, etc.) |
112
+ | `mcpx allow --list` | Show current mcpx-related permissions |
113
+ | `mcpx allow --cursor <server>` | Allow for Cursor instead of Claude Code |
114
+ | `mcpx deny <server>` | Remove permissions for a server |
115
+ | `mcpx deny --all` | Remove all mcpx-related permissions |
107
116
  | `mcpx check-update` | Check for a newer version of mcpx |
108
117
  | `mcpx upgrade` | Upgrade mcpx to the latest version |
109
118
 
@@ -627,6 +636,127 @@ To execute tools:
627
636
  Always search before executing — don't assume tool names.
628
637
  ```
629
638
 
639
+ ### Programmatic Usage (TypeScript SDK)
640
+
641
+ For agents that don't have shell access — remote, persistent, or isolated agents running in TypeScript:
642
+
643
+ ```typescript
644
+ import { McpxClient } from "@evantahler/mcpx";
645
+
646
+ const client = new McpxClient();
647
+ // or: new McpxClient({ configDir: "/path/to/.mcpx" })
648
+ // or: new McpxClient({ servers: { mcpServers: { ... } } })
649
+
650
+ // 1. Search for tools
651
+ const results = await client.search("send a message");
652
+
653
+ // 2. Inspect the tool schema
654
+ const tool = await client.info("arcade", "Slack_SendMessage");
655
+
656
+ // 3. Execute the tool
657
+ const result = await client.exec("arcade", "Slack_SendMessage", {
658
+ channel: "#general",
659
+ message: "hello",
660
+ });
661
+
662
+ // Also available: listTools, listResources, readResource,
663
+ // listPrompts, getPrompt, listTasks, getTask, cancelTask,
664
+ // getServerInfo, getServerNames, validateToolInput
665
+
666
+ await client.close();
667
+ ```
668
+
669
+ The SDK uses the same config files as the CLI (`~/.mcpx/servers.json`, `auth.json`, `search.json`). Server management (add, remove, auth) is done via the CLI — the SDK is read-only.
670
+
671
+ You can also pass server config directly, bypassing file loading entirely:
672
+
673
+ ```typescript
674
+ const client = new McpxClient({
675
+ servers: {
676
+ mcpServers: {
677
+ local: { command: "node", args: ["server.js"] },
678
+ remote: { url: "https://mcp.example.com" },
679
+ },
680
+ },
681
+ });
682
+ ```
683
+
684
+ ## Permissions (Claude Code & Cursor)
685
+
686
+ AI agents like Claude Code and Cursor prompt users to approve each `mcpx exec` call. `mcpx allow` and `mcpx deny` manage fine-grained permission rules so agents can self-authorize specific tools without broad access.
687
+
688
+ **Key insight:** If the user allows the initial permission pattern once (safe — it only writes to local settings files), the agent can then grant itself access to specific tools as needed. This is an opt-in workflow — by default, agents cannot self-authorize and will prompt the user for each `mcpx exec` call.
689
+
690
+ ```bash
691
+ # Allow all tools on a server (Claude Code, default)
692
+ mcpx allow github
693
+
694
+ # Allow for Cursor instead
695
+ mcpx allow github --cursor
696
+
697
+ # Allow specific tools only
698
+ mcpx allow github search_repositories get_file
699
+
700
+ # Allow read-only commands (search, info, list, servers, ping, etc.)
701
+ mcpx allow --all-read
702
+
703
+ # Allow all mcpx exec calls
704
+ mcpx allow --all
705
+
706
+ # Show current permissions across all scopes
707
+ mcpx allow --list
708
+ mcpx allow --list --cursor
709
+
710
+ # Preview what would be written
711
+ mcpx allow github --dry-run
712
+
713
+ # Revoke a server's permissions
714
+ mcpx deny github
715
+
716
+ # Revoke all mcpx permissions
717
+ mcpx deny --all
718
+ ```
719
+
720
+ **Target flag** — by default, permissions target Claude Code. Use `--cursor` to target Cursor instead:
721
+
722
+ | Flag | Pattern prefix | Settings files |
723
+ | ----------- | -------------- | ----------------------------------------------- |
724
+ | _(default)_ | `Bash(…)` | `.claude/settings.local.json`, etc. |
725
+ | `--cursor` | `Shell(…)` | `.cursor/cli.json`, `~/.cursor/cli-config.json` |
726
+
727
+ **Scope flags** control where the permission is written:
728
+
729
+ | Flag | Claude Code file | Cursor file | Default |
730
+ | ----------- | ----------------------------- | --------------------------- | ------- |
731
+ | `--local` | `.claude/settings.local.json` | `.cursor/cli.json` | ✓ |
732
+ | `--project` | `.claude/settings.json` | `.cursor/cli.json` | |
733
+ | `--global` | `~/.claude/settings.json` | `~/.cursor/cli-config.json` | |
734
+
735
+ **`allow` options:**
736
+
737
+ | Flag | Purpose |
738
+ | ------------ | --------------------------------------------------- |
739
+ | `--all` | Allow all mcpx exec calls |
740
+ | `--all-read` | Allow read-only commands (search, info, list, etc.) |
741
+ | `--list` | Show current mcpx-related permissions |
742
+ | `--cursor` | Target Cursor settings instead of Claude Code |
743
+ | `--local` | Write to local settings (default) |
744
+ | `--project` | Write to project settings (shared) |
745
+ | `--global` | Write to global settings |
746
+ | `--dry-run` | Show patterns without writing |
747
+
748
+ **`deny` options:**
749
+
750
+ | Flag | Purpose |
751
+ | ------------ | --------------------------------------------- |
752
+ | `--all` | Remove all mcpx-related permissions |
753
+ | `--all-read` | Remove read-only command permissions |
754
+ | `--cursor` | Target Cursor settings instead of Claude Code |
755
+ | `--local` | Write to local settings (default) |
756
+ | `--project` | Write to project settings (shared) |
757
+ | `--global` | Write to global settings |
758
+ | `--dry-run` | Show what would be removed |
759
+
630
760
  ## Development
631
761
 
632
762
  ```bash
package/package.json CHANGED
@@ -1,8 +1,14 @@
1
1
  {
2
2
  "name": "@evantahler/mcpx",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "A command-line interface for MCP servers. curl for MCP.",
5
5
  "type": "module",
6
+ "exports": {
7
+ ".": "./src/sdk.ts",
8
+ "./cli": "./src/cli.ts"
9
+ },
10
+ "main": "./src/sdk.ts",
11
+ "types": "./src/sdk.ts",
6
12
  "bin": {
7
13
  "mcpx": "./src/cli.ts"
8
14
  },
package/src/cli.ts CHANGED
@@ -15,6 +15,8 @@ import { registerResourceCommand } from "./commands/resource.ts";
15
15
  import { registerPromptCommand } from "./commands/prompt.ts";
16
16
  import { registerServersCommand } from "./commands/servers.ts";
17
17
  import { registerTaskCommand } from "./commands/task.ts";
18
+ import { registerAllowCommand } from "./commands/allow.ts";
19
+ import { registerDenyCommand } from "./commands/deny.ts";
18
20
  import { registerCheckUpdateCommand } from "./commands/check-update.ts";
19
21
  import { registerUpgradeCommand } from "./commands/upgrade.ts";
20
22
  import { maybeCheckForUpdate } from "./update/background.ts";
@@ -53,6 +55,8 @@ registerResourceCommand(program);
53
55
  registerPromptCommand(program);
54
56
  registerServersCommand(program);
55
57
  registerTaskCommand(program);
58
+ registerAllowCommand(program);
59
+ registerDenyCommand(program);
56
60
  registerCheckUpdateCommand(program);
57
61
  registerUpgradeCommand(program);
58
62
 
@@ -0,0 +1,163 @@
1
+ import type { Command } from "commander";
2
+ import { bold, cyan, dim, green, yellow } from "ansis";
3
+ import {
4
+ type Client,
5
+ type Scope,
6
+ resolveSettingsPath,
7
+ readClientSettings,
8
+ writeClientSettings,
9
+ execPattern,
10
+ readOnlyPatterns,
11
+ allExecPattern,
12
+ allowCommandPattern,
13
+ denyCommandPattern,
14
+ addPatterns,
15
+ getMcpxPatterns,
16
+ } from "../lib/client-settings.ts";
17
+ import { formatOutput } from "../output/format-output.ts";
18
+ import type { FormatOptions } from "../output/formatter.ts";
19
+
20
+ export function registerAllowCommand(program: Command) {
21
+ program
22
+ .command("allow")
23
+ .description("add permission rules for mcpx commands (Claude Code or Cursor)")
24
+ .argument("[server]", "server name to allow")
25
+ .argument("[tools...]", "specific tool names to allow")
26
+ .option("--all", "allow all mcpx exec calls")
27
+ .option("--all-read", "allow read-only commands (search, info, list, servers, ping, etc.)")
28
+ .option("--list", "show current mcpx-related permissions")
29
+ .option("--cursor", "target Cursor settings instead of Claude Code")
30
+ .option("--local", "write to local settings (default)")
31
+ .option("--project", "write to project settings (shared)")
32
+ .option("--global", "write to global settings")
33
+ .option("--dry-run", "show patterns without writing")
34
+ .action(
35
+ async (
36
+ server: string | undefined,
37
+ tools: string[],
38
+ options: {
39
+ all?: boolean;
40
+ allRead?: boolean;
41
+ list?: boolean;
42
+ cursor?: boolean;
43
+ local?: boolean;
44
+ project?: boolean;
45
+ global?: boolean;
46
+ dryRun?: boolean;
47
+ },
48
+ ) => {
49
+ const formatOptions: FormatOptions = { json: program.opts().json };
50
+ const client: Client = options.cursor ? "cursor" : "claude";
51
+
52
+ // --list mode: show current permissions across all scopes
53
+ if (options.list) {
54
+ // Cursor maps local and project to the same file, so only show unique scopes
55
+ const scopes: Scope[] =
56
+ client === "cursor" ? ["local", "global"] : ["local", "project", "global"];
57
+ const results: { scope: Scope; path: string; patterns: string[] }[] = [];
58
+
59
+ for (const scope of scopes) {
60
+ const path = resolveSettingsPath(scope, client);
61
+ const settings = await readClientSettings(path);
62
+ const patterns = getMcpxPatterns(settings, client);
63
+ results.push({ scope, path, patterns });
64
+ }
65
+
66
+ console.log(
67
+ formatOutput(
68
+ results.map((r) => ({ scope: r.scope, path: r.path, patterns: r.patterns })),
69
+ () => {
70
+ const lines: string[] = [];
71
+ for (const r of results) {
72
+ lines.push(bold(`${r.scope}`) + dim(` (${r.path})`));
73
+ if (r.patterns.length === 0) {
74
+ lines.push(` ${dim("(none)")}`);
75
+ } else {
76
+ for (const p of r.patterns) {
77
+ lines.push(` ${green("✓")} ${p}`);
78
+ }
79
+ }
80
+ lines.push("");
81
+ }
82
+ return lines.join("\n").trimEnd();
83
+ },
84
+ formatOptions,
85
+ ),
86
+ );
87
+ return;
88
+ }
89
+
90
+ // Build the list of patterns to add
91
+ const patterns: string[] = [];
92
+
93
+ if (options.all) {
94
+ patterns.push(allExecPattern(client));
95
+ }
96
+
97
+ if (options.allRead) {
98
+ patterns.push(...readOnlyPatterns(client));
99
+ }
100
+
101
+ if (server && tools.length > 0) {
102
+ for (const tool of tools) {
103
+ patterns.push(execPattern(server, tool, client));
104
+ }
105
+ } else if (server) {
106
+ patterns.push(execPattern(server, undefined, client));
107
+ }
108
+
109
+ if (patterns.length === 0) {
110
+ console.error("error: specify a server, --all, or --all-read. See 'mcpx allow --help'.");
111
+ process.exit(1);
112
+ }
113
+
114
+ // Always include allow/deny command patterns so the agent can self-manage
115
+ patterns.push(allowCommandPattern(client));
116
+ patterns.push(denyCommandPattern(client));
117
+
118
+ const scope: Scope = options.global ? "global" : options.project ? "project" : "local";
119
+ const path = resolveSettingsPath(scope, client);
120
+
121
+ if (options.dryRun) {
122
+ console.log(
123
+ formatOutput(
124
+ { scope, path, patterns },
125
+ () => {
126
+ const lines: string[] = [];
127
+ lines.push(bold("Dry run") + dim(` — would write to ${path}:`));
128
+ for (const p of patterns) {
129
+ lines.push(` ${yellow("+")} ${p}`);
130
+ }
131
+ return lines.join("\n");
132
+ },
133
+ formatOptions,
134
+ ),
135
+ );
136
+ return;
137
+ }
138
+
139
+ const settings = await readClientSettings(path);
140
+ const { settings: updated, added } = addPatterns(settings, patterns);
141
+ await writeClientSettings(path, updated);
142
+
143
+ console.log(
144
+ formatOutput(
145
+ { scope, path, added, total: (updated.permissions?.allow ?? []).length },
146
+ () => {
147
+ const lines: string[] = [];
148
+ if (added.length === 0) {
149
+ lines.push(dim("All patterns already present — no changes."));
150
+ } else {
151
+ lines.push(bold(`Added ${added.length} permission(s)`) + dim(` → ${path}`));
152
+ for (const p of added) {
153
+ lines.push(` ${green("+")} ${p}`);
154
+ }
155
+ }
156
+ return lines.join("\n");
157
+ },
158
+ formatOptions,
159
+ ),
160
+ );
161
+ },
162
+ );
163
+ }
@@ -0,0 +1,134 @@
1
+ import type { Command } from "commander";
2
+ import { bold, dim, green, red, yellow } from "ansis";
3
+ import {
4
+ type Client,
5
+ type Scope,
6
+ resolveSettingsPath,
7
+ readClientSettings,
8
+ writeClientSettings,
9
+ execPattern,
10
+ readOnlyPatterns,
11
+ allExecPattern,
12
+ removePatterns,
13
+ removeAllMcpxPatterns,
14
+ getServerPatterns,
15
+ } from "../lib/client-settings.ts";
16
+ import { formatOutput } from "../output/format-output.ts";
17
+ import type { FormatOptions } from "../output/formatter.ts";
18
+
19
+ export function registerDenyCommand(program: Command) {
20
+ program
21
+ .command("deny")
22
+ .description("remove permission rules for mcpx commands (Claude Code or Cursor)")
23
+ .argument("[server]", "server name to deny")
24
+ .argument("[tools...]", "specific tool names to deny")
25
+ .option("--all", "remove all mcpx-related permissions")
26
+ .option("--all-read", "remove read-only command permissions")
27
+ .option("--cursor", "target Cursor settings instead of Claude Code")
28
+ .option("--local", "write to local settings (default)")
29
+ .option("--project", "write to project settings (shared)")
30
+ .option("--global", "write to global settings")
31
+ .option("--dry-run", "show what would be removed")
32
+ .action(
33
+ async (
34
+ server: string | undefined,
35
+ tools: string[],
36
+ options: {
37
+ all?: boolean;
38
+ allRead?: boolean;
39
+ cursor?: boolean;
40
+ local?: boolean;
41
+ project?: boolean;
42
+ global?: boolean;
43
+ dryRun?: boolean;
44
+ },
45
+ ) => {
46
+ const formatOptions: FormatOptions = { json: program.opts().json };
47
+ const client: Client = options.cursor ? "cursor" : "claude";
48
+ const scope: Scope = options.global ? "global" : options.project ? "project" : "local";
49
+ const path = resolveSettingsPath(scope, client);
50
+ const settings = await readClientSettings(path);
51
+
52
+ let result: { settings: typeof settings; removed: string[] };
53
+
54
+ if (options.all) {
55
+ // Remove all mcpx-related patterns
56
+ result = removeAllMcpxPatterns(settings, client);
57
+ } else {
58
+ // Build the list of patterns to remove
59
+ const patterns: string[] = [];
60
+
61
+ if (options.allRead) {
62
+ patterns.push(...readOnlyPatterns(client));
63
+ }
64
+
65
+ if (server && tools.length > 0) {
66
+ for (const tool of tools) {
67
+ patterns.push(execPattern(server, tool, client));
68
+ }
69
+ } else if (server) {
70
+ // Remove the server-level pattern AND all tool-specific patterns for this server
71
+ patterns.push(execPattern(server, undefined, client));
72
+ patterns.push(...getServerPatterns(settings, server, client));
73
+ }
74
+
75
+ if (patterns.length === 0) {
76
+ console.error("error: specify a server, --all, or --all-read. See 'mcpx deny --help'.");
77
+ process.exit(1);
78
+ }
79
+
80
+ result = removePatterns(settings, patterns);
81
+ }
82
+
83
+ if (options.dryRun) {
84
+ console.log(
85
+ formatOutput(
86
+ { scope, path, wouldRemove: result.removed },
87
+ () => {
88
+ const lines: string[] = [];
89
+ lines.push(bold("Dry run") + dim(` — would remove from ${path}:`));
90
+ if (result.removed.length === 0) {
91
+ lines.push(` ${dim("(no matching patterns found)")}`);
92
+ } else {
93
+ for (const p of result.removed) {
94
+ lines.push(` ${yellow("-")} ${p}`);
95
+ }
96
+ }
97
+ return lines.join("\n");
98
+ },
99
+ formatOptions,
100
+ ),
101
+ );
102
+ return;
103
+ }
104
+
105
+ await writeClientSettings(path, result.settings);
106
+
107
+ console.log(
108
+ formatOutput(
109
+ {
110
+ scope,
111
+ path,
112
+ removed: result.removed,
113
+ total: (result.settings.permissions?.allow ?? []).length,
114
+ },
115
+ () => {
116
+ const lines: string[] = [];
117
+ if (result.removed.length === 0) {
118
+ lines.push(dim("No matching patterns found — no changes."));
119
+ } else {
120
+ lines.push(
121
+ bold(`Removed ${result.removed.length} permission(s)`) + dim(` → ${path}`),
122
+ );
123
+ for (const p of result.removed) {
124
+ lines.push(` ${red("-")} ${p}`);
125
+ }
126
+ }
127
+ return lines.join("\n");
128
+ },
129
+ formatOptions,
130
+ ),
131
+ );
132
+ },
133
+ );
134
+ }
@@ -0,0 +1,210 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+ import { readFile, mkdir, writeFile } from "fs/promises";
4
+
5
+ export type Client = "claude" | "cursor";
6
+ export type Scope = "local" | "project" | "global";
7
+
8
+ export interface ClientSettings {
9
+ permissions?: {
10
+ allow?: string[];
11
+ deny?: string[];
12
+ };
13
+ [key: string]: unknown;
14
+ }
15
+
16
+ function prefix(client: Client): string {
17
+ return client === "claude" ? "Bash" : "Shell";
18
+ }
19
+
20
+ /** Resolve the settings file path for a given scope and client */
21
+ export function resolveSettingsPath(scope: Scope, client: Client = "claude"): string {
22
+ if (client === "cursor") {
23
+ switch (scope) {
24
+ case "local":
25
+ case "project":
26
+ return join(process.cwd(), ".cursor", "cli.json");
27
+ case "global":
28
+ return join(homedir(), ".cursor", "cli-config.json");
29
+ }
30
+ }
31
+
32
+ switch (scope) {
33
+ case "local":
34
+ return join(process.cwd(), ".claude", "settings.local.json");
35
+ case "project":
36
+ return join(process.cwd(), ".claude", "settings.json");
37
+ case "global":
38
+ return join(homedir(), ".claude", "settings.json");
39
+ }
40
+ }
41
+
42
+ /** Read client settings from a file, returning empty settings if the file doesn't exist */
43
+ export async function readClientSettings(path: string): Promise<ClientSettings> {
44
+ try {
45
+ const content = await readFile(path, "utf-8");
46
+ return JSON.parse(content) as ClientSettings;
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ /** Write client settings to a file, creating parent directories as needed */
53
+ export async function writeClientSettings(path: string, settings: ClientSettings): Promise<void> {
54
+ const dir = join(path, "..");
55
+ await mkdir(dir, { recursive: true });
56
+ await writeFile(path, JSON.stringify(settings, null, 2) + "\n", "utf-8");
57
+ }
58
+
59
+ /** Generate a permission pattern for mcpx exec with a specific server and optional tool */
60
+ export function execPattern(server: string, tool?: string, client: Client = "claude"): string {
61
+ const p = prefix(client);
62
+ if (tool) {
63
+ return `${p}(mcpx exec:${server}:${tool}:*)`;
64
+ }
65
+ return `${p}(mcpx exec:${server}:*)`;
66
+ }
67
+
68
+ /** Read-only mcpx commands that are safe to allow broadly */
69
+ const READ_ONLY_COMMANDS = [
70
+ "search",
71
+ "info",
72
+ "servers",
73
+ "ping",
74
+ "resource",
75
+ "prompt",
76
+ "task",
77
+ "index",
78
+ ];
79
+
80
+ /** Generate patterns for all read-only mcpx commands */
81
+ export function readOnlyPatterns(client: Client = "claude"): string[] {
82
+ const p = prefix(client);
83
+ return READ_ONLY_COMMANDS.map((cmd) => `${p}(mcpx ${cmd}:*)`);
84
+ }
85
+
86
+ /** Generate the broad allow-all pattern for mcpx exec */
87
+ export function allExecPattern(client: Client = "claude"): string {
88
+ return `${prefix(client)}(mcpx exec:*)`;
89
+ }
90
+
91
+ /** Generate the allow pattern for mcpx allow itself */
92
+ export function allowCommandPattern(client: Client = "claude"): string {
93
+ return `${prefix(client)}(mcpx allow:*)`;
94
+ }
95
+
96
+ /** Generate the allow pattern for mcpx deny itself */
97
+ export function denyCommandPattern(client: Client = "claude"): string {
98
+ return `${prefix(client)}(mcpx deny:*)`;
99
+ }
100
+
101
+ /** Check if a permission pattern is mcpx-related */
102
+ export function isMcpxPattern(pattern: string, client: Client = "claude"): boolean {
103
+ return pattern.startsWith(`${prefix(client)}(mcpx `);
104
+ }
105
+
106
+ /** Add patterns to settings, deduplicating. Returns the updated settings and list of newly added patterns. */
107
+ export function addPatterns(
108
+ settings: ClientSettings,
109
+ patterns: string[],
110
+ ): { settings: ClientSettings; added: string[] } {
111
+ const existing = new Set(settings.permissions?.allow ?? []);
112
+ const added: string[] = [];
113
+
114
+ for (const p of patterns) {
115
+ if (!existing.has(p)) {
116
+ existing.add(p);
117
+ added.push(p);
118
+ }
119
+ }
120
+
121
+ return {
122
+ settings: {
123
+ ...settings,
124
+ permissions: {
125
+ ...settings.permissions,
126
+ allow: [...existing],
127
+ },
128
+ },
129
+ added,
130
+ };
131
+ }
132
+
133
+ /** Remove specific patterns from settings. Returns the updated settings and list of removed patterns. */
134
+ export function removePatterns(
135
+ settings: ClientSettings,
136
+ patterns: string[],
137
+ ): { settings: ClientSettings; removed: string[] } {
138
+ const existing = settings.permissions?.allow ?? [];
139
+ const toRemove = new Set(patterns);
140
+ const removed: string[] = [];
141
+ const remaining: string[] = [];
142
+
143
+ for (const p of existing) {
144
+ if (toRemove.has(p)) {
145
+ removed.push(p);
146
+ } else {
147
+ remaining.push(p);
148
+ }
149
+ }
150
+
151
+ return {
152
+ settings: {
153
+ ...settings,
154
+ permissions: {
155
+ ...settings.permissions,
156
+ allow: remaining,
157
+ },
158
+ },
159
+ removed,
160
+ };
161
+ }
162
+
163
+ /** Remove all mcpx-related patterns from settings. Returns the updated settings and list of removed patterns. */
164
+ export function removeAllMcpxPatterns(
165
+ settings: ClientSettings,
166
+ client: Client = "claude",
167
+ ): {
168
+ settings: ClientSettings;
169
+ removed: string[];
170
+ } {
171
+ const existing = settings.permissions?.allow ?? [];
172
+ const removed: string[] = [];
173
+ const remaining: string[] = [];
174
+
175
+ for (const p of existing) {
176
+ if (isMcpxPattern(p, client)) {
177
+ removed.push(p);
178
+ } else {
179
+ remaining.push(p);
180
+ }
181
+ }
182
+
183
+ return {
184
+ settings: {
185
+ ...settings,
186
+ permissions: {
187
+ ...settings.permissions,
188
+ allow: remaining,
189
+ },
190
+ },
191
+ removed,
192
+ };
193
+ }
194
+
195
+ /** Extract all mcpx-related patterns from settings */
196
+ export function getMcpxPatterns(settings: ClientSettings, client: Client = "claude"): string[] {
197
+ return (settings.permissions?.allow ?? []).filter((p) => isMcpxPattern(p, client));
198
+ }
199
+
200
+ /** Get all mcpx-related patterns for a specific server */
201
+ export function getServerPatterns(
202
+ settings: ClientSettings,
203
+ server: string,
204
+ client: Client = "claude",
205
+ ): string[] {
206
+ const p = prefix(client);
207
+ return getMcpxPatterns(settings, client).filter(
208
+ (pat) => pat.startsWith(`${p}(mcpx exec:${server}:`) || pat === `${p}(mcpx exec:${server}:*)`,
209
+ );
210
+ }
package/src/sdk.ts ADDED
@@ -0,0 +1,310 @@
1
+ import type {
2
+ CallToolResult,
3
+ GetTaskResult,
4
+ ListTasksResult,
5
+ CancelTaskResult,
6
+ } from "@modelcontextprotocol/sdk/types.js";
7
+ import { ServerManager } from "./client/manager.ts";
8
+ import type {
9
+ ServerManagerOptions,
10
+ ToolWithServer,
11
+ ResourceWithServer,
12
+ PromptWithServer,
13
+ ServerInfo,
14
+ ServerError,
15
+ } from "./client/manager.ts";
16
+ import { loadConfig } from "./config/loader.ts";
17
+ import type {
18
+ Tool,
19
+ Resource,
20
+ Prompt,
21
+ ServerConfig,
22
+ StdioServerConfig,
23
+ HttpServerConfig,
24
+ ServersFile,
25
+ AuthFile,
26
+ AuthEntry,
27
+ SearchIndex,
28
+ Config,
29
+ } from "./config/schemas.ts";
30
+ import { search } from "./search/index.ts";
31
+ import type { SearchResult, SearchOptions } from "./search/index.ts";
32
+ import { validateToolInput } from "./validation/schema.ts";
33
+ import type { ValidationResult, ValidationError } from "./validation/schema.ts";
34
+
35
+ // Re-export types for SDK consumers
36
+ export type {
37
+ // MCP SDK types
38
+ CallToolResult,
39
+ GetTaskResult,
40
+ ListTasksResult,
41
+ CancelTaskResult,
42
+ // Config types
43
+ Tool,
44
+ Resource,
45
+ Prompt,
46
+ ServerConfig,
47
+ StdioServerConfig,
48
+ HttpServerConfig,
49
+ ServersFile,
50
+ AuthFile,
51
+ AuthEntry,
52
+ SearchIndex,
53
+ Config,
54
+ // Manager types
55
+ ToolWithServer,
56
+ ResourceWithServer,
57
+ PromptWithServer,
58
+ ServerInfo,
59
+ ServerError,
60
+ // Search types
61
+ SearchResult,
62
+ SearchOptions,
63
+ // Validation types
64
+ ValidationResult,
65
+ ValidationError,
66
+ };
67
+
68
+ export interface McpxClientOptions {
69
+ /** Path to config directory. Defaults to ~/.mcpx or MCP_CONFIG_PATH env var. */
70
+ configDir?: string;
71
+ /** Inline server config — bypasses file loading when provided. */
72
+ servers?: ServersFile;
73
+ /** Inline auth config — bypasses file loading when provided. */
74
+ auth?: AuthFile;
75
+ /** Inline search index — bypasses file loading when provided. */
76
+ searchIndex?: SearchIndex;
77
+ /** Max concurrent server connections. Default: 5 */
78
+ concurrency?: number;
79
+ /** Request timeout in ms. Default: 1_800_000 (30 min) */
80
+ timeout?: number;
81
+ /** Max retries per operation. Default: 3 */
82
+ maxRetries?: number;
83
+ /** Enable verbose/trace logging. Default: false */
84
+ verbose?: boolean;
85
+ }
86
+
87
+ export class McpxClient {
88
+ private options: McpxClientOptions;
89
+ private manager: ServerManager | undefined;
90
+ private searchIndex: SearchIndex | undefined;
91
+ private connectPromise: Promise<void> | undefined;
92
+
93
+ constructor(options: McpxClientOptions = {}) {
94
+ this.options = options;
95
+ }
96
+
97
+ /** Ensure config is loaded and ServerManager is ready. Idempotent. */
98
+ private async ensureConnected(): Promise<ServerManager> {
99
+ if (this.manager) return this.manager;
100
+
101
+ if (!this.connectPromise) {
102
+ this.connectPromise = this.init();
103
+ }
104
+ await this.connectPromise;
105
+ return this.manager!;
106
+ }
107
+
108
+ private async init(): Promise<void> {
109
+ let servers: ServersFile;
110
+ let auth: AuthFile;
111
+ let configDir: string;
112
+ let searchIndex: SearchIndex;
113
+
114
+ if (this.options.servers) {
115
+ // Inline config — no file loading
116
+ servers = this.options.servers;
117
+ auth = this.options.auth ?? {};
118
+ configDir = this.options.configDir ?? "/tmp";
119
+ searchIndex = this.options.searchIndex ?? {
120
+ version: 1,
121
+ indexed_at: "",
122
+ embedding_model: "",
123
+ tools: [],
124
+ };
125
+ } else {
126
+ // Load from disk
127
+ const config = await loadConfig({ configFlag: this.options.configDir });
128
+ servers = config.servers;
129
+ auth = config.auth;
130
+ configDir = config.configDir;
131
+ searchIndex = config.searchIndex;
132
+ }
133
+
134
+ this.searchIndex = searchIndex;
135
+
136
+ const managerOpts: ServerManagerOptions = {
137
+ servers,
138
+ configDir,
139
+ auth,
140
+ concurrency: this.options.concurrency,
141
+ verbose: this.options.verbose,
142
+ timeout: this.options.timeout,
143
+ maxRetries: this.options.maxRetries,
144
+ logLevel: "emergency", // suppress server log messages from writing to stderr
145
+ noInteractive: true, // agents can't fill elicitation forms
146
+ };
147
+
148
+ this.manager = new ServerManager(managerOpts);
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Core workflow: search → info → exec
153
+ // ---------------------------------------------------------------------------
154
+
155
+ /** Search for tools by keyword and/or semantic similarity. Requires a pre-built index (run `mcpx index` via CLI). */
156
+ async search(query: string, options?: SearchOptions): Promise<SearchResult[]> {
157
+ await this.ensureConnected();
158
+ if (!this.searchIndex || this.searchIndex.tools.length === 0) {
159
+ throw new Error("No search index found. Build one with: mcpx index");
160
+ }
161
+ return search(query, this.searchIndex, options);
162
+ }
163
+
164
+ /** Get a tool's schema (name, description, inputSchema). */
165
+ async info(server: string, tool: string): Promise<Tool | undefined> {
166
+ const manager = await this.ensureConnected();
167
+ return manager.getToolSchema(server, tool);
168
+ }
169
+
170
+ /** Execute a tool and return the result. */
171
+ async exec(
172
+ server: string,
173
+ tool: string,
174
+ args?: Record<string, unknown>,
175
+ ): Promise<CallToolResult> {
176
+ const manager = await this.ensureConnected();
177
+ return manager.callTool(server, tool, args ?? {}) as Promise<CallToolResult>;
178
+ }
179
+
180
+ // ---------------------------------------------------------------------------
181
+ // Tools
182
+ // ---------------------------------------------------------------------------
183
+
184
+ /** List tools, optionally filtered to a single server. */
185
+ async listTools(server?: string): Promise<ToolWithServer[]> {
186
+ const manager = await this.ensureConnected();
187
+ if (server) {
188
+ const tools = await manager.listTools(server);
189
+ return tools.map((tool) => ({ server, tool }));
190
+ }
191
+ const { tools } = await manager.getAllTools();
192
+ return tools;
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // Validation
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /** Validate arguments against a tool's inputSchema. */
200
+ async validateToolInput(
201
+ server: string,
202
+ toolName: string,
203
+ args: Record<string, unknown>,
204
+ ): Promise<ValidationResult> {
205
+ const tool = await this.info(server, toolName);
206
+ if (!tool) {
207
+ return { valid: false, errors: [{ path: "(root)", message: `Tool not found: ${toolName}` }] };
208
+ }
209
+ return validateToolInput(server, tool, args);
210
+ }
211
+
212
+ // ---------------------------------------------------------------------------
213
+ // Resources
214
+ // ---------------------------------------------------------------------------
215
+
216
+ /** List resources, optionally filtered to a single server. */
217
+ async listResources(server?: string): Promise<ResourceWithServer[]> {
218
+ const manager = await this.ensureConnected();
219
+ if (server) {
220
+ const resources = await manager.listResources(server);
221
+ return resources.map((resource) => ({ server, resource }));
222
+ }
223
+ const { resources } = await manager.getAllResources();
224
+ return resources;
225
+ }
226
+
227
+ /** Read a specific resource by URI. */
228
+ async readResource(server: string, uri: string): Promise<unknown> {
229
+ const manager = await this.ensureConnected();
230
+ return manager.readResource(server, uri);
231
+ }
232
+
233
+ // ---------------------------------------------------------------------------
234
+ // Prompts
235
+ // ---------------------------------------------------------------------------
236
+
237
+ /** List prompts, optionally filtered to a single server. */
238
+ async listPrompts(server?: string): Promise<PromptWithServer[]> {
239
+ const manager = await this.ensureConnected();
240
+ if (server) {
241
+ const prompts = await manager.listPrompts(server);
242
+ return prompts.map((prompt) => ({ server, prompt }));
243
+ }
244
+ const { prompts } = await manager.getAllPrompts();
245
+ return prompts;
246
+ }
247
+
248
+ /** Get a specific prompt by name, optionally with arguments. */
249
+ async getPrompt(server: string, name: string, args?: Record<string, string>): Promise<unknown> {
250
+ const manager = await this.ensureConnected();
251
+ return manager.getPrompt(server, name, args);
252
+ }
253
+
254
+ // ---------------------------------------------------------------------------
255
+ // Tasks
256
+ // ---------------------------------------------------------------------------
257
+
258
+ /** List tasks on a server. */
259
+ async listTasks(server: string, cursor?: string): Promise<ListTasksResult> {
260
+ const manager = await this.ensureConnected();
261
+ return manager.listTasks(server, cursor);
262
+ }
263
+
264
+ /** Get the status of a task. */
265
+ async getTask(server: string, taskId: string): Promise<GetTaskResult> {
266
+ const manager = await this.ensureConnected();
267
+ return manager.getTask(server, taskId);
268
+ }
269
+
270
+ /** Retrieve the result of a completed task. */
271
+ async getTaskResult(server: string, taskId: string): Promise<CallToolResult> {
272
+ const manager = await this.ensureConnected();
273
+ return manager.getTaskResult(server, taskId);
274
+ }
275
+
276
+ /** Cancel a running task. */
277
+ async cancelTask(server: string, taskId: string): Promise<CancelTaskResult> {
278
+ const manager = await this.ensureConnected();
279
+ return manager.cancelTask(server, taskId);
280
+ }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Server info
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /** Get server info (version, capabilities, instructions). */
287
+ async getServerInfo(server: string): Promise<ServerInfo> {
288
+ const manager = await this.ensureConnected();
289
+ return manager.getServerInfo(server);
290
+ }
291
+
292
+ /** Get all configured server names. */
293
+ async getServerNames(): Promise<string[]> {
294
+ const manager = await this.ensureConnected();
295
+ return manager.getServerNames();
296
+ }
297
+
298
+ // ---------------------------------------------------------------------------
299
+ // Lifecycle
300
+ // ---------------------------------------------------------------------------
301
+
302
+ /** Disconnect all servers and clean up. */
303
+ async close(): Promise<void> {
304
+ if (this.manager) {
305
+ await this.manager.close();
306
+ this.manager = undefined;
307
+ this.connectPromise = undefined;
308
+ }
309
+ }
310
+ }