mcpskills-cli 0.1.0__tar.gz
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.
- mcpskills_cli-0.1.0/.cursor/skills/dellstore/SKILL.md +104 -0
- mcpskills_cli-0.1.0/.cursor/skills/dellstore/scripts/call.py +63 -0
- mcpskills_cli-0.1.0/PKG-INFO +7 -0
- mcpskills_cli-0.1.0/README.md +85 -0
- mcpskills_cli-0.1.0/Untitled-1 +1 -0
- mcpskills_cli-0.1.0/docs/LOW_TOKEN_SKILLS.md +49 -0
- mcpskills_cli-0.1.0/pyproject.toml +19 -0
- mcpskills_cli-0.1.0/src/mcp_cli/__init__.py +3 -0
- mcpskills_cli-0.1.0/src/mcp_cli/cli.py +86 -0
- mcpskills_cli-0.1.0/src/mcp_cli/client.py +18 -0
- mcpskills_cli-0.1.0/src/mcp_cli/credentials.py +41 -0
- mcpskills_cli-0.1.0/src/mcp_cli/generator.py +170 -0
- mcpskills_cli-0.1.0/src/mcp_cli/templates/call_bash.sh.j2 +57 -0
- mcpskills_cli-0.1.0/src/mcp_cli/templates/call_go.go.j2 +113 -0
- mcpskills_cli-0.1.0/src/mcp_cli/templates/call_node.js.j2 +63 -0
- mcpskills_cli-0.1.0/src/mcp_cli/templates/call_python.py.j2 +63 -0
- mcpskills_cli-0.1.0/src/mcp_cli/templates/call_rust.rs.j2 +101 -0
- mcpskills_cli-0.1.0/src/mcp_cli/templates/skill.md.j2 +27 -0
- mcpskills_cli-0.1.0/src/mcp_cli/templates/skill_single.md.j2 +21 -0
- mcpskills_cli-0.1.0/test_token_compare.py +107 -0
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: dellstore
|
|
3
|
+
description: MCP tools for dellstore. Available tools: list_tables, get_table_schema, execute_query, test_connection. Use when the user needs to call these MCP tools.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# dellstore MCP Tools
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
## Tables
|
|
11
|
+
|
|
12
|
+
**categories** — category (int PK), categoryname (varchar 50)
|
|
13
|
+
|
|
14
|
+
**cust_hist** — customerid (int, FK→customers.customerid), orderid (int), prod_id (int)
|
|
15
|
+
|
|
16
|
+
**customers** — customerid (int PK), firstname, lastname, address1, address2 (nullable), city, state (nullable), zip (int, nullable), country, region (smallint), email (nullable), phone (nullable), creditcardtype (int), creditcard, creditcardexpiration, username (unique), password, age (smallint, nullable), income (int, nullable), gender (varchar 1, nullable) — all strings varchar 50 unless noted
|
|
17
|
+
|
|
18
|
+
**inventory** — prod_id (int PK), quan_in_stock (int), sales (int)
|
|
19
|
+
|
|
20
|
+
**orderlines** — orderlineid (int), orderid (int, FK→orders.orderid), prod_id (int), quantity (smallint), orderdate (date)
|
|
21
|
+
|
|
22
|
+
**orders** — orderid (int PK), orderdate (date), customerid (int, nullable, FK→customers.customerid), netamount (numeric), tax (numeric), totalamount (numeric)
|
|
23
|
+
|
|
24
|
+
**products** — prod_id (int PK), category (int), title (varchar 50), actor (varchar 50), price (numeric), special (smallint, nullable), common_prod_id (int)
|
|
25
|
+
|
|
26
|
+
**reorder** — prod_id (int), date_low (date), quan_low (int), date_reordered (date, nullable), quan_reordered (int, nullable), date_expected (date, nullable)
|
|
27
|
+
|
|
28
|
+
## Available Tools
|
|
29
|
+
|
|
30
|
+
### list_tables
|
|
31
|
+
|
|
32
|
+
List all tables in the database.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
db_name: Identifier for the database instance (default: "default")
|
|
36
|
+
|
|
37
|
+
**Execute:**
|
|
38
|
+
```python
|
|
39
|
+
python3 /Users/dhanababu/workspace/forge-mcp-to-skills/.cursor/skills/dellstore/scripts/call.py list_tables
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### get_table_schema
|
|
43
|
+
|
|
44
|
+
Get schema information for a specific table.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
table_name: Name of the table to inspect
|
|
48
|
+
db_name: Identifier for the database instance (default: "default")
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
JSON string with table schema information
|
|
52
|
+
|
|
53
|
+
**Parameters:**
|
|
54
|
+
- `table_name` (string, required): -
|
|
55
|
+
|
|
56
|
+
**Execute:**
|
|
57
|
+
```python
|
|
58
|
+
python3 /Users/dhanababu/workspace/forge-mcp-to-skills/.cursor/skills/dellstore/scripts/call.py get_table_schema '{"table_name": "<table_name>"}'
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
### execute_query
|
|
62
|
+
|
|
63
|
+
Execute a SQL query with optional parameters.
|
|
64
|
+
|
|
65
|
+
This tool allows you to execute any SQL query
|
|
66
|
+
(SELECT, INSERT, UPDATE, DELETE, etc.)
|
|
67
|
+
with support for parameterized queries to prevent SQL injection.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
query: SQL query to execute
|
|
71
|
+
params: Optional dictionary of parameters for parameterized queries
|
|
72
|
+
db_name: Identifier for the database instance (default: "default")
|
|
73
|
+
|
|
74
|
+
Returns:
|
|
75
|
+
For SELECT queries: List of dictionaries representing rows
|
|
76
|
+
For INSERT/UPDATE/DELETE: List with affected_rows count
|
|
77
|
+
|
|
78
|
+
**Parameters:**
|
|
79
|
+
- `query` (string, required): -
|
|
80
|
+
- `params` (any, optional): -
|
|
81
|
+
|
|
82
|
+
**Execute:**
|
|
83
|
+
```python
|
|
84
|
+
python3 /Users/dhanababu/workspace/forge-mcp-to-skills/.cursor/skills/dellstore/scripts/call.py execute_query '{"query": "<query>"}'
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### test_connection
|
|
88
|
+
|
|
89
|
+
Test the database connection and return connection status information.
|
|
90
|
+
|
|
91
|
+
This tool verifies that the database connection is active and returns
|
|
92
|
+
connection details including database type, version, and pool
|
|
93
|
+
configuration.
|
|
94
|
+
|
|
95
|
+
Args:
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Dictionary with connection status, database information, and pool
|
|
99
|
+
settings
|
|
100
|
+
|
|
101
|
+
**Execute:**
|
|
102
|
+
```python
|
|
103
|
+
python3 /Users/dhanababu/workspace/forge-mcp-to-skills/.cursor/skills/dellstore/scripts/call.py test_connection
|
|
104
|
+
```
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Call MCP tool on server 'dellstore'."""
|
|
3
|
+
import configparser
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
SERVER = "dellstore"
|
|
10
|
+
CREDS_FILE = os.path.expanduser("~/.mcps/credentials")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_credentials():
|
|
14
|
+
cfg = configparser.ConfigParser()
|
|
15
|
+
cfg.read(CREDS_FILE)
|
|
16
|
+
if SERVER not in cfg:
|
|
17
|
+
print(f"No section [{SERVER}] in {CREDS_FILE}", file=sys.stderr)
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
return cfg[SERVER]["url"], cfg[SERVER]["token"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def call_tool(tool_name: str, arguments: dict) -> dict:
|
|
23
|
+
url, token = load_credentials()
|
|
24
|
+
url = url.rstrip("/") + "/"
|
|
25
|
+
body = json.dumps({
|
|
26
|
+
"jsonrpc": "2.0",
|
|
27
|
+
"method": "tools/call",
|
|
28
|
+
"params": {"name": tool_name, "arguments": arguments},
|
|
29
|
+
"id": 1,
|
|
30
|
+
}).encode()
|
|
31
|
+
req = urllib.request.Request(
|
|
32
|
+
url, data=body, method="POST",
|
|
33
|
+
headers={
|
|
34
|
+
"Authorization": f"Bearer {token}",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"Accept": "application/json, text/event-stream",
|
|
37
|
+
"MCP-Protocol-Version": "2025-06-18",
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
41
|
+
text = resp.read().decode()
|
|
42
|
+
for line in text.strip().split("\n"):
|
|
43
|
+
if line.startswith("data:"):
|
|
44
|
+
line = line[5:].strip()
|
|
45
|
+
if not line or line == "[DONE]":
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
obj = json.loads(line)
|
|
49
|
+
if "result" in obj or "error" in obj:
|
|
50
|
+
return obj
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
pass
|
|
53
|
+
return json.loads(text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
if len(sys.argv) < 2:
|
|
58
|
+
print(f"Usage: {sys.argv[0]} <tool_name> [json_args]", file=sys.stderr)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
tool = sys.argv[1]
|
|
61
|
+
args = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
|
|
62
|
+
result = call_tool(tool, args)
|
|
63
|
+
print(json.dumps(result, indent=2))
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# mcpskills-cli
|
|
2
|
+
|
|
3
|
+
Generate Agent Skills from MCP server tools. Connects via Streamable HTTP, discovers tools, and outputs a skill with schema docs and a call script in the language of your choice.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install .
|
|
9
|
+
# or for development:
|
|
10
|
+
pip install -e .
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Usage
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
mcpskills-cli --url <MCP_SERVER_URL> --token <TOKEN> [--name <NAME>] [--output <DIR>] [--script <LANG>] [--multi-skills]
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| Flag | Default | Description |
|
|
20
|
+
|---|---|---|
|
|
21
|
+
| `--url` | (required) | MCP server endpoint |
|
|
22
|
+
| `--token` | (required) | Bearer token |
|
|
23
|
+
| `--name` | from URL | Server name (skill dir + credentials key) |
|
|
24
|
+
| `--output` | `~/.cursor/skills` | Skills output directory |
|
|
25
|
+
| `--script` | `bash` | Call script language: `bash`, `python`, `node`, `go`, `rust` |
|
|
26
|
+
| `--multi-skills` | `false` | Generate a separate skill for each tool |
|
|
27
|
+
|
|
28
|
+
### Examples
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
# Generate single skill with all tools (default)
|
|
32
|
+
mcpskills-cli --url http://localhost:8027/mcp/abc123 --token mytoken --name my-db
|
|
33
|
+
|
|
34
|
+
# Generate separate skill for each tool
|
|
35
|
+
mcpskills-cli --url http://localhost:8027/mcp/abc123 --token mytoken --name my-db --multi-skills
|
|
36
|
+
|
|
37
|
+
# Generate with Python call script
|
|
38
|
+
mcpskills-cli --url http://localhost:8027/mcp/abc123 --token mytoken --name my-db --script python
|
|
39
|
+
|
|
40
|
+
# Generate with Node.js call script
|
|
41
|
+
mcpskills-cli --url http://localhost:8027/mcp/abc123 --token mytoken --name my-db --script node
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Credentials
|
|
45
|
+
|
|
46
|
+
Stored in `~/.mcps/credentials` (INI format, `chmod 600`). One section per server, updated automatically.
|
|
47
|
+
|
|
48
|
+
```ini
|
|
49
|
+
[my-db]
|
|
50
|
+
url = http://localhost:8027/mcp/abc123/
|
|
51
|
+
token = mytoken
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
Rotate tokens by editing the file directly; no need to regenerate skills.
|
|
55
|
+
|
|
56
|
+
## Generated Output
|
|
57
|
+
|
|
58
|
+
### Default Mode (Single Skill)
|
|
59
|
+
|
|
60
|
+
```
|
|
61
|
+
~/.cursor/skills/<server-name>/
|
|
62
|
+
SKILL.md # Documents all tools with parameters
|
|
63
|
+
scripts/
|
|
64
|
+
call.<ext> # Calls any tool: ./call.<ext> <tool_name> '{"key":"val"}'
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Multi-Skills Mode (--multi-skills)
|
|
68
|
+
|
|
69
|
+
```
|
|
70
|
+
~/.cursor/skills/<server-name>-<tool-name-1>/
|
|
71
|
+
SKILL.md # Documents single tool
|
|
72
|
+
scripts/
|
|
73
|
+
call.<ext> # Calls tool: ./call.<ext> <tool_name> '{"key":"val"}'
|
|
74
|
+
|
|
75
|
+
~/.cursor/skills/<server-name>-<tool-name-2>/
|
|
76
|
+
SKILL.md
|
|
77
|
+
scripts/
|
|
78
|
+
call.<ext>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Requirements
|
|
82
|
+
|
|
83
|
+
- Python >= 3.10
|
|
84
|
+
- `fastmcp` >= 2.3
|
|
85
|
+
- `jinja2` >= 3.1
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mcp-cli --url http://localhost:8032/mcp/8bb059d5-0115-4c08-b994-25c26695b027 --token mcp_token_Na7kQzvA2RxhzvAjT9P3Q0rPiaxlIHUnXiPHCoeMrhE --name neo4j-movie --script python --output .cursor/skills
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Auto-Generated Skills for Low Token Consumption
|
|
2
|
+
|
|
3
|
+
For questions like "show me top 10 products", token cost comes from:
|
|
4
|
+
|
|
5
|
+
1. **Skill doc reads** – agent loads multiple SKILL.md files
|
|
6
|
+
2. **Exploratory tool calls** – list_tables → get_table_schema → execute_query (3 round-trips)
|
|
7
|
+
|
|
8
|
+
## Recommendations
|
|
9
|
+
|
|
10
|
+
### 1. Generate a single skill (do not use `--multi-skills`)
|
|
11
|
+
|
|
12
|
+
- **One** SKILL.md that documents all MCP tools (list_tables, get_table_schema, execute_query, test_connection).
|
|
13
|
+
- Agent loads 1 skill instead of N → fewer tokens.
|
|
14
|
+
- Run: `mcpskills-cli --url <URL> --token <TOKEN> --name dellstore` (omit `--multi-skills`).
|
|
15
|
+
|
|
16
|
+
### 2. Add usage guidance in the single skill
|
|
17
|
+
|
|
18
|
+
Include a short "When to use" so the agent skips discovery when not needed:
|
|
19
|
+
|
|
20
|
+
- **Known table / clear intent** (e.g. "top 10 products") → use **execute_query** only with a `SELECT ... LIMIT` query.
|
|
21
|
+
- **Unknown database** → use list_tables, then get_table_schema, then execute_query.
|
|
22
|
+
|
|
23
|
+
This reduces 3 tool calls to 1 for the common case.
|
|
24
|
+
|
|
25
|
+
### 3. Optional: embed schema at generation time
|
|
26
|
+
|
|
27
|
+
If the generator can call the MCP at build time (list_tables + get_table_schema) and embed a "Known tables" section into SKILL.md, the agent gets table names and columns in one read and can go straight to execute_query. Requires a new option (e.g. `--include-schema`) and one-off MCP calls during `mcpskills-cli` run.
|
|
28
|
+
|
|
29
|
+
### 4. MCP server: high-level tools (lowest tokens)
|
|
30
|
+
|
|
31
|
+
Expose tools such as:
|
|
32
|
+
|
|
33
|
+
- `get_products(limit=10, offset=0, category=...)`
|
|
34
|
+
or
|
|
35
|
+
- `query_table(table_name, limit, columns, order_by)`
|
|
36
|
+
|
|
37
|
+
Then the agent: 1 skill + 1 call. mcpskills-cli just generates the skill for that tool; no generator change.
|
|
38
|
+
|
|
39
|
+
## Summary
|
|
40
|
+
|
|
41
|
+
| Approach | Skill reads | Tool calls (e.g. top 10 products) | Token impact |
|
|
42
|
+
|-----------------------|------------|------------------------------------|----------------|
|
|
43
|
+
| Multi-skills (current)| 3 | 3 (list + schema + query) | Highest |
|
|
44
|
+
| Single skill | 1 | 3 | Lower (docs) |
|
|
45
|
+
| Single skill + guidance | 1 | 1 (execute_query only) | Lower (docs + calls) |
|
|
46
|
+
| Single skill + embedded schema | 1 | 1 | Same as above |
|
|
47
|
+
| MCP high-level tool | 1 | 1 | Lowest |
|
|
48
|
+
|
|
49
|
+
**Practical minimum for mcpskills-cli:** generate **one skill** (no `--multi-skills`) and add **usage guidance** so the agent uses execute_query directly when the intent is clear.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "mcpskills-cli"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Generate Cursor Agent Skills from MCP server tools"
|
|
9
|
+
requires-python = ">=3.10"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"fastmcp>=2.3",
|
|
12
|
+
"jinja2>=3.1",
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
[project.scripts]
|
|
16
|
+
mcpskills-cli = "mcp_cli.cli:main"
|
|
17
|
+
|
|
18
|
+
[tool.hatch.build.targets.wheel]
|
|
19
|
+
packages = ["src/mcp_cli"]
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""CLI entry point for mcpskills-cli."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
from mcp_cli import credentials, client, generator
|
|
9
|
+
|
|
10
|
+
DEFAULT_OUTPUT = os.path.expanduser("~/.cursor/skills")
|
|
11
|
+
SCRIPT_CHOICES = list(generator.SCRIPT_LANG_MAP.keys())
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def sanitize_name(s: str) -> str:
|
|
15
|
+
return re.sub(r"[^a-z0-9]+", "-", s.lower()).strip("-") or "mcp-server"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def derive_server_name(url: str) -> str:
|
|
19
|
+
u = url.rstrip("/")
|
|
20
|
+
if "/" in u:
|
|
21
|
+
return sanitize_name(u.split("/")[-1])
|
|
22
|
+
return sanitize_name(u.replace(":", "-"))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def main():
|
|
26
|
+
ap = argparse.ArgumentParser(
|
|
27
|
+
prog="mcpskills-cli",
|
|
28
|
+
description="Generate Cursor Agent Skill from MCP server tools",
|
|
29
|
+
)
|
|
30
|
+
ap.add_argument("--url", required=True, help="MCP server URL (Streamable HTTP)")
|
|
31
|
+
ap.add_argument("--token", required=True, help="Bearer token for auth")
|
|
32
|
+
ap.add_argument("--output", default=DEFAULT_OUTPUT, help=f"Skills output dir (default: {DEFAULT_OUTPUT})")
|
|
33
|
+
ap.add_argument("--name", help="Server name (default: derived from URL)")
|
|
34
|
+
ap.add_argument(
|
|
35
|
+
"--script",
|
|
36
|
+
choices=SCRIPT_CHOICES,
|
|
37
|
+
default="bash",
|
|
38
|
+
help="Language for the generated call script (default: bash)",
|
|
39
|
+
)
|
|
40
|
+
ap.add_argument(
|
|
41
|
+
"--multi-skills",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Generate a separate skill for each tool",
|
|
44
|
+
)
|
|
45
|
+
args = ap.parse_args()
|
|
46
|
+
|
|
47
|
+
server_name = sanitize_name((args.name or derive_server_name(args.url)).strip())
|
|
48
|
+
url = args.url.rstrip("/") + "/"
|
|
49
|
+
|
|
50
|
+
try:
|
|
51
|
+
tools = client.list_tools(url, args.token)
|
|
52
|
+
except Exception as e:
|
|
53
|
+
print(f"Error fetching tools: {e}", file=sys.stderr)
|
|
54
|
+
sys.exit(1)
|
|
55
|
+
|
|
56
|
+
if not tools:
|
|
57
|
+
print("No tools returned by server.", file=sys.stderr)
|
|
58
|
+
sys.exit(1)
|
|
59
|
+
|
|
60
|
+
credentials.save(server_name, url, args.token)
|
|
61
|
+
print(f"Credentials saved to {credentials.CREDS_FILE} (chmod 600)")
|
|
62
|
+
|
|
63
|
+
result = generator.generate_skill(
|
|
64
|
+
server_name=server_name,
|
|
65
|
+
raw_tools=tools,
|
|
66
|
+
output_dir=os.path.expanduser(args.output),
|
|
67
|
+
script=args.script,
|
|
68
|
+
multi_skills=args.multi_skills,
|
|
69
|
+
)
|
|
70
|
+
lang_cfg = generator.SCRIPT_LANG_MAP[args.script]
|
|
71
|
+
|
|
72
|
+
if args.multi_skills:
|
|
73
|
+
print(f"\nSkills generated:")
|
|
74
|
+
for out_dir in result:
|
|
75
|
+
print(f" {out_dir}")
|
|
76
|
+
else:
|
|
77
|
+
out_dir = result
|
|
78
|
+
call_file = os.path.join(out_dir, "scripts", f"call.{lang_cfg['ext']}")
|
|
79
|
+
print(f"Skill generated at {out_dir}")
|
|
80
|
+
print(f" SKILL.md ({len(tools)} tools)")
|
|
81
|
+
print(f" scripts/call.{lang_cfg['ext']}")
|
|
82
|
+
print(f"Usage: {lang_cfg['bin']} {call_file} <tool_name> '{{}}' ")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
main()
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""MCP client wrapper using fastmcp."""
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from fastmcp import Client
|
|
7
|
+
from fastmcp.client.transports import StreamableHttpTransport
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
async def _list_tools(url: str, token: str) -> list[dict[str, Any]]:
|
|
11
|
+
transport = StreamableHttpTransport(url, auth=token)
|
|
12
|
+
async with Client(transport) as client:
|
|
13
|
+
tools = await client.list_tools()
|
|
14
|
+
return [t.model_dump() for t in tools]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def list_tools(url: str, token: str) -> list[dict[str, Any]]:
|
|
18
|
+
return asyncio.run(_list_tools(url, token))
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Manage MCP server credentials in ~/.mcps/credentials (INI format, chmod 600)."""
|
|
2
|
+
|
|
3
|
+
import configparser
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
CREDS_DIR = os.path.expanduser("~/.mcps")
|
|
8
|
+
CREDS_FILE = os.path.join(CREDS_DIR, "credentials")
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class ServerCredentials:
|
|
13
|
+
url: str
|
|
14
|
+
token: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def save(server_name: str, url: str, token: str) -> None:
|
|
18
|
+
os.makedirs(CREDS_DIR, mode=0o700, exist_ok=True)
|
|
19
|
+
cfg = configparser.ConfigParser()
|
|
20
|
+
if os.path.isfile(CREDS_FILE):
|
|
21
|
+
cfg.read(CREDS_FILE)
|
|
22
|
+
if server_name not in cfg:
|
|
23
|
+
cfg[server_name] = {}
|
|
24
|
+
cfg[server_name]["url"] = url
|
|
25
|
+
cfg[server_name]["token"] = token
|
|
26
|
+
with open(CREDS_FILE, "w") as f:
|
|
27
|
+
cfg.write(f)
|
|
28
|
+
os.chmod(CREDS_FILE, 0o600)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def load(server_name: str) -> ServerCredentials:
|
|
32
|
+
cfg = configparser.ConfigParser()
|
|
33
|
+
if not os.path.isfile(CREDS_FILE):
|
|
34
|
+
raise FileNotFoundError(f"Missing {CREDS_FILE}")
|
|
35
|
+
cfg.read(CREDS_FILE)
|
|
36
|
+
if server_name not in cfg:
|
|
37
|
+
raise KeyError(f"No section [{server_name}] in {CREDS_FILE}")
|
|
38
|
+
return ServerCredentials(
|
|
39
|
+
url=cfg[server_name]["url"],
|
|
40
|
+
token=cfg[server_name]["token"],
|
|
41
|
+
)
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Fetch MCP tools, render templates, write skill output."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import jinja2
|
|
9
|
+
|
|
10
|
+
SCRIPT_LANG_MAP = {
|
|
11
|
+
"bash": {
|
|
12
|
+
"template": "call_bash.sh.j2",
|
|
13
|
+
"ext": "sh",
|
|
14
|
+
"bin": "bash",
|
|
15
|
+
"lang": "bash",
|
|
16
|
+
},
|
|
17
|
+
"python": {
|
|
18
|
+
"template": "call_python.py.j2",
|
|
19
|
+
"ext": "py",
|
|
20
|
+
"bin": "python3",
|
|
21
|
+
"lang": "python",
|
|
22
|
+
},
|
|
23
|
+
"node": {
|
|
24
|
+
"template": "call_node.js.j2",
|
|
25
|
+
"ext": "js",
|
|
26
|
+
"bin": "node",
|
|
27
|
+
"lang": "javascript",
|
|
28
|
+
},
|
|
29
|
+
"go": {
|
|
30
|
+
"template": "call_go.go.j2",
|
|
31
|
+
"ext": "go",
|
|
32
|
+
"bin": "go run",
|
|
33
|
+
"lang": "go",
|
|
34
|
+
},
|
|
35
|
+
"rust": {
|
|
36
|
+
"template": "call_rust.rs.j2",
|
|
37
|
+
"ext": "rs",
|
|
38
|
+
"bin": "cargo run --",
|
|
39
|
+
"lang": "rust",
|
|
40
|
+
},
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class ToolParam:
|
|
46
|
+
name: str
|
|
47
|
+
type: str
|
|
48
|
+
required: str
|
|
49
|
+
description: str
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class ToolInfo:
|
|
54
|
+
name: str
|
|
55
|
+
description: str
|
|
56
|
+
params: list[ToolParam]
|
|
57
|
+
required_params: list[str]
|
|
58
|
+
example_args: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def parse_tool(raw: dict[str, Any]) -> ToolInfo:
|
|
62
|
+
schema = raw.get("inputSchema") or {}
|
|
63
|
+
props = schema.get("properties") or {}
|
|
64
|
+
required_set = set(schema.get("required") or [])
|
|
65
|
+
params = []
|
|
66
|
+
for key, spec in props.items():
|
|
67
|
+
if isinstance(spec, dict):
|
|
68
|
+
params.append(ToolParam(
|
|
69
|
+
name=key,
|
|
70
|
+
type=spec.get("type", "any"),
|
|
71
|
+
required="required" if key in required_set else "optional",
|
|
72
|
+
description=(spec.get("description") or "").strip(),
|
|
73
|
+
))
|
|
74
|
+
else:
|
|
75
|
+
params.append(
|
|
76
|
+
ToolParam(name=key, type="any", required="optional", description="")
|
|
77
|
+
)
|
|
78
|
+
req_params = [p.name for p in params if p.required == "required"]
|
|
79
|
+
example = (
|
|
80
|
+
json.dumps({k: f"<{k}>" for k in req_params}) if req_params else ""
|
|
81
|
+
)
|
|
82
|
+
return ToolInfo(
|
|
83
|
+
name=raw.get("name", "unknown"),
|
|
84
|
+
description=(raw.get("description") or "No description.").strip(),
|
|
85
|
+
params=params,
|
|
86
|
+
required_params=req_params,
|
|
87
|
+
example_args=example,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _get_env() -> jinja2.Environment:
|
|
92
|
+
return jinja2.Environment(
|
|
93
|
+
loader=jinja2.PackageLoader("mcp_cli", "templates"),
|
|
94
|
+
keep_trailing_newline=True,
|
|
95
|
+
trim_blocks=True,
|
|
96
|
+
lstrip_blocks=True,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def generate_skill(
|
|
101
|
+
server_name: str,
|
|
102
|
+
raw_tools: list[dict[str, Any]],
|
|
103
|
+
output_dir: str,
|
|
104
|
+
script: str = "bash",
|
|
105
|
+
multi_skills: bool = False,
|
|
106
|
+
) -> str | list[str]:
|
|
107
|
+
env = _get_env()
|
|
108
|
+
tools = [parse_tool(t) for t in raw_tools]
|
|
109
|
+
lang_cfg = SCRIPT_LANG_MAP[script]
|
|
110
|
+
|
|
111
|
+
if multi_skills:
|
|
112
|
+
generated_dirs = []
|
|
113
|
+
for tool in tools:
|
|
114
|
+
out_dir = os.path.join(output_dir, f"{server_name}-{tool.name}")
|
|
115
|
+
scripts_dir = os.path.join(out_dir, "scripts")
|
|
116
|
+
os.makedirs(scripts_dir, exist_ok=True)
|
|
117
|
+
skill_path = os.path.abspath(out_dir)
|
|
118
|
+
|
|
119
|
+
skill_md = env.get_template("skill_single.md.j2").render(
|
|
120
|
+
server_name=server_name,
|
|
121
|
+
tool=tool,
|
|
122
|
+
skill_path=skill_path,
|
|
123
|
+
script_lang=lang_cfg["lang"],
|
|
124
|
+
script_bin=lang_cfg["bin"],
|
|
125
|
+
script_ext=lang_cfg["ext"],
|
|
126
|
+
)
|
|
127
|
+
with open(os.path.join(out_dir, "SKILL.md"), "w") as f:
|
|
128
|
+
f.write(skill_md)
|
|
129
|
+
|
|
130
|
+
call_content = env.get_template(lang_cfg["template"]).render(
|
|
131
|
+
server_name=server_name
|
|
132
|
+
)
|
|
133
|
+
call_filename = f"call.{lang_cfg['ext']}"
|
|
134
|
+
call_path = os.path.join(scripts_dir, call_filename)
|
|
135
|
+
with open(call_path, "w") as f:
|
|
136
|
+
f.write(call_content)
|
|
137
|
+
os.chmod(call_path, 0o755)
|
|
138
|
+
|
|
139
|
+
generated_dirs.append(out_dir)
|
|
140
|
+
|
|
141
|
+
return generated_dirs
|
|
142
|
+
else:
|
|
143
|
+
tool_names = ", ".join(t.name for t in tools)
|
|
144
|
+
out_dir = os.path.join(output_dir, server_name)
|
|
145
|
+
scripts_dir = os.path.join(out_dir, "scripts")
|
|
146
|
+
os.makedirs(scripts_dir, exist_ok=True)
|
|
147
|
+
skill_path = os.path.abspath(out_dir)
|
|
148
|
+
|
|
149
|
+
skill_md = env.get_template("skill.md.j2").render(
|
|
150
|
+
server_name=server_name,
|
|
151
|
+
tools=tools,
|
|
152
|
+
tool_names=tool_names,
|
|
153
|
+
skill_path=skill_path,
|
|
154
|
+
script_lang=lang_cfg["lang"],
|
|
155
|
+
script_bin=lang_cfg["bin"],
|
|
156
|
+
script_ext=lang_cfg["ext"],
|
|
157
|
+
)
|
|
158
|
+
with open(os.path.join(out_dir, "SKILL.md"), "w") as f:
|
|
159
|
+
f.write(skill_md)
|
|
160
|
+
|
|
161
|
+
call_content = env.get_template(lang_cfg["template"]).render(
|
|
162
|
+
server_name=server_name
|
|
163
|
+
)
|
|
164
|
+
call_filename = f"call.{lang_cfg['ext']}"
|
|
165
|
+
call_path = os.path.join(scripts_dir, call_filename)
|
|
166
|
+
with open(call_path, "w") as f:
|
|
167
|
+
f.write(call_content)
|
|
168
|
+
os.chmod(call_path, 0o755)
|
|
169
|
+
|
|
170
|
+
return out_dir
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -e
|
|
3
|
+
SERVER="{{ server_name }}"
|
|
4
|
+
CREDS="${HOME}/.mcps/credentials"
|
|
5
|
+
if [[ ! -f "$CREDS" ]]; then
|
|
6
|
+
echo "Missing $CREDS" >&2
|
|
7
|
+
exit 1
|
|
8
|
+
fi
|
|
9
|
+
get_ini() {
|
|
10
|
+
local section="$1" key="$2"
|
|
11
|
+
awk -v S="\\[$section\\]" -v K="$key" '
|
|
12
|
+
$0 ~ S { found=1; next }
|
|
13
|
+
found && /^\[/ { exit }
|
|
14
|
+
found && $0 ~ "^" K "[[:space:]]*=" { sub(/^[^=]*=[[:space:]]*/, ""); print; exit }
|
|
15
|
+
' "$CREDS"
|
|
16
|
+
}
|
|
17
|
+
URL=$(get_ini "$SERVER" "url")
|
|
18
|
+
TOKEN=$(get_ini "$SERVER" "token")
|
|
19
|
+
if [[ -z "$URL" || -z "$TOKEN" ]]; then
|
|
20
|
+
echo "Missing url or token for [$SERVER] in $CREDS" >&2
|
|
21
|
+
exit 1
|
|
22
|
+
fi
|
|
23
|
+
[[ "$URL" != */ ]] && URL="$URL/"
|
|
24
|
+
TOOL="$1"
|
|
25
|
+
ARGS="${2:-{}}"
|
|
26
|
+
if [[ -z "$TOOL" ]]; then
|
|
27
|
+
echo "Usage: $0 <tool_name> [json_args]" >&2
|
|
28
|
+
exit 1
|
|
29
|
+
fi
|
|
30
|
+
BODY=$(python3 -c "
|
|
31
|
+
import json, sys
|
|
32
|
+
name, raw = sys.argv[1], sys.argv[2] if len(sys.argv) > 2 else '{}'
|
|
33
|
+
try: args = json.loads(raw)
|
|
34
|
+
except: args = {}
|
|
35
|
+
print(json.dumps({'jsonrpc':'2.0','method':'tools/call','params':{'name':name,'arguments':args},'id':1}))
|
|
36
|
+
" "$TOOL" "$ARGS")
|
|
37
|
+
RESPONSE=$(curl -s --max-time 30 -X POST "$URL" \
|
|
38
|
+
-H "Authorization: Bearer $TOKEN" \
|
|
39
|
+
-H "Content-Type: application/json" \
|
|
40
|
+
-H "Accept: application/json, text/event-stream" \
|
|
41
|
+
-H "MCP-Protocol-Version: 2025-06-18" \
|
|
42
|
+
-d "$BODY")
|
|
43
|
+
echo "$RESPONSE" | python3 -c "
|
|
44
|
+
import json, sys
|
|
45
|
+
text = sys.stdin.read().strip()
|
|
46
|
+
for line in text.split('\n'):
|
|
47
|
+
if line.startswith('data:'):
|
|
48
|
+
line = line[5:].strip()
|
|
49
|
+
if not line or line == '[DONE]': continue
|
|
50
|
+
try:
|
|
51
|
+
obj = json.loads(line)
|
|
52
|
+
if 'result' in obj or 'error' in obj:
|
|
53
|
+
print(json.dumps(obj, indent=2))
|
|
54
|
+
sys.exit(0)
|
|
55
|
+
except json.JSONDecodeError: pass
|
|
56
|
+
print(text)
|
|
57
|
+
"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// Call MCP tool on server '{{ server_name }}'.
|
|
2
|
+
// Usage: go run call.go <tool_name> [json_args]
|
|
3
|
+
package main
|
|
4
|
+
|
|
5
|
+
import (
|
|
6
|
+
"bufio"
|
|
7
|
+
"bytes"
|
|
8
|
+
"encoding/json"
|
|
9
|
+
"fmt"
|
|
10
|
+
"io"
|
|
11
|
+
"net/http"
|
|
12
|
+
"os"
|
|
13
|
+
"path/filepath"
|
|
14
|
+
"strings"
|
|
15
|
+
"time"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
const server = "{{ server_name }}"
|
|
19
|
+
|
|
20
|
+
func loadCredentials() (string, string) {
|
|
21
|
+
home, _ := os.UserHomeDir()
|
|
22
|
+
data, err := os.ReadFile(filepath.Join(home, ".mcps", "credentials"))
|
|
23
|
+
if err != nil {
|
|
24
|
+
fmt.Fprintln(os.Stderr, err)
|
|
25
|
+
os.Exit(1)
|
|
26
|
+
}
|
|
27
|
+
var url, token string
|
|
28
|
+
inSection := false
|
|
29
|
+
scanner := bufio.NewScanner(bytes.NewReader(data))
|
|
30
|
+
for scanner.Scan() {
|
|
31
|
+
line := strings.TrimSpace(scanner.Text())
|
|
32
|
+
if line == fmt.Sprintf("[%s]", server) {
|
|
33
|
+
inSection = true
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if inSection && strings.HasPrefix(line, "[") {
|
|
37
|
+
break
|
|
38
|
+
}
|
|
39
|
+
if inSection {
|
|
40
|
+
if k, v, ok := strings.Cut(line, "="); ok {
|
|
41
|
+
switch strings.TrimSpace(k) {
|
|
42
|
+
case "url":
|
|
43
|
+
url = strings.TrimSpace(v)
|
|
44
|
+
case "token":
|
|
45
|
+
token = strings.TrimSpace(v)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if url == "" || token == "" {
|
|
51
|
+
fmt.Fprintf(os.Stderr, "Missing url or token for [%s]\n", server)
|
|
52
|
+
os.Exit(1)
|
|
53
|
+
}
|
|
54
|
+
if !strings.HasSuffix(url, "/") {
|
|
55
|
+
url += "/"
|
|
56
|
+
}
|
|
57
|
+
return url, token
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
func main() {
|
|
61
|
+
if len(os.Args) < 2 {
|
|
62
|
+
fmt.Fprintf(os.Stderr, "Usage: %s <tool_name> [json_args]\n", os.Args[0])
|
|
63
|
+
os.Exit(1)
|
|
64
|
+
}
|
|
65
|
+
toolName := os.Args[1]
|
|
66
|
+
args := json.RawMessage("{}")
|
|
67
|
+
if len(os.Args) > 2 {
|
|
68
|
+
args = json.RawMessage(os.Args[2])
|
|
69
|
+
}
|
|
70
|
+
url, token := loadCredentials()
|
|
71
|
+
body, _ := json.Marshal(map[string]any{
|
|
72
|
+
"jsonrpc": "2.0",
|
|
73
|
+
"method": "tools/call",
|
|
74
|
+
"params": map[string]any{"name": toolName, "arguments": args},
|
|
75
|
+
"id": 1,
|
|
76
|
+
})
|
|
77
|
+
req, _ := http.NewRequest("POST", url, bytes.NewReader(body))
|
|
78
|
+
req.Header.Set("Authorization", "Bearer "+token)
|
|
79
|
+
req.Header.Set("Content-Type", "application/json")
|
|
80
|
+
req.Header.Set("Accept", "application/json, text/event-stream")
|
|
81
|
+
req.Header.Set("MCP-Protocol-Version", "2025-06-18")
|
|
82
|
+
client := &http.Client{Timeout: 30 * time.Second}
|
|
83
|
+
resp, err := client.Do(req)
|
|
84
|
+
if err != nil {
|
|
85
|
+
fmt.Fprintln(os.Stderr, err)
|
|
86
|
+
os.Exit(1)
|
|
87
|
+
}
|
|
88
|
+
defer resp.Body.Close()
|
|
89
|
+
raw, _ := io.ReadAll(resp.Body)
|
|
90
|
+
for _, line := range strings.Split(string(raw), "\n") {
|
|
91
|
+
line = strings.TrimSpace(line)
|
|
92
|
+
if strings.HasPrefix(line, "data:") {
|
|
93
|
+
line = strings.TrimSpace(line[5:])
|
|
94
|
+
}
|
|
95
|
+
if line == "" || line == "[DONE]" {
|
|
96
|
+
continue
|
|
97
|
+
}
|
|
98
|
+
var obj map[string]any
|
|
99
|
+
if json.Unmarshal([]byte(line), &obj) == nil {
|
|
100
|
+
if _, ok := obj["result"]; ok {
|
|
101
|
+
out, _ := json.MarshalIndent(obj, "", " ")
|
|
102
|
+
fmt.Println(string(out))
|
|
103
|
+
return
|
|
104
|
+
}
|
|
105
|
+
if _, ok := obj["error"]; ok {
|
|
106
|
+
out, _ := json.MarshalIndent(obj, "", " ")
|
|
107
|
+
fmt.Println(string(out))
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
fmt.Println(string(raw))
|
|
113
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Call MCP tool on server '{{ server_name }}'
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
const SERVER = "{{ server_name }}";
|
|
7
|
+
const CREDS_FILE = path.join(process.env.HOME, ".mcps", "credentials");
|
|
8
|
+
|
|
9
|
+
function loadCredentials() {
|
|
10
|
+
const text = fs.readFileSync(CREDS_FILE, "utf-8");
|
|
11
|
+
const section = new RegExp(`\\[${SERVER}\\]([\\s\\S]*?)(?=\\n\\[|$)`);
|
|
12
|
+
const match = text.match(section);
|
|
13
|
+
if (!match) {
|
|
14
|
+
console.error(`No section [${SERVER}] in ${CREDS_FILE}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
}
|
|
17
|
+
const block = match[1];
|
|
18
|
+
const url = block.match(/^url\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
19
|
+
const token = block.match(/^token\s*=\s*(.+)$/m)?.[1]?.trim();
|
|
20
|
+
if (!url || !token) {
|
|
21
|
+
console.error(`Missing url or token for [${SERVER}]`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
return { url: url.replace(/\/?$/, "/"), token };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function callTool(toolName, args) {
|
|
28
|
+
const { url, token } = loadCredentials();
|
|
29
|
+
const body = JSON.stringify({
|
|
30
|
+
jsonrpc: "2.0",
|
|
31
|
+
method: "tools/call",
|
|
32
|
+
params: { name: toolName, arguments: args },
|
|
33
|
+
id: 1,
|
|
34
|
+
});
|
|
35
|
+
const resp = await fetch(url, {
|
|
36
|
+
method: "POST",
|
|
37
|
+
headers: {
|
|
38
|
+
Authorization: `Bearer ${token}`,
|
|
39
|
+
"Content-Type": "application/json",
|
|
40
|
+
Accept: "application/json, text/event-stream",
|
|
41
|
+
"MCP-Protocol-Version": "2025-06-18",
|
|
42
|
+
},
|
|
43
|
+
body,
|
|
44
|
+
});
|
|
45
|
+
const text = await resp.text();
|
|
46
|
+
for (const line of text.split("\n")) {
|
|
47
|
+
let data = line.startsWith("data:") ? line.slice(5).trim() : line.trim();
|
|
48
|
+
if (!data || data === "[DONE]") continue;
|
|
49
|
+
try {
|
|
50
|
+
const obj = JSON.parse(data);
|
|
51
|
+
if (obj.result || obj.error) return obj;
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
return JSON.parse(text);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const [toolName, rawArgs] = process.argv.slice(2);
|
|
58
|
+
if (!toolName) {
|
|
59
|
+
console.error(`Usage: ${process.argv[1]} <tool_name> [json_args]`);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
const args = rawArgs ? JSON.parse(rawArgs) : {};
|
|
63
|
+
callTool(toolName, args).then((r) => console.log(JSON.stringify(r, null, 2)));
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Call MCP tool on server '{{ server_name }}'."""
|
|
3
|
+
import configparser
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import sys
|
|
7
|
+
import urllib.request
|
|
8
|
+
|
|
9
|
+
SERVER = "{{ server_name }}"
|
|
10
|
+
CREDS_FILE = os.path.expanduser("~/.mcps/credentials")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def load_credentials():
|
|
14
|
+
cfg = configparser.ConfigParser()
|
|
15
|
+
cfg.read(CREDS_FILE)
|
|
16
|
+
if SERVER not in cfg:
|
|
17
|
+
print(f"No section [{SERVER}] in {CREDS_FILE}", file=sys.stderr)
|
|
18
|
+
sys.exit(1)
|
|
19
|
+
return cfg[SERVER]["url"], cfg[SERVER]["token"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def call_tool(tool_name: str, arguments: dict) -> dict:
|
|
23
|
+
url, token = load_credentials()
|
|
24
|
+
url = url.rstrip("/") + "/"
|
|
25
|
+
body = json.dumps({
|
|
26
|
+
"jsonrpc": "2.0",
|
|
27
|
+
"method": "tools/call",
|
|
28
|
+
"params": {"name": tool_name, "arguments": arguments},
|
|
29
|
+
"id": 1,
|
|
30
|
+
}).encode()
|
|
31
|
+
req = urllib.request.Request(
|
|
32
|
+
url, data=body, method="POST",
|
|
33
|
+
headers={
|
|
34
|
+
"Authorization": f"Bearer {token}",
|
|
35
|
+
"Content-Type": "application/json",
|
|
36
|
+
"Accept": "application/json, text/event-stream",
|
|
37
|
+
"MCP-Protocol-Version": "2025-06-18",
|
|
38
|
+
},
|
|
39
|
+
)
|
|
40
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
41
|
+
text = resp.read().decode()
|
|
42
|
+
for line in text.strip().split("\n"):
|
|
43
|
+
if line.startswith("data:"):
|
|
44
|
+
line = line[5:].strip()
|
|
45
|
+
if not line or line == "[DONE]":
|
|
46
|
+
continue
|
|
47
|
+
try:
|
|
48
|
+
obj = json.loads(line)
|
|
49
|
+
if "result" in obj or "error" in obj:
|
|
50
|
+
return obj
|
|
51
|
+
except json.JSONDecodeError:
|
|
52
|
+
pass
|
|
53
|
+
return json.loads(text)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
if __name__ == "__main__":
|
|
57
|
+
if len(sys.argv) < 2:
|
|
58
|
+
print(f"Usage: {sys.argv[0]} <tool_name> [json_args]", file=sys.stderr)
|
|
59
|
+
sys.exit(1)
|
|
60
|
+
tool = sys.argv[1]
|
|
61
|
+
args = json.loads(sys.argv[2]) if len(sys.argv) > 2 else {}
|
|
62
|
+
result = call_tool(tool, args)
|
|
63
|
+
print(json.dumps(result, indent=2))
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Call MCP tool on server '{{ server_name }}'.
|
|
2
|
+
// Dependencies: ureq, serde_json
|
|
3
|
+
// cargo install cargo-script OR use a Cargo.toml with:
|
|
4
|
+
// [dependencies]
|
|
5
|
+
// ureq = "2"
|
|
6
|
+
// serde_json = "1"
|
|
7
|
+
// Usage: cargo run -- <tool_name> [json_args]
|
|
8
|
+
|
|
9
|
+
use std::env;
|
|
10
|
+
use std::fs;
|
|
11
|
+
use std::path::PathBuf;
|
|
12
|
+
use std::process;
|
|
13
|
+
|
|
14
|
+
const SERVER: &str = "{{ server_name }}";
|
|
15
|
+
|
|
16
|
+
fn creds_path() -> PathBuf {
|
|
17
|
+
let home = env::var("HOME").unwrap_or_else(|_| ".".into());
|
|
18
|
+
PathBuf::from(home).join(".mcps").join("credentials")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn load_credentials() -> (String, String) {
|
|
22
|
+
let text = fs::read_to_string(creds_path()).unwrap_or_else(|e| {
|
|
23
|
+
eprintln!("Cannot read credentials: {}", e);
|
|
24
|
+
process::exit(1);
|
|
25
|
+
});
|
|
26
|
+
let header = format!("[{}]", SERVER);
|
|
27
|
+
let mut in_section = false;
|
|
28
|
+
let (mut url, mut token) = (String::new(), String::new());
|
|
29
|
+
for line in text.lines() {
|
|
30
|
+
let trimmed = line.trim();
|
|
31
|
+
if trimmed == header {
|
|
32
|
+
in_section = true;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if in_section && trimmed.starts_with('[') {
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
if in_section {
|
|
39
|
+
if let Some((k, v)) = trimmed.split_once('=') {
|
|
40
|
+
match k.trim() {
|
|
41
|
+
"url" => url = v.trim().to_string(),
|
|
42
|
+
"token" => token = v.trim().to_string(),
|
|
43
|
+
_ => {}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if url.is_empty() || token.is_empty() {
|
|
49
|
+
eprintln!("Missing url or token for [{}]", SERVER);
|
|
50
|
+
process::exit(1);
|
|
51
|
+
}
|
|
52
|
+
if !url.ends_with('/') {
|
|
53
|
+
url.push('/');
|
|
54
|
+
}
|
|
55
|
+
(url, token)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
fn main() {
|
|
59
|
+
let args: Vec<String> = env::args().collect();
|
|
60
|
+
if args.len() < 2 {
|
|
61
|
+
eprintln!("Usage: {} <tool_name> [json_args]", args[0]);
|
|
62
|
+
process::exit(1);
|
|
63
|
+
}
|
|
64
|
+
let tool_name = &args[1];
|
|
65
|
+
let tool_args: serde_json::Value = if args.len() > 2 {
|
|
66
|
+
serde_json::from_str(&args[2]).unwrap_or(serde_json::json!({}))
|
|
67
|
+
} else {
|
|
68
|
+
serde_json::json!({})
|
|
69
|
+
};
|
|
70
|
+
let (url, token) = load_credentials();
|
|
71
|
+
let body = serde_json::json!({
|
|
72
|
+
"jsonrpc": "2.0",
|
|
73
|
+
"method": "tools/call",
|
|
74
|
+
"params": {"name": tool_name, "arguments": tool_args},
|
|
75
|
+
"id": 1
|
|
76
|
+
});
|
|
77
|
+
let resp = ureq::post(&url)
|
|
78
|
+
.set("Authorization", &format!("Bearer {}", token))
|
|
79
|
+
.set("Content-Type", "application/json")
|
|
80
|
+
.set("Accept", "application/json, text/event-stream")
|
|
81
|
+
.set("MCP-Protocol-Version", "2025-06-18")
|
|
82
|
+
.send_string(&body.to_string())
|
|
83
|
+
.unwrap_or_else(|e| {
|
|
84
|
+
eprintln!("Request failed: {}", e);
|
|
85
|
+
process::exit(1);
|
|
86
|
+
});
|
|
87
|
+
let text = resp.into_string().unwrap_or_default();
|
|
88
|
+
for line in text.lines() {
|
|
89
|
+
let data = if line.starts_with("data:") { line[5..].trim() } else { line.trim() };
|
|
90
|
+
if data.is_empty() || data == "[DONE]" {
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
if let Ok(obj) = serde_json::from_str::<serde_json::Value>(data) {
|
|
94
|
+
if obj.get("result").is_some() || obj.get("error").is_some() {
|
|
95
|
+
println!("{}", serde_json::to_string_pretty(&obj).unwrap());
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
println!("{}", text);
|
|
101
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: {{ server_name }}
|
|
3
|
+
description: MCP tools for {{ server_name }}. Available tools: {{ tool_names }}. Use when the user needs to call these MCP tools.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# {{ server_name }} MCP Tools
|
|
7
|
+
|
|
8
|
+
## Available Tools
|
|
9
|
+
{% for tool in tools %}
|
|
10
|
+
|
|
11
|
+
### {{ tool.name }}
|
|
12
|
+
|
|
13
|
+
{{ tool.description | default("No description.", true) }}
|
|
14
|
+
{% if tool.params %}
|
|
15
|
+
|
|
16
|
+
**Parameters:**
|
|
17
|
+
{% for p in tool.params %}
|
|
18
|
+
- `{{ p.name }}` ({{ p.type }}, {{ p.required }}): {{ p.description | default("-", true) }}
|
|
19
|
+
{% endfor %}
|
|
20
|
+
{% endif %}
|
|
21
|
+
|
|
22
|
+
**Execute:**
|
|
23
|
+
```{{ script_lang }}
|
|
24
|
+
{{ script_bin }} {{ skill_path }}/scripts/call.{{ script_ext }} {{ tool.name }}{% if tool.required_params %} '{{ tool.example_args }}'{% endif %}
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
{% endfor %}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: {{ server_name }}-{{ tool.name }}
|
|
3
|
+
description: MCP tool {{ tool.name }} from {{ server_name }}. {{ tool.description }}
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# {{ tool.name }}
|
|
7
|
+
|
|
8
|
+
{{ tool.description | default("No description.", true) }}
|
|
9
|
+
{% if tool.params %}
|
|
10
|
+
|
|
11
|
+
**Parameters:**
|
|
12
|
+
{% for p in tool.params %}
|
|
13
|
+
- `{{ p.name }}` ({{ p.type }}, {{ p.required }}): {{ p.description | default("-", true) }}
|
|
14
|
+
{% endfor %}
|
|
15
|
+
{% endif %}
|
|
16
|
+
|
|
17
|
+
**Execute:**
|
|
18
|
+
```{{ script_lang }}
|
|
19
|
+
{{ script_bin }} {{ skill_path }}/scripts/call.{{ script_ext }} {{ tool.name }}{% if tool.required_params %} '{{ tool.example_args }}'{% endif %}
|
|
20
|
+
|
|
21
|
+
```
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import urllib.request
|
|
3
|
+
import tiktoken
|
|
4
|
+
|
|
5
|
+
SERVERS = {
|
|
6
|
+
"dellstore": {
|
|
7
|
+
"url": "http://localhost:8027/mcp/a0504710-203f-4c4b-b864-5e64641d9ed3/",
|
|
8
|
+
"token": "mcp_token_bb1HbX3e-wykRQYziLNaGGYod_qhxrlffUm4DxmMn7w",
|
|
9
|
+
"skill": ".cursor/skills/dellstore/SKILL.md",
|
|
10
|
+
},
|
|
11
|
+
"neo4j-movie": {
|
|
12
|
+
"url": "http://localhost:8032/mcp/8bb059d5-0115-4c08-b994-25c26695b027/",
|
|
13
|
+
"token": "mcp_token_Na7kQzvA2RxhzvAjT9P3Q0rPiaxlIHUnXiPHCoeMrhE",
|
|
14
|
+
"skill": ".cursor/skills/neo4j-movie/SKILL.md",
|
|
15
|
+
},
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
enc = tiktoken.encoding_for_model("gpt-4o")
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def count_tokens(text: str) -> int:
|
|
22
|
+
return len(enc.encode(text))
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def get_mcp_tools_payload(url: str, token: str) -> str:
|
|
26
|
+
body = json.dumps({
|
|
27
|
+
"jsonrpc": "2.0",
|
|
28
|
+
"method": "tools/list",
|
|
29
|
+
"params": {},
|
|
30
|
+
"id": 1,
|
|
31
|
+
}).encode()
|
|
32
|
+
req = urllib.request.Request(
|
|
33
|
+
url, data=body, method="POST",
|
|
34
|
+
headers={
|
|
35
|
+
"Authorization": f"Bearer {token}",
|
|
36
|
+
"Content-Type": "application/json",
|
|
37
|
+
"Accept": "application/json, text/event-stream",
|
|
38
|
+
"MCP-Protocol-Version": "2025-06-18",
|
|
39
|
+
},
|
|
40
|
+
)
|
|
41
|
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
|
42
|
+
text = resp.read().decode()
|
|
43
|
+
for line in text.strip().split("\n"):
|
|
44
|
+
if line.startswith("data:"):
|
|
45
|
+
line = line[5:].strip()
|
|
46
|
+
if not line or line == "[DONE]":
|
|
47
|
+
continue
|
|
48
|
+
try:
|
|
49
|
+
obj = json.loads(line)
|
|
50
|
+
if "result" in obj:
|
|
51
|
+
return json.dumps(obj["result"]["tools"], indent=2)
|
|
52
|
+
except json.JSONDecodeError:
|
|
53
|
+
pass
|
|
54
|
+
return text
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def main():
|
|
58
|
+
print("=" * 60)
|
|
59
|
+
print("MCP Tools vs Skills — Token Comparison")
|
|
60
|
+
print("=" * 60)
|
|
61
|
+
|
|
62
|
+
mcp_payloads = {}
|
|
63
|
+
skill_payloads = {}
|
|
64
|
+
|
|
65
|
+
for name, cfg in SERVERS.items():
|
|
66
|
+
print(f"\n--- {name} ---")
|
|
67
|
+
|
|
68
|
+
mcp_json = get_mcp_tools_payload(cfg["url"], cfg["token"])
|
|
69
|
+
mcp_payloads[name] = mcp_json
|
|
70
|
+
|
|
71
|
+
with open(cfg["skill"]) as f:
|
|
72
|
+
skill_md = f.read()
|
|
73
|
+
skill_payloads[name] = skill_md
|
|
74
|
+
|
|
75
|
+
mcp_tokens = count_tokens(mcp_json)
|
|
76
|
+
skill_tokens = count_tokens(skill_md)
|
|
77
|
+
saved = mcp_tokens - skill_tokens
|
|
78
|
+
pct = (saved / mcp_tokens * 100) if mcp_tokens else 0
|
|
79
|
+
|
|
80
|
+
print(f" MCP JSON schema tokens : {mcp_tokens:>6}")
|
|
81
|
+
print(f" Skill markdown tokens : {skill_tokens:>6}")
|
|
82
|
+
print(f" Savings : {saved:>6} ({pct:.1f}%)")
|
|
83
|
+
|
|
84
|
+
print("\n" + "=" * 60)
|
|
85
|
+
print("Multi-server scenario (all servers active)")
|
|
86
|
+
print("=" * 60)
|
|
87
|
+
|
|
88
|
+
all_mcp = "\n".join(mcp_payloads.values())
|
|
89
|
+
all_mcp_tokens = count_tokens(all_mcp)
|
|
90
|
+
|
|
91
|
+
print(f"\n Traditional MCP (all tools every turn) : {all_mcp_tokens:>6} tokens")
|
|
92
|
+
|
|
93
|
+
for name, skill_md in skill_payloads.items():
|
|
94
|
+
t = count_tokens(skill_md)
|
|
95
|
+
saved = all_mcp_tokens - t
|
|
96
|
+
pct = (saved / all_mcp_tokens * 100) if all_mcp_tokens else 0
|
|
97
|
+
print(f" Skill '{name}' only : {t:>6} tokens (saves {saved}, {pct:.1f}%)")
|
|
98
|
+
|
|
99
|
+
all_skills = "\n".join(skill_payloads.values())
|
|
100
|
+
all_skills_tokens = count_tokens(all_skills)
|
|
101
|
+
saved = all_mcp_tokens - all_skills_tokens
|
|
102
|
+
pct = (saved / all_mcp_tokens * 100) if all_mcp_tokens else 0
|
|
103
|
+
print(f" All skills loaded (worst case) : {all_skills_tokens:>6} tokens (saves {saved}, {pct:.1f}%)")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
if __name__ == "__main__":
|
|
107
|
+
main()
|