tunc-mcp 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tunc_mcp-0.1.0/LICENSE +21 -0
- tunc_mcp-0.1.0/PKG-INFO +242 -0
- tunc_mcp-0.1.0/README.md +222 -0
- tunc_mcp-0.1.0/pyproject.toml +41 -0
- tunc_mcp-0.1.0/setup.cfg +4 -0
- tunc_mcp-0.1.0/src/tunc_mcp/__init__.py +27 -0
- tunc_mcp-0.1.0/src/tunc_mcp/cli.py +86 -0
- tunc_mcp-0.1.0/src/tunc_mcp/client.py +198 -0
- tunc_mcp-0.1.0/src/tunc_mcp.egg-info/PKG-INFO +242 -0
- tunc_mcp-0.1.0/src/tunc_mcp.egg-info/SOURCES.txt +13 -0
- tunc_mcp-0.1.0/src/tunc_mcp.egg-info/dependency_links.txt +1 -0
- tunc_mcp-0.1.0/src/tunc_mcp.egg-info/entry_points.txt +2 -0
- tunc_mcp-0.1.0/src/tunc_mcp.egg-info/requires.txt +3 -0
- tunc_mcp-0.1.0/src/tunc_mcp.egg-info/top_level.txt +1 -0
- tunc_mcp-0.1.0/tests/test_client.py +84 -0
tunc_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TUNC LLC
|
|
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.
|
tunc_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tunc-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP client for Tunc Orb (agent memory database), Tunc Art (artifact storage and private file sharing) and Tunc Doc (documents, spreadsheets, decks).
|
|
5
|
+
Author-email: TUNC LLC <hello@tunc.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://tuncorb.com
|
|
8
|
+
Project-URL: Repository, https://github.com/TUNC-AI/tunc-mcp-python
|
|
9
|
+
Keywords: mcp,model-context-protocol,agent-memory,agent-database,memory,agent,agents,multi-agent,multi-agent-orchestration,artifact,artifacts,artifact-storage,file-sharing,private-links,storage,database,records,documents,spreadsheets,slide-decks,a2a,agent-to-agent,ai-agents,llm,tunc
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# tunc-mcp
|
|
22
|
+
|
|
23
|
+
**Agent memory, artifact storage, and document editing for AI agents — over MCP.**
|
|
24
|
+
|
|
25
|
+
A small, dependency-free MCP (Model Context Protocol) client in Python for three
|
|
26
|
+
hosted services that AI agents and multi-agent systems actually need:
|
|
27
|
+
|
|
28
|
+
| Service | What it is | MCP endpoint |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| **Tunc Orb** | **Agent memory database** — durable records, logs and structured state shared by every agent you run | `https://mcp.tuncorb.com/mcp` |
|
|
31
|
+
| **Tunc Art** | **Artifact storage and private file sharing** — agent-generated files behind short private links | `https://mcp.tuncart.com/mcp` |
|
|
32
|
+
| **Tunc Doc** | **Documents, spreadsheets and slide decks** — import/export DOCX, XLSX, PPTX, PDF | `https://mcp.tuncdoc.com/mcp` |
|
|
33
|
+
|
|
34
|
+
All three are also reachable over **A2A** (agent-to-agent) and plain **REST**, so
|
|
35
|
+
the same account works whether your agent speaks MCP, A2A, or HTTP.
|
|
36
|
+
|
|
37
|
+
## Why this exists
|
|
38
|
+
|
|
39
|
+
Most agent stacks lose state the moment a context window rolls over. Writes from
|
|
40
|
+
two agents at once silently clobber each other. Artifacts end up as files on
|
|
41
|
+
someone's laptop. This gives an agent three boring, reliable primitives instead:
|
|
42
|
+
|
|
43
|
+
- **Memory that survives the conversation.** Records persist across sessions,
|
|
44
|
+
across agents, across models, with revision-guarded writes.
|
|
45
|
+
- **Artifacts with links you can send.** Upload a file, get a short private URL
|
|
46
|
+
back. No bucket configuration.
|
|
47
|
+
- **Documents an agent can actually produce.** Real DOCX, XLSX and PPTX, not
|
|
48
|
+
markdown pretending to be a report.
|
|
49
|
+
|
|
50
|
+
### Concurrency is the interesting part
|
|
51
|
+
|
|
52
|
+
`data_update` takes `if_revision`. Two agents writing the same record do not
|
|
53
|
+
race: the second gets a `revision_conflict`, re-reads, and retries. That turns a
|
|
54
|
+
lost-update bug — the classic multi-agent failure where a plausible but wrong
|
|
55
|
+
number survives — into a loud retry.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install tunc-mcp
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Python 3.9 or newer. No dependencies. Get an API key at
|
|
64
|
+
<https://tunchub.com/account/keys/new> — you can scope it read-only.
|
|
65
|
+
|
|
66
|
+
## Use
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from tunc_mcp import orb, art, doc
|
|
70
|
+
|
|
71
|
+
memory = orb() # reads TUNC_API_KEY
|
|
72
|
+
|
|
73
|
+
# Every tool takes an `operation` that picks what it does. Call list_tools()
|
|
74
|
+
# once and read the real schemas rather than trusting an example.
|
|
75
|
+
#
|
|
76
|
+
# data_write operation: create | append
|
|
77
|
+
# data_read operation: shelves | list | get | log | search | status
|
|
78
|
+
# data_update operation: replace | patch (needs if_revision)
|
|
79
|
+
# data_delete operation: record | entries | log (needs if_revision)
|
|
80
|
+
|
|
81
|
+
memory.call_tool("data_write", {
|
|
82
|
+
"operation": "create",
|
|
83
|
+
"name": "contacts/ada",
|
|
84
|
+
"about": "Primary contact, Example Ltd",
|
|
85
|
+
"value": {"email": "ada@example.com", "timezone": "Europe/London"},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
record = memory.call_tool("data_read", {"operation": "get", "name": "contacts/ada"})
|
|
89
|
+
revision = record["structuredContent"]["revision"]
|
|
90
|
+
|
|
91
|
+
memory.call_tool("data_update", {
|
|
92
|
+
"operation": "patch",
|
|
93
|
+
"name": "contacts/ada",
|
|
94
|
+
"if_revision": revision,
|
|
95
|
+
"patch": [{"op": "replace", "path": "/timezone", "value": "Europe/Paris"}],
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
memory.call_tool("data_delete", {"operation": "record", "name": "contacts/ada", "if_revision": revision})
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Artifacts — `art_write` takes `operation: create` and a `name`, and the result
|
|
102
|
+
keys the artifact by `id`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
files = art()
|
|
106
|
+
stored = files.call_tool("art_write", {
|
|
107
|
+
"operation": "create",
|
|
108
|
+
"name": "quarterly-report.pdf",
|
|
109
|
+
"mime_type": "application/pdf",
|
|
110
|
+
"content_base64": base64_bytes,
|
|
111
|
+
})
|
|
112
|
+
artifact_id = stored["structuredContent"]["artifact"]["id"]
|
|
113
|
+
|
|
114
|
+
files.call_tool("art_read", {"operation": "get", "artifact_id": artifact_id})
|
|
115
|
+
files.call_tool("art_delete", {"artifact_id": artifact_id})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Documents — `doc_create` needs only a title and returns the document under
|
|
119
|
+
`result.doc.id`; every other doc tool addresses it with `doc`:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
docs = doc()
|
|
123
|
+
made = docs.call_tool("doc_create", {"title": "Q4 summary", "markdown": "# Q4\n\nRevenue up 12%."})
|
|
124
|
+
doc_id = made["structuredContent"]["result"]["doc"]["id"]
|
|
125
|
+
|
|
126
|
+
docs.call_tool("doc_read", {"doc": doc_id})
|
|
127
|
+
docs.call_tool("doc_delete", {"doc": doc_id})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Discover what any service offers:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
for tool in orb().list_tools():
|
|
134
|
+
print(tool["name"], tool.get("inputSchema", {}).get("required"))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## CLI
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
export TUNC_API_KEY=...
|
|
141
|
+
|
|
142
|
+
tunc-mcp services
|
|
143
|
+
tunc-mcp tools --service orb
|
|
144
|
+
tunc-mcp call data_read '{"operation": "get", "name": "contacts/ada"}' --service orb
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Options
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
memory = orb(
|
|
151
|
+
api_key="...", # or TUNC_API_KEY in the environment
|
|
152
|
+
endpoint="https://mcp.tuncorb.com/mcp", # override: staging, a proxy, a local mock
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `api_key` — defaults to `TUNC_API_KEY`. Checked when the client is constructed.
|
|
157
|
+
- `endpoint` — replaces the built-in URL for that service, which is how you point
|
|
158
|
+
it at a test server.
|
|
159
|
+
- `service` — `"orb"`, `"art"` or `"doc"`, if you construct `TuncMcpClient`
|
|
160
|
+
directly instead of using `orb()` / `art()` / `doc()`.
|
|
161
|
+
|
|
162
|
+
`SERVERS` is the map of default endpoints. `connect(key)` returns all three
|
|
163
|
+
clients at once.
|
|
164
|
+
|
|
165
|
+
## Wire it into an MCP client
|
|
166
|
+
|
|
167
|
+
Any MCP-capable host works. Point it at the endpoint and pass the key as a
|
|
168
|
+
bearer header. For a Python host, the usual shape is:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from tunc_mcp import orb
|
|
172
|
+
client = orb()
|
|
173
|
+
tools = client.list_tools()
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## OAuth instead of a key
|
|
177
|
+
|
|
178
|
+
The servers accept a resource-bound OAuth token as well as an API key. That
|
|
179
|
+
matters for Doc: **`mcp.tuncdoc.com` advertises the same scope names as Orb
|
|
180
|
+
(`orb.read`, `orb.write`, `orb.delete`)**, so a token without a resource binding
|
|
181
|
+
defaults to the Orb audience and is rejected by Doc with "not scoped for this
|
|
182
|
+
service". Include the resource when you request the device grant:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
resource=https://mcp.tuncdoc.com/mcp
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
TuncMcpClient(service="doc", api_key=device_access_token)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Agent-to-agent
|
|
193
|
+
|
|
194
|
+
Every service publishes an A2A agent card, so agents that speak A2A can discover
|
|
195
|
+
and call them without a human in the loop:
|
|
196
|
+
|
|
197
|
+
- <https://tuncorb.com/.well-known/agent-card.json>
|
|
198
|
+
- <https://tuncart.com/.well-known/agent-card.json>
|
|
199
|
+
- <https://tuncdoc.com/.well-known/agent-card.json>
|
|
200
|
+
|
|
201
|
+
## What this package does not do
|
|
202
|
+
|
|
203
|
+
It is a client. It does not host anything, and it does not bundle an agent
|
|
204
|
+
runtime. If you want the workspace these services belong to — a multiplayer chat
|
|
205
|
+
where people and agents work together — that is a desktop and mobile app:
|
|
206
|
+
<https://tunchub.com>.
|
|
207
|
+
|
|
208
|
+
## Errors
|
|
209
|
+
|
|
210
|
+
Everything raises `TuncMcpError` with a `status`, a `code` and the server's
|
|
211
|
+
message. A `401` means the key is missing, revoked, or not scoped for that
|
|
212
|
+
service; the message says which.
|
|
213
|
+
|
|
214
|
+
## 中文
|
|
215
|
+
|
|
216
|
+
**面向 AI 智能体的记忆数据库、制品存储与文档编辑 —— 通过 MCP 接入。**
|
|
217
|
+
|
|
218
|
+
`tunc-mcp` 是一个零依赖的 Python MCP 客户端,连接三个托管服务:
|
|
219
|
+
|
|
220
|
+
| 服务 | 用途 | 接入点 |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| **Tunc Orb** | **智能体记忆数据库** —— 持久保存记录、日志与结构化状态,可在所有智能体之间共享 | `https://mcp.tuncorb.com/mcp` |
|
|
223
|
+
| **Tunc Art** | **制品存储与文件分享** —— 保存智能体生成的文件,返回私密短链接 | `https://mcp.tuncart.com/mcp` |
|
|
224
|
+
| **Tunc Doc** | **文档、表格与幻灯片** —— 可导入导出 DOCX、XLSX、PPTX、PDF | `https://mcp.tuncdoc.com/mcp` |
|
|
225
|
+
|
|
226
|
+
三个服务同时支持 **MCP**、**A2A**(智能体间通信)与 **REST**,同一个账号即可通用。
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install tunc-mcp
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
API 密钥:<https://tunchub.com/account/keys/new>(可设为只读)。
|
|
233
|
+
|
|
234
|
+
### 并发写入
|
|
235
|
+
|
|
236
|
+
`data_update` 接受 `if_revision`。两个智能体写同一条记录时不会互相覆盖:后到的写入收到 `revision_conflict`,重新读取后重试。这把多智能体最常见的“静默丢更新”故障,变成一个可以被发现的错误。
|
|
237
|
+
|
|
238
|
+
完整的多人协作工作空间(人与多个智能体共享同一实时对话)见 <https://tunchub.com>。
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT © TUNC LLC
|
tunc_mcp-0.1.0/README.md
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
# tunc-mcp
|
|
2
|
+
|
|
3
|
+
**Agent memory, artifact storage, and document editing for AI agents — over MCP.**
|
|
4
|
+
|
|
5
|
+
A small, dependency-free MCP (Model Context Protocol) client in Python for three
|
|
6
|
+
hosted services that AI agents and multi-agent systems actually need:
|
|
7
|
+
|
|
8
|
+
| Service | What it is | MCP endpoint |
|
|
9
|
+
|---|---|---|
|
|
10
|
+
| **Tunc Orb** | **Agent memory database** — durable records, logs and structured state shared by every agent you run | `https://mcp.tuncorb.com/mcp` |
|
|
11
|
+
| **Tunc Art** | **Artifact storage and private file sharing** — agent-generated files behind short private links | `https://mcp.tuncart.com/mcp` |
|
|
12
|
+
| **Tunc Doc** | **Documents, spreadsheets and slide decks** — import/export DOCX, XLSX, PPTX, PDF | `https://mcp.tuncdoc.com/mcp` |
|
|
13
|
+
|
|
14
|
+
All three are also reachable over **A2A** (agent-to-agent) and plain **REST**, so
|
|
15
|
+
the same account works whether your agent speaks MCP, A2A, or HTTP.
|
|
16
|
+
|
|
17
|
+
## Why this exists
|
|
18
|
+
|
|
19
|
+
Most agent stacks lose state the moment a context window rolls over. Writes from
|
|
20
|
+
two agents at once silently clobber each other. Artifacts end up as files on
|
|
21
|
+
someone's laptop. This gives an agent three boring, reliable primitives instead:
|
|
22
|
+
|
|
23
|
+
- **Memory that survives the conversation.** Records persist across sessions,
|
|
24
|
+
across agents, across models, with revision-guarded writes.
|
|
25
|
+
- **Artifacts with links you can send.** Upload a file, get a short private URL
|
|
26
|
+
back. No bucket configuration.
|
|
27
|
+
- **Documents an agent can actually produce.** Real DOCX, XLSX and PPTX, not
|
|
28
|
+
markdown pretending to be a report.
|
|
29
|
+
|
|
30
|
+
### Concurrency is the interesting part
|
|
31
|
+
|
|
32
|
+
`data_update` takes `if_revision`. Two agents writing the same record do not
|
|
33
|
+
race: the second gets a `revision_conflict`, re-reads, and retries. That turns a
|
|
34
|
+
lost-update bug — the classic multi-agent failure where a plausible but wrong
|
|
35
|
+
number survives — into a loud retry.
|
|
36
|
+
|
|
37
|
+
## Install
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
pip install tunc-mcp
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Python 3.9 or newer. No dependencies. Get an API key at
|
|
44
|
+
<https://tunchub.com/account/keys/new> — you can scope it read-only.
|
|
45
|
+
|
|
46
|
+
## Use
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from tunc_mcp import orb, art, doc
|
|
50
|
+
|
|
51
|
+
memory = orb() # reads TUNC_API_KEY
|
|
52
|
+
|
|
53
|
+
# Every tool takes an `operation` that picks what it does. Call list_tools()
|
|
54
|
+
# once and read the real schemas rather than trusting an example.
|
|
55
|
+
#
|
|
56
|
+
# data_write operation: create | append
|
|
57
|
+
# data_read operation: shelves | list | get | log | search | status
|
|
58
|
+
# data_update operation: replace | patch (needs if_revision)
|
|
59
|
+
# data_delete operation: record | entries | log (needs if_revision)
|
|
60
|
+
|
|
61
|
+
memory.call_tool("data_write", {
|
|
62
|
+
"operation": "create",
|
|
63
|
+
"name": "contacts/ada",
|
|
64
|
+
"about": "Primary contact, Example Ltd",
|
|
65
|
+
"value": {"email": "ada@example.com", "timezone": "Europe/London"},
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
record = memory.call_tool("data_read", {"operation": "get", "name": "contacts/ada"})
|
|
69
|
+
revision = record["structuredContent"]["revision"]
|
|
70
|
+
|
|
71
|
+
memory.call_tool("data_update", {
|
|
72
|
+
"operation": "patch",
|
|
73
|
+
"name": "contacts/ada",
|
|
74
|
+
"if_revision": revision,
|
|
75
|
+
"patch": [{"op": "replace", "path": "/timezone", "value": "Europe/Paris"}],
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
memory.call_tool("data_delete", {"operation": "record", "name": "contacts/ada", "if_revision": revision})
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Artifacts — `art_write` takes `operation: create` and a `name`, and the result
|
|
82
|
+
keys the artifact by `id`:
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
files = art()
|
|
86
|
+
stored = files.call_tool("art_write", {
|
|
87
|
+
"operation": "create",
|
|
88
|
+
"name": "quarterly-report.pdf",
|
|
89
|
+
"mime_type": "application/pdf",
|
|
90
|
+
"content_base64": base64_bytes,
|
|
91
|
+
})
|
|
92
|
+
artifact_id = stored["structuredContent"]["artifact"]["id"]
|
|
93
|
+
|
|
94
|
+
files.call_tool("art_read", {"operation": "get", "artifact_id": artifact_id})
|
|
95
|
+
files.call_tool("art_delete", {"artifact_id": artifact_id})
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Documents — `doc_create` needs only a title and returns the document under
|
|
99
|
+
`result.doc.id`; every other doc tool addresses it with `doc`:
|
|
100
|
+
|
|
101
|
+
```python
|
|
102
|
+
docs = doc()
|
|
103
|
+
made = docs.call_tool("doc_create", {"title": "Q4 summary", "markdown": "# Q4\n\nRevenue up 12%."})
|
|
104
|
+
doc_id = made["structuredContent"]["result"]["doc"]["id"]
|
|
105
|
+
|
|
106
|
+
docs.call_tool("doc_read", {"doc": doc_id})
|
|
107
|
+
docs.call_tool("doc_delete", {"doc": doc_id})
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Discover what any service offers:
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
for tool in orb().list_tools():
|
|
114
|
+
print(tool["name"], tool.get("inputSchema", {}).get("required"))
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## CLI
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
export TUNC_API_KEY=...
|
|
121
|
+
|
|
122
|
+
tunc-mcp services
|
|
123
|
+
tunc-mcp tools --service orb
|
|
124
|
+
tunc-mcp call data_read '{"operation": "get", "name": "contacts/ada"}' --service orb
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## Options
|
|
128
|
+
|
|
129
|
+
```python
|
|
130
|
+
memory = orb(
|
|
131
|
+
api_key="...", # or TUNC_API_KEY in the environment
|
|
132
|
+
endpoint="https://mcp.tuncorb.com/mcp", # override: staging, a proxy, a local mock
|
|
133
|
+
)
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
- `api_key` — defaults to `TUNC_API_KEY`. Checked when the client is constructed.
|
|
137
|
+
- `endpoint` — replaces the built-in URL for that service, which is how you point
|
|
138
|
+
it at a test server.
|
|
139
|
+
- `service` — `"orb"`, `"art"` or `"doc"`, if you construct `TuncMcpClient`
|
|
140
|
+
directly instead of using `orb()` / `art()` / `doc()`.
|
|
141
|
+
|
|
142
|
+
`SERVERS` is the map of default endpoints. `connect(key)` returns all three
|
|
143
|
+
clients at once.
|
|
144
|
+
|
|
145
|
+
## Wire it into an MCP client
|
|
146
|
+
|
|
147
|
+
Any MCP-capable host works. Point it at the endpoint and pass the key as a
|
|
148
|
+
bearer header. For a Python host, the usual shape is:
|
|
149
|
+
|
|
150
|
+
```python
|
|
151
|
+
from tunc_mcp import orb
|
|
152
|
+
client = orb()
|
|
153
|
+
tools = client.list_tools()
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
## OAuth instead of a key
|
|
157
|
+
|
|
158
|
+
The servers accept a resource-bound OAuth token as well as an API key. That
|
|
159
|
+
matters for Doc: **`mcp.tuncdoc.com` advertises the same scope names as Orb
|
|
160
|
+
(`orb.read`, `orb.write`, `orb.delete`)**, so a token without a resource binding
|
|
161
|
+
defaults to the Orb audience and is rejected by Doc with "not scoped for this
|
|
162
|
+
service". Include the resource when you request the device grant:
|
|
163
|
+
|
|
164
|
+
```
|
|
165
|
+
resource=https://mcp.tuncdoc.com/mcp
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```python
|
|
169
|
+
TuncMcpClient(service="doc", api_key=device_access_token)
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Agent-to-agent
|
|
173
|
+
|
|
174
|
+
Every service publishes an A2A agent card, so agents that speak A2A can discover
|
|
175
|
+
and call them without a human in the loop:
|
|
176
|
+
|
|
177
|
+
- <https://tuncorb.com/.well-known/agent-card.json>
|
|
178
|
+
- <https://tuncart.com/.well-known/agent-card.json>
|
|
179
|
+
- <https://tuncdoc.com/.well-known/agent-card.json>
|
|
180
|
+
|
|
181
|
+
## What this package does not do
|
|
182
|
+
|
|
183
|
+
It is a client. It does not host anything, and it does not bundle an agent
|
|
184
|
+
runtime. If you want the workspace these services belong to — a multiplayer chat
|
|
185
|
+
where people and agents work together — that is a desktop and mobile app:
|
|
186
|
+
<https://tunchub.com>.
|
|
187
|
+
|
|
188
|
+
## Errors
|
|
189
|
+
|
|
190
|
+
Everything raises `TuncMcpError` with a `status`, a `code` and the server's
|
|
191
|
+
message. A `401` means the key is missing, revoked, or not scoped for that
|
|
192
|
+
service; the message says which.
|
|
193
|
+
|
|
194
|
+
## 中文
|
|
195
|
+
|
|
196
|
+
**面向 AI 智能体的记忆数据库、制品存储与文档编辑 —— 通过 MCP 接入。**
|
|
197
|
+
|
|
198
|
+
`tunc-mcp` 是一个零依赖的 Python MCP 客户端,连接三个托管服务:
|
|
199
|
+
|
|
200
|
+
| 服务 | 用途 | 接入点 |
|
|
201
|
+
|---|---|---|
|
|
202
|
+
| **Tunc Orb** | **智能体记忆数据库** —— 持久保存记录、日志与结构化状态,可在所有智能体之间共享 | `https://mcp.tuncorb.com/mcp` |
|
|
203
|
+
| **Tunc Art** | **制品存储与文件分享** —— 保存智能体生成的文件,返回私密短链接 | `https://mcp.tuncart.com/mcp` |
|
|
204
|
+
| **Tunc Doc** | **文档、表格与幻灯片** —— 可导入导出 DOCX、XLSX、PPTX、PDF | `https://mcp.tuncdoc.com/mcp` |
|
|
205
|
+
|
|
206
|
+
三个服务同时支持 **MCP**、**A2A**(智能体间通信)与 **REST**,同一个账号即可通用。
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
pip install tunc-mcp
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
API 密钥:<https://tunchub.com/account/keys/new>(可设为只读)。
|
|
213
|
+
|
|
214
|
+
### 并发写入
|
|
215
|
+
|
|
216
|
+
`data_update` 接受 `if_revision`。两个智能体写同一条记录时不会互相覆盖:后到的写入收到 `revision_conflict`,重新读取后重试。这把多智能体最常见的“静默丢更新”故障,变成一个可以被发现的错误。
|
|
217
|
+
|
|
218
|
+
完整的多人协作工作空间(人与多个智能体共享同一实时对话)见 <https://tunchub.com>。
|
|
219
|
+
|
|
220
|
+
## License
|
|
221
|
+
|
|
222
|
+
MIT © TUNC LLC
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "tunc-mcp"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "MCP client for Tunc Orb (agent memory database), Tunc Art (artifact storage and private file sharing) and Tunc Doc (documents, spreadsheets, decks)."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "TUNC LLC", email = "hello@tunc.ai" }]
|
|
13
|
+
keywords = [
|
|
14
|
+
"mcp", "model-context-protocol", "agent-memory", "agent-database", "memory",
|
|
15
|
+
"agent", "agents", "multi-agent", "multi-agent-orchestration", "artifact",
|
|
16
|
+
"artifacts", "artifact-storage", "file-sharing", "private-links", "storage",
|
|
17
|
+
"database", "records", "documents", "spreadsheets", "slide-decks", "a2a",
|
|
18
|
+
"agent-to-agent", "ai-agents", "llm", "tunc",
|
|
19
|
+
]
|
|
20
|
+
classifiers = [
|
|
21
|
+
"Programming Language :: Python :: 3",
|
|
22
|
+
"License :: OSI Approved :: MIT License",
|
|
23
|
+
"Operating System :: OS Independent",
|
|
24
|
+
"Topic :: Software Development :: Libraries",
|
|
25
|
+
]
|
|
26
|
+
dependencies = []
|
|
27
|
+
|
|
28
|
+
[project.optional-dependencies]
|
|
29
|
+
# The protocol work here is a few hundred lines against three fixed endpoints,
|
|
30
|
+
# so the package stays dependency-free; this extra is only for running tests.
|
|
31
|
+
test = ["pytest>=7"]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://tuncorb.com"
|
|
35
|
+
Repository = "https://github.com/TUNC-AI/tunc-mcp-python"
|
|
36
|
+
|
|
37
|
+
[project.scripts]
|
|
38
|
+
tunc-mcp = "tunc_mcp.cli:main"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
tunc_mcp-0.1.0/setup.cfg
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""tunc-mcp — MCP client for Tunc Orb, Tunc Art and Tunc Doc."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
PROTOCOL_VERSION,
|
|
5
|
+
SERVERS,
|
|
6
|
+
TuncMcpClient,
|
|
7
|
+
TuncMcpError,
|
|
8
|
+
art,
|
|
9
|
+
connect,
|
|
10
|
+
doc,
|
|
11
|
+
orb,
|
|
12
|
+
parse_body,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"PROTOCOL_VERSION",
|
|
17
|
+
"SERVERS",
|
|
18
|
+
"TuncMcpClient",
|
|
19
|
+
"TuncMcpError",
|
|
20
|
+
"art",
|
|
21
|
+
"connect",
|
|
22
|
+
"doc",
|
|
23
|
+
"orb",
|
|
24
|
+
"parse_body",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Command line: inspect and call the Tunc MCP servers.
|
|
2
|
+
|
|
3
|
+
export TUNC_API_KEY=...
|
|
4
|
+
tunc-mcp services
|
|
5
|
+
tunc-mcp tools --service orb
|
|
6
|
+
tunc-mcp call data_read '{"operation": "get", "name": "contacts/ada"}'
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import sys
|
|
14
|
+
from typing import List, Optional
|
|
15
|
+
|
|
16
|
+
from .client import SERVERS, TuncMcpClient, TuncMcpError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(prog="tunc-mcp", description="MCP client for Tunc Orb, Tunc Art and Tunc Doc.")
|
|
21
|
+
sub = parser.add_subparsers(dest="command")
|
|
22
|
+
|
|
23
|
+
sub.add_parser("services", help="print the built-in endpoints")
|
|
24
|
+
|
|
25
|
+
tools = sub.add_parser("tools", help="list the tools a service offers")
|
|
26
|
+
tools.add_argument("--service", default="orb", choices=sorted(SERVERS))
|
|
27
|
+
tools.add_argument("--json", action="store_true", help="print the raw schema JSON")
|
|
28
|
+
|
|
29
|
+
call = sub.add_parser("call", help="call one tool")
|
|
30
|
+
call.add_argument("tool")
|
|
31
|
+
call.add_argument("arguments", nargs="?", default="{}", help="JSON object")
|
|
32
|
+
call.add_argument("--service", default="orb", choices=sorted(SERVERS))
|
|
33
|
+
|
|
34
|
+
return parser
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
38
|
+
parser = build_parser()
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
|
|
41
|
+
if args.command == "services":
|
|
42
|
+
print(json.dumps(SERVERS, indent=2))
|
|
43
|
+
return 0
|
|
44
|
+
|
|
45
|
+
if not args.command:
|
|
46
|
+
parser.print_help()
|
|
47
|
+
return 0
|
|
48
|
+
|
|
49
|
+
try:
|
|
50
|
+
client = TuncMcpClient(service=args.service)
|
|
51
|
+
except TuncMcpError as error:
|
|
52
|
+
print(f"tunc-mcp: {error}", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
if args.command == "tools":
|
|
57
|
+
tools = client.list_tools()
|
|
58
|
+
if args.json:
|
|
59
|
+
print(json.dumps(tools, indent=2))
|
|
60
|
+
return 0
|
|
61
|
+
print(f"{len(tools)} tools on {args.service} ({client.endpoint})\n")
|
|
62
|
+
for tool in tools:
|
|
63
|
+
summary = (tool.get("description") or "").split(".")[0]
|
|
64
|
+
print(f" {tool.get('name')}" + (f" — {summary}" if summary else ""))
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
if args.command == "call":
|
|
68
|
+
try:
|
|
69
|
+
arguments = json.loads(args.arguments)
|
|
70
|
+
except ValueError:
|
|
71
|
+
print("tunc-mcp: arguments must be a JSON object", file=sys.stderr)
|
|
72
|
+
return 1
|
|
73
|
+
print(json.dumps(client.call_tool(args.tool, arguments), indent=2))
|
|
74
|
+
return 0
|
|
75
|
+
except TuncMcpError as error:
|
|
76
|
+
print(f"tunc-mcp: {error}", file=sys.stderr)
|
|
77
|
+
if error.status:
|
|
78
|
+
print(f" HTTP {error.status}", file=sys.stderr)
|
|
79
|
+
return 1
|
|
80
|
+
|
|
81
|
+
parser.print_help()
|
|
82
|
+
return 0
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
if __name__ == "__main__":
|
|
86
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Minimal MCP (Model Context Protocol) client over streamable HTTP.
|
|
2
|
+
|
|
3
|
+
Speaks just enough of the protocol to talk to the Tunc servers: initialize,
|
|
4
|
+
tools/list and tools/call, with JSON-RPC 2.0 envelopes and event-stream
|
|
5
|
+
responses. Standard library only.
|
|
6
|
+
|
|
7
|
+
Servers:
|
|
8
|
+
Tunc Orb https://mcp.tuncorb.com/mcp agent memory database
|
|
9
|
+
Tunc Art https://mcp.tuncart.com/mcp artifact storage, private links
|
|
10
|
+
Tunc Doc https://mcp.tuncdoc.com/mcp documents, spreadsheets, decks
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import urllib.error
|
|
18
|
+
import urllib.request
|
|
19
|
+
import uuid
|
|
20
|
+
from typing import Any, Dict, List, Optional
|
|
21
|
+
|
|
22
|
+
SERVERS = {
|
|
23
|
+
"orb": "https://mcp.tuncorb.com/mcp",
|
|
24
|
+
"art": "https://mcp.tuncart.com/mcp",
|
|
25
|
+
"doc": "https://mcp.tuncdoc.com/mcp",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
PROTOCOL_VERSION = "2025-06-18"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class TuncMcpError(Exception):
|
|
32
|
+
"""An MCP or HTTP failure, carrying what the server actually said."""
|
|
33
|
+
|
|
34
|
+
def __init__(self, message: str, status: Optional[int] = None, code: Any = None, body: Any = None):
|
|
35
|
+
super().__init__(message)
|
|
36
|
+
self.status = status
|
|
37
|
+
self.code = code
|
|
38
|
+
self.body = body
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def parse_body(content_type: str, text: str) -> Optional[dict]:
|
|
42
|
+
"""Return the JSON-RPC payload from an event-stream or plain JSON body.
|
|
43
|
+
|
|
44
|
+
Streamable HTTP lets a server answer either way; Tunc answers JSON, other
|
|
45
|
+
MCP servers may answer text/event-stream, so both are handled.
|
|
46
|
+
"""
|
|
47
|
+
if not text:
|
|
48
|
+
return None
|
|
49
|
+
if "text/event-stream" not in (content_type or ""):
|
|
50
|
+
try:
|
|
51
|
+
return json.loads(text)
|
|
52
|
+
except ValueError:
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
last = None
|
|
56
|
+
for block in text.replace("\r\n", "\n").split("\n\n"):
|
|
57
|
+
data = "\n".join(
|
|
58
|
+
line[5:].lstrip() for line in block.split("\n") if line.startswith("data:")
|
|
59
|
+
)
|
|
60
|
+
if not data:
|
|
61
|
+
continue
|
|
62
|
+
try:
|
|
63
|
+
last = json.loads(data)
|
|
64
|
+
except ValueError:
|
|
65
|
+
# A partial frame: keep the previous complete one.
|
|
66
|
+
continue
|
|
67
|
+
return last
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TuncMcpClient:
|
|
71
|
+
def __init__(self, service: str = "orb", endpoint: Optional[str] = None, api_key: Optional[str] = None):
|
|
72
|
+
self.endpoint = endpoint or SERVERS.get(service)
|
|
73
|
+
if not self.endpoint:
|
|
74
|
+
raise TuncMcpError(f'Unknown service "{service}". Use orb, art or doc, or pass an endpoint.')
|
|
75
|
+
self.api_key = api_key or os.environ.get("TUNC_API_KEY")
|
|
76
|
+
if not self.api_key:
|
|
77
|
+
raise TuncMcpError(
|
|
78
|
+
"No API key. Pass api_key or set TUNC_API_KEY. "
|
|
79
|
+
"Create one at https://tunchub.com/account/keys/new"
|
|
80
|
+
)
|
|
81
|
+
self.session_id: Optional[str] = None
|
|
82
|
+
self._next_id = 1
|
|
83
|
+
self._initialized = False
|
|
84
|
+
self.server_info: Optional[dict] = None
|
|
85
|
+
self.capabilities: Optional[dict] = None
|
|
86
|
+
|
|
87
|
+
# -- transport ---------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def _headers(self) -> Dict[str, str]:
|
|
90
|
+
headers = {
|
|
91
|
+
"content-type": "application/json",
|
|
92
|
+
"accept": "application/json, text/event-stream",
|
|
93
|
+
"authorization": f"Bearer {self.api_key}",
|
|
94
|
+
# Cloudflare in front of the Tunc endpoints rejects urllib's default
|
|
95
|
+
# "Python-urllib/3.x" agent with a 403, which looks like an auth
|
|
96
|
+
# failure but is not. Always send a real agent.
|
|
97
|
+
"user-agent": "tunc-mcp-python/0.1.0",
|
|
98
|
+
}
|
|
99
|
+
if self.session_id:
|
|
100
|
+
headers["mcp-session-id"] = self.session_id
|
|
101
|
+
return headers
|
|
102
|
+
|
|
103
|
+
def _post(self, payload: dict) -> Any:
|
|
104
|
+
data = json.dumps(payload).encode("utf-8")
|
|
105
|
+
request = urllib.request.Request(self.endpoint, data=data, headers=self._headers(), method="POST")
|
|
106
|
+
try:
|
|
107
|
+
with urllib.request.urlopen(request, timeout=60) as response:
|
|
108
|
+
session = response.headers.get("mcp-session-id")
|
|
109
|
+
if session:
|
|
110
|
+
self.session_id = session
|
|
111
|
+
text = response.read().decode("utf-8", "replace")
|
|
112
|
+
content_type = response.headers.get("content-type", "")
|
|
113
|
+
except urllib.error.HTTPError as error:
|
|
114
|
+
text = error.read().decode("utf-8", "replace")
|
|
115
|
+
body = parse_body(error.headers.get("content-type", ""), text)
|
|
116
|
+
server_message = (body or {}).get("error", {}).get("message")
|
|
117
|
+
if error.code == 401:
|
|
118
|
+
raise TuncMcpError(
|
|
119
|
+
f"{server_message} (the API key is missing, revoked, or not scoped for this service)"
|
|
120
|
+
if server_message
|
|
121
|
+
else "Unauthorized. The API key is missing, revoked, or not scoped for this service.",
|
|
122
|
+
status=401,
|
|
123
|
+
code=(body or {}).get("error", {}).get("code"),
|
|
124
|
+
body=body,
|
|
125
|
+
) from None
|
|
126
|
+
raise TuncMcpError(
|
|
127
|
+
server_message or f"HTTP {error.code}", status=error.code, body=body
|
|
128
|
+
) from None
|
|
129
|
+
|
|
130
|
+
body = parse_body(content_type, text)
|
|
131
|
+
if body and body.get("error"):
|
|
132
|
+
raise TuncMcpError(
|
|
133
|
+
body["error"].get("message", "JSON-RPC error"), code=body["error"].get("code"), body=body
|
|
134
|
+
)
|
|
135
|
+
return (body or {}).get("result")
|
|
136
|
+
|
|
137
|
+
def _rpc(self, method: str, params: Optional[dict] = None) -> Any:
|
|
138
|
+
payload = {"jsonrpc": "2.0", "id": self._next_id, "method": method}
|
|
139
|
+
if params is not None:
|
|
140
|
+
payload["params"] = params
|
|
141
|
+
self._next_id += 1
|
|
142
|
+
return self._post(payload)
|
|
143
|
+
|
|
144
|
+
# -- protocol ----------------------------------------------------------
|
|
145
|
+
|
|
146
|
+
def initialize(self) -> "TuncMcpClient":
|
|
147
|
+
if self._initialized:
|
|
148
|
+
return self
|
|
149
|
+
result = self._rpc(
|
|
150
|
+
"initialize",
|
|
151
|
+
{
|
|
152
|
+
"protocolVersion": PROTOCOL_VERSION,
|
|
153
|
+
"capabilities": {},
|
|
154
|
+
"clientInfo": {"name": "tunc-mcp-python", "version": "0.1.0"},
|
|
155
|
+
},
|
|
156
|
+
)
|
|
157
|
+
# A notification carries no id and expects no body.
|
|
158
|
+
try:
|
|
159
|
+
self._post({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
|
160
|
+
except TuncMcpError:
|
|
161
|
+
pass
|
|
162
|
+
self.server_info = (result or {}).get("serverInfo")
|
|
163
|
+
self.capabilities = (result or {}).get("capabilities")
|
|
164
|
+
self._initialized = True
|
|
165
|
+
return self
|
|
166
|
+
|
|
167
|
+
def list_tools(self) -> List[dict]:
|
|
168
|
+
self.initialize()
|
|
169
|
+
return (self._rpc("tools/list", {}) or {}).get("tools", [])
|
|
170
|
+
|
|
171
|
+
def call_tool(self, name: str, arguments: Optional[dict] = None) -> Any:
|
|
172
|
+
self.initialize()
|
|
173
|
+
return self._rpc("tools/call", {"name": name, "arguments": arguments or {}})
|
|
174
|
+
|
|
175
|
+
def call_text(self, name: str, arguments: Optional[dict] = None) -> str:
|
|
176
|
+
"""The text content of a tool result, joined."""
|
|
177
|
+
result = self.call_tool(name, arguments)
|
|
178
|
+
parts = result.get("content", []) if isinstance(result, dict) else []
|
|
179
|
+
text = "\n".join(p.get("text", "") for p in parts if isinstance(p, dict) and p.get("type") == "text")
|
|
180
|
+
return text or json.dumps(result)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def orb(**kwargs: Any) -> TuncMcpClient:
|
|
184
|
+
return TuncMcpClient(service="orb", **kwargs)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def art(**kwargs: Any) -> TuncMcpClient:
|
|
188
|
+
return TuncMcpClient(service="art", **kwargs)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def doc(**kwargs: Any) -> TuncMcpClient:
|
|
192
|
+
return TuncMcpClient(service="doc", **kwargs)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def connect(api_key: Optional[str] = None) -> Dict[str, TuncMcpClient]:
|
|
196
|
+
"""All three clients from one key."""
|
|
197
|
+
key = api_key or os.environ.get("TUNC_API_KEY")
|
|
198
|
+
return {"orb": orb(api_key=key), "art": art(api_key=key), "doc": doc(api_key=key)}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tunc-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP client for Tunc Orb (agent memory database), Tunc Art (artifact storage and private file sharing) and Tunc Doc (documents, spreadsheets, decks).
|
|
5
|
+
Author-email: TUNC LLC <hello@tunc.ai>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://tuncorb.com
|
|
8
|
+
Project-URL: Repository, https://github.com/TUNC-AI/tunc-mcp-python
|
|
9
|
+
Keywords: mcp,model-context-protocol,agent-memory,agent-database,memory,agent,agents,multi-agent,multi-agent-orchestration,artifact,artifacts,artifact-storage,file-sharing,private-links,storage,database,records,documents,spreadsheets,slide-decks,a2a,agent-to-agent,ai-agents,llm,tunc
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Operating System :: OS Independent
|
|
13
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
14
|
+
Requires-Python: >=3.9
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
License-File: LICENSE
|
|
17
|
+
Provides-Extra: test
|
|
18
|
+
Requires-Dist: pytest>=7; extra == "test"
|
|
19
|
+
Dynamic: license-file
|
|
20
|
+
|
|
21
|
+
# tunc-mcp
|
|
22
|
+
|
|
23
|
+
**Agent memory, artifact storage, and document editing for AI agents — over MCP.**
|
|
24
|
+
|
|
25
|
+
A small, dependency-free MCP (Model Context Protocol) client in Python for three
|
|
26
|
+
hosted services that AI agents and multi-agent systems actually need:
|
|
27
|
+
|
|
28
|
+
| Service | What it is | MCP endpoint |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| **Tunc Orb** | **Agent memory database** — durable records, logs and structured state shared by every agent you run | `https://mcp.tuncorb.com/mcp` |
|
|
31
|
+
| **Tunc Art** | **Artifact storage and private file sharing** — agent-generated files behind short private links | `https://mcp.tuncart.com/mcp` |
|
|
32
|
+
| **Tunc Doc** | **Documents, spreadsheets and slide decks** — import/export DOCX, XLSX, PPTX, PDF | `https://mcp.tuncdoc.com/mcp` |
|
|
33
|
+
|
|
34
|
+
All three are also reachable over **A2A** (agent-to-agent) and plain **REST**, so
|
|
35
|
+
the same account works whether your agent speaks MCP, A2A, or HTTP.
|
|
36
|
+
|
|
37
|
+
## Why this exists
|
|
38
|
+
|
|
39
|
+
Most agent stacks lose state the moment a context window rolls over. Writes from
|
|
40
|
+
two agents at once silently clobber each other. Artifacts end up as files on
|
|
41
|
+
someone's laptop. This gives an agent three boring, reliable primitives instead:
|
|
42
|
+
|
|
43
|
+
- **Memory that survives the conversation.** Records persist across sessions,
|
|
44
|
+
across agents, across models, with revision-guarded writes.
|
|
45
|
+
- **Artifacts with links you can send.** Upload a file, get a short private URL
|
|
46
|
+
back. No bucket configuration.
|
|
47
|
+
- **Documents an agent can actually produce.** Real DOCX, XLSX and PPTX, not
|
|
48
|
+
markdown pretending to be a report.
|
|
49
|
+
|
|
50
|
+
### Concurrency is the interesting part
|
|
51
|
+
|
|
52
|
+
`data_update` takes `if_revision`. Two agents writing the same record do not
|
|
53
|
+
race: the second gets a `revision_conflict`, re-reads, and retries. That turns a
|
|
54
|
+
lost-update bug — the classic multi-agent failure where a plausible but wrong
|
|
55
|
+
number survives — into a loud retry.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pip install tunc-mcp
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Python 3.9 or newer. No dependencies. Get an API key at
|
|
64
|
+
<https://tunchub.com/account/keys/new> — you can scope it read-only.
|
|
65
|
+
|
|
66
|
+
## Use
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from tunc_mcp import orb, art, doc
|
|
70
|
+
|
|
71
|
+
memory = orb() # reads TUNC_API_KEY
|
|
72
|
+
|
|
73
|
+
# Every tool takes an `operation` that picks what it does. Call list_tools()
|
|
74
|
+
# once and read the real schemas rather than trusting an example.
|
|
75
|
+
#
|
|
76
|
+
# data_write operation: create | append
|
|
77
|
+
# data_read operation: shelves | list | get | log | search | status
|
|
78
|
+
# data_update operation: replace | patch (needs if_revision)
|
|
79
|
+
# data_delete operation: record | entries | log (needs if_revision)
|
|
80
|
+
|
|
81
|
+
memory.call_tool("data_write", {
|
|
82
|
+
"operation": "create",
|
|
83
|
+
"name": "contacts/ada",
|
|
84
|
+
"about": "Primary contact, Example Ltd",
|
|
85
|
+
"value": {"email": "ada@example.com", "timezone": "Europe/London"},
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
record = memory.call_tool("data_read", {"operation": "get", "name": "contacts/ada"})
|
|
89
|
+
revision = record["structuredContent"]["revision"]
|
|
90
|
+
|
|
91
|
+
memory.call_tool("data_update", {
|
|
92
|
+
"operation": "patch",
|
|
93
|
+
"name": "contacts/ada",
|
|
94
|
+
"if_revision": revision,
|
|
95
|
+
"patch": [{"op": "replace", "path": "/timezone", "value": "Europe/Paris"}],
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
memory.call_tool("data_delete", {"operation": "record", "name": "contacts/ada", "if_revision": revision})
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Artifacts — `art_write` takes `operation: create` and a `name`, and the result
|
|
102
|
+
keys the artifact by `id`:
|
|
103
|
+
|
|
104
|
+
```python
|
|
105
|
+
files = art()
|
|
106
|
+
stored = files.call_tool("art_write", {
|
|
107
|
+
"operation": "create",
|
|
108
|
+
"name": "quarterly-report.pdf",
|
|
109
|
+
"mime_type": "application/pdf",
|
|
110
|
+
"content_base64": base64_bytes,
|
|
111
|
+
})
|
|
112
|
+
artifact_id = stored["structuredContent"]["artifact"]["id"]
|
|
113
|
+
|
|
114
|
+
files.call_tool("art_read", {"operation": "get", "artifact_id": artifact_id})
|
|
115
|
+
files.call_tool("art_delete", {"artifact_id": artifact_id})
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Documents — `doc_create` needs only a title and returns the document under
|
|
119
|
+
`result.doc.id`; every other doc tool addresses it with `doc`:
|
|
120
|
+
|
|
121
|
+
```python
|
|
122
|
+
docs = doc()
|
|
123
|
+
made = docs.call_tool("doc_create", {"title": "Q4 summary", "markdown": "# Q4\n\nRevenue up 12%."})
|
|
124
|
+
doc_id = made["structuredContent"]["result"]["doc"]["id"]
|
|
125
|
+
|
|
126
|
+
docs.call_tool("doc_read", {"doc": doc_id})
|
|
127
|
+
docs.call_tool("doc_delete", {"doc": doc_id})
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Discover what any service offers:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
for tool in orb().list_tools():
|
|
134
|
+
print(tool["name"], tool.get("inputSchema", {}).get("required"))
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
## CLI
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
export TUNC_API_KEY=...
|
|
141
|
+
|
|
142
|
+
tunc-mcp services
|
|
143
|
+
tunc-mcp tools --service orb
|
|
144
|
+
tunc-mcp call data_read '{"operation": "get", "name": "contacts/ada"}' --service orb
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Options
|
|
148
|
+
|
|
149
|
+
```python
|
|
150
|
+
memory = orb(
|
|
151
|
+
api_key="...", # or TUNC_API_KEY in the environment
|
|
152
|
+
endpoint="https://mcp.tuncorb.com/mcp", # override: staging, a proxy, a local mock
|
|
153
|
+
)
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
- `api_key` — defaults to `TUNC_API_KEY`. Checked when the client is constructed.
|
|
157
|
+
- `endpoint` — replaces the built-in URL for that service, which is how you point
|
|
158
|
+
it at a test server.
|
|
159
|
+
- `service` — `"orb"`, `"art"` or `"doc"`, if you construct `TuncMcpClient`
|
|
160
|
+
directly instead of using `orb()` / `art()` / `doc()`.
|
|
161
|
+
|
|
162
|
+
`SERVERS` is the map of default endpoints. `connect(key)` returns all three
|
|
163
|
+
clients at once.
|
|
164
|
+
|
|
165
|
+
## Wire it into an MCP client
|
|
166
|
+
|
|
167
|
+
Any MCP-capable host works. Point it at the endpoint and pass the key as a
|
|
168
|
+
bearer header. For a Python host, the usual shape is:
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
from tunc_mcp import orb
|
|
172
|
+
client = orb()
|
|
173
|
+
tools = client.list_tools()
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## OAuth instead of a key
|
|
177
|
+
|
|
178
|
+
The servers accept a resource-bound OAuth token as well as an API key. That
|
|
179
|
+
matters for Doc: **`mcp.tuncdoc.com` advertises the same scope names as Orb
|
|
180
|
+
(`orb.read`, `orb.write`, `orb.delete`)**, so a token without a resource binding
|
|
181
|
+
defaults to the Orb audience and is rejected by Doc with "not scoped for this
|
|
182
|
+
service". Include the resource when you request the device grant:
|
|
183
|
+
|
|
184
|
+
```
|
|
185
|
+
resource=https://mcp.tuncdoc.com/mcp
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
```python
|
|
189
|
+
TuncMcpClient(service="doc", api_key=device_access_token)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
## Agent-to-agent
|
|
193
|
+
|
|
194
|
+
Every service publishes an A2A agent card, so agents that speak A2A can discover
|
|
195
|
+
and call them without a human in the loop:
|
|
196
|
+
|
|
197
|
+
- <https://tuncorb.com/.well-known/agent-card.json>
|
|
198
|
+
- <https://tuncart.com/.well-known/agent-card.json>
|
|
199
|
+
- <https://tuncdoc.com/.well-known/agent-card.json>
|
|
200
|
+
|
|
201
|
+
## What this package does not do
|
|
202
|
+
|
|
203
|
+
It is a client. It does not host anything, and it does not bundle an agent
|
|
204
|
+
runtime. If you want the workspace these services belong to — a multiplayer chat
|
|
205
|
+
where people and agents work together — that is a desktop and mobile app:
|
|
206
|
+
<https://tunchub.com>.
|
|
207
|
+
|
|
208
|
+
## Errors
|
|
209
|
+
|
|
210
|
+
Everything raises `TuncMcpError` with a `status`, a `code` and the server's
|
|
211
|
+
message. A `401` means the key is missing, revoked, or not scoped for that
|
|
212
|
+
service; the message says which.
|
|
213
|
+
|
|
214
|
+
## 中文
|
|
215
|
+
|
|
216
|
+
**面向 AI 智能体的记忆数据库、制品存储与文档编辑 —— 通过 MCP 接入。**
|
|
217
|
+
|
|
218
|
+
`tunc-mcp` 是一个零依赖的 Python MCP 客户端,连接三个托管服务:
|
|
219
|
+
|
|
220
|
+
| 服务 | 用途 | 接入点 |
|
|
221
|
+
|---|---|---|
|
|
222
|
+
| **Tunc Orb** | **智能体记忆数据库** —— 持久保存记录、日志与结构化状态,可在所有智能体之间共享 | `https://mcp.tuncorb.com/mcp` |
|
|
223
|
+
| **Tunc Art** | **制品存储与文件分享** —— 保存智能体生成的文件,返回私密短链接 | `https://mcp.tuncart.com/mcp` |
|
|
224
|
+
| **Tunc Doc** | **文档、表格与幻灯片** —— 可导入导出 DOCX、XLSX、PPTX、PDF | `https://mcp.tuncdoc.com/mcp` |
|
|
225
|
+
|
|
226
|
+
三个服务同时支持 **MCP**、**A2A**(智能体间通信)与 **REST**,同一个账号即可通用。
|
|
227
|
+
|
|
228
|
+
```bash
|
|
229
|
+
pip install tunc-mcp
|
|
230
|
+
```
|
|
231
|
+
|
|
232
|
+
API 密钥:<https://tunchub.com/account/keys/new>(可设为只读)。
|
|
233
|
+
|
|
234
|
+
### 并发写入
|
|
235
|
+
|
|
236
|
+
`data_update` 接受 `if_revision`。两个智能体写同一条记录时不会互相覆盖:后到的写入收到 `revision_conflict`,重新读取后重试。这把多智能体最常见的“静默丢更新”故障,变成一个可以被发现的错误。
|
|
237
|
+
|
|
238
|
+
完整的多人协作工作空间(人与多个智能体共享同一实时对话)见 <https://tunchub.com>。
|
|
239
|
+
|
|
240
|
+
## License
|
|
241
|
+
|
|
242
|
+
MIT © TUNC LLC
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/tunc_mcp/__init__.py
|
|
5
|
+
src/tunc_mcp/cli.py
|
|
6
|
+
src/tunc_mcp/client.py
|
|
7
|
+
src/tunc_mcp.egg-info/PKG-INFO
|
|
8
|
+
src/tunc_mcp.egg-info/SOURCES.txt
|
|
9
|
+
src/tunc_mcp.egg-info/dependency_links.txt
|
|
10
|
+
src/tunc_mcp.egg-info/entry_points.txt
|
|
11
|
+
src/tunc_mcp.egg-info/requires.txt
|
|
12
|
+
src/tunc_mcp.egg-info/top_level.txt
|
|
13
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
tunc_mcp
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import unittest
|
|
4
|
+
from unittest import mock
|
|
5
|
+
|
|
6
|
+
from tunc_mcp.client import SERVERS, TuncMcpClient, TuncMcpError, parse_body
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ParseBodyTests(unittest.TestCase):
|
|
10
|
+
def test_plain_json(self):
|
|
11
|
+
body = parse_body("application/json", '{"jsonrpc":"2.0","id":1,"result":{"ok":true}}')
|
|
12
|
+
self.assertTrue(body["result"]["ok"])
|
|
13
|
+
|
|
14
|
+
def test_event_stream_takes_last_complete_frame(self):
|
|
15
|
+
sse = (
|
|
16
|
+
'event: message\ndata: {"result":{"n":1}}\n\n'
|
|
17
|
+
'event: message\ndata: {"result":{"n":2}}\n\n'
|
|
18
|
+
)
|
|
19
|
+
self.assertEqual(parse_body("text/event-stream", sse)["result"]["n"], 2)
|
|
20
|
+
|
|
21
|
+
def test_truncated_frame_is_ignored(self):
|
|
22
|
+
sse = 'data: {"result":{"n":1}}\n\nata: {"result":\n'
|
|
23
|
+
self.assertEqual(parse_body("text/event-stream", sse)["result"]["n"], 1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ClientTests(unittest.TestCase):
|
|
27
|
+
def test_endpoints_are_the_documented_servers(self):
|
|
28
|
+
self.assertEqual(SERVERS["orb"], "https://mcp.tuncorb.com/mcp")
|
|
29
|
+
self.assertEqual(SERVERS["art"], "https://mcp.tuncart.com/mcp")
|
|
30
|
+
self.assertEqual(SERVERS["doc"], "https://mcp.tuncdoc.com/mcp")
|
|
31
|
+
|
|
32
|
+
def test_missing_key_fails_before_any_request(self):
|
|
33
|
+
saved = os.environ.pop("TUNC_API_KEY", None)
|
|
34
|
+
try:
|
|
35
|
+
with self.assertRaises(TuncMcpError):
|
|
36
|
+
TuncMcpClient(service="orb")
|
|
37
|
+
finally:
|
|
38
|
+
if saved is not None:
|
|
39
|
+
os.environ["TUNC_API_KEY"] = saved
|
|
40
|
+
|
|
41
|
+
def test_unknown_service_is_rejected(self):
|
|
42
|
+
with self.assertRaises(TuncMcpError):
|
|
43
|
+
TuncMcpClient(service="nope", api_key="k")
|
|
44
|
+
|
|
45
|
+
def test_call_tool_sends_jsonrpc_with_bearer_key(self):
|
|
46
|
+
calls = []
|
|
47
|
+
|
|
48
|
+
class FakeResponse:
|
|
49
|
+
status = 200
|
|
50
|
+
headers = {"content-type": "application/json", "mcp-session-id": "sess-1"}
|
|
51
|
+
|
|
52
|
+
def __init__(self, payload):
|
|
53
|
+
self._payload = json.dumps(payload).encode()
|
|
54
|
+
|
|
55
|
+
def read(self):
|
|
56
|
+
return self._payload
|
|
57
|
+
|
|
58
|
+
def __enter__(self):
|
|
59
|
+
return self
|
|
60
|
+
|
|
61
|
+
def __exit__(self, *exc):
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
def fake_urlopen(request, timeout=None):
|
|
65
|
+
payload = json.loads(request.data.decode())
|
|
66
|
+
calls.append((request, payload))
|
|
67
|
+
if payload["method"] == "initialize":
|
|
68
|
+
return FakeResponse({"jsonrpc": "2.0", "id": payload["id"], "result": {"serverInfo": {"name": "orb"}}})
|
|
69
|
+
if payload["method"] == "notifications/initialized":
|
|
70
|
+
return FakeResponse({})
|
|
71
|
+
return FakeResponse(
|
|
72
|
+
{"jsonrpc": "2.0", "id": payload["id"], "result": {"content": [{"type": "text", "text": "ok"}]}}
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
with mock.patch("urllib.request.urlopen", fake_urlopen):
|
|
76
|
+
client = TuncMcpClient(service="orb", api_key="test-key")
|
|
77
|
+
self.assertEqual(client.call_text("data_read", {"operation": "get"}), "ok")
|
|
78
|
+
|
|
79
|
+
last_request, last_payload = calls[-1]
|
|
80
|
+
self.assertEqual(last_request.full_url, SERVERS["orb"])
|
|
81
|
+
self.assertEqual(last_request.get_header("Authorization"), "Bearer test-key")
|
|
82
|
+
self.assertEqual(last_request.get_header("Mcp-session-id"), "sess-1")
|
|
83
|
+
self.assertEqual(last_payload["method"], "tools/call")
|
|
84
|
+
self.assertEqual(last_payload["params"], {"name": "data_read", "arguments": {"operation": "get"}})
|