hyperspell-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.
- hyperspell_mcp-0.1.0/.gitignore +51 -0
- hyperspell_mcp-0.1.0/PKG-INFO +31 -0
- hyperspell_mcp-0.1.0/README.md +21 -0
- hyperspell_mcp-0.1.0/pyproject.toml +24 -0
- hyperspell_mcp-0.1.0/src/hyperspell_mcp/__init__.py +17 -0
- hyperspell_mcp-0.1.0/src/hyperspell_mcp/backend.py +207 -0
- hyperspell_mcp-0.1.0/src/hyperspell_mcp/catalog.py +204 -0
- hyperspell_mcp-0.1.0/src/hyperspell_mcp/context.py +213 -0
- hyperspell_mcp-0.1.0/src/hyperspell_mcp/contract.py +110 -0
- hyperspell_mcp-0.1.0/tests/test_catalog.py +294 -0
- hyperspell_mcp-0.1.0/tests/test_context.py +248 -0
- hyperspell_mcp-0.1.0/uv.lock +1010 -0
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
.DS_Store
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
apps/*/.env.local
|
|
6
|
+
*.log
|
|
7
|
+
node_modules
|
|
8
|
+
|
|
9
|
+
# Local test scratchpad for development
|
|
10
|
+
local_tests/
|
|
11
|
+
|
|
12
|
+
# Personal scratch files (scripts, loom outlines, notes, etc.)
|
|
13
|
+
scratch/
|
|
14
|
+
|
|
15
|
+
# Git worktrees for isolated feature development
|
|
16
|
+
/.worktrees
|
|
17
|
+
.claude/worktrees
|
|
18
|
+
|
|
19
|
+
# Python cache
|
|
20
|
+
__pycache__/
|
|
21
|
+
*.pyc
|
|
22
|
+
*.pyo
|
|
23
|
+
*.pyd
|
|
24
|
+
apps/*/plans/*
|
|
25
|
+
# ENG-2475: this plan travels with the migration stack (referenced from the spec)
|
|
26
|
+
!apps/core/plans/resource_to_document_migration.md
|
|
27
|
+
.vercel
|
|
28
|
+
.terraform/
|
|
29
|
+
|
|
30
|
+
# Per-session handoff/braindump docs — see CLAUDE.md "Session handoff notes"
|
|
31
|
+
docs/handoffs/
|
|
32
|
+
|
|
33
|
+
# Real-prod-sourced labeled cluster fixture (PII-bearing — produced by
|
|
34
|
+
# `task api:seed-cluster-fixture` from prod canary apps then hand-labeled).
|
|
35
|
+
# Keep local for scoring; don't commit Slack/email content to the repo.
|
|
36
|
+
# labeled_docs/ fixtures use synthetic placeholders so they're safe; this
|
|
37
|
+
# fixture carries full mention contexts from the source.
|
|
38
|
+
apps/core/tests/fixtures/entity_extraction/labeled_clusters.json
|
|
39
|
+
|
|
40
|
+
# Claude Code & agent runtime artifacts
|
|
41
|
+
.agents/
|
|
42
|
+
.claude/projects/
|
|
43
|
+
.claude/*.lock
|
|
44
|
+
.vercel-snapshots/
|
|
45
|
+
AGENTIC_RESUME.md
|
|
46
|
+
skills-lock.json
|
|
47
|
+
infra/.terraform-version
|
|
48
|
+
|
|
49
|
+
# Tool scratch (firecrawl scrapes, playwright-mcp snapshots) — never commit
|
|
50
|
+
.firecrawl/
|
|
51
|
+
.playwright-mcp/
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hyperspell-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared MCP tool catalog and backends for the Hyperspell company brain.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: httpx<1,>=0.27
|
|
7
|
+
Requires-Dist: mcp<2,>=1.28
|
|
8
|
+
Requires-Dist: pydantic<3,>=2
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
|
|
11
|
+
# hyperspell-mcp
|
|
12
|
+
|
|
13
|
+
The single, canonical Model Context Protocol surface for the Hyperspell company brain.
|
|
14
|
+
|
|
15
|
+
This package owns the **tool catalog** (names, descriptions, annotations, parameter
|
|
16
|
+
defaults, compaction) and the **backend seam** that lets the same catalog run over two
|
|
17
|
+
transports:
|
|
18
|
+
|
|
19
|
+
- **Remote** — `register_tools(mcp, InProcessBackend())` mounted as Streamable HTTP at
|
|
20
|
+
`/mcp` on core-api. `InProcessBackend` lives in core-api because it calls the real
|
|
21
|
+
route handlers in-process.
|
|
22
|
+
- **Local** — `register_tools(mcp, HttpBackend(...))` run over stdio by the sync daemon,
|
|
23
|
+
plus `register_context_tools(mcp, sync_dir)` for the disk-only `*_context` tools and
|
|
24
|
+
`hyperbrain://` resources.
|
|
25
|
+
|
|
26
|
+
It deliberately does **not** copy core-api's request models. The tool parameters are
|
|
27
|
+
simple primitives; the only shared models are the lightweight response ("lite") models
|
|
28
|
+
that results are validated into so compaction is defined exactly once.
|
|
29
|
+
|
|
30
|
+
See `specs/components/unified-mcp-surface.md` for the full design and the
|
|
31
|
+
minimum-maintenance invariants.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# hyperspell-mcp
|
|
2
|
+
|
|
3
|
+
The single, canonical Model Context Protocol surface for the Hyperspell company brain.
|
|
4
|
+
|
|
5
|
+
This package owns the **tool catalog** (names, descriptions, annotations, parameter
|
|
6
|
+
defaults, compaction) and the **backend seam** that lets the same catalog run over two
|
|
7
|
+
transports:
|
|
8
|
+
|
|
9
|
+
- **Remote** — `register_tools(mcp, InProcessBackend())` mounted as Streamable HTTP at
|
|
10
|
+
`/mcp` on core-api. `InProcessBackend` lives in core-api because it calls the real
|
|
11
|
+
route handlers in-process.
|
|
12
|
+
- **Local** — `register_tools(mcp, HttpBackend(...))` run over stdio by the sync daemon,
|
|
13
|
+
plus `register_context_tools(mcp, sync_dir)` for the disk-only `*_context` tools and
|
|
14
|
+
`hyperbrain://` resources.
|
|
15
|
+
|
|
16
|
+
It deliberately does **not** copy core-api's request models. The tool parameters are
|
|
17
|
+
simple primitives; the only shared models are the lightweight response ("lite") models
|
|
18
|
+
that results are validated into so compaction is defined exactly once.
|
|
19
|
+
|
|
20
|
+
See `specs/components/unified-mcp-surface.md` for the full design and the
|
|
21
|
+
minimum-maintenance invariants.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "hyperspell-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Shared MCP tool catalog and backends for the Hyperspell company brain."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.10"
|
|
7
|
+
dependencies = [
|
|
8
|
+
"mcp>=1.28,<2",
|
|
9
|
+
"httpx>=0.27,<1",
|
|
10
|
+
"pydantic>=2,<3",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[build-system]
|
|
14
|
+
requires = ["hatchling"]
|
|
15
|
+
build-backend = "hatchling.build"
|
|
16
|
+
|
|
17
|
+
[tool.hatch.build.targets.wheel]
|
|
18
|
+
packages = ["src/hyperspell_mcp"]
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = ["pytest>=8"]
|
|
22
|
+
|
|
23
|
+
[tool.ruff]
|
|
24
|
+
line-length = 100
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# @implements specs/components/unified-mcp-surface.md
|
|
2
|
+
"""The single, canonical MCP surface for the Hyperspell company brain.
|
|
3
|
+
|
|
4
|
+
One tool catalog, rendered through any ``BrainBackend`` (in-process on core-api, or
|
|
5
|
+
HTTP from the local daemon). See ``specs/components/unified-mcp-surface.md``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .backend import BrainBackend, HttpBackend, UserIdentityRequired
|
|
9
|
+
from .catalog import register_context_tools, register_tools
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"BrainBackend",
|
|
13
|
+
"HttpBackend",
|
|
14
|
+
"UserIdentityRequired",
|
|
15
|
+
"register_tools",
|
|
16
|
+
"register_context_tools",
|
|
17
|
+
]
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# @implements specs/components/unified-mcp-surface.md
|
|
2
|
+
"""The backend seam for the unified MCP catalog.
|
|
3
|
+
|
|
4
|
+
``BrainBackend`` is the only transport-specific surface. Methods take primitives and
|
|
5
|
+
return the ``contract`` lite models; compaction and presentation live in the catalog.
|
|
6
|
+
|
|
7
|
+
``HttpBackend`` is the thin client used by the local stdio daemon. ``InProcessBackend``
|
|
8
|
+
lives in core-api (it imports the real route handlers) and is not part of this package.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Protocol, runtime_checkable
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
|
|
17
|
+
from .contract import (
|
|
18
|
+
ConnectionListLite,
|
|
19
|
+
JSONObject,
|
|
20
|
+
MemoryPageLite,
|
|
21
|
+
QueryResultRaw,
|
|
22
|
+
RememberResultLite,
|
|
23
|
+
clamp_page_size,
|
|
24
|
+
clamp_results,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UserIdentityRequired(Exception):
|
|
29
|
+
"""Raised when a user-scoped operation is attempted with an app-only credential."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@runtime_checkable
|
|
33
|
+
class BrainBackend(Protocol):
|
|
34
|
+
"""Data access for the brain tools, independent of transport."""
|
|
35
|
+
|
|
36
|
+
async def query(
|
|
37
|
+
self,
|
|
38
|
+
*,
|
|
39
|
+
query: str,
|
|
40
|
+
answer: bool,
|
|
41
|
+
effort: str,
|
|
42
|
+
max_results: int,
|
|
43
|
+
sources: list[str] | None = None,
|
|
44
|
+
) -> QueryResultRaw: ...
|
|
45
|
+
|
|
46
|
+
async def remember(self, *, text: str, title: str | None) -> RememberResultLite: ...
|
|
47
|
+
|
|
48
|
+
async def list_memories(
|
|
49
|
+
self, *, source: str | None, status: str | None, size: int, cursor: str | None
|
|
50
|
+
) -> MemoryPageLite: ...
|
|
51
|
+
|
|
52
|
+
async def list_connections(self) -> ConnectionListLite: ...
|
|
53
|
+
|
|
54
|
+
async def brain_status(self) -> JSONObject: ...
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class HttpBackend:
|
|
58
|
+
"""``BrainBackend`` over HTTP against the Hyperspell REST API (~6 endpoints).
|
|
59
|
+
|
|
60
|
+
Used by the local stdio daemon. Auth is the config API key; an optional user id is
|
|
61
|
+
sent as ``X-As-User`` for user-scoped calls.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(
|
|
65
|
+
self,
|
|
66
|
+
*,
|
|
67
|
+
base_url: str,
|
|
68
|
+
api_key: str,
|
|
69
|
+
user_id: str | None = None,
|
|
70
|
+
client: httpx.AsyncClient | None = None,
|
|
71
|
+
timeout: float = 30.0,
|
|
72
|
+
) -> None:
|
|
73
|
+
# Cache of connected sources so the all-source fan-out costs one /auth/me per
|
|
74
|
+
# client lifetime, not one per query.
|
|
75
|
+
self._connected: list[str] | None = None
|
|
76
|
+
# Matches the hyperspell SDK's auth headers exactly: Bearer api key + X-As-User.
|
|
77
|
+
headers = {"Authorization": f"Bearer {api_key}"}
|
|
78
|
+
if user_id:
|
|
79
|
+
headers["X-As-User"] = user_id
|
|
80
|
+
if client is not None:
|
|
81
|
+
client.headers.update(headers)
|
|
82
|
+
self._client = client
|
|
83
|
+
else:
|
|
84
|
+
self._client = httpx.AsyncClient(
|
|
85
|
+
base_url=base_url.rstrip("/"), headers=headers, timeout=timeout
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
async def _connected_sources(self) -> list[str]:
|
|
89
|
+
"""All sources to search by default: the vault plus every connected integration.
|
|
90
|
+
|
|
91
|
+
So ask/search fan out across Slack, email, Drive, etc. rather than silently
|
|
92
|
+
searching the vault only (the API defaults omitted ``sources`` to vault). Cached
|
|
93
|
+
for the client's lifetime.
|
|
94
|
+
|
|
95
|
+
Uses ``installed_integrations`` (actually connected by this user), NOT
|
|
96
|
+
``available_integrations`` (everything configured on the app): a
|
|
97
|
+
configured-but-unconnected source is at best noise, and a live-only integration
|
|
98
|
+
would spin up a real-time component a stale token could fail. ``/auth/me`` is
|
|
99
|
+
called unconditionally — it authenticates any user-scoped credential, including
|
|
100
|
+
the daemon's device JWT, which carries no separate ``user_id``. Only a userless
|
|
101
|
+
app-only key 401s there and falls back to vault.
|
|
102
|
+
"""
|
|
103
|
+
if self._connected is not None:
|
|
104
|
+
return self._connected
|
|
105
|
+
sources = ["vault"]
|
|
106
|
+
resp = await self._client.get("/auth/me")
|
|
107
|
+
if resp.status_code == 200:
|
|
108
|
+
for s in resp.json().get("installed_integrations") or []:
|
|
109
|
+
if s not in sources:
|
|
110
|
+
sources.append(s)
|
|
111
|
+
self._connected = sources
|
|
112
|
+
return self._connected
|
|
113
|
+
|
|
114
|
+
async def query(
|
|
115
|
+
self,
|
|
116
|
+
*,
|
|
117
|
+
query: str,
|
|
118
|
+
answer: bool,
|
|
119
|
+
effort: str,
|
|
120
|
+
max_results: int,
|
|
121
|
+
sources: list[str] | None = None,
|
|
122
|
+
) -> QueryResultRaw:
|
|
123
|
+
limit = clamp_results(max_results)
|
|
124
|
+
if sources is None:
|
|
125
|
+
sources = await self._connected_sources()
|
|
126
|
+
resp = await self._client.post(
|
|
127
|
+
"/memories/query",
|
|
128
|
+
json={
|
|
129
|
+
"query": query,
|
|
130
|
+
"answer": answer,
|
|
131
|
+
"effort": effort,
|
|
132
|
+
"sources": sources,
|
|
133
|
+
# Send max_results top-level only. QueryRequest's set_options validator
|
|
134
|
+
# copies the top-level value into options.max_results (the field the
|
|
135
|
+
# search reads); sending it under options instead would be overwritten by
|
|
136
|
+
# the top-level default (10).
|
|
137
|
+
"max_results": limit,
|
|
138
|
+
},
|
|
139
|
+
)
|
|
140
|
+
resp.raise_for_status()
|
|
141
|
+
return QueryResultRaw.model_validate(resp.json())
|
|
142
|
+
|
|
143
|
+
async def remember(self, *, text: str, title: str | None) -> RememberResultLite:
|
|
144
|
+
body: dict[str, object] = {"text": text}
|
|
145
|
+
if title:
|
|
146
|
+
body["title"] = title
|
|
147
|
+
resp = await self._client.post("/memories/add", json=body)
|
|
148
|
+
resp.raise_for_status()
|
|
149
|
+
return RememberResultLite.model_validate(resp.json())
|
|
150
|
+
|
|
151
|
+
async def list_memories(
|
|
152
|
+
self, *, source: str | None, status: str | None, size: int, cursor: str | None
|
|
153
|
+
) -> MemoryPageLite:
|
|
154
|
+
params: dict[str, object] = {"size": clamp_page_size(size)}
|
|
155
|
+
if source:
|
|
156
|
+
params["source"] = source
|
|
157
|
+
if status:
|
|
158
|
+
params["status"] = status
|
|
159
|
+
if cursor:
|
|
160
|
+
params["cursor"] = cursor
|
|
161
|
+
resp = await self._client.get("/memories/list", params=params)
|
|
162
|
+
resp.raise_for_status()
|
|
163
|
+
data = resp.json()
|
|
164
|
+
return MemoryPageLite(documents=data.get("items", []), next_cursor=data.get("next_cursor"))
|
|
165
|
+
|
|
166
|
+
async def list_connections(self) -> ConnectionListLite:
|
|
167
|
+
# Don't gate on self._user_id: a device JWT is user-scoped but carries no
|
|
168
|
+
# separate user_id. Let the server decide — /connections/list 401/403s for a
|
|
169
|
+
# userless app-only key, which we surface as UserIdentityRequired.
|
|
170
|
+
resp = await self._client.get("/connections/list")
|
|
171
|
+
if resp.status_code in (401, 403):
|
|
172
|
+
raise UserIdentityRequired("list_connections requires a user identity")
|
|
173
|
+
resp.raise_for_status()
|
|
174
|
+
return ConnectionListLite.model_validate(resp.json())
|
|
175
|
+
|
|
176
|
+
async def brain_status(self) -> JSONObject:
|
|
177
|
+
"""Network identity + coverage for this token (the remote-flavored brain_status).
|
|
178
|
+
|
|
179
|
+
``/auth/me`` gives identity + connected integrations for any user-scoped
|
|
180
|
+
credential (incl. the daemon's device JWT, which has no separate user_id); a
|
|
181
|
+
userless app-only key 401s there but still gets app-wide coverage from
|
|
182
|
+
``/memories/status``. Both calls are made unconditionally — no user_id gate.
|
|
183
|
+
"""
|
|
184
|
+
status: JSONObject = {"app_id": None, "user_scoped": False}
|
|
185
|
+
me = await self._client.get("/auth/me")
|
|
186
|
+
if me.status_code == 200:
|
|
187
|
+
data = me.json()
|
|
188
|
+
app = data.get("app") or {}
|
|
189
|
+
status.update(
|
|
190
|
+
{
|
|
191
|
+
"app_id": app.get("id"),
|
|
192
|
+
"user_scoped": True,
|
|
193
|
+
"available_integrations": data.get("available_integrations", []),
|
|
194
|
+
"installed_integrations": data.get("installed_integrations", []),
|
|
195
|
+
}
|
|
196
|
+
)
|
|
197
|
+
# The spec's brain_status is "identity + brain health/coverage"; compose
|
|
198
|
+
# /memories/status for document-level coverage (works for app-only keys too).
|
|
199
|
+
# Best-effort: never fail brain_status if the status route is unavailable.
|
|
200
|
+
cov = await self._client.get("/memories/status")
|
|
201
|
+
if cov.status_code == 200:
|
|
202
|
+
status["coverage"] = cov.json()
|
|
203
|
+
return status
|
|
204
|
+
|
|
205
|
+
async def aclose(self) -> None:
|
|
206
|
+
"""Close the underlying httpx client."""
|
|
207
|
+
await self._client.aclose()
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
# @implements specs/components/unified-mcp-surface.md
|
|
2
|
+
"""The canonical Hyperspell brain tool catalog, registered onto any FastMCP server.
|
|
3
|
+
|
|
4
|
+
``register_tools`` is the single definition of the six brain tools + the ``ask_brain``
|
|
5
|
+
prompt — names, descriptions, annotations, parameter defaults, and compaction all live
|
|
6
|
+
here, once, and run against any ``BrainBackend``. ``register_context_tools`` adds the
|
|
7
|
+
local-only filesystem tools and ``hyperbrain://`` resources for the stdio daemon.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from mcp.server.fastmcp import FastMCP
|
|
15
|
+
from mcp.types import ToolAnnotations
|
|
16
|
+
|
|
17
|
+
from . import context as ctx
|
|
18
|
+
from .backend import BrainBackend, UserIdentityRequired
|
|
19
|
+
from .contract import (
|
|
20
|
+
DEFAULT_EFFORT,
|
|
21
|
+
DOC_CITATION_FIELDS,
|
|
22
|
+
EFFORTS,
|
|
23
|
+
AskPayload,
|
|
24
|
+
ConnectionListLite,
|
|
25
|
+
JSONObject,
|
|
26
|
+
MemoryPageLite,
|
|
27
|
+
QueryResultRaw,
|
|
28
|
+
RememberResultLite,
|
|
29
|
+
SearchPayload,
|
|
30
|
+
clamp_page_size,
|
|
31
|
+
clamp_results,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
_READ_QUERY = ToolAnnotations(readOnlyHint=True, openWorldHint=True)
|
|
35
|
+
_READ_INTERNAL = ToolAnnotations(readOnlyHint=True, openWorldHint=False)
|
|
36
|
+
_WRITE = ToolAnnotations(
|
|
37
|
+
readOnlyHint=False, destructiveHint=False, idempotentHint=False, openWorldHint=True
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _effort(value: str) -> str:
|
|
42
|
+
if value not in EFFORTS:
|
|
43
|
+
raise ValueError(f"effort must be one of {sorted(EFFORTS)}")
|
|
44
|
+
return value
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _compact_doc(doc: JSONObject) -> JSONObject:
|
|
48
|
+
"""Reduce a document to its citation fields (single definition of compaction)."""
|
|
49
|
+
return {k: doc[k] for k in DOC_CITATION_FIELDS if k in doc}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _ask_payload(raw: QueryResultRaw, full: bool) -> AskPayload:
|
|
53
|
+
"""Assemble the ``ask`` wire payload — citations compacted unless ``full``."""
|
|
54
|
+
citations = raw.documents if full else [_compact_doc(d) for d in raw.documents]
|
|
55
|
+
return AskPayload(
|
|
56
|
+
answer=raw.answer, citations=citations, errors=raw.errors, query_id=raw.query_id
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _search_payload(raw: QueryResultRaw, full: bool) -> SearchPayload:
|
|
61
|
+
"""Assemble the ``search`` wire payload — documents compacted unless ``full``."""
|
|
62
|
+
documents = raw.documents if full else [_compact_doc(d) for d in raw.documents]
|
|
63
|
+
return SearchPayload(documents=documents, errors=raw.errors, query_id=raw.query_id)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def register_tools(mcp: FastMCP, backend: BrainBackend) -> None:
|
|
67
|
+
"""Register the six canonical brain tools + the ``ask_brain`` prompt on ``mcp``."""
|
|
68
|
+
|
|
69
|
+
async def ask(
|
|
70
|
+
question: str,
|
|
71
|
+
effort: str = DEFAULT_EFFORT,
|
|
72
|
+
max_results: int = 10,
|
|
73
|
+
full: bool = False,
|
|
74
|
+
) -> AskPayload:
|
|
75
|
+
"""Ask the company brain a question; returns a synthesized, cited answer."""
|
|
76
|
+
raw = await backend.query(
|
|
77
|
+
query=question,
|
|
78
|
+
answer=True,
|
|
79
|
+
effort=_effort(effort),
|
|
80
|
+
max_results=clamp_results(max_results),
|
|
81
|
+
)
|
|
82
|
+
return _ask_payload(raw, full)
|
|
83
|
+
|
|
84
|
+
async def search(
|
|
85
|
+
query: str,
|
|
86
|
+
max_results: int = 10,
|
|
87
|
+
effort: str = "minimal",
|
|
88
|
+
full: bool = False,
|
|
89
|
+
) -> SearchPayload:
|
|
90
|
+
"""Search the brain for ranked documents matching a query (no synthesis)."""
|
|
91
|
+
raw = await backend.query(
|
|
92
|
+
query=query,
|
|
93
|
+
answer=False,
|
|
94
|
+
effort=_effort(effort),
|
|
95
|
+
max_results=clamp_results(max_results),
|
|
96
|
+
)
|
|
97
|
+
return _search_payload(raw, full)
|
|
98
|
+
|
|
99
|
+
async def remember(text: str, title: str | None = None) -> RememberResultLite:
|
|
100
|
+
"""Write a note/document into the brain so future queries surface it."""
|
|
101
|
+
return await backend.remember(text=text, title=title)
|
|
102
|
+
|
|
103
|
+
async def list_memories(
|
|
104
|
+
source: str | None = None,
|
|
105
|
+
status: str | None = None,
|
|
106
|
+
size: int = 25,
|
|
107
|
+
cursor: str | None = None,
|
|
108
|
+
) -> MemoryPageLite:
|
|
109
|
+
"""List indexed documents (one page); filter by source/status."""
|
|
110
|
+
return await backend.list_memories(
|
|
111
|
+
source=source, status=status, size=clamp_page_size(size), cursor=cursor
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
async def list_connections() -> ConnectionListLite:
|
|
115
|
+
"""List the active integration connections for this user/app."""
|
|
116
|
+
try:
|
|
117
|
+
return await backend.list_connections()
|
|
118
|
+
except UserIdentityRequired:
|
|
119
|
+
return ConnectionListLite(error="user_identity_required")
|
|
120
|
+
|
|
121
|
+
async def brain_status() -> JSONObject:
|
|
122
|
+
"""Report brain status for this token.
|
|
123
|
+
|
|
124
|
+
One tool, two behaviors by transport: the remote backend returns network
|
|
125
|
+
identity (app, user-scope, connected integrations); the local backend returns
|
|
126
|
+
filesystem sync status (is a summary synced, how to read it).
|
|
127
|
+
"""
|
|
128
|
+
return await backend.brain_status()
|
|
129
|
+
|
|
130
|
+
mcp.tool(
|
|
131
|
+
description="Ask the company brain a question; returns a synthesized, cited answer.",
|
|
132
|
+
annotations=_READ_QUERY,
|
|
133
|
+
)(ask)
|
|
134
|
+
mcp.tool(
|
|
135
|
+
description="Search the brain for ranked documents matching a query (no synthesis).",
|
|
136
|
+
annotations=_READ_QUERY,
|
|
137
|
+
)(search)
|
|
138
|
+
mcp.tool(
|
|
139
|
+
description="Write a note/document into the brain so future queries surface it.",
|
|
140
|
+
annotations=_WRITE,
|
|
141
|
+
)(remember)
|
|
142
|
+
mcp.tool(
|
|
143
|
+
description="List indexed documents (one page); filter by source/status.",
|
|
144
|
+
annotations=_READ_INTERNAL,
|
|
145
|
+
)(list_memories)
|
|
146
|
+
mcp.tool(
|
|
147
|
+
description="List the active integration connections for this user/app.",
|
|
148
|
+
annotations=_READ_INTERNAL,
|
|
149
|
+
)(list_connections)
|
|
150
|
+
mcp.tool(
|
|
151
|
+
description="Report brain status and how to use it — local sync status on this "
|
|
152
|
+
"machine, or network identity and coverage.",
|
|
153
|
+
annotations=_READ_INTERNAL,
|
|
154
|
+
)(brain_status)
|
|
155
|
+
|
|
156
|
+
@mcp.prompt()
|
|
157
|
+
def ask_brain(question: str) -> str:
|
|
158
|
+
"""Prompt the host to use the ``ask`` tool for a brain question."""
|
|
159
|
+
return f"Use the `ask` tool to answer this from the company brain: {question}"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def register_context_tools(mcp: FastMCP, sync_dir: Path | str) -> None:
|
|
163
|
+
"""Register the local-only filesystem tools + ``hyperbrain://`` resources.
|
|
164
|
+
|
|
165
|
+
These read the synced ``~/.hyperspell`` summary off local disk and make no API call,
|
|
166
|
+
so they are only meaningful on the stdio (daemon) transport. The reachability and
|
|
167
|
+
symlink-escape hardening lives in ``context`` and is shared with the read resource.
|
|
168
|
+
"""
|
|
169
|
+
root = Path(sync_dir).expanduser()
|
|
170
|
+
|
|
171
|
+
async def list_context() -> list[str]:
|
|
172
|
+
"""List the locally-synced company-brain summary files (no API call)."""
|
|
173
|
+
return ctx.list_context_paths(root)
|
|
174
|
+
|
|
175
|
+
async def read_context(path: str) -> str:
|
|
176
|
+
"""Read one locally-synced summary file by its relative path (no API call)."""
|
|
177
|
+
return ctx.read_context_file(root, path)
|
|
178
|
+
|
|
179
|
+
async def grep_context(query: str, max_results: int = 50) -> JSONObject:
|
|
180
|
+
"""Case-insensitive substring search over the local summary tree (no API call)."""
|
|
181
|
+
return ctx.grep_context(root, query, max_results)
|
|
182
|
+
|
|
183
|
+
mcp.tool(
|
|
184
|
+
description="List the locally-synced company-brain summary files (no API call).",
|
|
185
|
+
annotations=_READ_INTERNAL,
|
|
186
|
+
)(list_context)
|
|
187
|
+
mcp.tool(
|
|
188
|
+
description="Read one locally-synced summary file by its relative path (no API call).",
|
|
189
|
+
annotations=_READ_INTERNAL,
|
|
190
|
+
)(read_context)
|
|
191
|
+
mcp.tool(
|
|
192
|
+
description="Keyword-search across the locally-synced summary (no API call).",
|
|
193
|
+
annotations=_READ_INTERNAL,
|
|
194
|
+
)(grep_context)
|
|
195
|
+
|
|
196
|
+
@mcp.resource("hyperbrain://context")
|
|
197
|
+
def context_index() -> list[str]:
|
|
198
|
+
"""The list of locally-synced summary files."""
|
|
199
|
+
return ctx.list_context_paths(root)
|
|
200
|
+
|
|
201
|
+
@mcp.resource("hyperbrain://context/{path}")
|
|
202
|
+
def context_file(path: str) -> str:
|
|
203
|
+
"""The contents of one locally-synced summary file."""
|
|
204
|
+
return ctx.read_context_file(root, path)
|