apiary-memory 0.2.0__py3-none-any.whl
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.
- apiary_memory-0.2.0.dist-info/METADATA +333 -0
- apiary_memory-0.2.0.dist-info/RECORD +11 -0
- apiary_memory-0.2.0.dist-info/WHEEL +5 -0
- apiary_memory-0.2.0.dist-info/entry_points.txt +5 -0
- apiary_memory-0.2.0.dist-info/licenses/LICENSE +21 -0
- apiary_memory-0.2.0.dist-info/top_level.txt +1 -0
- hive_mcp/__init__.py +27 -0
- hive_mcp/cli.py +261 -0
- hive_mcp/stdio.py +799 -0
- hive_mcp/store.py +872 -0
- hive_mcp/tools.json +211 -0
hive_mcp/stdio.py
ADDED
|
@@ -0,0 +1,799 @@
|
|
|
1
|
+
"""hive_mcp — a local-first memory server that speaks MCP over stdio.
|
|
2
|
+
|
|
3
|
+
Two engines, one tool surface
|
|
4
|
+
-----------------------------
|
|
5
|
+
This package carries its own memory store (:mod:`hive_mcp.store`): a SQLite
|
|
6
|
+
file in the user's home directory, holding what ``propose`` submits, releasing
|
|
7
|
+
it when the human confirms, and ranking it when ``recall`` asks. A machine with
|
|
8
|
+
nothing else installed therefore gets a server that can genuinely store and
|
|
9
|
+
retrieve — not just one that describes tools it cannot run.
|
|
10
|
+
|
|
11
|
+
If the Hive app is running on the same machine, the bridge prefers it: the app
|
|
12
|
+
adds encrypted vaults, daily evolution, multi-agent identity and a UI, and the
|
|
13
|
+
client should see the richer surface. The local store is then left untouched.
|
|
14
|
+
|
|
15
|
+
| ``HIVE_MODE`` | behaviour |
|
|
16
|
+
| --------------- | ---------------------------------------------------- |
|
|
17
|
+
| ``auto`` (default) | ask the app; use the local store only if it is unreachable |
|
|
18
|
+
| ``local`` | never touch the network; always use the local store |
|
|
19
|
+
| ``remote`` | only the app; a connection failure is an error |
|
|
20
|
+
|
|
21
|
+
Falling back only on *unreachable*, never on *refused*
|
|
22
|
+
------------------------------------------------------
|
|
23
|
+
This is the one routing rule that must not be relaxed. If the app is running
|
|
24
|
+
and answers with an error — a missing agent credential, a bad argument — the
|
|
25
|
+
error is returned to the caller verbatim. Silently retrying against the local
|
|
26
|
+
store would write the user's memory into a second database that nothing
|
|
27
|
+
displays, while their client believed it had reached the app. Silent data
|
|
28
|
+
forking is worse than any error message, so a response from the app is always
|
|
29
|
+
final.
|
|
30
|
+
|
|
31
|
+
Writes are proposals
|
|
32
|
+
--------------------
|
|
33
|
+
An agent can call ``propose``; it cannot confirm. There is no approve action in
|
|
34
|
+
the MCP surface here or upstream — confirmation is a human action, and it lives
|
|
35
|
+
in ``memory-hive-mcp-cli`` (see :mod:`hive_mcp.cli`). Removing that gate would
|
|
36
|
+
make the tool cheerful and the promise worthless.
|
|
37
|
+
|
|
38
|
+
Offline behaviour
|
|
39
|
+
-----------------
|
|
40
|
+
| Method | app unreachable |
|
|
41
|
+
| --------------- | ------------------------------------------------------ |
|
|
42
|
+
| ``initialize`` | answered locally, protocol ``2024-11-05`` |
|
|
43
|
+
| ``tools/list`` | answered locally from ``tools.json``, minus app-only inputs and wording |
|
|
44
|
+
| ``tools/call`` | answered by the local store (real storage, real search) |
|
|
45
|
+
| notifications | swallowed silently (never answered) |
|
|
46
|
+
|
|
47
|
+
``tools.json`` is a *snapshot* of the tool surface, not a second source of truth
|
|
48
|
+
for behaviour. When the app is reachable its own ``tools/list`` is what the
|
|
49
|
+
client sees, so drift is structurally impossible there; offline the snapshot is
|
|
50
|
+
the contract. ``tests/`` enforces that the two agree.
|
|
51
|
+
|
|
52
|
+
Advertise only inputs some engine in this session can honour
|
|
53
|
+
------------------------------------------------------------
|
|
54
|
+
``tools.json`` mirrors the app, and the app's surface offers two embedding
|
|
55
|
+
inputs — ``recall.query_vector`` and ``propose.vector`` — which the app really
|
|
56
|
+
implements and the local store really does not (``store.py``: "No segmenter, no
|
|
57
|
+
embedder, no vector index"). Relayed unchanged to a machine the local engine
|
|
58
|
+
serves, ``tools/list`` becomes a promise ``tools/call`` cannot keep: the caller
|
|
59
|
+
supplies an embedding, gets lexical results, and is told nothing. 0.1.8 shipped
|
|
60
|
+
exactly that, which is the same class of dishonesty as inventing a tool result.
|
|
61
|
+
|
|
62
|
+
Prose is the same defect in another medium: a description is a claim about what
|
|
63
|
+
will happen when the input is used, and "Matched semantically, not by substring"
|
|
64
|
+
is true of the app's vector path and false of an engine that ranks by word and
|
|
65
|
+
character-bigram overlap. The two maps below are therefore applied together -
|
|
66
|
+
``LOCAL_UNSUPPORTED_ARGUMENTS`` for inputs, ``LOCAL_PROPERTY_TEXT`` for wording.
|
|
67
|
+
|
|
68
|
+
The served list is narrowed to the engines that can actually answer a call in
|
|
69
|
+
this session:
|
|
70
|
+
|
|
71
|
+
| ``HIVE_MODE`` | served ``tools/list`` |
|
|
72
|
+
| ------------- | ------------------------------------------------------ |
|
|
73
|
+
| ``local`` | snapshot, minus embedding inputs, local wording |
|
|
74
|
+
| ``auto`` | the app's answer, minus embedding inputs, local wording |
|
|
75
|
+
| ``remote`` | the app's answer **verbatim**, embedding inputs intact |
|
|
76
|
+
|
|
77
|
+
Defaults are unified rather than narrowed. Both engines answer an omitted
|
|
78
|
+
``recall.limit`` with 10, an omitted ``recall.compact`` with true, and an
|
|
79
|
+
omitted ``say.ttl`` with 24 hours, because a default is a number in the schema,
|
|
80
|
+
not a capability one engine lacks. ``store.py`` holds each one as a named
|
|
81
|
+
constant and ``tests/test_bridge.py`` checks the advertised number against it.
|
|
82
|
+
|
|
83
|
+
``auto`` narrows even when the app answers, and that is the deliberate part.
|
|
84
|
+
Auto may route any later ``tools/call`` to the local store the moment the app
|
|
85
|
+
stops answering, so a list fetched while the app was alive has to stay true
|
|
86
|
+
after it dies. Widening later — ``remote``, or the app coming back — is safe: a
|
|
87
|
+
capability that appears mid-session was never promised and nothing cached can
|
|
88
|
+
be betrayed by it. Narrowing later is the failure this rule exists to prevent:
|
|
89
|
+
the client cached the richer list, keeps sending an embedding, and every call
|
|
90
|
+
degrades silently. Declaring the loss in each *response* (an ``ignored`` field)
|
|
91
|
+
was the alternative; it states the loss after the model has already acted on
|
|
92
|
+
the promise, so removing the input upstream of the decision is the cheaper
|
|
93
|
+
honesty. A mode-independent list also means one surface to document, and the
|
|
94
|
+
fields never blink as the app starts or stops.
|
|
95
|
+
|
|
96
|
+
One cursor in the answer, and no silent restart
|
|
97
|
+
----------------------------------------------
|
|
98
|
+
Inputs are narrowed; the *answer* is aligned, because a caller must not have to
|
|
99
|
+
branch on which engine replied. ``board`` returns the app's shape — ``items``,
|
|
100
|
+
``unread``, ``count``, ``cursor``, ``reader`` — and its ``cursor`` is a card id,
|
|
101
|
+
the same string ``mcp_server.py``'s ``_tool_board`` returns.
|
|
102
|
+
|
|
103
|
+
Until now it was the opposite, and ``auto`` is what made it bite. This engine
|
|
104
|
+
answered ``messages``/``total`` and a float epoch cursor, and parsed ``since``
|
|
105
|
+
with ``float()``. A caller that polled while the app answered got a card id,
|
|
106
|
+
kept it, and lost the app — at which point ``float('CB2026...')`` raised, the
|
|
107
|
+
exception was swallowed into ``since = 0``, and the entire board came back as if
|
|
108
|
+
nothing had been read, with no field saying the cursor had been dropped. A float
|
|
109
|
+
sent the other way matched no card id in the app, which quietly answered with
|
|
110
|
+
its newest ``limit``. Both directions failed silently, which is the one thing
|
|
111
|
+
this module refuses everywhere else.
|
|
112
|
+
|
|
113
|
+
One cursor type removes the misread. It cannot remove the case behind it: the
|
|
114
|
+
cursor's card may be gone — expired, trimmed, or issued by the other engine,
|
|
115
|
+
whose ids are ``CB…`` against this store's ``msg_…``. Neither engine can resume
|
|
116
|
+
from that, so both return their newest window; the app says nothing about it, and
|
|
117
|
+
this engine adds ``since_unknown`` and a ``note`` naming the reason. Restarting is
|
|
118
|
+
allowed; restarting silently is not.
|
|
119
|
+
|
|
120
|
+
Two answer shapes stay deliberately unequal, because the engines hold different
|
|
121
|
+
data: ``recall`` entries name their vault ``vault`` here and ``wh`` in the app,
|
|
122
|
+
and ``get_context`` returns one flat ``digest`` here against one bucket per
|
|
123
|
+
warehouse upstream. The README states both, so the difference is read rather
|
|
124
|
+
than discovered mid-session.
|
|
125
|
+
|
|
126
|
+
Configuration
|
|
127
|
+
-------------
|
|
128
|
+
* ``HIVE_MODE`` — ``auto`` (default), ``local``, ``remote``
|
|
129
|
+
* ``HIVE_DB`` — path to the memory file (default ``~/.memory-hive/hive.db``)
|
|
130
|
+
* ``HIVE_AGENT_ID`` — agent id issued by the Hive app, for remote calls
|
|
131
|
+
* ``HIVE_AGENT_TOKEN`` — access token issued by the Hive app, for remote calls
|
|
132
|
+
* ``HIVE_MCP_URL`` — app endpoint (default ``http://127.0.0.1:8768/mcp``)
|
|
133
|
+
|
|
134
|
+
Zero dependencies: standard library only.
|
|
135
|
+
"""
|
|
136
|
+
|
|
137
|
+
import json
|
|
138
|
+
import os
|
|
139
|
+
import sys
|
|
140
|
+
import urllib.error
|
|
141
|
+
import urllib.parse
|
|
142
|
+
import urllib.request
|
|
143
|
+
from pathlib import Path
|
|
144
|
+
|
|
145
|
+
# Bumped with pyproject.toml. Only used when neither a source checkout nor
|
|
146
|
+
# installed metadata can answer; tests assert it matches to keep the two from
|
|
147
|
+
# drifting the way 0.1.0 did.
|
|
148
|
+
_FALLBACK_VERSION = "0.2.0"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _resolve_version():
|
|
152
|
+
"""Report the version of the code that is actually running.
|
|
153
|
+
|
|
154
|
+
``importlib.metadata`` describes what pip installed, not what is on
|
|
155
|
+
``sys.path``. In a source checkout that sits beside an older release in
|
|
156
|
+
site-packages those two disagree, and the installed number is the
|
|
157
|
+
misleading one: you edit 0.1.7, run the tree, and read 0.1.6. A checkout
|
|
158
|
+
therefore reads its own ``pyproject.toml`` first.
|
|
159
|
+
"""
|
|
160
|
+
try:
|
|
161
|
+
manifest = Path(__file__).resolve().parent.parent.parent / "pyproject.toml"
|
|
162
|
+
if manifest.is_file():
|
|
163
|
+
in_project = False
|
|
164
|
+
for line in manifest.read_text(encoding="utf-8").splitlines():
|
|
165
|
+
stripped = line.strip()
|
|
166
|
+
if stripped.startswith("["):
|
|
167
|
+
in_project = stripped == "[project]"
|
|
168
|
+
continue
|
|
169
|
+
if in_project and stripped.startswith("version"):
|
|
170
|
+
value = stripped.partition("=")[2].strip().strip('"').strip("'")
|
|
171
|
+
if value:
|
|
172
|
+
return value
|
|
173
|
+
except OSError:
|
|
174
|
+
pass
|
|
175
|
+
try:
|
|
176
|
+
from importlib.metadata import version as _pkg_version
|
|
177
|
+
|
|
178
|
+
# The distribution was renamed memory-hive-mcp -> apiary-memory in 0.2.0.
|
|
179
|
+
# A source checkout, an editable install and a built wheel can each
|
|
180
|
+
# carry a different dist name, so ask for both and take the first that
|
|
181
|
+
# answers. Returning _FALLBACK_VERSION straight away would make every
|
|
182
|
+
# renamed install report a stale version over MCP.
|
|
183
|
+
for _dist in ("apiary-memory", "memory-hive-mcp"):
|
|
184
|
+
try:
|
|
185
|
+
return _pkg_version(_dist)
|
|
186
|
+
except Exception:
|
|
187
|
+
continue
|
|
188
|
+
return _FALLBACK_VERSION
|
|
189
|
+
except Exception:
|
|
190
|
+
return _FALLBACK_VERSION
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
__version__ = _resolve_version()
|
|
194
|
+
|
|
195
|
+
from hive_mcp.store import ( # noqa: E402
|
|
196
|
+
DEFAULT_BOARD_LIMIT,
|
|
197
|
+
DEFAULT_CONTEXT_LIMIT,
|
|
198
|
+
DEFAULT_PENDING_LIMIT,
|
|
199
|
+
DEFAULT_RECALL_COMPACT,
|
|
200
|
+
DEFAULT_RECALL_LIMIT,
|
|
201
|
+
DEFAULT_SAY_TTL,
|
|
202
|
+
Store,
|
|
203
|
+
StoreError,
|
|
204
|
+
default_db_path,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
# Inputs the app accepts and the local store cannot consume, per tool.
|
|
208
|
+
#
|
|
209
|
+
# The local engine takes neither list: ``Store.recall`` has no parameter for a
|
|
210
|
+
# precomputed vector and ``Store.propose`` stores none, so a field left in the
|
|
211
|
+
# list would be accepted, ignored, and never mentioned. Kept as a map rather
|
|
212
|
+
# than a blanket strip so the reason survives review: each entry is a real
|
|
213
|
+
# engine gap, not an oversight. ``tests/test_bridge.py`` checks every pair here
|
|
214
|
+
# against both ``tools.json`` and the store's actual signatures, so this map
|
|
215
|
+
# cannot quietly outlive the gap it describes.
|
|
216
|
+
LOCAL_UNSUPPORTED_ARGUMENTS = {
|
|
217
|
+
"recall": ("query_vector",),
|
|
218
|
+
"propose": ("vector",),
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
# Property descriptions that are true of the app and false of the local engine.
|
|
222
|
+
# Same rule as the map above, applied to prose: a description is a promise about
|
|
223
|
+
# what the input will do, and serving the app's promise from an engine that
|
|
224
|
+
# cannot keep it is the same dishonesty as advertising an input that gets
|
|
225
|
+
# dropped.
|
|
226
|
+
#
|
|
227
|
+
# ``recall.query`` is the case that forced this. The app *can* rank by meaning,
|
|
228
|
+
# but only through ``query_vector`` (cosine, see ``_vector_recall``); its plain
|
|
229
|
+
# text path is an inverted index over words and character bigrams with synonym
|
|
230
|
+
# expansion and recency decay (``index.py``: ``search_memories``). The local
|
|
231
|
+
# ranker is the same family without the synonyms: token overlap, a verbatim
|
|
232
|
+
# bonus, a recency nudge. Neither engine matches "semantically, not by
|
|
233
|
+
# substring" on text alone, so ``tools.json`` no longer claims it; it states
|
|
234
|
+
# what the app does and points at ``query_vector`` for the richer claim, which
|
|
235
|
+
# exists exactly where that input exists (``remote``). This override says what
|
|
236
|
+
# the local engine can do, including the part a caller has to plan around: no
|
|
237
|
+
# shared wording means no hit.
|
|
238
|
+
#
|
|
239
|
+
# ``tests/test_bridge.py`` checks every entry against ``tools.json``, so an
|
|
240
|
+
# override cannot outlive the property it replaces.
|
|
241
|
+
LOCAL_PROPERTY_TEXT = {
|
|
242
|
+
"recall": {
|
|
243
|
+
"query": (
|
|
244
|
+
"Natural-language search text, e.g. 'what did we decide about the "
|
|
245
|
+
"pricing page'. Ranked by how much of it the entry's words and "
|
|
246
|
+
"character bigrams cover, plus a small recency nudge and a bonus "
|
|
247
|
+
"when the whole query appears verbatim. This engine has no "
|
|
248
|
+
"embeddings, so an entry that shares no wording with the query "
|
|
249
|
+
"will not be found - add plain keywords alongside the sentence."
|
|
250
|
+
),
|
|
251
|
+
},
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
AGENT_ID = os.environ.get("HIVE_AGENT_ID", "")
|
|
255
|
+
AGENT_TOKEN = os.environ.get("HIVE_AGENT_TOKEN", "")
|
|
256
|
+
MCP_URL = os.environ.get("HIVE_MCP_URL", "http://127.0.0.1:8768/mcp")
|
|
257
|
+
|
|
258
|
+
# An unrecognised value is treated as `auto` rather than failing: a typo in an
|
|
259
|
+
# MCP host's env block should not produce a server that refuses to start.
|
|
260
|
+
MODE = (os.environ.get("HIVE_MODE") or "auto").strip().lower()
|
|
261
|
+
if MODE not in ("auto", "local", "remote"):
|
|
262
|
+
MODE = "auto"
|
|
263
|
+
|
|
264
|
+
SERVER_NAME = "apiary-memory"
|
|
265
|
+
APP_STORE = "https://apps.apple.com/app/id6806992983"
|
|
266
|
+
|
|
267
|
+
# Offline we claim only what we actually implement. Do not raise this to a
|
|
268
|
+
# protocol version the local path does not speak: overstating a capability is
|
|
269
|
+
# the same class of dishonesty as inventing a tool result.
|
|
270
|
+
OFFLINE_PROTOCOL = "2024-11-05"
|
|
271
|
+
|
|
272
|
+
# Discovery must not hang. A crawler should get an answer, not a stall.
|
|
273
|
+
DISCOVERY_TIMEOUT = 1.5
|
|
274
|
+
CALL_TIMEOUT = 30.0
|
|
275
|
+
|
|
276
|
+
# JSON-RPC codes
|
|
277
|
+
PARSE_ERROR = -32700
|
|
278
|
+
INVALID_REQUEST = -32600
|
|
279
|
+
INTERNAL_ERROR = -32603
|
|
280
|
+
|
|
281
|
+
# 502 = "the upstream is not there". Same number whether the app is stopped,
|
|
282
|
+
# the port is wrong, or a proxy answers on its behalf: one condition, and the
|
|
283
|
+
# only condition that justifies falling back to the local store.
|
|
284
|
+
UPSTREAM_DOWN = 502
|
|
285
|
+
|
|
286
|
+
_TOOLS_CACHE = None
|
|
287
|
+
|
|
288
|
+
LOCAL_TOOLS = ("recall", "propose", "manage", "get_context", "say", "board")
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _is_loopback(url):
|
|
292
|
+
"""True when the endpoint is on this machine.
|
|
293
|
+
|
|
294
|
+
Loopback traffic must never go through an HTTP proxy: (a) it is semantically
|
|
295
|
+
wrong, (b) when a proxy is configured it answers 502 on the proxy's behalf,
|
|
296
|
+
which used to strip the actionable part off our error message.
|
|
297
|
+
"""
|
|
298
|
+
try:
|
|
299
|
+
host = urllib.parse.urlsplit(url).hostname or ""
|
|
300
|
+
except ValueError:
|
|
301
|
+
return False
|
|
302
|
+
return host in ("127.0.0.1", "::1", "localhost", "0.0.0.0") or host.startswith(
|
|
303
|
+
"127."
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _opener():
|
|
308
|
+
"""Proxy-free opener for loopback, normal opener otherwise."""
|
|
309
|
+
if _is_loopback(MCP_URL):
|
|
310
|
+
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
|
311
|
+
return urllib.request.build_opener()
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def load_tools():
|
|
315
|
+
"""Read the bundled snapshot of the tool surface.
|
|
316
|
+
|
|
317
|
+
Cached after first read. A missing or malformed file is a packaging bug, so
|
|
318
|
+
it raises rather than returning ``[]`` — an empty list would look like a
|
|
319
|
+
healthy server with no tools, which is a lie a caller cannot detect.
|
|
320
|
+
"""
|
|
321
|
+
global _TOOLS_CACHE
|
|
322
|
+
if _TOOLS_CACHE is not None:
|
|
323
|
+
return _TOOLS_CACHE
|
|
324
|
+
path = Path(__file__).with_name("tools.json")
|
|
325
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
326
|
+
data = json.load(fh)
|
|
327
|
+
tools = data["tools"]
|
|
328
|
+
if not isinstance(tools, list) or not tools:
|
|
329
|
+
raise ValueError("%s contains no tools" % path)
|
|
330
|
+
_TOOLS_CACHE = tools
|
|
331
|
+
return tools
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def _narrow_for_local(tools):
|
|
335
|
+
"""Copy ``tools`` with what the local engine cannot honour dropped or restated.
|
|
336
|
+
|
|
337
|
+
Two kinds of narrowing in one function, because they answer one question -
|
|
338
|
+
what can the engine serving this session really do with this tool? -
|
|
339
|
+
and splitting them would eventually apply one where the other was
|
|
340
|
+
forgotten:
|
|
341
|
+
|
|
342
|
+
* an input the engine never reads is removed (``LOCAL_UNSUPPORTED_ARGUMENTS``);
|
|
343
|
+
* a property description the engine would falsify is replaced
|
|
344
|
+
(``LOCAL_PROPERTY_TEXT``).
|
|
345
|
+
|
|
346
|
+
Returns new dicts rather than editing in place: ``load_tools()`` hands out
|
|
347
|
+
one cached list, and the same process may serve a relayed app list later in
|
|
348
|
+
the session. Trimming the cache would make a remote answer lose fields
|
|
349
|
+
because a local answer had been served earlier.
|
|
350
|
+
|
|
351
|
+
Tolerant of shape: anything unrecognised is passed through untouched rather
|
|
352
|
+
than dropped, because losing the app's answer would be a worse failure than
|
|
353
|
+
one field too many.
|
|
354
|
+
"""
|
|
355
|
+
out = []
|
|
356
|
+
for tool in tools:
|
|
357
|
+
if not isinstance(tool, dict):
|
|
358
|
+
out.append(tool)
|
|
359
|
+
continue
|
|
360
|
+
name = tool.get("name")
|
|
361
|
+
droppable = LOCAL_UNSUPPORTED_ARGUMENTS.get(name) or ()
|
|
362
|
+
restated = LOCAL_PROPERTY_TEXT.get(name) or {}
|
|
363
|
+
schema = tool.get("inputSchema")
|
|
364
|
+
props = schema.get("properties") if isinstance(schema, dict) else None
|
|
365
|
+
if not isinstance(props, dict):
|
|
366
|
+
out.append(tool)
|
|
367
|
+
continue
|
|
368
|
+
dropped = [prop for prop in droppable if prop in props]
|
|
369
|
+
rewritten = [prop for prop in restated if prop in props]
|
|
370
|
+
if not dropped and not rewritten:
|
|
371
|
+
out.append(tool)
|
|
372
|
+
continue
|
|
373
|
+
narrowed_props = {}
|
|
374
|
+
for prop_name, spec in props.items():
|
|
375
|
+
if prop_name in dropped:
|
|
376
|
+
continue
|
|
377
|
+
text = restated.get(prop_name)
|
|
378
|
+
if text and isinstance(spec, dict):
|
|
379
|
+
spec = dict(spec)
|
|
380
|
+
spec["description"] = text
|
|
381
|
+
narrowed_props[prop_name] = spec
|
|
382
|
+
narrowed = dict(tool)
|
|
383
|
+
narrowed["inputSchema"] = dict(schema)
|
|
384
|
+
narrowed["inputSchema"]["properties"] = narrowed_props
|
|
385
|
+
out.append(narrowed)
|
|
386
|
+
return out
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _narrow_tools_result(resp):
|
|
390
|
+
"""Apply the same narrowing to a ``tools/list`` result relayed from the app.
|
|
391
|
+
|
|
392
|
+
Tolerant of shape: anything we do not recognise is passed through untouched
|
|
393
|
+
rather than dropped, because losing the app's answer would be a worse
|
|
394
|
+
failure than advertising one field too many.
|
|
395
|
+
"""
|
|
396
|
+
if not isinstance(resp, dict) or not isinstance(resp.get("result"), dict):
|
|
397
|
+
return resp
|
|
398
|
+
tools = resp["result"].get("tools")
|
|
399
|
+
if not isinstance(tools, list):
|
|
400
|
+
return resp
|
|
401
|
+
narrowed = dict(resp)
|
|
402
|
+
narrowed["result"] = dict(resp["result"])
|
|
403
|
+
narrowed["result"]["tools"] = _narrow_for_local(tools)
|
|
404
|
+
return narrowed
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _rpc_result(msg_id, result):
|
|
408
|
+
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _rpc_error(msg_id, code, message, data=None):
|
|
412
|
+
err = {"code": code, "message": message}
|
|
413
|
+
if data is not None:
|
|
414
|
+
err["data"] = data
|
|
415
|
+
return {"jsonrpc": "2.0", "id": msg_id, "error": err}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _hive_hint(reason=""):
|
|
419
|
+
"""One-line, actionable explanation for the remote-only failure path."""
|
|
420
|
+
tail = " (%s)" % reason if reason else ""
|
|
421
|
+
return (
|
|
422
|
+
"Hive app not reachable at %s%s, and HIVE_MODE=remote forbids the local "
|
|
423
|
+
"fallback. Either start the Hive app (macOS: %s) or set HIVE_MODE=local "
|
|
424
|
+
"to use the local memory file at %s. Set HIVE_MCP_URL if your Hive "
|
|
425
|
+
"listens elsewhere." % (MCP_URL, tail, APP_STORE, _db_path_hint())
|
|
426
|
+
)
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _db_path_hint():
|
|
430
|
+
"""The configured database path, without creating anything as a side effect."""
|
|
431
|
+
try:
|
|
432
|
+
return str(default_db_path())
|
|
433
|
+
except Exception:
|
|
434
|
+
return os.environ.get("HIVE_DB") or "~/.memory-hive/hive.db"
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _auth_hint():
|
|
438
|
+
"""Appended when the app is up but the caller has no usable credential.
|
|
439
|
+
|
|
440
|
+
This is the most common real-world stall: the app is installed, the user
|
|
441
|
+
never registered an agent, and the backend replies in Chinese.
|
|
442
|
+
"""
|
|
443
|
+
return (
|
|
444
|
+
" No usable agent credential. Register an agent inside the Hive app, then "
|
|
445
|
+
"set HIVE_AGENT_ID and HIVE_AGENT_TOKEN in your MCP host config "
|
|
446
|
+
"(see the Environment variables table in the README)."
|
|
447
|
+
)
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
_LOOKS_LIKE_AUTH = ("agent_id", "token", "鉴权", "凭证", "身份", "401", "403")
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _upstream_error(payload, code, detail, reason=""):
|
|
454
|
+
"""Uniform, honest failure for anything we could not deliver."""
|
|
455
|
+
body = _hive_hint(reason)
|
|
456
|
+
try:
|
|
457
|
+
text = json.dumps(detail, ensure_ascii=False) if detail is not None else ""
|
|
458
|
+
except Exception:
|
|
459
|
+
text = str(detail)
|
|
460
|
+
if text and any(k in text for k in _LOOKS_LIKE_AUTH):
|
|
461
|
+
body += _auth_hint()
|
|
462
|
+
return _rpc_error(
|
|
463
|
+
payload.get("id"),
|
|
464
|
+
INTERNAL_ERROR,
|
|
465
|
+
body,
|
|
466
|
+
{
|
|
467
|
+
"http_status": code,
|
|
468
|
+
"upstream": MCP_URL,
|
|
469
|
+
"method": payload.get("method"),
|
|
470
|
+
},
|
|
471
|
+
)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def _decorate_error(parsed, msg_id):
|
|
475
|
+
"""Add the credential hint to an auth-looking JSON-RPC error, else pass through.
|
|
476
|
+
|
|
477
|
+
The Hive answers auth failures as **HTTP 200 with a JSON-RPC error** (code
|
|
478
|
+
-32001), not as an HTTP status. Checking only the ``HTTPError`` branch left
|
|
479
|
+
the most common real-world stall — app installed, agent never registered, and
|
|
480
|
+
a Chinese-only message — with no pointer to HIVE_AGENT_ID / HIVE_AGENT_TOKEN.
|
|
481
|
+
"""
|
|
482
|
+
if not isinstance(parsed, dict) or not parsed.get("error"):
|
|
483
|
+
return parsed
|
|
484
|
+
err = dict(parsed["error"])
|
|
485
|
+
msg = str(err.get("message", ""))
|
|
486
|
+
if any(k in msg for k in _LOOKS_LIKE_AUTH) and "HIVE_AGENT_ID" not in msg:
|
|
487
|
+
err["message"] = msg + _auth_hint()
|
|
488
|
+
return {"jsonrpc": "2.0", "id": msg_id, "error": err}
|
|
489
|
+
|
|
490
|
+
|
|
491
|
+
def forward(payload, timeout=CALL_TIMEOUT):
|
|
492
|
+
"""POST the payload to the Hive app.
|
|
493
|
+
|
|
494
|
+
Returns a response dict, or ``None`` for a 202 (notification accepted, no
|
|
495
|
+
reply expected). On any transport failure returns a ready-to-send JSON-RPC
|
|
496
|
+
error carrying ``data.http_status``, which is how the caller distinguishes
|
|
497
|
+
"nothing is listening" from "the app said no".
|
|
498
|
+
"""
|
|
499
|
+
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
|
500
|
+
req = urllib.request.Request(MCP_URL, data=body, method="POST")
|
|
501
|
+
req.add_header("Content-Type", "application/json")
|
|
502
|
+
if AGENT_ID:
|
|
503
|
+
req.add_header("X-Hive-Agent", AGENT_ID)
|
|
504
|
+
if AGENT_TOKEN:
|
|
505
|
+
req.add_header("X-Hive-Token", AGENT_TOKEN)
|
|
506
|
+
try:
|
|
507
|
+
with _opener().open(req, timeout=timeout) as resp:
|
|
508
|
+
if resp.status == 202:
|
|
509
|
+
return None # notification: backend accepted, no response needed
|
|
510
|
+
raw = resp.read().decode("utf-8")
|
|
511
|
+
if not raw.strip():
|
|
512
|
+
return None # empty body: stay silent rather than emit a false error
|
|
513
|
+
# The app reports auth failures as HTTP 200 + JSON-RPC error, so the
|
|
514
|
+
# hint must be applied here too, not only in the HTTPError branch.
|
|
515
|
+
return _decorate_error(json.loads(raw), payload.get("id"))
|
|
516
|
+
except urllib.error.HTTPError as e:
|
|
517
|
+
if e.code == 202:
|
|
518
|
+
return None
|
|
519
|
+
# A proxy answering 502 used to fall through to a bare "HTTP 502" with no
|
|
520
|
+
# endpoint and no fix — exactly the failure mode this error path exists to
|
|
521
|
+
# prevent. Try the body first (the app speaks JSON), then always attach
|
|
522
|
+
# the actionable hint.
|
|
523
|
+
try:
|
|
524
|
+
parsed = json.loads(e.read().decode("utf-8"))
|
|
525
|
+
except Exception:
|
|
526
|
+
parsed = None
|
|
527
|
+
if isinstance(parsed, dict) and parsed.get("error"):
|
|
528
|
+
# The app itself answered: pass it through verbatim (plus the
|
|
529
|
+
# credential hint when it looks like auth). No fallback here.
|
|
530
|
+
return _decorate_error(parsed, payload.get("id"))
|
|
531
|
+
return _upstream_error(payload, e.code, "HTTP %s" % e.code, "HTTP %s" % e.code)
|
|
532
|
+
except Exception as e: # URLError, socket timeout, JSON decode, ...
|
|
533
|
+
return _upstream_error(payload, UPSTREAM_DOWN, str(e), str(e))
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
# --------------------------------------------------------------------------- #
|
|
537
|
+
# the local engine
|
|
538
|
+
# --------------------------------------------------------------------------- #
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def _tool_result(payload_dict, is_error=False):
|
|
542
|
+
"""Wrap a local answer in the same envelope the app uses.
|
|
543
|
+
|
|
544
|
+
The client must not be able to tell the two engines apart by shape, only by
|
|
545
|
+
the ``"mode"`` field inside the payload.
|
|
546
|
+
"""
|
|
547
|
+
return {
|
|
548
|
+
"content": [
|
|
549
|
+
{"type": "text", "text": json.dumps(payload_dict, ensure_ascii=False)}
|
|
550
|
+
],
|
|
551
|
+
"isError": bool(is_error),
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def local_call(name, arguments):
|
|
556
|
+
"""Run one tool against the local store. Returns the MCP result envelope.
|
|
557
|
+
|
|
558
|
+
Every default here is a named constant from ``store.py`` rather than a
|
|
559
|
+
literal, because these are the numbers ``tools.json`` advertises. The
|
|
560
|
+
literals used to be typed twice - ``limit=10`` in the schema and
|
|
561
|
+
``arguments.get("limit", 8)`` here - and a caller that omitted the argument
|
|
562
|
+
got the smaller, unadvertised answer with nothing to indicate the
|
|
563
|
+
substitution.
|
|
564
|
+
"""
|
|
565
|
+
if name not in LOCAL_TOOLS:
|
|
566
|
+
return _tool_result(
|
|
567
|
+
{"ok": False, "error": "unknown tool %r" % name, "mode": "local"}, True
|
|
568
|
+
)
|
|
569
|
+
arguments = arguments or {}
|
|
570
|
+
store = Store()
|
|
571
|
+
try:
|
|
572
|
+
if name == "recall":
|
|
573
|
+
payload = store.recall(
|
|
574
|
+
arguments.get("query", ""),
|
|
575
|
+
limit=arguments.get("limit", DEFAULT_RECALL_LIMIT),
|
|
576
|
+
vaults=arguments.get("vaults"),
|
|
577
|
+
compact=bool(arguments.get("compact", DEFAULT_RECALL_COMPACT)),
|
|
578
|
+
)
|
|
579
|
+
elif name == "propose":
|
|
580
|
+
payload = store.propose(
|
|
581
|
+
arguments.get("content", ""),
|
|
582
|
+
title=arguments.get("title", ""),
|
|
583
|
+
vault=arguments.get("vault", "current"),
|
|
584
|
+
tags=arguments.get("tags"),
|
|
585
|
+
client_id=arguments.get("client_id", ""),
|
|
586
|
+
)
|
|
587
|
+
elif name == "manage":
|
|
588
|
+
action = (arguments.get("action") or "").strip()
|
|
589
|
+
mem_id = arguments.get("id", "")
|
|
590
|
+
if action == "list_pending":
|
|
591
|
+
payload = store.list_pending(
|
|
592
|
+
limit=arguments.get("limit", DEFAULT_PENDING_LIMIT)
|
|
593
|
+
)
|
|
594
|
+
elif action == "get_by_id":
|
|
595
|
+
payload = store.get_by_id(mem_id)
|
|
596
|
+
elif action == "forget":
|
|
597
|
+
payload = store.forget(mem_id)
|
|
598
|
+
elif action == "restore":
|
|
599
|
+
payload = store.restore(mem_id)
|
|
600
|
+
elif action == "discard_pending":
|
|
601
|
+
payload = store.drop_pending(mem_id)
|
|
602
|
+
else:
|
|
603
|
+
raise StoreError(
|
|
604
|
+
"`action` must be one of: list_pending, forget, restore, "
|
|
605
|
+
"get_by_id, discard_pending (got %r)" % action
|
|
606
|
+
)
|
|
607
|
+
elif name == "get_context":
|
|
608
|
+
payload = store.get_context(
|
|
609
|
+
limit=arguments.get("limit", DEFAULT_CONTEXT_LIMIT)
|
|
610
|
+
)
|
|
611
|
+
elif name == "say":
|
|
612
|
+
payload = store.say(
|
|
613
|
+
arguments.get("summary", ""),
|
|
614
|
+
to=arguments.get("to", ""),
|
|
615
|
+
kind=arguments.get("kind", "data"),
|
|
616
|
+
stage=arguments.get("stage", ""),
|
|
617
|
+
task_id=arguments.get("task_id", ""),
|
|
618
|
+
refs=arguments.get("refs"),
|
|
619
|
+
ttl=arguments.get("ttl", DEFAULT_SAY_TTL),
|
|
620
|
+
actor=AGENT_ID,
|
|
621
|
+
)
|
|
622
|
+
else: # board
|
|
623
|
+
# `since` is the card id `board` returned last time, the same string
|
|
624
|
+
# the app hands out, so it survives a change of engine. Passing a
|
|
625
|
+
# default of 0 here is what used to make a foreign cursor a silent
|
|
626
|
+
# full re-read; `Store.board` now reports a cursor it cannot place.
|
|
627
|
+
payload = store.board(
|
|
628
|
+
limit=arguments.get("limit", DEFAULT_BOARD_LIMIT),
|
|
629
|
+
since=arguments.get("since", ""),
|
|
630
|
+
roster=bool(arguments.get("roster")),
|
|
631
|
+
reader=AGENT_ID,
|
|
632
|
+
)
|
|
633
|
+
except StoreError as exc:
|
|
634
|
+
return _tool_result({"ok": False, "error": str(exc), "mode": "local"}, True)
|
|
635
|
+
finally:
|
|
636
|
+
store.close()
|
|
637
|
+
# Tagged in one place rather than in every method: the docstring promises
|
|
638
|
+
# every local answer says which engine produced it, and a promise enforced
|
|
639
|
+
# at seventeen return statements is a promise that will drift.
|
|
640
|
+
if isinstance(payload, dict) and "mode" not in payload:
|
|
641
|
+
payload["mode"] = "local"
|
|
642
|
+
return _tool_result(payload)
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def _call_tool(payload, name, arguments):
|
|
646
|
+
"""Route one ``tools/call`` to the app or to the local store.
|
|
647
|
+
|
|
648
|
+
The order and the fallback condition are the whole design, so they are
|
|
649
|
+
stated once here rather than scattered:
|
|
650
|
+
|
|
651
|
+
* ``local`` — never network.
|
|
652
|
+
* ``remote`` — never local; a connection failure is reported as such.
|
|
653
|
+
* ``auto`` — ask the app first. Fall back only when nothing answered
|
|
654
|
+
(``http_status == 502``). An error the app itself produced is returned
|
|
655
|
+
unchanged: retrying it locally would split the user's memory across two
|
|
656
|
+
databases, one of which nothing displays.
|
|
657
|
+
"""
|
|
658
|
+
if MODE == "local":
|
|
659
|
+
# Wrap in the same JSON-RPC envelope the remote path produces: a tool
|
|
660
|
+
# result that is not inside `result` is not a valid MCP response.
|
|
661
|
+
return _rpc_result(payload.get("id"), local_call(name, arguments))
|
|
662
|
+
|
|
663
|
+
resp = forward(payload)
|
|
664
|
+
if resp is None:
|
|
665
|
+
# Notification-shaped call: nothing to answer.
|
|
666
|
+
return None
|
|
667
|
+
if "error" not in resp:
|
|
668
|
+
return resp
|
|
669
|
+
|
|
670
|
+
data = resp.get("error", {}).get("data") or {}
|
|
671
|
+
unreachable = data.get("http_status") == UPSTREAM_DOWN
|
|
672
|
+
if MODE == "auto" and unreachable:
|
|
673
|
+
return _rpc_result(payload.get("id"), local_call(name, arguments))
|
|
674
|
+
return resp
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _local_initialize(msg_id):
|
|
678
|
+
return _rpc_result(
|
|
679
|
+
msg_id,
|
|
680
|
+
{
|
|
681
|
+
"protocolVersion": OFFLINE_PROTOCOL,
|
|
682
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
683
|
+
"serverInfo": {"name": SERVER_NAME, "version": __version__},
|
|
684
|
+
"instructions": (
|
|
685
|
+
"Local-first personal memory. Agents may propose entries; only "
|
|
686
|
+
"the human confirms them (memory-hive-mcp-cli approve), so "
|
|
687
|
+
"recall searches confirmed memory only, and the tool list "
|
|
688
|
+
"advertises only inputs and wording the engine serving this "
|
|
689
|
+
"session can honour. This server keeps its "
|
|
690
|
+
"data in a SQLite file on this machine and sends nothing "
|
|
691
|
+
"offline; if the Hive app is running, this bridge uses it "
|
|
692
|
+
"instead and leaves the local file untouched. Responses state "
|
|
693
|
+
"which engine answered in their `mode` field."
|
|
694
|
+
),
|
|
695
|
+
},
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
def _forwarded_result(msg_id, payload, timeout):
|
|
700
|
+
"""Ask the app; return its response dict only if it carries a usable result."""
|
|
701
|
+
resp = forward(payload, timeout=timeout)
|
|
702
|
+
if isinstance(resp, dict) and resp.get("result"):
|
|
703
|
+
return resp
|
|
704
|
+
return None
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
def main():
|
|
708
|
+
for line in sys.stdin:
|
|
709
|
+
line = line.strip()
|
|
710
|
+
if not line:
|
|
711
|
+
continue
|
|
712
|
+
try:
|
|
713
|
+
payload = json.loads(line)
|
|
714
|
+
except Exception:
|
|
715
|
+
sys.stdout.write(
|
|
716
|
+
json.dumps(_rpc_error(None, PARSE_ERROR, "Parse error")) + "\n"
|
|
717
|
+
)
|
|
718
|
+
sys.stdout.flush()
|
|
719
|
+
continue
|
|
720
|
+
|
|
721
|
+
if not isinstance(payload, dict):
|
|
722
|
+
sys.stdout.write(
|
|
723
|
+
json.dumps(_rpc_error(None, INVALID_REQUEST, "Invalid Request")) + "\n"
|
|
724
|
+
)
|
|
725
|
+
sys.stdout.flush()
|
|
726
|
+
continue
|
|
727
|
+
|
|
728
|
+
method = payload.get("method")
|
|
729
|
+
msg_id = payload.get("id")
|
|
730
|
+
|
|
731
|
+
# Notifications carry no id and must never be answered.
|
|
732
|
+
if msg_id is None and method is not None:
|
|
733
|
+
if MODE != "local":
|
|
734
|
+
forward(payload, timeout=DISCOVERY_TIMEOUT) # best effort, silent
|
|
735
|
+
continue
|
|
736
|
+
|
|
737
|
+
try:
|
|
738
|
+
if method == "initialize":
|
|
739
|
+
resp = None
|
|
740
|
+
if MODE != "local":
|
|
741
|
+
# Prefer the app's real handshake; answer locally otherwise.
|
|
742
|
+
resp = _forwarded_result(msg_id, payload, DISCOVERY_TIMEOUT)
|
|
743
|
+
if resp is None:
|
|
744
|
+
resp = _local_initialize(msg_id)
|
|
745
|
+
|
|
746
|
+
elif method == "tools/list":
|
|
747
|
+
resp = None
|
|
748
|
+
if MODE != "local":
|
|
749
|
+
# Prefer the app's live tool surface: that is what makes
|
|
750
|
+
# drift structurally impossible for anyone with the app.
|
|
751
|
+
resp = _forwarded_result(msg_id, payload, DISCOVERY_TIMEOUT)
|
|
752
|
+
if resp is not None and MODE == "remote":
|
|
753
|
+
# The app is the only engine remote can route to, so its
|
|
754
|
+
# own list is exactly true. Relay it verbatim.
|
|
755
|
+
pass
|
|
756
|
+
elif resp is not None:
|
|
757
|
+
# auto: the app answered, but it may stop answering before
|
|
758
|
+
# the next tools/call. Narrow now so the list cannot turn
|
|
759
|
+
# into a lie mid-session (see the module docstring).
|
|
760
|
+
resp = _narrow_tools_result(resp)
|
|
761
|
+
else:
|
|
762
|
+
resp = _rpc_result(
|
|
763
|
+
msg_id, {"tools": _narrow_for_local(load_tools())}
|
|
764
|
+
)
|
|
765
|
+
|
|
766
|
+
elif method == "tools/call":
|
|
767
|
+
params = payload.get("params") or {}
|
|
768
|
+
resp = _call_tool(
|
|
769
|
+
payload, params.get("name"), params.get("arguments") or {}
|
|
770
|
+
)
|
|
771
|
+
|
|
772
|
+
elif MODE == "local":
|
|
773
|
+
# Nothing else is implemented locally, and inventing an answer
|
|
774
|
+
# would be worse than admitting it.
|
|
775
|
+
resp = _rpc_error(
|
|
776
|
+
msg_id,
|
|
777
|
+
INTERNAL_ERROR,
|
|
778
|
+
"method %r is not implemented by the local engine; it is "
|
|
779
|
+
"served by the Hive app only." % method,
|
|
780
|
+
{"mode": "local", "method": method},
|
|
781
|
+
)
|
|
782
|
+
|
|
783
|
+
else:
|
|
784
|
+
resp = forward(payload)
|
|
785
|
+
except Exception as e:
|
|
786
|
+
resp = _rpc_error(
|
|
787
|
+
msg_id,
|
|
788
|
+
INTERNAL_ERROR,
|
|
789
|
+
"%s failed to handle %s: %s" % (SERVER_NAME, method, e),
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
if resp is None:
|
|
793
|
+
continue # nothing to send back
|
|
794
|
+
sys.stdout.write(json.dumps(resp, ensure_ascii=False) + "\n")
|
|
795
|
+
sys.stdout.flush()
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
if __name__ == "__main__":
|
|
799
|
+
main()
|