sam-mesh 0.1.0rc4__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.
- sam_mesh-0.1.0rc4/.gitignore +69 -0
- sam_mesh-0.1.0rc4/PKG-INFO +209 -0
- sam_mesh-0.1.0rc4/README.md +187 -0
- sam_mesh-0.1.0rc4/examples/call.py +65 -0
- sam_mesh-0.1.0rc4/examples/serve.py +64 -0
- sam_mesh-0.1.0rc4/proto/circuit.proto +77 -0
- sam_mesh-0.1.0rc4/pyproject.toml +40 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/__init__.py +125 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/_gen/__init__.py +1 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/_gen/datalog.json +54 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/_proto/__init__.py +1 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/_proto/circuit_pb2.py +39 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/_proto/circuit_pb2.pyi +80 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/_proto/sam_pb2.py +141 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/_proto/sam_pb2.pyi +562 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/auth.py +111 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/authorizer.py +168 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/base58.py +40 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/biscuit.py +135 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/challenges.py +43 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/conformance.py +81 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/conformance_join.py +217 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/controlplane.py +350 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/credential.py +121 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/discovery.py +196 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/host.py +114 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/identity.py +131 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/mcp_client.py +169 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/mesh.py +284 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/relay.py +130 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/serve.py +503 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/session.py +577 -0
- sam_mesh-0.1.0rc4/src/agent_mesh/sync.py +97 -0
- sam_mesh-0.1.0rc4/tests/__init__.py +0 -0
- sam_mesh-0.1.0rc4/tests/test_authorizer.py +145 -0
- sam_mesh-0.1.0rc4/tests/test_biscuit.py +79 -0
- sam_mesh-0.1.0rc4/tests/test_controlplane.py +246 -0
- sam_mesh-0.1.0rc4/tests/test_host.py +116 -0
- sam_mesh-0.1.0rc4/tests/test_identity.py +86 -0
- sam_mesh-0.1.0rc4/tests/test_mcp.py +218 -0
- sam_mesh-0.1.0rc4/tests/test_mesh.py +195 -0
- sam_mesh-0.1.0rc4/tests/test_serve.py +212 -0
- sam_mesh-0.1.0rc4/tests/test_session.py +223 -0
- sam_mesh-0.1.0rc4/tests/test_sync.py +294 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# If you prefer the allow list template instead of the deny list, see community template:
|
|
2
|
+
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
|
|
3
|
+
#
|
|
4
|
+
# Binaries for programs and plugins
|
|
5
|
+
*.exe
|
|
6
|
+
*.exe~
|
|
7
|
+
*.dll
|
|
8
|
+
*.so
|
|
9
|
+
*.dylib
|
|
10
|
+
*.pyc
|
|
11
|
+
|
|
12
|
+
# Test binary, built with `go test -c`
|
|
13
|
+
*.test
|
|
14
|
+
|
|
15
|
+
# Code coverage profiles and other test artifacts
|
|
16
|
+
*.out
|
|
17
|
+
coverage.*
|
|
18
|
+
*.coverprofile
|
|
19
|
+
profile.cov
|
|
20
|
+
|
|
21
|
+
# Dependency directories (remove the comment below to include it)
|
|
22
|
+
# vendor/
|
|
23
|
+
|
|
24
|
+
# Go workspace file
|
|
25
|
+
go.work
|
|
26
|
+
go.work.sum
|
|
27
|
+
|
|
28
|
+
# env file
|
|
29
|
+
.env
|
|
30
|
+
|
|
31
|
+
# Editor/IDE
|
|
32
|
+
# .idea/
|
|
33
|
+
# .vscode/
|
|
34
|
+
#
|
|
35
|
+
bin/
|
|
36
|
+
dist/
|
|
37
|
+
# Stray binaries from `go build ./cmd/<name>/` in the repo root
|
|
38
|
+
/sam-node
|
|
39
|
+
/sam-box
|
|
40
|
+
/sam-router
|
|
41
|
+
/sam-console
|
|
42
|
+
|
|
43
|
+
# Playwright console UI tests
|
|
44
|
+
node_modules/
|
|
45
|
+
tests/ui/playwright-report/
|
|
46
|
+
tests/ui/test-results/
|
|
47
|
+
/sam-control-plane
|
|
48
|
+
/mcp-client
|
|
49
|
+
/nano-init
|
|
50
|
+
tests/e2e/logs/
|
|
51
|
+
tests/integration/logs/
|
|
52
|
+
tests/integration/scratch/
|
|
53
|
+
__pycache__/
|
|
54
|
+
.venv/
|
|
55
|
+
*.egg-info/
|
|
56
|
+
keys.db
|
|
57
|
+
mobile/logcat.txt
|
|
58
|
+
|
|
59
|
+
# Guest images built by scripts/build-rootfs.sh; gigabytes, and reproducible
|
|
60
|
+
# from the script, so they belong in a bucket rather than in history.
|
|
61
|
+
/rootfs.ext4
|
|
62
|
+
/rootfs.tar
|
|
63
|
+
|
|
64
|
+
# Android upload keystore and Firebase config: secrets, wherever they land.
|
|
65
|
+
*.jks
|
|
66
|
+
*.keystore
|
|
67
|
+
key.properties
|
|
68
|
+
google-services.json
|
|
69
|
+
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: sam-mesh
|
|
3
|
+
Version: 0.1.0-rc.4
|
|
4
|
+
Summary: Native SDK for joining a SAM agent mesh from inside a Python agent process
|
|
5
|
+
Project-URL: Homepage, https://github.com/google/sam
|
|
6
|
+
Project-URL: Source, https://github.com/google/sam/tree/main/sdk/python
|
|
7
|
+
License-Expression: Apache-2.0
|
|
8
|
+
Requires-Python: >=3.11
|
|
9
|
+
Requires-Dist: anyio>=4
|
|
10
|
+
Requires-Dist: biscuit-python>=0.4
|
|
11
|
+
Requires-Dist: cryptography>=42
|
|
12
|
+
Requires-Dist: h11>=0.14
|
|
13
|
+
Requires-Dist: httpx>=0.27
|
|
14
|
+
Requires-Dist: libp2p<0.8,>=0.7
|
|
15
|
+
Requires-Dist: mcp<3,>=2
|
|
16
|
+
Requires-Dist: multiaddr>=0.2
|
|
17
|
+
Requires-Dist: protobuf<8,>=4.25
|
|
18
|
+
Requires-Dist: trio>=0.26
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# sam-mesh (Python)
|
|
24
|
+
|
|
25
|
+
Native Python SDK for joining a SAM agent mesh from inside the agent
|
|
26
|
+
process. It replaces the `sam-node` sidecar for agents written in Python:
|
|
27
|
+
the agent enrolls with the control plane, joins the mesh through a router,
|
|
28
|
+
finds services and calls them, publishes services of its own, and follows
|
|
29
|
+
the control plane's keys, bans and policy while it runs. Import it as
|
|
30
|
+
`agent_mesh`.
|
|
31
|
+
|
|
32
|
+
Guide: [sam-mesh.dev/docs/guides/native-sdks](https://sam-mesh.dev/docs/guides/native-sdks/),
|
|
33
|
+
from an empty machine to two programs on a mesh.
|
|
34
|
+
Source: [github.com/google/sam/tree/main/sdk/python](https://github.com/google/sam/tree/main/sdk/python).
|
|
35
|
+
|
|
36
|
+
## Install
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install sam-mesh
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Python 3.11 or later. The SDK runs on trio (py-libp2p is trio-based); under
|
|
43
|
+
asyncio, use it through `anyio` with the trio backend. One dependency,
|
|
44
|
+
`fastecdsa`, builds from source against GMP (`libgmp-dev` on Debian).
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
Both programs below are in
|
|
49
|
+
[`examples/`](https://github.com/google/sam/tree/main/sdk/python/examples)
|
|
50
|
+
and run against a real mesh in the repository's tests. They read the mesh
|
|
51
|
+
from `SAM_CONTROL_PLANE_URL` and the enrollment token from
|
|
52
|
+
`SAM_BOOTSTRAP_TOKEN_PATH`; the guide shows how to get both from `sam-one`
|
|
53
|
+
or from the operator of an existing mesh.
|
|
54
|
+
|
|
55
|
+
Find a service and call it:
|
|
56
|
+
|
|
57
|
+
<!-- embed: sdk/python/examples/call.py -->
|
|
58
|
+
```python
|
|
59
|
+
"""Finds a service on the mesh and calls it: a tool of an MCP service, or a
|
|
60
|
+
path of an inference or A2A service.
|
|
61
|
+
|
|
62
|
+
python call.py mcp://greeter greet '{"name": "Ada"}'
|
|
63
|
+
python call.py a2a://greeter /card
|
|
64
|
+
python call.py inference://ollama /v1/models
|
|
65
|
+
|
|
66
|
+
SAM_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
67
|
+
SAM_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or SAM_JWT_PATH
|
|
68
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
69
|
+
projected service account token), and keeps the identity and credential in
|
|
70
|
+
SAM_STATE_DIR; later runs resume from there without it.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
import json
|
|
74
|
+
import os
|
|
75
|
+
import sys
|
|
76
|
+
|
|
77
|
+
import trio
|
|
78
|
+
from agent_mesh import AgentMesh
|
|
79
|
+
|
|
80
|
+
argv = sys.argv[1:]
|
|
81
|
+
service = argv[0] if len(argv) > 0 else "mcp://greeter"
|
|
82
|
+
tool_or_path = argv[1] if len(argv) > 1 else "greet"
|
|
83
|
+
args = json.loads(argv[2]) if len(argv) > 2 else {"name": "world"}
|
|
84
|
+
|
|
85
|
+
mesh = AgentMesh.enroll(
|
|
86
|
+
os.environ.get("SAM_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
87
|
+
bootstrap_token_path=os.environ.get("SAM_BOOTSTRAP_TOKEN_PATH"),
|
|
88
|
+
jwt_path=os.environ.get("SAM_JWT_PATH"),
|
|
89
|
+
state_dir=os.environ.get("SAM_STATE_DIR", "~/.config/sam-mesh/caller"),
|
|
90
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
91
|
+
allow_insecure=os.environ.get("SAM_INSECURE_CONTROL_PLANE") == "true",
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
async def main() -> None:
|
|
96
|
+
async with mesh.join() as session:
|
|
97
|
+
print(f"on the mesh as {session.peer_id}")
|
|
98
|
+
|
|
99
|
+
providers = await session.discover(service)
|
|
100
|
+
if not providers:
|
|
101
|
+
raise SystemExit(f"no member of the mesh serves {service}")
|
|
102
|
+
# A provider record can outlive its member; the first that answers is used.
|
|
103
|
+
for provider in providers:
|
|
104
|
+
try:
|
|
105
|
+
await session.connect(provider)
|
|
106
|
+
break
|
|
107
|
+
except (ConnectionError, PermissionError) as err:
|
|
108
|
+
print(f"{provider.peer_id}: {err}", file=sys.stderr)
|
|
109
|
+
else:
|
|
110
|
+
raise SystemExit(f"no provider of {service} is reachable")
|
|
111
|
+
print(f"{service} is served by {provider.peer_id}")
|
|
112
|
+
|
|
113
|
+
if service.startswith("mcp://"):
|
|
114
|
+
tools = await session.list_tools(provider, service)
|
|
115
|
+
print("tools:", ", ".join(t.name for t in tools))
|
|
116
|
+
result = await session.call_tool(provider, service, tool_or_path, args)
|
|
117
|
+
print("\n".join(result.text))
|
|
118
|
+
else:
|
|
119
|
+
response = await session.request(provider, service, tool_or_path)
|
|
120
|
+
print(response.status, response.text)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
trio.run(main)
|
|
124
|
+
```
|
|
125
|
+
<!-- /embed -->
|
|
126
|
+
|
|
127
|
+
Publish services of your own:
|
|
128
|
+
|
|
129
|
+
<!-- embed: sdk/python/examples/serve.py -->
|
|
130
|
+
```python
|
|
131
|
+
"""Publishes services on the mesh and answers callers until stopped: an MCP
|
|
132
|
+
tool, an A2A endpoint and, when OLLAMA_URL is set, the Ollama server running
|
|
133
|
+
beside this program as an inference service. The mesh policy decides which
|
|
134
|
+
members may call; the SDK turns the others away before anything reaches this
|
|
135
|
+
code or Ollama.
|
|
136
|
+
|
|
137
|
+
python serve.py # publishes mcp://greeter and a2a://greeter
|
|
138
|
+
python serve.py greeter-2 # the same under another name
|
|
139
|
+
|
|
140
|
+
SAM_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
141
|
+
SAM_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or SAM_JWT_PATH
|
|
142
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
143
|
+
projected service account token), and keeps the identity and credential in
|
|
144
|
+
SAM_STATE_DIR; later runs resume from there without it.
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
import json
|
|
148
|
+
import os
|
|
149
|
+
import sys
|
|
150
|
+
|
|
151
|
+
import trio
|
|
152
|
+
from agent_mesh import AgentMesh, HTTPRequest, HTTPResponse, HTTPService, MCPService, VerifiedBiscuit
|
|
153
|
+
from mcp.server.mcpserver import MCPServer
|
|
154
|
+
|
|
155
|
+
service_name = sys.argv[1] if len(sys.argv) > 1 else "greeter"
|
|
156
|
+
|
|
157
|
+
mesh = AgentMesh.enroll(
|
|
158
|
+
os.environ.get("SAM_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
159
|
+
bootstrap_token_path=os.environ.get("SAM_BOOTSTRAP_TOKEN_PATH"),
|
|
160
|
+
jwt_path=os.environ.get("SAM_JWT_PATH"),
|
|
161
|
+
state_dir=os.environ.get("SAM_STATE_DIR", f"~/.config/sam-mesh/{service_name}"),
|
|
162
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
163
|
+
allow_insecure=os.environ.get("SAM_INSECURE_CONTROL_PLANE") == "true",
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def create_server() -> MCPServer:
|
|
168
|
+
server = MCPServer(service_name)
|
|
169
|
+
|
|
170
|
+
@server.tool(description="Greets someone by name")
|
|
171
|
+
def greet(name: str) -> str:
|
|
172
|
+
return f"hello {name}"
|
|
173
|
+
|
|
174
|
+
return server
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
async def card(request: HTTPRequest, caller: VerifiedBiscuit) -> HTTPResponse:
|
|
178
|
+
body = json.dumps({"name": service_name, "path": request.path, "caller": caller.peer_id})
|
|
179
|
+
return HTTPResponse(status=200, headers={"content-type": "application/json"}, body=body.encode())
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def main() -> None:
|
|
183
|
+
async with mesh.join() as session:
|
|
184
|
+
await session.serve(MCPService(name=service_name, create_server=create_server))
|
|
185
|
+
await session.serve(HTTPService(type="a2a", name=service_name, target=card))
|
|
186
|
+
if "OLLAMA_URL" in os.environ:
|
|
187
|
+
await session.serve(HTTPService(type="inference", name="ollama", target=os.environ["OLLAMA_URL"]))
|
|
188
|
+
|
|
189
|
+
served = ", ".join(f"{t}://{n}" for t, n, _ in session.services.list())
|
|
190
|
+
print(f"serving {served} as {session.peer_id}", flush=True)
|
|
191
|
+
await trio.sleep_forever()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
trio.run(main)
|
|
195
|
+
```
|
|
196
|
+
<!-- /embed -->
|
|
197
|
+
|
|
198
|
+
`enroll` reuses the identity and credential saved in `state_dir` when they
|
|
199
|
+
are still valid for that control plane, and needs exactly one of
|
|
200
|
+
`bootstrap_token_path`, `bootstrap_token` or `jwt` otherwise. Read tokens
|
|
201
|
+
from a file or the environment; do not put them on a command line.
|
|
202
|
+
|
|
203
|
+
A plaintext `http://` control plane is accepted only on loopback. Pass
|
|
204
|
+
`allow_insecure=True` for a network you trust.
|
|
205
|
+
|
|
206
|
+
## License
|
|
207
|
+
|
|
208
|
+
Apache-2.0. Issues and contributions at
|
|
209
|
+
[github.com/google/sam](https://github.com/google/sam).
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
# sam-mesh (Python)
|
|
2
|
+
|
|
3
|
+
Native Python SDK for joining a SAM agent mesh from inside the agent
|
|
4
|
+
process. It replaces the `sam-node` sidecar for agents written in Python:
|
|
5
|
+
the agent enrolls with the control plane, joins the mesh through a router,
|
|
6
|
+
finds services and calls them, publishes services of its own, and follows
|
|
7
|
+
the control plane's keys, bans and policy while it runs. Import it as
|
|
8
|
+
`agent_mesh`.
|
|
9
|
+
|
|
10
|
+
Guide: [sam-mesh.dev/docs/guides/native-sdks](https://sam-mesh.dev/docs/guides/native-sdks/),
|
|
11
|
+
from an empty machine to two programs on a mesh.
|
|
12
|
+
Source: [github.com/google/sam/tree/main/sdk/python](https://github.com/google/sam/tree/main/sdk/python).
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install sam-mesh
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Python 3.11 or later. The SDK runs on trio (py-libp2p is trio-based); under
|
|
21
|
+
asyncio, use it through `anyio` with the trio backend. One dependency,
|
|
22
|
+
`fastecdsa`, builds from source against GMP (`libgmp-dev` on Debian).
|
|
23
|
+
|
|
24
|
+
## Use
|
|
25
|
+
|
|
26
|
+
Both programs below are in
|
|
27
|
+
[`examples/`](https://github.com/google/sam/tree/main/sdk/python/examples)
|
|
28
|
+
and run against a real mesh in the repository's tests. They read the mesh
|
|
29
|
+
from `SAM_CONTROL_PLANE_URL` and the enrollment token from
|
|
30
|
+
`SAM_BOOTSTRAP_TOKEN_PATH`; the guide shows how to get both from `sam-one`
|
|
31
|
+
or from the operator of an existing mesh.
|
|
32
|
+
|
|
33
|
+
Find a service and call it:
|
|
34
|
+
|
|
35
|
+
<!-- embed: sdk/python/examples/call.py -->
|
|
36
|
+
```python
|
|
37
|
+
"""Finds a service on the mesh and calls it: a tool of an MCP service, or a
|
|
38
|
+
path of an inference or A2A service.
|
|
39
|
+
|
|
40
|
+
python call.py mcp://greeter greet '{"name": "Ada"}'
|
|
41
|
+
python call.py a2a://greeter /card
|
|
42
|
+
python call.py inference://ollama /v1/models
|
|
43
|
+
|
|
44
|
+
SAM_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
45
|
+
SAM_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or SAM_JWT_PATH
|
|
46
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
47
|
+
projected service account token), and keeps the identity and credential in
|
|
48
|
+
SAM_STATE_DIR; later runs resume from there without it.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
import json
|
|
52
|
+
import os
|
|
53
|
+
import sys
|
|
54
|
+
|
|
55
|
+
import trio
|
|
56
|
+
from agent_mesh import AgentMesh
|
|
57
|
+
|
|
58
|
+
argv = sys.argv[1:]
|
|
59
|
+
service = argv[0] if len(argv) > 0 else "mcp://greeter"
|
|
60
|
+
tool_or_path = argv[1] if len(argv) > 1 else "greet"
|
|
61
|
+
args = json.loads(argv[2]) if len(argv) > 2 else {"name": "world"}
|
|
62
|
+
|
|
63
|
+
mesh = AgentMesh.enroll(
|
|
64
|
+
os.environ.get("SAM_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
65
|
+
bootstrap_token_path=os.environ.get("SAM_BOOTSTRAP_TOKEN_PATH"),
|
|
66
|
+
jwt_path=os.environ.get("SAM_JWT_PATH"),
|
|
67
|
+
state_dir=os.environ.get("SAM_STATE_DIR", "~/.config/sam-mesh/caller"),
|
|
68
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
69
|
+
allow_insecure=os.environ.get("SAM_INSECURE_CONTROL_PLANE") == "true",
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def main() -> None:
|
|
74
|
+
async with mesh.join() as session:
|
|
75
|
+
print(f"on the mesh as {session.peer_id}")
|
|
76
|
+
|
|
77
|
+
providers = await session.discover(service)
|
|
78
|
+
if not providers:
|
|
79
|
+
raise SystemExit(f"no member of the mesh serves {service}")
|
|
80
|
+
# A provider record can outlive its member; the first that answers is used.
|
|
81
|
+
for provider in providers:
|
|
82
|
+
try:
|
|
83
|
+
await session.connect(provider)
|
|
84
|
+
break
|
|
85
|
+
except (ConnectionError, PermissionError) as err:
|
|
86
|
+
print(f"{provider.peer_id}: {err}", file=sys.stderr)
|
|
87
|
+
else:
|
|
88
|
+
raise SystemExit(f"no provider of {service} is reachable")
|
|
89
|
+
print(f"{service} is served by {provider.peer_id}")
|
|
90
|
+
|
|
91
|
+
if service.startswith("mcp://"):
|
|
92
|
+
tools = await session.list_tools(provider, service)
|
|
93
|
+
print("tools:", ", ".join(t.name for t in tools))
|
|
94
|
+
result = await session.call_tool(provider, service, tool_or_path, args)
|
|
95
|
+
print("\n".join(result.text))
|
|
96
|
+
else:
|
|
97
|
+
response = await session.request(provider, service, tool_or_path)
|
|
98
|
+
print(response.status, response.text)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
trio.run(main)
|
|
102
|
+
```
|
|
103
|
+
<!-- /embed -->
|
|
104
|
+
|
|
105
|
+
Publish services of your own:
|
|
106
|
+
|
|
107
|
+
<!-- embed: sdk/python/examples/serve.py -->
|
|
108
|
+
```python
|
|
109
|
+
"""Publishes services on the mesh and answers callers until stopped: an MCP
|
|
110
|
+
tool, an A2A endpoint and, when OLLAMA_URL is set, the Ollama server running
|
|
111
|
+
beside this program as an inference service. The mesh policy decides which
|
|
112
|
+
members may call; the SDK turns the others away before anything reaches this
|
|
113
|
+
code or Ollama.
|
|
114
|
+
|
|
115
|
+
python serve.py # publishes mcp://greeter and a2a://greeter
|
|
116
|
+
python serve.py greeter-2 # the same under another name
|
|
117
|
+
|
|
118
|
+
SAM_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
119
|
+
SAM_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or SAM_JWT_PATH
|
|
120
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
121
|
+
projected service account token), and keeps the identity and credential in
|
|
122
|
+
SAM_STATE_DIR; later runs resume from there without it.
|
|
123
|
+
"""
|
|
124
|
+
|
|
125
|
+
import json
|
|
126
|
+
import os
|
|
127
|
+
import sys
|
|
128
|
+
|
|
129
|
+
import trio
|
|
130
|
+
from agent_mesh import AgentMesh, HTTPRequest, HTTPResponse, HTTPService, MCPService, VerifiedBiscuit
|
|
131
|
+
from mcp.server.mcpserver import MCPServer
|
|
132
|
+
|
|
133
|
+
service_name = sys.argv[1] if len(sys.argv) > 1 else "greeter"
|
|
134
|
+
|
|
135
|
+
mesh = AgentMesh.enroll(
|
|
136
|
+
os.environ.get("SAM_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
137
|
+
bootstrap_token_path=os.environ.get("SAM_BOOTSTRAP_TOKEN_PATH"),
|
|
138
|
+
jwt_path=os.environ.get("SAM_JWT_PATH"),
|
|
139
|
+
state_dir=os.environ.get("SAM_STATE_DIR", f"~/.config/sam-mesh/{service_name}"),
|
|
140
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
141
|
+
allow_insecure=os.environ.get("SAM_INSECURE_CONTROL_PLANE") == "true",
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def create_server() -> MCPServer:
|
|
146
|
+
server = MCPServer(service_name)
|
|
147
|
+
|
|
148
|
+
@server.tool(description="Greets someone by name")
|
|
149
|
+
def greet(name: str) -> str:
|
|
150
|
+
return f"hello {name}"
|
|
151
|
+
|
|
152
|
+
return server
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
async def card(request: HTTPRequest, caller: VerifiedBiscuit) -> HTTPResponse:
|
|
156
|
+
body = json.dumps({"name": service_name, "path": request.path, "caller": caller.peer_id})
|
|
157
|
+
return HTTPResponse(status=200, headers={"content-type": "application/json"}, body=body.encode())
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def main() -> None:
|
|
161
|
+
async with mesh.join() as session:
|
|
162
|
+
await session.serve(MCPService(name=service_name, create_server=create_server))
|
|
163
|
+
await session.serve(HTTPService(type="a2a", name=service_name, target=card))
|
|
164
|
+
if "OLLAMA_URL" in os.environ:
|
|
165
|
+
await session.serve(HTTPService(type="inference", name="ollama", target=os.environ["OLLAMA_URL"]))
|
|
166
|
+
|
|
167
|
+
served = ", ".join(f"{t}://{n}" for t, n, _ in session.services.list())
|
|
168
|
+
print(f"serving {served} as {session.peer_id}", flush=True)
|
|
169
|
+
await trio.sleep_forever()
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
trio.run(main)
|
|
173
|
+
```
|
|
174
|
+
<!-- /embed -->
|
|
175
|
+
|
|
176
|
+
`enroll` reuses the identity and credential saved in `state_dir` when they
|
|
177
|
+
are still valid for that control plane, and needs exactly one of
|
|
178
|
+
`bootstrap_token_path`, `bootstrap_token` or `jwt` otherwise. Read tokens
|
|
179
|
+
from a file or the environment; do not put them on a command line.
|
|
180
|
+
|
|
181
|
+
A plaintext `http://` control plane is accepted only on loopback. Pass
|
|
182
|
+
`allow_insecure=True` for a network you trust.
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
Apache-2.0. Issues and contributions at
|
|
187
|
+
[github.com/google/sam](https://github.com/google/sam).
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Finds a service on the mesh and calls it: a tool of an MCP service, or a
|
|
2
|
+
path of an inference or A2A service.
|
|
3
|
+
|
|
4
|
+
python call.py mcp://greeter greet '{"name": "Ada"}'
|
|
5
|
+
python call.py a2a://greeter /card
|
|
6
|
+
python call.py inference://ollama /v1/models
|
|
7
|
+
|
|
8
|
+
SAM_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
9
|
+
SAM_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or SAM_JWT_PATH
|
|
10
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
11
|
+
projected service account token), and keeps the identity and credential in
|
|
12
|
+
SAM_STATE_DIR; later runs resume from there without it.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
|
|
19
|
+
import trio
|
|
20
|
+
from agent_mesh import AgentMesh
|
|
21
|
+
|
|
22
|
+
argv = sys.argv[1:]
|
|
23
|
+
service = argv[0] if len(argv) > 0 else "mcp://greeter"
|
|
24
|
+
tool_or_path = argv[1] if len(argv) > 1 else "greet"
|
|
25
|
+
args = json.loads(argv[2]) if len(argv) > 2 else {"name": "world"}
|
|
26
|
+
|
|
27
|
+
mesh = AgentMesh.enroll(
|
|
28
|
+
os.environ.get("SAM_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
29
|
+
bootstrap_token_path=os.environ.get("SAM_BOOTSTRAP_TOKEN_PATH"),
|
|
30
|
+
jwt_path=os.environ.get("SAM_JWT_PATH"),
|
|
31
|
+
state_dir=os.environ.get("SAM_STATE_DIR", "~/.config/sam-mesh/caller"),
|
|
32
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
33
|
+
allow_insecure=os.environ.get("SAM_INSECURE_CONTROL_PLANE") == "true",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def main() -> None:
|
|
38
|
+
async with mesh.join() as session:
|
|
39
|
+
print(f"on the mesh as {session.peer_id}")
|
|
40
|
+
|
|
41
|
+
providers = await session.discover(service)
|
|
42
|
+
if not providers:
|
|
43
|
+
raise SystemExit(f"no member of the mesh serves {service}")
|
|
44
|
+
# A provider record can outlive its member; the first that answers is used.
|
|
45
|
+
for provider in providers:
|
|
46
|
+
try:
|
|
47
|
+
await session.connect(provider)
|
|
48
|
+
break
|
|
49
|
+
except (ConnectionError, PermissionError) as err:
|
|
50
|
+
print(f"{provider.peer_id}: {err}", file=sys.stderr)
|
|
51
|
+
else:
|
|
52
|
+
raise SystemExit(f"no provider of {service} is reachable")
|
|
53
|
+
print(f"{service} is served by {provider.peer_id}")
|
|
54
|
+
|
|
55
|
+
if service.startswith("mcp://"):
|
|
56
|
+
tools = await session.list_tools(provider, service)
|
|
57
|
+
print("tools:", ", ".join(t.name for t in tools))
|
|
58
|
+
result = await session.call_tool(provider, service, tool_or_path, args)
|
|
59
|
+
print("\n".join(result.text))
|
|
60
|
+
else:
|
|
61
|
+
response = await session.request(provider, service, tool_or_path)
|
|
62
|
+
print(response.status, response.text)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
trio.run(main)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Publishes services on the mesh and answers callers until stopped: an MCP
|
|
2
|
+
tool, an A2A endpoint and, when OLLAMA_URL is set, the Ollama server running
|
|
3
|
+
beside this program as an inference service. The mesh policy decides which
|
|
4
|
+
members may call; the SDK turns the others away before anything reaches this
|
|
5
|
+
code or Ollama.
|
|
6
|
+
|
|
7
|
+
python serve.py # publishes mcp://greeter and a2a://greeter
|
|
8
|
+
python serve.py greeter-2 # the same under another name
|
|
9
|
+
|
|
10
|
+
SAM_CONTROL_PLANE_URL names the mesh. The first run enrolls with the file
|
|
11
|
+
SAM_BOOTSTRAP_TOKEN_PATH (a token the mesh operator gave you) or SAM_JWT_PATH
|
|
12
|
+
(a workload identity token your platform issues, such as a Kubernetes
|
|
13
|
+
projected service account token), and keeps the identity and credential in
|
|
14
|
+
SAM_STATE_DIR; later runs resume from there without it.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import sys
|
|
20
|
+
|
|
21
|
+
import trio
|
|
22
|
+
from agent_mesh import AgentMesh, HTTPRequest, HTTPResponse, HTTPService, MCPService, VerifiedBiscuit
|
|
23
|
+
from mcp.server.mcpserver import MCPServer
|
|
24
|
+
|
|
25
|
+
service_name = sys.argv[1] if len(sys.argv) > 1 else "greeter"
|
|
26
|
+
|
|
27
|
+
mesh = AgentMesh.enroll(
|
|
28
|
+
os.environ.get("SAM_CONTROL_PLANE_URL", "https://mesh.example.com"),
|
|
29
|
+
bootstrap_token_path=os.environ.get("SAM_BOOTSTRAP_TOKEN_PATH"),
|
|
30
|
+
jwt_path=os.environ.get("SAM_JWT_PATH"),
|
|
31
|
+
state_dir=os.environ.get("SAM_STATE_DIR", f"~/.config/sam-mesh/{service_name}"),
|
|
32
|
+
# A plaintext http:// control plane is otherwise accepted only on loopback.
|
|
33
|
+
allow_insecure=os.environ.get("SAM_INSECURE_CONTROL_PLANE") == "true",
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_server() -> MCPServer:
|
|
38
|
+
server = MCPServer(service_name)
|
|
39
|
+
|
|
40
|
+
@server.tool(description="Greets someone by name")
|
|
41
|
+
def greet(name: str) -> str:
|
|
42
|
+
return f"hello {name}"
|
|
43
|
+
|
|
44
|
+
return server
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
async def card(request: HTTPRequest, caller: VerifiedBiscuit) -> HTTPResponse:
|
|
48
|
+
body = json.dumps({"name": service_name, "path": request.path, "caller": caller.peer_id})
|
|
49
|
+
return HTTPResponse(status=200, headers={"content-type": "application/json"}, body=body.encode())
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def main() -> None:
|
|
53
|
+
async with mesh.join() as session:
|
|
54
|
+
await session.serve(MCPService(name=service_name, create_server=create_server))
|
|
55
|
+
await session.serve(HTTPService(type="a2a", name=service_name, target=card))
|
|
56
|
+
if "OLLAMA_URL" in os.environ:
|
|
57
|
+
await session.serve(HTTPService(type="inference", name="ollama", target=os.environ["OLLAMA_URL"]))
|
|
58
|
+
|
|
59
|
+
served = ", ".join(f"{t}://{n}" for t, n, _ in session.services.list())
|
|
60
|
+
print(f"serving {served} as {session.peer_id}", flush=True)
|
|
61
|
+
await trio.sleep_forever()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
trio.run(main)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Copyright 2026 Google LLC
|
|
2
|
+
//
|
|
3
|
+
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
// you may not use this file except in compliance with the License.
|
|
5
|
+
// You may obtain a copy of the License at
|
|
6
|
+
//
|
|
7
|
+
// http://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
//
|
|
9
|
+
// Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
// See the License for the specific language governing permissions and
|
|
13
|
+
// limitations under the License.
|
|
14
|
+
|
|
15
|
+
// The libp2p circuit relay v2 wire format, copied from go-libp2p
|
|
16
|
+
// (p2p/protocol/circuitv2/pb/circuit.proto) so a client that only needs a
|
|
17
|
+
// reservation can speak to a router without depending on a relay client
|
|
18
|
+
// implementation. Messages travel varint-length-prefixed on the
|
|
19
|
+
// /libp2p/circuit/relay/0.2.0/hop stream.
|
|
20
|
+
|
|
21
|
+
syntax = "proto3";
|
|
22
|
+
|
|
23
|
+
package circuit.pb;
|
|
24
|
+
|
|
25
|
+
message HopMessage {
|
|
26
|
+
enum Type {
|
|
27
|
+
RESERVE = 0;
|
|
28
|
+
CONNECT = 1;
|
|
29
|
+
STATUS = 2;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
optional Type type = 1;
|
|
33
|
+
optional Peer peer = 2;
|
|
34
|
+
optional Reservation reservation = 3;
|
|
35
|
+
optional Limit limit = 4;
|
|
36
|
+
optional Status status = 5;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
message StopMessage {
|
|
40
|
+
enum Type {
|
|
41
|
+
CONNECT = 0;
|
|
42
|
+
STATUS = 1;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
optional Type type = 1;
|
|
46
|
+
optional Peer peer = 2;
|
|
47
|
+
optional Limit limit = 3;
|
|
48
|
+
optional Status status = 4;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
message Peer {
|
|
52
|
+
optional bytes id = 1;
|
|
53
|
+
repeated bytes addrs = 2;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
message Reservation {
|
|
57
|
+
optional uint64 expire = 1; // Unix expiration time (UTC)
|
|
58
|
+
repeated bytes addrs = 2; // relay addrs for reserving peer
|
|
59
|
+
optional bytes voucher = 3; // reservation voucher
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
message Limit {
|
|
63
|
+
optional uint32 duration = 1; // seconds
|
|
64
|
+
optional uint64 data = 2; // bytes
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
enum Status {
|
|
68
|
+
UNUSED = 0;
|
|
69
|
+
OK = 100;
|
|
70
|
+
RESERVATION_REFUSED = 200;
|
|
71
|
+
RESOURCE_LIMIT_EXCEEDED = 201;
|
|
72
|
+
PERMISSION_DENIED = 202;
|
|
73
|
+
CONNECTION_FAILED = 203;
|
|
74
|
+
NO_RESERVATION = 204;
|
|
75
|
+
MALFORMED_MESSAGE = 400;
|
|
76
|
+
UNEXPECTED_MESSAGE = 401;
|
|
77
|
+
}
|