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