# Directory Structure ``` ├── .gitignore ├── .python-version ├── docs │ ├── examples.md │ ├── readme.md │ ├── sys-mcp-load-artifact.png │ ├── sys-mcp-load-prompt.png │ ├── systems-mcp-artifact.png │ └── systems-mcp-prompt.png ├── LICENSE ├── main.py ├── pyproject.toml ├── README.md └── uv.lock ``` # Files -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- ``` 1 | 3.13 2 | ``` -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- ``` 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # UV 98 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | #uv.lock 102 | 103 | # poetry 104 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 105 | # This is especially recommended for binary packages to ensure reproducibility, and is more 106 | # commonly ignored for libraries. 107 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 108 | #poetry.lock 109 | 110 | # pdm 111 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 112 | #pdm.lock 113 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 114 | # in version control. 115 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 116 | .pdm.toml 117 | .pdm-python 118 | .pdm-build/ 119 | 120 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 121 | __pypackages__/ 122 | 123 | # Celery stuff 124 | celerybeat-schedule 125 | celerybeat.pid 126 | 127 | # SageMath parsed files 128 | *.sage.py 129 | 130 | # Environments 131 | .env 132 | .venv 133 | env/ 134 | venv/ 135 | ENV/ 136 | env.bak/ 137 | venv.bak/ 138 | 139 | # Spyder project settings 140 | .spyderproject 141 | .spyproject 142 | 143 | # Rope project settings 144 | .ropeproject 145 | 146 | # mkdocs documentation 147 | /site 148 | 149 | # mypy 150 | .mypy_cache/ 151 | .dmypy.json 152 | dmypy.json 153 | 154 | # Pyre type checker 155 | .pyre/ 156 | 157 | # pytype static type analyzer 158 | .pytype/ 159 | 160 | # Cython debug symbols 161 | cython_debug/ 162 | 163 | # PyCharm 164 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 165 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 166 | # and can be added to the global gitignore or merged into this file. For a more nuclear 167 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 168 | #.idea/ 169 | 170 | # Ruff stuff: 171 | .ruff_cache/ 172 | 173 | # PyPI configuration file 174 | .pypirc 175 | ``` -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- ```markdown 1 | # systems-mcp 2 | 3 | [systems-mcp](https://github.com/lethain/systems-mcp) is an MCP server for interacting with 4 | the [`lethain:systems`](https://github.com/lethain/systems/) library for systems modeling. 5 | 6 | It provides two tools: 7 | 8 | * `run_systems_model` runs the `systems` specification of a systems model. 9 | Takes two parameters, the specification and, optionally, the number of 10 | rounds to run the model (defaulting to 100). 11 | * `load_systems_documentation` loads documentation and examples into the context window. 12 | This is useful for priming models to be more helpful at writing systems models. 13 | 14 | It is intended for running locally in conjunction with Claude Desktop or a similar tool. 15 | 16 | ## Usage 17 | 18 | 19 | Here's an example of using `systems-mcp` to run and render a model. 20 | 21 |  22 | 23 | Here is the artifact generated from that prompt, including the output from 24 | running the systems model. 25 | 26 |  27 | 28 | Finally, here is an example of using the `load_systems_documentation` tool to prime 29 | the context window and using it to help generate a systems specification. 30 | This is loosely equivalent to including [`lethain:systems/README.md`](https://raw.githubusercontent.com/lethain/systems/refs/heads/master/README.md) in the context window, 31 | but also includes a handful of additional examples 32 | (see the included files in [./docs/](./docs/). 33 | 34 |  35 | 36 | Then you can render the model as before. 37 | 38 |  39 | 40 | The most interesting piece here is that I've never personally used `systems` to model a social network, 41 | but the LLM was able to do a remarkably decent job at generating a specification despite that. 42 | 43 | 44 | ## Installation 45 | 46 | These instructions describe installation for [Claude Desktop](https://claude.ai/download) on OS X. 47 | It should work similarly on other platforms. 48 | 49 | 1. Install [Claude Desktop](https://claude.ai/download). 50 | 2. Clone [systems-mcp](https://github.com/lethain/systems-mcp) into 51 | a convenient location, I'm assuming `/Users/will/systems-mcp` 52 | 3. Make sure you have `uv` installed, you can [follow these instructions](https://modelcontextprotocol.io/quickstart/server) 53 | 4. Go to Cladue Desktop, Setting, Developer, and have it create your MCP config file. 54 | Then you want to update your `claude_desktop_config.json`. 55 | (Note that you should replace `will` with your user, e.g. the output of `whoami`. 56 | 57 | cd ~/Library/Application\ Support/Claude/ 58 | vi claude_desktop_config.json 59 | 60 | Then add this section: 61 | 62 | { 63 | "mcpServers": { 64 | "systems": { 65 | "command": "uv", 66 | "args": [ 67 | "--directory", 68 | "/Users/will/systems-mcp", 69 | "run", 70 | "main.py" 71 | ] 72 | } 73 | } 74 | } 75 | 76 | 5. Close Claude and reopen it. 77 | 6. It should work... 78 | 79 | 80 | ``` -------------------------------------------------------------------------------- /docs/readme.md: -------------------------------------------------------------------------------- ```markdown 1 | 2 | # Systems 3 | 4 | `systems` is a set of tools for describing, running and visualizing 5 | [systems diagrams](https://lethain.com/systems-thinking/). 6 | 7 | 8 | Installation directions are below, and then get started by [working through the tutorial](./docs/tutorial.md) 9 | or reading through the [Jupyter notebook example](./notebooks/hiring.ipynb) example. 10 | 11 | For a more in-depth look at the system syntax, please read [the syntax specification](./docs/spec.md). 12 | 13 | ## Quickest start 14 | 15 | Follow the installation instructions below, then write a system definition 16 | such as: 17 | 18 | Start(10) 19 | Start > Middle @ 2 20 | Middle > End 21 | 22 | You can then evaluate your system (use `--csv` for an importable format): 23 | 24 | cat tmp.txt | systems-run -r 3 25 | 26 | Start Middle End 27 | 0 10 0 0 28 | 1 8 2 0 29 | 2 6 3 1 30 | 3 4 4 2 31 | 32 | See [the tutorial](./docs/tutorial.md) for more detailed starting information. 33 | 34 | ## Running in code 35 | 36 | It's also possible to write code to run your model, rather than rely on the command line tool. 37 | For example: 38 | 39 | from systems.parse import parse 40 | 41 | def results_for_spec(spec, rounds): 42 | model = parse(spec) 43 | results = model.run(rounds=rounds) 44 | return model, results 45 | 46 | spec = """Start(10) 47 | Start > Middle @ 2 48 | Middle > End""" 49 | 50 | model, results = results_for_spec(spec, 10) 51 | print(results) 52 | # outputs: [{'Start': 10, 'Middle': 0, 'End': 0}, {'Start': 8, 'Middle': 2, 'End': 0}, ...] 53 | 54 | This pattern is particularly useful when running from inside of a Jupyter Notebook, 55 | such as the examples in [`lethain/eng-strategy-models`](https://github.com/lethain/eng-strategy-models). 56 | 57 | 58 | ## Installation 59 | 60 | To install via PyPi: 61 | 62 | pip install systems 63 | 64 | To install for local development: 65 | 66 | git clone https://github.com/lethain/systems.git 67 | cd systems 68 | python3 -m venv ./env 69 | source ./env/bin/activate 70 | python setup.py develop 71 | 72 | Run tests via: 73 | 74 | python3 -m unittest tests/test_*.py 75 | 76 | Or run a single test via: 77 | 78 | python3 tests/test_parse.py TestParse.test_parse_complex_formula 79 | 80 | Please open an Github issue if you run into any problems! 81 | 82 | ## Jupyter notebooks 83 | 84 | Likely the easiest way to iterate on a model is within a Jupyter notebook. 85 | See an [example notebook here](./notebooks/hiring.ipynb). 86 | To install, follow the installation steps above, and followed by: 87 | 88 | # install graphviz 89 | brew install graphviz 90 | 91 | # install these additional python packages 92 | pip install jupyter pandas matplotlib 93 | 94 | 95 | 96 | ## Using the command line tools 97 | 98 | There are four command line tools that you'll use when creating and debugging 99 | systems/ 100 | 101 | `systems-run` is used to run models: 102 | 103 | $ cat examples/hiring.txt | systems-run -r 3 104 | PhoneScreens Onsites Offers Hires Employees Departures 105 | 0 0 0 0 0 5 0 106 | 1 25 0 0 0 5 0 107 | 2 25 12 0 0 5 0 108 | 3 25 12 6 0 5 0 109 | 110 | `systems-viz` is used to visualize models into [Graphviz](https://www.graphviz.org/): 111 | 112 | $ cat examples/hiring.txt | systems-viz 113 | // Parsed 114 | digraph { 115 | rankdir=LR 116 | 0 [label=Candidates] 117 | 1 [label=PhoneScreens] 118 | // etc, etc, some other stuff 119 | } 120 | 121 | Typically you'll pipe the output of `systems-viz` into `dot`, for example 122 | 123 | $ cat examples/hiring.txt | systems-viz | dot -Tpng -o tmp.png 124 | 125 | `systems-format` reads in a model, tokenizes it and formats the tokens 126 | into properly formatted results. This is similar to `gofmt`, and could 127 | be used for ensuring a consistent house formatting style for your diagrams. 128 | (It was primarily implemented to support generating human readable error 129 | messages instead of surfacing the tokens to humans when errors arise.) 130 | 131 | $ cat examples/hiring.txt | systems-fmt 132 | [Candidates] > PhoneScreens @ 25 133 | PhoneScreens > Onsites @ 0.5 134 | # etc etc 135 | 136 | `systems-lex` generates the tokens for a given system file. 137 | This is typically most useful when you're extending the lexer 138 | to support new types of functionality, but can also be useful 139 | for other kinds of debugging: 140 | 141 | $ cat examples/hiring.txt | systems-lex 142 | ('lines', 143 | [('line', 144 | 1, 145 | [('comment', '# wrap with [] to indicate an infinite stock that')]), 146 | ('line', 2, [('comment', "# isn't included in each table")]), 147 | ('line', 3, [('comment', '# integers are implicitly steady rates')]), 148 | ('line', 149 | 4, 150 | [('infinite_stock', 'Candidates', ('params', [])), 151 | ('flow_direction', '>'), 152 | ('stock', 'PhoneScreens', ('params', ())), 153 | ('flow_delimiter', '@'), 154 | ('flow', '', ('params', (('formula', [('whole', '25')]),)))]), 155 | ... 156 | ] 157 | ) 158 | 159 | 160 | ## Error messages 161 | 162 | The parser will do its best to give you a useful error message. 163 | For example, if you're missing delimiters: 164 | 165 | cat examples/no_delim.txt | systems-run 166 | line 1 is missing delimiter '>': "[a] < b @ 25" 167 | 168 | At worst, it will give you the line number and line that is 169 | creating an issue: 170 | 171 | cat examples/invalid_flow.txt | systems-run 172 | line 1 could not be parsed: "a > b @ 0..2" 173 | 174 | ## Uploading distribution 175 | 176 | If you are trying to install this on PyPi, the steps are roughly: 177 | 178 | python3 -m pip install --user --upgrade pip 179 | python3 -m pip install --user --upgrade wheel 180 | python3 -m pip install --user --upgrade twine 181 | python3 setup.py sdist bdist_wheel 182 | twine upload --repository-url https://upload.pypi.org/legacy/ dist/* 183 | 184 | That should more or less work. :) 185 | 186 | 187 | 188 | ## Syntax Specification 189 | 190 | The full the syntax specification is available in [./docs/spec.md](./docs/spec.md), 191 | and is replicated here to make this library easier to drive with an LLM. 192 | 193 | --- 194 | 195 | This file specifies the language used for describing systems in `systems`. 196 | There are three primary kinds of objects to specify: 197 | 198 | * `stocks` hold values, and 199 | * `flows` transition values from one stock to another. 200 | * finally, `formula` are used to describe initial and maximum values for stocks, 201 | and the magnitude of flows. 202 | 203 | 204 | ## Specifying stocks 205 | 206 | Stocks are specified on their own line, or implicitly in flow declarations: 207 | 208 | MyStock 209 | 210 | This would create a stock named `MyStock` with an initial value of zero and 211 | a maximum value of infinity: 212 | 213 | OtherStock(10) 214 | 215 | You can also specify maximum values: 216 | 217 | ThirdStock(0, 10) 218 | 219 | This would create `ThirdStock` with an initial value of zero, and a maximum value of ten. 220 | 221 | Going back to `OtherStock` for a moment, you can also use the special literal `inf` 222 | to explicitly specify its maximum value: 223 | 224 | OtherStock(10, inf) 225 | 226 | This is a more explicit way to specify a stock with an infinite maximum. 227 | Generally it's a strange indicator if you're using the `inf` literal directly, 228 | and instead you'd use the special syntax for infinite flows: 229 | 230 | [InfiniteFlow] 231 | 232 | This `InfiniteFlow` would have initial and maximum values of infinity. 233 | 234 | Without going too far into the details, initial and maximums can be specified using any 235 | legal formula, more on formulas below: 236 | 237 | Managers(2) 238 | Engineers(Managers * 4, Managers * 8) 239 | 240 | In many cases, though, you'll end up specifying your stocks inline in your 241 | flows, as opposed to doing them on their own lines, but the syntax 242 | is the same. 243 | 244 | ## Flows 245 | 246 | For example, this would have both `a` and `b` would initialize at zero, 247 | and both would have infinite maximum values, in addition there would be 248 | a flow of one unit per round from `a` to `b` (assuming that `a` is above zero): 249 | 250 | a > b @ 1 251 | 252 | In the above example, `a` has an initial value of zero, so it would never 253 | do anything. Most working systems address that problem by starting with 254 | an infinite stock: 255 | 256 | [a] > b @ 5 257 | b > [c] @ 3 258 | 259 | In the above, `a` and `c` would be infinite, and `b` would start 260 | with a value of zero. You can also solve the empty start problem 261 | by specifying non-zero initial values for your stocks: 262 | 263 | a(10) > b(3) @ 5 264 | b > c(12) @ 1 265 | c > a 266 | 267 | In this example, `a` is initialized at 10, `b` at 3, and `c` at 12. 268 | Note that you don't have to set the value at first reference. It is legal 269 | to initialize a value at a later definition of a stock, e.g. this is fine: 270 | 271 | a(1) > b @ 5 272 | b(2) > c @ 3 273 | c(3) > a @ 1 274 | 275 | However, it *is* illegal to initialize the same stock multiple times. 276 | 277 | a(1) > b(2) @ 1 278 | b(3) > a @ 1 279 | 280 | This will throw an error, because you can't initialize `b` twice with different values! 281 | 282 | ## Rates, Conversions and Leaks 283 | 284 | Each line specifies two nodes and the link between them. Links are described 285 | following the `@` character. The most common type of flow is a `rate`, which 286 | is a fixed transfer of values in one stock to another. 287 | 288 | For example, moving two units per round between `a` and `b`: 289 | 290 | # these are equivalent 291 | a > b @ 2 292 | a > b @ Rate(2) 293 | 294 | Up to two units will be transfered from `a` to `b` each round. 295 | 296 | Another common kind of flow is the `conversion` flow, which takes 297 | the entire contents of the source stock and multiplies that value 298 | against the conversion rate, adding the result to the next flow. 299 | 300 | # these are equivalent 301 | a(10) > b @ 0.5 302 | a(10) > b @ Conversion(0.5) 303 | 304 | The above would multiple `0.5` against `10` and move `5` units to `b`, 305 | with the other `5` units being lost to the conversion rate (e.g. disappearing). 306 | A common example of a conversion rate would be the offer acceptance rate 307 | in a [hiring funnel](https://lethain.com/hiring-funnel/). 308 | 309 | The third kind of flow is the `leak`, which combines properties of the 310 | `rate` and `conversion` flows. It moves a fixed percentage of the source 311 | flow into the destination flow, while leaving the remainder intact. 312 | 313 | a(10) > b @ Leak(0.2) 314 | 315 | Considering the difference between the `conversion` and `leak`, if the above 316 | were a `conversion`, then the value of `a` after one round would be `0`, but if it's 317 | a `leak`, then the value would be `8`. 318 | 319 | ## Formulas 320 | 321 | Any flow value, initial value and maximum value can be a formula: 322 | 323 | Recruiters(3) 324 | Engineers(Managers * 4, Managers * 8) 325 | [Candidates] > Engineers @ Recruiters * 6 326 | [Candidates] > Managers @ Recruiters * 3 327 | 328 | The above system shows that `Engineers` has an initial value of `Managers * 4`, 329 | a maximum value of `Managers * 8` and then shows that both `Engineers` and `Managers` 330 | grow at multiples of the value of the `Recruiters` stock. 331 | 332 | This is also a good example of using the `Recruiters` stock as 333 | a variable, as it doesn't' actually change over time. ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- ```toml 1 | [project] 2 | name = "systems-mcp" 3 | version = "0.1.0" 4 | description = "Add your description here" 5 | readme = "README.md" 6 | requires-python = ">=3.13" 7 | dependencies = [ 8 | "httpx>=0.28.1", 9 | "mcp[cli]>=1.8.0", 10 | "systems>=0.1.0", 11 | ] 12 | ``` -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- ```markdown 1 | # Example 1: Basic Stock Flow" 2 | # A simple stock and flow example" 3 | 4 | Start(100) 5 | Start > Middle @ 10 6 | Middle > End @ 5""" 7 | 8 | 9 | # Example 2: Hiring Pipeline 10 | # A model of a company hiring pipeline", 11 | 12 | [Candidates] > PhoneScreens @ 25 13 | PhoneScreens > Onsites @ Conversion(0.5) 14 | Onsites > Offers @ Conversion(0.5) 15 | Offers > Hires @ Conversion(0.7) 16 | Hires > Employees @ Conversion(0.9) 17 | Employees(5) 18 | Employees > Departures @ Leak(0.05) 19 | 20 | 21 | # Example 3: Customer Acquisition and Churn 22 | # Model of customer acquisition and retention" 23 | 24 | [PotentialCustomers] > EngagedCustomers @ 100 25 | # Initial Integration Flow 26 | EngagedCustomers > IntegratedCustomers @ Leak(0.5) 27 | # Baseline Churn Flow 28 | IntegratedCustomers > ChurnedCustomers @ Leak(0.1) 29 | # Experience Deprecation Flow 30 | IntegratedCustomers > DeprecationImpactedCustomers @ Leak(0.5) 31 | # Reintegrated Flow 32 | DeprecationImpactedCustomers > IntegratedCustomers @ Leak(0.9) 33 | # Deprecation-Influenced Churn 34 | DeprecationImpactedCustomers > ChurnedCustomers @ Leak(0.1)""" 35 | ``` -------------------------------------------------------------------------------- /main.py: -------------------------------------------------------------------------------- ```python 1 | import os.path 2 | import json 3 | import sys 4 | from typing import Any, Dict, List, Optional, Union 5 | from mcp.server.fastmcp import FastMCP 6 | 7 | # files to include in the call to `load_systems_documentation` 8 | DOCUMENTATION_FILES = ("./docs/readme.md", "./docs/examples.md") 9 | DOC_CACHE = None 10 | 11 | 12 | # Redirect debug prints to stderr 13 | def debug_print(*args, **kwargs): 14 | print(*args, file=sys.stderr, **kwargs) 15 | 16 | # Create MCP server 17 | mcp = FastMCP("systems_mcp") 18 | 19 | @mcp.tool() 20 | async def run_systems_model(spec: str, rounds: int = 100) -> str: 21 | """Run a systems model and return output of list of dictionaries in JSON. 22 | 23 | Args: 24 | spec: The systems model specification 25 | rounds: Number of rounds to run (default: 100) 26 | """ 27 | try: 28 | # Import here to avoid import errors if module is missing 29 | from systems.parse import parse 30 | 31 | debug_print(f"Running systems model for {rounds} rounds") 32 | 33 | # Parse the model and run it 34 | model = parse(spec) 35 | results = model.run(rounds=rounds) 36 | return json.dumps(results, indent=2, default=str) 37 | except Exception as e: 38 | debug_print(f"Error running systems model: {e}") 39 | return f"<div class='error'>Error running systems model: {str(e)}</div>" 40 | 41 | 42 | @mcp.tool() 43 | async def load_systems_documentation() -> str: 44 | """Load systems documentation, examples, and specification details to improve 45 | the models ability to generate specifications. 46 | 47 | Returns: 48 | Documentation and several examples of systems models 49 | """ 50 | global DOC_CACHE 51 | if DOC_CACHE is None: 52 | DOC_CACHE = "" 53 | for rel_file_path in DOCUMENTATION_FILES: 54 | with open(os.path.abspath(rel_file_path), 'r') as fin: 55 | DOC_CACHE += fin.read() + "\n\n" 56 | 57 | return f"Systems Documentation:\n\n {DOC_CACHE}" 58 | 59 | 60 | if __name__ == "__main__": 61 | debug_print("Starting systems-mcp server") 62 | mcp.run(transport='stdio') 63 | ```