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.
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()