muscle-memory-mcp 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.
- muscle_memory_mcp-0.1.0/.gitignore +4 -0
- muscle_memory_mcp-0.1.0/LICENSE +21 -0
- muscle_memory_mcp-0.1.0/PKG-INFO +134 -0
- muscle_memory_mcp-0.1.0/README.md +116 -0
- muscle_memory_mcp-0.1.0/pyproject.toml +38 -0
- muscle_memory_mcp-0.1.0/server.json +20 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/__init__.py +3 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/__main__.py +5 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/cli.py +138 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/core.py +158 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/index.py +47 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/onboarding.py +159 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/paths.py +68 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/runners.py +142 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/server.py +203 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/store.py +376 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/testing.py +59 -0
- muscle_memory_mcp-0.1.0/src/muscle_memory_mcp/trust.py +40 -0
- muscle_memory_mcp-0.1.0/tests/test_cli.py +82 -0
- muscle_memory_mcp-0.1.0/tests/test_core.py +348 -0
- muscle_memory_mcp-0.1.0/tests/test_docs.py +152 -0
- muscle_memory_mcp-0.1.0/tests/test_e2e.py +146 -0
- muscle_memory_mcp-0.1.0/tests/test_hardening.py +535 -0
- muscle_memory_mcp-0.1.0/tests/test_libraries.py +227 -0
- muscle_memory_mcp-0.1.0/tests/test_onboarding.py +195 -0
- muscle_memory_mcp-0.1.0/tests/test_packaging.py +58 -0
- muscle_memory_mcp-0.1.0/tests/test_release_workflow.py +30 -0
- muscle_memory_mcp-0.1.0/tests/test_runners.py +53 -0
- muscle_memory_mcp-0.1.0/tests/test_server.py +206 -0
- muscle_memory_mcp-0.1.0/tests/test_store.py +312 -0
- muscle_memory_mcp-0.1.0/tests/test_testing.py +188 -0
- muscle_memory_mcp-0.1.0/tests/test_trust.py +177 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Johannes Loor
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: muscle-memory-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server that gives your agent a library of its own tested, reusable scripts.
|
|
5
|
+
Project-URL: Homepage, https://github.com/johannesloor/muscle-memory
|
|
6
|
+
Project-URL: Repository, https://github.com/johannesloor/muscle-memory
|
|
7
|
+
Project-URL: Issues, https://github.com/johannesloor/muscle-memory/issues
|
|
8
|
+
Author: Johannes Loor
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: ai-agents,automation,claude-code,codex,cursor,mcp,mcp-server,model-context-protocol
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+

|
|
20
|
+
|
|
21
|
+
# Muscle Memory MCP Server
|
|
22
|
+
|
|
23
|
+
[](https://pypi.org/project/muscle-memory-mcp/) [](https://github.com/johannesloor/muscle-memory/actions/workflows/ci.yml) [](LICENSE)
|
|
24
|
+
|
|
25
|
+
<!-- mcp-name: io.github.johannesloor/muscle-memory -->
|
|
26
|
+
|
|
27
|
+
Your agent solves problems by writing scripts, then throws them away like they're nothing. Tomorrow it writes the same script again. Like having to figure out how to tie your shoelaces every morning. It lacks muscle memory.
|
|
28
|
+
|
|
29
|
+
The Muscle Memory MCP server gives your agent an empty library that it fills with its own tested scripts.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
[](https://insiders.vscode.dev/redirect/mcp/install?name=muscle-memory&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22muscle-memory-mcp%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=muscle-memory&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22muscle-memory-mcp%22%5D%7D&quality=insiders) [](https://cursor.com/en/install-mcp?name=muscle-memory&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJtdXNjbGUtbWVtb3J5LW1jcCJdfQ%3D%3D) [](https://lmstudio.ai/install-mcp?name=muscle-memory&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJtdXNjbGUtbWVtb3J5LW1jcCJdfQ%3D%3D)
|
|
34
|
+
|
|
35
|
+
**Hermes**
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
hermes mcp add muscle-memory --command uvx --args muscle-memory-mcp
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
**Claude Code**
|
|
42
|
+
|
|
43
|
+
```sh
|
|
44
|
+
claude mcp add muscle-memory -s user -- uvx muscle-memory-mcp
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
To keep the library somewhere other than the default, set `MUSCLE_MEMORY_DIR` in the server's environment.
|
|
48
|
+
|
|
49
|
+
### Codex
|
|
50
|
+
|
|
51
|
+
Add to `~/.codex/config.toml`:
|
|
52
|
+
|
|
53
|
+
```toml
|
|
54
|
+
[mcp_servers.muscle-memory]
|
|
55
|
+
command = "uvx"
|
|
56
|
+
args = ["muscle-memory-mcp"]
|
|
57
|
+
# env = { MUSCLE_MEMORY_DIR = "/absolute/path/to/library" }
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Cursor
|
|
61
|
+
|
|
62
|
+
Add to `~/.cursor/mcp.json`:
|
|
63
|
+
|
|
64
|
+
```json
|
|
65
|
+
{
|
|
66
|
+
"mcpServers": {
|
|
67
|
+
"muscle-memory": {
|
|
68
|
+
"command": "uvx",
|
|
69
|
+
"args": ["muscle-memory-mcp"]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
## Add to your AGENTS.md
|
|
77
|
+
|
|
78
|
+
Add this standing rule to your `AGENTS.md`, `CLAUDE.md`, `SOUL.md` or equivalent agent instructions:
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
Before writing helper code, check muscle-memory with mm_list and run a match with mm_run.
|
|
82
|
+
After giving the user the result, save reusable new helpers with mm_save.
|
|
83
|
+
Skip this for code that is itself the deliverable.
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Tool descriptions contain the safety and testing specifics for each operation.
|
|
87
|
+
|
|
88
|
+
## First run and storage
|
|
89
|
+
|
|
90
|
+
The server exposes `mm_list`, `mm_run`, `mm_save` and `mm_setup`. If the personal library has not been set up, the tools guide the agent to ask you once where it should live and then call `mm_setup`.
|
|
91
|
+
|
|
92
|
+
The personal library defaults to `~/.muscle-memory` (`%USERPROFILE%\.muscle-memory` on Windows); `MUSCLE_MEMORY_DIR` selects another location. Scripts in the personal library are available across projects.
|
|
93
|
+
|
|
94
|
+
An optional `.muscle-memory/` project library holds scripts specific to one codebase. Project-root detection depends on the MCP client's working directory. Set `MUSCLE_MEMORY_PROJECT_DIR` to the project's absolute path in the server environment to override detection explicitly. When a script name exists in both libraries, the project copy takes precedence; listings mark the personal copy as shadowed.
|
|
95
|
+
|
|
96
|
+
For personal libraries, saves write files locally if the folder is not in git. If the library is a git repo, each saved script is committed. Setup asks whether to also push scripts to the git remote automatically; run setup again to change your answer. Project libraries are never committed or pushed automatically: you commit them with your own work. On first project setup, the agent asks whether the library should be shared through git or kept private.
|
|
97
|
+
|
|
98
|
+
## Tools
|
|
99
|
+
|
|
100
|
+
- **`mm_list(area?)`** — list available areas and script counts, or list scripts in one area.
|
|
101
|
+
- **`mm_run(script, args)`** — run a saved script, passing through arguments.
|
|
102
|
+
- **`mm_save(name, code, test, area, usage, ...)`** — save or update a script after its test passes. Supports runner, test selection, and personal/project scope options; see the tool description for the full schema and safety behavior.
|
|
103
|
+
- **`mm_setup(target, ...)`** — set up a personal library or optional project library. Ask the user before setup; see the tool description for modes and options.
|
|
104
|
+
|
|
105
|
+
## Advanced: languages and tests
|
|
106
|
+
|
|
107
|
+
The runner is picked by file extension: Python, bash/zsh, Node, Ruby, Perl, PHP, Swift, Go, Lua, R, PowerShell, and `.cmd`/`.bat` on Windows. Other languages need an explicit runner argv when saved. Runtimes are never installed for you; a missing one gives an error naming the command.
|
|
108
|
+
|
|
109
|
+
By default a script's test uses the same extension and runner. `test_name` and `test_runner` allow independent test selection. Tests live under `tests/test_<stem>.<ext>`; selection metadata is stored beside the script. Tests receive `MM_SCRIPT` (absolute script path) and `MM_LIBRARY` (library root). Exit status 0 passes; failed tests prevent saving. Replacing a test extension removes the prior test only after the replacement passes.
|
|
110
|
+
|
|
111
|
+
## Advanced: CLI
|
|
112
|
+
|
|
113
|
+
The same command is also a CLI:
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
uvx muscle-memory-mcp list [AREA]
|
|
117
|
+
uvx muscle-memory-mcp run SCRIPT [ARGS...]
|
|
118
|
+
uvx muscle-memory-mcp save NAME --code FILE --test FILE --area AREA --usage TEXT
|
|
119
|
+
uvx muscle-memory-mcp setup [--target personal|project] [OPTIONS]
|
|
120
|
+
uvx muscle-memory-mcp test [NAME]
|
|
121
|
+
uvx muscle-memory-mcp trust --project
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
With no arguments it starts the MCP server over stdio. `--help` lists the options.
|
|
125
|
+
|
|
126
|
+
## Security and limits
|
|
127
|
+
|
|
128
|
+
- Scripts and tests run as you, with your environment and no sandbox. Passing tests is not a security review.
|
|
129
|
+
- A project library runs only after you approve the project once with `uvx muscle-memory-mcp trust --project`.
|
|
130
|
+
- Project-library detection depends on the client's working directory; set `MUSCLE_MEMORY_PROJECT_DIR` if needed.
|
|
131
|
+
|
|
132
|
+
## License
|
|
133
|
+
|
|
134
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
3
|
+
# Muscle Memory MCP Server
|
|
4
|
+
|
|
5
|
+
[](https://pypi.org/project/muscle-memory-mcp/) [](https://github.com/johannesloor/muscle-memory/actions/workflows/ci.yml) [](LICENSE)
|
|
6
|
+
|
|
7
|
+
<!-- mcp-name: io.github.johannesloor/muscle-memory -->
|
|
8
|
+
|
|
9
|
+
Your agent solves problems by writing scripts, then throws them away like they're nothing. Tomorrow it writes the same script again. Like having to figure out how to tie your shoelaces every morning. It lacks muscle memory.
|
|
10
|
+
|
|
11
|
+
The Muscle Memory MCP server gives your agent an empty library that it fills with its own tested scripts.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
[](https://insiders.vscode.dev/redirect/mcp/install?name=muscle-memory&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22muscle-memory-mcp%22%5D%7D) [](https://insiders.vscode.dev/redirect/mcp/install?name=muscle-memory&config=%7B%22command%22%3A%22uvx%22%2C%22args%22%3A%5B%22muscle-memory-mcp%22%5D%7D&quality=insiders) [](https://cursor.com/en/install-mcp?name=muscle-memory&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJtdXNjbGUtbWVtb3J5LW1jcCJdfQ%3D%3D) [](https://lmstudio.ai/install-mcp?name=muscle-memory&config=eyJjb21tYW5kIjoidXZ4IiwiYXJncyI6WyJtdXNjbGUtbWVtb3J5LW1jcCJdfQ%3D%3D)
|
|
16
|
+
|
|
17
|
+
**Hermes**
|
|
18
|
+
|
|
19
|
+
```sh
|
|
20
|
+
hermes mcp add muscle-memory --command uvx --args muscle-memory-mcp
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
**Claude Code**
|
|
24
|
+
|
|
25
|
+
```sh
|
|
26
|
+
claude mcp add muscle-memory -s user -- uvx muscle-memory-mcp
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
To keep the library somewhere other than the default, set `MUSCLE_MEMORY_DIR` in the server's environment.
|
|
30
|
+
|
|
31
|
+
### Codex
|
|
32
|
+
|
|
33
|
+
Add to `~/.codex/config.toml`:
|
|
34
|
+
|
|
35
|
+
```toml
|
|
36
|
+
[mcp_servers.muscle-memory]
|
|
37
|
+
command = "uvx"
|
|
38
|
+
args = ["muscle-memory-mcp"]
|
|
39
|
+
# env = { MUSCLE_MEMORY_DIR = "/absolute/path/to/library" }
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
### Cursor
|
|
43
|
+
|
|
44
|
+
Add to `~/.cursor/mcp.json`:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{
|
|
48
|
+
"mcpServers": {
|
|
49
|
+
"muscle-memory": {
|
|
50
|
+
"command": "uvx",
|
|
51
|
+
"args": ["muscle-memory-mcp"]
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
## Add to your AGENTS.md
|
|
59
|
+
|
|
60
|
+
Add this standing rule to your `AGENTS.md`, `CLAUDE.md`, `SOUL.md` or equivalent agent instructions:
|
|
61
|
+
|
|
62
|
+
```text
|
|
63
|
+
Before writing helper code, check muscle-memory with mm_list and run a match with mm_run.
|
|
64
|
+
After giving the user the result, save reusable new helpers with mm_save.
|
|
65
|
+
Skip this for code that is itself the deliverable.
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Tool descriptions contain the safety and testing specifics for each operation.
|
|
69
|
+
|
|
70
|
+
## First run and storage
|
|
71
|
+
|
|
72
|
+
The server exposes `mm_list`, `mm_run`, `mm_save` and `mm_setup`. If the personal library has not been set up, the tools guide the agent to ask you once where it should live and then call `mm_setup`.
|
|
73
|
+
|
|
74
|
+
The personal library defaults to `~/.muscle-memory` (`%USERPROFILE%\.muscle-memory` on Windows); `MUSCLE_MEMORY_DIR` selects another location. Scripts in the personal library are available across projects.
|
|
75
|
+
|
|
76
|
+
An optional `.muscle-memory/` project library holds scripts specific to one codebase. Project-root detection depends on the MCP client's working directory. Set `MUSCLE_MEMORY_PROJECT_DIR` to the project's absolute path in the server environment to override detection explicitly. When a script name exists in both libraries, the project copy takes precedence; listings mark the personal copy as shadowed.
|
|
77
|
+
|
|
78
|
+
For personal libraries, saves write files locally if the folder is not in git. If the library is a git repo, each saved script is committed. Setup asks whether to also push scripts to the git remote automatically; run setup again to change your answer. Project libraries are never committed or pushed automatically: you commit them with your own work. On first project setup, the agent asks whether the library should be shared through git or kept private.
|
|
79
|
+
|
|
80
|
+
## Tools
|
|
81
|
+
|
|
82
|
+
- **`mm_list(area?)`** — list available areas and script counts, or list scripts in one area.
|
|
83
|
+
- **`mm_run(script, args)`** — run a saved script, passing through arguments.
|
|
84
|
+
- **`mm_save(name, code, test, area, usage, ...)`** — save or update a script after its test passes. Supports runner, test selection, and personal/project scope options; see the tool description for the full schema and safety behavior.
|
|
85
|
+
- **`mm_setup(target, ...)`** — set up a personal library or optional project library. Ask the user before setup; see the tool description for modes and options.
|
|
86
|
+
|
|
87
|
+
## Advanced: languages and tests
|
|
88
|
+
|
|
89
|
+
The runner is picked by file extension: Python, bash/zsh, Node, Ruby, Perl, PHP, Swift, Go, Lua, R, PowerShell, and `.cmd`/`.bat` on Windows. Other languages need an explicit runner argv when saved. Runtimes are never installed for you; a missing one gives an error naming the command.
|
|
90
|
+
|
|
91
|
+
By default a script's test uses the same extension and runner. `test_name` and `test_runner` allow independent test selection. Tests live under `tests/test_<stem>.<ext>`; selection metadata is stored beside the script. Tests receive `MM_SCRIPT` (absolute script path) and `MM_LIBRARY` (library root). Exit status 0 passes; failed tests prevent saving. Replacing a test extension removes the prior test only after the replacement passes.
|
|
92
|
+
|
|
93
|
+
## Advanced: CLI
|
|
94
|
+
|
|
95
|
+
The same command is also a CLI:
|
|
96
|
+
|
|
97
|
+
```sh
|
|
98
|
+
uvx muscle-memory-mcp list [AREA]
|
|
99
|
+
uvx muscle-memory-mcp run SCRIPT [ARGS...]
|
|
100
|
+
uvx muscle-memory-mcp save NAME --code FILE --test FILE --area AREA --usage TEXT
|
|
101
|
+
uvx muscle-memory-mcp setup [--target personal|project] [OPTIONS]
|
|
102
|
+
uvx muscle-memory-mcp test [NAME]
|
|
103
|
+
uvx muscle-memory-mcp trust --project
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
With no arguments it starts the MCP server over stdio. `--help` lists the options.
|
|
107
|
+
|
|
108
|
+
## Security and limits
|
|
109
|
+
|
|
110
|
+
- Scripts and tests run as you, with your environment and no sandbox. Passing tests is not a security review.
|
|
111
|
+
- A project library runs only after you approve the project once with `uvx muscle-memory-mcp trust --project`.
|
|
112
|
+
- Project-library detection depends on the client's working directory; set `MUSCLE_MEMORY_PROJECT_DIR` if needed.
|
|
113
|
+
|
|
114
|
+
## License
|
|
115
|
+
|
|
116
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "muscle-memory-mcp"
|
|
7
|
+
dynamic = ["version"]
|
|
8
|
+
description = "MCP server that gives your agent a library of its own tested, reusable scripts."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{ name = "Johannes Loor" }]
|
|
13
|
+
dependencies = []
|
|
14
|
+
keywords = ["mcp", "mcp-server", "model-context-protocol", "ai-agents", "claude-code", "codex",
|
|
15
|
+
"cursor", "automation"]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Topic :: Software Development",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://github.com/johannesloor/muscle-memory"
|
|
25
|
+
Repository = "https://github.com/johannesloor/muscle-memory"
|
|
26
|
+
Issues = "https://github.com/johannesloor/muscle-memory/issues"
|
|
27
|
+
|
|
28
|
+
[project.scripts]
|
|
29
|
+
muscle-memory-mcp = "muscle_memory_mcp.cli:main"
|
|
30
|
+
|
|
31
|
+
[tool.hatch.version]
|
|
32
|
+
path = "src/muscle_memory_mcp/__init__.py"
|
|
33
|
+
|
|
34
|
+
[tool.hatch.build.targets.wheel]
|
|
35
|
+
packages = ["src/muscle_memory_mcp"]
|
|
36
|
+
|
|
37
|
+
[tool.hatch.build.targets.sdist]
|
|
38
|
+
include = ["src", "tests", "README.md", "LICENSE", "pyproject.toml", "server.json"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://static.modelcontextprotocol.io/schemas/2025-12-11/server.schema.json",
|
|
3
|
+
"name": "io.github.johannesloor/muscle-memory",
|
|
4
|
+
"description": "MCP server that gives your agent a library of its own tested, reusable scripts.",
|
|
5
|
+
"version": "0.1.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"url": "https://github.com/johannesloor/muscle-memory",
|
|
8
|
+
"source": "github"
|
|
9
|
+
},
|
|
10
|
+
"packages": [
|
|
11
|
+
{
|
|
12
|
+
"registryType": "pypi",
|
|
13
|
+
"identifier": "muscle-memory-mcp",
|
|
14
|
+
"version": "0.1.0",
|
|
15
|
+
"transport": {
|
|
16
|
+
"type": "stdio"
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Command-line interface for muscle-memory-mcp.
|
|
2
|
+
|
|
3
|
+
muscle-memory-mcp run the MCP server (stdio)
|
|
4
|
+
muscle-memory-mcp list [AREA] without AREA: all areas with script counts; with AREA: its scripts
|
|
5
|
+
muscle-memory-mcp run SCRIPT [ARGS...] run a library script, passing ARGS through
|
|
6
|
+
muscle-memory-mcp save NAME --code F --test F --area AREA --usage TEXT [--new-area] [--no-push]
|
|
7
|
+
add a script + test, run the suite, index, commit
|
|
8
|
+
muscle-memory-mcp setup [--target personal|project] [options] set up a library
|
|
9
|
+
muscle-memory-mcp test [NAME] without NAME: run every script's own test; with NAME: just that one
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import core, onboarding, paths, server, testing
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv=None):
|
|
21
|
+
if argv is None:
|
|
22
|
+
argv = sys.argv[1:]
|
|
23
|
+
for stream in (sys.stdout, sys.stderr): # scripts may print any Unicode; never crash on a code page
|
|
24
|
+
if hasattr(stream, "reconfigure"):
|
|
25
|
+
stream.reconfigure(encoding="utf-8")
|
|
26
|
+
if not argv:
|
|
27
|
+
server.main()
|
|
28
|
+
return 0
|
|
29
|
+
ap = argparse.ArgumentParser(prog="muscle-memory-mcp", description=__doc__,
|
|
30
|
+
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
31
|
+
sub = ap.add_subparsers(dest="cmd", required=True)
|
|
32
|
+
s = sub.add_parser("list"); s.add_argument("area", nargs="*")
|
|
33
|
+
r = sub.add_parser("run"); r.add_argument("script"); r.add_argument("args", nargs=argparse.REMAINDER)
|
|
34
|
+
v = sub.add_parser("save"); v.add_argument("name")
|
|
35
|
+
v.add_argument("--code", required=True); v.add_argument("--test", required=True)
|
|
36
|
+
v.add_argument("--area", required=True); v.add_argument("--usage", required=True)
|
|
37
|
+
v.add_argument("--scope", choices=("personal", "project"), default="personal")
|
|
38
|
+
v.add_argument("--replace", action="store_true")
|
|
39
|
+
push_options = v.add_mutually_exclusive_group()
|
|
40
|
+
push_options.add_argument("--push", action="store_true", help="push this save even if setup chose not to")
|
|
41
|
+
push_options.add_argument("--no-push", action="store_true", help="don't push this save even if setup chose to")
|
|
42
|
+
v.add_argument("--new-area", action="store_true", help="allow creating AREA if it does not exist")
|
|
43
|
+
v.add_argument("--test-name")
|
|
44
|
+
v.add_argument("--test-runner", help="JSON argv for the test interpreter")
|
|
45
|
+
v.add_argument("--runner", help="JSON array argv for a custom interpreter, e.g. '[\"bash\"]' or "
|
|
46
|
+
"'[\"deno\", \"run\"]'; only needed for extensions with no built-in runner")
|
|
47
|
+
setup = sub.add_parser("setup")
|
|
48
|
+
setup.add_argument("--target", choices=("personal", "project"), default="personal")
|
|
49
|
+
setup.add_argument("--mode", choices=("local", "existing", "clone"))
|
|
50
|
+
setup.add_argument("--path"); setup.add_argument("--remote")
|
|
51
|
+
setup.add_argument("--git", action="store_true")
|
|
52
|
+
setup.add_argument("--shared", action=argparse.BooleanOptionalAction, default=None,
|
|
53
|
+
help="project library: --shared commits it with the project, --no-shared keeps it private")
|
|
54
|
+
setup.add_argument("--push", action=argparse.BooleanOptionalAction, default=None,
|
|
55
|
+
help="personal git library: push scripts to the git remote automatically")
|
|
56
|
+
t = sub.add_parser("test"); t.add_argument("name", nargs="?",
|
|
57
|
+
help="one script's file name; omit to test every saved script")
|
|
58
|
+
trust_cmd = sub.add_parser("trust", help="review and explicitly approve a project library")
|
|
59
|
+
trust_cmd.add_argument("--project", action="store_true", required=True)
|
|
60
|
+
a = ap.parse_args(argv)
|
|
61
|
+
try:
|
|
62
|
+
if a.cmd == "trust":
|
|
63
|
+
project = paths.project_root()
|
|
64
|
+
if project is None:
|
|
65
|
+
raise ValueError("no project library here")
|
|
66
|
+
root = (project / ".muscle-memory").resolve()
|
|
67
|
+
if not (root / "INDEX.md").is_file():
|
|
68
|
+
raise ValueError("no project library here")
|
|
69
|
+
from . import trust
|
|
70
|
+
trust.approve(root)
|
|
71
|
+
print(f"Trusted project library {root}.")
|
|
72
|
+
return 0
|
|
73
|
+
if a.cmd == "setup":
|
|
74
|
+
print(server.call("mm_setup", {"target": a.target, "mode": a.mode, "path": a.path,
|
|
75
|
+
"remote": a.remote, "git": a.git, "shared": a.shared,
|
|
76
|
+
"push": a.push})[0])
|
|
77
|
+
return 0
|
|
78
|
+
if a.cmd == "list":
|
|
79
|
+
text = core.list_areas() if not a.area else core.list_area(" ".join(a.area))
|
|
80
|
+
print(text)
|
|
81
|
+
return 3 if text == onboarding.NOT_SET_UP else 0
|
|
82
|
+
if a.cmd == "run":
|
|
83
|
+
code, out, err = core.run(a.script, a.args)
|
|
84
|
+
sys.stdout.write(out); sys.stderr.write(err)
|
|
85
|
+
return code
|
|
86
|
+
if a.cmd == "test":
|
|
87
|
+
if a.name:
|
|
88
|
+
lib, _ = core.resolve(a.name)
|
|
89
|
+
jobs = [(lib, a.name)]
|
|
90
|
+
else:
|
|
91
|
+
jobs = []
|
|
92
|
+
for lib in paths.libraries():
|
|
93
|
+
scripts_dir = lib.root / "scripts"
|
|
94
|
+
if not scripts_dir.exists():
|
|
95
|
+
continue
|
|
96
|
+
names = sorted(p.name for p in scripts_dir.iterdir()
|
|
97
|
+
if p.is_file() and not p.name.startswith("test_")
|
|
98
|
+
and not p.name.endswith(".runner.json") and not p.name.endswith(".test.json"))
|
|
99
|
+
jobs.extend((lib, n) for n in names)
|
|
100
|
+
passed, failed = 0, []
|
|
101
|
+
for lib, n in jobs:
|
|
102
|
+
ok, msg = testing.run_test(lib.root, n)
|
|
103
|
+
if ok:
|
|
104
|
+
passed += 1
|
|
105
|
+
else:
|
|
106
|
+
failed.append(n)
|
|
107
|
+
print(f"FAIL {n}\n{msg}")
|
|
108
|
+
print(f"{passed} passed" + (f", {len(failed)} failed" if failed else ""))
|
|
109
|
+
return 1 if failed else 0
|
|
110
|
+
runner = None
|
|
111
|
+
test_runner = None
|
|
112
|
+
for option, raw in (("--runner", a.runner), ("--test-runner", a.test_runner)):
|
|
113
|
+
if raw:
|
|
114
|
+
try:
|
|
115
|
+
parsed = json.loads(raw)
|
|
116
|
+
except json.JSONDecodeError as e:
|
|
117
|
+
raise ValueError(f"{option} must be a JSON array of strings: {e}")
|
|
118
|
+
if option == "--runner": runner = parsed
|
|
119
|
+
else: test_runner = parsed
|
|
120
|
+
ok, msg = core.save(a.name, Path(a.code).read_text(encoding="utf-8"),
|
|
121
|
+
Path(a.test).read_text(encoding="utf-8"), a.area, a.usage,
|
|
122
|
+
scope=a.scope, new_area=a.new_area, runner=runner,
|
|
123
|
+
test_name=a.test_name, test_runner=test_runner,
|
|
124
|
+
replace=a.replace, push=True if a.push else False if a.no_push else None)
|
|
125
|
+
print(msg)
|
|
126
|
+
if msg == onboarding.NOT_SET_UP:
|
|
127
|
+
return 3
|
|
128
|
+
return 0 if ok else 1
|
|
129
|
+
except (ValueError, OSError, subprocess.SubprocessError) as e:
|
|
130
|
+
if str(e) == onboarding.NOT_SET_UP:
|
|
131
|
+
print(onboarding.NOT_SET_UP)
|
|
132
|
+
return 3
|
|
133
|
+
print(f"error: {e}", file=sys.stderr)
|
|
134
|
+
return 2
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
if __name__ == "__main__":
|
|
138
|
+
sys.exit(main())
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""Core library: list, run and save scripts in the muscle-memory library.
|
|
2
|
+
|
|
3
|
+
Personal library dir: $MUSCLE_MEMORY_DIR or ~/.muscle-memory; optional project library at the
|
|
4
|
+
project root's .muscle-memory/ (see paths.py). Both have scripts/ and tests/. This module is
|
|
5
|
+
standard library only. Scripts may be in any language: common ones are picked up by file
|
|
6
|
+
extension (see runners.LANGUAGE_RUNNERS); anything else needs an explicit --runner argv.
|
|
7
|
+
Each script's test runs in the script's own language, via testing.run_test() (see testing.py).
|
|
8
|
+
|
|
9
|
+
list_areas()/list_area()/resolve() see both the personal and (if present) project library,
|
|
10
|
+
labelled by scope, with the project library taking precedence on a name clash. areas()/
|
|
11
|
+
area_lines()/format_areas() are lower-level, single-library helpers. Save behaviour lives in store.py;
|
|
12
|
+
core.save() is retained as a compatibility re-export.
|
|
13
|
+
"""
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from . import paths, runners
|
|
18
|
+
from .onboarding import NOT_SET_UP
|
|
19
|
+
from .index import add_index_line, parse_index # noqa: F401 (re-exported; moved from here)
|
|
20
|
+
|
|
21
|
+
def lib_dir():
|
|
22
|
+
return paths.personal_root()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _read_index_text(root):
|
|
26
|
+
"""root's INDEX.md as UTF-8, newline='' preserved (empty string if it doesn't exist).
|
|
27
|
+
|
|
28
|
+
Path.read_text() only gained a newline= parameter in Python 3.13; this module supports
|
|
29
|
+
Python >=3.10, so open() is used directly instead.
|
|
30
|
+
"""
|
|
31
|
+
p = Path(root) / "INDEX.md"
|
|
32
|
+
if not p.exists():
|
|
33
|
+
return ""
|
|
34
|
+
with open(p, encoding="utf-8", newline="") as f:
|
|
35
|
+
return f.read()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def read_index():
|
|
39
|
+
return _read_index_text(lib_dir())
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def areas(text=None):
|
|
43
|
+
"""[(area, script count)] in index order, for a single library's index text."""
|
|
44
|
+
counts = {}
|
|
45
|
+
for e in parse_index(read_index() if text is None else text):
|
|
46
|
+
counts[e["area"]] = counts.get(e["area"], 0) + 1
|
|
47
|
+
return list(counts.items())
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def area_lines(area, text=None):
|
|
51
|
+
"""All index lines in one area (case-insensitive name); ValueError lists the areas if unknown."""
|
|
52
|
+
text = read_index() if text is None else text
|
|
53
|
+
known = [a for a, _ in areas(text)]
|
|
54
|
+
match = next((a for a in known if a.lower() == area.strip().lower()), None)
|
|
55
|
+
if match is None:
|
|
56
|
+
raise ValueError(f"no area '{area}'. Areas: {', '.join(known) or '(library is empty)'}")
|
|
57
|
+
return match, [e["line"] for e in parse_index(text) if e["area"] == match]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def format_areas(pairs):
|
|
61
|
+
if not pairs:
|
|
62
|
+
return "The library is empty. Do the task, answer, then save a script if the step is reusable."
|
|
63
|
+
return "Areas (script count):\n" + "\n".join(f"- {a} ({n})" for a, n in pairs)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def list_areas():
|
|
67
|
+
"""Areas from every visible library (project first), each labelled with its scope."""
|
|
68
|
+
if not paths.personal_root().is_dir():
|
|
69
|
+
return NOT_SET_UP
|
|
70
|
+
triples = [] # (area, count, scope)
|
|
71
|
+
for lib in paths.libraries():
|
|
72
|
+
for area, count in areas(_read_index_text(lib.root)):
|
|
73
|
+
triples.append((area, count, lib.scope))
|
|
74
|
+
if not triples:
|
|
75
|
+
return "The library is empty. Do the task, answer, then save a script if the step is reusable."
|
|
76
|
+
lines = "\n".join(f"- {a} ({n}, {s})" for a, n, s in triples)
|
|
77
|
+
return "Areas (script count):\n" + lines
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def list_area(area):
|
|
81
|
+
"""One area's lines from every library that has it, each under its own '## Area (scope)'.
|
|
82
|
+
|
|
83
|
+
A personal line whose script also exists in the project library's scripts/ (so mm_run
|
|
84
|
+
would pick the project one, per resolve()'s precedence) is marked '(shadowed)'.
|
|
85
|
+
"""
|
|
86
|
+
if not paths.personal_root().is_dir():
|
|
87
|
+
return NOT_SET_UP
|
|
88
|
+
libs = paths.libraries()
|
|
89
|
+
project_lib = next((lib for lib in libs if lib.scope == "project"), None)
|
|
90
|
+
project_scripts = set()
|
|
91
|
+
if project_lib is not None and (project_lib.root / "scripts").is_dir():
|
|
92
|
+
project_scripts = {p.name for p in (project_lib.root / "scripts").iterdir() if p.is_file()}
|
|
93
|
+
|
|
94
|
+
sections, known = [], []
|
|
95
|
+
for lib in libs:
|
|
96
|
+
text = _read_index_text(lib.root)
|
|
97
|
+
lib_known = [a for a, _ in areas(text)]
|
|
98
|
+
known.extend(a for a in lib_known if a not in known)
|
|
99
|
+
match = next((a for a in lib_known if a.lower() == area.strip().lower()), None)
|
|
100
|
+
if match is None:
|
|
101
|
+
continue
|
|
102
|
+
lines = []
|
|
103
|
+
for e in parse_index(text):
|
|
104
|
+
if e["area"] != match:
|
|
105
|
+
continue
|
|
106
|
+
line = e["line"]
|
|
107
|
+
if lib.scope == "personal" and e["script"] in project_scripts:
|
|
108
|
+
line += " (shadowed)"
|
|
109
|
+
lines.append(line)
|
|
110
|
+
sections.append(f"## {match} ({lib.scope})\n" + "\n".join(lines))
|
|
111
|
+
if not sections:
|
|
112
|
+
raise ValueError(f"no area '{area}'. Areas: {', '.join(known) or '(library is empty)'}")
|
|
113
|
+
return "\n\n".join(sections)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve(script):
|
|
117
|
+
"""(Library, Path) for script, checked project-first then personal; same name => project wins."""
|
|
118
|
+
for lib in paths.libraries():
|
|
119
|
+
d = (lib.root / "scripts").resolve()
|
|
120
|
+
p = (d / script).resolve()
|
|
121
|
+
if (p.parent == d and p.is_file() and not p.name.startswith("test_")
|
|
122
|
+
and not p.name.endswith(".runner.json") and not p.name.endswith(".test.json")):
|
|
123
|
+
return lib, p
|
|
124
|
+
raise ValueError(f"not a library script: {script}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def run(script, args, timeout=300):
|
|
128
|
+
if not paths.personal_root().is_dir():
|
|
129
|
+
raise ValueError(NOT_SET_UP)
|
|
130
|
+
lib, p = resolve(script)
|
|
131
|
+
if lib.scope == "project":
|
|
132
|
+
from . import trust
|
|
133
|
+
trust.require(lib.root)
|
|
134
|
+
if p.suffix.lower() in runners.WINDOWS_ONLY_EXTENSIONS:
|
|
135
|
+
runners.check_cmd_args(p, ["cmd"], args) # before runner_for, which refuses .bat off Windows
|
|
136
|
+
argv = runners.runner_for(p)
|
|
137
|
+
runners.check_cmd_args(p, argv, args)
|
|
138
|
+
cmd = [*argv, str(p), *args]
|
|
139
|
+
try:
|
|
140
|
+
return runners.run_process(cmd, cwd=Path(os.getcwd()), env=None, timeout=timeout)
|
|
141
|
+
except FileNotFoundError as e:
|
|
142
|
+
raise ValueError(f"failed to launch '{argv[0]}' for {p.name}: {e}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def save(name, code, test, area, usage, *, scope="personal", new_area=False, runner=None,
|
|
146
|
+
test_name=None, test_runner=None, replace=False, push=None):
|
|
147
|
+
"""Compatibility wrapper; new callers should use store.save.
|
|
148
|
+
|
|
149
|
+
Returns NOT_SET_UP for any scope when the personal library folder is missing, matching
|
|
150
|
+
mm_list/mm_run/mm_save's MCP behaviour (Task 6 review, finding 4): a project-scope save
|
|
151
|
+
still needs the personal folder to exist, since that's what marks first-run setup done.
|
|
152
|
+
"""
|
|
153
|
+
from .store import save as store_save
|
|
154
|
+
if not paths.personal_root().is_dir():
|
|
155
|
+
return False, NOT_SET_UP
|
|
156
|
+
return store_save(name, code, test, area, usage, scope=scope, new_area=new_area,
|
|
157
|
+
runner=runner, test_name=test_name, test_runner=test_runner,
|
|
158
|
+
replace=replace, push=push)
|