mindslice 0.1.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.
- mindslice/__init__.py +58 -0
- mindslice/__main__.py +5 -0
- mindslice/baking/__init__.py +1 -0
- mindslice/baking/archive.py +66 -0
- mindslice/baking/bake.py +299 -0
- mindslice/baking/bake_batch.py +537 -0
- mindslice/baking/slice_transfer.py +265 -0
- mindslice/errors.py +104 -0
- mindslice/interfaces/__init__.py +1 -0
- mindslice/interfaces/bake_review.py +230 -0
- mindslice/interfaces/cli.py +392 -0
- mindslice/interfaces/cli_addressing.py +52 -0
- mindslice/interfaces/doctor.py +184 -0
- mindslice/interfaces/mcp_server.py +536 -0
- mindslice/interfaces/render.py +346 -0
- mindslice/interfaces/staging_review.py +92 -0
- mindslice/model/__init__.py +1 -0
- mindslice/model/addressing.py +251 -0
- mindslice/model/card.py +641 -0
- mindslice/model/lineage.py +228 -0
- mindslice/model/text.py +19 -0
- mindslice/model/tomlfmt.py +129 -0
- mindslice/operations/__init__.py +1 -0
- mindslice/operations/audit.py +195 -0
- mindslice/operations/boot.py +183 -0
- mindslice/operations/impact.py +98 -0
- mindslice/operations/recovery.py +113 -0
- mindslice/operations/staging.py +641 -0
- mindslice/operations/status.py +137 -0
- mindslice/operations/validation.py +250 -0
- mindslice/operations/writer.py +481 -0
- mindslice/retrieval/__init__.py +1 -0
- mindslice/retrieval/index.py +634 -0
- mindslice/retrieval/lexical.py +215 -0
- mindslice/retrieval/query.py +593 -0
- mindslice/storage/__init__.py +1 -0
- mindslice/storage/card_io.py +14 -0
- mindslice/storage/config.py +192 -0
- mindslice/storage/fsutil.py +141 -0
- mindslice/storage/history.py +182 -0
- mindslice/storage/locking.py +125 -0
- mindslice/storage/snapshots.py +262 -0
- mindslice/workspace.py +385 -0
- mindslice-0.1.0.dist-info/METADATA +98 -0
- mindslice-0.1.0.dist-info/RECORD +50 -0
- mindslice-0.1.0.dist-info/WHEEL +5 -0
- mindslice-0.1.0.dist-info/entry_points.txt +2 -0
- mindslice-0.1.0.dist-info/licenses/LICENSE +202 -0
- mindslice-0.1.0.dist-info/licenses/NOTICE +22 -0
- mindslice-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
"""Thin stdio MCP server exposing the four Group A primitives as tools.
|
|
2
|
+
|
|
3
|
+
Speaks newline-delimited JSON-RPC 2.0 (the MCP stdio transport) using only the
|
|
4
|
+
standard library. Tools: ``manifest``, ``search``, ``read``, ``write``.
|
|
5
|
+
|
|
6
|
+
Run with ``mindslice -w <workspace> mcp`` (add ``--stage-writes`` to force all
|
|
7
|
+
agent writes into ``staging/`` for human review via ``diff``/``apply``).
|
|
8
|
+
|
|
9
|
+
Always-loaded cards (``load = "always"``) reach the agent without a query: rendered into the
|
|
10
|
+
``initialize`` instructions, listed by ``manifest``, and, when the set changes mid-session (a slice
|
|
11
|
+
mounted, a staged change approved), attached once to the next tool result as
|
|
12
|
+
``always_loaded_update``. Agent writes that touch them are always staged for the user's approval:
|
|
13
|
+
the agent cannot opt out with ``stage=false``, and ``apply`` is not an agent tool.
|
|
14
|
+
|
|
15
|
+
A client is free to keep the ``initialize`` instructions from the model (some park them in a file, some
|
|
16
|
+
cut them at a length), so ``manifest`` returns the same startup context: ``instructions`` (the usage
|
|
17
|
+
text) next to ``always_loaded``. One ``manifest`` call is then a complete session start in any client.
|
|
18
|
+
|
|
19
|
+
The server also notes which client connected and which tools it used in ``.cache/mcp-clients.json``
|
|
20
|
+
(``doctor`` reports it): evidence that an app really launches this workspace's server, whatever
|
|
21
|
+
configuration file made it do so.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import difflib
|
|
27
|
+
import json
|
|
28
|
+
import sys
|
|
29
|
+
from datetime import datetime, timezone
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
from typing import IO, Any
|
|
32
|
+
|
|
33
|
+
from .. import __version__
|
|
34
|
+
from ..operations.boot import fingerprint, render_markdown
|
|
35
|
+
from ..errors import InvalidArgumentsError, MindSliceError
|
|
36
|
+
from ..storage.fsutil import atomic_write_text
|
|
37
|
+
from ..workspace import Workspace
|
|
38
|
+
|
|
39
|
+
PROTOCOL_VERSIONS = ("2025-06-18", "2025-03-26", "2024-11-05")
|
|
40
|
+
|
|
41
|
+
# Which MCP clients connected to this workspace (under ``.cache/``: disposable, never part of memory).
|
|
42
|
+
CLIENTS_FILE = "mcp-clients.json"
|
|
43
|
+
DOCTOR_CLIENT = "mindslice-doctor" # doctor's own handshake probe is not an app connecting
|
|
44
|
+
|
|
45
|
+
_FOLLOW_SCHEMA = {
|
|
46
|
+
"type": ["boolean", "array"],
|
|
47
|
+
"items": {"type": "string"},
|
|
48
|
+
"description": "true = follow every supersedes edge (default); false = never; or an allowlist of mount roles / @aliases "
|
|
49
|
+
"whose replacements are trusted (e.g. [\"curated\"]). Untrusted replacements are still reported.",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _int(default: int, desc: str) -> dict[str, Any]:
|
|
54
|
+
return {"type": "integer", "minimum": 0, "default": default, "description": desc}
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
TOOLS: list[dict[str, Any]] = [
|
|
58
|
+
{
|
|
59
|
+
"name": "manifest",
|
|
60
|
+
"description": (
|
|
61
|
+
"Boot context; call this first in a session, and again after the conversation is compacted. Returns "
|
|
62
|
+
"instructions (how to use and maintain this memory), always_loaded (the full always-loaded cards: "
|
|
63
|
+
"persona, language, standing instructions that apply to the whole session; follow them) and the mounted "
|
|
64
|
+
"memory slices (alias, role, precedence, card counts, 160-char description, top-level folders)."
|
|
65
|
+
),
|
|
66
|
+
"inputSchema": {
|
|
67
|
+
"type": "object",
|
|
68
|
+
"properties": {
|
|
69
|
+
"limit": _int(20, "mounts per page"),
|
|
70
|
+
"offset": _int(0, "pagination offset"),
|
|
71
|
+
"max_folders_per_mount": _int(10, "top-level folders listed per mount"),
|
|
72
|
+
"include_overview": {"type": "boolean", "default": False, "description": "inline each slice's overview.md"},
|
|
73
|
+
"include_always": {"type": "boolean", "default": True, "description": "include instructions and always_loaded cards (first page only)"},
|
|
74
|
+
},
|
|
75
|
+
"additionalProperties": False,
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"name": "search",
|
|
80
|
+
"description": (
|
|
81
|
+
"Associative lexical search across all mounted slices (any language, including unspaced Chinese/Japanese/"
|
|
82
|
+
"Korean), ranked by BM25 score. Each card preview also reports coverage, the share of the query it matched. Returns card "
|
|
83
|
+
"previews (address, role, coverage, score, title, 240-char summary, 1-hop [[wikilink]] neighbors). Hits on "
|
|
84
|
+
"superseded cards are redirected to their replacement (see matched_via_superseded; bypassed_replacements "
|
|
85
|
+
"lists replacements your follow_supersedes policy did not trust). A preview whose "
|
|
86
|
+
"extends chain changed says so: baseline_superseded_by (a baseline, at any depth, was replaced) or "
|
|
87
|
+
"missing_baselines (one was deleted); read it before relying on it. Use read() on an "
|
|
88
|
+
"address for full content. Search is lexical: unmatched_terms lists query words no card contains. When "
|
|
89
|
+
"results are empty or weak, rephrase those words (fix a typo, try a synonym or the other language) or "
|
|
90
|
+
"reuse the vocabulary of the cards you did find."
|
|
91
|
+
),
|
|
92
|
+
"inputSchema": {
|
|
93
|
+
"type": "object",
|
|
94
|
+
"properties": {
|
|
95
|
+
"query": {"type": "string", "description": "free text; words are OR-ed and ranked by BM25 (word* = prefix); CJK needs no spaces"},
|
|
96
|
+
"source": {"type": ["string", "array"], "items": {"type": "string"}, "description": "restrict to mount alias(es), e.g. \"@project\""},
|
|
97
|
+
"tags": {"type": "array", "items": {"type": "string"}, "description": "cards must carry all of these tags"},
|
|
98
|
+
"k": _int(10, "max results"),
|
|
99
|
+
"offset": _int(0, "pagination offset"),
|
|
100
|
+
"max_neighbors": _int(3, "wikilink neighbors per preview"),
|
|
101
|
+
"follow_supersedes": _FOLLOW_SCHEMA,
|
|
102
|
+
},
|
|
103
|
+
"required": ["query"],
|
|
104
|
+
"additionalProperties": False,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "read",
|
|
109
|
+
"description": (
|
|
110
|
+
"Read one card by address: '@alias/path' (exact mount) or 'path' (highest-precedence mount wins; other "
|
|
111
|
+
"same-path cards are listed in shadows). Follows supersedes to the replacement (redirected_from; untrusted "
|
|
112
|
+
"replacements are listed in superseded_by_untrusted and bypassed_replacements, not followed), composes "
|
|
113
|
+
"extends baselines/extensions, and returns sha256 (pass it as write.if_match), links and backlinks."
|
|
114
|
+
),
|
|
115
|
+
"inputSchema": {
|
|
116
|
+
"type": "object",
|
|
117
|
+
"properties": {
|
|
118
|
+
"address": {"type": "string", "description": "e.g. \"@project/api/auth-flow\" or \"api/auth-flow\""},
|
|
119
|
+
"follow_supersedes": _FOLLOW_SCHEMA,
|
|
120
|
+
"max_backlinks": _int(10, "backlinks per page"),
|
|
121
|
+
"backlinks_offset": _int(0, "backlinks pagination offset"),
|
|
122
|
+
"max_extensions": _int(5, "extensions per page"),
|
|
123
|
+
"extensions_offset": _int(0, "extensions pagination offset"),
|
|
124
|
+
},
|
|
125
|
+
"required": ["address"],
|
|
126
|
+
"additionalProperties": False,
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
"name": "write",
|
|
131
|
+
"description": (
|
|
132
|
+
"Proactively record confirmed information useful in future sessions; do not wait for 'remember this'. "
|
|
133
|
+
"Use for lasting preferences, accepted decisions, stable constraints, corrections and verified reusable "
|
|
134
|
+
"lessons, within the user's memory policy. Search/read related memory first to avoid duplicate writes. "
|
|
135
|
+
"Write into @overlay (base slices are read-only). One focused sub-topic per card: "
|
|
136
|
+
"a short summary and only the self-contained '- ' bullet statements needed to keep the concrete values, "
|
|
137
|
+
"names, dates and conditions (search is lexical: a word you leave out can't be found); link related cards "
|
|
138
|
+
"with [[path]]. supersedes and delete act on whole cards, so keep facts that can change on their own on "
|
|
139
|
+
"separate cards. op=create (idempotent if identical), replace (only the fields you pass; content swaps the whole body, so include every statement that still holds, not just what changed), append (adds "
|
|
140
|
+
"bullets, skips duplicates; unions tags/aliases/extends/supersedes), delete. replace/append on a card "
|
|
141
|
+
"that lives only in a read-only slice (search or read found it as @slice/path) work with the same id: "
|
|
142
|
+
"the edited copy is saved in @overlay as that card's replacement (forked_from in the result). "
|
|
143
|
+
"supersedes: this card "
|
|
144
|
+
"replaces an outdated one, which stays searchable and forwards its hits here (restate whatever in it is "
|
|
145
|
+
"still true). extends: this card refines, adds an exception or if-then rule to, or depends on another "
|
|
146
|
+
"card as its premise; if that baseline is later superseded or deleted, this card is flagged. A wrong "
|
|
147
|
+
"supersedes hides a card that is still true and extends hides nothing, so when unsure, use extends. "
|
|
148
|
+
"Use delete to forget on request or to remove a wrong card. stage=true buffers the change in staging/ "
|
|
149
|
+
"for human review. Name the id by the durable topic or slot it fills (team/lead, "
|
|
150
|
+
"subscriptions/streaming), never by its current value (team/lead-sarah-chen): ids and links outlive "
|
|
151
|
+
"values. When a write supersedes or deletes a card, the response lists affected_backlinks (cards that "
|
|
152
|
+
"[[link]] to it) and affected_extensions (cards that extend it). Fix them in the same session: rewrite "
|
|
153
|
+
"a card that still states the old value as current or links to a card named after it; rebase an "
|
|
154
|
+
"extension that still holds (replace with extends naming the replacement), else supersede or delete "
|
|
155
|
+
"it. A link to a slot can stay (read() follows supersedes). read_only items are in base slices: "
|
|
156
|
+
"supersede one only if it states the old value as current. load=\"always\" makes a card load into "
|
|
157
|
+
"every session (persona, reply language, standing instructions); any change to the always-loaded context is staged for the user's approval "
|
|
158
|
+
"(requires_approval in the result): tell the user, who approves it with `mindslice apply`."
|
|
159
|
+
),
|
|
160
|
+
"inputSchema": {
|
|
161
|
+
"type": "object",
|
|
162
|
+
"properties": {
|
|
163
|
+
"id": {"type": "string", "description": "overlay-relative card path without .md, named by durable topic or slot, "
|
|
164
|
+
"e.g. \"team/lead\" (not \"team/lead-sarah-chen\"); if the slot's id is taken by the card being "
|
|
165
|
+
"superseded, qualify it with the date of the change (\"team/lead-2026-11\"), not the new value"},
|
|
166
|
+
"op": {"type": "string", "enum": ["create", "replace", "append", "delete"], "default": "create"},
|
|
167
|
+
"content": {"type": ["string", "array"], "items": {"type": "string"}, "description": "card body (Markdown), or bullet statements for append"},
|
|
168
|
+
"summary": {"type": "string", "description": "1-2 sentence abstract (<= 240 chars) stating the key fact with its concrete value; shown in search previews"},
|
|
169
|
+
"title": {"type": "string", "description": "specific, descriptive title; the most heavily weighted search field"},
|
|
170
|
+
"tags": {"type": "array", "items": {"type": "string"}, "description": "facets; searched, and filterable with search(tags=...)"},
|
|
171
|
+
"aliases": {"type": "array", "items": {"type": "string"}, "description": "3-8 search synonyms of 1-4 words: other phrasings, the asker's words, abbreviations, former names, other-language terms (weighted just below the title; also [[alias]] link targets)"},
|
|
172
|
+
"extends": {"type": "array", "items": {"type": "string"}, "description": "baselines this card refines, adds exceptions or if-then rules to, or depends on as a premise (@alias/path); flagged if one is later superseded or deleted"},
|
|
173
|
+
"supersedes": {"type": "array", "items": {"type": "string"}, "description": "outdated cards this one replaces (@alias/path); their search hits forward here"},
|
|
174
|
+
"stage": {"type": "boolean", "description": "true buffers the change in staging/ for human review; "
|
|
175
|
+
"changes to always-loaded cards are staged whatever you pass"},
|
|
176
|
+
"load": {"type": "string", "enum": ["always", "as-needed"], "description": "\"always\": loaded into "
|
|
177
|
+
"every session without a query (persona, language, standing instructions); default \"as-needed\""},
|
|
178
|
+
"reason": {"type": "string", "minLength": 1, "description": "Why this change is needed, including the user correction or source when available. Supply this on writes; the engine records history. Never edit .history directly."},
|
|
179
|
+
"if_match": {"type": "string", "description": "sha256 from read(); rejects the write if the card changed"},
|
|
180
|
+
},
|
|
181
|
+
"required": ["id"],
|
|
182
|
+
"additionalProperties": False,
|
|
183
|
+
},
|
|
184
|
+
},
|
|
185
|
+
]
|
|
186
|
+
|
|
187
|
+
INSTRUCTIONS = (
|
|
188
|
+
"MindSlice is your long-term memory. Start with manifest() to see the mounted slices, use search() to find "
|
|
189
|
+
"relevant cards, and read() an address for full content (it composes extensions and follows replacements).\n\n"
|
|
190
|
+
"Actively maintain memory within the user's requested scope and standing memory policy; do not wait for "
|
|
191
|
+
"the user to say 'remember this'. Record explicitly stated lasting preferences, accepted project decisions, "
|
|
192
|
+
"stable constraints, user corrections and verified reusable debugging lessons when they will help future "
|
|
193
|
+
"sessions. After a relevant confirmation or verified result, and before completing a meaningful task phase, "
|
|
194
|
+
"check whether anything new or corrected should be saved. Write it while the evidence is available; do not "
|
|
195
|
+
"rely on receiving a session-end notification. If there is no durable change, make no memory write.\n\n"
|
|
196
|
+
"Distinguish user decisions from your own suggestions that have not been adopted. Do not automatically store "
|
|
197
|
+
"guesses, one-off task state or raw conversation transcripts. Respect requests not to remember something. "
|
|
198
|
+
"Search and read related cards before writing unless you already did in this "
|
|
199
|
+
"conversation; update an existing card when appropriate and skip information already represented. Keep "
|
|
200
|
+
"the scope, conditions and evidence; do not invent detail to fill a card. "
|
|
201
|
+
"Supply reason with write() to explain the user correction or evidence for the change. The engine owns "
|
|
202
|
+
".history/changes.jsonl; never write that log yourself. "
|
|
203
|
+
"Write with write() into @overlay, one focused "
|
|
204
|
+
"sub-topic per card: a person, a decision, an ongoing thread and the current state of the work each get their "
|
|
205
|
+
"own card, linked with [[path]], not one card collecting a whole session. Put each fact on "
|
|
206
|
+
"the card of the subject it is about; a "
|
|
207
|
+
"current-state card says only where things stand now and links to the rest. Keep every card true and "
|
|
208
|
+
"complete: replace the statements that are now wrong instead of adding the "
|
|
209
|
+
"newer fact beside them. op=replace swaps the whole body, so send every statement that "
|
|
210
|
+
"still holds, never only the latest change or a note that the rest is unchanged; append only adds "
|
|
211
|
+
"statements that are new, and a card that has grown to cover several topics is split. Use extends when a card refines, adds an exception or if-then "
|
|
212
|
+
"rule to, or depends on another, supersedes to replace an outdated card (not delete), and delete only to "
|
|
213
|
+
"forget on request. Name a "
|
|
214
|
+
"card by the durable topic or slot it fills (team/lead, subscriptions/streaming), never by the value in it "
|
|
215
|
+
"(team/lead-sarah-chen). When a write supersedes or deletes a card, its response lists "
|
|
216
|
+
"affected_backlinks and affected_extensions, the cards that still link to or extend it: update or rebase each "
|
|
217
|
+
"of them in the same session. "
|
|
218
|
+
"Cards marked load=\"always\" are standing context for every session (below, and in manifest's always_loaded; "
|
|
219
|
+
"a tool result's always_loaded_update brings changes to them). After your context is compacted or summarized, "
|
|
220
|
+
"call manifest() again before continuing: its always_loaded is the current set, and updates delivered in earlier "
|
|
221
|
+
"tool results are gone. When the user states a lasting preference about "
|
|
222
|
+
"how you should behave (reply language, tone, persona), record it with load=\"always\"; such writes are staged "
|
|
223
|
+
"until the user approves them, so tell the user.\n\n"
|
|
224
|
+
"Check the write result before reporting success: distinguish saved/live memory from a pending proposal, "
|
|
225
|
+
"and never describe a failed write as remembered. Briefly mention meaningful saved updates or pending review "
|
|
226
|
+
"without interrupting the main task to ask whether every qualifying fact should be recorded. Automatic "
|
|
227
|
+
"recording does not authorize applying pending changes or baking slices; preserve the user's review workflow."
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
EMPTY_WORKSPACE_HINT = (
|
|
231
|
+
"# Empty memory workspace\n\n"
|
|
232
|
+
"This MindSlice workspace has no cards or mounted slices yet. If the user is greeting you, testing memory, or "
|
|
233
|
+
"asking how to get started, let them know MindSlice is connected and invite them to seed it in plain language: "
|
|
234
|
+
"(1) how they want you to reply (language, tone, standing rules — record with load=\"always\" and remind them to "
|
|
235
|
+
"approve with `mindslice apply`), (2) their tools, conventions or active projects (record as focused topic cards "
|
|
236
|
+
"in @overlay), or (3) an existing Markdown notes/docs folder or `.slice` to mount via `mindslice mount` (or "
|
|
237
|
+
"distill with the slice-builder skill if you have shell access). If the user is asking a concrete task instead, "
|
|
238
|
+
"handle the task first and record durable preferences or lessons along the way."
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def format_instructions(snap: dict[str, Any]) -> str:
|
|
243
|
+
"""Render MCP server instructions from a ``boot()`` snapshot."""
|
|
244
|
+
text = render_markdown(snap["cards"])
|
|
245
|
+
if text:
|
|
246
|
+
return INSTRUCTIONS + "\n\n" + text
|
|
247
|
+
if snap.get("empty_workspace"):
|
|
248
|
+
return INSTRUCTIONS + "\n\n" + EMPTY_WORKSPACE_HINT + "\n"
|
|
249
|
+
return INSTRUCTIONS
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
class MCPServer:
|
|
253
|
+
def __init__(self, ws: Workspace, stage_writes: bool = False) -> None:
|
|
254
|
+
self.ws = ws
|
|
255
|
+
self.stage_writes = stage_writes
|
|
256
|
+
self.protocol_version = PROTOCOL_VERSIONS[0]
|
|
257
|
+
# Fingerprints of the always-loaded cards the agent has been given (None: none delivered yet).
|
|
258
|
+
self._boot_seen: dict[str, str] | None = None
|
|
259
|
+
# This connection's entry in CLIENTS_FILE (None until a client introduces itself with initialize).
|
|
260
|
+
self._client: dict[str, Any] | None = None
|
|
261
|
+
|
|
262
|
+
# -- always-loaded cards ----------------------------------------------
|
|
263
|
+
def _boot_cards(self) -> list[dict[str, Any]]:
|
|
264
|
+
return self.ws.boot()["cards"]
|
|
265
|
+
|
|
266
|
+
def _mark_seen(self, cards: list[dict[str, Any]]) -> None:
|
|
267
|
+
self._boot_seen = {c["address"]: fingerprint(c) for c in cards}
|
|
268
|
+
|
|
269
|
+
def instructions(self) -> str:
|
|
270
|
+
"""Server instructions with always-loaded cards (or a cold-start hint when empty) appended."""
|
|
271
|
+
try:
|
|
272
|
+
snap = self.ws.boot()
|
|
273
|
+
except MindSliceError:
|
|
274
|
+
return INSTRUCTIONS
|
|
275
|
+
self._mark_seen(snap["cards"])
|
|
276
|
+
return format_instructions(snap)
|
|
277
|
+
|
|
278
|
+
def boot_update(self) -> dict[str, Any] | None:
|
|
279
|
+
"""Always-loaded cards added or changed since the agent last saw the set (and those removed)."""
|
|
280
|
+
try:
|
|
281
|
+
cards = self._boot_cards()
|
|
282
|
+
except MindSliceError:
|
|
283
|
+
return None
|
|
284
|
+
seen = self._boot_seen or {}
|
|
285
|
+
current = {c["address"]: fingerprint(c) for c in cards}
|
|
286
|
+
if current == seen:
|
|
287
|
+
self._boot_seen = current
|
|
288
|
+
return None
|
|
289
|
+
self._boot_seen = current
|
|
290
|
+
update: dict[str, Any] = {
|
|
291
|
+
"note": "The always-loaded memory changed (a slice was mounted or the user approved a change). "
|
|
292
|
+
"These cards apply to the rest of the session.",
|
|
293
|
+
"cards": [c for c in cards if seen.get(c["address"]) != current[c["address"]]],
|
|
294
|
+
}
|
|
295
|
+
removed = [a for a in seen if a not in current]
|
|
296
|
+
if removed:
|
|
297
|
+
update["removed"] = removed
|
|
298
|
+
return update
|
|
299
|
+
|
|
300
|
+
# -- connection record ------------------------------------------------
|
|
301
|
+
def _note_client(self, params: dict[str, Any]) -> None:
|
|
302
|
+
info = params.get("clientInfo")
|
|
303
|
+
info = info if isinstance(info, dict) else {}
|
|
304
|
+
name = info.get("name") if isinstance(info.get("name"), str) and info.get("name") else "unknown"
|
|
305
|
+
if name == DOCTOR_CLIENT:
|
|
306
|
+
return
|
|
307
|
+
self._client = {"name": name, "connected_at": _now(), "first_tool": None, "tools_used": []}
|
|
308
|
+
if isinstance(info.get("version"), str):
|
|
309
|
+
self._client["version"] = info["version"]
|
|
310
|
+
self._save_client()
|
|
311
|
+
|
|
312
|
+
def _note_tool(self, name: str) -> None:
|
|
313
|
+
client = self._client
|
|
314
|
+
if client is None or name in client["tools_used"]:
|
|
315
|
+
return # one write per tool per connection, not one per call
|
|
316
|
+
if client["first_tool"] is None:
|
|
317
|
+
client["first_tool"] = name
|
|
318
|
+
client["tools_used"].append(name)
|
|
319
|
+
self._save_client()
|
|
320
|
+
|
|
321
|
+
def _save_client(self) -> None:
|
|
322
|
+
"""Best effort: a diagnostic record must never fail a request."""
|
|
323
|
+
assert self._client is not None
|
|
324
|
+
try:
|
|
325
|
+
path = self.ws.cache_dir / CLIENTS_FILE
|
|
326
|
+
clients = read_clients(self.ws.root)
|
|
327
|
+
clients[self._client["name"]] = {k: v for k, v in self._client.items() if k != "name"}
|
|
328
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
329
|
+
atomic_write_text(path, json.dumps({"clients": clients}, ensure_ascii=False, indent=2) + "\n")
|
|
330
|
+
except OSError:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
# -- tool execution ---------------------------------------------------
|
|
334
|
+
def call_tool(self, name: str, arguments: dict[str, Any]) -> dict[str, Any]:
|
|
335
|
+
args = dict(arguments or {})
|
|
336
|
+
if name == "manifest":
|
|
337
|
+
result = self.ws.manifest(**args)
|
|
338
|
+
if args.get("offset", 0) == 0 and args.get("include_always", True):
|
|
339
|
+
# The startup context in full, for a client that did not pass the initialize instructions on.
|
|
340
|
+
result = {"instructions": INSTRUCTIONS, **result}
|
|
341
|
+
return result
|
|
342
|
+
if name == "search":
|
|
343
|
+
return self.ws.search(args.pop("query"), **args)
|
|
344
|
+
if name == "read":
|
|
345
|
+
return self.ws.read(args.pop("address"), **args)
|
|
346
|
+
if name == "write":
|
|
347
|
+
if self.stage_writes:
|
|
348
|
+
args["stage"] = True
|
|
349
|
+
elif args.get("stage") is False:
|
|
350
|
+
del args["stage"] # the default: direct, except changes to always-loaded cards (staged for approval)
|
|
351
|
+
return self.ws.write(args.pop("id"), **args)
|
|
352
|
+
raise KeyError(name)
|
|
353
|
+
|
|
354
|
+
# -- JSON-RPC ---------------------------------------------------------
|
|
355
|
+
def handle(self, message: Any) -> dict[str, Any] | None:
|
|
356
|
+
if not isinstance(message, dict) or message.get("jsonrpc") != "2.0":
|
|
357
|
+
return _error(None, -32600, "invalid request")
|
|
358
|
+
method = message.get("method")
|
|
359
|
+
msg_id = message.get("id")
|
|
360
|
+
is_notification = "id" not in message
|
|
361
|
+
params = message.get("params") or {}
|
|
362
|
+
try:
|
|
363
|
+
if method == "initialize":
|
|
364
|
+
self._note_client(params)
|
|
365
|
+
requested = params.get("protocolVersion")
|
|
366
|
+
self.protocol_version = requested if requested in PROTOCOL_VERSIONS else PROTOCOL_VERSIONS[0]
|
|
367
|
+
result = {
|
|
368
|
+
"protocolVersion": self.protocol_version,
|
|
369
|
+
"capabilities": {"tools": {"listChanged": False}},
|
|
370
|
+
"serverInfo": {"name": "mindslice", "version": __version__},
|
|
371
|
+
"instructions": self.instructions(),
|
|
372
|
+
}
|
|
373
|
+
elif method == "ping":
|
|
374
|
+
result = {}
|
|
375
|
+
elif method == "tools/list":
|
|
376
|
+
result = {"tools": TOOLS}
|
|
377
|
+
elif method == "tools/call":
|
|
378
|
+
result = self._tools_call(params)
|
|
379
|
+
elif isinstance(method, str) and method.startswith("notifications/"):
|
|
380
|
+
return None
|
|
381
|
+
else:
|
|
382
|
+
return None if is_notification else _error(msg_id, -32601, f"method not found: {method}")
|
|
383
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
384
|
+
return None if is_notification else _error(msg_id, -32603, f"internal error: {exc}")
|
|
385
|
+
if is_notification:
|
|
386
|
+
return None
|
|
387
|
+
return {"jsonrpc": "2.0", "id": msg_id, "result": result}
|
|
388
|
+
|
|
389
|
+
def _tools_call(self, params: dict[str, Any]) -> dict[str, Any]:
|
|
390
|
+
name = params.get("name")
|
|
391
|
+
if name not in {t["name"] for t in TOOLS}:
|
|
392
|
+
return _tool_result({"error": "UnknownTool", "message": f"unknown tool {name!r}"}, is_error=True)
|
|
393
|
+
self._note_tool(name)
|
|
394
|
+
arguments = accept_aliases(name, params.get("arguments"))
|
|
395
|
+
try:
|
|
396
|
+
check_arguments(name, {} if arguments is None else arguments)
|
|
397
|
+
payload = self.call_tool(name, arguments or {})
|
|
398
|
+
args = arguments or {}
|
|
399
|
+
if name == "manifest" and args.get("offset", 0) == 0 and args.get("include_always", True):
|
|
400
|
+
self._mark_seen(payload.get("always_loaded", []))
|
|
401
|
+
else:
|
|
402
|
+
update = self.boot_update()
|
|
403
|
+
if update:
|
|
404
|
+
payload["always_loaded_update"] = update
|
|
405
|
+
except MindSliceError as exc:
|
|
406
|
+
return _tool_result(exc.to_dict(), is_error=True)
|
|
407
|
+
except (TypeError, ValueError, KeyError) as exc: # raised below the arguments check: still the caller's input
|
|
408
|
+
err = InvalidArgumentsError(f"{name}: the arguments could not be used ({type(exc).__name__}: {exc}).",
|
|
409
|
+
tool=name, cause=type(exc).__name__)
|
|
410
|
+
return _tool_result(err.to_dict(), is_error=True)
|
|
411
|
+
return _tool_result(payload)
|
|
412
|
+
|
|
413
|
+
def serve(self, stdin: IO[str], stdout: IO[str]) -> None:
|
|
414
|
+
for line in stdin:
|
|
415
|
+
line = line.strip()
|
|
416
|
+
if not line:
|
|
417
|
+
continue
|
|
418
|
+
try:
|
|
419
|
+
message = json.loads(line)
|
|
420
|
+
except json.JSONDecodeError:
|
|
421
|
+
response: Any = _error(None, -32700, "parse error")
|
|
422
|
+
else:
|
|
423
|
+
if isinstance(message, list): # JSON-RPC batch (2025-03-26)
|
|
424
|
+
response = [r for r in (self.handle(m) for m in message) if r is not None] or None
|
|
425
|
+
else:
|
|
426
|
+
response = self.handle(message)
|
|
427
|
+
if response is not None:
|
|
428
|
+
stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
|
429
|
+
stdout.flush()
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
# Names a caller is likely to use for another argument of the same tool (shown as a hint, never accepted).
|
|
433
|
+
_ARGUMENT_HINTS = {"write": {"address": "id", "path": "id", "card": "id", "body": "content", "text": "content"},
|
|
434
|
+
"read": {"id": "address", "path": "address", "card": "address"},
|
|
435
|
+
"search": {"q": "query", "queries": "query", "text": "query", "mount": "source",
|
|
436
|
+
"mounts": "source", "alias": "source", "sources": "source", "top_k": "k"}}
|
|
437
|
+
|
|
438
|
+
# Another name accepted for an argument: the page size is ``limit`` on manifest and ``k`` on search, and a
|
|
439
|
+
# caller that has just used one reaches for it on the other. Read-only tools only; write() stays strict.
|
|
440
|
+
_ARGUMENT_ALIASES = {"search": {"limit": "k"}}
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def accept_aliases(name: Any, arguments: Any) -> Any:
|
|
444
|
+
"""``arguments`` with an accepted alias renamed to the schema's name (unless that one is given too)."""
|
|
445
|
+
aliases = _ARGUMENT_ALIASES.get(name) if isinstance(name, str) else None
|
|
446
|
+
if not aliases or not isinstance(arguments, dict):
|
|
447
|
+
return arguments
|
|
448
|
+
out = dict(arguments)
|
|
449
|
+
for alias, canonical in aliases.items():
|
|
450
|
+
if alias in out and canonical not in out:
|
|
451
|
+
out[canonical] = out.pop(alias)
|
|
452
|
+
return out
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _now() -> str:
|
|
456
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def read_clients(root: str | Path) -> dict[str, dict[str, Any]]:
|
|
460
|
+
"""The MCP clients that have connected to the workspace at ``root`` (``{name: record}``; empty if none)."""
|
|
461
|
+
try:
|
|
462
|
+
value = json.loads((Path(root) / ".cache" / CLIENTS_FILE).read_text(encoding="utf-8"))
|
|
463
|
+
except (OSError, ValueError):
|
|
464
|
+
return {}
|
|
465
|
+
clients = value.get("clients") if isinstance(value, dict) else None
|
|
466
|
+
if not isinstance(clients, dict):
|
|
467
|
+
return {}
|
|
468
|
+
return {k: v for k, v in clients.items() if isinstance(k, str) and isinstance(v, dict)}
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def check_arguments(name: str, arguments: Any) -> None:
|
|
472
|
+
"""The tool call's argument names against the tool's input schema: a missing required argument or one the
|
|
473
|
+
schema doesn't have raises ``InvalidArgumentsError`` naming each, with the allowed names (and a hint where
|
|
474
|
+
a name looks like another argument). Values are not checked here; the tool checks them."""
|
|
475
|
+
schema = next(t["inputSchema"] for t in TOOLS if t["name"] == name)
|
|
476
|
+
if not isinstance(arguments, dict):
|
|
477
|
+
raise InvalidArgumentsError(f"{name}: arguments must be a JSON object, not {type(arguments).__name__}.", tool=name)
|
|
478
|
+
allowed = list(schema.get("properties", {}))
|
|
479
|
+
missing = [k for k in schema.get("required", []) if k not in arguments]
|
|
480
|
+
unexpected = sorted(k for k in arguments if k not in allowed) if schema.get("additionalProperties") is False else []
|
|
481
|
+
if not missing and not unexpected:
|
|
482
|
+
return
|
|
483
|
+
hints = {}
|
|
484
|
+
for k in unexpected:
|
|
485
|
+
guess = _ARGUMENT_HINTS.get(name, {}).get(k) or next(iter(difflib.get_close_matches(k, allowed, n=1, cutoff=0.75)), None)
|
|
486
|
+
if guess:
|
|
487
|
+
hints[k] = guess
|
|
488
|
+
parts = []
|
|
489
|
+
if missing:
|
|
490
|
+
parts.append("missing required argument" + ("s " if len(missing) > 1 else " ") + ", ".join(f"'{k}'" for k in missing))
|
|
491
|
+
if unexpected:
|
|
492
|
+
parts.append("unknown argument" + ("s " if len(unexpected) > 1 else " ") + ", ".join(
|
|
493
|
+
f"'{k}'" + (f" (did you mean '{hints[k]}'?)" if k in hints else "") for k in unexpected))
|
|
494
|
+
raise InvalidArgumentsError(f"{name}: " + "; ".join(parts) + ". Allowed arguments: " + ", ".join(allowed) + ".",
|
|
495
|
+
tool=name, missing=missing, unexpected=unexpected, allowed=allowed, **({"hints": hints} if hints else {}))
|
|
496
|
+
|
|
497
|
+
|
|
498
|
+
def _tool_result(payload: dict[str, Any], is_error: bool = False) -> dict[str, Any]:
|
|
499
|
+
return {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}], "isError": is_error}
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _error(msg_id: Any, code: int, message: str) -> dict[str, Any]:
|
|
503
|
+
return {"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def ensure_utf8_stdio() -> None:
|
|
507
|
+
"""Pin stdio to UTF-8 (the MCP stdio transport requires it) whatever the console locale is.
|
|
508
|
+
|
|
509
|
+
Without this, CJK text crashes on Windows consoles or under ``PYTHONIOENCODING=ascii``.
|
|
510
|
+
Must run before anything is read from stdin.
|
|
511
|
+
"""
|
|
512
|
+
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
|
513
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
514
|
+
if reconfigure is None:
|
|
515
|
+
continue
|
|
516
|
+
try:
|
|
517
|
+
reconfigure(encoding="utf-8")
|
|
518
|
+
except (ValueError, OSError): # already consumed or detached; leave it alone
|
|
519
|
+
pass
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def serve(ws: Workspace, stage_writes: bool = False, stdin: IO[str] | None = None, stdout: IO[str] | None = None) -> None:
|
|
523
|
+
if stdin is None or stdout is None:
|
|
524
|
+
ensure_utf8_stdio()
|
|
525
|
+
MCPServer(ws, stage_writes).serve(stdin or sys.stdin, stdout or sys.stdout)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
if __name__ == "__main__": # pragma: no cover
|
|
529
|
+
import argparse
|
|
530
|
+
|
|
531
|
+
parser = argparse.ArgumentParser(description="MindSlice MCP server (stdio)")
|
|
532
|
+
parser.add_argument("workspace")
|
|
533
|
+
parser.add_argument("--stage-writes", action="store_true")
|
|
534
|
+
ns = parser.parse_args()
|
|
535
|
+
with Workspace(ns.workspace) as workspace:
|
|
536
|
+
serve(workspace, ns.stage_writes)
|