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 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.
@@ -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,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"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,2 @@
1
+ [console_scripts]
2
+ tunc-mcp = tunc_mcp.cli:main
@@ -0,0 +1,3 @@
1
+
2
+ [test]
3
+ pytest>=7
@@ -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"}})