munim 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.
- munim-0.1.0/.env.example +43 -0
- munim-0.1.0/.github/workflows/publish.yml +85 -0
- munim-0.1.0/.github/workflows/tests.yml +68 -0
- munim-0.1.0/.gitignore +16 -0
- munim-0.1.0/LICENSE +21 -0
- munim-0.1.0/PKG-INFO +433 -0
- munim-0.1.0/README.md +399 -0
- munim-0.1.0/brand/EULA.md +20 -0
- munim-0.1.0/brand/PRIVACY.md +35 -0
- munim-0.1.0/brand/logo.png +0 -0
- munim-0.1.0/brand/logo.svg +17 -0
- munim-0.1.0/demo/README.md +21 -0
- munim-0.1.0/demo/ivy-and-fern/index.html +91 -0
- munim-0.1.0/docs/DECISIONS.md +800 -0
- munim-0.1.0/docs/HACKATHON.md +292 -0
- munim-0.1.0/docs/SUBMISSION.md +177 -0
- munim-0.1.0/docs/VERCEL-INTEGRATION.md +99 -0
- munim-0.1.0/docs/VIDEO.md +207 -0
- munim-0.1.0/docs/posts/01-bedrock-aispl.md +90 -0
- munim-0.1.0/docs/posts/02-mcp-cannot-print.md +89 -0
- munim-0.1.0/docs/posts/03-deterministic-checks.md +79 -0
- munim-0.1.0/docs/stills/03-the-finding.jpg +0 -0
- munim-0.1.0/docs/stills/README.md +19 -0
- munim-0.1.0/docs/stills/feature-01-the-finding.png +0 -0
- munim-0.1.0/docs/superpowers/plans/2026-09-03-multi-client-mcp.md +579 -0
- munim-0.1.0/docs/superpowers/specs/2026-09-03-multi-client-mcp-design.md +283 -0
- munim-0.1.0/pyproject.toml +55 -0
- munim-0.1.0/scripts/cross_account_probe.py +162 -0
- munim-0.1.0/scripts/gate_strands.py +92 -0
- munim-0.1.0/scripts/probe_mcp_wrapper.py +157 -0
- munim-0.1.0/src/munim/__init__.py +0 -0
- munim-0.1.0/src/munim/adapters/__init__.py +3 -0
- munim-0.1.0/src/munim/adapters/cloudflare.py +173 -0
- munim-0.1.0/src/munim/adapters/resend.py +165 -0
- munim-0.1.0/src/munim/adapters/vercel.py +169 -0
- munim-0.1.0/src/munim/agent/__init__.py +0 -0
- munim-0.1.0/src/munim/agent/across.py +81 -0
- munim-0.1.0/src/munim/agent/launch.py +260 -0
- munim-0.1.0/src/munim/agent/mail.py +161 -0
- munim-0.1.0/src/munim/agent/mailplan.py +214 -0
- munim-0.1.0/src/munim/agent/model.py +43 -0
- munim-0.1.0/src/munim/agent/spf.py +67 -0
- munim-0.1.0/src/munim/agent/within.py +78 -0
- munim-0.1.0/src/munim/assets.py +115 -0
- munim-0.1.0/src/munim/checks/__init__.py +1 -0
- munim-0.1.0/src/munim/checks/dns.py +525 -0
- munim-0.1.0/src/munim/cli.py +758 -0
- munim-0.1.0/src/munim/connect/__init__.py +2 -0
- munim-0.1.0/src/munim/connect/callback.py +108 -0
- munim-0.1.0/src/munim/connect/oauth.py +238 -0
- munim-0.1.0/src/munim/connect/token.py +24 -0
- munim-0.1.0/src/munim/connected.py +69 -0
- munim-0.1.0/src/munim/container.py +150 -0
- munim-0.1.0/src/munim/doctor.py +191 -0
- munim-0.1.0/src/munim/env.py +23 -0
- munim-0.1.0/src/munim/migrate.py +47 -0
- munim-0.1.0/src/munim/registry.py +177 -0
- munim-0.1.0/src/munim/remote/__init__.py +4 -0
- munim-0.1.0/src/munim/remote/accounts.py +26 -0
- munim-0.1.0/src/munim/remote/discover.py +183 -0
- munim-0.1.0/src/munim/remote/identity.py +68 -0
- munim-0.1.0/src/munim/remote/servers.py +212 -0
- munim-0.1.0/src/munim/remote/session.py +301 -0
- munim-0.1.0/src/munim/remote/storage.py +164 -0
- munim-0.1.0/src/munim/remote/toolsets.py +104 -0
- munim-0.1.0/src/munim/report.py +140 -0
- munim-0.1.0/src/munim/room/__init__.py +0 -0
- munim-0.1.0/src/munim/room/server.py +215 -0
- munim-0.1.0/src/munim/room/static/index.html +333 -0
- munim-0.1.0/src/munim/room/static/reduce.mjs +104 -0
- munim-0.1.0/src/munim/runlog.py +131 -0
- munim-0.1.0/src/munim/server.py +416 -0
- munim-0.1.0/tests/conftest.py +37 -0
- munim-0.1.0/tests/room/reduce.test.mjs +52 -0
- munim-0.1.0/tests/test_agent_wiring.py +239 -0
- munim-0.1.0/tests/test_assets.py +102 -0
- munim-0.1.0/tests/test_catalogue.py +179 -0
- munim-0.1.0/tests/test_checks.py +241 -0
- munim-0.1.0/tests/test_cloudflare.py +236 -0
- munim-0.1.0/tests/test_concurrency.py +70 -0
- munim-0.1.0/tests/test_connect.py +235 -0
- munim-0.1.0/tests/test_connect_dedupe.py +94 -0
- munim-0.1.0/tests/test_connect_identity.py +142 -0
- munim-0.1.0/tests/test_connected.py +109 -0
- munim-0.1.0/tests/test_discover.py +116 -0
- munim-0.1.0/tests/test_doctor.py +115 -0
- munim-0.1.0/tests/test_isolation.py +102 -0
- munim-0.1.0/tests/test_mail.py +183 -0
- munim-0.1.0/tests/test_mailplan.py +89 -0
- munim-0.1.0/tests/test_merge.py +95 -0
- munim-0.1.0/tests/test_registry.py +173 -0
- munim-0.1.0/tests/test_remote_flow.py +187 -0
- munim-0.1.0/tests/test_remote_storage.py +307 -0
- munim-0.1.0/tests/test_rename.py +81 -0
- munim-0.1.0/tests/test_report.py +117 -0
- munim-0.1.0/tests/test_resend.py +105 -0
- munim-0.1.0/tests/test_room_args.py +63 -0
- munim-0.1.0/tests/test_server.py +212 -0
- munim-0.1.0/tests/test_spf.py +72 -0
- munim-0.1.0/tests/test_toolsets.py +118 -0
- munim-0.1.0/tests/test_url_auth.py +182 -0
- munim-0.1.0/tests/test_vercel.py +125 -0
- munim-0.1.0/tests/test_which_client.py +70 -0
- munim-0.1.0/uv.lock +1587 -0
munim-0.1.0/.env.example
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Model host. Strands is the requirement; the host is a config line.
|
|
2
|
+
# Bedrock is preferred but is blocked on this account by an AISPL/Marketplace
|
|
3
|
+
# billing limitation (docs/DECISIONS.md D17), so Gemini is the working default.
|
|
4
|
+
|
|
5
|
+
GEMINI_API_KEY=
|
|
6
|
+
# Skip Bedrock and go straight to Gemini. Worth setting if you have AWS
|
|
7
|
+
# credentials on disk for an account without Bedrock access: the model is
|
|
8
|
+
# constructed successfully either way, so the problem only surfaces on the
|
|
9
|
+
# first call.
|
|
10
|
+
# MUNIM_PREFER=gemini
|
|
11
|
+
# MUNIM_GEMINI_MODEL=gemini-2.5-flash
|
|
12
|
+
|
|
13
|
+
# Used automatically if Bedrock access is restored:
|
|
14
|
+
# AWS_PROFILE=hackathons
|
|
15
|
+
# AWS_REGION=us-east-1
|
|
16
|
+
# MUNIM_BEDROCK_MODEL=us.anthropic.claude-sonnet-4-5-20250929-v1:0
|
|
17
|
+
|
|
18
|
+
# Fallback:
|
|
19
|
+
# ANTHROPIC_API_KEY=
|
|
20
|
+
|
|
21
|
+
# OAuth apps. One registration per provider serves every client you connect:
|
|
22
|
+
# these identify Munim to the provider, not a client. Redirect URL is
|
|
23
|
+
# http://localhost:8976/oauth/callback for all of them. Without these,
|
|
24
|
+
# `munim connect` falls back to pasting an API key.
|
|
25
|
+
|
|
26
|
+
# VERCEL_OAUTH_CLIENT_ID=
|
|
27
|
+
# VERCEL_OAUTH_CLIENT_SECRET=
|
|
28
|
+
# CLOUDFLARE_OAUTH_CLIENT_ID=
|
|
29
|
+
# CLOUDFLARE_OAUTH_CLIENT_SECRET=
|
|
30
|
+
# SUPABASE_OAUTH_CLIENT_ID=
|
|
31
|
+
# SUPABASE_OAUTH_CLIENT_SECRET=
|
|
32
|
+
|
|
33
|
+
# Providers whose authorization server will not register a client on demand.
|
|
34
|
+
# Every Google MCP server is one: accounts.google.com advertises no
|
|
35
|
+
# registration endpoint and requires a secret, so an application has to be
|
|
36
|
+
# registered by hand at https://console.cloud.google.com/apis/credentials.
|
|
37
|
+
# Choose the "Desktop app" type: its redirect is http://localhost and Google
|
|
38
|
+
# documents its secret as not confidential, which is how every CLI ships one.
|
|
39
|
+
#
|
|
40
|
+
# GMAIL_OAUTH_CLIENT_ID=
|
|
41
|
+
# GMAIL_OAUTH_CLIENT_SECRET=
|
|
42
|
+
# STITCH_OAUTH_CLIENT_ID=
|
|
43
|
+
# STITCH_OAUTH_CLIENT_SECRET=
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
name: publish
|
|
2
|
+
|
|
3
|
+
# Trusted publishing: PyPI verifies this workflow's identity through GitHub, so
|
|
4
|
+
# no token exists to leak. It does nothing until a pending publisher is added on
|
|
5
|
+
# PyPI for this repository, this filename and the `pypi` environment.
|
|
6
|
+
#
|
|
7
|
+
# Fires on a tag, not on every push. A version on PyPI can never be reused or
|
|
8
|
+
# withdrawn, so publishing has to be a thing somebody decided rather than a
|
|
9
|
+
# thing that happened.
|
|
10
|
+
on:
|
|
11
|
+
push:
|
|
12
|
+
tags: ["v*"]
|
|
13
|
+
workflow_dispatch:
|
|
14
|
+
|
|
15
|
+
jobs:
|
|
16
|
+
# The tests run again here rather than trusting the run on the commit. A tag
|
|
17
|
+
# can point anywhere, including at something that never passed.
|
|
18
|
+
verify:
|
|
19
|
+
runs-on: ubuntu-latest
|
|
20
|
+
steps:
|
|
21
|
+
- uses: actions/checkout@v7
|
|
22
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
23
|
+
- run: uv sync --extra dev
|
|
24
|
+
- run: uv run pytest -q
|
|
25
|
+
|
|
26
|
+
# A tag that disagrees with pyproject publishes the wrong version number,
|
|
27
|
+
# and PyPI will not let it be republished under the right one. Cheaper to
|
|
28
|
+
# catch here than to burn a version.
|
|
29
|
+
- name: The tag matches the version being published
|
|
30
|
+
run: |
|
|
31
|
+
tagged="${GITHUB_REF_NAME#v}"
|
|
32
|
+
declared=$(uv run python -c "
|
|
33
|
+
import pathlib, sys
|
|
34
|
+
text = pathlib.Path('pyproject.toml').read_text()
|
|
35
|
+
try:
|
|
36
|
+
import tomllib # stdlib from 3.11; this package allows 3.10
|
|
37
|
+
print(tomllib.loads(text)['project']['version'])
|
|
38
|
+
except ModuleNotFoundError:
|
|
39
|
+
import re
|
|
40
|
+
print(re.search(r'^version = \"([^\"]+)\"', text, re.M).group(1))
|
|
41
|
+
")
|
|
42
|
+
echo "tag=$tagged pyproject=$declared"
|
|
43
|
+
test "$tagged" = "$declared" || {
|
|
44
|
+
echo "::error::tag v$tagged does not match pyproject version $declared"
|
|
45
|
+
exit 1
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
build:
|
|
49
|
+
needs: verify
|
|
50
|
+
runs-on: ubuntu-latest
|
|
51
|
+
steps:
|
|
52
|
+
- uses: actions/checkout@v7
|
|
53
|
+
- uses: astral-sh/setup-uv@v10.0.1
|
|
54
|
+
- run: uv build
|
|
55
|
+
|
|
56
|
+
# The control room ships inside the wheel so an install needs no npm. A
|
|
57
|
+
# wheel without it installs and then fails at the first `munim-room`,
|
|
58
|
+
# which is a bad way to find out.
|
|
59
|
+
- name: The wheel carries the built control room
|
|
60
|
+
run: |
|
|
61
|
+
uv run python -c "
|
|
62
|
+
import glob, zipfile, sys
|
|
63
|
+
names = zipfile.ZipFile(glob.glob('dist/*.whl')[0]).namelist()
|
|
64
|
+
static = [n for n in names if 'room/static' in n]
|
|
65
|
+
print(f'{len(static)} control room files in the wheel')
|
|
66
|
+
sys.exit(0 if static else 'the wheel has no control room')
|
|
67
|
+
"
|
|
68
|
+
|
|
69
|
+
- uses: actions/upload-artifact@v7
|
|
70
|
+
with:
|
|
71
|
+
name: dist
|
|
72
|
+
path: dist/
|
|
73
|
+
|
|
74
|
+
publish:
|
|
75
|
+
needs: build
|
|
76
|
+
runs-on: ubuntu-latest
|
|
77
|
+
environment: pypi
|
|
78
|
+
permissions:
|
|
79
|
+
id-token: write # what trusted publishing uses instead of a token
|
|
80
|
+
steps:
|
|
81
|
+
- uses: actions/download-artifact@v8
|
|
82
|
+
with:
|
|
83
|
+
name: dist
|
|
84
|
+
path: dist/
|
|
85
|
+
- uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
name: tests
|
|
2
|
+
|
|
3
|
+
# Evidence, not ceremony. The repository claims a test suite and a check
|
|
4
|
+
# catalogue that runs against live DNS; a reader has no way to know either
|
|
5
|
+
# works without running it themselves, and a judge will not.
|
|
6
|
+
on:
|
|
7
|
+
push:
|
|
8
|
+
branches: [main]
|
|
9
|
+
pull_request:
|
|
10
|
+
workflow_dispatch:
|
|
11
|
+
|
|
12
|
+
# Pushing twice in a minute queued two full runs, and the room job spends most
|
|
13
|
+
# of its time installing node modules. A superseded run tells nobody anything.
|
|
14
|
+
concurrency:
|
|
15
|
+
group: ${{ github.workflow }}-${{ github.ref }}
|
|
16
|
+
cancel-in-progress: true
|
|
17
|
+
|
|
18
|
+
jobs:
|
|
19
|
+
python:
|
|
20
|
+
runs-on: ubuntu-latest
|
|
21
|
+
steps:
|
|
22
|
+
- uses: actions/checkout@v7
|
|
23
|
+
|
|
24
|
+
- name: Install uv
|
|
25
|
+
uses: astral-sh/setup-uv@v10.0.1
|
|
26
|
+
|
|
27
|
+
- name: Install
|
|
28
|
+
run: uv sync --extra dev
|
|
29
|
+
|
|
30
|
+
# No model host and no keychain here, which is the point: the suite must
|
|
31
|
+
# pass on a machine that has neither. Anything needing a credential is a
|
|
32
|
+
# thing a judge cannot reproduce.
|
|
33
|
+
- name: Tests
|
|
34
|
+
run: uv run pytest -q
|
|
35
|
+
|
|
36
|
+
# The catalogue is thirteen credential-free checks. Running two of them
|
|
37
|
+
# against real domains proves the claim rather than asserting it, and it
|
|
38
|
+
# is the one part of this project a stranger can verify in ten seconds.
|
|
39
|
+
- name: The check catalogue, against live DNS
|
|
40
|
+
run: |
|
|
41
|
+
uv run python -c "
|
|
42
|
+
import asyncio
|
|
43
|
+
from munim.checks.dns import run_all_async, run_reachability_async
|
|
44
|
+
|
|
45
|
+
async def main():
|
|
46
|
+
for domain in ('github.com', 'cloudflare.com'):
|
|
47
|
+
results = await run_all_async(domain)
|
|
48
|
+
results += await run_reachability_async(domain)
|
|
49
|
+
passed = sum(r.status == 'pass' for r in results)
|
|
50
|
+
print(f'{domain}: {passed} passed, {len(results)} checks')
|
|
51
|
+
assert len({r.check for r in results}) == 13, 'the catalogue is thirteen'
|
|
52
|
+
asyncio.run(main())
|
|
53
|
+
"
|
|
54
|
+
|
|
55
|
+
room:
|
|
56
|
+
runs-on: ubuntu-latest
|
|
57
|
+
steps:
|
|
58
|
+
- uses: actions/checkout@v7
|
|
59
|
+
- uses: actions/setup-node@v7
|
|
60
|
+
with:
|
|
61
|
+
node-version: '22'
|
|
62
|
+
# No npm install and no build step: the control room is one HTML page
|
|
63
|
+
# that imports one ES module, and the module is what these six tests
|
|
64
|
+
# exercise. This used to be `npm ci` plus a Vite build against 82MB of
|
|
65
|
+
# node_modules, on every push of a Python project, to catch a committed
|
|
66
|
+
# build going stale. There is no committed build now.
|
|
67
|
+
- name: The control room reducer
|
|
68
|
+
run: node --test "tests/room/*.test.mjs"
|
munim-0.1.0/.gitignore
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
.venv/
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.pyc
|
|
4
|
+
.env
|
|
5
|
+
.env.*
|
|
6
|
+
# ...but the template is the only record of which variables exist.
|
|
7
|
+
!.env.example
|
|
8
|
+
*.token
|
|
9
|
+
fixtures/real/
|
|
10
|
+
.pytest_cache/
|
|
11
|
+
.DS_Store
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
# Build artifacts. The wheel is made on demand and published from CI or by hand.
|
|
15
|
+
dist/
|
|
16
|
+
*.egg-info/
|
munim-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Vishal Gupta
|
|
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.
|
munim-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: munim
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: One MCP server holding a live session with every client's account at once.
|
|
5
|
+
Project-URL: Homepage, https://github.com/vishalsg42/munim
|
|
6
|
+
Project-URL: Repository, https://github.com/vishalsg42/munim
|
|
7
|
+
Project-URL: Issues, https://github.com/vishalsg42/munim/issues
|
|
8
|
+
Author: Vishal Gupta
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: cloudflare,dkim,dmarc,dns,mcp,model-context-protocol,multi-account,spf,strands,vercel
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: System Administrators
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Internet :: Name Service (DNS)
|
|
20
|
+
Classifier: Topic :: System :: Systems Administration
|
|
21
|
+
Requires-Python: >=3.10
|
|
22
|
+
Requires-Dist: dnspython>=2.7.0
|
|
23
|
+
Requires-Dist: httpx>=0.28.1
|
|
24
|
+
Requires-Dist: keyring>=25.7.0
|
|
25
|
+
Requires-Dist: mcp<2.0.0,>=1.23.0
|
|
26
|
+
Requires-Dist: pydantic>=2.4.0
|
|
27
|
+
Requires-Dist: python-dotenv>=1.0.0
|
|
28
|
+
Requires-Dist: strands-agents==1.54.0
|
|
29
|
+
Provides-Extra: dev
|
|
30
|
+
Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
|
|
31
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
32
|
+
Requires-Dist: respx>=0.22; extra == 'dev'
|
|
33
|
+
Description-Content-Type: text/markdown
|
|
34
|
+
|
|
35
|
+
# Munim
|
|
36
|
+
|
|
37
|
+
**One MCP server holding a live session with every client's account at once.**
|
|
38
|
+
|
|
39
|
+
A coding agent can be logged in to one Cloudflare account. One Vercel. One Resend. Connect
|
|
40
|
+
a second client and the first one goes away. So the person looking after a dozen small
|
|
41
|
+
businesses runs a dozen agent sessions, and no single one of them can answer a question
|
|
42
|
+
about more than one client.
|
|
43
|
+
|
|
44
|
+
Munim holds them all. Each client gets its own registration with the provider, its own
|
|
45
|
+
token and its own namespace in the tool list, so one agent can read across every client
|
|
46
|
+
and write inside the one you named.
|
|
47
|
+
|
|
48
|
+
```
|
|
49
|
+
Kloudfirst -> Kloudfirst@gmail.com's Account (3 tools)
|
|
50
|
+
Balaji Roofings -> Tech.bajajiroofing@gmail.com's Account (3 tools)
|
|
51
|
+
|
|
52
|
+
both sessions opened concurrently, one process, no logout
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
That is a real run against two real Cloudflare accounts, not a diagram. Reproduce it with
|
|
56
|
+
your own two accounts: `scripts/cross_account_probe.py`.
|
|
57
|
+
|
|
58
|
+
### Why this is not just credential switching
|
|
59
|
+
|
|
60
|
+
The nearest prior work, [`mcpwarden`](https://github.com/ibhugeloo/mcpwarden), registers N
|
|
61
|
+
copies of a provider's MCP server in your coding agent, one per account, and its own
|
|
62
|
+
description calls them *"exclusive context profiles"*: one active at a time. That removes
|
|
63
|
+
the re-login and leaves the isolation. It cannot answer a question that spans two accounts,
|
|
64
|
+
because nothing sees across two entries in a tool list.
|
|
65
|
+
|
|
66
|
+
Isolation is the easy half. Twelve clients across four providers is 48 entries in your tool
|
|
67
|
+
list and still no vantage point. Munim is one entry holding 48 sessions, which is what
|
|
68
|
+
makes *"which of my clients has a domain expiring this quarter?"* a question you can ask.
|
|
69
|
+
|
|
70
|
+
Nothing is registered by hand. Cloudflare, Vercel and Resend each run their own MCP server
|
|
71
|
+
and each issues a client on demand, so connecting is a browser window and nothing else, and
|
|
72
|
+
there is no client secret anywhere in this repository.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
*A munim is the steward a business owner trusts to keep their books and handle their affairs
|
|
77
|
+
without being asked each time.*
|
|
78
|
+
|
|
79
|
+
---
|
|
80
|
+
|
|
81
|
+
## The problem
|
|
82
|
+
|
|
83
|
+
One person maintains the web and email setup of a dozen small businesses. The clients own
|
|
84
|
+
the Vercel, Cloudflare and Resend accounts and pay the bills; the operator holds delegated
|
|
85
|
+
access and does the work.
|
|
86
|
+
|
|
87
|
+
Every provider allows one login at a time. So the operator's workaround is a separate
|
|
88
|
+
coding-agent session per client. Isolation built out of browser tabs and discipline.
|
|
89
|
+
|
|
90
|
+
That costs three things:
|
|
91
|
+
|
|
92
|
+
1. **Switching.** Every action on a different client means re-authenticating somewhere.
|
|
93
|
+
2. **No vantage point.** *"Which clients have a domain expiring this quarter?"* cannot be
|
|
94
|
+
asked from anywhere, because no place can see all of them.
|
|
95
|
+
3. **Silent failure.** Standing up a client is a copy-paste dance between accounts, and one
|
|
96
|
+
of the handoffs fails invisibly.
|
|
97
|
+
|
|
98
|
+
That last one is the reason this exists. Resend emits DKIM and SPF records that must be
|
|
99
|
+
written into Cloudflare. Get the A record wrong and the site does not load, and you find out
|
|
100
|
+
in minutes. **Get the SPF record wrong and nothing breaks**: the client's invoices quietly
|
|
101
|
+
stop arriving, and nobody notices for weeks.
|
|
102
|
+
|
|
103
|
+
## What it does
|
|
104
|
+
|
|
105
|
+
Adds one MCP server to whatever coding agent you use. Each client becomes a **container**:
|
|
106
|
+
its own registration with the provider, its own token, its own namespace in the tool list.
|
|
107
|
+
Nothing is registered by hand, because all three providers issue a client on demand.
|
|
108
|
+
|
|
109
|
+
- **Read across every client.** *"Whose domain expires this quarter?"*
|
|
110
|
+
- **Write only inside one you have named.** A mutation loads one client's credentials and
|
|
111
|
+
no others.
|
|
112
|
+
- **Check the things nobody checks.** Not because they are hard, but because running them
|
|
113
|
+
by hand on every launch for every client is not realistic. An agent does not get bored on
|
|
114
|
+
check eleven.
|
|
115
|
+
|
|
116
|
+
## Install
|
|
117
|
+
|
|
118
|
+
Requires Python 3.10+.
|
|
119
|
+
|
|
120
|
+
Once it is on PyPI, nothing is cloned:
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
uv tool install munim
|
|
124
|
+
claude mcp add munim -- munim-mcp
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
Until then, or to work on it:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
git clone https://github.com/vishalsg42/munim && cd munim
|
|
131
|
+
uv venv && uv pip install -e .
|
|
132
|
+
claude mcp add munim -- "$(pwd)/.venv/bin/munim-mcp"
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The control room ships pre-built inside the package, so neither path needs npm.
|
|
136
|
+
|
|
137
|
+
Set a model host in `.env` (see `.env.example`). Any Strands-supported provider works:
|
|
138
|
+
Amazon Bedrock, Gemini, Anthropic, OpenAI, Ollama.
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
```
|
|
142
|
+
GEMINI_API_KEY=...
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Connect a client. Nothing is registered by hand: Cloudflare, Vercel and Resend each
|
|
146
|
+
run their own MCP server, and each registers a client on demand, so a browser opens
|
|
147
|
+
and that is the whole setup. There is no application to create and no client secret
|
|
148
|
+
anywhere in this project. Leave the name out and the account you sign in to supplies
|
|
149
|
+
it, which is what keeps a name and an account from drifting apart:
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
munim connect cloudflare # browser login; the account names the client
|
|
153
|
+
munim connect "Balaji Roofings" zoho --url https://… # Zoho: the URL is the credential
|
|
154
|
+
munim connect "Balaji Roofings" vercel # or name it yourself
|
|
155
|
+
munim rename "<account name>" "Balaji Roofings"
|
|
156
|
+
munim merge "<account name>" "Balaji Roofings" # if they were added twice
|
|
157
|
+
munim forget "<client>" # only when it holds nothing
|
|
158
|
+
munim clients # what is connected
|
|
159
|
+
munim doctor # what is missing, and the fix
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
Then, in your coding agent:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
which of my clients has a domain expiring this quarter?
|
|
166
|
+
check ivyandfern.co.uk for Ivy & Fern Studio
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Open the control room to watch a run:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
uv run munim-room # http://127.0.0.1:8977
|
|
173
|
+
uv run munim-room --port 8986 # if 8977 is taken
|
|
174
|
+
uv run munim-room --runs DIR --reports DIR # serve a different set of runs
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## Any MCP server
|
|
178
|
+
|
|
179
|
+
Ten providers are built in, and only the first needed any code. The rest are
|
|
180
|
+
table entries: a URL and the answer probing gave when asked how the server wants
|
|
181
|
+
to be authenticated. That is what the design is for. A provider stops being work
|
|
182
|
+
and becomes a row.
|
|
183
|
+
|
|
184
|
+
| ready, nothing to set up | needs an application | needs a URL |
|
|
185
|
+
|---|---|---|
|
|
186
|
+
| Cloudflare, Vercel, Resend, Netlify, Linear, Notion, Sentry | Gmail, Stitch | Zoho |
|
|
187
|
+
|
|
188
|
+
They are not the product either. The product is a session per client against
|
|
189
|
+
something that speaks MCP, and there is no reason it has to be a server somebody
|
|
190
|
+
else chose.
|
|
191
|
+
|
|
192
|
+
```bash
|
|
193
|
+
munim add-server acme https://mcp.acme.example/mcp
|
|
194
|
+
munim servers
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`add-server` works out what the server needs by doing what a client does: calling
|
|
198
|
+
it without credentials and reading the challenge back. Probing changes nothing on
|
|
199
|
+
the server, and there are three answers it can give.
|
|
200
|
+
|
|
201
|
+
| | what it means | what you do |
|
|
202
|
+
|---|---|---|
|
|
203
|
+
| **registers** | issues a client on demand (RFC 7591) | nothing. `munim connect acme` opens a browser |
|
|
204
|
+
| **app** | no registration endpoint, wants a secret | register an application once at the provider, put its id and secret in `.env`. Munim refuses before opening a browser rather than failing at the consent screen |
|
|
205
|
+
| **url** | answered without credentials | either it is open, or the URL carries the credential. Pass it with `connect --url`; it goes to your keychain, never to a file here, and is never printed whole |
|
|
206
|
+
|
|
207
|
+
All three were found by probing real servers rather than reading about them.
|
|
208
|
+
Cloudflare, Vercel and Resend register on demand. Google's servers, which is
|
|
209
|
+
Gmail, Stitch, Drive and Calendar, all authenticate against `accounts.google.com`,
|
|
210
|
+
which advertises no registration endpoint and wants `client_secret_post`. Zoho
|
|
211
|
+
issues a per-installation endpoint whose path is the credential.
|
|
212
|
+
|
|
213
|
+
## The tools your agent gets
|
|
214
|
+
|
|
215
|
+
Eight, and this is the whole surface. Anything not listed here is not reachable, whatever
|
|
216
|
+
else is in the repository.
|
|
217
|
+
|
|
218
|
+
| Tool | |
|
|
219
|
+
|---|---|
|
|
220
|
+
| `list_clients` | every client and what each is connected to |
|
|
221
|
+
| `find_across_clients` | one deterministic question over all of them at once |
|
|
222
|
+
| `ask_across_clients` | one open question over all of them, using their own accounts, read-only |
|
|
223
|
+
| `audit_all_clients` | the whole catalogue against every client, silent when they all pass |
|
|
224
|
+
| `check` | the 13-check catalogue against a client or a bare domain |
|
|
225
|
+
| `client_status` | what is known about one client |
|
|
226
|
+
| `add_client` | register one |
|
|
227
|
+
| `connect_provider` | store a pasted key, for providers with nothing better |
|
|
228
|
+
| `launch_status` | read a run back |
|
|
229
|
+
| `work_on_client` | do something inside one client's accounts, using their own provider tools |
|
|
230
|
+
| `plan_mail_setup` | what setting up email for a client would change, touching no DNS |
|
|
231
|
+
| `apply_mail_setup` | carry out a plan, with approval required to replace a record somebody put there |
|
|
232
|
+
|
|
233
|
+
Repair is the last two, and it is deliberately two calls rather than one. A tool call
|
|
234
|
+
returns once, so there is nowhere for a mid-flight question to go: `plan` reads what is
|
|
235
|
+
there and says what would change, `apply` carries out a plan the operator has seen.
|
|
236
|
+
Approval is the gap between them.
|
|
237
|
+
|
|
238
|
+
`apply` refuses without `approved=true` when the plan would replace or combine a record
|
|
239
|
+
somebody put there on purpose. Creating one that does not exist is not a judgement call;
|
|
240
|
+
changing one that does is, and it is someone else's live mail.
|
|
241
|
+
|
|
242
|
+
**Eight is now ten**, and the two that were missing are why: the repair code existed,
|
|
243
|
+
was tested, and had no caller outside its own module until an external reviewer pointed
|
|
244
|
+
it out. `agent/mail.py:set_up_mail` still takes a callback and is still unreachable from
|
|
245
|
+
MCP for that reason; `plan_mail_setup` and `apply_mail_setup` are the shape that survives
|
|
246
|
+
the boundary.
|
|
247
|
+
|
|
248
|
+
## What is implemented
|
|
249
|
+
|
|
250
|
+
| | State |
|
|
251
|
+
|---|---|
|
|
252
|
+
| Per-client credential containers, OS keychain | ✅ |
|
|
253
|
+
| Read across / write within | ✅ |
|
|
254
|
+
| Check catalogue, 13 checks, no credentials needed | ✅ |
|
|
255
|
+
| A session per client against the providers' own MCP servers | ✅ live against Cloudflare |
|
|
256
|
+
| Dynamic client registration, so nothing is registered by hand | ✅ Cloudflare, Vercel, Resend |
|
|
257
|
+
| Client named by the account it was authorised as | ✅ |
|
|
258
|
+
| Strands agent holding every client's provider tools, namespaced | ✅ |
|
|
259
|
+
| Cross-client questions, writes structurally absent | ✅ `ask_across_clients` |
|
|
260
|
+
| Run log with replay | ✅ open the room mid-run, or refresh, and the whole run replays |
|
|
261
|
+
| Resuming an interrupted launch from the log | ⬜ not implemented |
|
|
262
|
+
| Control room, live over SSE | ✅ |
|
|
263
|
+
| Launch report for the business owner | ✅ |
|
|
264
|
+
| OAuth connect (PKCE), issuer validated per RFC 9207 | ✅ Vercel live against two real accounts |
|
|
265
|
+
| Two accounts on one provider at once | ◐ registration proven; second sign-in not yet run |
|
|
266
|
+
| Cloudflare DNS writes: idempotent upsert, SPF merge | ✅ tested, including partial-failure behaviour; not yet run against a live zone |
|
|
267
|
+
| Vercel reads: deploys, env scope, env applied | ✅ live |
|
|
268
|
+
| Resend writes: create and verify a sender domain | ✅ |
|
|
269
|
+
| Vercel write operations | ⬜ not yet |
|
|
270
|
+
|
|
271
|
+
Re-running a launch after a partial failure does not duplicate anything, which
|
|
272
|
+
is the property people usually mean by resume: every write reads what is there
|
|
273
|
+
first and updates in place, and the SPF merge removes the leftovers before
|
|
274
|
+
writing so a failure part-way leaves one working policy rather than two that
|
|
275
|
+
receivers ignore. Picking a launch up from where it stopped is a different
|
|
276
|
+
thing, and it is not built.
|
|
277
|
+
|
|
278
|
+
### Why there is no AgentCore deployment
|
|
279
|
+
|
|
280
|
+
Worth stating rather than leaving as a gap. Bedrock is unreachable on the
|
|
281
|
+
development account: AWS Marketplace cannot complete a model subscription for
|
|
282
|
+
AISPL (India) customers, because RBI rules prevent it storing card details, and
|
|
283
|
+
Bedrock model access is provisioned as a Marketplace subscription. Separately,
|
|
284
|
+
AgentCore Runtime quota defaults to zero and increases take several days.
|
|
285
|
+
|
|
286
|
+
Strands is model-portable, so the agent runs on a different host with one
|
|
287
|
+
environment variable changed and no code change. That is the property AWS
|
|
288
|
+
advertises; this exercised it under duress. Restoring Bedrock is
|
|
289
|
+
`MUNIM_BEDROCK_MODEL` and nothing else.
|
|
290
|
+
|
|
291
|
+
**Nothing here is stubbed.** A capability that is not implemented is absent from the tool
|
|
292
|
+
list rather than present and inert. Resend, for example, has no OAuth flow anywhere in this
|
|
293
|
+
codebase because Resend publishes no authorization endpoint, not because it was skipped.
|
|
294
|
+
|
|
295
|
+
## How it is built
|
|
296
|
+
|
|
297
|
+
```mermaid
|
|
298
|
+
flowchart TD
|
|
299
|
+
A["Coding agent<br/>(Claude Code, Codex, Cursor)"] -->|stdio, JSON-RPC| B["Munim MCP server"]
|
|
300
|
+
|
|
301
|
+
B --> F["Checks<br/>13, deterministic, no credentials"]
|
|
302
|
+
B --> G["Strands agent"]
|
|
303
|
+
|
|
304
|
+
G --> S1["MCPClient<br/>prefix: acme_ltd"]
|
|
305
|
+
G --> S2["MCPClient<br/>prefix: ivy_fern"]
|
|
306
|
+
S1 --> K1[["Container(Acme Ltd)<br/>own registration, own token"]]
|
|
307
|
+
S2 --> K2[["Container(Ivy & Fern)<br/>own registration, own token"]]
|
|
308
|
+
K1 --> P["The providers' own MCP servers<br/>mcp.cloudflare.com · mcp.vercel.com · mcp.resend.com"]
|
|
309
|
+
K2 --> P
|
|
310
|
+
|
|
311
|
+
G --> H[("~/.munim/runs/<id>.jsonl<br/>the one source")]
|
|
312
|
+
F --> H
|
|
313
|
+
H --> I["Control room<br/>separate process, SSE"]
|
|
314
|
+
H --> J["Launch report<br/>for the business owner"]
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
Two clients, one provider, one process. That is the whole thing, and it is not
|
|
318
|
+
engineered: each client registers separately with the provider, so as far as the
|
|
319
|
+
provider is concerned they are two applications and there is nothing shared to
|
|
320
|
+
clobber. A coding agent holds one account per provider because one client id
|
|
321
|
+
shares one token store.
|
|
322
|
+
|
|
323
|
+
Four decisions carry the design:
|
|
324
|
+
|
|
325
|
+
**Enumeration is deterministic; only judgement is model work.** The checks decide pass or
|
|
326
|
+
fail from a DNS answer. The agent cannot contradict them, so it cannot invent a record or
|
|
327
|
+
argue a failing check into passing. What it does is the part a rule engine is bad at:
|
|
328
|
+
working out *why* something failed, and saying it to someone non-technical.
|
|
329
|
+
|
|
330
|
+
**The run log is the one source of truth.** The MCP server speaks JSON-RPC over stdout, so
|
|
331
|
+
it cannot print progress there without corrupting the protocol, and the subprocess dies
|
|
332
|
+
whenever the coding agent reconnects. Writing events to a file instead means the control
|
|
333
|
+
room survives a restart, can be opened mid-run with full replay, and an interrupted launch
|
|
334
|
+
leaves a record to resume from.
|
|
335
|
+
|
|
336
|
+
**A container is bound to one client at construction and cannot widen.** `"acme"` versus
|
|
337
|
+
`"acme-uk"` would otherwise be a *successful* mutation on the wrong account. Container
|
|
338
|
+
construction fails on an unregistered name, and the raw credential is never returned to
|
|
339
|
+
calling code: a session carries its own registration and its own token, filed under
|
|
340
|
+
`(client, provider)`, so two clients cannot borrow each other's. Where an adapter is used
|
|
341
|
+
instead it receives an authenticated HTTP client, so no log line or stack trace can leak a
|
|
342
|
+
token.
|
|
343
|
+
|
|
344
|
+
**Read across, write within is a property of which tools exist.** A tool that spans clients
|
|
345
|
+
is built from only those the provider marks `readOnlyHint`, default deny, so one that
|
|
346
|
+
changes something is not present to be called. It used to be a line in a system prompt, and
|
|
347
|
+
an instruction is not a boundary.
|
|
348
|
+
|
|
349
|
+
## Roadmap
|
|
350
|
+
|
|
351
|
+
Written down because the gaps are known, not because they are planned away.
|
|
352
|
+
|
|
353
|
+
**Linux and Windows.** Developed on macOS and the platform assumptions have
|
|
354
|
+
been found rather than guessed at. `doctor` now reports whether a keychain
|
|
355
|
+
backend exists instead of raising, credential reads degrade to "nothing
|
|
356
|
+
connected" rather than a stack trace, and the `claude` executable is resolved
|
|
357
|
+
through `shutil.which` because on Windows it is `claude.cmd`. What remains
|
|
358
|
+
untested is a real run on either: a headless Linux box needs `keyrings.alt` or
|
|
359
|
+
a secret service, and nobody has yet confirmed the browser callback and the
|
|
360
|
+
keychain behave there. CI runs the suite on Linux, which is a start and not the
|
|
361
|
+
same thing.
|
|
362
|
+
|
|
363
|
+
**Local stdio servers.** Munim holds sessions with remote MCP servers over
|
|
364
|
+
HTTP. A stdio server is a process, not an endpoint, so holding one per client
|
|
365
|
+
means spawning N processes with N environments, which is a different design and
|
|
366
|
+
is what `mcpwarden` does. On one real machine, 20 of 26 configured servers were
|
|
367
|
+
remote and 4 were stdio, so this is a real gap rather than a theoretical one.
|
|
368
|
+
|
|
369
|
+
**Providers whose authorization server will not register a client.** Every
|
|
370
|
+
Google MCP server, which is Gmail, Stitch, Drive and Calendar, authenticates
|
|
371
|
+
against `accounts.google.com`. It advertises no registration endpoint and
|
|
372
|
+
requires `client_secret_post`, so somebody has to be a registered application.
|
|
373
|
+
In a coding agent that somebody is the agent itself: a Gmail connector works
|
|
374
|
+
without setup because the client, not the operator, holds the Google
|
|
375
|
+
registration. Munim is a client too, so it needs its own, and that is a decision
|
|
376
|
+
about carrying a Google credential rather than a thing to slip in.
|
|
377
|
+
|
|
378
|
+
**Published to PyPI.** The package builds, a wheel installed into an empty
|
|
379
|
+
environment runs and carries the control room, the name is free, and
|
|
380
|
+
`.github/workflows/publish.yml` publishes on a tag through PyPI's trusted
|
|
381
|
+
publishing, so no token has to exist. What is missing is the pending publisher
|
|
382
|
+
on PyPI, which is an account decision rather than a code one. Until then
|
|
383
|
+
installing means cloning, while every other MCP server is one line in a config,
|
|
384
|
+
and that gap is plausibly why a credential tool with a good design ends up with
|
|
385
|
+
no adopters (D15).
|
|
386
|
+
|
|
387
|
+
**Watch mode.** `audit_all_clients` is the shape of it and runs on demand.
|
|
388
|
+
Running on a schedule and telling somebody only when the answer changes is the
|
|
389
|
+
version an operator would actually leave on.
|
|
390
|
+
|
|
391
|
+
**Vercel and Resend sessions.** Registration is confirmed against all three
|
|
392
|
+
providers, and only Cloudflare has been connected and used. The other two are
|
|
393
|
+
expected to work and that is not the same as knowing.
|
|
394
|
+
|
|
395
|
+
**Resuming an interrupted launch.** The run log records enough to do it and
|
|
396
|
+
nothing reads it back for that purpose.
|
|
397
|
+
|
|
398
|
+
## Development
|
|
399
|
+
|
|
400
|
+
```bash
|
|
401
|
+
uv pip install -e ".[dev]"
|
|
402
|
+
uv run pytest -q # the Python suite
|
|
403
|
+
node --test "tests/room/*.test.mjs" # the control room's reducer
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
The control room is one HTML page and one ES module, served as written. There
|
|
407
|
+
is no build step and no `node_modules`: the only reason Node appears at all is
|
|
408
|
+
to run six tests over the reducer, and those need no install.
|
|
409
|
+
|
|
410
|
+
To check the claim this project rests on, which is reading two client accounts
|
|
411
|
+
at once with no logout between them, connect two of your own and run:
|
|
412
|
+
|
|
413
|
+
```bash
|
|
414
|
+
uv run python scripts/cross_account_probe.py
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
It fails if either account is empty, and fails if the two share a project: two
|
|
418
|
+
grants returning the same projects are one account wearing two names, which
|
|
419
|
+
would make the claim vacuous. Measured on two real Vercel teams in
|
|
420
|
+
[`docs/DECISIONS.md`](docs/DECISIONS.md) D23.
|
|
421
|
+
|
|
422
|
+
Design decisions and the reasoning behind them are in [`docs/DECISIONS.md`](docs/DECISIONS.md),
|
|
423
|
+
including the ones that were wrong first time.
|
|
424
|
+
|
|
425
|
+
## Disclosure
|
|
426
|
+
|
|
427
|
+
Built with AI assistance (Claude Code), which the hackathon rules permit. No pre-existing
|
|
428
|
+
code was incorporated; the repository was created during the submission period. Prior
|
|
429
|
+
personal projects informed the working method but contributed no source.
|
|
430
|
+
|
|
431
|
+
## Licence
|
|
432
|
+
|
|
433
|
+
MIT. See [LICENSE](LICENSE).
|